feat(opencode-zen): add OpenCode Zen (PAYG) provider with free-tier fingerprint, alias ocz
Multi-endpoint provider on https://opencode.ai/zen/v1 (openai / claude / openai-responses transports mirroring opencode-go) with 71 models across paid + free tiers. Free-tier fingerprint: opencode/1.18.x UA spoof, ses_-session header, built-in tool quartet, forced stream. Usage endpoint /zen/v1/usage wired into the dashboard.
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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";
|
||||
|
||||
315
open-sse/executors/opencode-zen.js
Normal file
315
open-sse/executors/opencode-zen.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
123
open-sse/providers/registry/opencode-zen.js
Normal file
123
open-sse/providers/registry/opencode-zen.js
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
107
open-sse/services/usage/opencode-zen.js
Normal file
107
open-sse/services/usage/opencode-zen.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* OpenCode Zen usage — GET https://opencode.ai/zen/v1/usage
|
||||
* Auth: Bearer <apiKey>
|
||||
*/
|
||||
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
123
tests/unit/opencode-zen-models.test.js
Normal file
123
tests/unit/opencode-zen-models.test.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user