/** * OpenCode onPayload fix hooks. * * These mirror the transforms in OmniRoute's OpencodeExecutor.transformRequest * (source/OmniRoute/open-sse/executors/opencode.ts) and the reasoning-content * injector (source/OmniRoute/open-sse/utils/reasoningContentInjector.ts). * * They run inside a custom streamSimple's onPayload to patch upstream * incompatibilities that cause 400s: * * 1. stripClientMetadata — upstream rejects `client_metadata` * 2. limitTools — upstream caps `tools` at 128 * 3. rewriteEffortTier — expand effort aliases (deepseek-v4-pro-low → …) * 4. injectReasoningContent— thinking models need reasoning_content echoed * 5. stripBooleanReasoning — some upstreams reject boolean `reasoning` * * #4 only applies to the openai-completions transport (the reasoning_content * contract is OpenAI-specific); Anthropic-format payloads use thinking blocks. */ import { parseEffortLevel } from "./catalog.ts" import type { ProtocolType } from "../../types.ts" type Json = Record const MAX_TOOLS = 128 /** Placeholder injected for missing reasoning_content (non-empty so upstream accepts it). */ const REASONING_PLACEHOLDER = " " /** Predicate for thinking-mode model families that need reasoning_content echoed. */ const THINKING_MODEL_PATTERNS: RegExp[] = [ /deepseek/i, /\bkimi\b/i, /\bk2\b/i, /\bminimax\b/i, /\bmimo\b/i, /\bpickle\b/i, ] function isJsonObject(value: unknown): value is Json { return !!value && typeof value === "object" && !Array.isArray(value) } function isAssistantMessage(value: unknown): value is Json { return isJsonObject(value) && value["role"] === "assistant" } function hasNonEmptyReasoningContent(message: Json): boolean { const rc = message["reasoning_content"] return typeof rc === "string" && (rc as string).trim().length > 0 } /** * Test whether a model id belongs to a thinking-mode family that requires * `reasoning_content` to be echoed back on assistant messages (OpenAI transport). */ export function isThinkingMessageModel(model: string | undefined | null): boolean { if (!model || typeof model !== "string") return false return THINKING_MODEL_PATTERNS.some((re) => re.test(model)) } // --------------------------------------------------------------------------- // Hook 1: strip client_metadata // --------------------------------------------------------------------------- /** * Remove the `client_metadata` passthrough field. Upstream returns * 400 "Extra inputs are not permitted, field: 'client_metadata'". */ export function stripClientMetadata(body: T): T { if (Object.prototype.hasOwnProperty.call(body, "client_metadata")) { const { client_metadata: _drop, ...rest } = body void _drop return rest as T } return body } // --------------------------------------------------------------------------- // Hook 2: limit tools to 128 // --------------------------------------------------------------------------- /** * Cap `body.tools` at `max` entries (default 128). Upstream rejects oversized * tool arrays. Mutates a shallow copy only when truncation is needed. */ export function limitTools(body: T, max = MAX_TOOLS): T { const tools = body["tools"] if (Array.isArray(tools) && tools.length > max) { return { ...body, tools: tools.slice(0, max) } } return body } // --------------------------------------------------------------------------- // Hook 3: rewrite effort-tier alias → base + reasoning_effort // --------------------------------------------------------------------------- /** * Expand a model effort alias (e.g. "deepseek-v4-pro-low") into the canonical * base id + a `reasoning_effort` field. No-op for non-alias ids. * * Reads `body.model` so it works regardless of how the alias reached the body. */ export function rewriteEffortTier(body: T): T { const modelField = body["model"] if (typeof modelField !== "string") return body const parsed = parseEffortLevel(modelField) if (!parsed) return body const next: T = { ...body, model: parsed.baseModel } if (next["reasoning_effort"] === undefined) { ;(next as Json)["reasoning_effort"] = parsed.effort } return next } // --------------------------------------------------------------------------- // Hook 4: inject reasoning_content on assistant messages (OpenAI transport) // --------------------------------------------------------------------------- /** * Inject a placeholder `reasoning_content` on every assistant message that * lacks one. Thinking-mode upstreams (DeepSeek, Kimi, MiniMax, MiMo, big-pickle) * 400 with "reasoning_content must be passed back" when the field is absent. * * Only meaningful for the openai-completions transport. */ export function injectReasoningContent( body: T, modelId: string ): T { if (!isThinkingMessageModel(modelId)) return body const messages = body["messages"] if (!Array.isArray(messages)) return body let modified = false const newMessages = messages.map((message) => { if (!isAssistantMessage(message)) return message if (hasNonEmptyReasoningContent(message)) return message modified = true return { ...message, reasoning_content: REASONING_PLACEHOLDER } }) return modified ? ({ ...body, messages: newMessages } as T) : body } // --------------------------------------------------------------------------- // Hook 5: strip boolean-valued reasoning fields // --------------------------------------------------------------------------- /** * Remove a boolean-valued top-level `reasoning` field. Some upstreams reject * `reasoning: true/false` (they expect an effort string or omit it entirely). * Only acts when the field is actually boolean; leaves other shapes untouched. */ export function stripBooleanReasoning(body: T): T { if (typeof body["reasoning"] === "boolean") { const { reasoning: _drop, ...rest } = body void _drop return rest as T } return body } // --------------------------------------------------------------------------- // Composition // --------------------------------------------------------------------------- /** A synchronous payload transform applied inside onPayload. */ export type PayloadTransform = (payload: unknown) => unknown /** * Compose all applicable gotcha hooks into a single payload transform. * * Order: client_metadata → tools → effort-tier → boolean-reasoning → * reasoning_content (reasoning_content last so it reads the normalized model). * * `reasoning_content` injection is gated on the protocol because it is an * OpenAI-completions-only contract. */ export function composeOnPayload( protocol: ProtocolType, modelId?: string ): PayloadTransform { return (payload: unknown): unknown => { if (!isJsonObject(payload)) return payload let body: Json = payload body = stripClientMetadata(body) body = limitTools(body) body = rewriteEffortTier(body) body = stripBooleanReasoning(body) if (protocol === "openai-completions") { const resolvedModel = (typeof body["model"] === "string" && body["model"]) || modelId body = injectReasoningContent(body, resolvedModel as string) } return body } }