+ {displayName && (loginMethod === "OIDC" || loginMethod === "SAML") && (
+
person
{displayName}
- OIDC
+ {loginMethod}
)}
diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js
index 2933bc5d..1e58918a 100644
--- a/src/shared/constants/cliTools.js
+++ b/src/shared/constants/cliTools.js
@@ -8,7 +8,7 @@ export const MITM_TOOLS = {
description: "Google Antigravity IDE with MITM",
configType: "mitm",
mitmDomain: "daily-cloudcode-pa.googleapis.com",
- modelAliases: ["gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
+ modelAliases: ["gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
defaultModels: [
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", alias: "gemini-3.6-flash-high" },
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", alias: "gemini-3.6-flash-medium" },
@@ -57,8 +57,13 @@ export const MITM_TOOLS = {
color: "#FF6B00",
description: "Kiro IDE with MITM",
configType: "mitm",
- mitmDomain: "q.us-east-1.amazonaws.com",
+ mitmDomain: "runtime.us-east-1.kiro.dev",
defaultModels: [
+ // Kiro's agent/"vibe" mode sends modelId "auto" for the main turn and "simple-task"
+ // for background sub-tasks (verified via MITM request dump of generateAssistantResponse).
+ // Both need a mappable slot — otherwise getMappedModel returns null and the chat call
+ // is passed through to AWS instead of being routed to the chosen provider.
+ { id: "auto", name: "Auto (Kiro Agent)", alias: "auto" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", alias: "claude-sonnet-5" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4.5" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" },
diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json
index eda1604e..ae3a452f 100644
--- a/tests/__baseline__/alias-baseline.json
+++ b/tests/__baseline__/alias-baseline.json
@@ -122,6 +122,7 @@
"alicode": "alicode",
"alicode-intl": "alicode-intl",
"alims-intl": "alims-intl",
+ "alitp-intl": "alitp-intl",
"anthropic": "anthropic",
"antigravity": "ag",
"api-airforce": "af",
@@ -206,6 +207,7 @@
"alicode",
"alicode-intl",
"alims-intl",
+ "alitp-intl",
"anthropic",
"assemblyai",
"black-forest-labs",
diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json
index 7cc326c9..2bf96d44 100644
--- a/tests/__baseline__/providers-baseline.json
+++ b/tests/__baseline__/providers-baseline.json
@@ -708,7 +708,37 @@
"opencode-go": {
"baseUrl": "https://opencode.ai/zen/go/v1/chat/completions",
"headers": {},
- "format": "openai"
+ "format": "openai",
+ "transports": [
+ {
+ "format": "openai",
+ "baseUrl": "https://opencode.ai/zen/go/v1/chat/completions",
+ "auth": {
+ "combined": true,
+ "header": "Authorization",
+ "scheme": "bearer"
+ }
+ },
+ {
+ "format": "claude",
+ "baseUrl": "https://opencode.ai/zen/go/v1/messages",
+ "auth": {
+ "combined": true,
+ "header": "x-api-key",
+ "scheme": "raw",
+ "anthropicVersion": true
+ }
+ },
+ {
+ "format": "openai-responses",
+ "baseUrl": "https://opencode.ai/zen/go/v1/responses",
+ "auth": {
+ "combined": true,
+ "header": "Authorization",
+ "scheme": "bearer"
+ }
+ }
+ ]
},
"opencode": {
"baseUrl": "https://opencode.ai",
@@ -968,7 +998,15 @@
"tokenrouter": {
"baseUrl": "https://api.tokenrouter.com/v1/chat/completions",
"validateUrl": "https://api.tokenrouter.com/v1/models",
- "thinkingFormat": "openai",
+ "thinkingFormat": "tokenrouter",
+ "format": "openai"
+ },
+ "alitp-intl": {
+ "baseUrl": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
+ "headers": {},
+ "quirks": {
+ "preserveCacheControl": true
+ },
"format": "openai"
}
}
\ No newline at end of file
diff --git a/tests/auth/saml.test.js b/tests/auth/saml.test.js
new file mode 100644
index 00000000..bce032f1
--- /dev/null
+++ b/tests/auth/saml.test.js
@@ -0,0 +1,40 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ formatX509Certificate,
+ isSamlConfigured,
+ generateSamlMetadata,
+ pickSamlEmail,
+ pickSamlDisplayName,
+} from "../../src/lib/auth/saml.js";
+
+test("formatX509Certificate normalizes Base64 strings into PEM blocks", () => {
+ const rawBase64 = "MIIC1234567890123456789012345678901234567890123456789012345678901234567890";
+ const formatted = formatX509Certificate(rawBase64);
+ assert.match(formatted, /-----BEGIN CERTIFICATE-----/);
+ assert.match(formatted, /-----END CERTIFICATE-----/);
+ assert.equal(formatX509Certificate(""), "");
+});
+
+test("isSamlConfigured checks required fields", () => {
+ assert.equal(isSamlConfigured({ samlEntryPoint: "https://idp.com/sso", samlCert: "cert" }), true);
+ assert.equal(isSamlConfigured({ samlEntryPoint: "https://idp.com/sso" }), false);
+ assert.equal(isSamlConfigured({}), false);
+});
+
+test("generateSamlMetadata produces valid SP XML", () => {
+ const settings = {
+ samlEntryPoint: "https://idp.example.com/sso",
+ samlIssuer: "urn:9router:sp",
+ samlCert: "MIIC123456789012345678901234567890123456789012345678901234567890",
+ };
+ const xml = generateSamlMetadata("https://localhost:20127", settings);
+ assert.match(xml, /entityID="urn:9router:sp"/);
+ assert.match(xml, /Location="https:\/\/localhost:20127\/api\/auth\/saml\/acs"/);
+});
+
+test("Claims Extraction pickSamlEmail & pickSamlDisplayName", () => {
+ const profile = { email: "test@example.com", name: "Test User" };
+ assert.equal(pickSamlEmail(profile, {}), "test@example.com");
+ assert.equal(pickSamlDisplayName(profile, {}), "Test User");
+});
diff --git a/tests/translator/__snapshots__/golden-url-header.test.js.snap b/tests/translator/__snapshots__/golden-url-header.test.js.snap
index 9482052c..584e0313 100644
--- a/tests/translator/__snapshots__/golden-url-header.test.js.snap
+++ b/tests/translator/__snapshots__/golden-url-header.test.js.snap
@@ -38,6 +38,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > alicode-intl → hea
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > alims-intl → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer
",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > anthropic → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -66,6 +85,31 @@ exports[`GOLDEN buildHeaders (default executor providers) > anthropic → header
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > api-airforce → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://endpoint-proxy.local",
+ "X-Title": "Endpoint Proxy",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://endpoint-proxy.local",
+ "X-Title": "Endpoint Proxy",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://endpoint-proxy.local",
+ "X-Title": "Endpoint Proxy",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -85,6 +129,44 @@ exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → heade
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > baidu → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
+exports[`GOLDEN buildHeaders (default executor providers) > bazaarlink → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -104,6 +186,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > bluesminds → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > byteplus → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -268,6 +369,52 @@ exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (a
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > clinepass → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://cline.bot",
+ "User-Agent": "9Router/0.5.50",
+ "X-CLIENT-TYPE": "9router",
+ "X-CLIENT-VERSION": "0.5.50",
+ "X-CORE-VERSION": "0.5.50",
+ "X-IS-MULTIROOT": "false",
+ "X-PLATFORM": "linux",
+ "X-PLATFORM-VERSION": "v24.15.0",
+ "X-Title": "Cline",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://cline.bot",
+ "User-Agent": "9Router/0.5.50",
+ "X-CLIENT-TYPE": "9router",
+ "X-CLIENT-VERSION": "0.5.50",
+ "X-CORE-VERSION": "0.5.50",
+ "X-IS-MULTIROOT": "false",
+ "X-PLATFORM": "linux",
+ "X-PLATFORM-VERSION": "v24.15.0",
+ "X-Title": "Cline",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://cline.bot",
+ "User-Agent": "9Router/0.5.50",
+ "X-CLIENT-TYPE": "9router",
+ "X-CLIENT-VERSION": "0.5.50",
+ "X-CORE-VERSION": "0.5.50",
+ "X-IS-MULTIROOT": "false",
+ "X-PLATFORM": "linux",
+ "X-PLATFORM-VERSION": "v24.15.0",
+ "X-Title": "Cline",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -324,6 +471,43 @@ exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-cn → hea
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-intl → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
+ "X-IDE-Name": "IDE",
+ "X-IDE-Type": "IDE",
+ "X-Product": "SaaS",
+ "x-codebuddy-request": "1",
+ "x-requested-with": "XMLHttpRequest",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
+ "X-IDE-Name": "IDE",
+ "X-IDE-Type": "IDE",
+ "X-Product": "SaaS",
+ "x-codebuddy-request": "1",
+ "x-requested-with": "XMLHttpRequest",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
+ "X-IDE-Name": "IDE",
+ "X-IDE-Type": "IDE",
+ "X-Product": "SaaS",
+ "x-codebuddy-request": "1",
+ "x-requested-with": "XMLHttpRequest",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > cohere → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -381,6 +565,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > featherless → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -482,6 +685,34 @@ exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers (
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > grok-cli → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
+ "x-grok-client-identifier": "grok-shell",
+ "x-grok-client-version": "0.2.99",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
+ "x-grok-client-identifier": "grok-shell",
+ "x-grok-client-version": "0.2.99",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
+ "x-grok-client-identifier": "grok-shell",
+ "x-grok-client-version": "0.2.99",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -520,6 +751,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > hyperbolic → heade
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > kilo-gateway → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -539,6 +789,28 @@ exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > kimchi → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "kimchi/0.1.50",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "kimchi/0.1.50",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "kimchi/0.1.50",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -601,6 +873,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > kimi-coding → head
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > llm7 → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > minimax → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -689,6 +980,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > mmf → headers (api
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > morph → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > nanobanana → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -828,6 +1138,63 @@ exports[`GOLDEN buildHeaders (default executor providers) > perplexity → heade
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
+exports[`GOLDEN buildHeaders (default executor providers) > poolside → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
+exports[`GOLDEN buildHeaders (default executor providers) > sambanova → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -847,6 +1214,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → head
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > tencent → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > together → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -866,6 +1252,44 @@ exports[`GOLDEN buildHeaders (default executor providers) > together → headers
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > tokenrouter → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
+exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ },
+}
+`;
+
exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@@ -942,6 +1366,28 @@ exports[`GOLDEN buildHeaders (default executor providers) > xiaomi-mimo → head
}
`;
+exports[`GOLDEN buildHeaders (default executor providers) > zed → headers (apiKey / oauth) 1`] = `
+{
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "",
+ "Content-Type": "application/json",
+ "content-type": "application/json",
+ },
+ "nonStream": {
+ "Authorization": "",
+ "Content-Type": "application/json",
+ "content-type": "application/json",
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "",
+ "Content-Type": "application/json",
+ "content-type": "application/json",
+ },
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > alicode → url (stream + non-stream) 1`] = `
{
"nonStream": "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
@@ -956,6 +1402,13 @@ exports[`GOLDEN buildUrl (default executor providers) > alicode-intl → url (st
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > alims-intl → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
+ "stream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.anthropic.com/v1/messages",
@@ -963,6 +1416,13 @@ exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (strea
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > api-airforce → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.airforce/v1/chat/completions",
+ "stream": "https://api.airforce/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.assemblyai.com/v1/audio/transcriptions",
@@ -970,6 +1430,20 @@ exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stre
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > baidu → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://qianfan.baidubce.com/v2/chat/completions",
+ "stream": "https://qianfan.baidubce.com/v2/chat/completions",
+}
+`;
+
+exports[`GOLDEN buildUrl (default executor providers) > bazaarlink → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://bazaarlink.ai/api/v1/chat/completions",
+ "stream": "https://bazaarlink.ai/api/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.blackbox.ai/chat/completions",
@@ -977,6 +1451,13 @@ exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > bluesminds → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.bluesminds.com/v1/chat/completions",
+ "stream": "https://api.bluesminds.com/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > byteplus → url (stream + non-stream) 1`] = `
{
"nonStream": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions",
@@ -1012,6 +1493,13 @@ exports[`GOLDEN buildUrl (default executor providers) > cline → url (stream +
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > clinepass → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.cline.bot/api/v1/chat/completions",
+ "stream": "https://api.cline.bot/api/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions",
@@ -1026,6 +1514,13 @@ exports[`GOLDEN buildUrl (default executor providers) > codebuddy-cn → url (st
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > codebuddy-intl → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://www.codebuddy.ai/v2/chat/completions",
+ "stream": "https://www.codebuddy.ai/v2/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > cohere → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.cohere.ai/v1/chat/completions",
@@ -1047,6 +1542,13 @@ exports[`GOLDEN buildUrl (default executor providers) > deepseek → url (stream
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > featherless → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.featherless.ai/v1/chat/completions",
+ "stream": "https://api.featherless.ai/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.fireworks.ai/inference/v1/chat/completions",
@@ -1082,6 +1584,13 @@ exports[`GOLDEN buildUrl (default executor providers) > glm-cn → url (stream +
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > grok-cli → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://cli-chat-proxy.grok.com/v1/responses",
+ "stream": "https://cli-chat-proxy.grok.com/v1/responses",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.groq.com/openai/v1/chat/completions",
@@ -1096,6 +1605,13 @@ exports[`GOLDEN buildUrl (default executor providers) > hyperbolic → url (stre
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > kilo-gateway → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.kilo.ai/api/gateway/chat/completions",
+ "stream": "https://api.kilo.ai/api/gateway/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.kilo.ai/api/openrouter/chat/completions",
@@ -1103,6 +1619,13 @@ exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > kimchi → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://llm.kimchi.dev/openai/v1/chat/completions",
+ "stream": "https://llm.kimchi.dev/openai/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.kimi.com/coding/v1/messages?beta=true",
@@ -1117,6 +1640,13 @@ exports[`GOLDEN buildUrl (default executor providers) > kimi-coding → url (str
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > llm7 → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.llm7.io/v1/chat/completions",
+ "stream": "https://api.llm7.io/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > minimax → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.minimax.io/anthropic/v1/messages?beta=true",
@@ -1145,6 +1675,13 @@ exports[`GOLDEN buildUrl (default executor providers) > mmf → url (stream + no
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > morph → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.morphllm.com/v1/chat/completions",
+ "stream": "https://api.morphllm.com/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > nanobanana → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.nanobananaapi.ai/v1/chat/completions",
@@ -1194,6 +1731,27 @@ exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stre
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.perplexity.ai/v1/responses",
+ "stream": "https://api.perplexity.ai/v1/responses",
+}
+`;
+
+exports[`GOLDEN buildUrl (default executor providers) > poolside → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://inference.poolside.ai/v1/chat/completions",
+ "stream": "https://inference.poolside.ai/v1/chat/completions",
+}
+`;
+
+exports[`GOLDEN buildUrl (default executor providers) > sambanova → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.sambanova.ai/v1/chat/completions",
+ "stream": "https://api.sambanova.ai/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.siliconflow.com/v1/chat/completions",
@@ -1201,6 +1759,13 @@ exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (str
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > tencent → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions",
+ "stream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > together → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.together.xyz/v1/chat/completions",
@@ -1208,6 +1773,20 @@ exports[`GOLDEN buildUrl (default executor providers) > together → url (stream
}
`;
+exports[`GOLDEN buildUrl (default executor providers) > tokenrouter → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.tokenrouter.com/v1/chat/completions",
+ "stream": "https://api.tokenrouter.com/v1/chat/completions",
+}
+`;
+
+exports[`GOLDEN buildUrl (default executor providers) > venice → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://api.venice.ai/api/v1/chat/completions",
+ "stream": "https://api.venice.ai/api/v1/chat/completions",
+}
+`;
+
exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = `
{
"nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions",
@@ -1235,3 +1814,10 @@ exports[`GOLDEN buildUrl (default executor providers) > xiaomi-mimo → url (str
"stream": "https://api.xiaomimimo.com/v1/chat/completions",
}
`;
+
+exports[`GOLDEN buildUrl (default executor providers) > zed → url (stream + non-stream) 1`] = `
+{
+ "nonStream": "https://cloud.zed.dev/completions",
+ "stream": "https://cloud.zed.dev/completions",
+}
+`;
diff --git a/tests/unit/alibaba-token-plan-provider.test.js b/tests/unit/alibaba-token-plan-provider.test.js
new file mode 100644
index 00000000..bd3e7c02
--- /dev/null
+++ b/tests/unit/alibaba-token-plan-provider.test.js
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import REGISTRY from "../../open-sse/providers/registry/index.js";
+import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
+
+describe("Alibaba Token Plan provider", () => {
+ const entry = REGISTRY.find((e) => e.id === "alitp-intl");
+
+ it("is registered as an OpenAI-compatible apikey provider", () => {
+ expect(entry).toBeDefined();
+ expect(entry.category).toBe("apikey");
+ expect(PROVIDERS["alitp-intl"]).toBeDefined();
+ expect(PROVIDERS["alitp-intl"].format).toBe("openai");
+ });
+
+ it("targets the Singapore Token Plan host in compatible mode", () => {
+ // eu-central-1 answers IllegalEndpoint; the plan is Singapore-only.
+ expect(PROVIDERS["alitp-intl"].baseUrl).toBe(
+ "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
+ );
+ });
+
+ it("does not collide with the other three Alibaba key types", () => {
+ const hosts = ["alicode", "alicode-intl", "alims-intl", "alitp-intl"]
+ .map((id) => new URL(PROVIDERS[id].baseUrl).host);
+ expect(new Set(hosts).size).toBe(hosts.length);
+ });
+
+ it("exposes the models the plan actually serves", () => {
+ const ids = (PROVIDER_MODELS["alitp-intl"] || []).map((m) => m.id);
+ expect(ids).toEqual(expect.arrayContaining([
+ "qwen3.8-max-preview",
+ "qwen3.7-max",
+ "qwen3.7-plus",
+ "qwen3.6-flash",
+ "glm-5.2",
+ "deepseek-v4-pro",
+ ]));
+ });
+
+ it("keeps every registry id unique after adding the provider", () => {
+ const ids = REGISTRY.map((e) => e.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+});
diff --git a/tests/unit/antigravity-nonstream-usage-3260.test.js b/tests/unit/antigravity-nonstream-usage-3260.test.js
new file mode 100644
index 00000000..af7fc9a1
--- /dev/null
+++ b/tests/unit/antigravity-nonstream-usage-3260.test.js
@@ -0,0 +1,54 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/usageDb.js", () => ({
+ appendRequestLog: vi.fn(async () => {}),
+ saveRequestDetail: vi.fn(async () => {}),
+ saveRequestUsage: vi.fn(async () => {})
+}));
+
+const { extractUsageFromResponse } = await import("../../open-sse/handlers/chatCore/requestDetail.js");
+
+const USAGE_METADATA = {
+ promptTokenCount: 1234,
+ candidatesTokenCount: 56,
+ cachedContentTokenCount: 78,
+ thoughtsTokenCount: 90,
+};
+
+const EXPECTED = {
+ prompt_tokens: 1234,
+ completion_tokens: 56,
+ cached_tokens: 78,
+ reasoning_tokens: 90,
+};
+
+describe("#3260 non-streaming usage extraction for enveloped Gemini responses", () => {
+ it("reads usageMetadata out of the antigravity { response } envelope", () => {
+ expect(extractUsageFromResponse({ response: { usageMetadata: USAGE_METADATA } })).toEqual(EXPECTED);
+ });
+
+ it("still reads a top-level usageMetadata", () => {
+ expect(extractUsageFromResponse({ usageMetadata: USAGE_METADATA })).toEqual(EXPECTED);
+ });
+
+ it("prefers the top-level metadata when both are present", () => {
+ const enveloped = { ...USAGE_METADATA, promptTokenCount: 1 };
+ const out = extractUsageFromResponse({
+ usageMetadata: USAGE_METADATA,
+ response: { usageMetadata: enveloped },
+ });
+ expect(out.prompt_tokens).toBe(1234);
+ });
+
+ it("leaves the OpenAI and Claude shapes alone", () => {
+ expect(extractUsageFromResponse({ usage: { prompt_tokens: 10, completion_tokens: 2 } }))
+ .toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
+ expect(extractUsageFromResponse({ usage: { input_tokens: 10, output_tokens: 2 } }))
+ .toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
+ });
+
+ it("returns null when there is no usage anywhere", () => {
+ expect(extractUsageFromResponse({ response: { candidates: [] } })).toBeNull();
+ expect(extractUsageFromResponse(null)).toBeNull();
+ });
+});
diff --git a/tests/unit/antigravity-quota-gemini-3.7.test.js b/tests/unit/antigravity-quota-gemini-3.7.test.js
new file mode 100644
index 00000000..e172be31
--- /dev/null
+++ b/tests/unit/antigravity-quota-gemini-3.7.test.js
@@ -0,0 +1,61 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+
+const proxyAwareFetch = vi.fn(async (url) => ({
+ ok: true,
+ status: 200,
+ json: async () => url.includes(":loadCodeAssist")
+ ? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
+ : {
+ models: {
+ "gemini-3.7-flash-high": {
+ displayName: "Gemini 3.7 Flash (High)",
+ quotaInfo: { remainingFraction: 0.85, resetTime: "2026-08-25T12:00:00Z" },
+ },
+ "gemini-3.7-flash-medium": {
+ displayName: "Gemini 3.7 Flash (Medium)",
+ quotaInfo: { remainingFraction: 0.6, resetTime: "2026-08-25T12:00:00Z" },
+ },
+ "gemini-3.7-flash-low": {
+ displayName: "Gemini 3.7 Flash (Low)",
+ quotaInfo: { remainingFraction: 0.35, resetTime: "2026-08-25T12:00:00Z" },
+ },
+ "internal-model": {
+ displayName: "Internal",
+ isInternal: true,
+ quotaInfo: { remainingFraction: 0.5 },
+ },
+ },
+ },
+ text: async () => "{}",
+}));
+
+vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
+ proxyAwareFetch,
+}));
+
+describe("Antigravity quota tracker: Gemini 3.7 Flash usage bars", () => {
+ beforeEach(() => proxyAwareFetch.mockClear());
+
+ it("returns Gemini 3.7 Flash tier quotas so the dashboard can render usage bars", async () => {
+ const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
+
+ const usage = await getAntigravityUsage("access-token", {});
+
+ expect(usage.quotas["gemini-3.7-flash-high"]).toMatchObject({
+ used: 150,
+ total: 1000,
+ remainingPercentage: 85,
+ displayName: "Gemini 3.7 Flash (High)",
+ });
+ expect(usage.quotas["gemini-3.7-flash-medium"]).toMatchObject({
+ used: 400,
+ total: 1000,
+ remainingPercentage: 60,
+ });
+ expect(usage.quotas["gemini-3.7-flash-low"]).toMatchObject({
+ used: 650,
+ total: 1000,
+ remainingPercentage: 35,
+ });
+ });
+});
diff --git a/tests/unit/custom-server-peer-headers.test.js b/tests/unit/custom-server-peer-headers.test.js
new file mode 100644
index 00000000..7bc3053e
--- /dev/null
+++ b/tests/unit/custom-server-peer-headers.test.js
@@ -0,0 +1,86 @@
+// custom-server.js is the only thing that makes x-9r-real-ip trustworthy. Boot a real
+// HTTP server through it and confirm a client cannot smuggle its own peer headers in.
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import { createRequire } from "node:module";
+import http from "node:http";
+import { __test__ as requestDetails } from "@/lib/db/repos/requestDetailsRepo.js";
+
+const require = createRequire(import.meta.url);
+
+let server;
+let baseUrl;
+let seenHeaders;
+
+beforeAll(async () => {
+ require("../../custom-server.js");
+ server = http.createServer((req, res) => {
+ seenHeaders = req.headers;
+ res.end("ok");
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ baseUrl = `http://127.0.0.1:${server.address().port}`;
+});
+
+afterAll(async () => {
+ await new Promise((resolve) => server.close(resolve));
+});
+
+async function get(headers = {}) {
+ await fetch(baseUrl, { headers });
+ return seenHeaders;
+}
+
+describe("custom-server peer header sanitizing", () => {
+ it("generates a peer trust token at boot", () => {
+ expect(process.env.NINEROUTER_PEER_TOKEN).toMatch(/^[0-9a-f]{48}$/);
+ });
+
+ it("replaces a client-supplied x-9r-real-ip with the socket address", async () => {
+ const headers = await get({ "x-9r-real-ip": "203.0.113.55" });
+
+ expect(headers["x-9r-real-ip"]).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
+ });
+
+ it("stamps the trust token so downstream can tell the wrapper ran", async () => {
+ const headers = await get();
+
+ expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
+ });
+
+ it("drops a client-supplied peer trust token", async () => {
+ const headers = await get({ "x-9r-peer-token": "forged-token" });
+
+ expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
+ expect(headers["x-9r-peer-token"]).not.toBe("forged-token");
+ });
+
+ it("drops a client-supplied x-9r-via-proxy marker", async () => {
+ const headers = await get({ "x-9r-via-proxy": "1" });
+
+ expect(headers["x-9r-via-proxy"]).toBeUndefined();
+ });
+
+ it("marks via-proxy and adopts the forwarded IP for a loopback proxy hop", async () => {
+ const headers = await get({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" });
+
+ expect(headers["x-9r-via-proxy"]).toBe("1");
+ expect(headers["x-9r-real-ip"]).toBe("203.0.113.9");
+ expect(headers["x-forwarded-for"]).toBeUndefined();
+ });
+
+ // chat.js snapshots every client header into the request detail. Anything that grants
+ // access must not survive into a record the dashboard renders and cloud sync uploads.
+ it("keeps the peer token out of persisted request details", () => {
+ const sanitized = requestDetails.sanitizeHeaders({
+ "x-9r-peer-token": "secret",
+ "x-9r-cli-token": "secret",
+ "authorization": "Bearer sk-x",
+ "x-9r-real-ip": "127.0.0.1",
+ });
+
+ expect(sanitized["x-9r-peer-token"]).toBeUndefined();
+ expect(sanitized["x-9r-cli-token"]).toBeUndefined();
+ expect(sanitized["authorization"]).toBeUndefined();
+ expect(sanitized["x-9r-real-ip"]).toBe("127.0.0.1");
+ });
+});
diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js
index 099cbde7..fd30e4b2 100644
--- a/tests/unit/dashboard-guard.test.js
+++ b/tests/unit/dashboard-guard.test.js
@@ -35,6 +35,8 @@ vi.mock("@/lib/auth/dashboardSession", () => ({
const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
+const PEER_TOKEN = "peer-token-fixture";
+
function request(pathname, headers = {}) {
const normalizedHeaders = new Headers(headers);
return {
@@ -45,9 +47,16 @@ function request(pathname, headers = {}) {
};
}
+// A request that actually came through custom-server.js: peer IP stamped from the TCP
+// socket and proven by the per-process secret.
+function localRequest(pathname, headers = {}) {
+ return request(pathname, { "x-9r-peer-token": PEER_TOKEN, "x-9r-real-ip": "127.0.0.1", ...headers });
+}
+
describe("dashboard guard public LLM API access", () => {
beforeEach(() => {
vi.clearAllMocks();
+ process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@@ -55,14 +64,14 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback public LLM API without API key", async () => {
- const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" }));
+ const response = await proxy(localRequest("/v1/chat/completions", { host: "localhost:20128" }));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
});
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
- const response = await proxy(request("/v1/chat/completions", {
+ const response = await proxy(localRequest("/v1/chat/completions", {
host: "localhost",
"x-9r-real-ip": "10.204.111.34",
}));
@@ -72,7 +81,7 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback peer IP regardless of Host", async () => {
- const response = await proxy(request("/v1/chat/completions", {
+ const response = await proxy(localRequest("/v1/chat/completions", {
host: "localhost:20128",
"x-9r-real-ip": "127.0.0.1",
}));
@@ -89,7 +98,7 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback rewritten public LLM API without API key", async () => {
- const response = await proxy(request("/api/v1/chat/completions", { host: "localhost:20128" }));
+ const response = await proxy(localRequest("/api/v1/chat/completions", { host: "localhost:20128" }));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
@@ -191,6 +200,7 @@ describe("dashboard guard public LLM API access", () => {
describe("dashboard guard local-only access", () => {
beforeEach(() => {
vi.clearAllMocks();
+ process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@@ -207,7 +217,7 @@ describe("dashboard guard local-only access", () => {
});
it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
- const response = await proxy(request("/api/mcp/filesystem/sse", {
+ const response = await proxy(localRequest("/api/mcp/filesystem/sse", {
host: "localhost:20128",
origin: "http://localhost:20128",
}));
@@ -219,7 +229,7 @@ describe("dashboard guard local-only access", () => {
it("allows local-only route on loopback when requireLogin=false", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
- const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
+ const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://localhost:20128",
}));
@@ -240,7 +250,7 @@ describe("dashboard guard local-only access", () => {
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
- const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
+ const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://evil.example.com",
}));
diff --git a/tests/unit/fish-audio-tts.test.js b/tests/unit/fish-audio-tts.test.js
new file mode 100644
index 00000000..8a1cd6a2
--- /dev/null
+++ b/tests/unit/fish-audio-tts.test.js
@@ -0,0 +1,109 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import REGISTRY from "../../open-sse/providers/registry/index.js";
+import { PROVIDER_MEDIA } from "../../open-sse/providers/index.js";
+import { FORMAT_HANDLERS } from "../../open-sse/handlers/ttsProviders/genericFormats.js";
+import { AI_PROVIDERS } from "@/shared/constants/providers";
+
+const AUDIO = new Uint8Array(256).fill(7);
+
+function okResponse() {
+ return {
+ ok: true,
+ status: 200,
+ headers: new Headers({ "content-type": "audio/mpeg" }),
+ arrayBuffer: async () => AUDIO.buffer,
+ };
+}
+
+describe("Fish Audio TTS provider", () => {
+ const entry = REGISTRY.find((e) => e.id === "fish-audio");
+
+ it("is registered as a TTS-only apikey provider", () => {
+ expect(entry).toBeDefined();
+ expect(entry.category).toBe("apikey");
+ expect(entry.serviceKinds).toEqual(["tts"]);
+ expect(PROVIDER_MEDIA["fish-audio"]?.ttsConfig?.baseUrl).toBe("https://api.fish.audio/v1/tts");
+ });
+
+ it("is visible to the generic dispatcher, which reads AI_PROVIDERS", () => {
+ // synthesizeViaConfig() looks the provider up here, not in PROVIDER_MEDIA.
+ expect(AI_PROVIDERS["fish-audio"]?.ttsConfig?.format).toBe("fish-audio");
+ expect(typeof FORMAT_HANDLERS["fish-audio"]).toBe("function");
+ });
+
+ it("exposes the four documented models", () => {
+ const ids = (entry.ttsConfig.models || []).map((m) => m.id);
+ expect(ids).toEqual(["s2.1-pro-free", "s2.1-pro", "s2-pro", "s1"]);
+ });
+
+ it("keeps registry ids and aliases unique", () => {
+ const ids = REGISTRY.map((e) => e.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ const aliases = REGISTRY.map((e) => e.alias).filter(Boolean);
+ expect(new Set(aliases).size).toBe(aliases.length);
+ });
+});
+
+describe("Fish Audio TTS request shape", () => {
+ const handler = FORMAT_HANDLERS["fish-audio"];
+ let fetchMock;
+
+ beforeEach(() => {
+ fetchMock = vi.fn(async () => okResponse());
+ global.fetch = fetchMock;
+ });
+
+ const callArgs = () => {
+ const [url, init] = fetchMock.mock.calls.at(-1);
+ return { url, init, body: JSON.parse(init.body) };
+ };
+
+ it("sends the model as an HTTP header, not in the body", async () => {
+ await handler({
+ baseUrl: "https://api.fish.audio/v1/tts",
+ apiKey: "sk-test",
+ text: "xin chào",
+ modelId: "s1",
+ voiceId: "",
+ });
+
+ const { url, init, body } = callArgs();
+ expect(url).toBe("https://api.fish.audio/v1/tts");
+ expect(init.headers.model).toBe("s1");
+ expect(init.headers.Authorization).toBe("Bearer sk-test");
+ expect(body).toEqual({ text: "xin chào", format: "mp3" });
+ });
+
+ it("maps the voice onto reference_id, and omits it when unset", async () => {
+ await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "voice-abc" });
+ expect(callArgs().body.reference_id).toBe("voice-abc");
+
+ await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
+ expect(callArgs().body).not.toHaveProperty("reference_id");
+ });
+
+ it("defaults to the free model when none is given", async () => {
+ await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "", voiceId: "" });
+ expect(callArgs().init.headers.model).toBe("s2.1-pro-free");
+ });
+
+ it("returns base64 audio with its format", async () => {
+ const out = await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
+ expect(out.format).toBe("mp3");
+ expect(typeof out.base64).toBe("string");
+ expect(out.base64.length).toBeGreaterThan(0);
+ });
+
+ it("surfaces the upstream error message", async () => {
+ global.fetch = vi.fn(async () => ({
+ ok: false,
+ status: 402,
+ text: async () => JSON.stringify({ message: "Insufficient credit" }),
+ }));
+
+ await expect(
+ handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" }),
+ ).rejects.toThrow("Insufficient credit");
+ });
+});
diff --git a/tests/unit/fusion-strip-stream-options-3024.test.js b/tests/unit/fusion-strip-stream-options-3024.test.js
new file mode 100644
index 00000000..267cdede
--- /dev/null
+++ b/tests/unit/fusion-strip-stream-options-3024.test.js
@@ -0,0 +1,83 @@
+// Issue #3024 — Fusion combo must strip `stream_options` from panel requests
+// when running non-streaming, or DeepSeek rejects with
+// "stream_options should be set along with stream = true".
+
+import { describe, it, expect, vi } from "vitest";
+import { handleFusionChat } from "../../open-sse/services/combo.js";
+
+// Minimal logger stub (combo.js calls log.info/warn).
+const log = { info: () => {}, warn: () => {}, error: () => {} };
+
+function makeBody(extra = {}) {
+ return {
+ model: "combo/gemseek",
+ stream: true,
+ stream_options: { include_usage: true },
+ messages: [{ role: "user", content: "hi" }],
+ ...extra,
+ };
+}
+
+describe("Fusion strips stream_options (#3024)", () => {
+ it("removes stream_options before fanning out to panel models", async () => {
+ let capturedPanelBody = null;
+ const handleSingleModel = vi.fn(async (panelBody, model, isPanel) => {
+ if (isPanel) capturedPanelBody = panelBody;
+ // Simulate a successful non-stream JSON answer for the panel.
+ if (isPanel) {
+ return new Response(JSON.stringify({ choices: [{ message: { content: `ans-${model}` } }] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ // Judge leg: return a final answer.
+ return new Response(JSON.stringify({ choices: [{ message: { content: "final" } }] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ });
+
+ const res = await handleFusionChat({
+ body: makeBody(),
+ models: ["ds/deepseek-v4-flash", "gemini/gemini-3.5-flash-lite"],
+ handleSingleModel,
+ log,
+ comboName: "GemSeek",
+ judgeModel: "gemini/gemini-3.5-flash-lite",
+ });
+
+ expect(res).toBeInstanceOf(Response);
+ expect(capturedPanelBody).not.toBeNull();
+ // Critical assertion: stream_options must NOT leak into panel requests.
+ expect(capturedPanelBody.stream_options).toBeUndefined();
+ expect(capturedPanelBody.stream).toBe(false);
+ // Ensure the original client body still had it (proves we stripped deliberately).
+ expect(makeBody().stream_options).toBeDefined();
+ });
+
+ it("does not throw for a 2-model fusion with stream_options present", async () => {
+ const handleSingleModel = vi.fn(async (panelBody, model, isPanel) => {
+ if (isPanel) {
+ return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return new Response(JSON.stringify({ choices: [{ message: { content: "final" } }] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ });
+
+ const res = await handleFusionChat({
+ body: makeBody({ stream_options: { include_usage: true } }),
+ models: ["ds/deepseek-v4-pro", "ds/deepseek-v4-flash"],
+ handleSingleModel,
+ log,
+ comboName: "GemSeek",
+ judgeModel: "ds/deepseek-v4-pro",
+ });
+
+ expect(res.status).toBe(200);
+ });
+});
diff --git a/tests/unit/gemini-3.7-antigravity.test.js b/tests/unit/gemini-3.7-antigravity.test.js
new file mode 100644
index 00000000..d188d1f9
--- /dev/null
+++ b/tests/unit/gemini-3.7-antigravity.test.js
@@ -0,0 +1,36 @@
+import { describe, it, expect } from "vitest";
+import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
+import antigravityRegistry from "../../open-sse/providers/registry/antigravity.js";
+import geminiRegistry from "../../open-sse/providers/registry/gemini.js";
+import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
+
+describe("Gemini 3.7 Flash Support & Config (#3286, #3281)", () => {
+ it("registers gemini-3.7-flash tiered models in antigravity provider registry", () => {
+ const agIds = antigravityRegistry.models.map(m => m.id);
+ expect(agIds).toContain("gemini-3.7-flash-high");
+ expect(agIds).toContain("gemini-3.7-flash-medium");
+ expect(agIds).toContain("gemini-3.7-flash-low");
+ expect(agIds).not.toContain("gemini-3.7-flash");
+ });
+
+ it("registers gemini-3.7-flash in gemini provider registry", () => {
+ const geminiIds = geminiRegistry.models.map(m => m.id);
+ expect(geminiIds).toContain("gemini-3.7-flash");
+ });
+
+ it("resolves capabilities correctly for gemini-3.7 models with official limits", () => {
+ const caps = getCapabilitiesForModel("antigravity", "gemini-3.7-flash-high");
+ expect(caps.vision).toBe(true);
+ expect(caps.reasoning).toBe(true);
+ expect(caps.thinkingFormat).toBe("gemini-level");
+ expect(caps.contextWindow).toBe(1048576);
+ expect(caps.maxOutput).toBe(65536);
+ });
+
+ it("defines pricing matching gemini-3.6-flash baseline", () => {
+ expect(MODEL_PRICING["gemini-3.7-flash"]).toEqual(MODEL_PRICING["gemini-3.6-flash"]);
+ expect(MODEL_PRICING["gemini-3.7-flash-high"]).toEqual(MODEL_PRICING["gemini-3.6-flash-high"]);
+ expect(MODEL_PRICING["gemini-3.7-flash-medium"]).toEqual(MODEL_PRICING["gemini-3.6-flash-medium"]);
+ expect(MODEL_PRICING["gemini-3.7-flash-low"]).toEqual(MODEL_PRICING["gemini-3.6-flash-low"]);
+ });
+});
diff --git a/tests/unit/hermes-vision-detection.test.js b/tests/unit/hermes-vision-detection.test.js
new file mode 100644
index 00000000..40669614
--- /dev/null
+++ b/tests/unit/hermes-vision-detection.test.js
@@ -0,0 +1,115 @@
+import { describe, it, expect } from "vitest";
+import { detectRequiredCapabilities } from "../../open-sse/services/combo.js";
+import { augmentModelsWithCapacityAdapter } from "../../open-sse/services/capacityAdapter.js";
+import { stripUnsupportedModalities } from "../../open-sse/translator/concerns/modality.js";
+import { FORMATS } from "../../open-sse/translator/formats.js";
+
+describe("Hermes Vision Image Detection", () => {
+ it("detects vision from Ollama / Hermes images array", () => {
+ const body = {
+ messages: [
+ {
+ role: "user",
+ content: "Please analyze this image from Hermes",
+ images: ["iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="],
+ },
+ ],
+ };
+ const caps = detectRequiredCapabilities(body);
+ expect(caps.has("vision")).toBe(true);
+ });
+
+ it("detects vision from Vercel AI SDK / Hermes experimental_attachments", () => {
+ const body = {
+ messages: [
+ {
+ role: "user",
+ content: "Describe this attachment",
+ experimental_attachments: [
+ {
+ contentType: "image/png",
+ url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
+ },
+ ],
+ },
+ ],
+ };
+ const caps = detectRequiredCapabilities(body);
+ expect(caps.has("vision")).toBe(true);
+ });
+
+ it("detects vision from Hermes attachments array", () => {
+ const body = {
+ messages: [
+ {
+ role: "user",
+ content: "Look at this photo",
+ attachments: [
+ {
+ mediaType: "image/jpeg",
+ url: "https://example.com/photo.jpg",
+ },
+ ],
+ },
+ ],
+ };
+ const caps = detectRequiredCapabilities(body);
+ expect(caps.has("vision")).toBe(true);
+ });
+
+ it("detects vision from embedded data:image URI in string content", () => {
+ const body = {
+ messages: [
+ {
+ role: "user",
+ content: "Here is an inline image: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
+ },
+ ],
+ };
+ const caps = detectRequiredCapabilities(body);
+ expect(caps.has("vision")).toBe(true);
+ });
+
+ it("auto-switches non-vision model (deepseek-v4-pro) to Vision Adapter model (Kimi-K3)", () => {
+ const body = {
+ messages: [
+ {
+ role: "user",
+ content: "Analyze image",
+ images: ["base64data..."],
+ },
+ ],
+ };
+ const reqCaps = detectRequiredCapabilities(body);
+ const settings = {
+ capacityAdapter: {
+ vision: {
+ enabled: true,
+ models: ["cmc/moonshotai/Kimi-K3"],
+ },
+ },
+ };
+
+ const augmented = augmentModelsWithCapacityAdapter(["cmc/deepseek/deepseek-v4-pro"], reqCaps, settings);
+ expect(augmented).toEqual(["cmc/moonshotai/Kimi-K3", "cmc/deepseek/deepseek-v4-pro"]);
+ });
+
+ it("strips msg.images and attachments when model does not support vision", () => {
+ const body = {
+ messages: [
+ {
+ role: "user",
+ content: "Test text",
+ images: ["base64..."],
+ experimental_attachments: [{ contentType: "image/png", url: "data:image/png;base64,..." }],
+ },
+ ],
+ };
+ const noVisionCaps = { vision: false, pdf: false, audioInput: false };
+
+ stripUnsupportedModalities(body, FORMATS.OPENAI, noVisionCaps);
+
+ expect(body.messages[0].images).toBeUndefined();
+ expect(body.messages[0].experimental_attachments).toHaveLength(0);
+ });
+});
diff --git a/tests/unit/kiro-model-slots.test.js b/tests/unit/kiro-model-slots.test.js
index 8bb1afab..fb48479a 100644
--- a/tests/unit/kiro-model-slots.test.js
+++ b/tests/unit/kiro-model-slots.test.js
@@ -14,6 +14,13 @@ describe("Kiro MITM model slots", () => {
expect(Array.isArray(kiro.defaultModels)).toBe(true);
});
+ it("offers a mappable slot for the agent default model id 'auto'", () => {
+ // اسلات auto برای vibe mode لازمه — وگرنه درخواست میره AWS
+ const auto = kiro.defaultModels.find((m) => m.id === "auto");
+ expect(auto).toBeTruthy();
+ expect(auto.alias).toBe("auto");
+ });
+
it("offers a mappable slot for Claude Sonnet 5", () => {
const sonnet5 = kiro.defaultModels.find((m) => m.id === "claude-sonnet-5");
expect(sonnet5).toBeTruthy();
diff --git a/tests/unit/kiro-usage-and-tool-integrity.test.js b/tests/unit/kiro-usage-and-tool-integrity.test.js
new file mode 100644
index 00000000..94908b5f
--- /dev/null
+++ b/tests/unit/kiro-usage-and-tool-integrity.test.js
@@ -0,0 +1,369 @@
+/**
+ * Five Kiro defects, all on the OAuth/social Kiro route (kr/claude-sonnet-4.5
+ * with a >100k context). Production shape: 402 of 2156 usageHistory rows for
+ * Kiro recorded completionTokens 0, and the 25 newest rows all sat pinned at
+ * exactly 1 output token against prompts of 80k-103k -- i.e. the router was not
+ * measuring the answer, it was measuring nothing and rounding up.
+ *
+ * A. OUT 0 / OUT 1. finish() estimates completion tokens as
+ * totalContentLength / 4, but tool-call bytes were never added to
+ * totalContentLength. A turn whose entire answer is a tool call therefore
+ * measured as an empty answer (Math.max(1, ...) is where the 1 comes from).
+ *
+ * B. Truncation threw away a complete-enough answer. stopDisposition() maps
+ * model_context_window_exceeded (and max_tokens alongside tool calls) to
+ * terminal_incomplete, which hard-fails the turn -- even when the model had
+ * already streamed text. A truncated turn is what finish_reason "length" is
+ * for. Both disposition gates needed the bypass: the declared-stop-reason
+ * gate runs first and returns, so patching only finish() would be dead code.
+ *
+ * C. One bad tool fragment killed every good one. Three separate latches:
+ * emitTools() validated per turn and threw out of the loop; the frame-loop
+ * catch cleared state.tools wholesale; and the toolUseEvent branch returned
+ * early forever once toolValidationError was set. Net effect for the client:
+ * a turn that answered nothing.
+ *
+ * D. Cache tokens dropped on the kiro:claude route. kiro-to-claude built usage
+ * from prompt_tokens/completion_tokens only, so Claude clients lost
+ * cache_read_input_tokens / cache_creation_input_tokens and could neither
+ * price the turn nor size their prompt cache.
+ *
+ * E. Defence-in-depth only: both request translators discarded canonical.valid.
+ * canonicalizeKiroConversation() self-heals every failure mode it can detect
+ * (see the test below), so the guard is unreachable by construction today --
+ * it exists so a future validator rule cannot ship an unusable body silently.
+ *
+ * Kiro also answers an unusable conversation with 400 {"message":"Improperly
+ * formed request.","reason":"REQUEST_BODY_INVALID"}, which cools down every
+ * account that reports it. That is a property of ERROR_RULES rather than of this
+ * executor -- the same body fails identically on any account -- so it belongs to
+ * the request-scoped `fallback: false` rule kind, not here.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const fetchMock = vi.fn();
+
+vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
+ proxyAwareFetch: (...args) => fetchMock(...args)
+}));
+
+const { KiroExecutor } = await import("../../open-sse/executors/kiro.js");
+const { kiroToClaudeResponse, kiroToClaudeNonStreaming } = await import(
+ "../../open-sse/translator/response/kiro-to-claude.js"
+);
+const { validateKiroConversation, canonicalizeKiroConversation } = await import(
+ "../../open-sse/translator/concerns/kiroConversation.js"
+);
+
+const encoder = new TextEncoder();
+const credentials = {
+ accessToken: "test-token",
+ providerSpecificData: { kiroToolCallRepair: true }
+};
+
+function crc32(bytes) {
+ let crc = 0xffffffff;
+ for (const byte of bytes) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit++) {
+ crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
+ }
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+function encodeHeader(name, value) {
+ const nameBytes = encoder.encode(name);
+ const valueBytes = encoder.encode(value);
+ const bytes = new Uint8Array(1 + nameBytes.length + 3 + valueBytes.length);
+ let offset = 0;
+ bytes[offset++] = nameBytes.length;
+ bytes.set(nameBytes, offset);
+ offset += nameBytes.length;
+ bytes[offset++] = 7;
+ new DataView(bytes.buffer).setUint16(offset, valueBytes.length, false);
+ offset += 2;
+ bytes.set(valueBytes, offset);
+ return bytes;
+}
+
+function concat(chunks) {
+ const output = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.byteLength, 0));
+ let offset = 0;
+ for (const chunk of chunks) {
+ output.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return output;
+}
+
+function checksum(bytes) {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ view.setUint32(8, crc32(bytes.subarray(0, 8)), false);
+ view.setUint32(bytes.byteLength - 4, crc32(bytes.subarray(0, bytes.byteLength - 4)), false);
+ return bytes;
+}
+
+function frameFromEntries(entries, payload) {
+ const headers = concat(entries.map(([name, value]) => encodeHeader(name, value)));
+ const payloadBytes = encoder.encode(JSON.stringify(payload));
+ const totalLength = 12 + headers.byteLength + payloadBytes.byteLength + 4;
+ const frame = new Uint8Array(totalLength);
+ const view = new DataView(frame.buffer);
+ view.setUint32(0, totalLength, false);
+ view.setUint32(4, headers.byteLength, false);
+ frame.set(headers, 12);
+ frame.set(payloadBytes, 12 + headers.byteLength);
+ return checksum(frame);
+}
+
+function frame(eventType, payload) {
+ return frameFromEntries([[":event-type", eventType]], payload);
+}
+
+function response(frames, status = 200) {
+ return new Response(new ReadableStream({
+ start(controller) {
+ for (const value of frames) controller.enqueue(value);
+ controller.close();
+ }
+ }), { status, statusText: status === 200 ? "OK" : "Upstream Error" });
+}
+
+async function execute(executor = new KiroExecutor(), overrides = {}) {
+ return executor.execute({
+ model: "kr/claude-opus-4.8",
+ body: { systemPrompt: "base", conversationState: {} },
+ stream: true,
+ credentials,
+ ...overrides
+ });
+}
+
+// Output is held behind the ": kiro-validation" heartbeat until clean EOF, so
+// every executor assertion has to drain the whole response.
+async function run(frames) {
+ fetchMock.mockResolvedValueOnce(response(frames));
+ return await (await execute()).response.text();
+}
+
+// Same as run(), but with the bounded tool-call repair retry disabled, so a
+// hard failure is surfaced from the first attempt instead of triggering a
+// second upstream fetch.
+async function runNoRepair(frames) {
+ fetchMock.mockResolvedValueOnce(response(frames));
+ const result = await execute(new KiroExecutor(), {
+ credentials: { accessToken: "test-token", providerSpecificData: { kiroToolCallRepair: false } }
+ });
+ return await result.response.text();
+}
+
+// The estimator only runs when metering and context usage both arrived and the
+// upstream reported no token totals of its own -- the exact production shape.
+const METERED = [
+ frame("meteringEvent", { usage: 2, unit: "credit" }),
+ frame("contextUsageEvent", { contextUsagePercentage: 10 })
+];
+
+function usageFrom(body) {
+ const usages = body
+ .split("\n")
+ .filter(line => line.startsWith("data: ") && line.includes('"usage"'))
+ .map(line => JSON.parse(line.slice(6)).usage)
+ .filter(Boolean);
+ return usages[usages.length - 1];
+}
+
+beforeEach(() => {
+ fetchMock.mockReset();
+ vi.spyOn(console, "error").mockImplementation(() => {});
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("A: tool-call bytes count as output tokens", () => {
+ it("does not report a tool-only turn as OUT 1", async () => {
+ const body = await run([
+ frame("toolUseEvent", {
+ toolUseId: "call_a",
+ name: "tool_call",
+ input: { name: "mcp_search", arguments: { query: "why is the router reporting zero output" } }
+ }),
+ frame("metadataEvent", { stopReason: "tool_use" }),
+ ...METERED
+ ]);
+
+ const usage = usageFrom(body);
+ expect(usage).toBeDefined();
+ // Was 1: the Math.max floor over a totalContentLength of 0.
+ expect(usage.completion_tokens).toBeGreaterThan(10);
+ expect(usage.total_tokens).toBe(usage.prompt_tokens + usage.completion_tokens);
+ });
+
+ it("still counts plain text output", async () => {
+ const body = await run([
+ frame("assistantResponseEvent", { content: "x".repeat(400) }),
+ frame("metadataEvent", { stopReason: "end_turn" }),
+ ...METERED
+ ]);
+ expect(usageFrom(body).completion_tokens).toBe(100);
+ });
+});
+
+describe("C: one unusable tool fragment does not take the whole turn with it", () => {
+ it("ships the valid call and drops only the invalid one", async () => {
+ const body = await run([
+ frame("toolUseEvent", {
+ toolUseId: "good",
+ name: "tool_call",
+ input: { name: "mcp_search", arguments: { q: "router" } }
+ }),
+ // No nested MCP name -> unusable, cannot be forwarded to the client.
+ frame("toolUseEvent", { toolUseId: "bad", name: "tool_call", input: { arguments: { q: "router" } } }),
+ frame("metadataEvent", { stopReason: "tool_use" }),
+ ...METERED
+ ]);
+
+ expect(body).toContain('\\"name\\":\\"mcp_search\\"');
+ expect(body).not.toContain('"id":"bad"');
+ expect(body).toContain('"finish_reason":"tool_calls"');
+ });
+
+ it("keeps streamed text when the only tool call is unusable", async () => {
+ const body = await run([
+ frame("assistantResponseEvent", { content: "Here is what I found." }),
+ frame("toolUseEvent", { toolUseId: "bad", name: "tool_call", input: { arguments: {} } }),
+ frame("metadataEvent", { stopReason: "tool_use" }),
+ ...METERED
+ ]);
+
+ expect(body).toContain("Here is what I found.");
+ expect(body).not.toContain("invalid_kiro_tool_call");
+ });
+
+ it("still hard-fails when nothing usable was produced at all", async () => {
+ const body = await runNoRepair([
+ frame("toolUseEvent", { toolUseId: "bad", name: "tool_call", input: { arguments: {} } }),
+ frame("metadataEvent", { stopReason: "tool_use" })
+ ]);
+ expect(body).toContain("invalid_kiro_tool_call");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("B: truncation after output closes as length, not as a failure", () => {
+ it("keeps the text and reports finish_reason length", async () => {
+ const body = await run([
+ frame("assistantResponseEvent", { content: "Partial but usable answer." }),
+ frame("metadataEvent", { stopReason: "model_context_window_exceeded" }),
+ ...METERED
+ ]);
+
+ expect(body).toContain("Partial but usable answer.");
+ expect(body).toContain('"finish_reason":"length"');
+ expect(body).not.toContain("kiro_terminal_incomplete");
+ });
+
+ it("still fails a truncation that produced nothing", async () => {
+ const body = await run([
+ frame("metadataEvent", { stopReason: "model_context_window_exceeded" })
+ ]);
+ expect(body).toContain("kiro_terminal_incomplete");
+ });
+});
+
+describe("D: cache tokens survive the kiro -> claude translation", () => {
+ const finishChunk = (usage) => ({
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
+ usage
+ });
+
+ function finalUsage(usage) {
+ const state = {};
+ // Usage rides an earlier chunk in the real stream; feed it the same way.
+ kiroToClaudeResponse({ choices: [{ index: 0, delta: { content: "hi" } }], usage }, state);
+ const events = kiroToClaudeResponse(finishChunk(usage), state) || [];
+ return events.find(e => e.type === "message_delta")?.usage;
+ }
+
+ it("forwards the flat Chat spelling the executor emits", () => {
+ expect(finalUsage({
+ prompt_tokens: 103000,
+ completion_tokens: 640,
+ cache_read_input_tokens: 98000,
+ cache_creation_input_tokens: 1912
+ })).toEqual({
+ input_tokens: 103000,
+ output_tokens: 640,
+ cache_read_input_tokens: 98000,
+ cache_creation_input_tokens: 1912
+ });
+ });
+
+ it("also accepts the nested details spelling used on passthrough", () => {
+ expect(finalUsage({
+ prompt_tokens: 500,
+ completion_tokens: 20,
+ prompt_tokens_details: { cached_tokens: 480, cache_creation_tokens: 20 }
+ })).toEqual({
+ input_tokens: 500,
+ output_tokens: 20,
+ cache_read_input_tokens: 480,
+ cache_creation_input_tokens: 20
+ });
+ });
+
+ it("omits the cache keys when the upstream reported none", () => {
+ expect(finalUsage({ prompt_tokens: 500, completion_tokens: 20 }))
+ .toEqual({ input_tokens: 500, output_tokens: 20 });
+ });
+
+ it("preserves cache on the non-streaming path too", () => {
+ const message = kiroToClaudeNonStreaming({
+ choices: [{ message: { content: "hi" } }],
+ usage: { prompt_tokens: 90, completion_tokens: 4, cache_read_input_tokens: 80 }
+ });
+ expect(message.usage).toMatchObject({
+ input_tokens: 90,
+ output_tokens: 4,
+ cache_read_input_tokens: 80
+ });
+ expect(message.usage).not.toHaveProperty("cache_creation_input_tokens");
+ });
+});
+
+describe("E: the translator valid-guard is defence-in-depth", () => {
+ const SPECS = [{ toolSpecification: { name: "read_file", inputSchema: { json: { type: "object" } } } }];
+
+ it("validateKiroConversation names the offending turn", () => {
+ // Hand-built, NOT normalized: assistant first, then a tool call with no
+ // matching result and a name no spec declares.
+ const result = validateKiroConversation(
+ [{ assistantResponseMessage: { content: "hi", toolUses: [{ toolUseId: "t1", name: "ghost" }] } }],
+ { userInputMessage: { content: "go" } },
+ SPECS
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain("role:0");
+ expect(result.errors).toContain("pair:0");
+ expect(result.errors).toContain("spec:0");
+ });
+
+ it("canonicalizeKiroConversation heals that same conversation", () => {
+ // This is why the guard cannot fire today: normalizeTurns() forces
+ // user-first/user-last alternation and non-empty content, and the
+ // second-chance pass flattens every structured tool turn to text.
+ const out = canonicalizeKiroConversation({
+ history: [{ assistantResponseMessage: { content: "hi", toolUses: [{ toolUseId: "t1", name: "ghost" }] } }],
+ currentMessage: { userInputMessage: { content: "" } },
+ modelId: "claude-sonnet-4-5",
+ toolSpecs: SPECS
+ });
+
+ expect(out.valid).toBe(true);
+ expect(out.errors).toEqual([]);
+ expect(out.currentMessage.userInputMessage.content).toBe("continue");
+ expect(out.history[0].userInputMessage).toBeDefined();
+ });
+});
diff --git a/tests/unit/local-request-peer-trust-3294.test.js b/tests/unit/local-request-peer-trust-3294.test.js
new file mode 100644
index 00000000..7cc72907
--- /dev/null
+++ b/tests/unit/local-request-peer-trust-3294.test.js
@@ -0,0 +1,211 @@
+// GHSA-pjm4-8fpg-f9p6 (#3294): `next start` leaves custom-server.js out of the request
+// path, so x-9r-real-ip arrives straight from the client and a remote caller can claim to
+// be loopback. Host is spoofable the same way, so it cannot be the production fallback.
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ nextResponse: Symbol("next"),
+ jsonResponse: vi.fn((body, init) => ({ status: init?.status || 200, body })),
+ getSettings: vi.fn(),
+ validateApiKey: vi.fn(),
+ getConsistentMachineId: vi.fn(),
+ verifyDashboardAuthToken: vi.fn(),
+}));
+
+vi.mock("next/server", () => ({
+ NextResponse: {
+ next: vi.fn(() => mocks.nextResponse),
+ json: mocks.jsonResponse,
+ redirect: vi.fn((url) => ({ status: 307, url })),
+ },
+}));
+
+vi.mock("@/lib/localDb", () => ({
+ getSettings: mocks.getSettings,
+ validateApiKey: mocks.validateApiKey,
+}));
+
+vi.mock("@/shared/utils/machineId", () => ({
+ getConsistentMachineId: mocks.getConsistentMachineId,
+}));
+
+vi.mock("@/lib/auth/dashboardSession", () => ({
+ verifyDashboardAuthToken: mocks.verifyDashboardAuthToken,
+}));
+
+const { proxy } = await import("../../src/dashboardGuard.js");
+const { getClientIp } = await import("../../src/lib/auth/loginLimiter.js");
+
+const PEER_TOKEN = "peer-token-fixture";
+
+function request(pathname, headers = {}) {
+ return {
+ nextUrl: { pathname, searchParams: new URL(`http://localhost${pathname}`).searchParams },
+ headers: new Headers(headers),
+ cookies: { get: vi.fn(() => undefined) },
+ url: `http://localhost${pathname}`,
+ };
+}
+
+const originalNodeEnv = process.env.NODE_ENV;
+
+describe("peer header trust", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
+ process.env.NODE_ENV = "production";
+ mocks.getSettings.mockResolvedValue({ requireLogin: true });
+ mocks.validateApiKey.mockResolvedValue(false);
+ mocks.getConsistentMachineId.mockResolvedValue("cli-token");
+ mocks.verifyDashboardAuthToken.mockResolvedValue(false);
+ });
+
+ afterEach(() => {
+ process.env.NODE_ENV = originalNodeEnv;
+ delete process.env.NINEROUTER_PEER_TOKEN;
+ });
+
+ it("rejects a spoofed loopback peer IP that carries no trust proof", async () => {
+ const response = await proxy(request("/api/v1/models", {
+ host: "172.18.192.1:20140",
+ "x-9r-real-ip": "127.0.0.1",
+ }));
+
+ expect(response.status).toBe(401);
+ expect(response.body.error).toBe("API key required for remote API access");
+ });
+
+ it("rejects a spoofed loopback peer IP carrying a wrong trust token", async () => {
+ const response = await proxy(request("/api/v1/models", {
+ host: "172.18.192.1:20140",
+ "x-9r-real-ip": "127.0.0.1",
+ "x-9r-peer-token": "guessed-token",
+ }));
+
+ expect(response.status).toBe(401);
+ });
+
+ it("rejects a spoofed loopback Host in production", async () => {
+ const response = await proxy(request("/api/v1/models", { host: "localhost" }));
+
+ expect(response.status).toBe(401);
+ });
+
+ it("rejects a spoofed loopback peer IP when the wrapper never booted", async () => {
+ delete process.env.NINEROUTER_PEER_TOKEN;
+
+ const response = await proxy(request("/api/v1/models", {
+ host: "172.18.192.1:20140",
+ "x-9r-real-ip": "127.0.0.1",
+ "x-9r-peer-token": "any-token",
+ }));
+
+ expect(response.status).toBe(401);
+ });
+
+ it("keeps serving a genuinely local request stamped by the wrapper", async () => {
+ const response = await proxy(request("/api/v1/models", {
+ host: "localhost:20128",
+ "x-9r-real-ip": "127.0.0.1",
+ "x-9r-peer-token": PEER_TOKEN,
+ }));
+
+ expect(response).toBe(mocks.nextResponse);
+ expect(mocks.validateApiKey).not.toHaveBeenCalled();
+ });
+
+ // A dual-stack listener reports loopback as ::ffff:127.0.0.1, which the old
+ // split-on-first-colon check reduced to "".
+ it.each(["::ffff:127.0.0.1", "::1", "[::1]", "127.0.0.1", "::FFFF:127.0.0.1"])(
+ "treats %s as a loopback peer",
+ async (peerIp) => {
+ const response = await proxy(request("/api/v1/models", {
+ host: "localhost:20128",
+ "x-9r-real-ip": peerIp,
+ "x-9r-peer-token": PEER_TOKEN,
+ }));
+
+ expect(response).toBe(mocks.nextResponse);
+ }
+ );
+
+ it.each(["::ffff:10.204.111.34", "2001:db8::1", "[2001:db8::1]", "10.204.111.34"])(
+ "refuses %s as a peer",
+ async (peerIp) => {
+ const response = await proxy(request("/api/v1/models", {
+ host: "localhost:20128",
+ "x-9r-real-ip": peerIp,
+ "x-9r-peer-token": PEER_TOKEN,
+ }));
+
+ expect(response.status).toBe(401);
+ }
+ );
+
+ it("still refuses a stamped non-loopback peer IP", async () => {
+ const response = await proxy(request("/api/v1/models", {
+ host: "localhost:20128",
+ "x-9r-real-ip": "10.204.111.34",
+ "x-9r-peer-token": PEER_TOKEN,
+ }));
+
+ expect(response.status).toBe(401);
+ });
+
+ it("blocks spoofed local-only routes that would otherwise spawn processes", async () => {
+ mocks.getSettings.mockResolvedValue({ requireLogin: false });
+
+ const response = await proxy(request("/api/mcp/filesystem/sse", {
+ host: "172.18.192.1:20140",
+ "x-9r-real-ip": "127.0.0.1",
+ }));
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe("Local only: CLI token required");
+ });
+
+ it("accepts the legacy Host fallback only in development", async () => {
+ process.env.NODE_ENV = "development";
+
+ const response = await proxy(request("/api/v1/models", { host: "localhost:20127" }));
+
+ expect(response).toBe(mocks.nextResponse);
+ });
+});
+
+describe("login limiter client IP", () => {
+ beforeEach(() => {
+ process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
+ delete process.env.TRUST_PROXY;
+ });
+
+ afterEach(() => {
+ delete process.env.NINEROUTER_PEER_TOKEN;
+ delete process.env.TRUST_PROXY;
+ });
+
+ it("buckets spoofed peer IPs together so lockout cannot be rotated away", () => {
+ const first = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "1.1.1.1" }));
+ const second = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "2.2.2.2" }));
+
+ expect(first).toBe("unknown");
+ expect(second).toBe("unknown");
+ });
+
+ it("keys on the stamped peer IP when the wrapper proved it", () => {
+ const ip = getClientIp(request("/api/auth/login", {
+ "x-9r-real-ip": "203.0.113.9",
+ "x-9r-peer-token": PEER_TOKEN,
+ }));
+
+ expect(ip).toBe("203.0.113.9");
+ });
+
+ it("still honours TRUST_PROXY for operators fronting 9router with a reverse proxy", () => {
+ process.env.TRUST_PROXY = "true";
+
+ const ip = getClientIp(request("/api/auth/login", { "x-forwarded-for": "198.51.100.7, 10.0.0.1" }));
+
+ expect(ip).toBe("198.51.100.7");
+ });
+});
diff --git a/tests/unit/openai-responses-empty-toolcalls.test.js b/tests/unit/openai-responses-empty-toolcalls.test.js
new file mode 100644
index 00000000..10455747
--- /dev/null
+++ b/tests/unit/openai-responses-empty-toolcalls.test.js
@@ -0,0 +1,52 @@
+/**
+ * Some providers (e.g. codebuddy / cbcn) attach `tool_calls: []` to every
+ * streaming chunk. An empty array is truthy in JS, so the guard
+ * `if (delta.tool_calls)` closed the message on the first content token,
+ * emitting `output_text.done` early and truncating the answer. This mirrors
+ * the real repro: `codex exec -m cbcn/kimi-k3` answered only "cod" instead
+ * of "codex-ok".
+ */
+import { describe, it, expect } from "vitest";
+import { openaiToOpenAIResponsesResponse } from "../../open-sse/translator/response/openai-responses.js";
+import { initState } from "../../open-sse/translator/index.js";
+import { FORMATS } from "../../open-sse/translator/formats.js";
+
+describe("OpenAI Chat stream → Responses: empty tool_calls arrays", () => {
+ it("does not emit output_text.done early when every chunk carries tool_calls: []", () => {
+ const state = initState(FORMATS.OPENAI_RESPONSES);
+ const chunks = [
+ { id: "cmb-test", choices: [{ index: 0, delta: { role: "assistant", content: "", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
+ { id: "cmb-test", choices: [{ index: 0, delta: { content: "", reasoning_content: "thinking", tool_calls: [] }, finish_reason: null }] },
+ { id: "cmb-test", choices: [{ index: 0, delta: { content: "cod", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
+ { id: "cmb-test", choices: [{ index: 0, delta: { content: "ex", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
+ { id: "cmb-test", choices: [{ index: 0, delta: { content: "-ok", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
+ { id: "cmb-test", choices: [{ index: 0, delta: { content: "", reasoning_content: "", tool_calls: [] }, finish_reason: "stop" }] },
+ ];
+
+ const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
+ const textDone = events.filter((e) => e.event === "response.output_text.done");
+ const textDeltas = events.filter((e) => e.event === "response.output_text.delta");
+
+ expect(textDone).toHaveLength(1);
+ expect(textDone[0].data.text).toBe("codex-ok");
+ expect(textDeltas.map((e) => e.data.delta).join("")).toBe("codex-ok");
+ // done must come after every delta
+ expect(events.indexOf(textDone[0])).toBe(events.indexOf(textDeltas[textDeltas.length - 1]) + 1);
+ });
+
+ it("still closes the message before a real tool call", () => {
+ const state = initState(FORMATS.OPENAI_RESPONSES);
+ const chunks = [
+ { id: "cmb-test", choices: [{ index: 0, delta: { content: "Let me run that.", tool_calls: [] }, finish_reason: null }] },
+ { id: "cmb-test", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "exec", arguments: "" } }] }, finish_reason: null }] },
+ { id: "cmb-test", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
+ ];
+
+ const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
+ const added = events.find((e) => e.event === "response.output_item.added" && e.data.item?.type === "function_call");
+ const textDone = events.find((e) => e.event === "response.output_text.done");
+
+ expect(added).toBeTruthy();
+ expect(textDone.data.text).toBe("Let me run that.");
+ });
+});
diff --git a/tests/unit/opencode-go-models.test.js b/tests/unit/opencode-go-models.test.js
index 10946ab1..fcbffae9 100644
--- a/tests/unit/opencode-go-models.test.js
+++ b/tests/unit/opencode-go-models.test.js
@@ -1,71 +1,97 @@
import { describe, expect, it } from "vitest";
-import { PROVIDER_MODELS, getModelTargetFormat } from "../../open-sse/config/providerModels.js";
-import { OpenCodeGoExecutor } from "../../open-sse/executors/opencode-go.js";
+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";
-const CHAT_MODELS = [
- "glm-5.2",
- "glm-5.1",
- // OpenCode Go docs' endpoint table currently says kimi-k2.7, but its
- // config example and the live API use kimi-k2.7-code.
- "kimi-k2.7-code",
- "kimi-k2.6",
- "deepseek-v4-pro",
- "deepseek-v4-flash",
- "mimo-v2.5",
- "mimo-v2.5-pro",
-];
+// Chat-only models (no /messages, no /responses support on opencode-go)
+const CHAT_ONLY = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "mimo-v2.5", "mimo-v2.5-pro"];
+// Models that also expose the Anthropic /messages endpoint
+const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"];
+// Models that also expose the OpenAI /responses endpoint
+const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"];
-const MESSAGES_MODELS = [
- "minimax-m3",
- "minimax-m2.7",
- "minimax-m2.5",
- "qwen3.7-max",
- "qwen3.7-plus",
- "qwen3.6-plus",
-];
+// 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 Go official model catalog", () => {
- it("matches the documented OpenCode Go model IDs", () => {
- const ids = (PROVIDER_MODELS["opencode-go"] || []).map((model) => model.id);
-
- expect(ids).toEqual([...CHAT_MODELS, ...MESSAGES_MODELS]);
+describe("OpenCode Go model catalog", () => {
+ it("matches the documented model IDs", () => {
+ const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id);
+ expect(ids).toEqual([
+ "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6",
+ "deepseek-v4-pro", "deepseek-v4-flash",
+ "mimo-v2.5", "mimo-v2.5-pro",
+ "minimax-m3", "minimax-m2.7", "minimax-m2.5",
+ "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
+ ]);
});
+});
- it("marks documented Qwen and MiniMax models as Anthropic messages format", () => {
- for (const model of MESSAGES_MODELS) {
- expect(getModelTargetFormat("opencode-go", model)).toBe("claude");
+describe("OpenCode Go per-model supportedFormats", () => {
+ it("declares [openai, claude] for MiniMax + Qwen models", () => {
+ for (const m of CLAUDE_CAPABLE) {
+ expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude"]);
}
});
- it("keeps GLM, Kimi, DeepSeek, and MiMo on OpenAI-compatible chat format", () => {
- for (const model of CHAT_MODELS) {
- expect(getModelTargetFormat("opencode-go", model)).toBeNull();
+ it("declares [openai, claude, openai-responses] for DeepSeek models", () => {
+ for (const m of RESPONSES_CAPABLE) {
+ expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude", "openai-responses"]);
+ }
+ });
+
+ it("declares [openai] only for chat-only models (GLM/Kimi/MiMo) → guards /messages routing", () => {
+ for (const m of CHAT_ONLY) {
+ expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai"]);
}
});
});
-describe("OpenCode Go endpoint routing", () => {
- it("routes Qwen and MiniMax models to the messages endpoint with x-api-key auth", () => {
- const executor = new OpenCodeGoExecutor();
+describe("OpenCode Go multi-endpoint transports", () => {
+ it("declares openai / claude / openai-responses transports", () => {
+ const formats = (PROVIDERS["opencode-go"].transports || []).map((t) => t.format);
+ expect(formats).toEqual(["openai", "claude", "openai-responses"]);
+ });
- for (const model of MESSAGES_MODELS) {
- expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/messages");
- const headers = executor.buildHeaders({ apiKey: "sk-test" }, false);
- expect(headers["x-api-key"]).toBe("sk-test");
- expect(headers["anthropic-version"]).toBeDefined();
- expect(headers.Authorization).toBeUndefined();
+ it("resolveTransport picks the endpoint matching the client sourceFormat", () => {
+ expect(resolveTransport("opencode-go", "claude").baseUrl).toBe("https://opencode.ai/zen/go/v1/messages");
+ expect(resolveTransport("opencode-go", "openai-responses").baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
+ expect(resolveTransport("opencode-go", "openai").baseUrl).toBe("https://opencode.ai/zen/go/v1/chat/completions");
+ });
+
+ it("uses x-api-key + anthropicVersion on the claude transport", () => {
+ const t = resolveTransport("opencode-go", "claude");
+ expect(t.auth.header).toBe("x-api-key");
+ expect(t.auth.anthropicVersion).toBe(true);
+ });
+});
+
+describe("OpenCode Go per-model transport guard (chatCore logic)", () => {
+ it("routes MiniMax/Qwen + claude-format client to /messages", () => {
+ for (const m of CLAUDE_CAPABLE) {
+ expect(pickTransport("opencode-go", "claude", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/messages");
}
});
- it("routes GLM, Kimi, DeepSeek, and MiMo models to chat/completions with bearer auth", () => {
- const executor = new OpenCodeGoExecutor();
+ it("does NOT route chat-only models to /messages on a claude-format request", () => {
+ for (const m of CHAT_ONLY) {
+ expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull();
+ }
+ });
- for (const model of CHAT_MODELS) {
- expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/chat/completions");
- const headers = executor.buildHeaders({ apiKey: "sk-test" }, false);
- expect(headers.Authorization).toBe("Bearer sk-test");
- expect(headers["x-api-key"]).toBeUndefined();
- expect(headers["anthropic-version"]).toBeUndefined();
+ it("routes DeepSeek + responses-format client to /responses", () => {
+ for (const m of RESPONSES_CAPABLE) {
+ expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
+ }
+ });
+
+ it("does NOT route MiniMax (no responses support) to /responses", () => {
+ for (const m of CLAUDE_CAPABLE) {
+ expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)).toBeNull();
}
});
});
diff --git a/tests/unit/ping-reasoning-models-3010.test.js b/tests/unit/ping-reasoning-models-3010.test.js
new file mode 100644
index 00000000..a1aa545f
--- /dev/null
+++ b/tests/unit/ping-reasoning-models-3010.test.js
@@ -0,0 +1,77 @@
+// Issue #3010 — Dashboard "Test" button fails for reasoning models because of a
+// tiny max_tokens probe. pingModelByKind must use a sane budget (1024) and treat a
+// reasoning-only (length-limited) response as a successful connection.
+//
+// The route module pulls in Next.js-only deps (@/lib/localDb, etc.) that don't
+// resolve under raw vitest, so we mock them and exercise the exported function.
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Mock the heavy Next.js-dependent imports BEFORE importing ping.js.
+vi.mock("@/lib/localDb", () => ({ getApiKeys: vi.fn(async () => [{ key: "test-key", isActive: true }]) }));
+vi.mock("@/shared/constants/config", () => ({ UPDATER_CONFIG: { appPort: 20127 } }));
+vi.mock("@/shared/utils/machineId", () => ({ getConsistentMachineId: vi.fn(async () => "cli-token") }));
+
+const { pingModelByKind } = await import("../../src/app/api/models/test/ping.js");
+
+describe("pingModelByKind reasoning models (#3010)", () => {
+ let fetchMock;
+
+ beforeEach(() => {
+ fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ function jsonResponse(obj) {
+ return {
+ ok: true,
+ status: 200,
+ text: async () => JSON.stringify(obj),
+ json: async () => obj,
+ };
+ }
+
+ it("uses a 1024-token budget for the chat completions probe", async () => {
+ fetchMock.mockResolvedValue(jsonResponse({ choices: [{ message: { content: "Hi there!" } }] }));
+
+ await pingModelByKind("cline-pass/kimi-k3", "llm", "http://127.0.0.1:20127");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(body.max_tokens).toBe(1024);
+ });
+
+ it("treats a reasoning-only (length-limited) response as ok:true", async () => {
+ fetchMock.mockResolvedValue(
+ jsonResponse({
+ choices: [
+ {
+ finish_reason: "length",
+ message: { content: "", reasoning: "The user said hi — a simple greeting..." },
+ },
+ ],
+ })
+ );
+
+ const result = await pingModelByKind("cline-pass/kimi-k3", "llm", "http://127.0.0.1:20127");
+ expect(result.ok).toBe(true);
+ expect(result.note).toMatch(/reasoning-only/);
+ });
+
+ it("still fails when there are no choices and no reasoning", async () => {
+ fetchMock.mockResolvedValue(jsonResponse({ choices: [] }));
+ const result = await pingModelByKind("some/model", "llm", "http://127.0.0.1:20127");
+ expect(result.ok).toBe(false);
+ expect(result.error).toMatch(/no completion choices/);
+ });
+
+ it("passes a normal answer with the larger budget", async () => {
+ fetchMock.mockResolvedValue(jsonResponse({ choices: [{ message: { content: "Hello!" } }] }));
+ const result = await pingModelByKind("openai/gpt-4o", "llm", "http://127.0.0.1:20127");
+ expect(result.ok).toBe(true);
+ });
+});
diff --git a/tests/unit/qoder-billing.test.js b/tests/unit/qoder-billing.test.js
new file mode 100644
index 00000000..f97a1293
--- /dev/null
+++ b/tests/unit/qoder-billing.test.js
@@ -0,0 +1,147 @@
+/**
+ * Unit tests for qoder billing error detection.
+ *
+ * Ensures that billing blocks (code 112, 10605, pricingUrl) are detected
+ * on the first SSE frame and returned as 403 responses so chatCore can
+ * mark the connection unavailable and trigger combo failover.
+ */
+
+import { describe, it, expect } from "vitest";
+import { __test__ as qoderExecutorInternals } from "../../open-sse/executors/qoder.js";
+
+describe("isBillingBlock", () => {
+ const { isBillingBlock } = qoderExecutorInternals;
+
+ it("detects code 112 (quota exhausted)", () => {
+ const msg = '{"code":"112","message":"Quota exhausted","pricingUrl":"..."}';
+ expect(isBillingBlock(msg)).toBe(true);
+ });
+
+ it("detects code 10605 (queue throttle)", () => {
+ const msg = '{"code":"10605","message":"Queue limit"}';
+ expect(isBillingBlock(msg)).toBe(true);
+ });
+
+ it("detects pricingUrl field", () => {
+ const msg = '{"message":"Upgrade required","pricingUrl":"https://..."}';
+ expect(isBillingBlock(msg)).toBe(true);
+ });
+
+ it("returns false for normal errors without billing markers", () => {
+ const msg = '{"code":"500","message":"Internal error"}';
+ expect(isBillingBlock(msg)).toBe(false);
+ });
+
+ it("returns false for empty or non-string input", () => {
+ expect(isBillingBlock("")).toBe(false);
+ expect(isBillingBlock(null)).toBe(false);
+ expect(isBillingBlock(undefined)).toBe(false);
+ });
+});
+
+describe("wrapQoderSSE billing detection", () => {
+ const { wrapQoderSSE } = qoderExecutorInternals;
+
+ function makeResponse(lines, { status = 200 } = {}) {
+ const body = new ReadableStream({
+ start(controller) {
+ const encoder = new TextEncoder();
+ for (const line of lines) controller.enqueue(encoder.encode(line));
+ controller.close();
+ },
+ });
+ return new Response(body, { status });
+ }
+
+ it("returns 403 response when first frame is billing block (code 112)", async () => {
+ const billingEnv = JSON.stringify({
+ statusCodeValue: 403,
+ body: '{"code":"112","message":"Quota exhausted","pricingUrl":"https://qoder.sh/pricing"}',
+ });
+ const upstream = `data: ${billingEnv}\n\n`;
+
+ const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
+
+ expect(wrapped.status).toBe(403);
+ expect(wrapped.ok).toBe(false);
+ const json = await wrapped.json();
+ expect(json.error).toBeDefined();
+ expect(json.error.message).toContain("112");
+ });
+
+ it("returns 403 response when first frame is billing block (code 10605)", async () => {
+ const billingEnv = JSON.stringify({
+ statusCodeValue: 429,
+ body: '{"code":"10605","message":"Queue limit"}',
+ });
+ const upstream = `data: ${billingEnv}\n\n`;
+
+ const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
+
+ expect(wrapped.status).toBe(403);
+ expect(wrapped.ok).toBe(false);
+ });
+
+ it("returns 403 response when first frame has pricingUrl", async () => {
+ const billingEnv = JSON.stringify({
+ statusCodeValue: 402,
+ body: '{"message":"Payment required","pricingUrl":"https://..."}',
+ });
+ const upstream = `data: ${billingEnv}\n\n`;
+
+ const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
+
+ expect(wrapped.status).toBe(403);
+ });
+
+ it("passes through normal errors (non-billing) as wrapped SSE", async () => {
+ const errorEnv = JSON.stringify({
+ statusCodeValue: 500,
+ body: "Internal server error",
+ });
+ const upstream = `data: ${errorEnv}\n\n`;
+
+ const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
+
+ // Normal error: still 200 response, error text in SSE body
+ expect(wrapped.status).toBe(200);
+ expect(wrapped.ok).toBe(true);
+
+ const reader = wrapped.body.getReader();
+ const decoder = new TextDecoder();
+ let buf = "";
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buf += decoder.decode(value, { stream: true });
+ }
+ buf += decoder.decode();
+
+ expect(buf).toContain("[qoder error 500");
+ expect(buf).toContain("data: [DONE]");
+ });
+
+ it("passes through successful responses unchanged", async () => {
+ const inner = JSON.stringify({ choices: [{ delta: { content: "hello" } }] });
+ const successEnv = JSON.stringify({ statusCodeValue: 200, body: inner });
+ const upstream = `data: ${successEnv}\n\n`;
+
+ const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
+
+ expect(wrapped.status).toBe(200);
+ expect(wrapped.ok).toBe(true);
+
+ const reader = wrapped.body.getReader();
+ const decoder = new TextDecoder();
+ let buf = "";
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buf += decoder.decode(value, { stream: true });
+ }
+ buf += decoder.decode();
+
+ expect(buf).toContain(`data: ${inner}`);
+ expect(buf).toContain("data: [DONE]");
+ });
+});
diff --git a/tests/unit/qoder.test.js b/tests/unit/qoder.test.js
index fc5f20a1..d8fce1ae 100644
--- a/tests/unit/qoder.test.js
+++ b/tests/unit/qoder.test.js
@@ -401,7 +401,7 @@ describe("wrapQoderSSE", () => {
it("forwards an OpenAI envelope chunk and emits [DONE] in flush", async () => {
const inner = JSON.stringify({ choices: [{ delta: { content: "hi" } }] });
const upstream = `data: ${JSON.stringify({ statusCodeValue: 200, body: inner })}\n\n`;
- const wrapped = wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
+ const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
const out = await drain(wrapped);
expect(out).toContain(`data: ${inner}\n\n`);
expect(out).toContain("data: [DONE]\n\n");
@@ -413,7 +413,7 @@ describe("wrapQoderSSE", () => {
const inner = JSON.stringify({ choices: [{ delta: { content: "tail" } }], finish_reason: "stop" });
// Note: NO trailing \n on the final line.
const upstream = `data: ${JSON.stringify({ statusCodeValue: 200, body: inner })}`;
- const wrapped = wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
+ const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
const out = await drain(wrapped);
expect(out).toContain(`data: ${inner}\n\n`);
});
@@ -426,7 +426,7 @@ describe("wrapQoderSSE", () => {
const errorEnv = JSON.stringify({ statusCodeValue: 500, body: "boom" });
const validInner = JSON.stringify({ choices: [{ delta: { content: "leak" } }] });
const validEnv = JSON.stringify({ statusCodeValue: 200, body: validInner });
- const wrapped = wrapQoderSSE(
+ const wrapped = await wrapQoderSSE(
makeResponse([`data: ${errorEnv}\n\ndata: ${validEnv}\n\n`]),
"qoder/auto",
);
@@ -443,7 +443,7 @@ describe("wrapQoderSSE", () => {
it("strips embedded newlines from inner body before forwarding", async () => {
const innerWithNewlines = '{"choices":[{"delta":{"content":"a\nb"}}]}';
const env = JSON.stringify({ statusCodeValue: 200, body: innerWithNewlines });
- const wrapped = wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/auto");
+ const wrapped = await wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/auto");
const out = await drain(wrapped);
// The forwarded data: line should be a single event terminated by \n\n
// and contain no internal \n other than the trailing pair.
@@ -455,15 +455,15 @@ describe("wrapQoderSSE", () => {
it("upstream error envelope produces an error chunk + [DONE]", async () => {
const env = JSON.stringify({ statusCodeValue: 503, body: "service unavailable" });
- const wrapped = wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/lite");
+ const wrapped = await wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/lite");
const out = await drain(wrapped);
expect(out).toContain("[qoder error 503");
expect(out).toContain("data: [DONE]\n\n");
});
- it("non-ok responses are returned unchanged (no transform)", () => {
+ it("non-ok responses are returned unchanged (no transform)", async () => {
const r = new Response("not ok", { status: 500 });
- const wrapped = wrapQoderSSE(r, "qoder/auto");
+ const wrapped = await wrapQoderSSE(r, "qoder/auto");
expect(wrapped).toBe(r);
});
});
diff --git a/tests/unit/request-details-redaction.test.js b/tests/unit/request-details-redaction.test.js
new file mode 100644
index 00000000..23850be6
--- /dev/null
+++ b/tests/unit/request-details-redaction.test.js
@@ -0,0 +1,54 @@
+import { describe, it, expect } from "vitest";
+
+// Mirror the redaction logic from src/app/api/usage/request-details/route.js
+// so we can test it in isolation.
+function redactDetails(details) {
+ return (details || []).map((d) => {
+ const redacted = { ...d };
+ for (const key of ["request", "providerRequest", "providerResponse", "response"]) {
+ if (redacted[key] !== undefined) {
+ redacted[key] = { redacted: true };
+ }
+ }
+ return redacted;
+ });
+}
+
+describe("request-details redaction", () => {
+ it("removes conversation payloads but keeps metadata", () => {
+ const details = [{
+ id: "abc",
+ provider: "opencode",
+ model: "deepseek-v4-flash-free",
+ timestamp: "2026-08-05T00:00:00Z",
+ status: "success",
+ tokens: { prompt_tokens: 10, completion_tokens: 5 },
+ request: { messages: [{ role: "user", content: "secret prompt" }] },
+ providerRequest: { messages: [{ role: "user", content: "secret prompt" }] },
+ providerResponse: { choices: [{ message: { content: "secret answer" } }] },
+ response: { content: "secret answer" },
+ }];
+ const out = redactDetails(details)[0];
+ expect(out.id).toBe("abc");
+ expect(out.provider).toBe("opencode");
+ expect(out.model).toBe("deepseek-v4-flash-free");
+ expect(out.tokens).toEqual({ prompt_tokens: 10, completion_tokens: 5 });
+ expect(out.request).toEqual({ redacted: true });
+ expect(out.providerRequest).toEqual({ redacted: true });
+ expect(out.providerResponse).toEqual({ redacted: true });
+ expect(out.response).toEqual({ redacted: true });
+ });
+
+ it("handles empty details", () => {
+ expect(redactDetails([])).toEqual([]);
+ expect(redactDetails(null)).toEqual([]);
+ });
+
+ it("keeps non-sensitive fields untouched", () => {
+ const details = [{ id: "x", status: "error", latency: { total: 100 } }];
+ const out = redactDetails(details)[0];
+ expect(out.id).toBe("x");
+ expect(out.status).toBe("error");
+ expect(out.latency).toEqual({ total: 100 });
+ });
+});
diff --git a/tests/unit/responses-prompt-cache-key-3216.test.js b/tests/unit/responses-prompt-cache-key-3216.test.js
new file mode 100644
index 00000000..2306ff38
--- /dev/null
+++ b/tests/unit/responses-prompt-cache-key-3216.test.js
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+
+const { openaiToOpenAIResponsesRequest, openaiResponsesToOpenAIRequest } =
+ await import("../../open-sse/translator/request/openai-responses.js");
+
+const CHAT_BODY = (extra = {}) => ({
+ model: "example-model",
+ messages: [{ role: "user", content: "hello" }],
+ ...extra,
+});
+
+describe("#3216 prompt_cache_key across the chat/responses translation", () => {
+ it("preserves an explicit key when converting chat → responses", () => {
+ const out = openaiToOpenAIResponsesRequest(
+ "example-model",
+ CHAT_BODY({ prompt_cache_key: "stable-cache-key" }),
+ true,
+ {},
+ );
+
+ expect(out.prompt_cache_key).toBe("stable-cache-key");
+ });
+
+ it("does not invent a key when the client sent none", () => {
+ const out = openaiToOpenAIResponsesRequest("example-model", CHAT_BODY(), true, {});
+
+ expect(out.prompt_cache_key).toBeUndefined();
+ });
+
+ it("still drops the key on the responses → chat direction", () => {
+ const out = openaiResponsesToOpenAIRequest(
+ "example-model",
+ {
+ model: "example-model",
+ input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
+ prompt_cache_key: "stable-cache-key",
+ },
+ true,
+ {},
+ );
+
+ expect(out.prompt_cache_key).toBeUndefined();
+ });
+});
diff --git a/tests/unit/saml.test.js b/tests/unit/saml.test.js
new file mode 100644
index 00000000..8cca779e
--- /dev/null
+++ b/tests/unit/saml.test.js
@@ -0,0 +1,144 @@
+import { describe, it, expect } from "vitest";
+import {
+ formatX509Certificate,
+ isSamlConfigured,
+ generateSamlMetadata,
+ pickSamlEmail,
+ pickSamlDisplayName,
+ validateSamlResponse,
+} from "../../src/lib/auth/saml.js";
+import { mergeWithDefaults } from "../../src/lib/db/repos/settingsRepo.js";
+
+describe("SAML 2.0 Auth Engine Utilities", () => {
+ describe("formatX509Certificate", () => {
+ it("formats raw Base64 string into standard 64-column PEM block", () => {
+ const rawBase64 = "MIIC1234567890123456789012345678901234567890123456789012345678901234567890";
+ const formatted = formatX509Certificate(rawBase64);
+ expect(formatted).toContain("-----BEGIN CERTIFICATE-----");
+ expect(formatted).toContain("-----END CERTIFICATE-----");
+ expect(formatted).toContain("MIIC123456789012345678901234567890123456789012345678901234567890");
+ expect(formatted).toContain("\n1234567890\n");
+ });
+
+ it("cleans existing PEM header/footer and extra whitespace", () => {
+ const rawPem = `
+ -----BEGIN CERTIFICATE-----
+ MIIC123456789012345678901234567890123456789012345678901234567890
+ 1234567890
+ -----END CERTIFICATE-----
+ `;
+ const formatted = formatX509Certificate(rawPem);
+ expect(formatted).toContain("-----BEGIN CERTIFICATE-----");
+ expect(formatted.match(/BEGIN CERTIFICATE/g)?.length).toBe(1);
+ });
+
+ it("returns empty string for null, undefined, or invalid inputs", () => {
+ expect(formatX509Certificate(null)).toBe("");
+ expect(formatX509Certificate(undefined)).toBe("");
+ expect(formatX509Certificate(" ")).toBe("");
+ });
+ });
+
+ describe("isSamlConfigured", () => {
+ it("returns true when entryPoint and cert are non-empty", () => {
+ expect(
+ isSamlConfigured({
+ samlEntryPoint: "https://idp.example.com/sso",
+ samlCert: "dummy-cert",
+ })
+ ).toBe(true);
+ });
+
+ it("returns false if entryPoint or cert is missing", () => {
+ expect(isSamlConfigured({ samlEntryPoint: "https://idp.example.com/sso" })).toBe(false);
+ expect(isSamlConfigured({ samlCert: "dummy-cert" })).toBe(false);
+ expect(isSamlConfigured({})).toBe(false);
+ });
+ });
+
+ describe("generateSamlMetadata", () => {
+ it("generates valid SP XML metadata with Entity ID and ACS binding", () => {
+ const settings = {
+ samlEntryPoint: "https://idp.example.com/sso",
+ samlIssuer: "urn:9router:sp",
+ samlCert: "MIIC123456789012345678901234567890123456789012345678901234567890",
+ };
+ const xml = generateSamlMetadata("https://localhost:20127", settings);
+ expect(xml).toContain('entityID="urn:9router:sp"');
+ expect(xml).toContain('Location="https://localhost:20127/api/auth/saml/acs"');
+ expect(xml).toContain('WantAssertionsSigned="true"');
+ });
+ });
+
+ describe("InResponseTo Replay Validation", () => {
+ it("throws error when expectedRequestId is supplied but InResponseTo is missing", async () => {
+ const settings = { samlCert: "dummy-cert" };
+ const rawXml = Buffer.from('').toString("base64");
+ await expect(
+ validateSamlResponse(null, { SAMLResponse: rawXml }, "req-123", settings)
+ ).rejects.toThrow(/InResponseTo mismatch/);
+ });
+
+ it("throws error when expectedRequestId is supplied but InResponseTo does not match", async () => {
+ const settings = { samlCert: "dummy-cert" };
+ const rawXml = Buffer.from('').toString("base64");
+ await expect(
+ validateSamlResponse(null, { SAMLResponse: rawXml }, "req-123", settings)
+ ).rejects.toThrow(/InResponseTo mismatch/);
+ });
+
+ it("throws error if samlCert is not configured", async () => {
+ const rawXml = Buffer.from('').toString("base64");
+ await expect(
+ validateSamlResponse(null, { SAMLResponse: rawXml }, "req-123", {})
+ ).rejects.toThrow(/Certificate/);
+ });
+ });
+
+ describe("Claims Extraction", () => {
+ const mockProfile = {
+ email: "user@example.com",
+ displayName: "Jane Doe",
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": ["custom@example.com"],
+ customEmail: "custom-email@example.com",
+ customName: "Custom User",
+ };
+
+ it("pickSamlEmail extracts custom attribute or common claims", () => {
+ expect(pickSamlEmail(mockProfile, {})).toBe("user@example.com");
+ expect(
+ pickSamlEmail(mockProfile, { samlAttributeEmail: "customEmail" })
+ ).toBe("custom-email@example.com");
+ expect(
+ pickSamlEmail(
+ { "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": ["custom@example.com"] },
+ {}
+ )
+ ).toBe("custom@example.com");
+ });
+
+ it("pickSamlDisplayName extracts custom attribute, common names, or falls back to email", () => {
+ expect(pickSamlDisplayName(mockProfile, {})).toBe("Jane Doe");
+ expect(
+ pickSamlDisplayName(mockProfile, { samlAttributeName: "customName" })
+ ).toBe("Custom User");
+ expect(
+ pickSamlDisplayName({ email: "user@example.com" }, {})
+ ).toBe("user@example.com");
+ expect(
+ pickSamlDisplayName({ givenName: "Alice", surname: "Smith" }, {})
+ ).toBe("Alice Smith");
+ });
+ });
+
+ describe("Settings Repository Defaults", () => {
+ it("mergeWithDefaults safely populates SAML defaults for existing installations", () => {
+ const merged = mergeWithDefaults({ authMode: "password" });
+ expect(merged.ssoType).toBe("oidc");
+ expect(merged.samlIssuer).toBe("urn:9router:sp");
+ expect(merged.samlLoginLabel).toBe("Sign in with SAML SSO");
+ expect(merged.samlAttributeEmail).toBe("email");
+ expect(merged.samlAttributeName).toBe("name");
+ });
+ });
+});
diff --git a/tests/unit/search-ssrf-guard.test.js b/tests/unit/search-ssrf-guard.test.js
new file mode 100644
index 00000000..2ed6e553
--- /dev/null
+++ b/tests/unit/search-ssrf-guard.test.js
@@ -0,0 +1,49 @@
+import { describe, it, expect } from "vitest";
+import { resolveBaseUrl } from "../../open-sse/handlers/search/callers.js";
+
+const CONFIG = { id: "searxng", baseUrl: "https://searxng.example.com" };
+
+describe("resolveBaseUrl SSRF guard", () => {
+ it("uses provider default when no override", () => {
+ expect(resolveBaseUrl(CONFIG, {})).toBe("https://searxng.example.com");
+ });
+
+ it("allows public https override", () => {
+ const params = { providerOptions: { baseUrl: "https://my-searxng.example.com" } };
+ expect(resolveBaseUrl(CONFIG, params)).toBe("https://my-searxng.example.com");
+ });
+
+ it("allows public http override", () => {
+ const params = { providerOptions: { baseUrl: "http://searxng.example.net" } };
+ expect(resolveBaseUrl(CONFIG, params)).toBe("http://searxng.example.net");
+ });
+
+ it("rejects loopback override", () => {
+ const params = { providerOptions: { baseUrl: "http://127.0.0.1:18999" } };
+ expect(() => resolveBaseUrl(CONFIG, params)).toThrow();
+ });
+
+ it("rejects private IP override", () => {
+ for (const ip of ["10.0.0.1", "192.168.1.1", "172.16.0.1"]) {
+ const params = { providerOptions: { baseUrl: `http://${ip}` } };
+ expect(() => resolveBaseUrl(CONFIG, params), `should reject ${ip}`).toThrow();
+ }
+ });
+
+ it("rejects localhost hostname override", () => {
+ const params = { providerOptions: { baseUrl: "http://localhost:8080" } };
+ expect(() => resolveBaseUrl(CONFIG, params)).toThrow();
+ });
+
+ it("rejects cloud metadata override", () => {
+ const params = { providerOptions: { baseUrl: "http://169.254.169.254/latest/meta-data" } };
+ expect(() => resolveBaseUrl(CONFIG, params)).toThrow();
+ });
+
+ it("rejects non-http protocols", () => {
+ for (const proto of ["file:///etc/passwd", "gopher://127.0.0.1:70", "ftp://10.0.0.1"]) {
+ const params = { providerOptions: { baseUrl: proto } };
+ expect(() => resolveBaseUrl(CONFIG, params), `should reject ${proto}`).toThrow();
+ }
+ });
+});
diff --git a/tests/unit/standalone-assets.test.js b/tests/unit/standalone-assets.test.js
index 94951325..61a00820 100644
--- a/tests/unit/standalone-assets.test.js
+++ b/tests/unit/standalone-assets.test.js
@@ -37,6 +37,17 @@ describe("standalone build assets", () => {
.toBe("static asset");
});
+ // Without the wrapper beside server.js nothing can prove a request is local.
+ it("copies the request-sanitizing server wrapper into the standalone output", () => {
+ const projectRoot = createBuildFixture(".next");
+ writeFileSync(join(projectRoot, "custom-server.js"), "wrapper");
+
+ copyStandaloneAssets({ projectRoot, distDir: ".next" });
+
+ expect(readFileSync(join(projectRoot, ".next", "standalone", "custom-server.js"), "utf8"))
+ .toBe("wrapper");
+ });
+
it("does not modify workspace-traced CLI builds", () => {
const projectRoot = createBuildFixture(".next-cli-build");
const previousMode = process.env.NEXT_TRACING_ROOT_MODE;