Skip to content

Commit d7efbb4

Browse files
authored
feat(runtime-host): own multi-account API-key onboarding (#3882)
* feat(storage): allocate onboarding connection identity Generated-by: OpenAI Codex * feat(runtime-host): own multi-account onboarding Generated-by: OpenAI Codex * feat(cli): expose multi-account onboarding Generated-by: OpenAI Codex * test(cli): pin existing account onboarding identity Generated-by: OpenAI Codex * fix(cli): preserve committed onboarding saves Generated-by: OpenAI Codex
1 parent 6fca2f5 commit d7efbb4

20 files changed

Lines changed: 1558 additions & 481 deletions

packages/cli/src/__tests__/pi-tui-runner.test.ts

Lines changed: 376 additions & 39 deletions
Large diffs are not rendered by default.

packages/cli/src/__tests__/runtime-host-onboarding.test.ts

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@
2020
import { describe, test } from 'node:test';
2121
import assert from 'node:assert/strict';
2222
import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy';
23-
import { projectProviders, projectRuntimeHostModelChoices } from '../runtime-host-onboarding.js';
23+
import type { RuntimeHostConnection } from '@maka/runtime-host/client';
24+
import {
25+
createRuntimeHostOnboardingSurface,
26+
projectProviders,
27+
projectRuntimeHostModelChoices,
28+
} from '../runtime-host-onboarding.js';
2429

2530
function catalog(connections: ConnectionCatalogSnapshot['connections']): ConnectionCatalogSnapshot {
2631
return { revision: 1, defaultTarget: null, connections };
@@ -37,6 +42,44 @@ const live = {
3742
models: [{ id: 'gpt-5-mini', displayName: 'GPT-5 Mini' }],
3843
} as const;
3944

45+
describe('createRuntimeHostOnboardingSurface', () => {
46+
test('keeps the committed Connection when the follow-up catalog refresh fails', async () => {
47+
const committed = {
48+
connectionId: 'committed-openai-id',
49+
revision: 3,
50+
slug: 'openai-2',
51+
providerType: 'openai',
52+
} as const;
53+
const connection = {
54+
request: async (operation: string) => {
55+
if (operation === 'connection.onboarding.save') {
56+
return { kind: 'saved', connection: committed };
57+
}
58+
if (operation === 'connection.catalog.query') {
59+
throw new Error('transient catalog failure');
60+
}
61+
throw new Error(`Unexpected operation ${operation}`);
62+
},
63+
} as unknown as RuntimeHostConnection;
64+
65+
const result = await createRuntimeHostOnboardingSurface(connection).save({
66+
target: { kind: 'create', providerType: 'openai' },
67+
apiKey: 'sk-test',
68+
enabledModelIds: ['gpt-5-mini'],
69+
models: [{ id: 'gpt-5-mini' }],
70+
});
71+
72+
assert.deepEqual(result, {
73+
kind: 'ok',
74+
connection: committed,
75+
refresh: {
76+
kind: 'failed',
77+
warning: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。',
78+
},
79+
});
80+
});
81+
});
82+
4083
describe('projectRuntimeHostModelChoices', () => {
4184
test('a retained retired connection contributes no /model choices', () => {
4285
// Retirement keeps the connection enabled so its credential stays visible
@@ -92,31 +135,39 @@ describe('projectProviders', () => {
92135
models: [{ id: 'relay/model' }],
93136
} as const;
94137

95-
test('a Desktop-created relay under a custom slug reads as the existing connection', () => {
96-
// Identity must survive the projection: a sole connection of the provider
97-
// type is "the" one to edit even off the canonical slug, or saving would
98-
// duplicate it there (#3467 review).
99-
const entry = projectProviders(catalog([relay])).find(
138+
test('a Desktop-created relay and add-account action are both explicit', () => {
139+
const entries = projectProviders(catalog([relay])).filter(
100140
({ providerType }) => providerType === 'openai-compatible',
101141
);
102-
assert.equal(entry?.hasConnection, true);
103-
assert.equal(entry?.connectionId, 'relay-custom-id');
142+
const entry = entries.find(({ target }) => target.kind === 'existing');
143+
assert.deepEqual(entry?.target, { kind: 'existing', connectionId: 'relay-custom-id' });
144+
assert.equal(entry && 'connectionSlug' in entry ? entry.connectionSlug : undefined, 'my-relay');
104145
assert.deepEqual(entry?.enabledModelIds, ['relay/model']);
146+
assert.deepEqual(entries.find(({ target }) => target.kind === 'create')?.target, {
147+
kind: 'create',
148+
providerType: 'openai-compatible',
149+
});
105150
});
106151

107-
test('several non-canonical connections resolve to none — the wizard offers a fresh setup', () => {
108-
const entry = projectProviders(
152+
test('several non-canonical connections remain independently editable', () => {
153+
const entries = projectProviders(
109154
catalog([relay, { ...relay, connectionId: 'relay-2-id', slug: 'my-relay-2' }]),
110-
).find(({ providerType }) => providerType === 'openai-compatible');
111-
assert.equal(entry?.hasConnection, false);
112-
assert.equal(entry?.connectionId, undefined);
155+
).filter(({ providerType }) => providerType === 'openai-compatible');
156+
assert.deepEqual(
157+
entries.flatMap(({ target }) => (target.kind === 'existing' ? [target.connectionId] : [])),
158+
['relay-custom-id', 'relay-2-id'],
159+
);
113160
});
114161

115-
test('the canonical-slug connection wins over other connections of the type', () => {
162+
test('a canonical connection does not hide another account', () => {
116163
const canonical = { ...relay, connectionId: 'canonical-id', slug: 'openai-compatible' };
117-
const entry = projectProviders(catalog([relay, canonical])).find(
118-
({ providerType }) => providerType === 'openai-compatible',
164+
const entries = projectProviders(catalog([relay, canonical])).filter(
165+
({ providerType, target }) =>
166+
providerType === 'openai-compatible' && target.kind === 'existing',
167+
);
168+
assert.deepEqual(
169+
entries.flatMap(({ target }) => (target.kind === 'existing' ? [target.connectionId] : [])),
170+
['relay-custom-id', 'canonical-id'],
119171
);
120-
assert.equal(entry?.connectionId, 'canonical-id');
121172
});
122173
});

packages/cli/src/pi-tui-contracts.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import type { ForeignSessionDigest, ForeignSessionSummary } from '@maka/core/foreign-session';
2121
import type { ModelInfo, ProviderType } from '@maka/core/llm-connections';
2222
import type { ThinkingLevel } from '@maka/core/model-thinking';
23+
import type { ConnectionOnboardingTarget } from '@maka/core/runtime-policy';
2324
import type { MakaPiTuiTurnActivity } from './pi-tui-turn.js';
2425

2526
export interface ModelChoice {
@@ -52,17 +53,21 @@ export interface OnboardableProvider {
5253
fallbackModels: readonly string[];
5354
}
5455

55-
export interface OnboardingProviderEntry extends OnboardableProvider {
56-
hasConnection: boolean;
57-
/** The existing connection's identity, so saving edits it in place. */
58-
connectionId?: string;
59-
enabledModelIds: readonly string[];
60-
}
56+
export type OnboardingProviderEntry = OnboardableProvider &
57+
(
58+
| {
59+
target: Extract<ConnectionOnboardingTarget, { readonly kind: 'create' }>;
60+
enabledModelIds: readonly string[];
61+
}
62+
| {
63+
target: Extract<ConnectionOnboardingTarget, { readonly kind: 'existing' }>;
64+
connectionSlug: string;
65+
enabledModelIds: readonly string[];
66+
}
67+
);
6168

6269
export interface OnboardingVerifyInput {
63-
providerType: ProviderType;
64-
/** The existing connection this edit targets; absent creates/updates the canonical-slug one. */
65-
connectionId?: string;
70+
target: ConnectionOnboardingTarget;
6671
apiKey?: string;
6772
/** Endpoint for `requiresBaseUrl` providers; blank reuses the persisted one. */
6873
baseUrl?: string;
@@ -80,18 +85,27 @@ export type OnboardingVerifyResult =
8085
};
8186

8287
export interface OnboardingSaveInput {
83-
providerType: ProviderType;
84-
/** The existing connection this edit targets; absent creates/updates the canonical-slug one. */
85-
connectionId?: string;
88+
target: ConnectionOnboardingTarget;
8689
apiKey?: string;
8790
/** Endpoint for `requiresBaseUrl` providers; blank reuses the persisted one. */
8891
baseUrl?: string;
8992
enabledModelIds: readonly string[];
9093
models: readonly ModelInfo[];
9194
}
9295

96+
export interface OnboardingSavedConnection {
97+
connectionId: string;
98+
revision: number;
99+
slug: string;
100+
providerType: ProviderType;
101+
}
102+
93103
export type OnboardingSaveResult =
94-
| { kind: 'ok'; modelChoices: ModelChoice[] }
104+
| {
105+
kind: 'ok';
106+
connection: OnboardingSavedConnection;
107+
refresh: { kind: 'ok'; modelChoices: ModelChoice[] } | { kind: 'failed'; warning: string };
108+
}
95109
| { kind: 'error'; text: string };
96110

97111
export interface MakaOnboardingSurface {

packages/cli/src/pi-tui-pickers.ts

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -591,16 +591,57 @@ export function modelPickerItems(
591591
* caller maps it back to the {@link ModelChoice}. The description carries the
592592
* owning connection so identical model ids on different providers are readable.
593593
*/
594+
export function modelChoiceConnectionLabels(choices: readonly ModelChoice[]): Map<string, string> {
595+
const connectionBySlug = new Map<
596+
string,
597+
Pick<ModelChoice, 'connectionId' | 'connectionName' | 'connectionSlug'>
598+
>();
599+
for (const choice of choices) {
600+
if (!connectionBySlug.has(choice.connectionSlug)) {
601+
connectionBySlug.set(choice.connectionSlug, choice);
602+
}
603+
}
604+
const connections = [...connectionBySlug.values()]
605+
.map((choice) => ({
606+
...choice,
607+
base: choice.connectionName.trim()
608+
? `${choice.connectionName.trim()} · ${choice.connectionSlug}`
609+
: choice.connectionSlug,
610+
}))
611+
.sort(
612+
(left, right) =>
613+
left.base.localeCompare(right.base) ||
614+
left.connectionSlug.localeCompare(right.connectionSlug),
615+
);
616+
const used = new Set<string>();
617+
const labels = new Map<string, string>();
618+
for (const connection of connections) {
619+
let label = connection.base;
620+
if (used.has(label) && connection.connectionId) {
621+
label = `${connection.base} · ${connection.connectionId}`;
622+
}
623+
let suffix = 2;
624+
while (used.has(label)) {
625+
label = `${connection.base} · ${connection.connectionId ?? connection.connectionSlug} · ${suffix}`;
626+
suffix += 1;
627+
}
628+
used.add(label);
629+
labels.set(connection.connectionSlug, label);
630+
}
631+
return labels;
632+
}
633+
594634
function modelChoicePickerItems(
595635
choices: readonly ModelChoice[],
596636
current: { model: string; connectionId?: string; connectionSlug: string },
597637
): SelectItem[] {
638+
const connectionLabels = modelChoiceConnectionLabels(choices);
598639
return choices.map((choice, index) => {
599640
const isCurrent =
600641
choice.model === current.model &&
601642
choice.connectionId === current.connectionId &&
602643
choice.connectionSlug === current.connectionSlug;
603-
const tags = [choice.connectionName || choice.connectionSlug];
644+
const tags = [connectionLabels.get(choice.connectionSlug) ?? choice.connectionSlug];
604645
if (isCurrent) tags.push('current');
605646
else if (choice.isDefaultConnection) tags.push('default');
606647
return {
@@ -797,14 +838,21 @@ export function onboardingProviderPickerItems(
797838
providers: readonly OnboardingProviderEntry[],
798839
): SelectItem[] {
799840
return providers.map((provider) => ({
800-
value: provider.providerType,
841+
value: onboardingProviderKey(provider),
801842
label: provider.label,
802-
description: provider.hasConnection
803-
? `${provider.providerType} · 已设置`
804-
: provider.providerType,
843+
description:
844+
'connectionSlug' in provider
845+
? `${provider.providerType} · ${provider.connectionSlug} · 已设置`
846+
: `${provider.providerType} · 添加账号`,
805847
}));
806848
}
807849

850+
function onboardingProviderKey(provider: OnboardingProviderEntry): string {
851+
return provider.target.kind === 'existing'
852+
? provider.target.connectionId
853+
: `create:${provider.target.providerType}`;
854+
}
855+
808856
const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
809857
off: '关',
810858
minimal: '最小',
@@ -857,7 +905,7 @@ export interface OnboardingWizardInput {
857905
/** search→key: the user picked a provider. The runner records it — and the
858906
* existing connection's identity, when the catalog resolved one — for
859907
* verify/save, so saving edits that connection in place. */
860-
onPickProvider: (providerType: ProviderType, existingConnectionId: string | undefined) => void;
908+
onPickProvider: (provider: OnboardingProviderEntry) => void;
861909
/** baseUrl submit (only for `requiresBaseUrl` providers). Empty means "reuse
862910
* the existing connection's persisted endpoint"; the wizard has already
863911
* rejected an empty value for a provider with no connection. */
@@ -905,6 +953,7 @@ export class OnboardingWizard implements Component {
905953
private modelHighlight = 0;
906954
private modelScroll = 0;
907955
private successCount = 0;
956+
private successWarning: string | undefined;
908957

909958
constructor(
910959
private readonly tui: TUI,
@@ -943,7 +992,9 @@ export class OnboardingWizard implements Component {
943992
{ minPrimaryColumnWidth: 16, maxPrimaryColumnWidth: 32 },
944993
);
945994
list.onSelect = (item) => {
946-
const provider = this.filtered.find((p) => p.providerType === item.value);
995+
const provider = this.filtered.find(
996+
(candidate) => onboardingProviderKey(candidate) === item.value,
997+
);
947998
if (!provider) return;
948999
this.enterKeyPhase(provider);
9491000
};
@@ -968,7 +1019,7 @@ export class OnboardingWizard implements Component {
9681019
this.modelHighlight = 0;
9691020
this.modelScroll = 0;
9701021
this.modelsSearchEditor.setText('');
971-
this.input.onPickProvider(provider.providerType, provider.connectionId);
1022+
this.input.onPickProvider(provider);
9721023
}
9731024

9741025
private submitBaseUrl(value: string): void {
@@ -991,7 +1042,7 @@ export class OnboardingWizard implements Component {
9911042
*/
9921043
private validateBaseUrl(trimmed: string): string | null {
9931044
if (!trimmed) {
994-
return this.picked?.hasConnection ? null : '需要填写 Base URL';
1045+
return this.picked?.target.kind === 'existing' ? null : '需要填写 Base URL';
9951046
}
9961047
let parsed: URL;
9971048
try {
@@ -1086,9 +1137,10 @@ export class OnboardingWizard implements Component {
10861137
}
10871138

10881139
/** Runner hook: save succeeded — show the enabled-model count in-frame. */
1089-
setSuccess(enabledCount: number): void {
1140+
setSuccess(enabledCount: number, warning?: string): void {
10901141
this.phase = 'success';
10911142
this.successCount = enabledCount;
1143+
this.successWarning = warning;
10921144
this.status = { kind: 'prompt' };
10931145
}
10941146

@@ -1286,9 +1338,10 @@ export class OnboardingWizard implements Component {
12861338
this.keyEditor.focused = false;
12871339
this.modelsSearchEditor.focused = false;
12881340
const label = this.picked?.label ?? '';
1289-
const hint = this.picked?.hasConnection
1290-
? '留空复用已保存的 Base URL,或输入新地址替换 · Esc 返回选择服务商'
1291-
: '输入中转站的 Base URL(http/https)· Esc 返回选择服务商';
1341+
const hint =
1342+
this.picked?.target.kind === 'existing'
1343+
? '留空复用已保存的 Base URL,或输入新地址替换 · Esc 返回选择服务商'
1344+
: '输入中转站的 Base URL(http/https)· Esc 返回选择服务商';
12921345
return [
12931346
padLine(`Set Up Provider ${ansi.dim(${this.step(2)}`)} ${ansi.accent(label)}`, width),
12941347
padLine(ansi.dim(hint), width),
@@ -1331,9 +1384,10 @@ export class OnboardingWizard implements Component {
13311384
this.modelsSearchEditor.focused = false;
13321385
const label = this.picked?.label ?? '';
13331386
const backTarget = this.picked?.requiresBaseUrl ? 'Esc 返回 Base URL' : 'Esc 返回选择服务商';
1334-
const hint = this.picked?.hasConnection
1335-
? `留空复用已保存的 key,或输入新 key 轮换 · ${backTarget}`
1336-
: `输入 API key · 仅本机存储 · ${backTarget}`;
1387+
const hint =
1388+
this.picked?.target.kind === 'existing'
1389+
? `留空复用已保存的 key,或输入新 key 轮换 · ${backTarget}`
1390+
: `输入 API key · 仅本机存储 · ${backTarget}`;
13371391
return [
13381392
padLine(
13391393
`Set Up Provider ${ansi.dim(${this.step(this.picked?.requiresBaseUrl ? 3 : 2)}`)} ${ansi.accent(label)}`,
@@ -1420,6 +1474,7 @@ export class OnboardingWizard implements Component {
14201474
return [
14211475
padLine(`Set Up Provider ${ansi.dim('· 完成')} ${ansi.accent(label)}`, width),
14221476
padLine(ansi.green(`✓ 已启用 ${this.successCount} 个模型`), width),
1477+
...(this.successWarning ? [padLine(ansi.yellow(this.successWarning), width)] : []),
14231478
padLine('', width),
14241479
padLine(ansi.dim('Enter 关闭'), width),
14251480
padLine(ansi.accent('-'.repeat(width)), width),

0 commit comments

Comments
 (0)