Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/opencode-go.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions scripts/update-opencode-go-models.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
110 changes: 67 additions & 43 deletions src/data/opencode-go-models.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -141,8 +165,6 @@
"text",
"image"
],
"upstreamModelId": "gpt-5.6-luna",
"family": "gpt",
"compatibility": {
"reasoningEffortMap": {
"off": "none",
Expand All @@ -156,7 +178,9 @@
"supportsStore": false,
"supportsDeveloperRole": false,
"maxTokensField": "max_tokens"
}
},
"upstreamModelId": "gpt-5.6-luna",
"family": "gpt"
},
{
"id": "hy3",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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",
Expand All @@ -403,10 +427,6 @@
"image"
],
"compatibility": {
"supportsStore": false,
"supportsDeveloperRole": false,
"thinkingFormat": "qwen",
"maxTokensField": "max_tokens",
"reasoningEffortMap": {
"low": "low",
"medium": "medium",
Expand All @@ -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"
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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"
}
]
35 changes: 34 additions & 1 deletion src/data/opencode-go-models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import models from './opencode-go-models.json';
import type { CachedModel } from '../registry/types.js';

Expand All @@ -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<CachedModel, 'id' | 'name'>
& Partial<Omit<CachedModel, 'id' | 'name'>>;
Expand All @@ -25,3 +26,35 @@ type OpenCodeGoModel = Pick<CachedModel, 'id' | 'name'>
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<string, string> | 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 };
}
6 changes: 6 additions & 0 deletions src/provider-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -81,6 +82,11 @@ export async function verifyOpenCodeGoCredential(apiKey: string): Promise<string
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
// Go answers `MissingSessionID` to any request without a session header,
// and whether it validates the key BEFORE that check is not something we
// have observed. Sending the header removes the question: the probe then
// cannot mistake a missing-session rejection for a verdict on the key.
...openCodeGoSessionHeaders({ providerId: OPENCODE_GO_PROVIDER_ID }, undefined),
},
body: JSON.stringify({ model: model.upstreamModelId ?? model.id }),
// This is the FIRST thing a user hits after pasting a key, and it runs
Expand Down
16 changes: 15 additions & 1 deletion src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
estimateAnthropicInputTokens,
} from './anthropic-endpoints.js';
import { withResponsesWebSocketDiagnosticContext } from './oauth/responses-websocket.js';
import { openCodeGoSessionHeaders } from './data/opencode-go-models.js';
import { resolveContextWindow } from './context-window.js';
import { listenTcpServer } from './listener-ready.js';
import type { ModelRuntimeCompatibility } from './model-runtime-compatibility.js';
Expand Down Expand Up @@ -537,6 +538,14 @@ export async function startProxyCatalog(

let effectiveBeta = inboundBeta;
let claudeCodeSessionId: string | undefined;
const inboundSessionRaw = req.headers['x-claude-code-session-id'];
const goSessionHeaders = openCodeGoSessionHeaders(
{ providerId: route.providerId, baseUrl: upstreamUrl },
extractClaudeSessionId(
anthropicBody,
Array.isArray(inboundSessionRaw) ? inboundSessionRaw[0] : inboundSessionRaw,
),
);
if (isOAuth) {
// Identity injection and beta selection for Claude Code OAuth.
const seed = route.providerId ?? route.realModelId;
Expand All @@ -556,7 +565,7 @@ export async function startProxyCatalog(
authType: routeAuthType,
log: message => plog(message),
claudeCodeSessionId,
extraHeaders: route.headers,
extraHeaders: { ...route.headers, ...goSessionHeaders },
refreshToken: route.refreshToken,
onTokenRefreshed: refreshed => { route.apiKey = refreshed; },
signal: clientAbort.signal,
Expand Down Expand Up @@ -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}`,
Expand Down
2 changes: 2 additions & 0 deletions src/sdk-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ export interface SdkCallParams {
maxOutputTokens?: number;
temperature?: number;
providerOptions?: Record<string, Record<string, unknown>>;
/** Per-request upstream headers; `streamText`/`generateText` take them as-is. */
headers?: Record<string, string>;
}

// ── system ───────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading