From 28f5b17cd6b93da8917afd50e5b92a40e0f76dce Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Mon, 14 Sep 2026 12:58:25 +0000 Subject: [PATCH] feat(providers): formalize model discovery modes Declare modelDiscoveryMode on provider capabilities so listing, Settings pickers, imports, and diagnostics stop treating GET /models as universal. Fixes #210 --- .changeset/provider-model-discovery-modes.md | 7 + apps/desktop/src/main/connection-ipc.test.ts | 131 +++++++++++- apps/desktop/src/main/connection-ipc.ts | 99 +++++++++- .../main/imports/claude-code-config.test.ts | 25 +++ .../src/main/imports/claude-code-config.ts | 3 +- .../src/main/imports/codex-config.test.ts | 4 + apps/desktop/src/main/imports/codex-config.ts | 8 +- .../main/imports/gemini-cli-config.test.ts | 4 + .../src/main/imports/gemini-cli-config.ts | 5 +- .../src/main/imports/opencode-config.test.ts | 4 + .../src/main/imports/opencode-config.ts | 7 +- .../main/onboarding/provider-parsers.test.ts | 56 ++++++ .../src/main/onboarding/provider-parsers.ts | 22 +++ .../src/main/onboarding/providers-crud.ts | 7 + .../src/main/provider-settings.test.ts | 29 +++ apps/desktop/src/main/provider-settings.ts | 11 ++ apps/desktop/src/preload/index.ts | 5 + .../src/components/AddCustomProviderModal.tsx | 42 +++- .../components/ConnectionDiagnosticPanel.tsx | 19 +- .../renderer/src/components/Settings.test.ts | 60 ++++++ .../src/renderer/src/components/Settings.tsx | 2 +- .../src/components/settings/ModelsTab.tsx | 3 + .../src/components/settings/primitives.tsx | 129 ++++++++++-- packages/i18n/src/locales/en.json | 9 + packages/i18n/src/locales/es.json | 9 + packages/i18n/src/locales/pt-BR.json | 9 + packages/i18n/src/locales/zh-CN.json | 9 + packages/shared/src/config.test.ts | 51 +++++ packages/shared/src/config.ts | 59 ++++-- packages/shared/src/diagnostics.test.ts | 24 +++ packages/shared/src/diagnostics.ts | 22 ++- packages/shared/src/index.ts | 23 +++ packages/shared/src/model-discovery.test.ts | 186 ++++++++++++++++++ packages/shared/src/model-discovery.ts | 149 ++++++++++++++ 34 files changed, 1178 insertions(+), 54 deletions(-) create mode 100644 .changeset/provider-model-discovery-modes.md create mode 100644 apps/desktop/src/main/onboarding/provider-parsers.test.ts create mode 100644 packages/shared/src/model-discovery.test.ts create mode 100644 packages/shared/src/model-discovery.ts diff --git a/.changeset/provider-model-discovery-modes.md b/.changeset/provider-model-discovery-modes.md new file mode 100644 index 000000000..e5a851c16 --- /dev/null +++ b/.changeset/provider-model-discovery-modes.md @@ -0,0 +1,7 @@ +--- +"@open-codesign/desktop": patch +"@open-codesign/i18n": patch +"@open-codesign/shared": patch +--- + +Formalize provider `modelDiscoveryMode` (`models`, `static-hint`, `manual`, `infer-only`) so listing, Settings pickers, imports, and diagnostics no longer treat GET `/models` as universal. diff --git a/apps/desktop/src/main/connection-ipc.test.ts b/apps/desktop/src/main/connection-ipc.test.ts index f6a869dde..55e62a4dd 100644 --- a/apps/desktop/src/main/connection-ipc.test.ts +++ b/apps/desktop/src/main/connection-ipc.test.ts @@ -14,11 +14,13 @@ vi.mock('./tls-override', () => ({ })); import { createHash } from 'node:crypto'; +import { resolveListForProviderPlan } from '@open-codesign/shared'; import { _clearModelsCache, buildAuthHeaders, buildAuthHeadersForWire, CONNECTION_FETCH_TIMEOUT_MS, + type ConnectionTestResponse, classifyHttpError, classifyNetworkTarget, extractIds, @@ -27,6 +29,7 @@ import { getCacheKey, handleConfigV1TestEndpoint, handleOllamaV1Probe, + type ModelsListResponse, normalizeBaseUrl, normalizeOllamaBaseUrl, runProviderTest, @@ -39,8 +42,6 @@ import { withTlsBypass } from './tls-override'; // network responses without hitting the network. // --------------------------------------------------------------------------- -import type { ConnectionTestResponse, ModelsListResponse } from './connection-ipc'; - // --------------------------------------------------------------------------- // connection:v1:test test helper // --------------------------------------------------------------------------- @@ -555,6 +556,31 @@ describe('models:v1:list-for-provider input validation', () => { const result = validateListForProviderInput('claude-code-anthropic'); expect(result).toBeNull(); }); + + it('respects declared discovery modes instead of always fetching /models', () => { + expect( + resolveListForProviderPlan('chatgpt-codex', { + wire: 'openai-codex-responses', + defaultModel: 'gpt-5.5', + modelsHint: ['gpt-5.5', 'gpt-5.4'], + requiresApiKey: false, + }).action, + ).toBe('return'); + expect( + resolveListForProviderPlan('glm', { + wire: 'openai-chat', + defaultModel: 'glm-4.6', + capabilities: { modelDiscoveryMode: 'infer-only' }, + }), + ).toMatchObject({ action: 'return', source: 'local', models: ['glm-4.6'] }); + expect( + resolveListForProviderPlan('openai', { + wire: 'openai-chat', + defaultModel: 'gpt-4o', + capabilities: { modelDiscoveryMode: 'models' }, + }).action, + ).toBe('fetch-remote'); + }); }); // --------------------------------------------------------------------------- @@ -1188,6 +1214,88 @@ describe('runProviderTest degrade-probe (issue #179)', () => { }); }); +describe('runProviderTest discovery modes (issue #210)', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('infer-only skips GET /models and treats inference success as compatible', async () => { + const { calls, restore } = installFakeFetch((url) => { + if (url.endsWith('/models')) return { status: 404 }; + if (url.endsWith('/chat/completions')) return { status: 200, body: { id: 'ok' } }; + return { status: 500 }; + }); + try { + const res = await runProviderTest({ + provider: 'glm', + wire: 'openai-chat', + apiKey: 'sk-test', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + modelDiscoveryMode: 'infer-only', + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.probeMethod).toBe('inference'); + expect(res.compatibility).toBe('compatible'); + } + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toMatch(/\/chat\/completions$/); + expect(calls.some((c) => c.url.endsWith('/models'))).toBe(false); + } finally { + restore(); + } + }); + + it('manual skips GET /models', async () => { + const { calls, restore } = installFakeFetch((url) => { + if (url.endsWith('/responses')) return { status: 200, body: { id: 'ok' } }; + return { status: 404 }; + }); + try { + const res = await runProviderTest({ + provider: 'custom', + wire: 'openai-responses', + apiKey: 'sk-test', + baseUrl: 'https://proxy.example.com/v1', + modelDiscoveryMode: 'manual', + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.probeMethod).toBe('inference'); + expect(res.compatibility).toBe('compatible'); + } + expect(calls.some((c) => c.url.endsWith('/models'))).toBe(false); + } finally { + restore(); + } + }); + + it('models mode still degrade-probes /models 404 as before', async () => { + const { calls, restore } = installFakeFetch((url) => { + if (url.endsWith('/models')) return { status: 404 }; + if (url.endsWith('/chat/completions')) return { status: 200, body: { id: 'ok' } }; + return { status: 500 }; + }); + try { + const res = await runProviderTest({ + provider: 'glm', + wire: 'openai-chat', + apiKey: 'sk-test', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + modelDiscoveryMode: 'models', + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.probeMethod).toBe('chat_completion_degraded'); + expect(res.compatibility).toBe('degraded'); + } + expect(calls[0]?.url).toMatch(/\/models$/); + } finally { + restore(); + } + }); +}); + describe('config:v1:test-endpoint response parsing', () => { beforeEach(() => { vi.useRealTimers(); @@ -1212,6 +1320,25 @@ describe('config:v1:test-endpoint response parsing', () => { } }); + it('treats GET /models 404 as listing-unavailable rather than a hard failure', async () => { + const { restore } = installFakeFetch(() => ({ status: 404 })); + try { + await expect( + handleConfigV1TestEndpoint({ + wire: 'openai-chat', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + apiKey: 'sk-test', + }), + ).resolves.toEqual({ + ok: false, + error: 'not-a-model-endpoint', + message: 'HTTP 404', + }); + } finally { + restore(); + } + }); + it('classifies private and metadata network targets', () => { expect(classifyNetworkTarget('https://provider.example/v1')).toBe('public'); expect(classifyNetworkTarget('http://localhost:8317')).toBe('loopback'); diff --git a/apps/desktop/src/main/connection-ipc.ts b/apps/desktop/src/main/connection-ipc.ts index 066e505d4..309902da2 100644 --- a/apps/desktop/src/main/connection-ipc.ts +++ b/apps/desktop/src/main/connection-ipc.ts @@ -4,11 +4,15 @@ import { BUILTIN_PROVIDERS, CodesignError, canonicalBaseUrl, + connectionTestProbesModelsEndpoint, type DiagnosticCategory, ERROR_CODES, ensureVersionedBase, isSupportedOnboardingProvider, type ProviderEntry, + type ProviderModelDiscoveryMode, + resolveListForProviderPlan, + resolveProviderCapabilities, type SupportedOnboardingProvider, stripInferenceEndpointSuffix, type WireApi, @@ -72,6 +76,8 @@ export interface ConnectionTestResult { ok: true; /** * `models` when the standard GET /models probe succeeded. + * `inference` when the provider's discovery mode does not expect /models + * and the real invoke path answered. * `chat_completion_degraded` when /models 404'd but POST /chat/completions * proved the openai-chat wire is alive (e.g. Zhipu GLM — no public /models). * `responses_degraded` when /models 404'd but POST /responses proved the @@ -81,6 +87,7 @@ export interface ConnectionTestResult { */ probeMethod?: | 'models' + | 'inference' | 'chat_completion_degraded' | 'responses_degraded' | 'anthropic_messages_degraded'; @@ -100,12 +107,18 @@ export interface ConnectionTestError { export type ConnectionTestResponse = ConnectionTestResult | ConnectionTestError; export type ModelsListResponse = - | { ok: true; models: string[] } + | { + ok: true; + models: string[]; + discoveryMode?: ProviderModelDiscoveryMode; + source?: 'remote' | 'static-hint' | 'local'; + } | { ok: false; code: 'IPC_BAD_INPUT' | 'NETWORK' | 'HTTP' | 'PARSE'; message: string; hint: string; + discoveryMode?: ProviderModelDiscoveryMode; }; function parseConnectionTestPayload(raw: unknown): ConnectionTestPayloadV1 { @@ -492,6 +505,9 @@ export interface ActiveProviderCredentials { builtin?: boolean; /** Opt-in TLS verification bypass; only honored when `builtin === false`. */ tlsRejectUnauthorized?: boolean; + /** Listing strategy for this provider. Defaults to `models` when omitted so + * existing tests and older callers keep probing GET /models. */ + modelDiscoveryMode?: ProviderModelDiscoveryMode; } function resolveCredentialsForProvider( @@ -539,6 +555,7 @@ function resolveCredentialsForProvider( apiKey, baseUrl: entry.baseUrl, builtin: entry.builtin === true, + modelDiscoveryMode: resolveProviderCapabilities(providerId, entry).modelDiscoveryMode, ...(entry.httpHeaders !== undefined ? { httpHeaders: entry.httpHeaders } : {}), ...(entry.tlsRejectUnauthorized !== undefined ? { tlsRejectUnauthorized: entry.tlsRejectUnauthorized } @@ -607,10 +624,15 @@ export async function runProviderTest( return testChatGPTCodexOAuth(); } + const discoveryMode = creds.modelDiscoveryMode ?? 'models'; + const bypass = creds.builtin !== true && creds.tlsRejectUnauthorized === true; + if (!connectionTestProbesModelsEndpoint(discoveryMode)) { + return withTlsBypass(bypass, () => probeDeclaredInference(creds)); + } + // Bypass is the per-provider opt-in, force-gated so a tampered config can // never weaken TLS for built-in providers. Wrapping the whole body covers // both the GET /models probe and the inner POST inside tryDegradeProbe. - const bypass = creds.builtin !== true && creds.tlsRejectUnauthorized === true; return withTlsBypass(bypass, async () => { const { url, normalizedBaseUrl } = buildEndpointForWire(creds.wire, creds.baseUrl); const headers = buildAuthHeadersForWire( @@ -666,6 +688,55 @@ export async function runProviderTest( }); } +async function probeDeclaredInference( + creds: ActiveProviderCredentials, +): Promise { + if ( + creds.wire !== 'openai-chat' && + creds.wire !== 'openai-responses' && + creds.wire !== 'anthropic' + ) { + return { + ok: false, + code: 'IPC_BAD_INPUT', + message: `Discovery mode does not probe /models for wire "${creds.wire}"`, + hint: 'Use a listed model id or switch this provider to a listing-capable endpoint', + compatibility: 'incompatible', + reasonCategory: 'unknown', + }; + } + const { normalizedBaseUrl } = buildEndpointForWire(creds.wire, creds.baseUrl); + const headers = buildAuthHeadersForWire( + creds.wire, + creds.apiKey, + creds.httpHeaders, + creds.baseUrl, + ); + const probe = await probeInferenceEndpoint(creds.wire, normalizedBaseUrl, headers); + if (probe.kind === 'pass') { + return { ok: true, probeMethod: 'inference', compatibility: 'compatible' }; + } + if (probe.kind === 'network') { + return { + ok: false, + code: 'NETWORK', + message: probe.message, + hint: 'Cannot reach provider inference endpoint', + compatibility: 'incompatible', + reasonCategory: 'unknown', + }; + } + const { code, hint } = classifyHttpError(probe.status); + return { + ok: false, + code, + message: `HTTP ${probe.status}`, + hint, + compatibility: 'incompatible', + reasonCategory: connectionCategoryForStatus(probe.status, normalizedBaseUrl), + }; +} + async function tryDegradeProbe( wire: 'openai-chat' | 'openai-responses' | 'anthropic', normalizedBaseUrl: string, @@ -977,11 +1048,14 @@ async function handleModelsV1ListForProvider(raw: unknown): Promise 0) { - return { ok: true, models: entry.modelsHint }; + const plan = resolveListForProviderPlan(providerId, entry); + if (plan.action === 'return') { + return { + ok: true, + models: plan.models, + discoveryMode: plan.discoveryMode, + source: plan.source, + }; } const keyResult = resolveApiKeyForListing(providerId, entry); @@ -989,7 +1063,9 @@ async function handleModelsV1ListForProvider(raw: unknown): Promise { expect(out.apiKey).toBe('sk-ant-test'); expect(out.apiKeySource).toBe('settings-json'); expect(out.userType).toBe('has-api-key'); + expect(out.provider?.capabilities).toMatchObject({ + modelDiscoveryMode: 'infer-only', + supportsModelsEndpoint: false, + }); }); it('accepts ANTHROPIC_API_KEY from settings.json as an alternate Claude key', () => { @@ -46,6 +50,27 @@ describe('parseClaudeCodeSettings', () => { expect(out.provider?.envKey).toBe('ANTHROPIC_AUTH_TOKEN'); }); + it('stamps models discovery for official Anthropic and infer-only for proxies', () => { + const official = parseClaudeCodeSettings(JSON.stringify({ env: { ANTHROPIC_API_KEY: 'k' } }), { + env: {}, + }); + expect(official.provider?.baseUrl).toBe('https://api.anthropic.com'); + expect(official.provider?.capabilities?.modelDiscoveryMode).toBe('models'); + expect(official.provider?.capabilities?.supportsModelsEndpoint).toBe(true); + + const proxy = parseClaudeCodeSettings( + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://localhost:8082', + ANTHROPIC_AUTH_TOKEN: 'k', + }, + }), + { env: {} }, + ); + expect(proxy.provider?.capabilities?.modelDiscoveryMode).toBe('infer-only'); + expect(proxy.provider?.capabilities?.supportsModelsEndpoint).toBe(false); + }); + it('classifies no-key + localhost baseUrl as local-proxy', () => { const json = JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'http://localhost:8082' } }); const out = parseClaudeCodeSettings(json, { env: {} }); diff --git a/apps/desktop/src/main/imports/claude-code-config.ts b/apps/desktop/src/main/imports/claude-code-config.ts index 59718ecdb..cdcb7f74b 100644 --- a/apps/desktop/src/main/imports/claude-code-config.ts +++ b/apps/desktop/src/main/imports/claude-code-config.ts @@ -1,7 +1,7 @@ import { access } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import type { ProviderEntry } from '@open-codesign/shared'; +import { capabilitiesForImportedProvider, type ProviderEntry } from '@open-codesign/shared'; import { safeReadImportFile } from './safe-read'; // test comment sentinel @@ -262,6 +262,7 @@ export function parseClaudeCodeSettings( // imports just work; higher-tier users can raise it in Settings → // Providers → Reasoning depth. reasoningLevel: 'medium', + capabilities: capabilitiesForImportedProvider('claude-code', { baseUrl }), }; if (apiKey === null && userType !== 'local-proxy' && userType !== 'remote-gateway') { diff --git a/apps/desktop/src/main/imports/codex-config.test.ts b/apps/desktop/src/main/imports/codex-config.test.ts index c1330aafd..68ec9f3af 100644 --- a/apps/desktop/src/main/imports/codex-config.test.ts +++ b/apps/desktop/src/main/imports/codex-config.test.ts @@ -30,6 +30,10 @@ wire_api = "chat" expect(entry?.baseUrl).toBe('https://api.deepseek.com/v1'); expect(entry?.envKey).toBe('DEEPSEEK_API_KEY'); expect(entry?.defaultModel).toBe('deepseek-chat'); + expect(entry?.capabilities).toMatchObject({ + modelDiscoveryMode: 'infer-only', + supportsModelsEndpoint: false, + }); expect(out.activeProvider).toBe('codex-deepseek'); expect(out.activeModel).toBe('deepseek-chat'); }); diff --git a/apps/desktop/src/main/imports/codex-config.ts b/apps/desktop/src/main/imports/codex-config.ts index 330ef428d..c8fb7f19e 100644 --- a/apps/desktop/src/main/imports/codex-config.ts +++ b/apps/desktop/src/main/imports/codex-config.ts @@ -1,6 +1,11 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import { detectWireFromBaseUrl, type ProviderEntry, type WireApi } from '@open-codesign/shared'; +import { + capabilitiesForImportedProvider, + detectWireFromBaseUrl, + type ProviderEntry, + type WireApi, +} from '@open-codesign/shared'; import { safeReadImportFile } from './safe-read'; /** @@ -233,6 +238,7 @@ function parseProviderBlock( activeModel, warnings, ), + capabilities: capabilitiesForImportedProvider('codex', { baseUrl, wire }), }; const declaresEnvKey = block.env_key !== undefined; applyEnvKey(entry, block, id, envKeyMap, warnings); diff --git a/apps/desktop/src/main/imports/gemini-cli-config.test.ts b/apps/desktop/src/main/imports/gemini-cli-config.test.ts index a8d63cc07..bc6532f55 100644 --- a/apps/desktop/src/main/imports/gemini-cli-config.test.ts +++ b/apps/desktop/src/main/imports/gemini-cli-config.test.ts @@ -112,6 +112,10 @@ describe('readGeminiCliConfig', () => { expect(out.provider.baseUrl).toBe(GEMINI_OPENAI_COMPAT_BASE_URL); expect(out.provider.defaultModel).toBe(GEMINI_DEFAULT_MODEL); expect(out.provider.envKey).toBe('GEMINI_API_KEY'); + expect(out.provider.capabilities).toMatchObject({ + modelDiscoveryMode: 'models', + supportsModelsEndpoint: true, + }); expect(out.warnings).toEqual([]); }); diff --git a/apps/desktop/src/main/imports/gemini-cli-config.ts b/apps/desktop/src/main/imports/gemini-cli-config.ts index 3ec91a685..4101d74f5 100644 --- a/apps/desktop/src/main/imports/gemini-cli-config.ts +++ b/apps/desktop/src/main/imports/gemini-cli-config.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import type { ProviderEntry } from '@open-codesign/shared'; +import { capabilitiesForImportedProvider, type ProviderEntry } from '@open-codesign/shared'; import { safeReadImportFile } from './safe-read'; /** @@ -300,6 +300,9 @@ export async function readGeminiCliConfig( baseUrl: GEMINI_OPENAI_COMPAT_BASE_URL, defaultModel: GEMINI_DEFAULT_MODEL, envKey: 'GEMINI_API_KEY', + capabilities: capabilitiesForImportedProvider('gemini', { + baseUrl: GEMINI_OPENAI_COMPAT_BASE_URL, + }), }; return { diff --git a/apps/desktop/src/main/imports/opencode-config.test.ts b/apps/desktop/src/main/imports/opencode-config.test.ts index 542b3f83c..6a972aae5 100644 --- a/apps/desktop/src/main/imports/opencode-config.test.ts +++ b/apps/desktop/src/main/imports/opencode-config.test.ts @@ -84,6 +84,10 @@ describe('readOpencodeConfig', () => { expect(entry?.wire).toBe('anthropic'); expect(entry?.baseUrl).toBe('https://api.anthropic.com'); expect(entry?.defaultModel).toBe('claude-sonnet-4-6'); + expect(entry?.capabilities).toMatchObject({ + modelDiscoveryMode: 'models', + supportsModelsEndpoint: true, + }); expect(out?.apiKeyMap['opencode-anthropic']).toBe('sk-ant-abc'); }); diff --git a/apps/desktop/src/main/imports/opencode-config.ts b/apps/desktop/src/main/imports/opencode-config.ts index 0732c3923..98d6ba654 100644 --- a/apps/desktop/src/main/imports/opencode-config.ts +++ b/apps/desktop/src/main/imports/opencode-config.ts @@ -1,6 +1,10 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import type { ProviderEntry, WireApi } from '@open-codesign/shared'; +import { + capabilitiesForImportedProvider, + type ProviderEntry, + type WireApi, +} from '@open-codesign/shared'; import { safeReadImportFile } from './safe-read'; /** @@ -365,6 +369,7 @@ function parseAuthBlock(providerId: string, entry: unknown): ParsedAuthEntry { baseUrl: mapping.baseUrl, defaultModel: mapping.defaultModel, envKey: mapping.envKey, + capabilities: capabilitiesForImportedProvider('opencode', { baseUrl: mapping.baseUrl }), }, apiKey: auth.key.trim(), }; diff --git a/apps/desktop/src/main/onboarding/provider-parsers.test.ts b/apps/desktop/src/main/onboarding/provider-parsers.test.ts new file mode 100644 index 000000000..1fb3ba96f --- /dev/null +++ b/apps/desktop/src/main/onboarding/provider-parsers.test.ts @@ -0,0 +1,56 @@ +import { CodesignError } from '@open-codesign/shared'; +import { describe, expect, it } from 'vitest'; +import { parseAddProviderPayload, parseUpdateProviderPayload } from './provider-parsers'; + +const addBase = { + id: 'custom-glm', + name: 'GLM', + wire: 'openai-chat', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + apiKey: 'sk-test', + defaultModel: 'glm-4.6', + setAsActive: true, +} as const; + +describe('parseAddProviderPayload discovery mode', () => { + it('omits modelDiscoveryMode when the field is absent', () => { + const parsed = parseAddProviderPayload(addBase); + expect(parsed.modelDiscoveryMode).toBeUndefined(); + }); + + it('accepts declared discovery modes', () => { + expect( + parseAddProviderPayload({ ...addBase, modelDiscoveryMode: 'infer-only' }).modelDiscoveryMode, + ).toBe('infer-only'); + expect( + parseAddProviderPayload({ ...addBase, modelDiscoveryMode: 'manual' }).modelDiscoveryMode, + ).toBe('manual'); + expect( + parseAddProviderPayload({ ...addBase, modelDiscoveryMode: 'static-hint' }).modelDiscoveryMode, + ).toBe('static-hint'); + expect( + parseAddProviderPayload({ ...addBase, modelDiscoveryMode: 'models' }).modelDiscoveryMode, + ).toBe('models'); + }); + + it('rejects unknown discovery modes', () => { + expect(() => parseAddProviderPayload({ ...addBase, modelDiscoveryMode: 'catalog' })).toThrow( + CodesignError, + ); + }); +}); + +describe('parseUpdateProviderPayload discovery mode', () => { + it('accepts an optional modelDiscoveryMode on update', () => { + expect(parseUpdateProviderPayload({ id: 'custom-glm', modelDiscoveryMode: 'manual' })).toEqual({ + id: 'custom-glm', + modelDiscoveryMode: 'manual', + }); + }); + + it('rejects unknown discovery modes on update', () => { + expect(() => + parseUpdateProviderPayload({ id: 'custom-glm', modelDiscoveryMode: 'guess' }), + ).toThrow(/Unsupported modelDiscoveryMode/); + }); +}); diff --git a/apps/desktop/src/main/onboarding/provider-parsers.ts b/apps/desktop/src/main/onboarding/provider-parsers.ts index 827cf8020..f355feae0 100644 --- a/apps/desktop/src/main/onboarding/provider-parsers.ts +++ b/apps/desktop/src/main/onboarding/provider-parsers.ts @@ -3,6 +3,8 @@ import { CodesignError, ERROR_CODES, isSupportedOnboardingProvider, + type ProviderModelDiscoveryMode, + ProviderModelDiscoveryModeSchema, type ReasoningLevel, ReasoningLevelSchema, type SupportedOnboardingProvider, @@ -41,6 +43,7 @@ export interface AddCustomProviderInput { /** Per-provider TLS verification opt-out (#229). Built-in providers * force-ignore this flag at runtime. */ tlsRejectUnauthorized?: boolean; + modelDiscoveryMode?: ProviderModelDiscoveryMode; setAsActive: boolean; } @@ -61,6 +64,7 @@ export interface UpdateProviderInput { /** Tri-state: `true`/`false` writes the field; `null` clears it back to * the default (strict TLS); `undefined` leaves the existing value alone. */ tlsRejectUnauthorized?: boolean | null; + modelDiscoveryMode?: ProviderModelDiscoveryMode; } const SAVE_KEY_FIELDS = ['provider', 'apiKey', 'modelPrimary', 'baseUrl'] as const; @@ -77,6 +81,7 @@ const ADD_PROVIDER_FIELDS = [ 'queryParams', 'envKey', 'tlsRejectUnauthorized', + 'modelDiscoveryMode', 'setAsActive', ] as const; const UPDATE_PROVIDER_FIELDS = [ @@ -91,6 +96,7 @@ const UPDATE_PROVIDER_FIELDS = [ 'apiKey', 'requiresApiKey', 'tlsRejectUnauthorized', + 'modelDiscoveryMode', ] as const; function assertKnownFields( @@ -152,6 +158,18 @@ function validOptionalUrl(value: unknown, field: string): string | undefined { return validUrl(value, field); } +function parseOptionalDiscoveryMode(value: unknown): ProviderModelDiscoveryMode | undefined { + if (value === undefined) return undefined; + const parsed = ProviderModelDiscoveryModeSchema.safeParse(value); + if (!parsed.success) { + throw new CodesignError( + `Unsupported modelDiscoveryMode: ${String(value)}`, + ERROR_CODES.IPC_BAD_INPUT, + ); + } + return parsed.data; +} + function validRequiredUrl(value: unknown, field: string): string { if (typeof value !== 'string' || value.trim().length === 0) { throw new CodesignError(`${field} must be a non-empty string`, ERROR_CODES.IPC_BAD_INPUT); @@ -331,6 +349,8 @@ export function parseAddProviderPayload(raw: unknown): AddCustomProviderInput { } out.tlsRejectUnauthorized = r['tlsRejectUnauthorized']; } + const discoveryMode = parseOptionalDiscoveryMode(r['modelDiscoveryMode']); + if (discoveryMode !== undefined) out.modelDiscoveryMode = discoveryMode; return out; } @@ -413,5 +433,7 @@ export function parseUpdateProviderPayload(raw: unknown): UpdateProviderInput { } out.tlsRejectUnauthorized = r['tlsRejectUnauthorized']; } + const discoveryMode = parseOptionalDiscoveryMode(r['modelDiscoveryMode']); + if (discoveryMode !== undefined) out.modelDiscoveryMode = discoveryMode; return out; } diff --git a/apps/desktop/src/main/onboarding/providers-crud.ts b/apps/desktop/src/main/onboarding/providers-crud.ts index 3e8dc0bff..3dd7cee8e 100644 --- a/apps/desktop/src/main/onboarding/providers-crud.ts +++ b/apps/desktop/src/main/onboarding/providers-crud.ts @@ -6,6 +6,7 @@ import { ERROR_CODES, hydrateConfig, isSupportedOnboardingProvider, + mergeDiscoveryMode, modelsEndpointUrl, type OnboardingState, type ProviderEntry, @@ -228,6 +229,9 @@ export async function runAddCustomProvider( ...(input.queryParams !== undefined ? { queryParams: input.queryParams } : {}), ...(input.envKey !== undefined ? { envKey: input.envKey } : {}), ...(input.tlsRejectUnauthorized === true ? { tlsRejectUnauthorized: true } : {}), + ...(input.modelDiscoveryMode !== undefined + ? { capabilities: mergeDiscoveryMode(undefined, input.modelDiscoveryMode) } + : {}), }; const nextProviders = { ...(cachedConfig?.providers ?? {}), [entry.id]: entry }; const nextSecrets = { ...(cachedConfig?.secrets ?? {}) }; @@ -318,6 +322,9 @@ export async function runUpdateProvider(input: UpdateProviderInput): Promise { expect(anthropicRow?.maskedKey).toMatch(/sk-.*\*{3}/); expect(anthropicRow?.isActive).toBe(true); expect(anthropicRow?.hasKey).toBe(true); + expect(anthropicRow?.modelDiscoveryMode).toBe('models'); }); it('surfaces keyless providers as rows with hasKey:false', () => { @@ -173,8 +174,36 @@ describe('toProviderRows', () => { label: 'Ollama (local)', hasKey: true, maskedKey: '', + modelDiscoveryMode: 'models', }); }); + + it('stamps infer-only from stored capabilities without remote listing', () => { + const cfg = makeCfg({ + provider: 'glm', + modelPrimary: 'glm-4.6', + secrets: { glm: { ciphertext: 'enc' } }, + providers: { + glm: { + id: 'glm', + name: 'GLM', + builtin: false, + wire: 'openai-chat', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + defaultModel: 'glm-4.6', + capabilities: { + supportsModelsEndpoint: false, + modelDiscoveryMode: 'infer-only', + }, + }, + }, + }); + + const rows = toProviderRows(cfg, () => 'sk-test-token-1234567890'); + const glmRow = rows.find((row) => row.provider === 'glm'); + expect(glmRow?.modelDiscoveryMode).toBe('infer-only'); + expect(glmRow?.modelsHint).toBeUndefined(); + }); }); describe('assertProviderHasStoredSecret', () => { diff --git a/apps/desktop/src/main/provider-settings.ts b/apps/desktop/src/main/provider-settings.ts index 385c7698d..20b839038 100644 --- a/apps/desktop/src/main/provider-settings.ts +++ b/apps/desktop/src/main/provider-settings.ts @@ -7,6 +7,7 @@ import { type ModelRef, PROVIDER_SHORTLIST, type ProviderEntry, + type ProviderModelDiscoveryMode, type ReasoningLevel, resolveProviderCapabilities, type WireApi, @@ -33,6 +34,11 @@ export interface ProviderRow { /** Per-provider TLS verification opt-out (#229). Only surfaced for * custom / imported providers; the runtime force-ignores it on built-ins. */ tlsRejectUnauthorized?: boolean; + /** How this provider discovers model IDs. Drives Settings picker UX and + * whether `models:v1:list-for-provider` hits GET /models. */ + modelDiscoveryMode: ProviderModelDiscoveryMode; + /** Static catalog used when `modelDiscoveryMode` is `static-hint`. */ + modelsHint?: string[]; error?: 'decryption_failed' | string; } @@ -162,6 +168,11 @@ export function toProviderRows( // declare keyless mode in their ProviderEntry/capabilities. hasKey: ref !== undefined || isKeylessProviderAllowed(provider, entry), requiresApiKey: !isKeylessProviderAllowed(provider, entry), + modelDiscoveryMode: resolveProviderCapabilities(provider, entry ?? { wire: 'openai-chat' }) + .modelDiscoveryMode, + ...(entry?.modelsHint !== undefined && entry.modelsHint.length > 0 + ? { modelsHint: entry.modelsHint } + : {}), ...(entry?.reasoningLevel !== undefined ? { reasoningLevel: entry.reasoningLevel } : {}), ...(entry?.tlsRejectUnauthorized === true ? { tlsRejectUnauthorized: true } : {}), ...(rowError !== undefined ? { error: rowError } : {}), diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 2a6e40577..a54d3f97b 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -22,6 +22,7 @@ import type { ModelRef, OnboardingState, PreviewMode, + ProviderModelDiscoveryMode, ReasoningLevel, ReportEventInput, ReportEventResult, @@ -215,6 +216,8 @@ export interface ProviderRow { * Built-in providers force-ignore this flag at runtime; only surfaced * for custom/imported providers. See #229. */ tlsRejectUnauthorized?: boolean; + modelDiscoveryMode: ProviderModelDiscoveryMode; + modelsHint?: string[]; error?: 'decryption_failed' | string; } @@ -581,6 +584,7 @@ const api = { queryParams?: Record; envKey?: string; tlsRejectUnauthorized?: boolean; + modelDiscoveryMode?: ProviderModelDiscoveryMode; setAsActive: boolean; }) => ipcRenderer.invoke('config:v1:add-provider', input) as Promise, updateProvider: (input: { @@ -601,6 +605,7 @@ const api = { /** Per-provider TLS verification opt-out (#229). Omit to leave * untouched; `false`/`true` writes the field through. */ tlsRejectUnauthorized?: boolean; + modelDiscoveryMode?: ProviderModelDiscoveryMode; }) => ipcRenderer.invoke('config:v1:update-provider', input) as Promise, removeProvider: (id: string) => ipcRenderer.invoke('config:v1:remove-provider', id) as Promise, diff --git a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx index 027bbcbea..b67c4f263 100644 --- a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx +++ b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx @@ -1,5 +1,11 @@ import { useT } from '@open-codesign/i18n'; -import { canonicalBaseUrl, detectWireFromBaseUrl, type WireApi } from '@open-codesign/shared'; +import { + canonicalBaseUrl, + detectWireFromBaseUrl, + discoveryModeForCustomProvider, + type ProviderModelDiscoveryMode, + type WireApi, +} from '@open-codesign/shared'; import { Button } from '@open-codesign/ui'; import { AlertCircle, Check, CheckCircle, Loader2, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; @@ -43,6 +49,7 @@ interface Props { /** Existing per-provider TLS verification opt-out, so the checkbox can * start in the right state when re-opening Edit. */ tlsRejectUnauthorized?: boolean; + modelDiscoveryMode?: ProviderModelDiscoveryMode; }; } @@ -50,6 +57,7 @@ type TestState = | { kind: 'idle' } | { kind: 'testing' } | { kind: 'ok'; modelCount: number } + | { kind: 'listing-unavailable' } | { kind: 'error'; message: string }; type DiscoveryState = @@ -168,7 +176,10 @@ export function AddCustomProviderModal({ const [discovery, setDiscovery] = useState({ kind: 'idle' }); // When true, user explicitly chose to type a model name instead of picking from the dropdown. - const [manualModel, setManualModel] = useState(false); + const [manualModel, setManualModel] = useState( + editTarget?.modelDiscoveryMode === 'manual' || editTarget?.modelDiscoveryMode === 'infer-only', + ); + const discoveryTouched = useRef(!isEdit); // Track whether user has explicitly typed/picked a model so auto-pick doesn't override it. const userPickedModel = useRef(defaultModel.trim().length > 0); @@ -195,6 +206,7 @@ export function AddCustomProviderModal({ return; } debounceTimer.current = setTimeout(() => { + discoveryTouched.current = true; void runDiscovery(currentBaseUrl, currentWire, privateNetworkAllowed, keyRequired); }, 500); } @@ -320,6 +332,10 @@ export function AddCustomProviderModal({ setDefaultModel(pickBestModel(res.models)); } } + } else if (res.error === 'not-a-model-endpoint') { + discoveryTouched.current = true; + setDiscovery((current) => (current.kind === 'found' ? current : { kind: 'failed' })); + setTest({ kind: 'listing-unavailable' }); } else setTest({ kind: 'error', message: res.message }); } catch (err) { if (seq === discoverySeq.current) { @@ -357,6 +373,12 @@ export function AddCustomProviderModal({ update.tlsRejectUnauthorized = !!tlsRejectUnauthorized; } } + if (discoveryTouched.current) { + update.modelDiscoveryMode = discoveryModeForCustomProvider({ + discoveryKind: discovery.kind, + manualModel, + }); + } await window.codesign.config.updateProvider(update); } else { const slug = slugify(name); @@ -369,6 +391,10 @@ export function AddCustomProviderModal({ ...buildProviderAuthPayload(requiresApiKey, apiKey), defaultModel: defaultModel.trim(), setAsActive: initialSetAsActive, + modelDiscoveryMode: discoveryModeForCustomProvider({ + discoveryKind: discovery.kind, + manualModel, + }), ...(tlsRejectUnauthorized ? { tlsRejectUnauthorized: true } : {}), }); } @@ -604,7 +630,10 @@ export function AddCustomProviderModal({ + + ) : ( + setPrimary(e.target.value)} + onBlur={() => { + const next = primary.trim(); + const currentSaved = isActive + ? (config.modelPrimary ?? row.defaultModel ?? '') + : (row.defaultModel ?? ''); + if (next.length > 0 && next !== currentSaved) handleChange(next); + }} + onKeyDown={(e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + const next = primary.trim(); + if (next.length > 0) handleChange(next); + }} + placeholder={t('settings.providers.discovery.enterModelId')} + className="h-6 min-w-[12rem] px-2 rounded-[var(--radius-sm)] bg-[var(--color-surface)] border border-[var(--color-border)] font-mono text-[var(--text-xs)] text-[var(--color-text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]" + aria-label={t('settings.providers.editModel')} + /> + )} + + {hintKey !== null && ( +

{t(hintKey)}

)} ); diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index c59c5b23c..c15c24337 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -499,6 +499,7 @@ "discoveryFailed": "Could not auto-discover models", "test": "Test connection", "testOk": "OK — {{count}} models available", + "testOkNoListing": "No model listing — enter a model ID. Inference can still work.", "save": "Save & continue", "saveEdit": "Save changes" }, @@ -591,8 +592,15 @@ "reasoningSaved": "Reasoning depth saved", "reasoningSaveFailed": "Failed to save reasoning depth", "connectionOk": "Connection OK", + "connectionOkDegraded": "Connected — model listing is unavailable; inference still works", "connectionFailed": "Connection failed" }, + "discovery": { + "manualHint": "Model IDs are entered manually for this provider.", + "inferOnlyHint": "This provider does not list models — enter a model ID. Inference can still work.", + "staticHint": "Known models for this provider. You can still enter another ID.", + "enterModelId": "model-id" + }, "cliProxyApi": { "presetName": "CLIProxyAPI", "presetDescription": "Local proxy that wraps Claude/Codex/Gemini OAuth subscriptions", @@ -1102,6 +1110,7 @@ "corsError": "CORS error (should not happen in main process). This is a bug.", "sslError": "SSL / certificate error (self-signed or private-CA cert?). Enable Settings → Providers → → Disable TLS verification to connect to trusted internal endpoints.", "endpointNotFound": "The endpoint path exists in the Base URL but the provider did not expose this route.", + "modelsListingNotApplicable": "This provider does not expose a model listing endpoint. That is expected — enter a model ID and use inference instead of treating missing /models as a hard failure.", "gatewayIncompatible": "The gateway accepted the connection but does not implement this provider's API. Try switching wire (e.g. openai-chat).", "gatewayWafBlocked": "The gateway or reverse proxy blocked the generation request before it reached the model. Test Connection can still pass because it only probes the /models endpoint.", "openaiResponsesMisconfigured": "The endpoint rejected the request shape. The wire may be wrong — try switching to openai-chat.", diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index b9140674b..8eb2bc258 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -420,6 +420,7 @@ "discoveryFailed": "No se pudieron auto-descubrir los modelos", "test": "Probar conexión", "testOk": "OK — {{count}} modelos disponibles", + "testOkNoListing": "Sin listado de modelos — introduce un ID. La inferencia puede seguir funcionando.", "save": "Guardar y continuar", "saveEdit": "Guardar cambios" }, @@ -512,8 +513,15 @@ "reasoningSaved": "Profundidad de razonamiento guardada", "reasoningSaveFailed": "Error al guardar la profundidad de razonamiento", "connectionOk": "Conexión OK", + "connectionOkDegraded": "Conectado — no hay listado de modelos; la inferencia sigue funcionando", "connectionFailed": "Conexión fallida" }, + "discovery": { + "manualHint": "Los ID de modelo se introducen manualmente para este proveedor.", + "inferOnlyHint": "Este proveedor no lista modelos — introduce un ID. La inferencia puede seguir funcionando.", + "staticHint": "Modelos conocidos de este proveedor. Aún puedes introducir otro ID.", + "enterModelId": "model-id" + }, "cliProxyApi": { "presetName": "CLIProxyAPI", "presetDescription": "Proxy local que envuelve suscripciones OAuth de Claude/Codex/Gemini", @@ -1020,6 +1028,7 @@ "timedOut": "Tiempo de espera de la solicitud agotado — revisa el cortafuegos o VPN.", "corsError": "Error de CORS (no debería pasar en el proceso principal). Esto es un error.", "sslError": "Error de SSL / certificado (¿certificado autofirmado o CA privada?). Actívalo en Ajustes → Proveedores → este proveedor → Desactivar verificación TLS para conectarte a endpoints internos de confianza.", + "modelsListingNotApplicable": "Este proveedor no expone un endpoint de listado de modelos. Es esperado: introduce un ID y usa la inferencia en lugar de tratar la ausencia de /models como un fallo duro.", "gatewayIncompatible": "La pasarela aceptó la conexión pero no implementa la API de este proveedor. Intenta cambiar de protocolo (ej. openai-chat).", "gatewayWafBlocked": "La pasarela o el proxy inverso bloqueó la generación antes de llegar al modelo. Test Connection puede pasar porque solo prueba el endpoint /models.", "openaiResponsesMisconfigured": "El punto final rechazó la forma de la solicitud. El protocolo puede ser incorrecto — intenta cambiar a openai-chat.", diff --git a/packages/i18n/src/locales/pt-BR.json b/packages/i18n/src/locales/pt-BR.json index 9beab78f4..81e060ece 100644 --- a/packages/i18n/src/locales/pt-BR.json +++ b/packages/i18n/src/locales/pt-BR.json @@ -383,6 +383,7 @@ "compatibilityHintBody": "Alguns coding plans, relays e gateways compatíveis com OpenAI só permitem clientes específicos, como Claude Code, openclaw ou Hermes. Mesmo que a API pareça compatível, o Open CoDesign ainda pode ser bloqueado por uma lista de apps permitidos.", "test": "Testar conexão", "testOk": "OK — {{count}} modelos disponíveis", + "testOkNoListing": "Sem listagem de modelos — informe um ID. A inferência ainda pode funcionar.", "save": "Salvar e continuar", "saveEdit": "Salvar alterações", "switchToDropdown": "Escolher da lista", @@ -476,9 +477,16 @@ "reasoningSaved": "Profundidade de raciocínio salva", "reasoningSaveFailed": "Falha ao salvar a profundidade de raciocínio", "connectionOk": "Conexão OK", + "connectionOkDegraded": "Conectado — sem listagem de modelos; a inferência ainda funciona", "connectionFailed": "Falha na conexão", "saveFailed": "Falha ao salvar o provedor" }, + "discovery": { + "manualHint": "Os IDs de modelo são informados manualmente neste provedor.", + "inferOnlyHint": "Este provedor não lista modelos — informe um ID. A inferência ainda pode funcionar.", + "staticHint": "Modelos conhecidos deste provedor. Você ainda pode informar outro ID.", + "enterModelId": "model-id" + }, "reasoning": { "label": "Profundidade de raciocínio", "default": "Padrão (automático)", @@ -982,6 +990,7 @@ "corsError": "Erro de CORS (não deveria acontecer no processo principal). Isso é um bug.", "sslError": "Erro de SSL / certificado (certificado autoassinado ou CA privada?). Ative em Configurações → Provedores → este provedor → Desativar verificação TLS para conectar a endpoints internos confiáveis.", "endpointNotFound": "A URL base já contém um caminho de versão, mas o provedor não expôs esta rota.", + "modelsListingNotApplicable": "Este provedor não expõe um endpoint de listagem de modelos. Isso é esperado — informe um ID e use a inferência em vez de tratar a ausência de /models como falha grave.", "gatewayIncompatible": "O gateway aceitou a conexão, mas não implementa a API deste provedor. Tente trocar o wire, por exemplo para openai-chat.", "gatewayWafBlocked": "O gateway ou proxy reverso bloqueou a geração antes de ela chegar ao modelo. Test Connection ainda pode passar porque só verifica o endpoint /models.", "openaiResponsesMisconfigured": "O endpoint rejeitou o formato da requisição. O wire pode estar errado; tente openai-chat.", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index e0d4fdb65..88d077d81 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -499,6 +499,7 @@ "discoveryFailed": "无法自动发现模型", "test": "测试连接", "testOk": "正常 — 共 {{count}} 个模型", + "testOkNoListing": "没有模型列表 — 请手动输入模型 ID。推理接口仍可工作。", "save": "保存并继续", "saveEdit": "保存修改" }, @@ -591,8 +592,15 @@ "reasoningSaved": "已保存推理深度", "reasoningSaveFailed": "保存推理深度失败", "connectionOk": "连接正常", + "connectionOkDegraded": "已连接 — 无法列出模型,但推理接口可用", "connectionFailed": "连接失败" }, + "discovery": { + "manualHint": "此服务商需要手动输入模型 ID。", + "inferOnlyHint": "此服务商不提供模型列表 — 请输入模型 ID。推理接口仍可工作。", + "staticHint": "此服务商的已知模型。仍可手动输入其他 ID。", + "enterModelId": "model-id" + }, "cliProxyApi": { "presetName": "CLIProxyAPI", "presetDescription": "本地反代,将 Claude/Codex/Gemini 的订阅账号包装成统一 API", @@ -1098,6 +1106,7 @@ "corsError": "CORS 跨域错误(主进程中不应出现此错误,这是一个 Bug)。", "sslError": "SSL / 证书错误(自签名或私有 CA 证书?)。可在「设置 → 服务商 → 该条目 → 禁用 TLS 校验」启用对可信内网端点的连接。", "endpointNotFound": "Base URL 已包含版本路径,但 Provider 没有暴露这个接口路径。", + "modelsListingNotApplicable": "此服务商不提供模型列表接口。这是预期行为 — 请输入模型 ID 并通过推理验证,不要把缺少 /models 当成硬失败。", "gatewayIncompatible": "网关接受了连接但没有实现该 Provider 的 API。尝试切换 wire(例如改为 openai-chat)。", "gatewayWafBlocked": "网关或反代在请求到达模型前拦截了生成请求。测试连接可能仍然通过,因为它只探测 /models 端点。", "openaiResponsesMisconfigured": "端点拒绝了请求格式。wire 可能配错了——尝试切换到 openai-chat。", diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index 40db9e452..97645d22f 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -522,6 +522,57 @@ describe('provider capability helpers', () => { expect(caps.supportsModelsEndpoint).toBe(false); expect(caps.modelDiscoveryMode).toBe('manual'); }); + + it('accepts infer-only as a declared discovery mode', () => { + const parsed = ConfigV3Schema.parse({ + version: 3, + activeProvider: 'glm', + activeModel: 'glm-4.6', + secrets: {}, + providers: { + glm: { + id: 'glm', + name: 'GLM', + builtin: false, + wire: 'openai-chat', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + defaultModel: 'glm-4.6', + capabilities: { + supportsModelsEndpoint: false, + modelDiscoveryMode: 'infer-only', + }, + }, + }, + }); + expect(parsed.providers['glm']?.capabilities?.modelDiscoveryMode).toBe('infer-only'); + }); + + it('derives infer-only when listing is explicitly disabled without a hint', () => { + const caps = resolveProviderCapabilities('custom-proxy', { + wire: 'openai-chat', + capabilities: { supportsModelsEndpoint: false }, + }); + expect(caps.modelDiscoveryMode).toBe('infer-only'); + expect(caps.supportsModelsEndpoint).toBe(false); + }); + + it('derives static-hint for openai-codex-responses even without modelsHint', () => { + const caps = defaultProviderCapabilities('chatgpt-codex', { + wire: 'openai-codex-responses', + requiresApiKey: false, + }); + expect(caps.modelDiscoveryMode).toBe('static-hint'); + expect(caps.supportsModelsEndpoint).toBe(false); + }); + + it('syncs supportsModelsEndpoint when only modelDiscoveryMode is overridden', () => { + const caps = resolveProviderCapabilities('custom-lite', { + wire: 'openai-chat', + capabilities: { modelDiscoveryMode: 'infer-only' }, + }); + expect(caps.modelDiscoveryMode).toBe('infer-only'); + expect(caps.supportsModelsEndpoint).toBe(false); + }); }); it('round-trips opt-in web search settings and keeps Tavily in the existing secrets map', () => { diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index dbc740776..b384fda1f 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -104,7 +104,12 @@ export type StoredDesignSystem = z.infer; export const ReasoningLevelSchema = z.enum(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']); export type ReasoningLevel = z.infer; -export const ProviderModelDiscoveryModeSchema = z.enum(['models', 'static-hint', 'manual']); +export const ProviderModelDiscoveryModeSchema = z.enum([ + 'models', + 'static-hint', + 'manual', + 'infer-only', +]); export type ProviderModelDiscoveryMode = z.infer; export const ProviderCapabilitiesSchema = z @@ -196,7 +201,7 @@ export const ProviderEntrySchema = z .strict(); export type ProviderEntry = z.infer; -interface ProviderCapabilityInput { +export interface ProviderCapabilityInput { wire: WireApi; requiresApiKey?: boolean | undefined; modelsHint?: string[] | undefined; @@ -204,33 +209,65 @@ interface ProviderCapabilityInput { capabilities?: ProviderCapabilities | undefined; } +export interface ResolvedProviderCapabilities { + supportsKeyless: boolean; + supportsModelsEndpoint: boolean; + supportsReasoning: boolean; + requiresClaudeCodeIdentity: boolean; + modelDiscoveryMode: ProviderModelDiscoveryMode; +} + +export function deriveModelDiscoveryMode( + entry: ProviderCapabilityInput, +): ProviderModelDiscoveryMode { + if (entry.wire === 'openai-codex-responses') return 'static-hint'; + if (entry.modelsHint !== undefined && entry.modelsHint.length > 0) return 'static-hint'; + return 'models'; +} + +function resolveDeclaredDiscoveryMode( + defaults: ResolvedProviderCapabilities, + explicit: ProviderCapabilities, +): ProviderModelDiscoveryMode { + if (explicit.modelDiscoveryMode !== undefined) return explicit.modelDiscoveryMode; + if (explicit.supportsModelsEndpoint === false && defaults.modelDiscoveryMode === 'models') { + return 'infer-only'; + } + return defaults.modelDiscoveryMode; +} + export function defaultProviderCapabilities( _providerId: string, entry: ProviderCapabilityInput, -): Required { - const supportsModelsEndpoint = - entry.wire !== 'openai-codex-responses' && entry.modelsHint === undefined; +): ResolvedProviderCapabilities { + const modelDiscoveryMode = deriveModelDiscoveryMode(entry); return { supportsKeyless: entry.requiresApiKey === false, - supportsModelsEndpoint, + supportsModelsEndpoint: modelDiscoveryMode === 'models', supportsReasoning: (entry.reasoningLevel !== undefined && entry.reasoningLevel !== 'off') || entry.wire === 'anthropic' || entry.wire === 'openai-responses' || entry.wire === 'openai-codex-responses', requiresClaudeCodeIdentity: false, - modelDiscoveryMode: - entry.modelsHint !== undefined ? 'static-hint' : supportsModelsEndpoint ? 'models' : 'manual', + modelDiscoveryMode, }; } export function resolveProviderCapabilities( providerId: string, entry: ProviderCapabilityInput, -): Required { +): ResolvedProviderCapabilities { + const defaults = defaultProviderCapabilities(providerId, entry); + const explicit = entry.capabilities ?? {}; + const modelDiscoveryMode = resolveDeclaredDiscoveryMode(defaults, explicit); return { - ...defaultProviderCapabilities(providerId, entry), - ...(entry.capabilities ?? {}), + supportsKeyless: explicit.supportsKeyless ?? defaults.supportsKeyless, + supportsModelsEndpoint: modelDiscoveryMode === 'models', + supportsReasoning: explicit.supportsReasoning ?? defaults.supportsReasoning, + requiresClaudeCodeIdentity: + explicit.requiresClaudeCodeIdentity ?? defaults.requiresClaudeCodeIdentity, + modelDiscoveryMode, }; } diff --git a/packages/shared/src/diagnostics.test.ts b/packages/shared/src/diagnostics.test.ts index f6afc5fd7..0459c5681 100644 --- a/packages/shared/src/diagnostics.test.ts +++ b/packages/shared/src/diagnostics.test.ts @@ -67,6 +67,30 @@ describe('diagnose', () => { expect(result[0]?.suggestedFix).toBeUndefined(); }); + it('404 on /models is listing-not-applicable when discovery mode does not expect listing', () => { + const result = diagnose('404', { + ...baseCtx, + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + attemptedUrl: 'https://open.bigmodel.cn/api/paas/v4/models', + modelDiscoveryMode: 'infer-only', + }); + expect(result[0]?.cause).toBe('diagnostics.cause.modelsListingNotApplicable'); + expect(result[0]?.category).toBe('model-discovery-degraded'); + expect(result[0]?.severity).toBe('info'); + expect(result[0]?.suggestedFix).toBeUndefined(); + }); + + it('404 on an inference URL is still an endpoint failure even for infer-only providers', () => { + const result = diagnose('404', { + ...baseCtx, + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + attemptedUrl: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', + modelDiscoveryMode: 'infer-only', + }); + expect(result[0]?.cause).toBe('diagnostics.cause.endpointNotFound'); + expect(result[0]?.category).toBe('endpoint-not-found'); + }); + it('404 classifies endpoint-not-found when baseUrl already has /v1 (e.g. Cloudflare Workers AI)', () => { const result = diagnose('404', { ...baseCtx, diff --git a/packages/shared/src/diagnostics.ts b/packages/shared/src/diagnostics.ts index bf3008f05..63501f70d 100644 --- a/packages/shared/src/diagnostics.ts +++ b/packages/shared/src/diagnostics.ts @@ -1,4 +1,5 @@ -import type { ReasoningLevel, WireApi } from './config'; +import type { ProviderModelDiscoveryMode, ReasoningLevel, WireApi } from './config'; +import { looksLikeModelsListingUrl, usesRemoteModelsListing } from './model-discovery'; export type ErrorCode = | '401' @@ -90,6 +91,12 @@ export interface DiagnoseContext { status?: number; /** Raw attempted URL, if available */ attemptedUrl?: string; + /** + * Declared listing strategy for this provider. When the mode does not + * expect GET /models, a 404 on that route is a listing limitation, not a + * uniform hard failure. + */ + modelDiscoveryMode?: ProviderModelDiscoveryMode; } const BILLING_URLS: Record = { @@ -153,6 +160,19 @@ export function diagnose(code: ErrorCode, ctx: DiagnoseContext): DiagnosticHypot } if (normalised === '404') { + if ( + ctx.modelDiscoveryMode !== undefined && + !usesRemoteModelsListing(ctx.modelDiscoveryMode) && + looksLikeModelsListingUrl(ctx.attemptedUrl) + ) { + return [ + h({ + cause: 'diagnostics.cause.modelsListingNotApplicable', + category: 'model-discovery-degraded', + severity: 'info', + }), + ]; + } // If the baseUrl already encodes a version segment (/v1, /v4, /v1beta, // etc.), suggesting "add /v1" is wrong — Zhipu GLM uses /v4, AI Studio // uses /v1beta, and some Cloudflare Workers AI gateways already carry diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fb238d618..bd880998e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -312,10 +312,12 @@ export type { ImageGenerationSize, OnboardingState, ProviderCapabilities, + ProviderCapabilityInput, ProviderEntry, ProviderModelDiscoveryMode, ProviderShortlist, ReasoningLevel, + ResolvedProviderCapabilities, SupportedOnboardingProvider, WireApi, } from './config'; @@ -325,6 +327,7 @@ export { ConfigSchema, ConfigV3Schema, defaultProviderCapabilities, + deriveModelDiscoveryMode, detectWireFromBaseUrl, hydrateConfig, IMAGE_GENERATION_SCHEMA_VERSION, @@ -377,6 +380,26 @@ export { ERROR_CODE_DESCRIPTIONS, ERROR_CODES } from './error-codes'; // from this barrel — it's main-process only. Import from // '@open-codesign/shared/fingerprint' directly. export type { FingerprintInput } from './fingerprint'; +export type { + ImportedProviderSource, + ListForProviderPlan, + ModelsListSource, + SettingsModelPickerKind, +} from './model-discovery'; +export { + capabilitiesForDiscoveryMode, + capabilitiesForImportedProvider, + connectionTestProbesModelsEndpoint, + discoveryModeForCustomProvider, + discoveryModeForImport, + isDefaultAnthropicApiHost, + localModelsForDiscoveryMode, + looksLikeModelsListingUrl, + mergeDiscoveryMode, + resolveListForProviderPlan, + settingsModelPickerKind, + usesRemoteModelsListing, +} from './model-discovery'; export type { ProxyPresetId } from './proxy-presets'; export { getPresetById, diff --git a/packages/shared/src/model-discovery.test.ts b/packages/shared/src/model-discovery.test.ts new file mode 100644 index 000000000..6574523e5 --- /dev/null +++ b/packages/shared/src/model-discovery.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import { + capabilitiesForDiscoveryMode, + capabilitiesForImportedProvider, + connectionTestProbesModelsEndpoint, + discoveryModeForCustomProvider, + discoveryModeForImport, + localModelsForDiscoveryMode, + looksLikeModelsListingUrl, + mergeDiscoveryMode, + resolveListForProviderPlan, + settingsModelPickerKind, + usesRemoteModelsListing, +} from './model-discovery'; + +describe('discovery mode helpers', () => { + it('only models mode uses a remote listing endpoint', () => { + expect(usesRemoteModelsListing('models')).toBe(true); + expect(usesRemoteModelsListing('static-hint')).toBe(false); + expect(usesRemoteModelsListing('manual')).toBe(false); + expect(usesRemoteModelsListing('infer-only')).toBe(false); + expect(connectionTestProbesModelsEndpoint('models')).toBe(true); + expect(connectionTestProbesModelsEndpoint('infer-only')).toBe(false); + }); + + it('maps picker UX to select vs manual without forcing remote discover', () => { + expect(settingsModelPickerKind('models')).toBe('select'); + expect(settingsModelPickerKind('static-hint')).toBe('select'); + expect(settingsModelPickerKind('manual')).toBe('manual'); + expect(settingsModelPickerKind('infer-only')).toBe('manual'); + }); + + it('keeps supportsModelsEndpoint in lockstep with the declared mode', () => { + expect(capabilitiesForDiscoveryMode('models').supportsModelsEndpoint).toBe(true); + expect(capabilitiesForDiscoveryMode('infer-only').supportsModelsEndpoint).toBe(false); + expect(capabilitiesForDiscoveryMode('manual').modelDiscoveryMode).toBe('manual'); + expect(mergeDiscoveryMode({ supportsReasoning: true }, 'infer-only')).toEqual({ + supportsReasoning: true, + modelDiscoveryMode: 'infer-only', + supportsModelsEndpoint: false, + }); + }); +}); + +describe('localModelsForDiscoveryMode', () => { + it('returns null for remote listing so callers fetch /models', () => { + expect( + localModelsForDiscoveryMode('models', { defaultModel: 'gpt-4o', modelsHint: ['gpt-4.1'] }), + ).toBeNull(); + }); + + it('returns the static catalog plus defaultModel when missing from the hint', () => { + expect( + localModelsForDiscoveryMode('static-hint', { + defaultModel: 'gpt-5.5', + modelsHint: ['gpt-5.4', 'gpt-5.4-mini'], + }), + ).toEqual(['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.5']); + }); + + it('seeds infer-only and manual from defaultModel without a remote catalog', () => { + expect(localModelsForDiscoveryMode('infer-only', { defaultModel: 'glm-4.6' })).toEqual([ + 'glm-4.6', + ]); + expect( + localModelsForDiscoveryMode('manual', { + defaultModel: 'custom-model', + modelsHint: ['custom-model', 'other'], + }), + ).toEqual(['custom-model', 'other']); + }); +}); + +describe('resolveListForProviderPlan', () => { + it('fetches remotely when the provider declares models listing', () => { + expect( + resolveListForProviderPlan('openai', { + wire: 'openai-chat', + defaultModel: 'gpt-4o', + capabilities: { modelDiscoveryMode: 'models' }, + }), + ).toEqual({ action: 'fetch-remote', discoveryMode: 'models', source: 'remote' }); + }); + + it('returns the static hint without hitting /models', () => { + expect( + resolveListForProviderPlan('chatgpt-codex', { + wire: 'openai-codex-responses', + defaultModel: 'gpt-5.5', + modelsHint: ['gpt-5.5', 'gpt-5.4'], + requiresApiKey: false, + }), + ).toEqual({ + action: 'return', + models: ['gpt-5.5', 'gpt-5.4'], + discoveryMode: 'static-hint', + source: 'static-hint', + }); + }); + + it('returns the imported default for infer-only providers', () => { + expect( + resolveListForProviderPlan('codex-coproxy', { + wire: 'openai-responses', + defaultModel: 'gpt-5.5', + capabilities: { modelDiscoveryMode: 'infer-only', supportsModelsEndpoint: false }, + }), + ).toEqual({ + action: 'return', + models: ['gpt-5.5'], + discoveryMode: 'infer-only', + source: 'local', + }); + }); + + it('returns the typed default for manual providers', () => { + expect( + resolveListForProviderPlan('custom-lite', { + wire: 'openai-chat', + defaultModel: 'gpt-4.1', + capabilities: { modelDiscoveryMode: 'manual', supportsModelsEndpoint: false }, + }), + ).toMatchObject({ + action: 'return', + models: ['gpt-4.1'], + discoveryMode: 'manual', + source: 'local', + }); + }); +}); + +describe('import and custom defaults', () => { + it('uses /models for official Claude Code Anthropic, infer-only for proxies', () => { + expect(discoveryModeForImport('claude-code', { baseUrl: 'https://api.anthropic.com' })).toBe( + 'models', + ); + expect(discoveryModeForImport('claude-code', { baseUrl: 'http://localhost:8082' })).toBe( + 'infer-only', + ); + expect( + capabilitiesForImportedProvider('claude-code', { baseUrl: 'https://gateway.example.com' }) + .supportsModelsEndpoint, + ).toBe(false); + }); + + it('marks Gemini and OpenCode official maps as listing-capable', () => { + expect( + discoveryModeForImport('gemini', { + baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', + }), + ).toBe('models'); + expect(discoveryModeForImport('opencode', { baseUrl: 'https://api.openai.com/v1' })).toBe( + 'models', + ); + }); + + it('treats Codex imports as infer-only (one known default, no listing contract)', () => { + expect(discoveryModeForImport('codex', { baseUrl: 'https://api.deepseek.com/v1' })).toBe( + 'infer-only', + ); + }); + + it('stamps custom-provider modes from discovery outcome', () => { + expect(discoveryModeForCustomProvider({ discoveryKind: 'found', manualModel: false })).toBe( + 'models', + ); + expect(discoveryModeForCustomProvider({ discoveryKind: 'found', manualModel: true })).toBe( + 'manual', + ); + expect(discoveryModeForCustomProvider({ discoveryKind: 'failed', manualModel: false })).toBe( + 'infer-only', + ); + expect(discoveryModeForCustomProvider({ discoveryKind: 'idle', manualModel: false })).toBe( + 'manual', + ); + }); +}); + +describe('looksLikeModelsListingUrl', () => { + it('detects /models listing URLs used by connection diagnostics', () => { + expect(looksLikeModelsListingUrl('https://api.openai.com/v1/models')).toBe(true); + expect(looksLikeModelsListingUrl('https://api.anthropic.com/v1/models')).toBe(true); + expect(looksLikeModelsListingUrl('https://api.openai.com/v1/chat/completions')).toBe(false); + expect(looksLikeModelsListingUrl(undefined)).toBe(false); + }); +}); diff --git a/packages/shared/src/model-discovery.ts b/packages/shared/src/model-discovery.ts new file mode 100644 index 000000000..682b34380 --- /dev/null +++ b/packages/shared/src/model-discovery.ts @@ -0,0 +1,149 @@ +import type { + ProviderCapabilities, + ProviderEntry, + ProviderModelDiscoveryMode, + WireApi, +} from './config'; +import { resolveProviderCapabilities } from './config'; + +export type ModelsListSource = 'remote' | 'static-hint' | 'local'; + +export type ListForProviderPlan = + | { + action: 'return'; + models: string[]; + discoveryMode: ProviderModelDiscoveryMode; + source: Exclude; + } + | { + action: 'fetch-remote'; + discoveryMode: 'models'; + source: 'remote'; + }; + +export type ImportedProviderSource = 'claude-code' | 'gemini' | 'opencode' | 'codex'; + +export type SettingsModelPickerKind = 'select' | 'manual'; + +function pushUnique(models: string[], seen: Set, value: string | undefined): void { + const trimmed = value?.trim() ?? ''; + if (trimmed.length === 0 || seen.has(trimmed)) return; + seen.add(trimmed); + models.push(trimmed); +} + +export function usesRemoteModelsListing(mode: ProviderModelDiscoveryMode): boolean { + return mode === 'models'; +} + +export function connectionTestProbesModelsEndpoint(mode: ProviderModelDiscoveryMode): boolean { + return mode === 'models'; +} + +export function settingsModelPickerKind(mode: ProviderModelDiscoveryMode): SettingsModelPickerKind { + return mode === 'manual' || mode === 'infer-only' ? 'manual' : 'select'; +} + +export function capabilitiesForDiscoveryMode( + mode: ProviderModelDiscoveryMode, + extra?: Partial, +): ProviderCapabilities { + return { + supportsModelsEndpoint: mode === 'models', + modelDiscoveryMode: mode, + ...extra, + }; +} + +export function mergeDiscoveryMode( + existing: ProviderCapabilities | undefined, + mode: ProviderModelDiscoveryMode, +): ProviderCapabilities { + return { + ...existing, + modelDiscoveryMode: mode, + supportsModelsEndpoint: mode === 'models', + }; +} + +export function localModelsForDiscoveryMode( + mode: ProviderModelDiscoveryMode, + entry: { defaultModel: string; modelsHint?: string[] | undefined }, +): string[] | null { + if (usesRemoteModelsListing(mode)) return null; + const models: string[] = []; + const seen = new Set(); + if (mode === 'static-hint') { + for (const id of entry.modelsHint ?? []) pushUnique(models, seen, id); + pushUnique(models, seen, entry.defaultModel); + return models; + } + pushUnique(models, seen, entry.defaultModel); + for (const id of entry.modelsHint ?? []) pushUnique(models, seen, id); + return models; +} + +export function resolveListForProviderPlan( + providerId: string, + entry: Pick< + ProviderEntry, + 'wire' | 'defaultModel' | 'modelsHint' | 'requiresApiKey' | 'reasoningLevel' | 'capabilities' + >, +): ListForProviderPlan { + const mode = resolveProviderCapabilities(providerId, entry).modelDiscoveryMode; + if (usesRemoteModelsListing(mode)) { + return { action: 'fetch-remote', discoveryMode: 'models', source: 'remote' }; + } + const models = localModelsForDiscoveryMode(mode, entry) ?? []; + return { + action: 'return', + models, + discoveryMode: mode, + source: mode === 'static-hint' ? 'static-hint' : 'local', + }; +} + +export function isDefaultAnthropicApiHost(baseUrl: string): boolean { + try { + return new URL(baseUrl).hostname.toLowerCase() === 'api.anthropic.com'; + } catch { + return false; + } +} + +export function discoveryModeForImport( + source: ImportedProviderSource, + entry: { baseUrl: string; wire?: WireApi }, +): ProviderModelDiscoveryMode { + if (source === 'codex') return 'infer-only'; + if (source === 'claude-code') { + return isDefaultAnthropicApiHost(entry.baseUrl) ? 'models' : 'infer-only'; + } + return 'models'; +} + +export function capabilitiesForImportedProvider( + source: ImportedProviderSource, + entry: { baseUrl: string; wire?: WireApi }, +): ProviderCapabilities { + return capabilitiesForDiscoveryMode(discoveryModeForImport(source, entry)); +} + +export function discoveryModeForCustomProvider(input: { + discoveryKind: 'idle' | 'discovering' | 'found' | 'failed'; + manualModel: boolean; +}): ProviderModelDiscoveryMode { + if (input.manualModel) return 'manual'; + if (input.discoveryKind === 'found') return 'models'; + if (input.discoveryKind === 'failed') return 'infer-only'; + return 'manual'; +} + +export function looksLikeModelsListingUrl(url: string | undefined): boolean { + if (url === undefined || url.length === 0) return false; + try { + return /\/models\/?$/i.test(new URL(url).pathname); + } catch { + return /\/models\/?$/i.test(url); + } +}