diff --git a/docs/opencode-go.md b/docs/opencode-go.md index 22d5a941..5627919e 100644 --- a/docs/opencode-go.md +++ b/docs/opencode-go.md @@ -19,6 +19,8 @@ The provider uses one credential with two upstream wire protocols: The selective proxy diverts only explicit `clodex:opencode-go:...` model ids or saved aliases. Ordinary Claude model traffic remains on Claude Code's native Anthropic connection. +Both endpoints require an `x-opencode-session` header and answer `MissingSessionID` without one. Clodex sends Claude Code's own session id on every Go request — proxy mode, the API server's `/anthropic/v1/messages`, and both branches of its `/openai/v1/chat/completions` — so Go can keep a conversation on one backend. A request with no client session id gets one stable per-process id instead. Note what that fallback means for a shared `clodex server`: every session-less client of one server process presents the same id to Go, which lets Go correlate those requests with each other. It discloses no content, and a header is required either way, so the alternative would be a fresh id per request and no prefix-cache locality at all. + ## Supported transport scope The upstream OpenCode Go catalog also contains Responses-API models. Clodex intentionally excludes those entries from this provider (currently Grok and mainline GPT; other unmapped ids are reported by the updater until their transport is verified). The supported catalog contains only Anthropic Messages and Chat Completions models. diff --git a/scripts/update-opencode-go-models.mjs b/scripts/update-opencode-go-models.mjs index 595a98a5..409ea4eb 100755 --- a/scripts/update-opencode-go-models.mjs +++ b/scripts/update-opencode-go-models.mjs @@ -37,6 +37,8 @@ const ANTHROPIC_BASE_URL = 'https://opencode.ai/zen/go'; // models (grok, mainline gpt) are deliberately absent. const TRANSPORTS = Object.assign(Object.create(null), { 'deepseek-v4-flash': 'openai-completions', + // Measured 2026-09-11: V4.1 Flash answers on /v1/messages (thinking block + text). + 'deepseek-v4.1-flash': 'anthropic-messages', 'deepseek-v4-pro': 'openai-completions', 'glm-5.1': 'openai-completions', 'glm-5.2': 'openai-completions', diff --git a/src/data/opencode-go-models.json b/src/data/opencode-go-models.json index 648a1d72..6f704ded 100644 --- a/src/data/opencode-go-models.json +++ b/src/data/opencode-go-models.json @@ -1,12 +1,12 @@ [ { "id": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash (2x usage)", + "name": "DeepSeek V4 Flash", "contextWindow": 1000000, "cost": { - "input": 0.07, - "output": 0.14, - "cache_read": 0.0014 + "input": 0.15, + "output": 0.6, + "cache_read": 0.003 }, "modelFormat": "openai", "npm": "@ai-sdk/openai-compatible", @@ -34,12 +34,12 @@ }, { "id": "deepseek-v4-pro", - "name": "DeepSeek V4 Pro", + "name": "DeepSeek V4 Pro (New)", "contextWindow": 1000000, "cost": { - "input": 0.435, - "output": 0.87, - "cache_read": 0.003625 + "input": 0.66, + "output": 1.98, + "cache_read": 0.022 }, "modelFormat": "openai", "npm": "@ai-sdk/openai-compatible", @@ -65,6 +65,30 @@ "upstreamModelId": "deepseek-v4-pro", "family": "deepseek" }, + { + "id": "deepseek-v4.1-flash", + "name": "DeepSeek V4.1 Flash", + "contextWindow": 1000000, + "cost": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.003 + }, + "modelFormat": "anthropic", + "npm": "@ai-sdk/anthropic", + "apiUrl": "https://opencode.ai/zen/go", + "reasoning": true, + "modalities": [ + "text", + "image" + ], + "compatibility": { + "supportsCountTokens": false, + "supportsReasoningEffort": false + }, + "upstreamModelId": "deepseek-v4.1-flash", + "family": "deepseek" + }, { "id": "glm-5.1", "name": "GLM-5.1", @@ -82,10 +106,10 @@ "text" ], "compatibility": { + "supportsReasoningEffort": false, "supportsStore": false, "supportsDeveloperRole": false, - "maxTokensField": "max_tokens", - "supportsReasoningEffort": false + "maxTokensField": "max_tokens" }, "upstreamModelId": "glm-5.1", "family": "glm" @@ -125,13 +149,13 @@ }, { "id": "gpt-5.6-luna", - "name": "GPT-5.6 Luna (2x usage)", + "name": "GPT-5.6 Luna", "contextWindow": 1050000, "cost": { - "input": 0.1, - "output": 0.6, - "cache_read": 0.01, - "cache_write": 0.125 + "input": 0.2, + "output": 1.2, + "cache_read": 0.02, + "cache_write": 0.25 }, "modelFormat": "openai", "npm": "@ai-sdk/openai-compatible", @@ -141,8 +165,6 @@ "text", "image" ], - "upstreamModelId": "gpt-5.6-luna", - "family": "gpt", "compatibility": { "reasoningEffortMap": { "off": "none", @@ -156,7 +178,9 @@ "supportsStore": false, "supportsDeveloperRole": false, "maxTokensField": "max_tokens" - } + }, + "upstreamModelId": "gpt-5.6-luna", + "family": "gpt" }, { "id": "hy3", @@ -242,10 +266,10 @@ "image" ], "compatibility": { + "supportsReasoningEffort": false, "supportsStore": false, "supportsDeveloperRole": false, - "maxTokensField": "max_tokens", - "supportsReasoningEffort": false + "maxTokensField": "max_tokens" }, "upstreamModelId": "kimi-k2.7-code", "family": "kimi" @@ -302,10 +326,10 @@ "image" ], "compatibility": { + "supportsReasoningEffort": false, "supportsStore": false, "supportsDeveloperRole": false, - "maxTokensField": "max_tokens", - "supportsReasoningEffort": false + "maxTokensField": "max_tokens" }, "upstreamModelId": "mimo-v2.5", "family": "mimo" @@ -327,10 +351,10 @@ "text" ], "compatibility": { + "supportsReasoningEffort": false, "supportsStore": false, "supportsDeveloperRole": false, - "maxTokensField": "max_tokens", - "supportsReasoningEffort": false + "maxTokensField": "max_tokens" }, "upstreamModelId": "mimo-v2.5-pro", "family": "mimo" @@ -352,10 +376,10 @@ "text" ], "compatibility": { + "supportsReasoningEffort": false, "supportsStore": false, "supportsDeveloperRole": false, - "maxTokensField": "max_tokens", - "supportsReasoningEffort": false + "maxTokensField": "max_tokens" }, "upstreamModelId": "minimax-m2.7", "family": "minimax" @@ -377,12 +401,12 @@ "text", "image" ], - "upstreamModelId": "minimax-m3", - "family": "minimax", "compatibility": { "supportsCountTokens": false, "supportsReasoningEffort": false - } + }, + "upstreamModelId": "minimax-m3", + "family": "minimax" }, { "id": "qwen3.6-plus", @@ -403,10 +427,6 @@ "image" ], "compatibility": { - "supportsStore": false, - "supportsDeveloperRole": false, - "thinkingFormat": "qwen", - "maxTokensField": "max_tokens", "reasoningEffortMap": { "low": "low", "medium": "medium", @@ -415,7 +435,11 @@ "max": "max", "minimal": null, "off": null - } + }, + "supportsStore": false, + "supportsDeveloperRole": false, + "thinkingFormat": "qwen", + "maxTokensField": "max_tokens" }, "upstreamModelId": "qwen3.6-plus", "family": "qwen3.6" @@ -437,12 +461,12 @@ "modalities": [ "text" ], - "upstreamModelId": "qwen3.7-max", - "family": "qwen3.7", "compatibility": { "supportsCountTokens": false, "supportsReasoningEffort": false - } + }, + "upstreamModelId": "qwen3.7-max", + "family": "qwen3.7" }, { "id": "qwen3.7-plus", @@ -462,12 +486,12 @@ "text", "image" ], - "upstreamModelId": "qwen3.7-plus", - "family": "qwen3.7", "compatibility": { "supportsCountTokens": false, "supportsReasoningEffort": false - } + }, + "upstreamModelId": "qwen3.7-plus", + "family": "qwen3.7" }, { "id": "qwen3.8-max", @@ -487,11 +511,11 @@ "text", "image" ], - "upstreamModelId": "qwen3.8-max", - "family": "qwen3.8", "compatibility": { "supportsCountTokens": false, "supportsReasoningEffort": false - } + }, + "upstreamModelId": "qwen3.8-max", + "family": "qwen3.8" } ] diff --git a/src/data/opencode-go-models.ts b/src/data/opencode-go-models.ts index dc475fc5..7f5d0926 100644 --- a/src/data/opencode-go-models.ts +++ b/src/data/opencode-go-models.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import models from './opencode-go-models.json'; import type { CachedModel } from '../registry/types.js'; @@ -6,7 +7,7 @@ export const OPENCODE_GO_PROVIDER_NAME = 'OpenCode Go'; export const OPENCODE_GO_COMPLETIONS_BASE_URL = 'https://opencode.ai/zen/go/v1'; export const OPENCODE_GO_ANTHROPIC_BASE_URL = 'https://opencode.ai/zen/go'; export const OPENCODE_GO_SOURCE = 'https://models.dev/api.json'; -export const OPENCODE_GO_SOURCE_FETCHED_AT = '2026-08-08T20:10:24.208Z'; +export const OPENCODE_GO_SOURCE_FETCHED_AT = '2026-09-11T08:20:44.219Z'; type OpenCodeGoModel = Pick & Partial>; @@ -25,3 +26,35 @@ type OpenCodeGoModel = Pick export function buildOpenCodeGoModels(): OpenCodeGoModel[] { return structuredClone(models) as unknown as OpenCodeGoModel[]; } + +/** Used when the client sent no session id; one per process, never per request. */ +const OPENCODE_GO_FALLBACK_SESSION_ID = randomUUID(); + +/** + * OpenCode Go rejects any request that carries no session id — both + * `/v1/messages` and `/v1/chat/completions` answer + * `MissingSessionID: Request is missing x-opencode-session` (measured + * 2026-09-11 against the live endpoint). The header lets Go pin a conversation + * to one backend and keep its prefix cache warm, so forward Claude Code's own + * session id where one is known and fall back to a stable per-process id. + * + * A model counts as Go by provider id or by pointing at the Go base URL. The + * URL arm is what covers an imported or migrated provider whose id has drifted + * from the canonical `opencode-go` (a shape `registry/resolve-template.ts` + * supports): there the URL is the only remaining signal. It therefore has to + * read every field a Go entry can carry its URL in — `apiBaseUrl` is the one + * the runtime `ServerModelInfo` actually populates for openai-compatible + * providers (`server/models.ts`, built in `provider-catalog.ts`), and + * `baseUrl` is the anthropic-format sibling; `apiUrl` is the cached-registry + * spelling. + */ +export function openCodeGoSessionHeaders( + model: { providerId?: string; apiUrl?: string; baseUrl?: string; apiBaseUrl?: string }, + claudeSessionId: string | undefined, +): Record | undefined { + const url = String(model.apiBaseUrl ?? model.baseUrl ?? model.apiUrl ?? ''); + const isGo = model.providerId === OPENCODE_GO_PROVIDER_ID + || /^https:\/\/opencode\.ai\/zen\/go(\/|$)/.test(url); + if (!isGo) return undefined; + return { 'x-opencode-session': claudeSessionId ?? OPENCODE_GO_FALLBACK_SESSION_ID }; +} diff --git a/src/provider-templates.ts b/src/provider-templates.ts index eef6ba3b..73f8fdf3 100644 --- a/src/provider-templates.ts +++ b/src/provider-templates.ts @@ -7,6 +7,7 @@ import { OPENCODE_GO_COMPLETIONS_BASE_URL, OPENCODE_GO_PROVIDER_ID, OPENCODE_GO_PROVIDER_NAME, + openCodeGoSessionHeaders, } from './data/opencode-go-models.js'; export type ProviderAuthType = 'api' | 'oauth' | 'none'; @@ -81,6 +82,11 @@ export async function verifyOpenCodeGoCredential(apiKey: string): Promise plog(message), claudeCodeSessionId, - extraHeaders: route.headers, + extraHeaders: { ...route.headers, ...goSessionHeaders }, refreshToken: route.refreshToken, onTokenRefreshed: refreshed => { route.apiKey = refreshed; }, signal: clientAbort.signal, @@ -627,6 +636,11 @@ export async function startProxyCatalog( upstreamModelId: route.realModelId, }, }); + const goSdkSessionHeaders = openCodeGoSessionHeaders( + { providerId: route.providerId, baseUrl: route.baseURL }, + claudeSessionId, + ); + if (goSdkSessionHeaders) params.headers = { ...params.headers, ...goSdkSessionHeaders }; plog(() => `sdk: npm=${route.npm} model=${route.realModelId}, stream=${clientWantsStream}, ` + `tools=${anthropicBody.tools?.length ?? 0}, msgs=${params.messages.length}`, diff --git a/src/sdk-adapter.ts b/src/sdk-adapter.ts index 9eb1f619..76c8171a 100644 --- a/src/sdk-adapter.ts +++ b/src/sdk-adapter.ts @@ -202,6 +202,8 @@ export interface SdkCallParams { maxOutputTokens?: number; temperature?: number; providerOptions?: Record>; + /** Per-request upstream headers; `streamText`/`generateText` take them as-is. */ + headers?: Record; } // ── system ─────────────────────────────────────────────────────────────────── diff --git a/src/server/router.ts b/src/server/router.ts index 45f9ee51..3f71cdb4 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -63,6 +63,7 @@ import { type AnthropicRequest, } from '../sdk-adapter.js'; import { withResponsesWebSocketDiagnosticContext } from '../oauth/responses-websocket.js'; +import { openCodeGoSessionHeaders } from '../data/opencode-go-models.js'; import { listenTcpServer, tcpListenerUrlHost } from '../listener-ready.js'; export interface ServerOptions { @@ -326,6 +327,7 @@ async function handleAnthropicMessages( const forwardBody: Record = { ...body, model: upstreamModelId(model) }; const authType = model.authType ?? 'api'; const isOAuth = authType === 'oauth'; + const goSessionHeaders = openCodeGoSessionHeaders(model, claudeSessionId); auditInference(options, { requestId, @@ -362,7 +364,7 @@ async function handleAnthropicMessages( authType, log: message => plog(message), claudeCodeSessionId, - extraHeaders: model.headers, + extraHeaders: { ...model.headers, ...goSessionHeaders }, refreshToken, onTokenRefreshed: refreshed => { model.apiKey = refreshed; }, signal: clientAbort.signal, @@ -440,6 +442,8 @@ async function handleAnthropicMessages( maxTools: npmMaxTools, log: plog, }); + const goSdkSessionHeaders = openCodeGoSessionHeaders(model, claudeSessionId); + if (goSdkSessionHeaders) params.headers = { ...params.headers, ...goSdkSessionHeaders }; const clientWantsStream = Boolean(body.stream); // Use the display name in the response model field when masking is on — Claude // Desktop shows the response model field in its status bar chip, so this surfaces @@ -680,6 +684,15 @@ async function handleOpenAIChatCompletions( const model = lookupModel(res, options.catalog, body.model); if (!model) return; + // An OpenAI-format body carries no Claude metadata block, so the session id can + // only come from the header Claude Code sends. Absent one, openCodeGoSessionHeaders + // falls back to its stable per-process id — Go rejects a request with no session + // header at all, on this route exactly as on /v1/messages. + const openAiSessionIdHeader = Array.isArray(req.headers['x-claude-code-session-id']) + ? req.headers['x-claude-code-session-id'][0] + : req.headers['x-claude-code-session-id']; + const goSessionHeaders = openCodeGoSessionHeaders(model, openAiSessionIdHeader); + if (supportsDirectOpenAIChatCompletions(model)) { if (model.completionsUrl && !/^https?:\/\//i.test(model.completionsUrl)) { sendJson(res, 400, { error: { message: `Invalid provider completionsUrl: must be http:// or https://` } }); @@ -720,7 +733,7 @@ async function handleOpenAIChatCompletions( try { await relayAnthropicMessages(res, completionsUrl, forwardBody, apiKey, Boolean(body.stream), { authType: model.authType ?? 'api', - extraHeaders: model.headers, + extraHeaders: { ...model.headers, ...goSessionHeaders }, refreshToken, onTokenRefreshed: refreshed => { model.apiKey = refreshed; }, signal: clientAbort.signal, @@ -768,6 +781,7 @@ async function handleOpenAIChatCompletions( const baseURL = model.modelFormat === 'anthropic' ? model.baseUrl : model.apiBaseUrl; const openAiOAuth = isOpenAiOAuthRoute(model); const params = translateOpenAiRequest(body as unknown as OpenAiRequest, { openAiOAuth }); + if (goSessionHeaders) params.headers = { ...params.headers, ...goSessionHeaders }; const clientWantsStream = Boolean(body.stream); const responseModelId = getResponseModelId(body.model, model, options); diff --git a/tests/opencode-go-catalog-invariants.test.ts b/tests/opencode-go-catalog-invariants.test.ts index d5a58613..28f0c2cf 100644 --- a/tests/opencode-go-catalog-invariants.test.ts +++ b/tests/opencode-go-catalog-invariants.test.ts @@ -80,7 +80,7 @@ describe('opencode-go catalog invariants', () => { it('keeps prototype names unmapped while regenerating every reviewed model', () => { const mappedIds = [ - 'deepseek-v4-flash', 'deepseek-v4-pro', 'glm-5.1', 'glm-5.2', 'gpt-5.6-luna', + 'deepseek-v4-flash', 'deepseek-v4.1-flash', 'deepseek-v4-pro', 'glm-5.1', 'glm-5.2', 'gpt-5.6-luna', 'hy3', 'kimi-k2.6', 'kimi-k2.7-code', 'kimi-k3', 'mimo-v2.5', 'mimo-v2.5-pro', 'minimax-m2.7', 'minimax-m3', 'qwen3.6-plus', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.8-max', diff --git a/tests/opencode-go-session-header-wire.test.ts b/tests/opencode-go-session-header-wire.test.ts new file mode 100644 index 00000000..6b9454fb --- /dev/null +++ b/tests/opencode-go-session-header-wire.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { createServer } from 'node:http'; +import { createLanguageModel } from '../src/provider-factory.js'; +import { generateAnthropicResponse } from '../src/sdk-adapter.js'; +import { openCodeGoSessionHeaders } from '../src/data/opencode-go-models.js'; + +/** + * The SDK-route tests in `opencode-go-session-header.test.ts` mock model creation + * and the generation call, so they assert `params.headers` rather than what leaves + * the process — overwriting the header inside sdk-adapter's generation calls passes + * that whole suite. This file closes that gap the only way it can be closed: no + * mocks at all, a real `@ai-sdk/openai-compatible` model pointed at a local + * listener, and the assertion made on the bytes the listener received. + */ +const SESSION_ID = 'f3c1a0f4-5cf0-4b1a-9d3e-6a2a1a5c9f01'; + +async function startChatCompletionsListener(): Promise<{ + baseUrl: string; + received: Array; + close: () => Promise; +}> { + const received: Array = []; + const server = createServer(async (req, res) => { + for await (const _chunk of req) { /* drain */ } + const raw = req.headers['x-opencode-session']; + received.push(Array.isArray(raw) ? raw[0] : raw); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'chatcmpl-wire', + object: 'chat.completion', + created: 0, + model: 'deepseek-v4.1-flash', + choices: [{ index: 0, message: { role: 'assistant', content: 'wire ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing listener address'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + received, + close: () => new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve()))), + }; +} + +describe('the Go session header reaches the wire through the real SDK path', () => { + it('arrives as a request header on an openai-compatible generation', async () => { + const listener = await startChatCompletionsListener(); + try { + const model = await createLanguageModel({ + npm: '@ai-sdk/openai-compatible', + modelId: 'deepseek-v4.1-flash', + apiKey: 'go-key', + baseURL: listener.baseUrl, + providerId: 'opencode-go', + authType: 'api', + }); + const headers = openCodeGoSessionHeaders({ providerId: 'opencode-go' }, SESSION_ID); + await generateAnthropicResponse( + model, + { messages: [{ role: 'user', content: 'hi' }], maxOutputTokens: 16, headers }, + 'deepseek-v4.1-flash', + ); + expect(listener.received).toEqual([SESSION_ID]); + } finally { + await listener.close(); + } + }); +}); diff --git a/tests/opencode-go-session-header.test.ts b/tests/opencode-go-session-header.test.ts new file mode 100644 index 00000000..de4679c5 --- /dev/null +++ b/tests/opencode-go-session-header.test.ts @@ -0,0 +1,408 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import http from 'node:http'; +import { createServer } from 'node:http'; +import { openCodeGoSessionHeaders, OPENCODE_GO_ANTHROPIC_BASE_URL } from '../src/data/opencode-go-models.js'; +import { createLanguageModel } from '../src/provider-factory.js'; +import { generateAnthropicResponse } from '../src/sdk-adapter.js'; +import { generateOpenAiResponse } from '../src/openai-adapter.js'; +import { startProxyCatalog, type ProxyRoute } from '../src/proxy.js'; +import { createGatewayModelCatalog } from '../src/server/models.js'; +import { startServer, type ServerHandle } from '../src/server/router.js'; + +vi.mock('../src/provider-factory.js', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, createLanguageModel: vi.fn().mockResolvedValue({}) }; +}); + +vi.mock('../src/sdk-adapter.js', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + generateAnthropicResponse: vi.fn(async (_model: unknown, _params: unknown, modelId: string) => ({ + id: 'msg-sdk', + type: 'message', + role: 'assistant', + model: modelId, + content: [{ type: 'text', text: 'sdk ok' }], + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + })), + }; +}); + +vi.mock('../src/openai-adapter.js', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + generateOpenAiResponse: vi.fn(async (_model: unknown, _params: unknown, modelId: string) => ({ + id: 'chatcmpl-sdk', + object: 'chat.completion', + created: 0, + model: modelId, + choices: [{ index: 0, message: { role: 'assistant', content: 'sdk ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + })), + }; +}); + +const SESSION_ID = '927b8642-15d2-4535-ab27-1430ae54c4aa'; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** Capture the header the upstream actually receives, on a local listener. */ +async function startUpstream(): Promise<{ baseUrl: string; sessions: Array; close: () => Promise }> { + const sessions: Array = []; + const server = createServer(async (req, res) => { + for await (const _chunk of req) { /* drain */ } + const raw = req.headers['x-opencode-session']; + sessions.push(Array.isArray(raw) ? raw[0] : raw); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'msg-go', type: 'message', role: 'assistant', model: 'deepseek-v4.1-flash', + content: [{ type: 'text', text: 'go ok' }], stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing upstream address'); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + sessions, + close: () => new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve()))), + }; +} + +function post( + port: number, + path: string, + body: unknown, + headers: Record = {}, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body); + const req = http.request( + { + hostname: '127.0.0.1', + port, + path, + method: 'POST', + headers: { + 'content-type': 'application/json', + 'anthropic-version': '2023-06-01', + 'content-length': Buffer.byteLength(payload), + ...headers, + }, + }, + res => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data })); + }, + ); + req.on('error', reject); + req.write(payload); + req.end(); + }); +} + +const messagesBody = { max_tokens: 100, messages: [{ role: 'user', content: 'hi' }], stream: false }; + +describe('openCodeGoSessionHeaders', () => { + it('names Go by provider id or by base URL, and nothing else', () => { + expect(openCodeGoSessionHeaders({ providerId: 'opencode-go' }, SESSION_ID)) + .toEqual({ 'x-opencode-session': SESSION_ID }); + expect(openCodeGoSessionHeaders({ providerId: 'custom', baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL }, SESSION_ID)) + .toEqual({ 'x-opencode-session': SESSION_ID }); + expect(openCodeGoSessionHeaders({ providerId: 'custom', apiUrl: `${OPENCODE_GO_ANTHROPIC_BASE_URL}/v1` }, SESSION_ID)) + .toEqual({ 'x-opencode-session': SESSION_ID }); + expect(openCodeGoSessionHeaders({ providerId: 'openai-oauth' }, SESSION_ID)).toBeUndefined(); + expect(openCodeGoSessionHeaders({ providerId: 'custom', baseUrl: 'https://opencode.ai/zen/v1' }, SESSION_ID)) + .toBeUndefined(); + expect(openCodeGoSessionHeaders({ providerId: 'custom', baseUrl: 'https://opencode.ai.example/zen/go' }, SESSION_ID)) + .toBeUndefined(); + }); + + // The runtime ServerModelInfo carries the openai-compatible URL as `apiBaseUrl` + // (server/models.ts, built in provider-catalog.ts), and an imported or migrated + // provider can carry a drifted id — resolve-template.ts supports that shape — so + // the URL is then the only signal there is. Reading `baseUrl ?? apiUrl` alone + // missed exactly this case. + it('names Go from the production URL field even when the provider id has drifted', () => { + expect(openCodeGoSessionHeaders( + { providerId: 'opencode-go-imported-2', apiBaseUrl: 'https://opencode.ai/zen/go/v1' }, + SESSION_ID, + )).toEqual({ 'x-opencode-session': SESSION_ID }); + expect(openCodeGoSessionHeaders( + { providerId: 'kilo', apiBaseUrl: 'https://api.kilo.ai/api/gateway' }, + SESSION_ID, + )).toBeUndefined(); + }); + + it('falls back to one stable per-process id when the client sent no session', () => { + const first = openCodeGoSessionHeaders({ providerId: 'opencode-go' }, undefined)!['x-opencode-session']; + const second = openCodeGoSessionHeaders({ providerId: 'opencode-go' }, undefined)!['x-opencode-session']; + expect(first).toMatch(UUID_RE); + expect(second).toBe(first); + }); +}); + +describe('proxy mode sends x-opencode-session to OpenCode Go', () => { + afterEach(() => { + vi.mocked(createLanguageModel).mockClear(); + vi.mocked(generateAnthropicResponse).mockClear(); + }); + + it('on the Anthropic Messages passthrough, carrying the client session id', async () => { + const upstream = await startUpstream(); + const route: ProxyRoute = { + aliasId: 'clodex:opencode-go:deepseek-v4.1-flash', + realModelId: 'deepseek-v4.1-flash', + displayName: 'DeepSeek V4.1 Flash', + upstreamUrl: upstream.baseUrl, + apiKey: 'go-key', + modelFormat: 'anthropic', + providerId: 'opencode-go', + }; + const handle = await startProxyCatalog([route], route.aliasId, false); + try { + const res = await post(handle.port, '/v1/messages', { model: route.aliasId, ...messagesBody }, { + authorization: `Bearer ${handle.token}`, + 'x-claude-code-session-id': SESSION_ID, + }); + expect(res.status, res.body).toBe(200); + expect(upstream.sessions).toEqual([SESSION_ID]); + } finally { + handle.close(); + await upstream.close(); + } + }); + + it('on the Anthropic Messages passthrough without a client session, using the process fallback', async () => { + const upstream = await startUpstream(); + const route: ProxyRoute = { + aliasId: 'clodex:opencode-go:deepseek-v4.1-flash', + realModelId: 'deepseek-v4.1-flash', + displayName: 'DeepSeek V4.1 Flash', + upstreamUrl: upstream.baseUrl, + apiKey: 'go-key', + modelFormat: 'anthropic', + providerId: 'opencode-go', + }; + const handle = await startProxyCatalog([route], route.aliasId, false); + try { + const res = await post(handle.port, '/v1/messages', { model: route.aliasId, ...messagesBody }, { + authorization: `Bearer ${handle.token}`, + }); + expect(res.status, res.body).toBe(200); + expect(upstream.sessions).toHaveLength(1); + expect(upstream.sessions[0]).toMatch(UUID_RE); + } finally { + handle.close(); + await upstream.close(); + } + }); + + it('on the SDK (Chat Completions) route, as a per-request header', async () => { + const route: ProxyRoute = { + aliasId: 'clodex:opencode-go:deepseek-v4-pro', + realModelId: 'deepseek-v4-pro', + displayName: 'DeepSeek V4 Pro', + upstreamUrl: '', + apiKey: 'go-key', + modelFormat: 'openai', + npm: '@ai-sdk/openai-compatible', + baseURL: 'https://opencode.ai/zen/go/v1', + providerId: 'opencode-go', + }; + const handle = await startProxyCatalog([route], route.aliasId, false); + try { + const res = await post(handle.port, '/v1/messages', { model: route.aliasId, ...messagesBody }, { + authorization: `Bearer ${handle.token}`, + 'x-claude-code-session-id': SESSION_ID, + }); + expect(res.status, res.body).toBe(200); + expect(generateAnthropicResponse).toHaveBeenCalledOnce(); + const params = vi.mocked(generateAnthropicResponse).mock.calls[0]![1] as { headers?: Record }; + expect(params.headers).toEqual({ 'x-opencode-session': SESSION_ID }); + } finally { + handle.close(); + } + }); + + it('not on a non-Go SDK route', async () => { + const route: ProxyRoute = { + aliasId: 'clodex:kilo:tencent/hy3', + realModelId: 'tencent/hy3', + displayName: 'Tencent Hy3', + upstreamUrl: '', + apiKey: '', + modelFormat: 'openai', + npm: '@ai-sdk/openai-compatible', + baseURL: 'https://api.kilo.ai/api/gateway', + providerId: 'kilo', + authType: 'none', + }; + const handle = await startProxyCatalog([route], route.aliasId, false); + try { + const res = await post(handle.port, '/v1/messages', { model: route.aliasId, ...messagesBody }, { + authorization: `Bearer ${handle.token}`, + 'x-claude-code-session-id': SESSION_ID, + }); + expect(res.status, res.body).toBe(200); + const params = vi.mocked(generateAnthropicResponse).mock.calls[0]![1] as { headers?: Record }; + expect(params.headers).toBeUndefined(); + } finally { + handle.close(); + } + }); +}); + +describe('API server sends x-opencode-session to OpenCode Go', () => { + const handles: Array<{ close: () => Promise }> = []; + afterEach(async () => { + vi.mocked(createLanguageModel).mockClear(); + vi.mocked(generateAnthropicResponse).mockClear(); + vi.mocked(generateOpenAiResponse).mockClear(); + while (handles.length) await handles.pop()!.close(); + }); + + it('on the Anthropic Messages passthrough', async () => { + const upstream = await startUpstream(); + handles.push(upstream); + const server: ServerHandle = await startServer({ + host: '127.0.0.1', + port: 0, + apiKey: 'go-key', + serverPassword: null, + catalog: createGatewayModelCatalog([{ + id: 'deepseek-v4.1-flash', + name: 'DeepSeek V4.1 Flash', + isFree: false, + brand: 'Other', + providerId: 'opencode-go', + sourceBackend: 'opencode-go', + modelFormat: 'anthropic', + baseUrl: upstream.baseUrl, + }]), + }); + handles.push(server); + const res = await post(new URL(server.url).port as unknown as number, '/anthropic/v1/messages', { + model: 'deepseek-v4.1-flash', ...messagesBody, + }, { 'x-claude-code-session-id': SESSION_ID }); + expect(res.status, res.body).toBe(200); + expect(upstream.sessions).toEqual([SESSION_ID]); + }); + + it('on the SDK (Chat Completions) route', async () => { + const server: ServerHandle = await startServer({ + host: '127.0.0.1', + port: 0, + apiKey: 'go-key', + serverPassword: null, + catalog: createGatewayModelCatalog([{ + id: 'deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + isFree: false, + brand: 'Other', + providerId: 'opencode-go', + sourceBackend: 'opencode-go', + modelFormat: 'openai', + npm: '@ai-sdk/openai-compatible', + apiUrl: 'https://opencode.ai/zen/go/v1', + }]), + }); + handles.push(server); + const res = await post(new URL(server.url).port as unknown as number, '/anthropic/v1/messages', { + model: 'deepseek-v4-pro', ...messagesBody, + }, { 'x-claude-code-session-id': SESSION_ID }); + expect(res.status, res.body).toBe(200); + expect(generateAnthropicResponse).toHaveBeenCalledOnce(); + const params = vi.mocked(generateAnthropicResponse).mock.calls[0]![1] as { headers?: Record }; + expect(params.headers).toEqual({ 'x-opencode-session': SESSION_ID }); + }); + + // /openai/v1/chat/completions is advertised to OpenAI-compatible clients, so it is + // production-reachable for every Go model — and it has two branches, neither of which + // used to send the session header. One test per branch. + it('on the OpenAI-compatible route, raw-relay branch, as seen on the wire', async () => { + const upstream = await startUpstream(); + handles.push(upstream); + const server: ServerHandle = await startServer({ + host: '127.0.0.1', + port: 0, + apiKey: 'go-key', + serverPassword: null, + catalog: createGatewayModelCatalog([{ + id: 'deepseek-v4.1-flash', + name: 'DeepSeek V4.1 Flash', + isFree: false, + brand: 'Other', + providerId: 'opencode-go', + sourceBackend: 'opencode-go', + modelFormat: 'openai', + completionsUrl: `${upstream.baseUrl}/v1/chat/completions`, + }]), + }); + handles.push(server); + const res = await post(new URL(server.url).port as unknown as number, '/openai/v1/chat/completions', { + model: 'deepseek-v4.1-flash', messages: [{ role: 'user', content: 'hi' }], stream: false, + }, { 'x-claude-code-session-id': SESSION_ID }); + expect(res.status, res.body).toBe(200); + expect(upstream.sessions).toEqual([SESSION_ID]); + }); + + it('on the OpenAI-compatible route, SDK-translation branch', async () => { + const server: ServerHandle = await startServer({ + host: '127.0.0.1', + port: 0, + apiKey: 'go-key', + serverPassword: null, + catalog: createGatewayModelCatalog([{ + id: 'deepseek-v4.1-flash', + name: 'DeepSeek V4.1 Flash', + isFree: false, + brand: 'Other', + providerId: 'opencode-go', + sourceBackend: 'opencode-go', + modelFormat: 'anthropic', + npm: '@ai-sdk/openai-compatible', + baseUrl: 'https://opencode.ai/zen/go', + }]), + }); + handles.push(server); + const res = await post(new URL(server.url).port as unknown as number, '/openai/v1/chat/completions', { + model: 'deepseek-v4.1-flash', messages: [{ role: 'user', content: 'hi' }], stream: false, + }, { 'x-claude-code-session-id': SESSION_ID }); + expect(res.status, res.body).toBe(200); + expect(generateOpenAiResponse).toHaveBeenCalledOnce(); + const params = vi.mocked(generateOpenAiResponse).mock.calls[0]![1] as { headers?: Record }; + expect(params.headers).toEqual({ 'x-opencode-session': SESSION_ID }); + }); + + it('not on a non-Go model through the OpenAI-compatible route', async () => { + const server: ServerHandle = await startServer({ + host: '127.0.0.1', + port: 0, + apiKey: 'kilo-key', + serverPassword: null, + catalog: createGatewayModelCatalog([{ + id: 'tencent/hy3', + name: 'Tencent Hy3', + isFree: false, + brand: 'Other', + providerId: 'kilo', + sourceBackend: 'kilo', + modelFormat: 'anthropic', + npm: '@ai-sdk/openai-compatible', + baseUrl: 'https://api.kilo.ai/api/gateway', + }]), + }); + handles.push(server); + const res = await post(new URL(server.url).port as unknown as number, '/openai/v1/chat/completions', { + model: 'tencent/hy3', messages: [{ role: 'user', content: 'hi' }], stream: false, + }, { 'x-claude-code-session-id': SESSION_ID }); + expect(res.status, res.body).toBe(200); + const params = vi.mocked(generateOpenAiResponse).mock.calls[0]![1] as { headers?: Record }; + expect(params.headers).toBeUndefined(); + }); +}); diff --git a/tests/opencode-go.test.ts b/tests/opencode-go.test.ts index 200e07ea..47bf2726 100644 --- a/tests/opencode-go.test.ts +++ b/tests/opencode-go.test.ts @@ -47,11 +47,11 @@ describe('OpenCode Go catalog', () => { expect(OPENCODE_GO_SOURCE).toBe('https://models.dev/api.json'); expect(new Date(OPENCODE_GO_SOURCE_FETCHED_AT).toISOString()).toBe(OPENCODE_GO_SOURCE_FETCHED_AT); - expect(models).toHaveLength(17); + expect(models).toHaveLength(18); expect(new Set(ids).size).toBe(models.length); expect(ids).not.toContain('grok-4.5'); expect(new Set(models.map(model => model.modelFormat))).toEqual(new Set(['anthropic', 'openai'])); - expect(models.filter(model => model.modelFormat === 'anthropic')).toHaveLength(4); + expect(models.filter(model => model.modelFormat === 'anthropic')).toHaveLength(5); expect(models.filter(model => model.modelFormat === 'openai')).toHaveLength(13); }); @@ -85,11 +85,20 @@ describe('OpenCode Go catalog', () => { }); expect(byId.get('qwen3.6-plus')?.compatibility?.thinkingFormat).toBe('qwen'); expect(byId.get('kimi-k2.6')?.compatibility?.supportsReasoningEffort).toBe(false); + // 2026-09-11 feed: the "(2x usage)" promotional pricing ended; list price doubled. expect(byId.get('gpt-5.6-luna')?.cost).toEqual({ - input: 0.1, - output: 0.6, - cache_read: 0.01, - cache_write: 0.125, + input: 0.2, + output: 1.2, + cache_read: 0.02, + cache_write: 0.25, + }); + expect(byId.get('deepseek-v4.1-flash')).toMatchObject({ + modelFormat: 'anthropic', + npm: '@ai-sdk/anthropic', + apiUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, + contextWindow: 1_000_000, + modalities: ['text', 'image'], + compatibility: { supportsReasoningEffort: false, supportsCountTokens: false }, }); }); @@ -110,7 +119,7 @@ describe('OpenCode Go catalog', () => { apiUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, }); expect(result[1]).toMatchObject({ - name: 'DeepSeek V4 Pro', + name: 'DeepSeek V4 Pro (New)', modelFormat: 'openai', npm: '@ai-sdk/openai-compatible', apiUrl: OPENCODE_GO_COMPLETIONS_BASE_URL, diff --git a/tests/registry-refresh-models.test.ts b/tests/registry-refresh-models.test.ts index f5b4fa54..6f10d02e 100644 --- a/tests/registry-refresh-models.test.ts +++ b/tests/registry-refresh-models.test.ts @@ -716,7 +716,7 @@ describe('registry/refresh-models', () => { const savedRegistry = vi.mocked(io.saveRegistry).mock.calls[0]?.[0] as ProviderRegistry; const savedModel = savedRegistry.providers[0]?.modelsCache?.models[0]; expect(result).toMatchObject({ ok: true, modelCount: 1 }); - expect(savedModel?.cost).toEqual({ input: 0.435, output: 0.87, cache_read: 0.003625 }); + expect(savedModel?.cost).toEqual({ input: 0.66, output: 1.98, cache_read: 0.022 }); expect(pricing.enrichModelsWithPricing).not.toHaveBeenCalled(); });