blob: 2b7d6ce6bbf3bc1c92bc592c7f6ede082c6bbef6 [file]
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { LlmConnection, ModelInfo, ProviderType } from '@maka/core/llm-connections';
import type { ConnectionStore, CredentialStore } from '@maka/storage';
import {
listApiKeyOnboardableProviders,
listOnboardingProviders,
saveApiKeyConnection,
verifyApiKeyConnection,
} from '../onboarding.js';
describe('listOnboardingProviders', () => {
test('marks existing connections as set and carries their enabled model ids', async () => {
const openai = makeConnection({
slug: 'openai',
providerType: 'openai',
defaultModel: 'gpt-5.5',
enabledModelIds: ['gpt-5.5', 'gpt-5.5-mini'],
});
const providers = await listOnboardingProviders({
connectionStore: {
list: async () => [openai],
},
});
const openaiEntry = providers.find((p) => p.providerType === 'openai');
assert.equal(openaiEntry?.hasConnection, true);
assert.deepEqual(openaiEntry?.enabledModelIds, ['gpt-5.5', 'gpt-5.5-mini']);
// A provider with no connection is not marked set and starts with no models.
const anthropic = providers.find((p) => p.providerType === 'anthropic');
assert.equal(anthropic?.hasConnection, false);
assert.deepEqual(anthropic?.enabledModelIds, []);
});
test('only lists API-key providers that do not require a base url', async () => {
const providers = await listOnboardingProviders({
connectionStore: { list: async () => [] },
});
for (const provider of providers) {
assert.equal(provider.requiresBaseUrl, false);
assert.ok(provider.authKind === 'api_key' || provider.authKind === 'optional_api_key');
}
assert.ok(!providers.some((p) => p.providerType === 'ollama'));
});
});
describe('verifyApiKeyConnection', () => {
test('probes a new connection with the supplied key without persisting', async () => {
let created = false;
let secretStored = false;
let defaultSet = false;
let probed: Array<{ slug: string; providerType: ProviderType; apiKey: string }> = [];
const connectionStore: Pick<
ConnectionStore,
'get' | 'create' | 'update' | 'remove' | 'getDefault' | 'setDefault'
> = {
get: async () => null,
create: async () => {
created = true;
return makeConnection({});
},
update: async () => {
throw new Error('update must not be called during verify');
},
remove: async () => {},
getDefault: async () => null,
setDefault: async () => {
defaultSet = true;
},
};
const credentialStore: Pick<CredentialStore, 'getSecret' | 'setSecret'> = {
getSecret: async () => null,
setSecret: async () => {
secretStored = true;
},
};
const result = await verifyApiKeyConnection({
providerType: 'openai',
apiKey: 'sk-test',
connectionStore,
credentialStore,
fetchModels: async (connection, apiKey) => {
probed.push({ slug: connection.slug, providerType: connection.providerType, apiKey });
return [{ id: 'gpt-5.5' }];
},
});
assert.deepEqual(result, { kind: 'ok', models: [{ id: 'gpt-5.5' }] });
assert.equal(created, false, 'verify must not create the connection');
assert.equal(secretStored, false, 'verify must not store the secret');
assert.equal(defaultSet, false, 'verify must not set the default');
assert.equal(probed.length, 1);
assert.equal(probed[0]!.apiKey, 'sk-test');
assert.equal(probed[0]!.providerType, 'openai');
});
test('rejects a blank key for a new required-key provider without probing', async () => {
let probed = false;
const result = await verifyApiKeyConnection({
providerType: 'openai',
apiKey: ' ',
connectionStore: { get: async () => null },
credentialStore: { getSecret: async () => null },
fetchModels: async () => {
probed = true;
return [];
},
});
assert.equal(result.kind, 'error');
assert.match((result as { text: string }).text, /API key is required/);
assert.equal(probed, false);
});
test('reuses the stored secret for an existing connection when the key is blank', async () => {
let probedKey = '';
let secretStored = false;
const credentialStore: Pick<CredentialStore, 'getSecret' | 'setSecret'> = {
getSecret: async () => 'stored-key',
setSecret: async () => {
secretStored = true;
},
};
const result = await verifyApiKeyConnection({
providerType: 'openai',
apiKey: '',
connectionStore: {
get: async () => makeConnection({ slug: 'openai', providerType: 'openai' }),
},
credentialStore,
fetchModels: async (_connection, apiKey) => {
probedKey = apiKey;
return [{ id: 'gpt-5.5' }];
},
});
assert.equal(result.kind, 'ok');
assert.equal(probedKey, 'stored-key');
assert.equal(secretStored, false, 'verify must not rotate the stored key');
});
test('probes with a newly supplied key for an existing connection (rotation preview)', async () => {
let probedKey = '';
let readStored = false;
const result = await verifyApiKeyConnection({
providerType: 'openai',
apiKey: 'sk-rotated',
connectionStore: {
get: async () => makeConnection({ slug: 'openai', providerType: 'openai' }),
},
credentialStore: {
getSecret: async () => {
readStored = true;
return 'stored-key';
},
},
fetchModels: async (_connection, apiKey) => {
probedKey = apiKey;
return [{ id: 'gpt-5.5' }];
},
});
assert.equal(result.kind, 'ok');
assert.equal(probedKey, 'sk-rotated');
assert.equal(readStored, false, 'a supplied key short-circuits the stored secret read');
});
test('records a probe failure as an error without throwing', async () => {
const result = await verifyApiKeyConnection({
providerType: 'openai',
apiKey: 'sk-bad',
connectionStore: { get: async () => null },
credentialStore: { getSecret: async () => null },
fetchModels: async () => {
throw new Error('HTTP 401');
},
});
assert.deepEqual(result, { kind: 'error', text: 'HTTP 401' });
});
test('rejects a provider that does not accept an API key before probing', async () => {
let probed = false;
const result = await verifyApiKeyConnection({
providerType: 'ollama',
apiKey: 'unused',
connectionStore: { get: async () => null },
credentialStore: { getSecret: async () => null },
fetchModels: async () => {
probed = true;
return [];
},
});
assert.equal(result.kind, 'error');
assert.match((result as { text: string }).text, /does not accept an API key/);
assert.equal(probed, false);
});
});
describe('saveApiKeyConnection', () => {
test('persists a new connection with curation and sets default only when none exists', async () => {
const createdInputs: Array<{ slug: string; providerType: ProviderType; defaultModel: string }> =
[];
const updatedPatches: Array<{
enabledModelIds?: string[];
defaultModel?: string;
models?: ModelInfo[];
lastTestStatus?: string;
}> = [];
const storedSecrets: Array<{ slug: string; value: string }> = [];
let defaultSlug: string | null = null;
const setDefaultCalls: string[] = [];
const result = await saveApiKeyConnection({
providerType: 'openai',
apiKey: 'sk-test',
enabledModelIds: ['gpt-5.5', 'gpt-5.5-mini'],
models: [{ id: 'gpt-5.5' }, { id: 'gpt-5.5-mini' }],
connectionStore: {
get: async () => null,
create: async (input) => {
createdInputs.push({
slug: input.slug,
providerType: input.providerType,
defaultModel: input.defaultModel ?? '',
});
return makeConnection({
slug: input.slug,
providerType: input.providerType,
defaultModel: input.defaultModel ?? 'gpt-5.5',
});
},
update: async (_slug, patch) => {
updatedPatches.push({
enabledModelIds: patch.enabledModelIds,
defaultModel: patch.defaultModel,
models: patch.models,
lastTestStatus: patch.lastTestStatus,
});
return makeConnection({ slug: 'openai', providerType: 'openai' });
},
remove: async () => {},
getDefault: async () => defaultSlug,
setDefault: async (slug) => {
if (slug) setDefaultCalls.push(slug);
defaultSlug = slug;
},
},
credentialStore: {
getSecret: async () => null,
deleteSecret: async () => {},
setSecret: async (slug, _kind, value) => {
storedSecrets.push({ slug, value });
},
},
fetchModelChoices: async () => [
{
connectionSlug: 'openai',
connectionName: 'OpenAI',
providerType: 'openai',
model: 'gpt-5.5',
isDefaultConnection: true,
},
],
});
assert.equal(result.kind, 'ok');
// The secret is written for a new connection.
assert.deepEqual(storedSecrets, [{ slug: 'openai', value: 'sk-test' }]);
// The connection is created then updated with the curated enabled set + cache.
assert.equal(createdInputs.length, 1);
assert.equal(createdInputs[0]!.slug, 'openai');
assert.equal(updatedPatches.length, 1);
assert.deepEqual(updatedPatches[0]!.enabledModelIds, ['gpt-5.5', 'gpt-5.5-mini']);
assert.deepEqual(updatedPatches[0]!.models, [{ id: 'gpt-5.5' }, { id: 'gpt-5.5-mini' }]);
assert.equal(updatedPatches[0]!.lastTestStatus, 'verified');
// No default existed, so the new connection becomes the default.
assert.deepEqual(setDefaultCalls, ['openai']);
// The refreshed ready model choices come back for the running TUI.
assert.equal((result as { modelChoices: unknown[] }).modelChoices.length, 1);
});
test('rolls back a newly created connection when the secret write fails', async () => {
const removedSlugs: string[] = [];
const result = await saveApiKeyConnection({
providerType: 'openai',
apiKey: 'sk-test',
enabledModelIds: ['gpt-5.5'],
models: [{ id: 'gpt-5.5' }],
connectionStore: {
get: async () => null,
create: async (input) =>
makeConnection({ slug: input.slug, providerType: input.providerType }),
update: async () => {
throw new Error('update must not be called when the secret write fails');
},
remove: async (slug) => {
removedSlugs.push(slug);
},
getDefault: async () => null,
setDefault: async () => {},
},
credentialStore: {
getSecret: async () => null,
deleteSecret: async () => {},
setSecret: async () => {
throw new Error('disk full');
},
},
fetchModelChoices: async () => [],
});
assert.equal(result.kind, 'error');
assert.match((result as { text: string }).text, /disk full/);
assert.deepEqual(removedSlugs, ['openai']);
});
test('rolls back a newly created connection and secret when the model update fails', async () => {
const removedSlugs: string[] = [];
const deletedSecrets: Array<{ slug: string; kind?: string }> = [];
const result = await saveApiKeyConnection({
providerType: 'openai',
apiKey: 'sk-new',
enabledModelIds: ['gpt-5.5'],
models: [{ id: 'gpt-5.5' }],
connectionStore: {
get: async () => null,
create: async (input) =>
makeConnection({ slug: input.slug, providerType: input.providerType }),
update: async () => {
throw new Error('disk full');
},
remove: async (slug) => {
removedSlugs.push(slug);
},
getDefault: async () => null,
setDefault: async () => {},
},
credentialStore: {
getSecret: async () => null,
setSecret: async () => {},
deleteSecret: async (slug, kind) => {
deletedSecrets.push({ slug, kind });
},
},
fetchModelChoices: async () => [],
});
assert.equal(result.kind, 'error');
assert.match((result as { text: string }).text, /disk full/);
assert.deepEqual(removedSlugs, ['openai']);
assert.deepEqual(deletedSecrets, [{ slug: 'openai', kind: 'api_key' }]);
});
test('rolls back a rotated secret when the model update fails on an existing connection', async () => {
const secretWrites: string[] = [];
let currentSecret = 'sk-old';
const result = await saveApiKeyConnection({
providerType: 'openai',
apiKey: 'sk-new',
enabledModelIds: ['gpt-5.5'],
models: [{ id: 'gpt-5.5' }],
connectionStore: {
get: async () =>
makeConnection({ slug: 'openai', providerType: 'openai', defaultModel: 'gpt-5.5' }),
create: async () => {
throw new Error('create must not be called for an existing connection');
},
update: async () => {
throw new Error('disk full');
},
remove: async () => {},
getDefault: async () => 'openai',
setDefault: async () => {
throw new Error('setDefault must not be called when a default already exists');
},
},
credentialStore: {
getSecret: async () => currentSecret,
setSecret: async (_slug, _kind, value) => {
secretWrites.push(value);
currentSecret = value;
},
deleteSecret: async () => {
throw new Error('deleteSecret must not be called when an old secret exists');
},
},
fetchModelChoices: async () => [],
});
assert.equal(result.kind, 'error');
assert.match((result as { text: string }).text, /disk full/);
assert.deepEqual(secretWrites, ['sk-new', 'sk-old']);
assert.equal(currentSecret, 'sk-old');
});
test('updates an existing connection without rotating the key when it is blank', async () => {
let secretStored = false;
const updatedPatches: Array<{ enabledModelIds?: string[]; defaultModel?: string }> = [];
const result = await saveApiKeyConnection({
providerType: 'openai',
apiKey: '',
enabledModelIds: ['gpt-5.5', 'gpt-5.5-mini'],
models: [{ id: 'gpt-5.5' }, { id: 'gpt-5.5-mini' }],
connectionStore: {
get: async () =>
makeConnection({ slug: 'openai', providerType: 'openai', defaultModel: 'gpt-5.5' }),
create: async () => {
throw new Error('create must not be called for an existing connection');
},
update: async (_slug, patch) => {
updatedPatches.push({
enabledModelIds: patch.enabledModelIds,
defaultModel: patch.defaultModel,
});
return makeConnection({ slug: 'openai', providerType: 'openai' });
},
remove: async () => {},
getDefault: async () => 'openai',
setDefault: async () => {
throw new Error('setDefault must not be called when a default already exists');
},
},
credentialStore: {
getSecret: async () => null,
deleteSecret: async () => {},
setSecret: async () => {
secretStored = true;
},
},
fetchModelChoices: async () => [],
});
assert.equal(result.kind, 'ok');
assert.equal(secretStored, false, 'a blank key must not rotate the stored secret');
assert.equal(updatedPatches.length, 1);
assert.deepEqual(updatedPatches[0]!.enabledModelIds, ['gpt-5.5', 'gpt-5.5-mini']);
});
test('rotates the key before updating an existing connection and leaves it untouched on failure', async () => {
const storedSecrets: Array<string> = [];
let updated = false;
const result = await saveApiKeyConnection({
providerType: 'openai',
apiKey: 'sk-rotated',
enabledModelIds: ['gpt-5.5'],
models: [{ id: 'gpt-5.5' }],
connectionStore: {
get: async () => makeConnection({ slug: 'openai', providerType: 'openai' }),
create: async () => {
throw new Error('create must not be called');
},
update: async () => {
updated = true;
return makeConnection({ slug: 'openai', providerType: 'openai' });
},
remove: async () => {},
getDefault: async () => 'openai',
setDefault: async () => {},
},
credentialStore: {
getSecret: async () => null,
deleteSecret: async () => {},
setSecret: async () => {
throw new Error('disk full');
},
},
fetchModelChoices: async () => [],
});
assert.equal(result.kind, 'error');
assert.match((result as { text: string }).text, /disk full/);
assert.equal(updated, false, 'a rotation failure must not update the existing connection');
assert.deepEqual(storedSecrets, []);
});
test('does not replace an existing default connection during in-session setup', async () => {
const setDefaultCalls: string[] = [];
const result = await saveApiKeyConnection({
providerType: 'anthropic',
apiKey: 'sk-test',
enabledModelIds: ['claude-sonnet-5'],
models: [{ id: 'claude-sonnet-5' }],
connectionStore: {
get: async (slug) =>
slug === 'anthropic'
? makeConnection({ slug: 'anthropic', providerType: 'anthropic' })
: null,
create: async (input) =>
makeConnection({ slug: input.slug, providerType: input.providerType }),
update: async () => makeConnection({ slug: 'anthropic', providerType: 'anthropic' }),
remove: async () => {},
getDefault: async () => 'openai', // another connection is already the default
setDefault: async (slug) => {
if (slug) setDefaultCalls.push(slug);
},
},
credentialStore: {
getSecret: async () => null,
setSecret: async () => {},
deleteSecret: async () => {},
},
fetchModelChoices: async () => [],
});
assert.equal(result.kind, 'ok');
assert.deepEqual(setDefaultCalls, [], 'in-session setup must not replace the existing default');
});
test('keeps the existing defaultModel when it is still enabled, else picks the first enabled', async () => {
const cases: Array<{ existingDefault: string; enabled: string[]; expected: string }> = [
{ existingDefault: 'gpt-5.5', enabled: ['gpt-5.5', 'gpt-5.5-mini'], expected: 'gpt-5.5' },
{ existingDefault: 'gpt-4', enabled: ['gpt-5.5', 'gpt-5.5-mini'], expected: 'gpt-5.5' },
];
for (const { existingDefault, enabled, expected } of cases) {
let savedDefault: string | undefined;
await saveApiKeyConnection({
providerType: 'openai',
apiKey: '',
enabledModelIds: enabled,
models: enabled.map((id) => ({ id })),
connectionStore: {
get: async () =>
makeConnection({
slug: 'openai',
providerType: 'openai',
defaultModel: existingDefault,
}),
create: async () => {
throw new Error('create must not be called');
},
update: async (_slug, patch) => {
savedDefault = patch.defaultModel;
return makeConnection({ slug: 'openai', providerType: 'openai' });
},
remove: async () => {},
getDefault: async () => 'openai',
setDefault: async () => {},
},
credentialStore: {
getSecret: async () => null,
setSecret: async () => {},
deleteSecret: async () => {},
},
fetchModelChoices: async () => [],
});
assert.equal(savedDefault, expected);
}
});
test('rejects an empty enabled-model set before touching the stores', async () => {
let created = false;
let updated = false;
let secretStored = false;
const result = await saveApiKeyConnection({
providerType: 'openai',
apiKey: 'sk-test',
enabledModelIds: [],
models: [],
connectionStore: {
get: async () => null,
create: async () => {
created = true;
return makeConnection({});
},
update: async () => {
updated = true;
return makeConnection({});
},
remove: async () => {},
getDefault: async () => null,
setDefault: async () => {},
},
credentialStore: {
getSecret: async () => null,
deleteSecret: async () => {},
setSecret: async () => {
secretStored = true;
},
},
fetchModelChoices: async () => [],
});
assert.equal(result.kind, 'error');
assert.match((result as { text: string }).text, /至少选择一个模型|at least one model/i);
assert.equal(created, false);
assert.equal(updated, false);
assert.equal(secretStored, false);
});
test('rejects a provider that does not accept an API key before persisting', async () => {
const result = await saveApiKeyConnection({
providerType: 'ollama',
apiKey: 'unused',
enabledModelIds: ['x'],
models: [{ id: 'x' }],
connectionStore: {
get: async () => null,
create: async () => makeConnection({}),
update: async () => makeConnection({}),
remove: async () => {},
getDefault: async () => null,
setDefault: async () => {},
},
credentialStore: {
getSecret: async () => null,
setSecret: async () => {},
deleteSecret: async () => {},
},
fetchModelChoices: async () => [],
});
assert.equal(result.kind, 'error');
assert.match((result as { text: string }).text, /does not accept an API key/);
});
});
describe('listApiKeyOnboardableProviders', () => {
test('lists only API-key providers, excluding OAuth and keyless ones', () => {
const providers = listApiKeyOnboardableProviders();
const types = providers.map((p) => p.providerType);
assert.ok(types.includes('anthropic'));
assert.ok(types.includes('openai'));
// keyless local models and OAuth subscription providers are not onboardable this way
assert.ok(!types.includes('ollama'));
for (const provider of providers) {
assert.ok(
provider.authKind === 'api_key' || provider.authKind === 'optional_api_key',
`${provider.providerType} should accept an api key`,
);
}
});
test('excludes providers that require a user-supplied baseUrl (phase 1)', () => {
const providers = listApiKeyOnboardableProviders();
const anthropic = providers.find((p) => p.providerType === 'anthropic');
// anthropic ships a default baseUrl, so the wizard skips that field for it.
assert.equal(anthropic?.requiresBaseUrl, false);
// Phase 1 cannot collect a base URL, so providers without a default one are
// not onboardable yet (they would wedge the install — see PR #1177 review).
for (const provider of providers) {
assert.equal(
provider.requiresBaseUrl,
false,
`${provider.providerType} requires a base URL and must be excluded until the wizard can prompt for one`,
);
}
});
});
function makeConnection(input: Partial<LlmConnection>): LlmConnection {
return {
slug: 'conn',
name: 'Connection',
providerType: 'ollama',
defaultModel: 'llama3.2',
enabled: true,
createdAt: 1,
updatedAt: 1,
...input,
};
}