Skip to content
Open
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
205 changes: 205 additions & 0 deletions apps/desktop/src/main/__tests__/provider-add-submission.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
createProviderWithDiscovery,
validateAddProviderDraft,
type AddProviderDraft,
type AddProviderField,
} from '../../renderer/settings/provider-add-submission.js';
import {
PROVIDER_DEFAULTS,
providerSupportsModelDiscovery,
type CreateConnectionInput,
type LlmConnection,
type ProviderType,
} from '@maka/core/llm-connections';

// A compile-time half of the same promise: the gate's field union has no
// model rule to report, so one cannot be added without this line failing.
type NoModelRule = 'defaultModel' extends AddProviderField ? never : true;
const _fieldGateHasNoModelRule: NoModelRule = true;
void _fieldGateHasNoModelRule;

const RELAY_TYPES: readonly ProviderType[] = ['openai-compatible', 'openai-responses-compatible'];

function draft(over: Partial<AddProviderDraft> = {}): AddProviderDraft {
return {
providerType: 'openai-compatible',
slug: 'house-relay',
existingSlugs: [],
apiKey: 'sk-test',
cloudflareAccountId: '',
baseUrl: 'https://relay.example.com/v1',
...over,
};
}

function connection(slug: string): LlmConnection {
return {
slug,
name: slug,
providerType: 'openai-compatible',
defaultModel: '',
enabled: true,
createdAt: 0,
updatedAt: 0,
} as LlmConnection;
}

function bridge(over: {
create?: (input: CreateConnectionInput) => Promise<LlmConnection>;
fetchModels?: (slug: string) => Promise<unknown>;
}) {
return {
create: over.create ?? (async (input) => connection(input.slug)),
fetchModels: over.fetchModels ?? (async () => ({ models: [], source: 'fetched' })),
};
}

// The first of the two behaviours this module exists to protect. A custom
// relay used to be the only provider class that refused to be created without
// a hand-typed model id — before the app had asked the relay what it serves.
test('a custom relay is created without a hand-typed model id', () => {
for (const providerType of RELAY_TYPES) {
assert.equal(validateAddProviderDraft(draft({ providerType })), null, providerType);
}
});

test('no provider type demands a model id at creation', () => {
// Stated across the catalog rather than for the two relays alone: the rule
// that came back would be a per-provider `if`, and asserting only where it
// used to live would let it reappear next door.
for (const providerType of Object.keys(PROVIDER_DEFAULTS) as ProviderType[]) {
const defaults = PROVIDER_DEFAULTS[providerType];
if (defaults.status === 'phase3-experimental') continue;
const issue = validateAddProviderDraft(
draft({
providerType,
slug: 'probe-connection',
apiKey: 'sk-test',
baseUrl: 'https://example.com/v1',
cloudflareAccountId: 'account-id',
}),
);
assert.equal(issue, null, `${providerType} refused a draft with no model id`);
}
});

// The second. Discovery failures were reported for every provider except the
// custom relays, which are the endpoints most likely to be misconfigured.
test('a discovery failure reaches the caller for a custom relay', async () => {
for (const providerType of RELAY_TYPES) {
const failure = new Error('relay refused /v1/models');
const created = await createProviderWithDiscovery(
bridge({
fetchModels: async () => {
throw failure;
},
}),
{ slug: 'house-relay', name: 'House', providerType } as CreateConnectionInput,
);
assert.equal(created.connection.slug, 'house-relay');
assert.equal(created.modelDiscoveryError, failure, providerType);
}
});

test('a discovery failure reaches the caller for a built-in provider too', async () => {
const failure = new Error('401');
const created = await createProviderWithDiscovery(
bridge({
fetchModels: async () => {
throw failure;
},
}),
{ slug: 'openai-main', name: 'OpenAI', providerType: 'openai' } as CreateConnectionInput,
);
assert.equal(created.modelDiscoveryError, failure);
});

test('a failed catalog fetch still yields the created connection', async () => {
// Discovery is a convenience on top of a successful create, never a
// condition of it: reporting the failure must not read as "nothing was
// created", or the user is sent to make a duplicate.
const created = await createProviderWithDiscovery(
bridge({
fetchModels: async () => {
throw new Error('ECONNREFUSED');
},
}),
{ slug: 'house-relay', name: 'House', providerType: 'openai-compatible' } as CreateConnectionInput,
);
assert.equal(created.connection.slug, 'house-relay');
});

test('a successful catalog fetch reports no error', async () => {
const created = await createProviderWithDiscovery(
bridge({}),
{ slug: 'house-relay', name: 'House', providerType: 'openai-compatible' } as CreateConnectionInput,
);
assert.equal(created.modelDiscoveryError, undefined);
});

test('a provider without discovery is not asked, and reports no error', async () => {
const withoutDiscovery = (Object.keys(PROVIDER_DEFAULTS) as ProviderType[]).find(
(providerType) => !providerSupportsModelDiscovery(providerType),
);
assert.ok(withoutDiscovery, 'expected at least one provider with no discovery endpoint');
let asked = false;
const created = await createProviderWithDiscovery(
bridge({
fetchModels: async () => {
asked = true;
return {};
},
}),
{ slug: 'static-catalog', name: 'Static', providerType: withoutDiscovery } as CreateConnectionInput,
);
assert.equal(asked, false);
assert.equal(created.modelDiscoveryError, undefined);
});

test('a create failure propagates instead of being reported as a discovery problem', async () => {
const failure = new Error('slug already exists');
await assert.rejects(
createProviderWithDiscovery(
bridge({
create: async () => {
throw failure;
},
}),
{ slug: 'house-relay', name: 'House', providerType: 'openai-compatible' } as CreateConnectionInput,
),
failure,
);
});

test('the field gate still reports the rules that survived', () => {
assert.deepEqual(validateAddProviderDraft(draft({ slug: 'Not A Slug' }))?.field, 'slug');
assert.deepEqual(validateAddProviderDraft(draft({ existingSlugs: ['house-relay'] })), {
field: 'slug',
reason: 'duplicate',
});
assert.deepEqual(validateAddProviderDraft(draft({ providerType: 'openai', apiKey: ' ' })), {
field: 'apiKey',
reason: 'required',
});
assert.deepEqual(
validateAddProviderDraft(
draft({ providerType: 'cloudflare-workers-ai', cloudflareAccountId: ' ' }),
),
{ field: 'accountId', reason: 'required' },
);
assert.deepEqual(validateAddProviderDraft(draft({ baseUrl: ' ' })), {
field: 'baseUrl',
reason: 'required',
});
});

test('a duplicate slug outranks a missing key, so one fix is asked for at a time', () => {
assert.deepEqual(
validateAddProviderDraft(
draft({ providerType: 'openai', slug: 'taken', existingSlugs: ['taken'], apiKey: '' }),
),
{ field: 'slug', reason: 'duplicate' },
);
});
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ const zhCopy = {
accountIdPlaceholder: '填写账户 ID',
saving: '保存中…', save: '保存供应商', keyRequired: (name: string) => `请填写 ${name} API Key`,
apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服务地址',
defaultModel: '默认模型', defaultModelPlaceholder: '填写你的中转站模型 ID,例如 gpt-4o、claude-sonnet-4-5 或自定义模型名', defaultModelHelp: '用于首次连接测试和模型选择器兜底;保存后仍会自动拉取模型目录。', defaultModelRequired: '请填写默认模型 ID。保存后仍会自动拉取模型目录。',
defaultModel: '默认模型', defaultModelPlaceholder: '留空即可,保存后自动拉取', defaultModelHelp: '保存后 Maka 会向该端点拉取模型目录。只有当端点不提供目录时,才需要在这里手填一个模型 ID。',
...zhCapabilitiesCopy,
},
oauthFlow: {
Expand Down Expand Up @@ -307,7 +307,7 @@ const enCopy: ProviderSettingsCopy = {
accountIdPlaceholder: 'Enter account ID',
saving: 'Saving…', save: 'Save provider', keyRequired: (name: string) => `Enter the ${name} API key`,
apiKeyLabel: 'API key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: 'Service URL',
defaultModel: 'Default model', defaultModelPlaceholder: 'Enter your relay model id, e.g. gpt-4o, claude-sonnet-4-5, or a custom model name', defaultModelHelp: 'Used as the first connection-test and picker fallback; Maka still fetches the model catalog after saving.', defaultModelRequired: 'Enter a default model id. Maka still fetches the model catalog after saving.',
defaultModel: 'Default model', defaultModelPlaceholder: 'Leave empty — fetched after saving', defaultModelHelp: 'Maka fetches the model catalog from this endpoint after saving. Type a model id here only if the endpoint serves no catalog.',
...enCapabilitiesCopy,
},
oauthFlow: {
Expand Down
Loading