From a0efc9ddd7592b3d423aff0a391013e8fc090f54 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Mon, 14 Sep 2026 10:33:47 +0000 Subject: [PATCH] feat(desktop): align connection tests with the generate invoke contract Make connection:v1:test-active and test-provider share active-provider resolution, auth/headers, canonical baseUrl, wire, and keyless/OAuth rules with generate. Probe the real inference endpoint and treat GET /models as optional discovery, with structured diverges results when the host is reachable but invoke would fail. Fixes #216 Signed-off-by: Xuanrui Li --- .changeset/connection-test-invoke-parity.md | 8 + apps/desktop/src/main/connection-ipc.test.ts | 119 ++++- apps/desktop/src/main/connection-ipc.ts | 436 +++++++++--------- .../main/effective-provider-contract.test.ts | 221 +++++++++ .../src/main/effective-provider-contract.ts | 187 ++++++++ apps/desktop/src/main/ipc/generate.ts | 10 +- apps/desktop/src/main/memory-ipc.ts | 4 +- .../src/main/provider-settings.test.ts | 30 ++ apps/desktop/src/main/provider-settings.ts | 87 +++- .../settings/connection-test-toast.test.ts | 51 ++ .../settings/connection-test-toast.ts | 11 + .../src/components/settings/primitives.tsx | 40 +- packages/i18n/src/locales/en.json | 7 +- packages/i18n/src/locales/es.json | 7 +- packages/i18n/src/locales/pt-BR.json | 7 +- packages/i18n/src/locales/zh-CN.json | 7 +- packages/providers/src/index.test.ts | 32 ++ packages/providers/src/index.ts | 16 +- packages/shared/src/base-url.test.ts | 56 +++ packages/shared/src/base-url.ts | 53 +++ packages/shared/src/connection-test.ts | 78 ++++ packages/shared/src/diagnostics.test.ts | 7 + packages/shared/src/diagnostics.ts | 16 + packages/shared/src/index.ts | 14 +- 24 files changed, 1252 insertions(+), 252 deletions(-) create mode 100644 .changeset/connection-test-invoke-parity.md create mode 100644 apps/desktop/src/main/effective-provider-contract.test.ts create mode 100644 apps/desktop/src/main/effective-provider-contract.ts create mode 100644 apps/desktop/src/renderer/src/components/settings/connection-test-toast.test.ts create mode 100644 apps/desktop/src/renderer/src/components/settings/connection-test-toast.ts create mode 100644 packages/shared/src/connection-test.ts diff --git a/.changeset/connection-test-invoke-parity.md b/.changeset/connection-test-invoke-parity.md new file mode 100644 index 00000000..e3c54343 --- /dev/null +++ b/.changeset/connection-test-invoke-parity.md @@ -0,0 +1,8 @@ +--- +"@open-codesign/desktop": patch +"@open-codesign/shared": patch +"@open-codesign/providers": patch +"@open-codesign/i18n": patch +--- + +Make connection tests validate the real generate invoke contract (shared provider resolution, auth, baseUrl, and wire) instead of treating GET /models as a stand-in for runtime. diff --git a/apps/desktop/src/main/connection-ipc.test.ts b/apps/desktop/src/main/connection-ipc.test.ts index 5a101f81..e523fe18 100644 --- a/apps/desktop/src/main/connection-ipc.test.ts +++ b/apps/desktop/src/main/connection-ipc.test.ts @@ -1021,7 +1021,7 @@ describe('runProviderTest degrade-probe (issue #179)', () => { } }); - it('openai-chat: /models 200 → no degrade probe, probeMethod=models', async () => { + it('openai-chat: /models 200 still confirms the generate invoke path', async () => { const { calls, restore } = installFakeFetch(() => ({ status: 200, body: { data: [] } })); try { const res = await runProviderTest({ @@ -1035,8 +1035,12 @@ describe('runProviderTest degrade-probe (issue #179)', () => { expect(res.probeMethod).toBe('models'); expect(res.compatibility).toBe('compatible'); } - expect(calls).toHaveLength(1); + expect(calls).toHaveLength(2); expect(calls[0]?.method).toBe('GET'); + expect(calls[0]?.url).toMatch(/\/models$/); + expect(calls[1]?.method).toBe('POST'); + expect(calls[1]?.url).toMatch(/\/chat\/completions$/); + if (res.ok) expect(res.invokeParity).toBe('aligned'); } finally { restore(); } @@ -1054,7 +1058,7 @@ describe('runProviderTest degrade-probe (issue #179)', () => { expect(res.ok).toBe(false); if (!res.ok) expect(res.code).toBe('404'); if (!res.ok) expect(res.compatibility).toBe('incompatible'); - // Only /v1/models should have been probed — no /v1/messages degrade. + // Both /models (discovery) and /messages (invoke) are probed. expect(calls).toHaveLength(2); expect(calls[0]?.url).toMatch(/\/v1\/models$/); expect(calls[1]?.url).toMatch(/\/v1\/messages$/); @@ -1155,6 +1159,115 @@ describe('runProviderTest degrade-probe (issue #179)', () => { } }); + it('openai-chat: /models 200 + invoke 404 is reachable but diverges from generate', async () => { + const { calls, restore } = installFakeFetch((url) => { + if (url.endsWith('/models')) return { status: 200, body: { data: [{ id: 'gpt-4o' }] } }; + if (url.endsWith('/chat/completions')) return { status: 404 }; + return { status: 500 }; + }); + try { + const res = await runProviderTest({ + provider: 'responses-on-chat-gateway', + wire: 'openai-chat', + apiKey: 'sk-test', + baseUrl: 'https://gateway.example.com/v1', + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.compatibility).toBe('diverges'); + expect(res.invokeParity).toBe('diverged'); + expect(res.reachableVia).toBe('models'); + expect(res.code).toBe('INVOKE_DIVERGED'); + expect(res.reasonCategory).toBe('invoke-contract-diverged'); + expect(res.invokeContract?.invokeUrl).toBe( + 'https://gateway.example.com/v1/chat/completions', + ); + } + expect(calls.some((c) => c.url.endsWith('/models'))).toBe(true); + expect(calls.some((c) => c.url.endsWith('/chat/completions'))).toBe(true); + } finally { + restore(); + } + }); + + it('openai-responses: /models 200 must not pass when /responses is missing (no chat false-positive)', async () => { + const { calls, restore } = installFakeFetch((url) => { + if (url.endsWith('/models')) return { status: 200, body: { data: [] } }; + if (url.endsWith('/responses')) return { status: 404 }; + if (url.endsWith('/chat/completions')) return { status: 200, body: { id: 'wrong-probe' } }; + return { status: 500 }; + }); + try { + const res = await runProviderTest({ + provider: 'chat-only-gateway', + wire: 'openai-responses', + apiKey: 'sk-test', + baseUrl: 'https://gateway.example.com/v1', + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.compatibility).toBe('diverges'); + expect(res.invokeParity).toBe('diverged'); + } + expect(calls.some((c) => c.url.endsWith('/chat/completions'))).toBe(false); + } finally { + restore(); + } + }); + + it('chatgpt codex: resolved OAuth token is treated as aligned invoke auth (no /models probe)', async () => { + const { calls, restore } = installFakeFetch(() => { + throw new Error('Codex connection test must not HTTP-probe /models or /codex/responses'); + }); + try { + const res = await runProviderTest({ + provider: 'chatgpt-codex', + wire: 'openai-codex-responses', + apiKey: 'oauth-access-token', + baseUrl: 'https://chatgpt.com/backend-api', + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.probeMethod).toBe('codex_oauth'); + expect(res.invokeParity).toBe('aligned'); + expect(res.modelsProbeRelation).toBe('unavailable'); + expect(res.invokeContract?.authMode).toBe('codex-oauth'); + expect(res.invokeContract?.invokeUrl).toBe( + 'https://chatgpt.com/backend-api/codex/responses', + ); + } + expect(calls).toHaveLength(0); + } finally { + restore(); + } + }); + + it('keyless openai-chat proxy: empty apiKey still probes invoke with no Authorization header', 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: 'local-proxy', + wire: 'openai-chat', + apiKey: '', + baseUrl: 'http://127.0.0.1:8317/v1', + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.compatibility).toBe('degraded'); + expect(res.invokeParity).toBe('degraded-discovery'); + expect(res.invokeContract?.authMode).toBe('keyless'); + expect(res.invokeContract?.allowKeyless).toBe(true); + } + expect(calls[1]?.method).toBe('POST'); + } finally { + restore(); + } + }); + it('openai-responses: /models 404 + /responses 404 → preserves original 404 (no /chat/completions false-positive)', async () => { // Regression: the previous implementation probed /chat/completions for // every OpenAI-compat wire. A gateway that only implements /chat/completions diff --git a/apps/desktop/src/main/connection-ipc.ts b/apps/desktop/src/main/connection-ipc.ts index 1041c9d0..7e29b47e 100644 --- a/apps/desktop/src/main/connection-ipc.ts +++ b/apps/desktop/src/main/connection-ipc.ts @@ -3,11 +3,17 @@ import { isIP } from 'node:net'; import { BUILTIN_PROVIDERS, CodesignError, + type ConnectionTestError, + type ConnectionTestResponse, + type ConnectionTestResult, canonicalBaseUrl, type DiagnosticCategory, ERROR_CODES, ensureVersionedBase, + inferenceEndpointUrl, isSupportedOnboardingProvider, + modelsEndpointUrl, + modelsProbeRelationForWire, type ProviderEntry, type SupportedOnboardingProvider, stripInferenceEndpointSuffix, @@ -15,6 +21,13 @@ import { } from '@open-codesign/shared'; import { buildAuthHeaders, buildAuthHeadersForWire } from './auth-headers'; import { getCodexTokenStore } from './codex-oauth-ipc'; +import { + connectionErrorFromUnknown, + type EffectiveProviderContract, + invokeAuthMode, + resolveEffectiveInvokeContract, + toInvokeContractView, +} from './effective-provider-contract'; import { ipcMain } from './electron-runtime'; import { getApiKeyForProvider, getCachedConfig, hasApiKeyForProvider } from './onboarding-ipc'; import { isKeylessProviderAllowed } from './provider-settings'; @@ -67,36 +80,7 @@ function assertKnownFields( } } -export interface ConnectionTestResult { - ok: true; - /** - * `models` when the standard GET /models probe succeeded. - * `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 - * openai-responses wire is alive. We probe the wire's real inference - * endpoint so a gateway that only implements /chat/completions can't - * false-positive for a user whose provider is on the Responses API. - */ - probeMethod?: - | 'models' - | 'chat_completion_degraded' - | 'responses_degraded' - | 'anthropic_messages_degraded'; - compatibility?: 'compatible' | 'degraded'; - reasonCategory?: DiagnosticCategory; -} - -export interface ConnectionTestError { - ok: false; - code: 'IPC_BAD_INPUT' | '401' | '404' | 'ECONNREFUSED' | 'NETWORK' | 'PARSE'; - message: string; - hint: string; - compatibility?: 'incompatible'; - reasonCategory?: DiagnosticCategory; -} - -export type ConnectionTestResponse = ConnectionTestResult | ConnectionTestError; +export type { ConnectionTestError, ConnectionTestResponse, ConnectionTestResult }; export type ModelsListResponse = | { ok: true; models: string[] } @@ -493,209 +477,214 @@ export interface ActiveProviderCredentials { tlsRejectUnauthorized?: boolean; } -function resolveCredentialsForProvider( - providerId: string, -): ActiveProviderCredentials | ConnectionTestError { - const cfg = getCachedConfig(); - if (cfg === null || providerId.length === 0) { - return { - ok: false, - code: 'IPC_BAD_INPUT', - message: 'No active provider configured', - hint: 'Complete onboarding first', - }; - } - const entry = - cfg.providers[providerId] ?? - (isSupportedOnboardingProvider(providerId) ? BUILTIN_PROVIDERS[providerId] : undefined); - if (entry === undefined) { - return { - ok: false, - code: 'IPC_BAD_INPUT', - message: `Provider "${providerId}" not found in config`, - hint: 'Re-add the provider from Settings', - }; - } - let apiKey = ''; - if (isKeylessProviderAllowed(providerId, entry) && !hasApiKeyForProvider(providerId)) { - apiKey = ''; - } else { - try { - apiKey = getApiKeyForProvider(providerId); - } catch (err) { - return { - ok: false, - code: 'IPC_BAD_INPUT', - message: - err instanceof Error ? err.message : `No API key stored for provider "${providerId}"`, - hint: 'Open Settings and import Codex again, or add an API key for this provider', - }; - } - } +function invokeCredentialDeps() { return { - provider: providerId, - wire: entry.wire, - apiKey, - baseUrl: entry.baseUrl, - builtin: entry.builtin === true, - ...(entry.httpHeaders !== undefined ? { httpHeaders: entry.httpHeaders } : {}), - ...(entry.tlsRejectUnauthorized !== undefined - ? { tlsRejectUnauthorized: entry.tlsRejectUnauthorized } - : {}), + getCodexAccessToken: () => getCodexTokenStore().getValidAccessToken(), + getApiKeyForProvider, + hasApiKeyForProvider, }; } -function resolveActiveCredentials(): ActiveProviderCredentials | ConnectionTestError { - const cfg = getCachedConfig(); - const active = cfg?.activeProvider; - if (active === undefined || active.length === 0) { - return { - ok: false, - code: 'IPC_BAD_INPUT', - message: 'No active provider configured', - hint: 'Complete onboarding first', - }; - } - return resolveCredentialsForProvider(active); +function noConfigError(): ConnectionTestError { + return { + ok: false, + code: 'IPC_BAD_INPUT', + message: 'No active provider configured', + hint: 'Complete onboarding first', + }; } -async function testChatGPTCodexOAuth(): Promise { - let stored: Awaited['read']>>; - try { - stored = await getCodexTokenStore().read(); - } catch (err) { - return { - ok: false, - code: '401', - message: err instanceof Error ? err.message : String(err), - hint: 'ChatGPT 订阅凭证读取失败,请到 Settings 重新登录', - }; - } - if (stored === null) { - return { - ok: false, - code: '401', - message: 'No ChatGPT OAuth token stored', - hint: 'ChatGPT 订阅未登录,请到 Settings 登录', - compatibility: 'incompatible', - reasonCategory: 'auth', - }; - } - if (stored.expiresAt < Date.now()) { - return { - ok: false, - code: '401', - message: 'ChatGPT OAuth token expired', - hint: 'ChatGPT 订阅登录已过期,请重新登录', - compatibility: 'incompatible', - reasonCategory: 'auth', - }; - } - return { ok: true, compatibility: 'compatible' }; +function contractFromCredentials(creds: ActiveProviderCredentials): EffectiveProviderContract { + const relation = modelsProbeRelationForWire(creds.wire); + const canonical = canonicalBaseUrl(creds.baseUrl, creds.wire); + const modelsUrl = + relation === 'optional-discovery' ? modelsEndpointUrl(creds.baseUrl, creds.wire) : null; + const allowKeyless = creds.apiKey.length === 0; + return { + providerId: creds.provider, + modelId: 'probe', + wire: creds.wire, + storedBaseUrl: creds.baseUrl, + canonicalBaseUrl: canonical, + modelsUrl, + invokeUrl: inferenceEndpointUrl(creds.baseUrl, creds.wire), + authHeaders: buildAuthHeadersForWire(creds.wire, creds.apiKey, creds.httpHeaders, canonical), + httpHeaders: creds.httpHeaders, + queryParams: undefined, + apiKey: creds.apiKey, + allowKeyless, + builtin: creds.builtin === true, + tlsRejectUnauthorized: creds.tlsRejectUnauthorized, + tlsBypass: creds.builtin !== true && creds.tlsRejectUnauthorized === true, + modelsProbeRelation: relation, + authMode: invokeAuthMode(creds.wire, creds.apiKey), + overridden: false, + reasoningLevel: undefined, + }; } -export async function runProviderTest( - creds: ActiveProviderCredentials, -): Promise { - // ChatGPT subscription uses OAuth + ChatGPT-Account-Id headers; its host - // has no `/models` endpoint that a generic Bearer probe can reach. A plain - // HTTP probe would return 401 here and render as the misleading "API key - // 错误或权限不足" hint — so we check the OAuth token store directly and - // surface a login-specific hint instead. - if (creds.wire === 'openai-codex-responses') { - return testChatGPTCodexOAuth(); - } - - // 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( - creds.wire, - creds.apiKey, - creds.httpHeaders, - creds.baseUrl, - ); +function isHttpInvokeWire(wire: WireApi): wire is 'openai-chat' | 'openai-responses' | 'anthropic' { + return wire === 'openai-chat' || wire === 'openai-responses' || wire === 'anthropic'; +} - let res: Response; - try { - res = await fetchWithTimeout(url, { method: 'GET', headers }); - } catch (err) { - const { code, hint } = classifyNetworkError(err); +function degradedProbeMethod( + wire: 'openai-chat' | 'openai-responses' | 'anthropic', +): NonNullable { + if (wire === 'openai-responses') return 'responses_degraded'; + if (wire === 'anthropic') return 'anthropic_messages_degraded'; + return 'chat_completion_degraded'; +} + +export async function testInvokeContract( + contract: EffectiveProviderContract, +): Promise { + if (contract.wire === 'openai-codex-responses') { + if (contract.apiKey.length === 0) { return { ok: false, - code, - message: err instanceof Error ? err.message : 'Network request failed', - hint, + code: '401', + message: 'No ChatGPT OAuth token stored', + hint: 'ChatGPT 订阅未登录,请到 Settings 登录', compatibility: 'incompatible', - reasonCategory: code === 'ECONNREFUSED' ? 'network-unreachable' : 'unknown', + reasonCategory: 'auth', + invokeParity: 'aligned', + modelsProbeRelation: 'unavailable', + invokeContract: toInvokeContractView(contract), }; } - if (!res.ok) { - // Some OpenAI-compatible gateways (Zhipu GLM, a handful of self-hosted - // proxies) don't expose /models but their /chat/completions works fine. - // If the primary probe 404s on those wires, degrade-probe with a tiny - // chat request before declaring the endpoint dead. We intentionally do - // not degrade anthropic — its /v1/models is standard, and skipping it - // would mask real path-shape mistakes. - if ( - res.status === 404 && - (creds.wire === 'openai-chat' || - creds.wire === 'openai-responses' || - creds.wire === 'anthropic') - ) { - const degraded = await tryDegradeProbe(creds.wire, normalizedBaseUrl, headers); - if (degraded !== null) return degraded; - // Inference endpoint also 404'd (or the network dropped) — fall through - // and report the original /models 404. - } - const { code, hint } = classifyHttpError(res.status); + return { + ok: true, + probeMethod: 'codex_oauth', + compatibility: 'compatible', + invokeParity: 'aligned', + modelsProbeRelation: 'unavailable', + invokeContract: toInvokeContractView(contract), + }; + } + + return withTlsBypass(contract.tlsBypass, async () => { + let models: ProbeResult | { kind: 'skipped' } = { kind: 'skipped' }; + if (contract.modelsProbeRelation === 'optional-discovery' && contract.modelsUrl !== null) { + models = await probeGet(contract.modelsUrl, contract.authHeaders); + } + + if (!isHttpInvokeWire(contract.wire)) { return { ok: false, - code, - message: `HTTP ${res.status}`, - hint, + code: 'IPC_BAD_INPUT', + message: `Unsupported invoke wire: ${contract.wire}`, + hint: 'Re-add the provider from Settings', compatibility: 'incompatible', - reasonCategory: connectionCategoryForStatus(res.status, normalizedBaseUrl), + invokeParity: 'aligned', + invokeContract: toInvokeContractView(contract), }; } - return { ok: true, probeMethod: 'models', compatibility: 'compatible' }; + const invoke = await probeInferenceEndpoint( + contract.wire, + contract.canonicalBaseUrl, + contract.authHeaders, + ); + return classifyInvokeAndModelsProbes(contract, models, invoke); }); } -async function tryDegradeProbe( - wire: 'openai-chat' | 'openai-responses' | 'anthropic', - normalizedBaseUrl: string, - headers: Record, -): Promise { - const probe = await probeInferenceEndpoint(wire, normalizedBaseUrl, headers); - if (probe.kind === 'pass') { +export async function runProviderTest( + creds: ActiveProviderCredentials, +): Promise { + return testInvokeContract(contractFromCredentials(creds)); +} + +function classifyInvokeAndModelsProbes( + contract: EffectiveProviderContract, + models: ProbeResult | { kind: 'skipped' }, + invoke: ProbeResult, +): ConnectionTestResponse { + const view = toInvokeContractView(contract); + const modelsPass = models.kind === 'pass'; + const modelsSkipped = models.kind === 'skipped'; + + if (invoke.kind === 'pass') { + if (modelsPass || modelsSkipped) { + return { + ok: true, + probeMethod: modelsPass ? 'models' : 'invoke', + compatibility: 'compatible', + invokeParity: 'aligned', + modelsProbeRelation: contract.modelsProbeRelation, + invokeContract: view, + }; + } return { ok: true, - probeMethod: - wire === 'openai-responses' - ? 'responses_degraded' - : wire === 'anthropic' - ? 'anthropic_messages_degraded' - : 'chat_completion_degraded', + probeMethod: isHttpInvokeWire(contract.wire) ? degradedProbeMethod(contract.wire) : 'invoke', compatibility: 'degraded', reasonCategory: 'model-discovery-degraded', + invokeParity: 'degraded-discovery', + modelsProbeRelation: contract.modelsProbeRelation, + invokeContract: view, }; } - if (probe.kind === 'http' && probe.status !== 404) { - const { code, hint } = classifyHttpError(probe.status); + + if (modelsPass) { + const failed = invokeFailureFields(invoke, contract.canonicalBaseUrl); return { ok: false, + code: 'INVOKE_DIVERGED', + message: failed.message, + hint: 'The /models endpoint is reachable, but the inference endpoint used at generate-time failed. Connection test and generate share this invoke contract.', + compatibility: 'diverges', + reasonCategory: 'invoke-contract-diverged', + invokeParity: 'diverged', + reachableVia: 'models', + probeMethod: 'models', + modelsProbeRelation: contract.modelsProbeRelation, + invokeContract: view, + }; + } + + const failed = invokeFailureFields(invoke, contract.canonicalBaseUrl); + return { + ok: false, + ...failed, + compatibility: 'incompatible', + invokeParity: 'aligned', + modelsProbeRelation: contract.modelsProbeRelation, + invokeContract: view, + }; +} + +function invokeFailureFields( + invoke: ProbeResult, + canonicalBaseUrl: string, +): Pick { + if (invoke.kind === 'http') { + const { code, hint } = classifyHttpError(invoke.status); + return { code, - message: `HTTP ${probe.status}`, + message: `HTTP ${invoke.status}`, hint, - compatibility: 'incompatible', - reasonCategory: connectionCategoryForStatus(probe.status, normalizedBaseUrl), + reasonCategory: connectionCategoryForStatus(invoke.status, canonicalBaseUrl), }; } - return null; + const err = new Error(invoke.kind === 'network' ? invoke.message : 'Network request failed'); + const { code, hint } = classifyNetworkError(err); + return { + code, + message: err.message, + hint, + reasonCategory: code === 'ECONNREFUSED' ? 'network-unreachable' : 'unknown', + }; +} + +async function probeGet(url: string, headers: Record): Promise { + let res: Response; + try { + res = await fetchWithTimeout(url, { method: 'GET', headers }); + } catch (err) { + return { kind: 'network', message: err instanceof Error ? err.message : String(err) }; + } + if (res.ok) return { kind: 'pass' }; + return { kind: 'http', status: res.status }; } type ProbeResult = @@ -719,12 +708,7 @@ async function probeInferenceEndpoint( normalizedBaseUrl: string, headers: Record, ): Promise { - const url = - wire === 'anthropic' - ? `${normalizedBaseUrl}/v1/messages` - : wire === 'openai-responses' - ? `${normalizedBaseUrl}/responses` - : `${normalizedBaseUrl}/chat/completions`; + const url = inferenceEndpointUrl(normalizedBaseUrl, wire); const body = wire === 'anthropic' ? JSON.stringify({ @@ -794,11 +778,7 @@ export function registerConnectionIpc(): void { ipcMain.handle('models:v1:list', (_e, raw: unknown) => handleModelsV1List(raw)); // Tests the currently active provider using the stored (encrypted) key — no key passed from renderer. - ipcMain.handle('connection:v1:test-active', async (): Promise => { - const creds = resolveActiveCredentials(); - if (!('provider' in creds)) return creds; - return runProviderTest(creds); - }); + ipcMain.handle('connection:v1:test-active', () => handleConnectionV1TestActive()); // Tests a specific provider by id — used by the per-row "Test connection" // button in Settings. Same probe as test-active but routed by id. @@ -902,7 +882,25 @@ async function handleModelsV1List(raw: unknown): Promise { return result; } -async function handleConnectionV1TestProvider(raw: unknown): Promise { +export async function handleConnectionV1TestActive(): Promise { + const cfg = getCachedConfig(); + if (cfg === null || cfg.activeProvider.length === 0) return noConfigError(); + try { + const contract = await resolveEffectiveInvokeContract({ + cfg, + mode: 'active', + hint: { provider: cfg.activeProvider, modelId: cfg.activeModel }, + creds: invokeCredentialDeps(), + }); + return testInvokeContract(contract); + } catch (err) { + return connectionErrorFromUnknown(err); + } +} + +export async function handleConnectionV1TestProvider( + raw: unknown, +): Promise { if (typeof raw !== 'string' || raw.length === 0) { return { ok: false, @@ -911,9 +909,19 @@ async function handleConnectionV1TestProvider(raw: unknown): Promise; + providers?: Record; +}): Config { + const providers: Record = { + anthropic: { + ...BUILTIN_PROVIDERS.anthropic, + }, + openai: { + ...BUILTIN_PROVIDERS.openai, + }, + openrouter: { + ...BUILTIN_PROVIDERS.openrouter, + }, + ...(input.providers ?? {}), + }; + return hydrateConfig({ + version: 3, + activeProvider: input.provider, + activeModel: input.modelPrimary, + secrets: input.secrets ?? {}, + providers, + }); +} + +function creds( + overrides: { + getCodexAccessToken?: () => Promise; + getApiKeyForProvider?: (id: string) => string; + hasApiKeyForProvider?: (id: string) => boolean; + } = {}, +) { + return { + getCodexAccessToken: overrides.getCodexAccessToken ?? vi.fn().mockResolvedValue('codex-token'), + getApiKeyForProvider: overrides.getApiKeyForProvider ?? vi.fn().mockReturnValue('stored-key'), + hasApiKeyForProvider: overrides.hasApiKeyForProvider ?? vi.fn().mockReturnValue(true), + }; +} + +describe('resolveEffectiveInvokeContract vs generate', () => { + it('test-active shares active-provider snap, wire, canonical baseUrl, and API key with generate', async () => { + const cfg = makeCfg({ + provider: 'openrouter', + modelPrimary: 'anthropic/claude-sonnet-4.6', + secrets: { + openai: { ciphertext: 'enc-oai' }, + openrouter: { ciphertext: 'enc-or' }, + }, + providers: { + openai: { + ...BUILTIN_PROVIDERS.openai, + baseUrl: 'https://api.duckcoding.ai/v1', + }, + }, + }); + const hint = { provider: 'openai', modelId: 'gpt-4o' }; + const deps = creds(); + + const active = resolveActiveModel(cfg, hint); + const apiKey = await resolveCredentialForProvider( + active.model.provider, + active.allowKeyless, + deps, + ); + const contract = await resolveEffectiveInvokeContract({ + cfg, + mode: 'active', + hint, + creds: deps, + }); + + expect(contract.overridden).toBe(true); + expect(contract.providerId).toBe(active.model.provider); + expect(contract.modelId).toBe(active.model.modelId); + expect(contract.wire).toBe(active.wire); + expect(contract.canonicalBaseUrl).toBe(canonicalInvokeBaseUrl(active.baseUrl, active.wire)); + expect(contract.apiKey).toBe(apiKey); + expect(contract.providerId).toBe('openrouter'); + expect(contract.canonicalBaseUrl).toBe('https://openrouter.ai/api/v1'); + expect(contract.canonicalBaseUrl).not.toBe('https://api.duckcoding.ai/v1'); + expect(contract.authMode).toBe('bearer'); + expect(contract.authHeaders['authorization']).toBe('Bearer stored-key'); + }); + + it('test-provider uses the named provider contract and does not snap to active', async () => { + const cfg = makeCfg({ + provider: 'openrouter', + modelPrimary: 'anthropic/claude-sonnet-4.6', + secrets: { + openai: { ciphertext: 'enc-oai' }, + openrouter: { ciphertext: 'enc-or' }, + }, + providers: { + openai: { + ...BUILTIN_PROVIDERS.openai, + baseUrl: 'https://gateway.example.com', + }, + }, + }); + const deps = creds(); + const named = resolveProviderModel(cfg, 'openai'); + const contract = await resolveEffectiveInvokeContract({ + cfg, + mode: 'provider', + providerId: 'openai', + creds: deps, + }); + + expect(contract.overridden).toBe(false); + expect(contract.providerId).toBe('openai'); + expect(contract.wire).toBe(named.wire); + expect(contract.canonicalBaseUrl).toBe('https://gateway.example.com/v1'); + expect(contract.invokeUrl).toBe('https://gateway.example.com/v1/chat/completions'); + expect(contract.modelsUrl).toBe('https://gateway.example.com/v1/models'); + expect(contract.modelsProbeRelation).toBe('optional-discovery'); + }); + + it('ChatGPT Codex OAuth uses getValidAccessToken, not a stored API-key row', async () => { + const cfg = makeCfg({ + provider: CHATGPT_CODEX_PROVIDER_ID, + modelPrimary: 'gpt-5.5', + providers: { + [CHATGPT_CODEX_PROVIDER_ID]: { + id: CHATGPT_CODEX_PROVIDER_ID, + name: 'ChatGPT 订阅', + builtin: false, + wire: 'openai-codex-responses', + baseUrl: 'https://chatgpt.com/backend-api', + defaultModel: 'gpt-5.5', + requiresApiKey: false, + capabilities: { + supportsKeyless: true, + supportsModelsEndpoint: false, + modelDiscoveryMode: 'static-hint', + }, + }, + }, + }); + const deps = creds({ + getCodexAccessToken: vi.fn().mockResolvedValue('refreshed-oauth'), + getApiKeyForProvider: vi.fn(() => { + throw new Error('must not read API-key storage for Codex'); + }), + hasApiKeyForProvider: vi.fn().mockReturnValue(false), + }); + + const contract = await resolveEffectiveInvokeContract({ + cfg, + mode: 'active', + creds: deps, + }); + + expect(deps.getCodexAccessToken).toHaveBeenCalledTimes(1); + expect(deps.getApiKeyForProvider).not.toHaveBeenCalled(); + expect(contract.apiKey).toBe('refreshed-oauth'); + expect(contract.authMode).toBe('codex-oauth'); + expect(contract.modelsProbeRelation).toBe('unavailable'); + expect(contract.modelsUrl).toBeNull(); + expect(contract.invokeUrl).toBe('https://chatgpt.com/backend-api/codex/responses'); + expect(contract.authHeaders['authorization']).toBe('Bearer refreshed-oauth'); + }); + + it('keyless proxy keeps an empty bearer, matching generate allowKeyless', async () => { + const cfg = makeCfg({ + provider: 'local-proxy', + modelPrimary: 'llama3', + providers: { + 'local-proxy': { + id: 'local-proxy', + name: 'Local proxy', + builtin: false, + wire: 'openai-chat', + baseUrl: 'http://127.0.0.1:8317/v1', + defaultModel: 'llama3', + requiresApiKey: false, + }, + }, + }); + const deps = creds({ + hasApiKeyForProvider: vi.fn().mockReturnValue(false), + getApiKeyForProvider: vi.fn(() => { + throw new Error('must not read key storage when keyless'); + }), + }); + + const active = resolveActiveModel(cfg, { provider: 'local-proxy', modelId: 'llama3' }); + const apiKey = await resolveCredentialForProvider( + active.model.provider, + active.allowKeyless, + deps, + ); + const contract = await resolveEffectiveInvokeContract({ + cfg, + mode: 'active', + creds: deps, + }); + + expect(active.allowKeyless).toBe(true); + expect(apiKey).toBe(''); + expect(contract.apiKey).toBe(''); + expect(contract.authMode).toBe('keyless'); + expect(contract.allowKeyless).toBe(true); + expect(contract.authHeaders['authorization']).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/main/effective-provider-contract.ts b/apps/desktop/src/main/effective-provider-contract.ts new file mode 100644 index 00000000..24f1b89f --- /dev/null +++ b/apps/desktop/src/main/effective-provider-contract.ts @@ -0,0 +1,187 @@ +import { looksLikeClaudeOAuthToken } from '@open-codesign/providers'; +import { + CHATGPT_CODEX_PROVIDER_ID, + CodesignError, + type Config, + type ConnectionTestError, + canonicalBaseUrl, + ERROR_CODES, + type InvokeAuthMode, + type InvokeContractView, + inferenceEndpointUrl, + type ModelsProbeRelation, + modelsEndpointUrl, + modelsProbeRelationForProvider, + type WireApi, +} from '@open-codesign/shared'; +import { buildAuthHeadersForWire } from './auth-headers'; +import { + type ActiveModelResolution, + type ProviderModelResolution, + resolveActiveModel, + resolveProviderModel, +} from './provider-settings'; +import { + type ResolveCredentialForProviderDeps, + resolveCredentialForProvider, +} from './resolve-api-key'; + +export interface EffectiveProviderContract { + providerId: string; + modelId: string; + wire: WireApi; + storedBaseUrl: string; + canonicalBaseUrl: string; + modelsUrl: string | null; + invokeUrl: string; + authHeaders: Record; + httpHeaders: Record | undefined; + queryParams: Record | undefined; + apiKey: string; + allowKeyless: boolean; + builtin: boolean; + tlsRejectUnauthorized: boolean | undefined; + tlsBypass: boolean; + modelsProbeRelation: ModelsProbeRelation; + authMode: InvokeAuthMode; + overridden: boolean; + reasoningLevel: ActiveModelResolution['reasoningLevel']; +} + +export function canonicalInvokeBaseUrl( + stored: string | null | undefined, + wire: WireApi, +): string | undefined { + if (stored === undefined || stored === null || stored.length === 0) return undefined; + return canonicalBaseUrl(stored, wire); +} + +export function invokeAuthMode(wire: WireApi, apiKey: string): InvokeAuthMode { + if (wire === 'openai-codex-responses') return 'codex-oauth'; + if (apiKey.length === 0) return 'keyless'; + if (wire === 'anthropic' && looksLikeClaudeOAuthToken(apiKey)) return 'anthropic-oauth'; + if (wire === 'anthropic') return 'x-api-key'; + return 'bearer'; +} + +export function toInvokeContractView(contract: EffectiveProviderContract): InvokeContractView { + return { + provider: contract.providerId, + wire: contract.wire, + canonicalBaseUrl: contract.canonicalBaseUrl, + invokeUrl: contract.invokeUrl, + modelsUrl: contract.modelsUrl, + modelsProbeRelation: contract.modelsProbeRelation, + authMode: contract.authMode, + allowKeyless: contract.allowKeyless, + }; +} + +export function buildEffectiveProviderContract( + resolved: ProviderModelResolution, + apiKey: string, + overridden: boolean, +): EffectiveProviderContract { + const storedBaseUrl = resolved.baseUrl ?? ''; + const canonical = canonicalBaseUrl(storedBaseUrl, resolved.wire); + const modelsProbeRelation = modelsProbeRelationForProvider(resolved.wire, { + supportsModelsEndpoint: resolved.supportsModelsEndpoint, + modelDiscoveryMode: resolved.modelDiscoveryMode, + }); + let modelsUrl: string | null = null; + if (modelsProbeRelation === 'optional-discovery') { + modelsUrl = modelsEndpointUrl(storedBaseUrl, resolved.wire); + } + return { + providerId: resolved.model.provider, + modelId: resolved.model.modelId, + wire: resolved.wire, + storedBaseUrl, + canonicalBaseUrl: canonical, + modelsUrl, + invokeUrl: inferenceEndpointUrl(storedBaseUrl, resolved.wire), + authHeaders: buildAuthHeadersForWire(resolved.wire, apiKey, resolved.httpHeaders, canonical), + httpHeaders: resolved.httpHeaders, + queryParams: resolved.queryParams, + apiKey, + allowKeyless: resolved.allowKeyless, + builtin: resolved.builtin, + tlsRejectUnauthorized: resolved.tlsRejectUnauthorized, + tlsBypass: resolved.builtin !== true && resolved.tlsRejectUnauthorized === true, + modelsProbeRelation, + authMode: invokeAuthMode(resolved.wire, apiKey), + overridden, + reasoningLevel: resolved.reasoningLevel, + }; +} + +export async function resolveEffectiveInvokeContract(input: { + cfg: Config; + mode: 'active' | 'provider'; + providerId?: string; + hint?: { provider: string; modelId: string }; + creds: ResolveCredentialForProviderDeps; +}): Promise { + const { cfg, creds } = input; + if (input.mode === 'active') { + const hint = input.hint ?? { + provider: cfg.activeProvider, + modelId: cfg.activeModel, + }; + const active = resolveActiveModel(cfg, hint); + const apiKey = await resolveCredentialForProvider( + active.model.provider, + active.allowKeyless, + creds, + ); + return buildEffectiveProviderContract(active, apiKey, active.overridden); + } + const providerId = input.providerId; + if (providerId === undefined || providerId.length === 0) { + throw new CodesignError( + 'test-provider expects a provider id string', + ERROR_CODES.IPC_BAD_INPUT, + ); + } + const resolved = resolveProviderModel(cfg, providerId); + const apiKey = await resolveCredentialForProvider(providerId, resolved.allowKeyless, creds); + return buildEffectiveProviderContract(resolved, apiKey, false); +} + +export function connectionErrorFromUnknown(err: unknown): ConnectionTestError { + const message = err instanceof Error ? err.message : String(err); + const code = err instanceof CodesignError ? err.code : undefined; + const isAuth = + code === ERROR_CODES.PROVIDER_AUTH_MISSING || + code === ERROR_CODES.PROVIDER_KEY_MISSING || + code === ERROR_CODES.CODEX_TOKEN_NOT_LOGGED_IN; + if (isAuth) { + const isCodex = + message.toLowerCase().includes('chatgpt') || message.toLowerCase().includes('codex'); + return { + ok: false, + code: '401', + message, + hint: isCodex + ? 'ChatGPT 订阅未登录或已过期,请到 Settings 重新登录' + : 'Open Settings and import Codex again, or add an API key for this provider', + compatibility: 'incompatible', + reasonCategory: 'auth', + invokeParity: 'aligned', + }; + } + return { + ok: false, + code: 'IPC_BAD_INPUT', + message, + hint: 'Re-add the provider from Settings', + compatibility: 'incompatible', + reasonCategory: 'bad-input', + invokeParity: 'aligned', + }; +} + +/** ChatGPT Codex generate never uses a stored API-key row; it always refreshes OAuth. */ +export function isCodexOAuthProvider(providerId: string, wire: WireApi): boolean { + return providerId === CHATGPT_CODEX_PROVIDER_ID || wire === 'openai-codex-responses'; +} diff --git a/apps/desktop/src/main/ipc/generate.ts b/apps/desktop/src/main/ipc/generate.ts index 1e977931..23f60c7f 100644 --- a/apps/desktop/src/main/ipc/generate.ts +++ b/apps/desktop/src/main/ipc/generate.ts @@ -35,6 +35,7 @@ import type { AgentStreamEvent } from '../../preload/index'; import { requestAsk } from '../ask-ipc'; import { CHATGPT_CODEX_PROVIDER_ID, getCodexTokenStore } from '../codex-oauth-ipc'; import { makeRuntimeVerifier } from '../done-verify'; +import { canonicalInvokeBaseUrl } from '../effective-provider-contract'; import { app, ipcMain } from '../electron-runtime'; import { acquireInFlightWorkspaceGeneration, @@ -771,8 +772,9 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe const apiKey = await resolveApiKeyForActive(active.model.provider, allowKeyless); // Once we've snapped to the canonical active provider, the renderer-supplied // baseUrl can no longer be trusted — it may belong to a different (stale) - // provider. Always use the per-provider baseUrl from cached config. - const baseUrl = active.baseUrl ?? undefined; + // provider. Always use the per-provider baseUrl from cached config, then + // canonicalize it the same way connection tests and the agent runtime do. + const baseUrl = canonicalInvokeBaseUrl(active.baseUrl, active.wire); if (active.overridden) { payload.baseUrl = baseUrl; } @@ -1352,7 +1354,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe const active = resolveActiveModel(cfg, hint); const allowKeyless = active.allowKeyless; const apiKey = await resolveApiKeyForActive(active.model.provider, allowKeyless); - const baseUrl = active.baseUrl ?? undefined; + const baseUrl = canonicalInvokeBaseUrl(active.baseUrl, active.wire); const tlsBypass = resolveTlsBypassFor(cfg, active.model.provider); const { workspaceRoot, promptContext } = await withStableWorkspacePath( @@ -1493,7 +1495,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe }); const allowKeyless = active.allowKeyless; const apiKey = await resolveApiKeyForActive(active.model.provider, allowKeyless); - const baseUrl = active.baseUrl ?? undefined; + const baseUrl = canonicalInvokeBaseUrl(active.baseUrl, active.wire); const tlsBypass = resolveTlsBypassFor(cfg, active.model.provider); const titleLogger: CoreLogger = { info: (event, data) => logIpc.info(event, data), diff --git a/apps/desktop/src/main/memory-ipc.ts b/apps/desktop/src/main/memory-ipc.ts index 8d1cbf68..2b81d11b 100644 --- a/apps/desktop/src/main/memory-ipc.ts +++ b/apps/desktop/src/main/memory-ipc.ts @@ -25,6 +25,7 @@ import { updateWorkspaceMemory, } from '@open-codesign/core'; import { getCodexTokenStore } from './codex-oauth-ipc'; +import { canonicalInvokeBaseUrl } from './effective-provider-contract'; import { app, ipcMain, shell } from './electron-runtime'; import { getLogger } from './logger'; import { getApiKeyForProvider, getCachedConfig, hasApiKeyForProvider } from './onboarding-ipc'; @@ -397,11 +398,12 @@ async function resolveUserMemoryConsolidationOptions( getApiKeyForProvider, hasApiKeyForProvider, }); + const baseUrl = canonicalInvokeBaseUrl(active.baseUrl, active.wire); return { force, model: active.model, apiKey, - ...(active.baseUrl !== null ? { baseUrl: active.baseUrl } : {}), + ...(baseUrl !== undefined ? { baseUrl } : {}), wire: active.wire, ...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}), ...(active.reasoningLevel !== undefined ? { reasoningLevel: active.reasoningLevel } : {}), diff --git a/apps/desktop/src/main/provider-settings.test.ts b/apps/desktop/src/main/provider-settings.test.ts index 126a83fe..2bd7a5fa 100644 --- a/apps/desktop/src/main/provider-settings.test.ts +++ b/apps/desktop/src/main/provider-settings.test.ts @@ -11,6 +11,7 @@ import { getAddProviderDefaults, isKeylessProviderAllowed, resolveActiveModel, + resolveProviderModel, toProviderRows, } from './provider-settings'; @@ -558,4 +559,33 @@ describe('resolveActiveModel', () => { }), ).toThrowError(CodesignError); }); + + it('threads builtin and model-discovery flags used by the invoke contract', () => { + const result = resolveActiveModel(baseCfg, { + provider: 'openrouter', + modelId: 'anthropic/claude-haiku-3', + }); + expect(result.builtin).toBe(true); + expect(result.supportsModelsEndpoint).toBe(true); + expect(result.modelDiscoveryMode).toBe('models'); + }); +}); + +describe('resolveProviderModel', () => { + it('does not snap a named provider back to cfg.activeProvider', () => { + const cfg = makeCfg({ + provider: 'openrouter', + modelPrimary: 'anthropic/claude-sonnet-4.6', + secrets: { + openai: { ciphertext: 'enc-oai' }, + openrouter: { ciphertext: 'enc-or' }, + }, + baseUrls: { openai: 'https://api.duckcoding.ai/v1' }, + }); + + const result = resolveProviderModel(cfg, 'openai', 'gpt-4o'); + expect(result.model).toEqual({ provider: 'openai', modelId: 'gpt-4o' }); + expect(result.baseUrl).toBe('https://api.duckcoding.ai/v1'); + expect(result.wire).toBe('openai-chat'); + }); }); diff --git a/apps/desktop/src/main/provider-settings.ts b/apps/desktop/src/main/provider-settings.ts index 6c73fd20..6cfe84d9 100644 --- a/apps/desktop/src/main/provider-settings.ts +++ b/apps/desktop/src/main/provider-settings.ts @@ -211,7 +211,7 @@ export function computeDeleteProviderResult(cfg: Config, toDelete: string): Dele * Result of resolving which provider/model to call against, given the canonical * cached config and the renderer's hint payload. */ -export interface ActiveModelResolution { +export interface ProviderModelResolution { model: ModelRef; baseUrl: string | null; wire: WireApi; @@ -219,10 +219,74 @@ export interface ActiveModelResolution { queryParams: Record | undefined; reasoningLevel: ReasoningLevel | undefined; allowKeyless: boolean; + builtin: boolean; + tlsRejectUnauthorized: boolean | undefined; + supportsModelsEndpoint: boolean; + modelDiscoveryMode: import('@open-codesign/shared').ProviderModelDiscoveryMode; +} + +/** + * Result of resolving which provider/model to call against, given the canonical + * cached config and the renderer's hint payload. + */ +export interface ActiveModelResolution extends ProviderModelResolution { /** True when the renderer-supplied hint provider didn't match the canonical active. */ overridden: boolean; } +function providerModelFromEntry( + providerId: string, + entry: ProviderEntry, + modelId: string, + cfg: Config, + missingSecretMessage: string, +): ProviderModelResolution { + const allowKeyless = isKeylessProviderAllowed(providerId, entry); + if (cfg.secrets[providerId] === undefined && !allowKeyless) { + throw new CodesignError(missingSecretMessage, ERROR_CODES.PROVIDER_KEY_MISSING); + } + const caps = resolveProviderCapabilities(providerId, entry); + return { + model: { provider: providerId, modelId }, + baseUrl: entry.baseUrl, + wire: entry.wire, + httpHeaders: entry.httpHeaders, + queryParams: entry.queryParams, + reasoningLevel: entry.reasoningLevel, + allowKeyless, + builtin: entry.builtin === true, + tlsRejectUnauthorized: entry.tlsRejectUnauthorized, + supportsModelsEndpoint: caps.supportsModelsEndpoint === true, + modelDiscoveryMode: caps.modelDiscoveryMode ?? 'models', + }; +} + +/** + * Resolve a stored provider the same way generate would if that provider were + * active — used by `connection:v1:test-provider`. Does not snap to + * `cfg.activeProvider`. + */ +export function resolveProviderModel( + cfg: Config, + providerId: string, + modelId?: string, +): ProviderModelResolution { + const entry = resolveEntryFor(cfg, providerId); + if (entry === null) { + throw new CodesignError( + `Provider "${providerId}" has no provider entry on disk.`, + ERROR_CODES.PROVIDER_NOT_SUPPORTED, + ); + } + return providerModelFromEntry( + providerId, + entry, + modelId ?? entry.defaultModel, + cfg, + `No API key stored for provider "${providerId}". Re-run onboarding to add one.`, + ); +} + export function resolveActiveModel( cfg: Config, hint: { provider: string; modelId: string }, @@ -235,23 +299,16 @@ export function resolveActiveModel( ERROR_CODES.PROVIDER_NOT_SUPPORTED, ); } - const allowKeyless = isKeylessProviderAllowed(activeId, entry); - if (cfg.secrets[activeId] === undefined && !allowKeyless) { - throw new CodesignError( - `No API key stored for active provider "${activeId}". Re-run onboarding to add one.`, - ERROR_CODES.PROVIDER_KEY_MISSING, - ); - } const overridden = activeId !== hint.provider; const modelId = overridden ? cfg.activeModel : hint.modelId; return { - model: { provider: activeId, modelId }, - baseUrl: entry.baseUrl, - wire: entry.wire, - httpHeaders: entry.httpHeaders, - queryParams: entry.queryParams, - reasoningLevel: entry.reasoningLevel, - allowKeyless, + ...providerModelFromEntry( + activeId, + entry, + modelId, + cfg, + `No API key stored for active provider "${activeId}". Re-run onboarding to add one.`, + ), overridden, }; } diff --git a/apps/desktop/src/renderer/src/components/settings/connection-test-toast.test.ts b/apps/desktop/src/renderer/src/components/settings/connection-test-toast.test.ts new file mode 100644 index 00000000..4df916c7 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/settings/connection-test-toast.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { connectionTestToastKind } from './connection-test-toast'; + +describe('connectionTestToastKind', () => { + it('treats a fully aligned probe as Connection OK', () => { + expect( + connectionTestToastKind({ + ok: true, + compatibility: 'compatible', + invokeParity: 'aligned', + probeMethod: 'models', + }), + ).toBe('ok'); + }); + + it('treats missing /models with working invoke as degraded, not failed', () => { + expect( + connectionTestToastKind({ + ok: true, + compatibility: 'degraded', + invokeParity: 'degraded-discovery', + probeMethod: 'chat_completion_degraded', + reasonCategory: 'model-discovery-degraded', + }), + ).toBe('degraded'); + }); + + it('distinguishes reachable-but-diverges-from-invoke from a hard failure', () => { + expect( + connectionTestToastKind({ + ok: false, + code: 'INVOKE_DIVERGED', + message: 'HTTP 404', + hint: 'The /models endpoint is reachable, but the inference endpoint used at generate-time failed.', + compatibility: 'diverges', + invokeParity: 'diverged', + reachableVia: 'models', + reasonCategory: 'invoke-contract-diverged', + }), + ).toBe('diverges'); + expect( + connectionTestToastKind({ + ok: false, + code: '401', + message: 'HTTP 401', + hint: 'API key 错误或权限不足', + compatibility: 'incompatible', + }), + ).toBe('failed'); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/settings/connection-test-toast.ts b/apps/desktop/src/renderer/src/components/settings/connection-test-toast.ts new file mode 100644 index 00000000..bdf8a00d --- /dev/null +++ b/apps/desktop/src/renderer/src/components/settings/connection-test-toast.ts @@ -0,0 +1,11 @@ +import type { ConnectionTestResponse } from '@open-codesign/shared'; + +export type ConnectionTestToastKind = 'ok' | 'degraded' | 'diverges' | 'failed'; + +/** Map the structured connection-test payload onto Settings toast variants. */ +export function connectionTestToastKind(res: ConnectionTestResponse): ConnectionTestToastKind { + if (res.ok && res.compatibility === 'degraded') return 'degraded'; + if (!res.ok && res.compatibility === 'diverges') return 'diverges'; + if (res.ok) return 'ok'; + return 'failed'; +} diff --git a/apps/desktop/src/renderer/src/components/settings/primitives.tsx b/apps/desktop/src/renderer/src/components/settings/primitives.tsx index 8c5e4e97..6ca8ade0 100644 --- a/apps/desktop/src/renderer/src/components/settings/primitives.tsx +++ b/apps/desktop/src/renderer/src/components/settings/primitives.tsx @@ -17,6 +17,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { ProviderRow } from '../../../../preload/index'; import { recordAction } from '../../lib/action-timeline'; import { useCodesignStore } from '../../store'; +import { connectionTestToastKind } from './connection-test-toast'; /** * Electron IPC wraps thrown errors as @@ -316,15 +317,48 @@ export function ProviderCard({ } try { const res = await window.codesign.connection.testProvider(row.provider); - recordAction({ type: 'connection.test', data: { provider: row.provider, ok: res.ok } }); - if (res.ok) { + recordAction({ + type: 'connection.test', + data: { + provider: row.provider, + ok: res.ok, + compatibility: 'compatibility' in res ? res.compatibility : undefined, + invokeParity: 'invokeParity' in res ? res.invokeParity : undefined, + }, + }); + const kind = connectionTestToastKind(res); + if (kind === 'ok') { pushToast({ variant: 'success', title: t('settings.providers.toast.connectionOk') }); + } else if (kind === 'degraded') { + pushToast({ + variant: 'info', + title: t('settings.providers.toast.connectionDegraded'), + description: t('settings.providers.toast.connectionDegradedBody'), + }); + } else if (kind === 'diverges') { + reportableErrorToast({ + code: 'CONNECTION_TEST_DIVERGED', + scope: 'settings', + title: t('settings.providers.toast.connectionDiverges'), + description: + res.ok === false + ? res.hint || res.message + : t('settings.providers.toast.connectionDivergesBody'), + context: { + provider: row.provider, + invokeParity: res.invokeParity, + ...(res.ok === false && res.invokeContract !== undefined + ? { invokeUrl: res.invokeContract.invokeUrl } + : {}), + }, + }); } else { reportableErrorToast({ code: 'CONNECTION_TEST_FAILED', scope: 'settings', title: t('settings.providers.toast.connectionFailed'), - description: res.hint || res.message, + description: + res.ok === false ? res.hint || res.message : t('settings.common.unknownError'), context: { provider: row.provider }, }); } diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 348521a2..dea1730d 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -543,6 +543,10 @@ "reasoningSaved": "Reasoning depth saved", "reasoningSaveFailed": "Failed to save reasoning depth", "connectionOk": "Connection OK", + "connectionDegraded": "Connected, model list unavailable", + "connectionDegradedBody": "Generate will work. This endpoint does not expose /models; Open CoDesign confirmed the real inference path instead.", + "connectionDiverges": "Reachable, but generate would fail", + "connectionDivergesBody": "The /models endpoint responded, but the inference URL used at generate-time did not. Connection test and generate share the same invoke contract.", "connectionFailed": "Connection failed" }, "cliProxyApi": { @@ -1052,7 +1056,8 @@ "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.", "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.", + "gatewayWafBlocked": "The gateway or reverse proxy blocked the generation request before it reached the model.", + "invokeContractDiverged": "The provider is reachable, but the inference endpoint used at generate-time failed. /models success is not enough — Open CoDesign generate uses this wire's real invoke URL.", "openaiResponsesMisconfigured": "The endpoint rejected the request shape. The wire may be wrong — try switching to openai-chat.", "unsupportedRole": "The endpoint rejected the role format sent to the model. This is usually a wire or model-policy mismatch.", "reasoningPolicy": "The endpoint rejected reasoning metadata for this model. Lower the reasoning depth and try again.", diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index 789c051a..1720a8e4 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -481,6 +481,10 @@ "reasoningSaved": "Profundidad de razonamiento guardada", "reasoningSaveFailed": "Error al guardar la profundidad de razonamiento", "connectionOk": "Conexión OK", + "connectionDegraded": "Conectado, lista de modelos no disponible", + "connectionDegradedBody": "Generate funcionará. Este endpoint no expone /models; Open CoDesign confirmó la ruta real de inferencia.", + "connectionDiverges": "Alcanzable, pero generate fallaría", + "connectionDivergesBody": "El endpoint /models respondió, pero la URL de inferencia usada al generar no. La prueba de conexión y generate comparten el mismo contrato de invocación.", "connectionFailed": "Conexión fallida" }, "cliProxyApi": { @@ -987,7 +991,8 @@ "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.", "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.", + "gatewayWafBlocked": "La pasarela o el proxy inverso bloqueó la generación antes de llegar al modelo.", + "invokeContractDiverged": "El proveedor es alcanzable, pero falló el endpoint de inferencia que usa generate. El éxito de /models no basta: Open CoDesign generate usa la URL real de invocación de este protocolo.", "openaiResponsesMisconfigured": "El punto final rechazó la forma de la solicitud. El protocolo puede ser incorrecto — intenta cambiar a openai-chat.", "unsupportedRole": "El endpoint rechazó el formato de rol enviado al modelo. Normalmente es una incompatibilidad de protocolo o política del modelo.", "reasoningPolicy": "El endpoint rechazó los metadatos de razonamiento para este modelo. Reduce la profundidad de razonamiento e inténtalo de nuevo.", diff --git a/packages/i18n/src/locales/pt-BR.json b/packages/i18n/src/locales/pt-BR.json index 520e584c..c4dd7951 100644 --- a/packages/i18n/src/locales/pt-BR.json +++ b/packages/i18n/src/locales/pt-BR.json @@ -445,6 +445,10 @@ "reasoningSaved": "Profundidade de raciocínio salva", "reasoningSaveFailed": "Falha ao salvar a profundidade de raciocínio", "connectionOk": "Conexão OK", + "connectionDegraded": "Conectado, lista de modelos indisponível", + "connectionDegradedBody": "O generate vai funcionar. Este endpoint não expõe /models; o Open CoDesign confirmou o caminho real de inferência.", + "connectionDiverges": "Alcançável, mas o generate falharia", + "connectionDivergesBody": "O endpoint /models respondeu, mas a URL de inferência usada no generate falhou. O teste de conexão e o generate compartilham o mesmo contrato de invocação.", "connectionFailed": "Falha na conexão", "saveFailed": "Falha ao salvar o provedor" }, @@ -949,7 +953,8 @@ "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.", "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.", + "gatewayWafBlocked": "O gateway ou proxy reverso bloqueou a geração antes de ela chegar ao modelo.", + "invokeContractDiverged": "O provedor é alcançável, mas o endpoint de inferência usado no generate falhou. Sucesso em /models não basta: o generate do Open CoDesign usa a URL real de invocação deste wire.", "openaiResponsesMisconfigured": "O endpoint rejeitou o formato da requisição. O wire pode estar errado; tente openai-chat.", "unsupportedRole": "O endpoint rejeitou o formato de role enviado ao modelo. Normalmente é incompatibilidade de wire ou política do modelo.", "reasoningPolicy": "O endpoint rejeitou metadados de reasoning para este modelo. Reduza a profundidade de reasoning e tente novamente.", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index ffede179..eee3604b 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -543,6 +543,10 @@ "reasoningSaved": "已保存推理深度", "reasoningSaveFailed": "保存推理深度失败", "connectionOk": "连接正常", + "connectionDegraded": "已连通,但无法列出模型", + "connectionDegradedBody": "实际生成可以使用。该端点没有 /models;Open CoDesign 已改为确认真实推理路径。", + "connectionDiverges": "能连通,但实际生成会失败", + "connectionDivergesBody": "/models 有响应,但生成时使用的推理地址失败。测试连接与实际调用现在共用同一套 invoke contract。", "connectionFailed": "连接失败" }, "cliProxyApi": { @@ -1048,7 +1052,8 @@ "sslError": "SSL / 证书错误(自签名或私有 CA 证书?)。可在「设置 → 服务商 → 该条目 → 禁用 TLS 校验」启用对可信内网端点的连接。", "endpointNotFound": "Base URL 已包含版本路径,但 Provider 没有暴露这个接口路径。", "gatewayIncompatible": "网关接受了连接但没有实现该 Provider 的 API。尝试切换 wire(例如改为 openai-chat)。", - "gatewayWafBlocked": "网关或反代在请求到达模型前拦截了生成请求。测试连接可能仍然通过,因为它只探测 /models 端点。", + "gatewayWafBlocked": "网关或反代在请求到达模型前拦截了生成请求。", + "invokeContractDiverged": "Provider 可连通,但生成时使用的推理端点失败。仅 /models 成功不够——Open CoDesign 的实际调用走该 wire 的真实 invoke URL。", "openaiResponsesMisconfigured": "端点拒绝了请求格式。wire 可能配错了——尝试切换到 openai-chat。", "unsupportedRole": "端点拒绝了发送给模型的 role 格式,通常是 wire 或模型策略不兼容。", "reasoningPolicy": "端点拒绝了该模型的 reasoning 元数据。请降低 reasoning 深度后重试。", diff --git a/packages/providers/src/index.test.ts b/packages/providers/src/index.test.ts index c2ec14ec..0742eac5 100644 --- a/packages/providers/src/index.test.ts +++ b/packages/providers/src/index.test.ts @@ -279,6 +279,38 @@ describe('complete', () => { expect(result.content).toBe('ok'); }); + it('canonicalizes openai-chat baseUrl the same way generate and connection tests do', async () => { + getModelMock.mockReturnValue(undefined); + completeSimpleMock.mockImplementationOnce(async (model, _context, opts) => { + expect(opts.baseUrl).toBe('https://gateway.example.com/v1'); + expect(model.baseUrl).toBe('https://gateway.example.com/v1'); + return { + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + api: 'openai-completions', + provider: 'gateway', + model: 'gpt-4o', + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: 'stop', + timestamp: Date.now(), + }; + }); + + await complete({ provider: 'gateway', modelId: 'gpt-4o' }, [{ role: 'user', content: 'hi' }], { + apiKey: 'sk-test', + wire: 'openai-chat', + baseUrl: 'https://gateway.example.com', + }); + expect(completeSimpleMock).toHaveBeenCalledTimes(1); + }); + it('appends image inputs to the final user turn for openai-codex-responses', async () => { getModelMock.mockReturnValue({ id: 'gpt-5.4', diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 34cfdf70..d6a8c1ff 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -9,6 +9,7 @@ import { type ChatMessage, CodesignError, + canonicalBaseUrl, ERROR_CODES, type ModelRef, type ReasoningLevel as SharedReasoningLevel, @@ -354,11 +355,15 @@ export async function complete( throw new CodesignError('Missing API key', ERROR_CODES.PROVIDER_AUTH_MISSING); } const apiKey = trimmedApiKey.length > 0 ? trimmedApiKey : 'open-codesign-keyless'; + const baseUrl = + opts.baseUrl !== undefined && opts.wire !== undefined + ? canonicalBaseUrl(opts.baseUrl, opts.wire) + : opts.baseUrl; // Gemini's OpenAI-compat endpoint rejects the `models/` prefix that its own // /models listing returns (issue #175). Normalize on the wire only; Settings // keeps the prefixed form so provider/model UX stays in sync with /models. - const effectiveModelId = normalizeGeminiModelId(model.modelId, opts.baseUrl); + const effectiveModelId = normalizeGeminiModelId(model.modelId, baseUrl); const pi = (await import('@mariozechner/pi-ai')) as unknown as { getModel: (provider: string, modelId: string) => PiModel | undefined; @@ -380,7 +385,7 @@ export async function complete( let piModel = pi.getModel(model.provider, effectiveModelId); if (!piModel) { if (opts.wire !== undefined) { - piModel = synthesizeWireModel(model.provider, effectiveModelId, opts.wire, opts.baseUrl); + piModel = synthesizeWireModel(model.provider, effectiveModelId, opts.wire, baseUrl); } else if (model.provider === 'openrouter') { piModel = synthesizeOpenRouterModel(effectiveModelId); } else { @@ -404,7 +409,7 @@ export async function complete( } = { apiKey, }; - if (opts.baseUrl !== undefined) piOpts.baseUrl = opts.baseUrl; + if (baseUrl !== undefined) piOpts.baseUrl = baseUrl; if (opts.signal !== undefined) piOpts.signal = opts.signal; if (opts.maxTokens !== undefined) piOpts.maxTokens = opts.maxTokens; if (opts.reasoning !== undefined && opts.reasoning !== 'off') piOpts.reasoning = opts.reasoning; @@ -437,10 +442,7 @@ export async function complete( // sub2api-issued key and you hit the plain API-key branch. Force the // identity headers for custom anthropic endpoints so the WAF admits us. // User-supplied httpHeaders keep precedence. - if ( - shouldForceClaudeCodeIdentity(opts.wire, opts.baseUrl) && - !looksLikeClaudeOAuthToken(apiKey) - ) { + if (shouldForceClaudeCodeIdentity(opts.wire, baseUrl) && !looksLikeClaudeOAuthToken(apiKey)) { piOpts.headers = { ...claudeCodeIdentityHeaders(), ...(piOpts.headers ?? {}) }; } diff --git a/packages/shared/src/base-url.test.ts b/packages/shared/src/base-url.test.ts index c38456bb..3c2d0918 100644 --- a/packages/shared/src/base-url.test.ts +++ b/packages/shared/src/base-url.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; import { canonicalBaseUrl, ensureVersionedBase, + inferenceEndpointUrl, modelsEndpointUrl, + modelsProbeRelationForProvider, + modelsProbeRelationForWire, stripInferenceEndpointSuffix, } from './base-url'; @@ -446,3 +449,56 @@ describe('modelsEndpointUrl', () => { ).toThrow(/modelsHint/); }); }); + +describe('inferenceEndpointUrl', () => { + it('openai-chat: posts to /chat/completions on the canonical base', () => { + expect(inferenceEndpointUrl('https://api.openai.com', 'openai-chat')).toBe( + 'https://api.openai.com/v1/chat/completions', + ); + expect( + inferenceEndpointUrl('https://open.bigmodel.cn/api/paas/v4/chat/completions', 'openai-chat'), + ).toBe('https://open.bigmodel.cn/api/paas/v4/chat/completions'); + }); + + it('openai-responses: posts to /responses, never /chat/completions', () => { + expect(inferenceEndpointUrl('https://api.openai.com/v1', 'openai-responses')).toBe( + 'https://api.openai.com/v1/responses', + ); + }); + + it('anthropic: posts to /v1/messages from the unversioned root', () => { + expect(inferenceEndpointUrl('https://api.anthropic.com/v1', 'anthropic')).toBe( + 'https://api.anthropic.com/v1/messages', + ); + }); + + it('openai-codex-responses: posts to /codex/responses from the bare ChatGPT base', () => { + expect(inferenceEndpointUrl('https://chatgpt.com/backend-api', 'openai-codex-responses')).toBe( + 'https://chatgpt.com/backend-api/codex/responses', + ); + }); +}); + +describe('modelsProbeRelation', () => { + it('treats GET /models as optional discovery for HTTP wires used at generate-time', () => { + expect(modelsProbeRelationForWire('openai-chat')).toBe('optional-discovery'); + expect(modelsProbeRelationForWire('openai-responses')).toBe('optional-discovery'); + expect(modelsProbeRelationForWire('anthropic')).toBe('optional-discovery'); + }); + + it('marks ChatGPT Codex /models as unavailable — generate never calls it', () => { + expect(modelsProbeRelationForWire('openai-codex-responses')).toBe('unavailable'); + }); + + it('honors provider capability flags so static-hint listings skip /models', () => { + expect( + modelsProbeRelationForProvider('openai-chat', { + supportsModelsEndpoint: false, + modelDiscoveryMode: 'manual', + }), + ).toBe('unavailable'); + expect( + modelsProbeRelationForProvider('openai-responses', { modelDiscoveryMode: 'static-hint' }), + ).toBe('unavailable'); + }); +}); diff --git a/packages/shared/src/base-url.ts b/packages/shared/src/base-url.ts index bc033fb6..6fbc5a0d 100644 --- a/packages/shared/src/base-url.ts +++ b/packages/shared/src/base-url.ts @@ -138,3 +138,56 @@ export function modelsEndpointUrl(baseUrl: string, wire: CanonicalWire): string // already-versioned base. return wire === 'anthropic' ? `${base}/v1/models` : `${base}/models`; } + +/** + * The URL runtime generate actually POSTs to for this wire. + * + * GET /models is discovery-only and is never on the generate path. Connection + * tests that want to validate the real invocation contract must probe this + * URL (or, for ChatGPT Codex, the OAuth token used to call it). + */ +export function inferenceEndpointUrl(baseUrl: string, wire: CanonicalWire): string { + const base = canonicalBaseUrl(baseUrl, wire); + switch (wire) { + case 'anthropic': + return `${base}/v1/messages`; + case 'openai-responses': + return `${base}/responses`; + case 'openai-codex-responses': + return `${base}/codex/responses`; + case 'openai-chat': + return `${base}/chat/completions`; + } +} + +/** + * How GET /models relates to real generate. + * + * - `optional-discovery`: /models uses the same auth, headers, canonical + * baseUrl, and wire as invoke, but generate does not call it. A missing + * /models endpoint with a working inference endpoint is degraded + * discovery, not an invoke failure. + * - `unavailable`: the wire has no user-discoverable /models (ChatGPT Codex) + * or the provider declared that model listing is static/manual. Do not + * treat a failed /models probe as evidence that generate will fail. + */ +export type ModelsProbeRelation = 'optional-discovery' | 'unavailable'; + +export function modelsProbeRelationForWire(wire: CanonicalWire): ModelsProbeRelation { + return wire === 'openai-codex-responses' ? 'unavailable' : 'optional-discovery'; +} + +export function modelsProbeRelationForProvider( + wire: CanonicalWire, + caps?: { + supportsModelsEndpoint?: boolean | undefined; + modelDiscoveryMode?: 'models' | 'static-hint' | 'manual' | undefined; + }, +): ModelsProbeRelation { + if (wire === 'openai-codex-responses') return 'unavailable'; + if (caps?.supportsModelsEndpoint === false) return 'unavailable'; + if (caps?.modelDiscoveryMode === 'static-hint' || caps?.modelDiscoveryMode === 'manual') { + return 'unavailable'; + } + return 'optional-discovery'; +} diff --git a/packages/shared/src/connection-test.ts b/packages/shared/src/connection-test.ts new file mode 100644 index 00000000..368062b7 --- /dev/null +++ b/packages/shared/src/connection-test.ts @@ -0,0 +1,78 @@ +import type { ModelsProbeRelation } from './base-url'; +import type { WireApi } from './config'; +import type { DiagnosticCategory } from './diagnostics'; + +/** Auth shape runtime generate will send for this provider. */ +export type InvokeAuthMode = 'codex-oauth' | 'anthropic-oauth' | 'bearer' | 'x-api-key' | 'keyless'; + +/** + * Whether a connection probe used the same invoke contract as generate. + * + * - `aligned`: the probe used the generate auth/header/baseUrl/wire, and the + * inference endpoint either succeeded or was the Codex OAuth path. + * - `degraded-discovery`: inference works (generate would work) but GET + * /models is missing or unusable. + * - `diverged`: something is reachable (usually GET /models) but the real + * inference endpoint used at generate-time failed. + */ +export type InvokeParity = 'aligned' | 'degraded-discovery' | 'diverged'; + +export type ConnectionProbeMethod = + | 'models' + | 'invoke' + | 'chat_completion_degraded' + | 'responses_degraded' + | 'anthropic_messages_degraded' + | 'codex_oauth'; + +/** + * Non-secret snapshot of the contract generate will use. Safe to send to the + * renderer so Settings can distinguish "host reachable" from "invoke-ready". + */ +export interface InvokeContractView { + provider: string; + wire: WireApi; + canonicalBaseUrl: string; + invokeUrl: string; + modelsUrl: string | null; + modelsProbeRelation: ModelsProbeRelation; + authMode: InvokeAuthMode; + allowKeyless: boolean; +} + +export interface ConnectionTestResult { + ok: true; + /** + * `models` when GET /models succeeded *and* the invoke probe confirmed the + * generate path. + * `invoke` when /models was skipped (unsupported) and the inference + * endpoint was probed directly. + * `*_degraded` when /models failed but the wire's real inference endpoint + * is alive — generate would work. + * `codex_oauth` when ChatGPT Codex resolved the same refreshable OAuth + * token generate uses (no /models on this wire). + */ + probeMethod?: ConnectionProbeMethod; + compatibility?: 'compatible' | 'degraded'; + reasonCategory?: DiagnosticCategory; + invokeParity?: InvokeParity; + modelsProbeRelation?: ModelsProbeRelation; + invokeContract?: InvokeContractView; +} + +export interface ConnectionTestError { + ok: false; + code: 'IPC_BAD_INPUT' | '401' | '404' | 'ECONNREFUSED' | 'NETWORK' | 'PARSE' | 'INVOKE_DIVERGED'; + message: string; + hint: string; + compatibility?: 'incompatible' | 'diverges'; + reasonCategory?: DiagnosticCategory; + invokeParity?: InvokeParity; + modelsProbeRelation?: ModelsProbeRelation; + /** Present when GET /models succeeded but the generate inference path failed. */ + reachableVia?: 'models'; + probeMethod?: ConnectionProbeMethod; + invokeContract?: InvokeContractView; +} + +export type ConnectionTestResponse = ConnectionTestResult | ConnectionTestError; diff --git a/packages/shared/src/diagnostics.test.ts b/packages/shared/src/diagnostics.test.ts index f6afc5fd..51b9c77c 100644 --- a/packages/shared/src/diagnostics.test.ts +++ b/packages/shared/src/diagnostics.test.ts @@ -123,6 +123,13 @@ describe('diagnose', () => { expect(result[0]?.suggestedFix?.label).toBe('diagnostics.fix.disableTls'); }); + it('maps INVOKE_DIVERGED to invoke-contract-diverged', () => { + const result = diagnose('INVOKE_DIVERGED', baseCtx); + expect(result[0]?.cause).toBe('diagnostics.cause.invokeContractDiverged'); + expect(result[0]?.category).toBe('invoke-contract-diverged'); + expect(result[0]?.suggestedFix?.kind).toBe('openSettings'); + }); + it('maps unknown codes to generic unknown cause', () => { const result = diagnose('SOME_UNKNOWN_CODE', baseCtx); expect(result[0]?.cause).toBe('diagnostics.cause.unknown'); diff --git a/packages/shared/src/diagnostics.ts b/packages/shared/src/diagnostics.ts index bf3008f0..601f53d3 100644 --- a/packages/shared/src/diagnostics.ts +++ b/packages/shared/src/diagnostics.ts @@ -33,6 +33,7 @@ export type DiagnosticCategory = | 'gateway-waf-blocked' | 'generation-timeout' | 'model-discovery-degraded' + | 'invoke-contract-diverged' | 'transport-interrupted' | 'upstream-server-error' | 'reference-url-invalid' @@ -234,6 +235,21 @@ export function diagnose(code: ErrorCode, ctx: DiagnoseContext): DiagnosticHypot ]; } + if (normalised === 'INVOKE_DIVERGED') { + return [ + h({ + cause: 'diagnostics.cause.invokeContractDiverged', + category: 'invoke-contract-diverged', + severity: 'warning', + suggestedFix: { + kind: 'openSettings', + label: 'diagnostics.fix.switchWire', + settingsTab: 'models', + }, + }), + ]; + } + if (normalised === 'SSL') { return [ h({ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 00ba1848..aad0d674 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -277,11 +277,14 @@ export const ProjectDraft = z.object({ }); export type ProjectDraft = z.infer; -export type { CanonicalWire } from './base-url'; +export type { CanonicalWire, ModelsProbeRelation } from './base-url'; export { canonicalBaseUrl, ensureVersionedBase, + inferenceEndpointUrl, modelsEndpointUrl, + modelsProbeRelationForProvider, + modelsProbeRelationForWire, stripInferenceEndpointSuffix, } from './base-url'; export { CodesignError } from './codesign-error'; @@ -334,6 +337,15 @@ export { toPersistedV3, WireApiSchema, } from './config'; +export type { + ConnectionProbeMethod, + ConnectionTestError, + ConnectionTestResponse, + ConnectionTestResult, + InvokeAuthMode, + InvokeContractView, + InvokeParity, +} from './connection-test'; export type { DesignToken } from './design-token'; export { DesignTokenSet, DesignTokenV1 } from './design-token'; export type {