diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index afa98fa7..f4747cd4 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -53,7 +53,7 @@ export function findModelName(aliasOrId, modelId) { } export function getModelTargetFormat(aliasOrId, modelId) { - if ((!aliasOrId || aliasOrId === "oc" || aliasOrId === "opencode" || aliasOrId === "ocg" || aliasOrId === "opencode-go") && isMuseSparkModel(modelId)) { + if ((!aliasOrId || aliasOrId === "oc" || aliasOrId === "opencode" || aliasOrId === "ocg" || aliasOrId === "opencode-go" || aliasOrId === "ocz" || aliasOrId === "opencode-zen") && isMuseSparkModel(modelId)) { return FORMATS.OPENAI_RESPONSES; } const models = PROVIDER_MODELS[aliasOrId]; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index f48a8ecb..ed17d330 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -11,6 +11,7 @@ import { CursorExecutor } from "./cursor.js"; import { VertexExecutor } from "./vertex.js"; import { OpenCodeExecutor } from "./opencode.js"; import { OpenCodeGoExecutor } from "./opencode-go.js"; +import { OpenCodeZenExecutor } from "./opencode-zen.js"; import { GrokWebExecutor } from "./grok-web.js"; import { GrokCliExecutor } from "./grok-cli.js"; import { PerplexityWebExecutor } from "./perplexity-web.js"; @@ -43,6 +44,7 @@ const executors = { "vertex-partner": new VertexExecutor("vertex-partner"), opencode: new OpenCodeExecutor(), "opencode-go": new OpenCodeGoExecutor(), + "opencode-zen": new OpenCodeZenExecutor(), "grok-web": new GrokWebExecutor(), "grok-cli": new GrokCliExecutor(), gcli: new GrokCliExecutor(), // Alias @@ -89,6 +91,7 @@ export { VertexExecutor } from "./vertex.js"; export { DefaultExecutor } from "./default.js"; export { OpenCodeExecutor } from "./opencode.js"; export { OpenCodeGoExecutor } from "./opencode-go.js"; +export { OpenCodeZenExecutor } from "./opencode-zen.js"; export { GrokWebExecutor } from "./grok-web.js"; export { GrokCliExecutor } from "./grok-cli.js"; export { PerplexityWebExecutor } from "./perplexity-web.js"; diff --git a/open-sse/executors/opencode-zen.js b/open-sse/executors/opencode-zen.js new file mode 100644 index 00000000..8a43ff85 --- /dev/null +++ b/open-sse/executors/opencode-zen.js @@ -0,0 +1,315 @@ +import crypto from "node:crypto"; +import { DefaultExecutor } from "./default.js"; +import { resolveSessionId } from "../utils/sessionManager.js"; +import { isMuseSparkModel } from "../providers/models/helpers.js"; +import { + normalizeResponsesInput, + clampResponsesCallId, + coerceResponsesArguments, + coerceResponsesOutput, +} from "../translator/formats/responsesApi.js"; + +const SESSION_HEADER = "x-opencode-session"; +const SESSION_FIELD = "_opencodeZenSession"; +const MAX_SESSION_LENGTH = 256; + +const RESPONSES_BASE_URL = "https://opencode.ai/zen/v1/responses"; +const MAX_TOOL_NAME_LEN = 128; +const OPENCODE_UA = "opencode/1.18.31"; +export const OPENCODE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; +const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +// Free-tier fingerprint (mirrors opencode executor, PR #4132): upstream 403s +// requests without the file-search quartet and without stream:true. +const OPENCODE_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"]; + +function hasValidOpencodeVersion(ua) { + const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i); + if (!m) return false; + const major = parseInt(m[1], 10); + const minor = parseInt(m[2], 10); + return major > 1 || (major === 1 && minor >= 17); +} + +function unstableRandom() { + const bytes = crypto.randomBytes(14); + let randomPart = ""; + for (let i = 0; i < 14; i++) { + randomPart += BASE62_CHARS[bytes[i] % 62]; + } + return randomPart; +} + +export function generateSessionId(timestamp = Date.now()) { + const current = BigInt(timestamp) * 0x1000n + 1n; + const value = ~current; + const time = Array.from({ length: 6 }, (_, index) => + Number((value >> BigInt(40 - 8 * index)) & 0xffn) + .toString(16) + .padStart(2, "0") + ).join(""); + return `ses_${time}${unstableRandom()}`; +} + +export function generateRequestId(timestamp = Date.now()) { + const current = BigInt(timestamp) * 0x1000n + 1n; + const value = current; + const time = Array.from({ length: 6 }, (_, index) => + Number((value >> BigInt(40 - 8 * index)) & 0xffn) + .toString(16) + .padStart(2, "0") + ).join(""); + return `msg_${time}${unstableRandom()}`; +} + +export function translateSessionId(sessionId, clientTool = "") { + if (typeof sessionId === "string" && OPENCODE_SESSION_RE.test(sessionId.trim())) { + return sessionId.trim(); + } + const digest = crypto + .createHash("sha256") + .update(`opencode\0${clientTool || "generic"}\0${sessionId || ""}`) + .digest(); + const timeHex = digest.subarray(0, 6).toString("hex"); + let randomPart = ""; + for (let i = 6; i < 20; i++) { + randomPart += BASE62_CHARS[digest[i] % 62]; + } + return `ses_${timeHex}${randomPart}`; +} + +function toolNameOf(tool) { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return ""; + const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) ? tool.function : null; + const raw = typeof tool.name === "string" ? tool.name : (typeof fn?.name === "string" ? fn.name : ""); + return raw.trim(); +} + +function ensureChatFingerprintTools(body) { + if (!body || typeof body !== "object") return; + const present = new Set(); + if (Array.isArray(body.tools)) { + for (const tool of body.tools) { + const name = toolNameOf(tool); + if (name) present.add(name); + } + } else { + body.tools = []; + } + for (const name of OPENCODE_FINGERPRINT_TOOLS) { + if (present.has(name)) continue; + body.tools.push({ + type: "function", + function: { + name, + description: `OpenCode built-in ${name} tool`, + parameters: { type: "object", properties: {} }, + }, + }); + present.add(name); + } +} + +function ensureResponsesFingerprintTools(body) { + if (!body || typeof body !== "object") return; + const present = new Set(); + if (Array.isArray(body.tools)) { + for (const tool of body.tools) { + const name = toolNameOf(tool); + if (name) present.add(name); + } + } else { + body.tools = []; + } + for (const name of OPENCODE_FINGERPRINT_TOOLS) { + if (present.has(name)) continue; + body.tools.push({ + type: "function", + name, + description: `OpenCode built-in ${name} tool`, + parameters: { type: "object", properties: {} }, + }); + present.add(name); + } +} + +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) { + const normalized = normalizeSession(value); + if (normalized && OPENCODE_SESSION_RE.test(normalized)) return normalized; + } + } + return null; +} + +function translatedSession(sessionId, clientTool) { + return translateSessionId(sessionId, clientTool); +} + +// Strip the thinking suffix "model(level)" so checks hit the base id. +function baseModelId(model) { + return String(model || "").replace(/\([^()]+\)\s*$/, "").trim(); +} + +function isResponsesModel(model) { + return isMuseSparkModel(baseModelId(model)); +} + +// 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 OpenCodeZenExecutor extends DefaultExecutor { + constructor() { + super("opencode-zen"); + } + + 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-zen", + }); + + 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 raw = credentials?.rawHeaders || {}; + const lower = {}; + for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v; + const downstreamUa = lower["user-agent"] || ""; + // Free-tier gate: spoof the official client UA. + headers["User-Agent"] = hasValidOpencodeVersion(downstreamUa) ? downstreamUa : OPENCODE_UA; + headers["x-opencode-client"] = lower["x-opencode-client"] || "desktop"; + 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); + // Free-tier gate: upstream 403s stream:false even when everything else is valid. + if (out && typeof out === "object") out.stream = true; + if (!isResponsesModel(model || body?.model)) { + ensureChatFingerprintTools(out); + 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; + ensureResponsesFingerprintTools(out); + normalizeResponsesTools(out); + sanitizeResponsesItems(out); + return out; + } +} diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index fb355d6d..56a8d41d 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -69,6 +69,7 @@ import p66 from "./ollama.js"; import p123 from "./ollama-search.js"; import p67 from "./openai.js"; import p68 from "./opencode-go.js"; +import p68z from "./opencode-zen.js"; import p69 from "./opencode.js"; import p70 from "./openrouter.js"; import p71 from "./perplexity-web.js"; @@ -194,6 +195,7 @@ export default [ p123, p67, p68, + p68z, p69, p70, p71, diff --git a/open-sse/providers/registry/opencode-zen.js b/open-sse/providers/registry/opencode-zen.js new file mode 100644 index 00000000..8ec2ff1f --- /dev/null +++ b/open-sse/providers/registry/opencode-zen.js @@ -0,0 +1,123 @@ +export default { + id: "opencode-zen", + priority: 205, + alias: "ocz", + aliases: [ + "opencode-zen", + ], + uiAlias: "ocz", + display: { + name: "OpenCode Zen", + icon: "terminal", + color: "#E87040", + textIcon: "OZ", + website: "https://opencode.ai/auth", + notice: { + text: "OpenCode Zen PAYG: pay-as-you-go, key from https://opencode.ai/auth. Same models as Zen: paid + free tiers on the fast lane.", + apiKeyUrl: "https://opencode.ai/auth", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://opencode.ai/zen/v1/chat/completions", + headers: {}, + usage: { + url: "https://opencode.ai/zen/v1/usage", + }, + }, + // Multi-endpoint: pick the transport matching the client sourceFormat to skip + // translation. Mirrors opencode-go, pointed at /zen/v1 (see https://opencode.ai/docs/zen/). + transports: [ + { format: "openai", baseUrl: "https://opencode.ai/zen/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, + { format: "claude", baseUrl: "https://opencode.ai/zen/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } }, + { format: "openai-responses", baseUrl: "https://opencode.ai/zen/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, + ], + // supportedFormats follow the endpoint table in https://opencode.ai/docs/zen/ + // (live /zen/v1/models, 2026-09-18: 71 ids). + models: [ + // Claude (messages) + { id: "claude-fable-5", name: "Claude Fable 5", supportedFormats: ["claude"] }, + { id: "claude-fable-5-1", name: "Claude Fable 5.1", supportedFormats: ["claude"] }, + { id: "claude-opus-5", name: "Claude Opus 5", supportedFormats: ["claude"] }, + { id: "claude-opus-4-8", name: "Claude Opus 4.8", supportedFormats: ["claude"] }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7", supportedFormats: ["claude"] }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6", supportedFormats: ["claude"] }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5", supportedFormats: ["claude"] }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5", supportedFormats: ["claude"] }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", supportedFormats: ["claude"] }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", supportedFormats: ["claude"] }, + { id: "claude-sonnet-4", name: "Claude Sonnet 4", supportedFormats: ["claude"] }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", supportedFormats: ["claude"] }, + // Gemini (own path, via chat completions transport) + { id: "gemini-3.6-flash", name: "Gemini 3.6 Flash", supportedFormats: ["openai"] }, + { id: "gemini-3.8-flash", name: "Gemini 3.8 Flash", supportedFormats: ["openai"] }, + { id: "gemini-3.7-flash", name: "Gemini 3.7 Flash", supportedFormats: ["openai"] }, + { id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite", supportedFormats: ["openai"] }, + { id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", supportedFormats: ["openai"] }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", supportedFormats: ["openai"] }, + { id: "gemini-3-flash", name: "Gemini 3 Flash", supportedFormats: ["openai"] }, + // GPT / Grok / Muse Spark paid (responses) + { id: "gpt-6-astra", name: "GPT 6 Astra", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.6-sol", name: "GPT 5.6 Sol", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.6-terra", name: "GPT 5.6 Terra", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.6-luna", name: "GPT 5.6 Luna", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.5", name: "GPT 5.5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.5-pro", name: "GPT 5.5 Pro", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.4", name: "GPT 5.4", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.4-pro", name: "GPT 5.4 Pro", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.4-nano", name: "GPT 5.4 Nano", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.3-codex", name: "GPT 5.3 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.2", name: "GPT 5.2", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.2-codex", name: "GPT 5.2 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.1", name: "GPT 5.1", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.1-codex-max", name: "GPT 5.1 Codex Max", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.1-codex", name: "GPT 5.1 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5.1-codex-mini", name: "GPT 5.1 Codex Mini", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5", name: "GPT 5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5-codex", name: "GPT 5 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "gpt-5-nano", name: "GPT 5 Nano", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "grok-build-0.1", name: "Grok Build 0.1", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "grok-4.6", name: "Grok 4.6", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "grok-4.5", name: "Grok 4.5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "muse-spark-1.3", name: "Muse Spark 1.3", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "muse-spark-1.2", name: "Muse Spark 1.2", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + // Qwen paid (messages) + { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["claude"] }, + { id: "qwen3.5-plus", name: "Qwen 3.5 Plus", supportedFormats: ["claude"] }, + // DeepSeek / GLM / MiniMax / Kimi / Big Pickle (chat completions) + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai"] }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai"] }, + { id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision Exp", supportedFormats: ["openai"] }, + { id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)", supportedFormats: ["openai"] }, + { id: "glm-5.3", name: "GLM 5.3", supportedFormats: ["openai"] }, + { id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] }, + { id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] }, + { id: "glm-5", name: "GLM 5", supportedFormats: ["openai"] }, + { id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai"] }, + { id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai"] }, + { id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai"] }, + { id: "kimi-k3", name: "Kimi K3", supportedFormats: ["openai"] }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] }, + { id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] }, + { id: "kimi-k2.5", name: "Kimi K2.5", supportedFormats: ["openai"] }, + { id: "big-pickle", name: "Big Pickle", supportedFormats: ["openai"] }, + { id: "union-alpha", name: "Union Alpha", supportedFormats: ["claude"] }, + // Free tier on the keyed lane (chat completions) + { id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", supportedFormats: ["openai"] }, + { id: "mimo-v2.5-free", name: "MiMo V2.5 Free", supportedFormats: ["openai"] }, + { id: "ling-3.0-flash-fin-free", name: "Ling 3.0 Flash Fin Free", supportedFormats: ["openai"] }, + { id: "nemotron-3-ultra-free", name: "Nemotron 3 Ultra Free", supportedFormats: ["openai"] }, + { id: "nemotron-3.5-lightning-free", name: "Nemotron 3.5 Lightning Free", supportedFormats: ["openai"] }, + // Free tier on the keyed lane (responses) + { id: "muse-spark-1.3-contributor-free", name: "Muse Spark 1.3 Contributor Free", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + { id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Contributor Free", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, + ], + modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" }, + passthroughModels: true, + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index cca5d861..90ecb0e9 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -15,6 +15,7 @@ import { getGrokCliUsage } from "./usage/grok-cli.js"; import { getKimiUsage } from "./usage/kimi.js"; import { getDeepseekUsage } from "./usage/deepseek.js"; import { getOpenCodeGoUsage } from "./usage/opencode-go.js"; +import { getOpenCodeZenUsage } from "./usage/opencode-zen.js"; import { getGroqUsage } from "./usage/groq.js"; import { getZedUsage } from "./usage/zed.js"; import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.js"; @@ -59,6 +60,7 @@ const USAGE_HANDLERS = { "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData), "opencode-go": (c) => getOpenCodeGoUsage(c.apiKey, c.proxyOptions), + "opencode-zen": (c) => getOpenCodeZenUsage(c.apiKey, c.proxyOptions), deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions), groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions), zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), diff --git a/open-sse/services/usage/opencode-zen.js b/open-sse/services/usage/opencode-zen.js new file mode 100644 index 00000000..54dacec8 --- /dev/null +++ b/open-sse/services/usage/opencode-zen.js @@ -0,0 +1,107 @@ +/** + * OpenCode Zen usage — GET https://opencode.ai/zen/v1/usage + * Auth: Bearer + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { parseResetTime, toFiniteNumber, U } from "./shared.js"; + +const USAGE_URL = U("opencode-zen").url; +const QUOTA_NAMES = { + rolling: "Rolling", + weekly: "Weekly", + monthly: "Monthly", +}; + +function parsePercent(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +export async function getOpenCodeZenUsage(apiKey = null, proxyOptions = null) { + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + return { + message: "OpenCode Zen API key not available. Add a key to view usage.", + }; + } + + try { + const response = await proxyAwareFetch( + USAGE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + Accept: "application/json", + }, + }, + proxyOptions, + ); + + if (response.status === 401) { + return { + plan: "OpenCode Zen", + message: "OpenCode Zen authentication failed. Check the API key.", + }; + } + + if (response.status === 403) { + const error = await response.json().catch(() => null); + const subscriptionRequired = error?.error?.type === "EntitlementError"; + return { + plan: "OpenCode Zen", + message: subscriptionRequired + ? "OpenCode Zen billing required for this API key." + : "OpenCode Zen access forbidden for this API key.", + }; + } + + if (!response.ok) { + return { + plan: "OpenCode Zen", + message: `OpenCode Zen usage API error (${response.status}).`, + }; + } + + const data = await response.json().catch(() => null); + if (!data?.usage || typeof data.usage !== "object") { + return { + plan: "OpenCode Zen", + message: "OpenCode Zen usage response did not contain quota data.", + }; + } + + const quotas = {}; + for (const [period, name] of Object.entries(QUOTA_NAMES)) { + const quota = data.usage[period]; + if (!quota || typeof quota !== "object") continue; + const percent = parsePercent(quota.percent); + if (percent === null) continue; + const used = Math.max(0, Math.min(100, toFiniteNumber(percent, 0))); + quotas[name] = { + used, + total: 100, + remaining: 100 - used, + remainingPercentage: 100 - used, + resetAt: parseResetTime(quota.resetsAt), + unlimited: false, + }; + } + + + if (Object.keys(quotas).length === 0) { + return { + plan: "OpenCode Zen", + message: "OpenCode Zen usage response did not contain valid quota data.", + }; + } + + return { plan: "OpenCode Zen", quotas }; + } catch (error) { + return { message: `OpenCode Zen error: ${error.message}` }; + } +} diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json index ae3a452f..fee2d748 100644 --- a/tests/__baseline__/alias-baseline.json +++ b/tests/__baseline__/alias-baseline.json @@ -180,6 +180,7 @@ "openai": "openai", "opencode": "oc", "opencode-go": "opencode-go", + "opencode-zen": "ocz", "openrouter": "openrouter", "perplexity": "perplexity", "perplexity-agent": "perplexity-agent", @@ -265,6 +266,7 @@ "nebius", "nvidia", "oc", + "ocz", "ollama", "openai", "openai-tts-models", diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 89a3db64..d4de0970 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -762,6 +762,44 @@ } ] }, + "opencode-zen": { + "baseUrl": "https://opencode.ai/zen/v1/chat/completions", + "headers": {}, + "usage": { + "url": "https://opencode.ai/zen/v1/usage" + }, + "format": "openai", + "transports": [ + { + "format": "openai", + "baseUrl": "https://opencode.ai/zen/v1/chat/completions", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + } + }, + { + "format": "claude", + "baseUrl": "https://opencode.ai/zen/v1/messages", + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw", + "anthropicVersion": true + } + }, + { + "format": "openai-responses", + "baseUrl": "https://opencode.ai/zen/v1/responses", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + } + } + ] + }, "opencode": { "baseUrl": "https://opencode.ai", "headers": { diff --git a/tests/unit/opencode-zen-models.test.js b/tests/unit/opencode-zen-models.test.js new file mode 100644 index 00000000..3812d400 --- /dev/null +++ b/tests/unit/opencode-zen-models.test.js @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { PROVIDER_MODELS, getModelSupportedFormats } from "../../open-sse/config/providerModels.js"; +import { PROVIDERS } from "../../open-sse/config/providers.js"; +import { resolveTransport } from "../../open-sse/services/provider.js"; + +// Chat-only models (no /messages, no /responses support on opencode-zen) +const CHAT_ONLY = ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", + "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", + "minimax-m3", "minimax-m2.7", "minimax-m2.5", + "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", + "big-pickle", "deepseek-v4-flash-free", + "mimo-v2.5-free", "ling-3.0-flash-fin-free", "nemotron-3-ultra-free", "nemotron-3.5-lightning-free"]; +// Models that also expose the Anthropic /messages endpoint +const CLAUDE_CAPABLE = ["claude-fable-5", "claude-fable-5-1", "claude-opus-5", + "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", + "claude-sonnet-5", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-sonnet-4", + "claude-haiku-4-5", "qwen3.6-plus", "qwen3.5-plus", "union-alpha"]; +// Models that also expose the OpenAI /responses endpoint +const RESPONSES_CAPABLE = ["gpt-6-astra", + "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", + "gpt-5.5", "gpt-5.5-pro", + "gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini", "gpt-5.4-nano", + "gpt-5.3-codex-spark", "gpt-5.3-codex", + "gpt-5.2", "gpt-5.2-codex", + "gpt-5.1", "gpt-5.1-codex-max", "gpt-5.1-codex", "gpt-5.1-codex-mini", + "gpt-5", "gpt-5-codex", "gpt-5-nano", + "grok-build-0.1", "grok-4.6", "grok-4.5", + "muse-spark-1.3", "muse-spark-1.2", + "muse-spark-1.3-contributor-free", "muse-spark-1.2-contributor-free"]; + +// Mirror of chatCore's per-model transport guard: use the sourceFormat-matched +// transport only when the model declares support for that sourceFormat. +function pickTransport(provider, sourceFormat, alias, model) { + const supported = getModelSupportedFormats(alias, model); + const rt = resolveTransport(provider, sourceFormat); + return supported?.includes(sourceFormat) ? rt : null; +} + +describe("OpenCode Zen model catalog", () => { + it("matches the documented model IDs", () => { + const ids = (PROVIDER_MODELS["ocz"] || []).map((m) => m.id); + expect(ids).toContain("muse-spark-1.3-contributor-free"); + expect(ids).toContain("gpt-5.5"); + expect(ids).toContain("claude-opus-5"); + expect(ids).toContain("kimi-k3"); + expect(ids).toContain("deepseek-v4-pro"); + expect(ids.length).toBeGreaterThan(60); + }); +}); + +describe("OpenCode Zen per-model supportedFormats", () => { + it("declares [claude] for Claude + Qwen + union-alpha models", () => { + for (const m of CLAUDE_CAPABLE) { + expect(getModelSupportedFormats("ocz", m)).toEqual(["claude"]); + } + }); + + it("declares [openai-responses] for GPT/Grok/Spark responses models", () => { + for (const m of RESPONSES_CAPABLE) { + expect(getModelSupportedFormats("ocz", m)).toEqual(["openai-responses"]); + } + }); + + it("declares [openai] only for chat-only models (GLM/Kimi/MiMo) → guards /messages routing", () => { + for (const m of CHAT_ONLY) { + expect(getModelSupportedFormats("ocz", m)).toEqual(["openai"]); + } + }); +}); + +describe("OpenCode Zen multi-endpoint transports", () => { + it("declares openai / claude / openai-responses transports", () => { + const formats = (PROVIDERS["opencode-zen"].transports || []).map((t) => t.format); + expect(formats).toEqual(["openai", "claude", "openai-responses"]); + }); + + it("resolveTransport picks the endpoint matching the client sourceFormat", () => { + expect(resolveTransport("opencode-zen", "claude").baseUrl).toBe("https://opencode.ai/zen/v1/messages"); + expect(resolveTransport("opencode-zen", "openai-responses").baseUrl).toBe("https://opencode.ai/zen/v1/responses"); + expect(resolveTransport("opencode-zen", "openai").baseUrl).toBe("https://opencode.ai/zen/v1/chat/completions"); + }); + + it("uses x-api-key + anthropicVersion on the claude transport", () => { + const t = resolveTransport("opencode-zen", "claude"); + expect(t.auth.header).toBe("x-api-key"); + expect(t.auth.anthropicVersion).toBe(true); + }); +}); + +describe("OpenCode Zen per-model transport guard (chatCore logic)", () => { + it("routes MiniMax/Qwen + claude-format client to /messages", () => { + for (const m of CLAUDE_CAPABLE) { + expect(pickTransport("opencode-zen", "claude", "ocz", m)?.baseUrl).toBe("https://opencode.ai/zen/v1/messages"); + } + }); + + it("does NOT route chat-only models to /messages on a claude-format request", () => { + for (const m of CHAT_ONLY) { + expect(pickTransport("opencode-zen", "claude", "ocz", m)).toBeNull(); + } + }); + + it("routes DeepSeek + responses-format client to /responses", () => { + for (const m of RESPONSES_CAPABLE) { + expect(pickTransport("opencode-zen", "openai-responses", "ocz", m)?.baseUrl).toBe("https://opencode.ai/zen/v1/responses"); + } + }); + + it("routes Muse Spark (responses-only) to /responses, never to /messages", () => { + for (const m of ["muse-spark-1.2", "muse-spark-1.3", "muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free", "grok-4.6", "gpt-5.6-luna"]) { + expect(getModelSupportedFormats("ocz", m)).toEqual(["openai-responses"]); + expect(pickTransport("opencode-zen", "openai-responses", "ocz", m)?.baseUrl).toBe("https://opencode.ai/zen/v1/responses"); + expect(pickTransport("opencode-zen", "claude", "ocz", m)).toBeNull(); + expect(pickTransport("opencode-zen", "openai", "ocz", m)).toBeNull(); + } + }); + + it("does NOT route MiniMax (no responses support) to /responses", () => { + for (const m of CLAUDE_CAPABLE) { + expect(pickTransport("opencode-zen", "openai-responses", "ocz", m)).toBeNull(); + } + }); +});