Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,12 @@ interface FakeOnboardingOpts {
verify?: (input: {
providerType: ProviderType;
apiKey?: string;
baseUrl?: string;
}) => Promise<OnboardingVerifyResult>;
save?: (input: {
providerType: ProviderType;
apiKey?: string;
baseUrl?: string;
enabledModelIds: readonly string[];
models: readonly ModelInfo[];
}) => Promise<OnboardingSaveResult>;
Expand Down Expand Up @@ -884,6 +886,81 @@ describe('Maka Pi TUI runner', () => {
]);
});

test('wizard collects a base URL for a custom relay and threads it through verify and save', async () => {
const terminal = new FakeTerminal();
const driver = new SlashCommandDriver();
const verifyCalls: Array<{ baseUrl?: string }> = [];
const saveCalls: Array<{ baseUrl?: string }> = [];
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'claude-sonnet-4-5',
connectionSlug: 'claude-subscription',
permissionMode: 'bypass',
terminal,
onboarding: fakeOnboardingSurface({
verify: async (input) => {
verifyCalls.push(input);
return { kind: 'ok', models: [{ id: 'relay/model' }] };
},
save: async (input) => {
saveCalls.push(input);
return { kind: 'ok', modelChoices: [] };
},
}),
});

await waitForTuiPaint(terminal);
terminal.input('/setup');
terminal.input('\r');
await waitFor(() => {
try {
return latestPlainLineContaining(terminal.writes.join(''), 'Set Up Provider') !== null;
} catch {
return false;
}
});
// Filter down to the relay entries and pick the first (OpenAI Chat).
terminal.input('relay');
terminal.input('\r');
// The relay flow inserts the base-URL step (2/4) before the key.
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Base URL'));
assert.ok(plainTerminalOutput(terminal.screenOutput()).includes('2/4'));
// A malformed endpoint is rejected in place, before any host call.
terminal.input('not a url');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('不是有效的 URL'));
for (let i = 0; i < 'not a url'.length; i++) terminal.input('\x7f'); // clear the field
terminal.input('https://relay.example.test/v1');
terminal.input('\r');
await waitFor(() => {
try {
return latestPlainLineContaining(terminal.writes.join(''), 'API key') !== null;
} catch {
return false;
}
});
terminal.input('sk-relay');
terminal.input('\r');
await waitFor(() => verifyCalls.length === 1);
assert.equal(verifyCalls[0]?.baseUrl, 'https://relay.example.test/v1');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('4/4'));
terminal.input(' '); // toggle the discovered model on
terminal.input('\r'); // save
await waitFor(() => saveCalls.length === 1);
assert.equal(saveCalls[0]?.baseUrl, 'https://relay.example.test/v1');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('已启用'));

process.emit('SIGTERM');
await Promise.race([
run,
delay(CLOSE_BUDGET_MS).then(() => {
throw new Error('TUI did not close after SIGTERM');
}),
]);
});

