import crypto from "crypto"; import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { resolveSessionId } from "../utils/sessionManager.js"; const OPENCODE_UA = "opencode"; // Models served by /zen/v1/responses; every other model stays on /chat/completions. const RESPONSES_MODELS = new Set(["muse-spark-1.2-contributor-free"]); function generateRequestId() { return `msg_${crypto.randomUUID().replace(/-/g, "")}`; } function generateSessionId() { return `ses_${crypto.randomUUID().replace(/-/g, "")}`; } // Strip the thinking suffix "model(level)" so registry lookups hit the base id. function baseModelId(model) { return String(model || "").replace(/\([^()]+\)\s*$/, "").trim(); } function isResponsesModel(model) { return RESPONSES_MODELS.has(baseModelId(model)); } function resolveOpencodeSession(body, credentials) { const headers = credentials?.rawHeaders || {}; return resolveSessionId({ headers, body, connectionId: credentials?.connectionId, scope: "opencode", generate: generateSessionId, }); } function normalizeOpencodeReasoning(model, body) { const current = body.reasoning; const currentReasoning = current && typeof current === "object" && !Array.isArray(current) ? current : null; const requestedEffort = typeof body.reasoning_effort === "string" ? body.reasoning_effort : currentReasoning?.effort; if (typeof requestedEffort !== "string") return; const cleanModel = baseModelId(model || body.model); const supportedLevels = getThinkingLevels("opencode", cleanModel); let effort = requestedEffort.toLowerCase().trim(); if ((effort === "max" || effort === "ultra") && supportedLevels?.length && !supportedLevels.includes(effort)) { if (effort === "ultra" && supportedLevels.includes("max")) effort = "max"; else if (supportedLevels.includes("xhigh")) effort = "xhigh"; } body.reasoning = { ...currentReasoning, effort }; if (!body.reasoning.summary) body.reasoning.summary = "auto"; delete body.reasoning_effort; } export class OpenCodeExecutor extends BaseExecutor { constructor() { super("opencode", PROVIDERS.opencode); this._currentSessionId = null; } transformRequest(model, body, stream, credentials) { this._currentSessionId = resolveOpencodeSession(body, credentials); if (isResponsesModel(model)) { // Responses API names the output cap max_output_tokens and takes thinking // as reasoning:{effort,summary} — normalize the Chat fields at this boundary. if (body.max_output_tokens === undefined) { if (body.max_completion_tokens !== undefined) body.max_output_tokens = body.max_completion_tokens; else if (body.max_tokens !== undefined) body.max_output_tokens = body.max_tokens; } delete body.max_tokens; delete body.max_completion_tokens; normalizeOpencodeReasoning(model, body); } return injectReasoningContent({ provider: this.provider, model, body }); } buildUrl(model) { const base = this.config.baseUrl; return isResponsesModel(model) ? `${base}/zen/v1/responses` : `${base}/zen/v1/chat/completions`; } buildHeaders(credentials, stream = true) { const raw = credentials?.rawHeaders || {}; const lower = {}; for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v; const downstreamUa = lower["user-agent"] || ""; const isOpencodeDownstream = downstreamUa.toLowerCase().includes("opencode"); return { "Content-Type": "application/json", "Authorization": "Bearer public", "User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA, "x-opencode-client": lower["x-opencode-client"] || "desktop", "x-opencode-session": lower["x-opencode-session"] || this._currentSessionId || generateSessionId(), "x-opencode-request": lower["x-opencode-request"] || generateRequestId(), "x-opencode-project": lower["x-opencode-project"] || "global", "Accept": stream ? "text/event-stream" : "*/*", }; } }