Strip prior-turn type: reasoning items and encrypted_content fields from body.input on Muse Spark Responses endpoints in OpenCode and OpenCode Go executors to avoid HTTP 400 errors across rotated proxy accounts.
193 lines
7.9 KiB
JavaScript
193 lines
7.9 KiB
JavaScript
import crypto from "node:crypto";
|
|
import { DefaultExecutor } from "./default.js";
|
|
import { resolveSessionId } from "../utils/sessionManager.js";
|
|
import { modelTargetFormat } from "../providers/models/schema.js";
|
|
import { getProviderModels } from "../config/providerModels.js";
|
|
import {
|
|
normalizeResponsesInput,
|
|
clampResponsesCallId,
|
|
coerceResponsesArguments,
|
|
coerceResponsesOutput,
|
|
} from "../translator/formats/responsesApi.js";
|
|
|
|
const SESSION_HEADER = "x-opencode-session";
|
|
const SESSION_FIELD = "_opencodeGoSession";
|
|
const MAX_SESSION_LENGTH = 256;
|
|
|
|
const RESPONSES_BASE_URL = "https://opencode.ai/zen/go/v1/responses";
|
|
const MAX_TOOL_NAME_LEN = 128;
|
|
|
|
function normalizeSession(value) {
|
|
if (typeof value !== "string") return null;
|
|
const normalized = value.trim();
|
|
if (!normalized || normalized.length > MAX_SESSION_LENGTH) return null;
|
|
return normalized;
|
|
}
|
|
|
|
function nativeSession(headers) {
|
|
if (!headers || typeof headers !== "object") return null;
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (key.toLowerCase() === SESSION_HEADER) return normalizeSession(value);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function translatedSession(sessionId, clientTool) {
|
|
const digest = crypto
|
|
.createHash("sha256")
|
|
.update(`opencode-go\0${clientTool || "generic"}\0${sessionId}`)
|
|
.digest("hex")
|
|
.slice(0, 32);
|
|
return `ses_${digest}`;
|
|
}
|
|
|
|
// Strip the thinking suffix "model(level)" so checks hit the base id.
|
|
function baseModelId(model) {
|
|
return String(model || "").replace(/\([^()]+\)\s*$/, "").trim();
|
|
}
|
|
|
|
// Responses-only per the provider registry (grok-4.6, gpt-5.6-luna, muse-spark, …).
|
|
// Reading the registry keeps this in sync with config — never hardcode model ids here.
|
|
function isResponsesModel(model) {
|
|
const entry = getProviderModels("opencode-go").find((m) => m.id === baseModelId(model));
|
|
return modelTargetFormat(entry) === "openai-responses";
|
|
}
|
|
|
|
// Flatten Chat Completions tool declarations into the Responses flat shape and
|
|
// drop hosted/nameless tools the /responses endpoint rejects.
|
|
function normalizeResponsesTools(body) {
|
|
if (!Array.isArray(body.tools)) return;
|
|
const validNames = new Set();
|
|
body.tools = body.tools.filter((tool) => {
|
|
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
|
|
const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) ? tool.function : null;
|
|
const rawName = typeof tool.name === "string" ? tool.name : (typeof fn?.name === "string" ? fn.name : "");
|
|
const name = rawName.trim();
|
|
if (!name) return false;
|
|
const description = typeof tool.description === "string" ? tool.description : (typeof fn?.description === "string" ? fn.description : "");
|
|
let parameters = (tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters))
|
|
? tool.parameters
|
|
: (fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters) ? fn.parameters : { type: "object", properties: {} });
|
|
// Mirror the request translator: {type:"object"} without properties is rejected
|
|
// by strict Responses backends, so fill in the empty properties map.
|
|
if (parameters.type === "object" && !parameters.properties) parameters = { ...parameters, properties: {} };
|
|
for (const k of Object.keys(tool)) delete tool[k];
|
|
tool.type = "function";
|
|
tool.name = name.slice(0, MAX_TOOL_NAME_LEN);
|
|
if (description) tool.description = description;
|
|
tool.parameters = parameters;
|
|
validNames.add(tool.name);
|
|
return true;
|
|
});
|
|
if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) {
|
|
if (body.tool_choice.type === "function") {
|
|
const n = typeof body.tool_choice.name === "string" ? body.tool_choice.name.trim() : "";
|
|
if (!n || !validNames.has(n)) delete body.tool_choice;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Last line of defense for native Responses clients (sourceFormat === targetFormat
|
|
// skips translation): coerce items in place so malformed tool payloads 400 here
|
|
// with a clear shape instead of upstream as InputValidationError.
|
|
function sanitizeResponsesItems(body) {
|
|
if (!Array.isArray(body.input)) return;
|
|
body.input = body.input.filter((item) => {
|
|
if (!item || typeof item !== "object" || Array.isArray(item)) return true;
|
|
// Strip prior-turn reasoning items: Muse Spark contributor models route to
|
|
// an upstream Console backend where encrypted_content cannot be validated across
|
|
// rotated accounts or sessions, causing 400 "reasoning encrypted_content was not issued to this caller".
|
|
if (item.type === "reasoning") return false;
|
|
delete item.encrypted_content;
|
|
delete item.reasoning_encrypted_content;
|
|
if (item.type === "function_call") {
|
|
if (!item.name || typeof item.name !== "string" || item.name.trim() === "") return false;
|
|
item.name = item.name.trim().slice(0, MAX_TOOL_NAME_LEN);
|
|
item.call_id = clampResponsesCallId(item.call_id);
|
|
item.arguments = coerceResponsesArguments(item.arguments);
|
|
return true;
|
|
}
|
|
if (item.type === "function_call_output") {
|
|
item.call_id = clampResponsesCallId(item.call_id);
|
|
item.output = coerceResponsesOutput(item.output);
|
|
return true;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export class OpenCodeGoExecutor extends DefaultExecutor {
|
|
constructor() {
|
|
super("opencode-go");
|
|
}
|
|
|
|
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
|
// Muse Spark lives on /responses even when a stale runtimeTransport leaks in.
|
|
if (isResponsesModel(model)) return RESPONSES_BASE_URL;
|
|
return super.buildUrl(model, stream, urlIndex, credentials);
|
|
}
|
|
|
|
prepareRequestCredentials({ body, credentials, providerSessionId, clientTool } = {}) {
|
|
const sourceCredentials = credentials || {};
|
|
const native = nativeSession(sourceCredentials.rawHeaders);
|
|
const resolved = normalizeSession(providerSessionId) || resolveSessionId({
|
|
headers: sourceCredentials.rawHeaders,
|
|
body,
|
|
connectionId: sourceCredentials.connectionId,
|
|
scope: "opencode-go",
|
|
});
|
|
|
|
return {
|
|
...sourceCredentials,
|
|
[SESSION_FIELD]: native || translatedSession(resolved, clientTool),
|
|
};
|
|
}
|
|
|
|
async execute(args) {
|
|
const credentials = this.prepareRequestCredentials(args);
|
|
return super.execute({ ...args, credentials });
|
|
}
|
|
|
|
buildHeaders(credentials, stream = true, url, model) {
|
|
const headers = super.buildHeaders(credentials || {}, stream, url, model);
|
|
const prepared = credentials?.[SESSION_FIELD];
|
|
if (prepared) {
|
|
headers[SESSION_HEADER] = prepared;
|
|
return headers;
|
|
}
|
|
|
|
const fallback = this.prepareRequestCredentials({ credentials });
|
|
headers[SESSION_HEADER] = fallback[SESSION_FIELD];
|
|
return headers;
|
|
}
|
|
|
|
transformRequest(model, body, stream, credentials) {
|
|
const out = super.transformRequest(model, body);
|
|
if (!isResponsesModel(model || body?.model)) return out;
|
|
const normalized = normalizeResponsesInput(out.input);
|
|
if (normalized) out.input = normalized;
|
|
if (!Array.isArray(out.input) || out.input.length === 0) {
|
|
out.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }];
|
|
}
|
|
// Responses names the output cap max_output_tokens, not max_tokens.
|
|
if (out.max_output_tokens === undefined) {
|
|
if (out.max_completion_tokens !== undefined) out.max_output_tokens = out.max_completion_tokens;
|
|
else if (out.max_tokens !== undefined) out.max_output_tokens = out.max_tokens;
|
|
}
|
|
delete out.max_tokens;
|
|
delete out.max_completion_tokens;
|
|
if (out.reasoning_effort !== undefined && out.reasoning === undefined) {
|
|
out.reasoning = { effort: out.reasoning_effort, summary: "auto" };
|
|
}
|
|
if (out.reasoning && typeof out.reasoning === "object" && !Array.isArray(out.reasoning)) {
|
|
if (!out.reasoning.summary) out.reasoning.summary = "auto";
|
|
}
|
|
delete out.reasoning_effort;
|
|
out.stream = true;
|
|
out.store = false;
|
|
normalizeResponsesTools(out);
|
|
sanitizeResponsesItems(out);
|
|
return out;
|
|
}
|
|
}
|