import { randomUUID } from "crypto"; import { BaseExecutor, setUserAgentHeader, type ExecuteInput, type ProviderCredentials, } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; import { injectReasoningContentForThinkingModel, isThinkingMessageModel, } from "../utils/reasoningContentInjector.ts"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; /** * 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 interface OpencodeAccountProxyConfig { fingerprint: string; proxy: { type: string; host: string; port: number; username?: string; password?: string; } | null; } /** Runtime rotation/cooldown state for one "OpenCode Free" account. */ interface OpencodeAccountState { /** Account id (UI: providerSpecificData.fingerprints[i]); "" for the default direct account. */ fingerprint: string; cooldownUntil: number; consecutiveFails: number; /** Resolved proxy config for this account (null = direct egress). */ proxy: OpencodeAccountProxyConfig["proxy"]; } const OPENCODE_COOLDOWN_BASE_MS = 5_000; const OPENCODE_COOLDOWN_MAX_MS = 60_000; export class OpencodeExecutor extends BaseExecutor { _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 }, ]; private 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; } private isAccountReady(account: OpencodeAccountState): boolean { return account.cooldownUntil <= Date.now(); } /** Round-robin pick, skipping accounts in cooldown; falls back to the next index. */ private pickAccount(): OpencodeAccountState { for (let i = 0; i < this.accounts.length; i++) { const idx = (this.nextAccountIdx + i) % this.accounts.length; const acct = this.accounts[idx]; if (this.isAccountReady(acct)) { this.nextAccountIdx = (idx + 1) % this.accounts.length; return acct; } } const fallbackIdx = this.nextAccountIdx % this.accounts.length; this.nextAccountIdx = (this.nextAccountIdx + 1) % this.accounts.length; return this.accounts[fallbackIdx]; } private markCooldown(account: OpencodeAccountState): void { account.consecutiveFails++; const backoff = Math.min( OPENCODE_COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1), OPENCODE_COOLDOWN_MAX_MS ); account.cooldownUntil = Date.now() + backoff + Math.random() * 1000; } private markSuccess(account: OpencodeAccountState): void { account.consecutiveFails = 0; } /** Mask an account id for logs (UI calls it a fingerprint). */ private static maskAccountId(fingerprint: string): string { if (!fingerprint) return "direct"; return `${fingerprint.slice(0, 8)}…`; } async execute(input: ExecuteInput) { this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai"; try { this.syncAccountsFromCredentials(input.credentials); const hasProxies = this.accounts.some((a) => a.proxy !== null); // Fast path: no multi-account proxy wiring configured → original behavior. if (this.accounts.length === 1 && !hasProxies) { return await super.execute(input); } const { log } = input; let lastResult: Awaited> | null = null; for (let attempt = 0; attempt < this.accounts.length; attempt++) { const account = this.pickAccount(); const masked = OpencodeExecutor.maskAccountId(account.fingerprint); // #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. const result = await runWithProxyContext(account.proxy, () => super.execute({ ...input, skipUpstreamRetry: true }) ); 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; } this.markSuccess(account); return result; } // All accounts returned 429 (or errored) — surface the last response. return 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 ) { const headers: Record = { "Content-Type": "application/json" }; const key = credentials?.apiKey || credentials?.accessToken; 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"; } if (clientHeaders) { const clientUA = clientHeaders["User-Agent"] || clientHeaders["user-agent"]; if (clientUA) { setUserAgentHeader(headers, clientUA); } // Forward OpenCode request metadata headers from client const findClientHeader = (name: string) => Object.entries(clientHeaders).find( ([key]) => key.toLowerCase() === name.toLowerCase() )?.[1]; const opencodeHeaderKeys = [ "x-opencode-session", "x-opencode-request", "x-opencode-project", "x-opencode-client", ]; for (const headerName of opencodeHeaderKeys) { const value = findClientHeader(headerName); if (value) { headers[headerName] = value; } } // #4022: OpenCode CLI only emits x-opencode-* headers when the provider id // starts with "opencode". For a custom-named provider (e.g. "omniroute") it // instead sends x-session-affinity / X-Session-Id, which both carry the same // OpenCode sessionID. Map that session id onto x-opencode-session so session // continuity to the opencode.ai upstream works regardless of how the user // named the provider. Scoped to this executor (opencode.ai/zen upstreams // only) — the generic DefaultExecutor intentionally does NOT do this, to // avoid leaking the client session id to arbitrary third-party upstreams. if (!headers["x-opencode-session"]) { const sessionAffinity = findClientHeader("x-session-affinity") || findClientHeader("x-session-id"); if (sessionAffinity) { headers["x-opencode-session"] = sessionAffinity; // #4465: a custom-named provider only reaches this fallback because the // OpenCode CLI did NOT emit the x-opencode-* set (it only does so when the // provider id starts with "opencode"). It therefore also dropped // x-opencode-request, a per-request correlation id. Synthesize one so these // users are not disadvantaged versus opencode-prefixed providers on the // opencode.ai upstream. x-opencode-client / x-opencode-project are NOT // fabricated: their valid values are opencode-internal and inventing them // could be rejected upstream — they remain forward-only above. Scoped to this // executor (opencode.ai/zen) and only to the fallback path, so the direct // OpenCode CLI flow (which controls its own request id) is untouched. if (!headers["x-opencode-request"]) { headers["x-opencode-request"] = randomUUID(); } } } } void model; return headers; } transformRequest( model: string, body: any, stream: boolean, credentials: ProviderCredentials ): any { let modifiedBody = super.transformRequest(model, body, stream, credentials); if ( modifiedBody && typeof modifiedBody === "object" && Array.isArray(modifiedBody.tools) && modifiedBody.tools.length > 128 ) { modifiedBody.tools = modifiedBody.tools.slice(0, 128); } if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) { const mb = modifiedBody as Record; const m = String(model || ""); const effortLevels = ["low", "medium", "high", "max"] as const; const matchedLevel = effortLevels.find((level) => m.endsWith(`-${level}`)); if (matchedLevel) { const base = m.slice(0, -matchedLevel.length - 1); if (base.toLowerCase() === "deepseek-v4-pro") { mb.model = "deepseek-v4-pro"; if (mb.reasoning_effort === undefined) { mb.reasoning_effort = matchedLevel; } } } } // #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; } }