import { BaseExecutor, type ExecuteInput, type ExecutorExecuteResult, type ProviderCredentials, } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; import { injectReasoningContentForThinkingModel, isThinkingMessageModel, } from "../utils/reasoningContentInjector.ts"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; import { type AccountProxyConfig, type RotatableAccount, pickAccount as pickRotatableAccount, markCooldown as markAccountCooldown, markSuccess as markAccountSuccess, maskAccountId, isNetworkErrorRotatable, isEmptyUpstreamRejection, extractChatcmplId, } from "./accountRotation.ts"; import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags"; /** * Per-account proxy configuration, persisted by NoAuthAccountCard under * `providerSpecificData.accountProxies` (keyed by the account id, which the UI * stores in `providerSpecificData.fingerprints`). Same shape mimocode uses. */ export type OpencodeAccountProxyConfig = AccountProxyConfig; /** Runtime rotation/cooldown state for one "OpenCode Free" account. */ interface OpencodeAccountState extends RotatableAccount { /** Account id (UI: providerSpecificData.fingerprints[i]); "" for the default direct account. */ fingerprint: string; } const EFFORT_LEVELS = ["none", "low", "high", "max"] as const; /** * Models that work WITHOUT any API key on the free/noauth opencode tier. * * The upstream free tier rotates frequently — when a `-free` suffix model is * delisted upstream, the upstream returns "Model X is not supported" (a separate * issue from this gate). The set is defined by two data sources: * * 1. **Known free models** — models explicitly listed in the noauth * `opencode` provider registry (`open-sse/config/providers/registry/opencode/index.ts`). * These are the canonical free models. `deepseek-v4-flash-free` appears in both * the noauth AND the zen registry (it is free on both tiers). * 2. **`-free` suffix** — any model whose id ends in `-free`. This automatically * covers upstream free-tier additions without a code deploy. * * For `opencode-go`, there is no free tier — ALL models require an API key. */ const OPENCODE_FREE_MODELS = new Set([ "big-pickle", "deepseek-v4-flash-free", "mimo-v2.5-free", "hy3-free", "nemotron-3-ultra-free", "north-mini-code-free", ]); /** * Models on opencode-go that support effort-tier aliases. Each entry maps the * canonical base id to the set of effort suffixes the upstream supports. * * - DeepSeek V4 Pro and Flash: none/low/high/max * - glm-5.2: high/max only (Z.AI maps these through the reasoning plane; * low/medium are not supported on the OpenAI transport) * - mimo-v2.5: high/max only (same reasoning; Xiaomi MiMo does not document * low/medium effort tiers) * - #8353 OpenCode Go registry effort variants (exact suffix sets from * `opencode models opencode-go --verbose`; MiniMax M3 excluded — different * thinking-mode mapping): * grok-4.5 low/medium/high; hy3 none/low/high; kimi-k3 max; * qwen3.6-plus / qwen3.7-max / qwen3.7-plus high/max; * muse-spark-1.2-contributor minimal/low/medium/high/xhigh (no max) */ const EFFORT_TIERS: Record = { "deepseek-v4-pro": EFFORT_LEVELS, "deepseek-v4-flash": EFFORT_LEVELS, "glm-5.2": ["high", "max"], "mimo-v2.5": ["high", "max"], "grok-4.5": ["low", "medium", "high"], hy3: ["none", "low", "high"], "kimi-k3": ["max"], "qwen3.6-plus": ["high", "max"], "qwen3.7-max": ["high", "max"], "qwen3.7-plus": ["high", "max"], "muse-spark-1.2-contributor": ["minimal", "low", "medium", "high", "xhigh"], }; /** * Parse a model string with an effort-level suffix. * e.g. "deepseek-v4-pro-low" → { baseModel: "deepseek-v4-pro", effort: "low" } * "glm-5.2-high" → { baseModel: "glm-5.2", effort: "high" } * Returns null if the model doesn't match any known effort-tier pattern. */ export function parseEffortLevel(model: string): { baseModel: string; effort: string } | null { const m = String(model || ""); for (const [baseModel, levels] of Object.entries(EFFORT_TIERS)) { for (const level of levels) { if (m === `${baseModel}-${level}`) { return { baseModel, effort: level }; } } } return null; } /** * Determine whether a model requires an API key on the given opencode provider. * * - `opencode-go`: ALL models require a key (no free tier). * - `opencode` / `opencode-zen`: premium = any model NOT in the free set (known * free models OR ending in `-free`). * - Unknown models are assumed premium (fail-safe). */ export function isPremiumOpencodeModel(model: string, provider: string): boolean { // opencode-go has no free tier — every model requires a key. if (provider === "opencode-go") return true; // Models ending in `-free` are always free on the noauth/zen tier. if (model.endsWith("-free")) return false; // Check the known free model catalog. return !OPENCODE_FREE_MODELS.has(model); } /** * Resolves the registry `targetFormat` for a model, aliasing `provider` first. * * `PROVIDER_MODELS` is keyed by the provider's public ALIAS (e.g. `"oc"`), not its * raw registry id (e.g. `"opencode"`) — mirrors `resolveChatCoreTargetFormat()` * (`handlers/chatCore/targetFormat.ts`), which already aliases before calling * `getModelTargetFormat()`. Calling it with the raw id here made every entry miss * silently (fell through to `"openai"`), while chatCore's own request-body * translation (correctly aliased) still switched to the Responses API shape for * `targetFormat:"openai-responses"` models — sending a Responses-shaped body to * the `/chat/completions` URL this executor's own `buildUrl()` kept selecting. * Exported for testability. */ export function resolveOpencodeTargetFormat(provider: string, model: string): string { const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; return getModelTargetFormat(alias, model) || "openai"; } /** * muse-spark (opencode-go) burns its entire output budget on invisible * server-side reasoning before emitting any content. With small caller-set * budgets the upstream answers HTTP 200 with an empty message * (`{"message":{"role":"assistant"},"finish_reason":null}` and * `completion_tokens == max_tokens`) — chatCore then flags the fake success as * "Provider returned empty content" / 502 and burns a fallback attempt. * * Verified live 2026-08-23: max_tokens=64/100 → empty content; * 256/512/1024 → content present (hidden reasoning consumed 196–253 of it). * * Floor raised budgets only — explicit large budgets and non-muse-spark models * are untouched, and no budget is synthesized when the caller set none. */ export const MUSE_SPARK_MIN_OUTPUT_TOKENS = 512; export function applyMuseSparkMinOutputTokens(model: string, body: Record): void { if (!model.startsWith("muse-spark")) return; const current = body.max_tokens; if (typeof current !== "number" || !Number.isFinite(current)) return; if (current >= MUSE_SPARK_MIN_OUTPUT_TOKENS) return; body.max_tokens = MUSE_SPARK_MIN_OUTPUT_TOKENS; } /** * muse-spark's gateway reports `finish_reason:"length"` whenever its hidden * reasoning consumed part of the output budget — even when the visible * completion is tiny relative to the requested budget (observed: ~270 * completion tokens on a 128000-token request). OpenAI-protocol clients map a * "length" stop onto the caller's own max-tokens cap, so Claude Code aborts a * fully-delivered answer with "response exceeded the 128000 output token * maximum". * * Rewrite `length` → `stop` when the reported completion count proves the real * token limit was never reached (<90% of the caller's budget). Genuine * truncations at the budget are preserved. Streaming frames carry usage before * the terminal finish frame, so the completion count is known in time. */ export function normalizeMuseSparkFinishReason( payload: Record, requestedBudget: number | null, /** Streaming: usage arrives in an earlier frame than the finish frame — caller passes the tracked count here. */ completionOverride?: number | null ): void { const choices = Array.isArray(payload.choices) ? payload.choices : []; for (const choice of choices) { if (!choice || typeof choice !== "object") continue; const record = choice as Record; if (record.finish_reason !== "length") continue; if (requestedBudget === null || requestedBudget === undefined) continue; const usage = payload.usage as Record | undefined; const completion = typeof completionOverride === "number" ? completionOverride : typeof usage?.completion_tokens === "number" ? usage.completion_tokens : null; if (completion === null) continue; if (completion < Math.floor(requestedBudget * 0.9)) { record.finish_reason = "stop"; } } } /** SSE line normalizer for muse-spark streams: tracks usage, rewrites finish frames. */ export function createMuseSparkStreamFinishNormalizer( requestedBudget: number | null ): (dataLine: string) => string { let completionTokens: number | null = null; return (line: string): string => { const trimmed = line.trim(); if (!trimmed.startsWith("data:") || trimmed.includes("[DONE]")) return line; let parsed: unknown; try { parsed = JSON.parse(trimmed.slice(5).trim()); } catch { return line; } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return line; const payload = parsed as Record; const usage = payload.usage as Record | undefined; if (usage && typeof usage.completion_tokens === "number") { completionTokens = usage.completion_tokens; } const hadFinish = Array.isArray(payload.choices) ? (payload.choices as Array>).some( (c) => c && c.finish_reason === "length" ) : false; if (!hadFinish) return line; normalizeMuseSparkFinishReason(payload, requestedBudget, completionTokens); return `data: ${JSON.stringify(payload)}`; }; } export class OpencodeExecutor extends BaseExecutor { /** Delegates to `isPremiumOpencodeModel`. Exported for testability. */ static isPremiumModel(model: string, provider: string): boolean { return isPremiumOpencodeModel(model, provider); } _requestFormat: string | null = null; /** * Per-account rotation state, rebuilt from credentials on each request. The * default entry (fingerprint "") represents the single anonymous account with * no configured proxy — preserves the historical direct pass-through when the * user has not configured any per-account proxy. */ private accounts: OpencodeAccountState[] = [ { fingerprint: "", cooldownUntil: 0, consecutiveFails: 0, proxy: null }, ]; // Not `private`: passed as the mutable rotation cursor to the shared // pickAccount() helper, which needs a plain `{ nextAccountIdx }` shape — // TS's private-member nominal check rejects `this` there otherwise. nextAccountIdx = 0; constructor(provider: string) { super(provider, PROVIDERS[provider] || PROVIDERS.openai); } /** * Rebuild `accounts` from `providerSpecificData.fingerprints` + * `providerSpecificData.accountProxies`. Each configured account id becomes a * rotation slot carrying its own proxy. When the user configured no accounts * at all, the single default direct account is kept (backward compatible). */ private syncAccountsFromCredentials(credentials: ProviderCredentials): void { const psd = credentials?.providerSpecificData; const fingerprints = Array.isArray(psd?.fingerprints) ? (psd!.fingerprints as unknown[]).filter((f): f is string => typeof f === "string") : []; const accountProxies = psd?.accountProxies as OpencodeAccountProxyConfig[] | undefined; const proxyMap = Array.isArray(accountProxies) ? new Map(accountProxies.map((ap) => [ap.fingerprint, ap.proxy ?? null] as const)) : null; if (fingerprints.length === 0) { // No configured accounts — keep a single direct account. this.accounts = [{ fingerprint: "", cooldownUntil: 0, consecutiveFails: 0, proxy: null }]; this.nextAccountIdx = 0; return; } const previous = new Map(this.accounts.map((a) => [a.fingerprint, a] as const)); this.accounts = fingerprints.map((fp) => { const prior = previous.get(fp); return { fingerprint: fp, cooldownUntil: prior?.cooldownUntil ?? 0, consecutiveFails: prior?.consecutiveFails ?? 0, proxy: proxyMap ? (proxyMap.get(fp) ?? null) : null, }; }); if (this.nextAccountIdx >= this.accounts.length) this.nextAccountIdx = 0; } /** Round-robin pick, skipping accounts in cooldown; falls back to the next index. */ private pickAccount(): OpencodeAccountState { return pickRotatableAccount(this.accounts, this); } private markCooldown( account: OpencodeAccountState, kind: "transient" | "terminal" = "transient" ): void { markAccountCooldown(account, kind); } private markSuccess(account: OpencodeAccountState): void { markAccountSuccess(account); } /** * Rewrite muse-spark's bogus `finish_reason:"length"` (see the * normalizeMuseSparkFinishReason note) to `"stop"` on both streaming and * non-streaming success responses. Non-muse-spark models pass through * untouched. */ private normalizeMuseSparkResponse( input: ExecuteInput, result: ExecutorExecuteResult ): ExecutorExecuteResult { const model = String(input.model ?? ""); if (!model.startsWith("muse-spark")) return result; if (!("response" in result) || !result.response?.ok || !result.response.body) return result; const bodyObj = input.body && typeof input.body === "object" && !Array.isArray(input.body) ? (input.body as Record) : null; const rawBudget = bodyObj?.max_tokens; const budget = typeof rawBudget === "number" && Number.isFinite(rawBudget) ? rawBudget : null; const response = result.response; const isSse = response.headers.get("content-type")?.includes("event-stream") ?? false; if (!isSse) { // Non-streaming JSON: rewrite in a buffered pass. const stream = new ReadableStream({ async start(controller) { try { const text = await response.clone().text(); let out = text; try { const parsed = JSON.parse(text) as Record; normalizeMuseSparkFinishReason(parsed, budget); out = JSON.stringify(parsed); } catch { /* not JSON — forward verbatim */ } controller.enqueue(new TextEncoder().encode(out)); } catch (err) { controller.error(err); return; } controller.close(); }, }); return { ...result, response: new Response(stream, { status: response.status, statusText: response.statusText, headers: response.headers, }), }; } // Streaming SSE: line-buffered passthrough with finish_reason rewriting. const normalizer = createMuseSparkStreamFinishNormalizer(budget); const decoder = new TextDecoder(); const encoder = new TextEncoder(); let buffer = ""; const reader = response.body.getReader(); const stream = new ReadableStream({ async pull(controller) { try { const { done, value } = await reader.read(); if (done) { if (buffer.length > 0) controller.enqueue(encoder.encode(normalizer(buffer))); controller.close(); return; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) controller.enqueue(encoder.encode(normalizer(line) + "\n")); } catch (err) { controller.error(err); } }, cancel(reason) { reader.cancel(reason).catch(() => undefined); }, }); return { ...result, response: new Response(stream, { status: response.status, statusText: response.statusText, headers: response.headers, }), }; } async execute(input: ExecuteInput) { this._requestFormat = resolveOpencodeTargetFormat(this.provider, input.model); // #8681: Gate premium opencode models behind a usable API key. // When the connection is keyless (no apiKey, no accessToken) and the model // is a premium model (not on the free tier), return a clear 402 error // instead of proxying the raw upstream 401 "Missing API key" response. const creds = input.credentials; const isKeyless = !creds?.apiKey && !creds?.accessToken && !creds?.providerSpecificData?.extraApiKeys; if (isKeyless && isPremiumOpencodeModel(input.model, this.provider)) { const bodyJson = JSON.stringify({ error: { message: "This model requires an opencode API key — add one in Settings → Providers.", type: "invalid_request_error", code: "premium_model_requires_key", }, }); return { response: new Response(bodyJson, { status: 402, headers: { "Content-Type": "application/json" }, }), url: "", headers: {} as Record, transformedBody: null, }; } try { // muse-spark reasoning models consume the entire output budget on hidden // server-side reasoning; small caller budgets come back as empty-message // 200s ("Provider returned empty content"). Raise tiny budgets to the // floor before dispatch (see MUSE_SPARK_MIN_OUTPUT_TOKENS). if (input.body && typeof input.body === "object" && !Array.isArray(input.body)) { applyMuseSparkMinOutputTokens(String(input.model ?? ""), input.body as Record); } this.syncAccountsFromCredentials(input.credentials); const { log } = input; const hasProxies = this.accounts.some((a) => a.proxy !== null); // Fast path: no multi-account proxy wiring configured → original behavior, // plus exactly ONE bounded retry when the upstream answers a 400 empty // rejection (same predicate and logging as the rotation loop). Everything // else passes untouched: this path deliberately preserves BaseExecutor's // intra-URL 429 retries (no skipUpstreamRetry here). if (this.accounts.length === 1 && !hasProxies) { const single = (await super.execute(input)) as HttpExecuteResult; if (single.response.status === 400) { let bodyText: string | null = null; try { bodyText = await single.response.clone().text(); } catch { log?.debug?.("OPENCODE", "body read failed on direct account"); } if (bodyText !== null) { if (isEmptyUpstreamRejection(400, bodyText)) { const chatcmplId = extractChatcmplId(bodyText); log?.warn?.( "OPENCODE", `upstream empty rejection on direct account (${chatcmplId}), retrying once…` ); return this.normalizeMuseSparkResponse(input, await super.execute(input)); } log?.debug?.( "OPENCODE", "400 without error field, signature not matched on direct account — observing" ); } } return this.normalizeMuseSparkResponse(input, single); } // This loop only ever dispatches through super.execute() (the HTTP request // path), which always resolves the object-shaped arm of ExecutorExecuteResult // — the bare-Response arm belongs to web/scraping executors only (base.ts:290). type HttpExecuteResult = Extract< Awaited>, { response: Response } >; let lastResult: HttpExecuteResult | null = null; let lastSharedEgressError: unknown = null; const sharedEgressGuardEnabled = isNetworkRotationSharedEgressGuardEnabled(); // Set once a proxy-less account's network throw reveals the shared // egress is down (see NETWORK_ROTATION_SHARED_EGRESS_GUARD below) — // subsequent proxy-less accounts this request are skipped without a // network call, but proxied accounts (independent egress) are still // tried normally. let sharedEgressDown = false; // Bounded extra attempts for empty upstream rejections: +1 for a single // account (retry the same one), none for a multi-account fleet (rotation // through the accounts is the retry). Avoids an unbounded loop on a // persistently malformed upstream. const emptyRejectionBudget = this.accounts.length === 1 ? 1 : 0; for (let attempt = 0; attempt < this.accounts.length + emptyRejectionBudget; attempt++) { const account = this.pickAccount(); const masked = maskAccountId(account.fingerprint); if (sharedEgressGuardEnabled && sharedEgressDown && !account.proxy) { log?.warn?.( "OPENCODE", `skipping account ${masked} (no dedicated proxy, shared egress already down this request)` ); continue; } // #5217 (Gap 2): promoted debug→info so the per-request account/proxy // rotation selection is visible in the Console log view at the default // APP_LOG_LEVEL=info (users could not see which account/proxy was used). // Token stays masked — never log the full account id. log?.info?.( "OPENCODE", `dispatch via account ${masked} (idx ${attempt + 1}/${this.accounts.length})` + (account.proxy ? ` through proxy ${account.proxy.host}:${account.proxy.port}` : " direct") ); // Pin egress to this account's proxy for the whole BaseExecutor dispatch // (incl. its intra-URL 429 retries). skipUpstreamRetry lets THIS loop own // the cross-account 429 fallback instead of BaseExecutor's same-key retry. let result: HttpExecuteResult; try { // super.execute() here always dispatches the HTTP path (opencode is an // OpenAI-compatible API, never the web/scraping bare-Response arm) — // see base.ts:290-294. result = (await runWithProxyContext(account.proxy, () => super.execute({ ...input, skipUpstreamRetry: true }) )) as HttpExecuteResult; } catch (err) { const reason = err instanceof Error ? err.message : String(err); // A network exception (timeout, connection refused/reset) is only // account-scoped when this account has its OWN egress (a configured // proxy) — that's the case a dead/unreachable proxy justifies rotating // away from. Without a proxy, accounts share the same network egress: // the failure isn't attributable to this account. Never swallowed // silently either way: logged before rotating, skipping, or rethrowing. if (!isNetworkErrorRotatable(account)) { if (sharedEgressGuardEnabled) { this.markCooldown(account); sharedEgressDown = true; lastSharedEgressError = err; log?.warn?.( "OPENCODE", `network error on account ${masked} (no dedicated proxy, shared egress), cooldown applied — trying next available account… (${reason})` ); continue; } log?.warn?.( "OPENCODE", `network error on account ${masked} (no dedicated proxy, shared egress) — not rotating (${reason})` ); throw err; } this.markCooldown(account); log?.warn?.( "OPENCODE", `network error on account ${masked}, rotating to next… (${reason})` ); continue; } lastResult = result; const status = result.response.status; if (status === 429) { this.markCooldown(account); log?.warn?.("OPENCODE", `Rate limited (429) on account ${masked}, rotating to next…`); continue; } // Empty upstream rejection (malformed 400: no error field, no real // content, finish_reason null — see isEmptyUpstreamRejection). Rotate/ // retry instead of propagating it as a fatal success: the observed // envelope was marking subagent sessions as failed. Read the body ONLY // for a 400 (never a 200/streaming — that would buffer the good path); // classify, log, and continue. Neitheries markCooldown nor markSuccess: // the failure is upstream's, not this account's. if (status === 400) { let bodyText: string | null = null; try { bodyText = await result.response.clone().text(); } catch { log?.debug?.("OPENCODE", "body read failed on empty rejection check"); } if (bodyText !== null && isEmptyUpstreamRejection(400, bodyText)) { const chatcmplId = extractChatcmplId(bodyText); log?.warn?.( "OPENCODE", `upstream empty rejection on account ${masked} (${chatcmplId}), rotating to next…` ); continue; } // A 400 carrying a real error (or non-empty content): propagate // immediately, untouched — same as before this change. this.markSuccess(account); return result; } this.markSuccess(account); return this.normalizeMuseSparkResponse(input, result); } // The loop exhausted without a result. If it's because every remaining // proxy-less account was skipped once the shared egress was known down // (rather than actually tried), propagate that original throw — an // extra direct call here would just be a second doomed attempt against // the same dead path, which is exactly the latency this guard exists // to avoid (see NETWORK_ROTATION_SHARED_EGRESS_GUARD). if (sharedEgressDown && !lastResult && lastSharedEgressError !== null) { throw lastSharedEgressError; } // All accounts returned 429 (or errored) — surface the last response. return this.normalizeMuseSparkResponse( input, lastResult ?? (await super.execute(input)) ); } finally { this._requestFormat = null; } } buildUrl( model: string, stream: boolean, urlIndex = 0, credentials: ProviderCredentials | null = null ) { void urlIndex; void credentials; const base = this.config.baseUrl; switch (this._requestFormat) { case "claude": return `${base}/messages`; case "openai-responses": return `${base}/responses`; case "gemini": return `${base}/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`; default: return `${base}/chat/completions`; } } buildHeaders( credentials: ProviderCredentials | null, stream = true, clientHeaders?: Record | null, model?: string, _health?: Record, body?: unknown ) { const headers: Record = { "Content-Type": "application/json" }; // #8467: honor Extra API Keys rotation via BaseExecutor.resolveEffectiveKey. // Fall back to accessToken only when no apiKey/extras resolve to a key. const key = credentials ? this.resolveEffectiveKey(credentials) || credentials.accessToken : undefined; if (key) { if (this._requestFormat === "claude") { headers["x-api-key"] = key; } else { headers["Authorization"] = `Bearer ${key}`; } } if (this._requestFormat === "claude") { headers["anthropic-version"] = "2023-06-01"; } if (stream) { headers["Accept"] = "text/event-stream"; } // Synthesize OpenCode CLI identity headers by default so Cloudflare in front of // opencode.ai/zen doesn't 429 VPS requests lacking CLI identity. Opt-out via // OPENCODE_SYNTHESIZE_CLI_HEADERS=false. Client-supplied headers always win; // User-Agent is replaced with the CLI UA unless the client already sends one that // looks like the OpenCode CLI. Default values match 9router's proven defaults. const synthesizeCli = !/^(0|false|no|off)$/i.test( process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS?.trim() ?? "" ); const cliDefaults = synthesizeCli ? (() => { const providerId = this.config?.id || this.provider || "opencode"; const envUAKey = `${providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_USER_AGENT`; return { userAgent: process.env[envUAKey]?.trim() || process.env.OPENCODE_USER_AGENT?.trim() || "opencode", client: process.env.OPENCODE_CLIENT?.trim() || "desktop", project: process.env.OPENCODE_PROJECT?.trim() || "global", }; })() : undefined; if (clientHeaders || cliDefaults) { const b = body && typeof body === "object" ? (body as Record) : null; forwardOpencodeClientHeaders(headers, clientHeaders ?? {}, { synthesizeRequestId: true, cliDefaults, sessionBody: b ? { model: typeof b.model === "string" ? b.model : undefined, system: b.system, messages: Array.isArray(b.messages) ? (b.messages as Array<{ role?: string; content?: unknown }>) : undefined, tools: Array.isArray(b.tools) ? (b.tools as Array<{ name?: string; function?: { name?: string } }>) : undefined, } : undefined, }); } void model; return headers; } /** * OpenCode's free DeepSeek V4 Flash endpoint accepts json_object but * rejects json_schema response_format with HTTP 400. Preserve the schema * as an instruction and downgrade only this proven-incompatible route to * json_object so callers still receive structured JSON. */ private applyDeepSeekJsonSchemaFallback(model: string, body: T): T { if ( model !== "deepseek-v4-flash-free" || (this.provider !== "opencode" && this.provider !== "opencode-zen") ) { return body; } if (!body || typeof body !== "object" || Array.isArray(body)) { return body; } const record = body as Record; const responseFormat = record.response_format as | { type?: string; json_schema?: { schema?: unknown; }; } | undefined; if (responseFormat?.type !== "json_schema" || !responseFormat.json_schema?.schema) { return body; } const schemaJson = JSON.stringify(responseFormat.json_schema.schema, null, 2); const prompt = "You must respond with valid JSON that strictly follows " + "this JSON schema:\\n```json\\n" + schemaJson + "\\n```\\nRespond ONLY with the JSON object, no other text."; const messages: Array> = Array.isArray(record.messages) ? (record.messages as Array>).map((message) => ({ ...message })) : []; const systemMessage = messages.find((message) => message.role === "system"); if (systemMessage) { if (typeof systemMessage.content === "string") { systemMessage.content = `${systemMessage.content}\\n\\n${prompt}`; } else if (Array.isArray(systemMessage.content)) { systemMessage.content.push({ type: "text", text: `\\n\\n${prompt}`, }); } } else { messages.unshift({ role: "system", content: prompt, }); } return { ...record, messages, response_format: { type: "json_object", }, } as T; } transformRequest( model: string, body: any, stream: boolean, credentials: ProviderCredentials ): any { let modifiedBody = super.transformRequest(model, body, stream, credentials); modifiedBody = this.applyDeepSeekJsonSchemaFallback(model, modifiedBody); // 9router#1442: OpenCode upstreams (e.g. kimi-k2.6 via opencode-go) return // 400 "Extra inputs are not permitted, field: 'client_metadata'" — an // OpenAI-Codex/Claude-CLI passthrough field with no equivalent here. The // DefaultExecutor strip only covers cerebras/mistral, and OpencodeExecutor // extends BaseExecutor directly, so nothing removed it on this path. if ( modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody) && Object.prototype.hasOwnProperty.call(modifiedBody, "client_metadata") ) { delete (modifiedBody as Record).client_metadata; } if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) { const mb = modifiedBody as Record; if (Array.isArray(mb.tools) && mb.tools.length > 128) { mb.tools = mb.tools.slice(0, 128); } } if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) { const mb = modifiedBody as Record; const parsed = parseEffortLevel(model); if (parsed) { mb.model = parsed.baseModel; if (mb.reasoning_effort === undefined) { mb.reasoning_effort = parsed.effort; } } } // #1543 / upstream PR #1099: thinking-mode upstreams routed through OpenCode // (DeepSeek V4 Flash, Kimi, MiniMax, ...) require reasoning_content echoed // back on assistant messages, or they 400 with "reasoning_content must be // passed back". OpenAI clients drop it across turns, so we inject a // placeholder for the affected model families. if (isThinkingMessageModel(model)) { modifiedBody = injectReasoningContentForThinkingModel(modifiedBody); } return modifiedBody; } }