test('save refreshes the running model choices even when the user backs out during saving', async () => {
const terminal = new FakeTerminal();
const driver = new SlashCommandDriver();
Expand Down
44 changes: 43 additions & 1 deletion packages/cli/src/__tests__/runtime-host-onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy';
import { projectRuntimeHostModelChoices } from '../runtime-host-onboarding.js';
import { projectProviders, projectRuntimeHostModelChoices } from '../runtime-host-onboarding.js';

function catalog(connections: ConnectionCatalogSnapshot['connections']): ConnectionCatalogSnapshot {
return { revision: 1, defaultTarget: null, connections };
Expand Down Expand Up @@ -78,3 +78,45 @@ describe('projectRuntimeHostModelChoices', () => {
assert.equal(choices[0]?.displayName, 'GPT-5 Mini');
});
});

describe('projectProviders', () => {
const relay = {
connectionId: 'relay-custom-id',
revision: 1,
slug: 'my-relay',
name: 'My Relay',
providerType: 'openai-compatible',
baseUrl: 'https://relay.example.test/v1',
enabled: true,
enabledModelIds: ['relay/model'],
models: [{ id: 'relay/model' }],
} as const;

test('a Desktop-created relay under a custom slug reads as the existing connection', () => {
// Identity must survive the projection: a sole connection of the provider
// type is "the" one to edit even off the canonical slug, or saving would
// duplicate it there (#3467 review).
const entry = projectProviders(catalog([relay])).find(
({ providerType }) => providerType === 'openai-compatible',
);
assert.equal(entry?.hasConnection, true);
assert.equal(entry?.connectionId, 'relay-custom-id');
assert.deepEqual(entry?.enabledModelIds, ['relay/model']);
});

test('several non-canonical connections resolve to none — the wizard offers a fresh setup', () => {
const entry = projectProviders(
catalog([relay, { ...relay, connectionId: 'relay-2-id', slug: 'my-relay-2' }]),
).find(({ providerType }) => providerType === 'openai-compatible');
assert.equal(entry?.hasConnection, false);
assert.equal(entry?.connectionId, undefined);
});

test('the canonical-slug connection wins over other connections of the type', () => {
const canonical = { ...relay, connectionId: 'canonical-id', slug: 'openai-compatible' };
const entry = projectProviders(catalog([relay, canonical])).find(
({ providerType }) => providerType === 'openai-compatible',
);
assert.equal(entry?.connectionId, 'canonical-id');
});
});
33 changes: 21 additions & 12 deletions packages/cli/src/onboarding-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,25 @@ import {
import type { OnboardableProvider } from './pi-tui-contracts.js';

export function listApiKeyOnboardableProviders(): OnboardableProvider[] {
return CATALOG_PROVIDER_TYPES.filter((providerType) => providerAuthSupportsApiKey(providerType))
.map((providerType) => {
const definition = PROVIDER_DEFAULTS[providerType];
return {
providerType,
label: definition.label,
authKind: definition.authKind as 'api_key' | 'optional_api_key',
requiresBaseUrl: !definition.baseUrl,
fallbackModels: definition.fallbackModels,
};
})
.filter((provider) => !provider.requiresBaseUrl);
// Custom relays have no built-in base URL and stay listed: `requiresBaseUrl`
// tells the wizard to collect an endpoint before the API key. The original
// phase-1 wizard filtered every empty-baseUrl provider out because it had no
// base-URL step to offer (#1254); that step exists now (#3405). Providers
// whose endpoint is derived rather than user-supplied (cloudflare-workers-ai
// interpolates an account id into a URL template) are still excluded — a
// plain base-URL prompt cannot onboard them.
return CATALOG_PROVIDER_TYPES.filter((providerType) => {
if (!providerAuthSupportsApiKey(providerType)) return false;
const definition = PROVIDER_DEFAULTS[providerType];
return Boolean(definition.baseUrl) || definition.category === 'custom';
}).map((providerType) => {
const definition = PROVIDER_DEFAULTS[providerType];
return {
providerType,
label: definition.label,
authKind: definition.authKind as 'api_key' | 'optional_api_key',
requiresBaseUrl: !definition.baseUrl,
fallbackModels: definition.fallbackModels,
};
});
}
19 changes: 18 additions & 1 deletion packages/cli/src/pi-tui-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,21 +52,38 @@ export interface OnboardableProvider {

export interface OnboardingProviderEntry extends OnboardableProvider {
hasConnection: boolean;
/** The existing connection's identity, so saving edits it in place. */
connectionId?: string;
enabledModelIds: readonly string[];
}

export interface OnboardingVerifyInput {
providerType: ProviderType;
/** The existing connection this edit targets; absent creates/updates the canonical-slug one. */
connectionId?: string;
apiKey?: string;
/** Endpoint for `requiresBaseUrl` providers; blank reuses the persisted one. */
baseUrl?: string;
}

export type OnboardingVerifyResult =
| { kind: 'ok'; models: ModelInfo[] }
| { kind: 'error'; text: string };
| {
kind: 'error';
text: string;
/** The wizard's provider snapshot is outdated (e.g. the targeted
* connection is gone) — retyping the key cannot fix this, so the
* runner shows the text without its retype-the-key framing. */
stale?: boolean;
};

export interface OnboardingSaveInput {
providerType: ProviderType;
/** The existing connection this edit targets; absent creates/updates the canonical-slug one. */
connectionId?: string;
apiKey?: string;
/** Endpoint for `requiresBaseUrl` providers; blank reuses the persisted one. */
baseUrl?: string;
enabledModelIds: readonly string[];
models: readonly ModelInfo[];
}
Expand Down
Loading