Merge remote-tracking branch 'origin/master' into gitea/feature/end
Resolved conflicts taking origin/master (v0.5.55) as canonical, with local features re-applied: - runtime log level (LOG_LEVEL env + dashboard Settings → Logging, applied immediately and persisted across restarts) - free/noAuth provider enable/disable toggle via providerStrategies.enabled - parallel model testing (Test All Models / Test Selected Keys)
This commit is contained in:
@@ -4,25 +4,26 @@ Unit tests for the `/v1/embeddings` endpoint implementation.
|
||||
|
||||
## Setup
|
||||
|
||||
Vitest must be installed globally or in `/tmp/node_modules` (due to npm workspace hoisting from the root Next.js project):
|
||||
Install test dependencies from the `tests/` directory:
|
||||
|
||||
```bash
|
||||
cd /tmp && npm install vitest
|
||||
cd tests/ && npm install
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd tests/
|
||||
NODE_PATH=/tmp/node_modules /tmp/node_modules/.bin/vitest run --reporter=verbose --config ./vitest.config.js
|
||||
```
|
||||
|
||||
Or using the package script (from the `tests/` directory):
|
||||
From the `tests/` directory:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Or run vitest directly with npx:
|
||||
|
||||
```bash
|
||||
npx vitest run --reporter=verbose --config ./vitest.config.js
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
| File | What it tests |
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"cc": "claude",
|
||||
"cx": "codex",
|
||||
"gc": "gemini-cli",
|
||||
"qw": "qwen",
|
||||
"qw": "qw",
|
||||
"if": "iflow",
|
||||
"ag": "antigravity",
|
||||
"gh": "github",
|
||||
"kr": "kiro",
|
||||
"cu": "cursor",
|
||||
"kc": "kilocode",
|
||||
"kmc": "kimi-coding",
|
||||
"kmc": "kimi",
|
||||
"cl": "cline",
|
||||
"oc": "opencode",
|
||||
"ocg": "opencode-go",
|
||||
@@ -66,6 +66,10 @@
|
||||
"vertex-partner": "vertex-partner",
|
||||
"gw": "grok-web",
|
||||
"grok-web": "grok-web",
|
||||
"gcli": "grok-cli",
|
||||
"gb": "grok-cli",
|
||||
"grok-build": "grok-cli",
|
||||
"grok-cli": "grok-cli",
|
||||
"pw": "perplexity-web",
|
||||
"perplexity-web": "perplexity-web",
|
||||
"mimo": "xiaomi-mimo",
|
||||
@@ -89,29 +93,61 @@
|
||||
"polly": "aws-polly",
|
||||
"aws-polly": "aws-polly",
|
||||
"bb": "blackbox",
|
||||
"blackbox": "blackbox"
|
||||
"blackbox": "blackbox",
|
||||
"af": "api-airforce",
|
||||
"airforce": "api-airforce",
|
||||
"api-airforce": "api-airforce",
|
||||
"llm7": "llm7",
|
||||
"llm-7": "llm7",
|
||||
"samba": "sambanova",
|
||||
"sambanova": "sambanova",
|
||||
"bm": "bluesminds",
|
||||
"bluesminds": "bluesminds",
|
||||
"bzl": "bazaarlink",
|
||||
"bazaarlink": "bazaarlink",
|
||||
"kgw": "kilo-gateway",
|
||||
"kilo-gateway": "kilo-gateway",
|
||||
"hunyuan": "tencent",
|
||||
"tencent": "tencent",
|
||||
"qianfan": "baidu",
|
||||
"baidu": "baidu",
|
||||
"ernie": "baidu",
|
||||
"dv": "dv",
|
||||
"devin": "devin",
|
||||
"devin-cli": "devin-cli",
|
||||
"morph": "morph",
|
||||
"morphllm": "morph"
|
||||
},
|
||||
"idToAlias": {
|
||||
"alicode": "alicode",
|
||||
"alicode-intl": "alicode-intl",
|
||||
"alims-intl": "alims-intl",
|
||||
"alitp-intl": "alitp-intl",
|
||||
"anthropic": "anthropic",
|
||||
"antigravity": "ag",
|
||||
"api-airforce": "af",
|
||||
"assemblyai": "assemblyai",
|
||||
"azure": "azure",
|
||||
"baidu": "qianfan",
|
||||
"bazaarlink": "bzl",
|
||||
"blackbox": "blackbox",
|
||||
"bluesminds": "bm",
|
||||
"byteplus": "byteplus",
|
||||
"cerebras": "cerebras",
|
||||
"chutes": "chutes",
|
||||
"claude": "cc",
|
||||
"cline": "cl",
|
||||
"clinepass": "clinepass",
|
||||
"cloudflare-ai": "cloudflare-ai",
|
||||
"codebuddy-cn": "cbcn",
|
||||
"codebuddy-intl": "cbai",
|
||||
"codex": "cx",
|
||||
"cohere": "cohere",
|
||||
"commandcode": "commandcode",
|
||||
"cursor": "cu",
|
||||
"deepgram": "deepgram",
|
||||
"deepseek": "deepseek",
|
||||
"featherless": "featherless",
|
||||
"fireworks": "fireworks",
|
||||
"gemini": "gemini",
|
||||
"gemini-cli": "gc",
|
||||
@@ -119,19 +155,23 @@
|
||||
"gitlab": "gitlab",
|
||||
"glm": "glm",
|
||||
"glm-cn": "glm-cn",
|
||||
"grok-cli": "gcli",
|
||||
"grok-web": "grok-web",
|
||||
"groq": "groq",
|
||||
"hyperbolic": "hyperbolic",
|
||||
"iflow": "if",
|
||||
"kilo-gateway": "kgw",
|
||||
"kilocode": "kc",
|
||||
"kimchi": "kimchi",
|
||||
"kimi": "kimi",
|
||||
"kimi-coding": "kmc",
|
||||
"kiro": "kr",
|
||||
"llm7": "llm7",
|
||||
"mimo-free": "mmf",
|
||||
"minimax": "minimax",
|
||||
"minimax-cn": "minimax-cn",
|
||||
"mistral": "mistral",
|
||||
"mmf": "mmf",
|
||||
"morph": "morph",
|
||||
"nanobanana": "nanobanana",
|
||||
"nebius": "nebius",
|
||||
"nvidia": "nvidia",
|
||||
@@ -142,32 +182,45 @@
|
||||
"opencode-go": "opencode-go",
|
||||
"openrouter": "openrouter",
|
||||
"perplexity": "perplexity",
|
||||
"perplexity-agent": "perplexity-agent",
|
||||
"perplexity-web": "perplexity-web",
|
||||
"poolside": "poolside",
|
||||
"qoder": "qd",
|
||||
"qwen": "qw",
|
||||
"sambanova": "samba",
|
||||
"siliconflow": "siliconflow",
|
||||
"tencent": "hunyuan",
|
||||
"together": "together",
|
||||
"tokenrouter": "tokenrouter",
|
||||
"venice": "venice",
|
||||
"vercel-ai-gateway": "vercel-ai-gateway",
|
||||
"vertex": "vertex",
|
||||
"vertex-partner": "vertex-partner",
|
||||
"volcengine-ark": "volcengine-ark",
|
||||
"xai": "xai",
|
||||
"xiaomi-mimo": "xiaomi-mimo",
|
||||
"xiaomi-tokenplan": "xiaomi-tokenplan"
|
||||
"xiaomi-tokenplan": "xiaomi-tokenplan",
|
||||
"zed": "zd"
|
||||
},
|
||||
"modelKeys": [
|
||||
"af",
|
||||
"ag",
|
||||
"alicode",
|
||||
"alicode-intl",
|
||||
"alims-intl",
|
||||
"alitp-intl",
|
||||
"anthropic",
|
||||
"assemblyai",
|
||||
"black-forest-labs",
|
||||
"blackbox",
|
||||
"bm",
|
||||
"byteplus",
|
||||
"bzl",
|
||||
"cbai",
|
||||
"cbcn",
|
||||
"cc",
|
||||
"cerebras",
|
||||
"cl",
|
||||
"clinepass",
|
||||
"cloudflare-ai",
|
||||
"cohere",
|
||||
"comfyui",
|
||||
@@ -179,8 +232,10 @@
|
||||
"edge-tts",
|
||||
"elevenlabs-tts-models",
|
||||
"fal-ai",
|
||||
"featherless",
|
||||
"fireworks",
|
||||
"gc",
|
||||
"gcli",
|
||||
"gemini",
|
||||
"gemini-tts-models",
|
||||
"gemini-tts-voices",
|
||||
@@ -191,17 +246,21 @@
|
||||
"grok-web",
|
||||
"groq",
|
||||
"huggingface",
|
||||
"hunyuan",
|
||||
"hyperbolic",
|
||||
"if",
|
||||
"kc",
|
||||
"kgw",
|
||||
"kimchi",
|
||||
"kimi",
|
||||
"kmc",
|
||||
"kr",
|
||||
"llm7",
|
||||
"local-device",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"mistral",
|
||||
"mmf",
|
||||
"morph",
|
||||
"nanobanana",
|
||||
"nebius",
|
||||
"nvidia",
|
||||
@@ -215,21 +274,31 @@
|
||||
"openrouter-tts-models",
|
||||
"openrouter-tts-voices",
|
||||
"perplexity",
|
||||
"perplexity-agent",
|
||||
"perplexity-web",
|
||||
"poolside",
|
||||
"qd",
|
||||
"qw",
|
||||
"qianfan",
|
||||
"recraft",
|
||||
"runwayml",
|
||||
"samba",
|
||||
"sdwebui",
|
||||
"selfhosted-embedding",
|
||||
"selfhosted-stt",
|
||||
"selfhosted-tts",
|
||||
"siliconflow",
|
||||
"stability-ai",
|
||||
"together",
|
||||
"tokenrouter",
|
||||
"venice",
|
||||
"vertex",
|
||||
"vertex-partner",
|
||||
"volcengine-ark",
|
||||
"voyage-ai",
|
||||
"xai",
|
||||
"xiaomi-mimo",
|
||||
"xiaomi-tokenplan"
|
||||
"xiaomi-mimo-tts-models",
|
||||
"xiaomi-tokenplan",
|
||||
"zd"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
tests/unit/antigravity-mitm.test.js :: Antigravity MITM model handling flags the out-of-box agent/Default model mandatory
|
||||
tests/unit/claude-header-forwarding.test.js :: proxyAwareFetch — api.anthropic.com routing routes api.anthropic.com to gotScraping (non-streaming) and returns ok response
|
||||
tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import extracts tokens using exact keys
|
||||
tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import falls back to fuzzy key matching on macOS when exact keys are missing
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"token": "https://api.anthropic.com/v1/oauth/token",
|
||||
"auth": "https://api.anthropic.com/v1/oauth/authorize"
|
||||
},
|
||||
"qwen": {
|
||||
"token": "https://qwen.ai/api/v1/oauth2/token",
|
||||
"auth": "https://qwen.ai/api/v1/oauth2/device/code"
|
||||
},
|
||||
"iflow": {
|
||||
"token": "https://iflow.cn/oauth/token",
|
||||
"auth": "https://iflow.cn/oauth"
|
||||
@@ -29,28 +25,27 @@
|
||||
"tokenUrls": {
|
||||
"claude": "https://api.anthropic.com/v1/oauth/token",
|
||||
"codex": "https://auth.openai.com/oauth/token",
|
||||
"qwen": "https://chat.qwen.ai/api/v1/oauth2/token",
|
||||
"iflow": "https://iflow.cn/oauth/token",
|
||||
"kiro": "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
|
||||
"xai": "https://auth.x.ai/oauth2/token",
|
||||
"grok-cli": "https://auth.x.ai/oauth2/token",
|
||||
"cline": "https://api.cline.bot/api/v1/auth/token",
|
||||
"kimi-coding": "https://auth.kimi.com/api/oauth/token"
|
||||
"kimi": "https://auth.kimi.com/api/oauth/token"
|
||||
},
|
||||
"authUrls": {
|
||||
"qwen": "https://chat.qwen.ai/api/v1/oauth2/device/code",
|
||||
"iflow": "https://iflow.cn/oauth",
|
||||
"kiro": "https://prod.us-east-1.auth.desktop.kiro.dev"
|
||||
},
|
||||
"refreshUrls": {
|
||||
"cline": "https://api.cline.bot/api/v1/auth/refresh",
|
||||
"kimi-coding": "https://auth.kimi.com/api/oauth/token",
|
||||
"xai": "https://auth.x.ai/oauth2/token"
|
||||
"kimi": "https://auth.kimi.com/api/oauth/token",
|
||||
"xai": "https://auth.x.ai/oauth2/token",
|
||||
"grok-cli": "https://auth.x.ai/oauth2/token"
|
||||
},
|
||||
"clientIds": {
|
||||
"claude": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
"codex": "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
"qwen": "f0304373b74a44d2b584a3fb70ca9e56",
|
||||
"iflow": "10009311001",
|
||||
"kimi-coding": "17e5f671-d194-4dfb-9706-5516cb48c098"
|
||||
"kimi": "17e5f671-d194-4dfb-9706-5516cb48c098",
|
||||
"grok-cli": "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,9 +16,13 @@ const ALIAS_TOKENS = [
|
||||
"mistral","pplx","perplexity","together","fireworks","cerebras","cohere","nvidia","nebius",
|
||||
"siliconflow","hyp","hyperbolic","dg","deepgram","aai","assemblyai","nb","nanobanana","ch",
|
||||
"chutes","ark","volcengine-ark","byteplus","bpm","cursor","vx","vertex","vxp","vertex-partner",
|
||||
"gw","grok-web","pw","perplexity-web","mimo","xiaomi-mimo","xmtp","xiaomi-tokenplan","cf",
|
||||
"gw","grok-web","gcli","gb","grok-build","grok-cli","pw","perplexity-web","mimo","xiaomi-mimo",
|
||||
"xmtp","xiaomi-tokenplan","cf",
|
||||
"cloudflare-ai","fal","fal-ai","stability","stability-ai","bfl","black-forest-labs","recraft",
|
||||
"topaz","runway","runwayml","jina","jina-ai","polly","aws-polly","bb","blackbox",
|
||||
"af","airforce","api-airforce","llm7","llm-7","samba","sambanova","bm","bluesminds",
|
||||
"bzl","bazaarlink","kgw","kilo-gateway","hunyuan","tencent","qianfan","baidu","ernie",
|
||||
"dv","devin","devin-cli","morph","morphllm",
|
||||
];
|
||||
|
||||
// Sort idToAlias by key — runtime accesses by key, order is irrelevant (content-based)
|
||||
|
||||
@@ -15,29 +15,30 @@ const resolved = {
|
||||
tokenUrls: {
|
||||
claude: PROVIDERS.claude?.tokenUrl,
|
||||
codex: PROVIDERS.codex?.tokenUrl,
|
||||
qwen: PROVIDERS.qwen?.tokenUrl,
|
||||
iflow: PROVIDERS.iflow?.tokenUrl,
|
||||
kiro: PROVIDERS.kiro?.tokenUrl,
|
||||
xai: PROVIDERS.xai?.tokenUrl,
|
||||
// Grok CLI injects oauth.tokenUrl onto PROVIDERS via OAUTH_INJECT_FIELDS
|
||||
"grok-cli": PROVIDERS["grok-cli"]?.tokenUrl,
|
||||
cline: PROVIDERS.cline?.tokenUrl,
|
||||
"kimi-coding": PROVIDERS["kimi-coding"]?.tokenUrl,
|
||||
kimi: PROVIDERS.kimi?.tokenUrl,
|
||||
},
|
||||
authUrls: {
|
||||
qwen: PROVIDERS.qwen?.authUrl,
|
||||
iflow: PROVIDERS.iflow?.authUrl,
|
||||
kiro: PROVIDERS.kiro?.authUrl,
|
||||
},
|
||||
refreshUrls: {
|
||||
cline: PROVIDERS.cline?.refreshUrl,
|
||||
"kimi-coding": PROVIDERS["kimi-coding"]?.refreshUrl,
|
||||
kimi: PROVIDERS.kimi?.refreshUrl,
|
||||
xai: PROVIDERS.xai?.refreshUrl,
|
||||
"grok-cli": PROVIDERS["grok-cli"]?.tokenUrl,
|
||||
},
|
||||
clientIds: {
|
||||
claude: PROVIDERS.claude?.clientId,
|
||||
codex: PROVIDERS.codex?.clientId,
|
||||
qwen: PROVIDERS.qwen?.clientId,
|
||||
iflow: PROVIDERS.iflow?.clientId,
|
||||
"kimi-coding": PROVIDERS["kimi-coding"]?.clientId,
|
||||
kimi: PROVIDERS.kimi?.clientId,
|
||||
"grok-cli": PROVIDERS["grok-cli"]?.clientId,
|
||||
},
|
||||
};
|
||||
const current = JSON.parse(JSON.stringify(resolved));
|
||||
|
||||
40
tests/auth/saml.test.js
Normal file
40
tests/auth/saml.test.js
Normal file
@@ -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");
|
||||
});
|
||||
@@ -5,8 +5,8 @@
|
||||
"type": "module",
|
||||
"description": "Unit tests for 9router embeddings endpoint",
|
||||
"scripts": {
|
||||
"test": "NODE_PATH=/tmp/node_modules /tmp/node_modules/.bin/vitest run --reporter=verbose",
|
||||
"test:watch": "NODE_PATH=/tmp/node_modules /tmp/node_modules/.bin/vitest --reporter=verbose"
|
||||
"test": "vitest run --reporter=verbose",
|
||||
"test:watch": "vitest --reporter=verbose"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.0"
|
||||
|
||||
@@ -118,6 +118,9 @@ exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → adaptive outpu
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"thinking": {
|
||||
"type": "adaptive",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://endpoint-proxy.local",
|
||||
"X-Title": "Endpoint Proxy",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://endpoint-proxy.local",
|
||||
"X-Title": "Endpoint Proxy",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > bazaarlink → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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 <TOK>",
|
||||
"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 <TOK>",
|
||||
"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 <TOK>",
|
||||
"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 <TOK>",
|
||||
"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 <TOK>",
|
||||
"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 <TOK>",
|
||||
"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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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 <TOK>",
|
||||
"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 <TOK>",
|
||||
"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 <TOK>",
|
||||
"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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "kimchi/0.1.50",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "kimchi/0.1.50",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > poolside → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > sambanova → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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 <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = `
|
||||
{
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"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": "<CRED>",
|
||||
"Content-Type": "application/json",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "<CRED>",
|
||||
"Content-Type": "application/json",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "<CRED>",
|
||||
"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",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -4,6 +4,8 @@ import "./registerAll.js";
|
||||
import { translateRequest, translateResponse, initState } from "../../open-sse/translator/index.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
|
||||
import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.js";
|
||||
import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../open-sse/config/appConstants.js";
|
||||
|
||||
const AG2O = (req) =>
|
||||
translateRequest(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, "m", { request: req }, true, null, null);
|
||||
@@ -106,4 +108,32 @@ describe("Antigravity executor", () => {
|
||||
const query = out.request.tools[0].functionDeclarations[0].parameters.properties.query;
|
||||
expect(query).toEqual({ type: "string", description: "Search query" });
|
||||
});
|
||||
|
||||
it("does not inject the legacy Antigravity default system prompt for Gemini-backed models", () => {
|
||||
const out = openaiToAntigravityRequest("gemini-3.5-flash-low", {
|
||||
messages: [
|
||||
{ role: "system", content: "USER_SYSTEM_PROMPT" },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
}, true, { projectId: "project-1", connectionId: "conn-1" });
|
||||
|
||||
const system = JSON.stringify(out.request.systemInstruction);
|
||||
expect(system).toContain("USER_SYSTEM_PROMPT");
|
||||
expect(system).not.toContain(ANTIGRAVITY_DEFAULT_SYSTEM);
|
||||
expect(system).not.toContain("Please ignore the following [ignore]");
|
||||
});
|
||||
|
||||
it("does not inject the legacy Antigravity default system prompt for Claude-backed models", () => {
|
||||
const out = openaiToAntigravityRequest("claude-opus-4-6-thinking", {
|
||||
messages: [
|
||||
{ role: "system", content: "USER_SYSTEM_PROMPT" },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
}, true, { projectId: "project-1", connectionId: "conn-1" });
|
||||
|
||||
const system = JSON.stringify(out.request.systemInstruction);
|
||||
expect(system).toContain("USER_SYSTEM_PROMPT");
|
||||
expect(system).not.toContain(ANTIGRAVITY_DEFAULT_SYSTEM);
|
||||
expect(system).not.toContain("Please ignore the following [ignore]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,9 +20,7 @@ describe("Codex CLI Responses → OpenAI", () => {
|
||||
expect(asst?.tool_calls?.length ?? 0, "empty tool_calls[] produced").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// openai-responses.js:109-110 — arguments passed through without ensuring string type
|
||||
// KNOWN BUG
|
||||
it.fails("function_call arguments end up as a string", () => {
|
||||
it("function_call arguments end up as a string", () => {
|
||||
const out = R2O({
|
||||
input: [{ type: "function_call", call_id: "c1", name: "f", arguments: { a: 1 } }],
|
||||
});
|
||||
@@ -46,6 +44,20 @@ describe("Codex CLI Responses → OpenAI", () => {
|
||||
});
|
||||
|
||||
describe("OpenAI → Codex Responses (reverse)", () => {
|
||||
it("maps developer messages to Responses API instructions", () => {
|
||||
const out = O2R({
|
||||
messages: [
|
||||
{ role: "developer", content: "Follow the project rules." },
|
||||
{ role: "user", content: "Hello" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(out.instructions).toBe("Follow the project rules.");
|
||||
expect(out.input).toEqual([
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Hello" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
// openai-responses.js:13 — clampCallId NOT applied on Responses→Chat; but here Chat→Responses must clamp
|
||||
it("call_id longer than 64 chars is clamped", () => {
|
||||
const longId = "call_" + "x".repeat(80);
|
||||
|
||||
@@ -5,8 +5,34 @@ import { translateRequest } from "../../open-sse/translator/index.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
const O2K = (body) => translateRequest(FORMATS.OPENAI, FORMATS.KIRO, "m", body, true, null, "kiro");
|
||||
const R2K = (model, body) => translateRequest(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.KIRO,
|
||||
model,
|
||||
body,
|
||||
true,
|
||||
null,
|
||||
"kiro"
|
||||
);
|
||||
|
||||
describe("OpenAI → Kiro", () => {
|
||||
it.each([
|
||||
["high", "gpt-5.6-sol"],
|
||||
["medium", "gpt-5.6-terra"],
|
||||
["low", "gpt-5.6-luna"],
|
||||
])("preserves Responses reasoning.effort %s through the full Kiro route", (effort, model) => {
|
||||
const out = R2K(model, {
|
||||
input: "Use the requested effort",
|
||||
reasoning: { effort },
|
||||
});
|
||||
|
||||
expect(out.additionalModelRequestFields).toEqual({
|
||||
reasoning: { effort },
|
||||
});
|
||||
expect(out.systemPrompt || "").not.toContain("<thinking_mode>");
|
||||
expect(out.systemPrompt || "").not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
// openai-to-kiro.js — safeJSONParse guards bad tool-call JSON (fixed in PR #1582)
|
||||
it("malformed tool arguments do not throw the whole request", () => {
|
||||
expect(() =>
|
||||
|
||||
@@ -67,6 +67,122 @@ describe("OpenAI → Claude context mapping", () => {
|
||||
expect(JSON.stringify(out), "remote image dropped").toContain("pic.png");
|
||||
});
|
||||
|
||||
// claude.js hasValidContent() — a user message whose content is only an
|
||||
// image (no text block) was filtered out by prepareClaudeRequest's
|
||||
// "drop empty messages" pass, so an image-only turn (e.g. a vision
|
||||
// describe request with no accompanying prompt text in the user message)
|
||||
// produced an empty `messages` array and Anthropic rejected the request
|
||||
// with "messages: at least one message is required".
|
||||
it("user message with only an image is not dropped as empty", () => {
|
||||
const out = T({
|
||||
messages: [
|
||||
{ role: "system", content: "Describe the image." },
|
||||
{ role: "user", content: [
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
|
||||
] },
|
||||
],
|
||||
});
|
||||
expect(out.messages.length, "image-only user message was dropped").toBeGreaterThan(0);
|
||||
expect(out.messages[0].content).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: "image" })])
|
||||
);
|
||||
});
|
||||
|
||||
// prepareClaudeRequest reconciles max_tokens vs thinking.budget_tokens.
|
||||
// applyThinking runs after adjustMaxTokens caps max_tokens, so a claude-budget
|
||||
// model at "max" effort (budget 128000) can exceed the clamped max_tokens and
|
||||
// trip Anthropic's "max_tokens > budget_tokens" rule (400). See claude.js.
|
||||
describe("max_tokens vs thinking.budget_tokens reconciliation", () => {
|
||||
// 64k-ceiling model (maxOutput 64000) + max-effort budget 128000: budget alone
|
||||
// exceeds the ceiling → cap max_tokens at 64000 and shrink budget below it.
|
||||
it("max effort budget on a 64k model → budget < max_tokens ≤ 64000", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-opus-4-20250514",
|
||||
max_tokens: 64000,
|
||||
thinking: { type: "enabled", budget_tokens: 128000 },
|
||||
messages: [{ role: "user", content: "q" }],
|
||||
}, "anthropic");
|
||||
expect(out.max_tokens).toBe(64000);
|
||||
expect(out.thinking.budget_tokens).toBeLessThan(out.max_tokens);
|
||||
expect(out.thinking.budget_tokens).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Budget fits under the ceiling but exceeds a small client max_tokens →
|
||||
// raise max_tokens to fit, preserving the requested thinking depth.
|
||||
it("xhigh budget with a low client max_tokens → raise max_tokens, preserve budget", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-opus-4-20250514",
|
||||
max_tokens: 16000,
|
||||
thinking: { type: "enabled", budget_tokens: 32768 },
|
||||
messages: [{ role: "user", content: "q" }],
|
||||
}, "anthropic");
|
||||
expect(out.thinking.budget_tokens).toBe(32768);
|
||||
expect(out.max_tokens).toBe(33792); // 32768 + 1024, under the 64000 ceiling
|
||||
});
|
||||
|
||||
// Budget already below max_tokens → nothing to reconcile.
|
||||
it("high budget under max_tokens → both unchanged", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-opus-4-20250514",
|
||||
max_tokens: 64000,
|
||||
thinking: { type: "enabled", budget_tokens: 24576 },
|
||||
messages: [{ role: "user", content: "q" }],
|
||||
}, "anthropic");
|
||||
expect(out.max_tokens).toBe(64000);
|
||||
expect(out.thinking.budget_tokens).toBe(24576);
|
||||
});
|
||||
|
||||
// Non-budget thinking shapes (adaptive / disabled) carry no budget_tokens →
|
||||
// the reconciliation must never touch them.
|
||||
it("adaptive thinking (no budget_tokens) is left untouched", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-opus-4-20250514",
|
||||
max_tokens: 64000,
|
||||
thinking: { type: "adaptive" },
|
||||
messages: [{ role: "user", content: "q" }],
|
||||
}, "anthropic");
|
||||
expect(out.max_tokens).toBe(64000);
|
||||
expect(out.thinking).toEqual({ type: "adaptive" });
|
||||
});
|
||||
|
||||
// Lifted ceiling: a claude-budget model whose caps declare maxOutput 128000
|
||||
// (e.g. fable) may use the full budget at max effort instead of being pinned
|
||||
// to the conservative 64000 default.
|
||||
it("max effort budget on a 128k model → max_tokens up to 128000, budget preserved just under", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-fable-5",
|
||||
max_tokens: 64000,
|
||||
thinking: { type: "enabled", budget_tokens: 128000 },
|
||||
messages: [{ role: "user", content: "q" }],
|
||||
}, "anthropic");
|
||||
expect(out.max_tokens).toBe(128000);
|
||||
expect(out.thinking.budget_tokens).toBe(126976); // 128000 - 1024
|
||||
expect(out.thinking.budget_tokens).toBeLessThan(out.max_tokens);
|
||||
});
|
||||
|
||||
// Regression: a default 64k-ceiling model still clamps an over-large client
|
||||
// max_tokens down to 64000 (the lift is per-model, not global).
|
||||
it("over-large client max_tokens on a 64k model is still clamped to 64000", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-opus-4-20250514",
|
||||
max_tokens: 120000,
|
||||
messages: [{ role: "user", content: "q" }],
|
||||
}, "anthropic");
|
||||
expect(out.max_tokens).toBe(64000);
|
||||
});
|
||||
|
||||
// Lifted ceiling for a 128k model: a large client max_tokens is now allowed
|
||||
// through instead of being clamped to 64000.
|
||||
it("large client max_tokens on a 128k model is allowed up to maxOutput", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-fable-5",
|
||||
max_tokens: 100000,
|
||||
messages: [{ role: "user", content: "q" }],
|
||||
}, "anthropic");
|
||||
expect(out.max_tokens).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
it("DeepSeek Claude transport adds a thinking placeholder before tool_use in thinking mode", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "deepseek-v4-pro",
|
||||
|
||||
@@ -6,8 +6,8 @@ import "./registerAll.js";
|
||||
import { translateRequest, translateResponse } from "../../open-sse/translator/index.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
const C2K = (body) =>
|
||||
translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", body, true, null, "kiro");
|
||||
const C2K = (body, credentials = null, model = "claude-sonnet-4.5") =>
|
||||
translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, model, body, true, credentials, "kiro");
|
||||
|
||||
describe("Claude → Kiro (direct route)", () => {
|
||||
it("produces a Kiro conversationState payload", () => {
|
||||
@@ -16,6 +16,27 @@ describe("Claude → Kiro (direct route)", () => {
|
||||
expect(out.conversationState.currentMessage.userInputMessage.content).toContain("hello");
|
||||
});
|
||||
|
||||
it("keeps conversationId stable from client session headers and replays frozen msg0", () => {
|
||||
const credentials = {
|
||||
rawHeaders: { "x-session-id": "hermes-session-123-claude-replay" },
|
||||
connectionId: "kiro-account-1",
|
||||
};
|
||||
const first = C2K({ messages: [{ role: "user", content: "first" }] }, credentials);
|
||||
const second = C2K({ messages: [{ role: "user", content: "second" }] }, credentials);
|
||||
|
||||
expect(first.conversationState.conversationId).toBe("hermes-session-123-claude-replay");
|
||||
expect(second.conversationState.conversationId).toBe("hermes-session-123-claude-replay");
|
||||
expect(first.conversationState.agentContinuationId).toBeTruthy();
|
||||
expect(second.conversationState.agentContinuationId).toBe(first.conversationState.agentContinuationId);
|
||||
expect(first.conversationState.agentTaskType).toBe("vibe");
|
||||
expect(second.conversationState.history[0].userInputMessage.content).toBe(
|
||||
first.conversationState.currentMessage.userInputMessage.content
|
||||
);
|
||||
expect(second.conversationState.history[0].userInputMessage.modelId).toBe("claude-sonnet-4.5");
|
||||
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
|
||||
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("second");
|
||||
});
|
||||
|
||||
it("guard 1: with no tools, a dangling tool_result is flattened to text (no structured ref)", () => {
|
||||
// Client omitted `tools` but kept a tool_result after compaction.
|
||||
const out = C2K({
|
||||
@@ -60,20 +81,125 @@ describe("Claude → Kiro (direct route)", () => {
|
||||
null,
|
||||
"kiro"
|
||||
);
|
||||
expect(out.conversationState.currentMessage.userInputMessage.content).toContain(
|
||||
expect(out.systemPrompt).toContain(
|
||||
"<thinking_mode>enabled</thinking_mode>"
|
||||
);
|
||||
expect(out.agentMode).toBe("vibe");
|
||||
});
|
||||
|
||||
it("maps output_config.effort high to Kiro max_thinking_length 24576", () => {
|
||||
it("does not send additionalModelRequestFields for Kiro models without effort support", () => {
|
||||
const out = C2K({
|
||||
output_config: { effort: "high" },
|
||||
messages: [{ role: "user", content: "think with adaptive effort" }],
|
||||
});
|
||||
|
||||
expect(out.conversationState.currentMessage.userInputMessage.content).toContain(
|
||||
"<max_thinking_length>24576</max_thinking_length>"
|
||||
expect(out.additionalModelRequestFields).toBeUndefined();
|
||||
expect(out.thinking).toBeUndefined();
|
||||
expect(out.systemPrompt).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("normalizes an unsupported Kiro intensity suffix while preserving agentic behavior", () => {
|
||||
const out = C2K(
|
||||
{ messages: [{ role: "user", content: "hello" }] },
|
||||
null,
|
||||
"claude-sonnet-4.5-thinking-agentic(high)",
|
||||
);
|
||||
|
||||
expect(out.conversationState.currentMessage.userInputMessage.modelId).toBe("claude-sonnet-4.5");
|
||||
expect(out.additionalModelRequestFields).toBeUndefined();
|
||||
expect(out.systemPrompt).toContain("CHUNKED WRITE PROTOCOL");
|
||||
});
|
||||
|
||||
it("maps output_config.effort high to Kiro CLI-style additionalModelRequestFields for effort models", () => {
|
||||
const out = C2K({
|
||||
output_config: { effort: "high" },
|
||||
messages: [{ role: "user", content: "think with adaptive effort" }],
|
||||
}, null, "claude-sonnet-5");
|
||||
|
||||
expect(out.additionalModelRequestFields).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
expect(out.thinking).toBeUndefined();
|
||||
expect(out.systemPrompt).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("maps Claude-format effort to GPT-5.6 reasoning fields without legacy prompt tags", () => {
|
||||
const out = C2K({
|
||||
output_config: { effort: "low" },
|
||||
messages: [{ role: "user", content: "think lightly" }],
|
||||
}, null, "gpt-5.6-sol");
|
||||
|
||||
expect(out.additionalModelRequestFields).toEqual({
|
||||
reasoning: { effort: "low" },
|
||||
});
|
||||
expect(out.systemPrompt || "").not.toContain("<thinking_mode>");
|
||||
expect(out.systemPrompt || "").not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
it.each(["auto", "minimal", "ultra"])(
|
||||
"keeps the legacy thinking fallback for unsupported GPT-5.6 effort %s",
|
||||
(effort) => {
|
||||
const out = C2K({
|
||||
output_config: { effort },
|
||||
messages: [{ role: "user", content: "Use legacy thinking" }],
|
||||
}, null, "gpt-5.6-sol");
|
||||
|
||||
expect(out.additionalModelRequestFields).toBeUndefined();
|
||||
expect(out.systemPrompt).toContain("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(out.systemPrompt).toContain("<max_thinking_length>");
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["none", "off", "disabled"])(
|
||||
"keeps GPT-5.6 reasoning intentionally disabled for effort %s",
|
||||
(effort) => {
|
||||
const out = C2K({
|
||||
output_config: { effort },
|
||||
messages: [{ role: "user", content: "Do not reason" }],
|
||||
}, null, "gpt-5.6-sol");
|
||||
|
||||
expect(out.additionalModelRequestFields).toBeUndefined();
|
||||
expect(out.systemPrompt || "").not.toContain("<thinking_mode>");
|
||||
expect(out.systemPrompt || "").not.toContain("<max_thinking_length>");
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps explicit Claude effort ahead of an injected OpenAI effort", () => {
|
||||
const out = C2K({
|
||||
output_config: { effort: "low" },
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "honor the client effort" }],
|
||||
}, null, "gpt-5.6-sol");
|
||||
|
||||
expect(out.additionalModelRequestFields).toEqual({
|
||||
reasoning: { effort: "low" },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends Claude system as top-level systemPrompt and keeps a user-content fallback", () => {
|
||||
const out = C2K({
|
||||
system: "system-only instruction",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
});
|
||||
|
||||
expect(out.systemPrompt).toContain("system-only instruction");
|
||||
expect(out.conversationState.currentMessage.userInputMessage.content).toContain("system-only instruction");
|
||||
});
|
||||
|
||||
it("keeps top-level systemPrompt stable across turns", () => {
|
||||
const first = C2K({
|
||||
system: "stable instruction",
|
||||
messages: [{ role: "user", content: "first" }],
|
||||
});
|
||||
const second = C2K({
|
||||
system: "stable instruction",
|
||||
messages: [{ role: "user", content: "second" }],
|
||||
});
|
||||
|
||||
expect(first.systemPrompt).toBe(second.systemPrompt);
|
||||
expect(first.systemPrompt).not.toContain("Current time");
|
||||
expect(first.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -54,6 +54,45 @@ describe("GOLDEN request: OpenAI → Gemini", () => {
|
||||
const out = translateRequest(FORMATS.OPENAI, FORMATS.GEMINI, "gemini-3-pro", baseBody(), true, { apiKey: "k" }, "gemini");
|
||||
expect(clean(out)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("Gemini CLI tool requests include validated toolConfig and enough output for high thinking", () => {
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "Call add with 7 and 35." }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "add",
|
||||
description: "Add two numbers",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
a: { type: "number" },
|
||||
b: { type: "number" },
|
||||
},
|
||||
required: ["a", "b"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
reasoning_effort: "high",
|
||||
max_tokens: 128,
|
||||
};
|
||||
const out = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.GEMINI_CLI,
|
||||
"gemini-3.1-pro-preview",
|
||||
body,
|
||||
true,
|
||||
{ accessToken: "t", projectId: "p" },
|
||||
"gemini-cli"
|
||||
);
|
||||
|
||||
expect(out.request.toolConfig).toEqual({ functionCallingConfig: { mode: "VALIDATED" } });
|
||||
expect(out.request.safetySettings).toBeDefined();
|
||||
expect(out.request.generationConfig.thinkingConfig).toEqual({ thinkingLevel: "high", includeThoughts: true });
|
||||
expect(out.request.generationConfig.maxOutputTokens).toBe(65535);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GOLDEN request: OpenAI → Kiro", () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ const SPECIAL_CRED = {
|
||||
// Chúng được lock riêng ở 11-provider edge tests / unit test chuyên biệt.
|
||||
const SPECIALIZED = new Set([
|
||||
"antigravity", "azure", "gemini-cli", "github", "iflow", "qoder", "kiro",
|
||||
"codex", "cursor", "vertex", "vertex-partner", "qwen", "opencode",
|
||||
"codex", "cursor", "vertex", "vertex-partner", "opencode",
|
||||
"opencode-go", "grok-web", "perplexity-web", "ollama-local", "commandcode",
|
||||
"xiaomi-tokenplan", "mimo-free",
|
||||
]);
|
||||
|
||||
@@ -18,6 +18,12 @@ describe("parseSuffix", () => {
|
||||
it("parses level suffix", () => {
|
||||
expect(parseSuffix("gpt-5(high)")).toEqual({ cleanModel: "gpt-5", override: { mode: "level", level: "high" } });
|
||||
});
|
||||
it("parses ultra suffix", () => {
|
||||
expect(parseSuffix("gpt-5.6-sol(ultra)")).toEqual({
|
||||
cleanModel: "gpt-5.6-sol",
|
||||
override: { mode: "level", level: "ultra" },
|
||||
});
|
||||
});
|
||||
it("parses numeric budget suffix", () => {
|
||||
expect(parseSuffix("model(8192)")).toEqual({ cleanModel: "model", override: { mode: "budget", budget: 8192 } });
|
||||
});
|
||||
@@ -55,10 +61,14 @@ describe("extractThinking", () => {
|
||||
});
|
||||
|
||||
describe("applyThinking per provider format", () => {
|
||||
it("claude 4.6+ → adaptive output_config (no budget_tokens)", () => {
|
||||
it("claude 4.6+ → adaptive thinking + output_config (no budget_tokens)", () => {
|
||||
const out = apply("claude", "claude-opus-4.7", { reasoning_effort: "high" }, "claude");
|
||||
expect(out.output_config).toEqual({ effort: "high" });
|
||||
expect(out.thinking).toBeUndefined();
|
||||
// Anthropic: on Opus 4.6/4.7/4.8 and Sonnet 4.6 thinking stays OFF unless
|
||||
// thinking:{type:"adaptive"} is sent explicitly; output_config alone is not
|
||||
// enough (and Anthropic-compatible shims like Copilot default off even on
|
||||
// Sonnet 5). Both fields together are the documented adaptive shape.
|
||||
expect(out.thinking).toEqual({ type: "adaptive" });
|
||||
});
|
||||
it("claude haiku → enabled+budget", () => {
|
||||
const out = apply("claude", "claude-haiku-4.5", { reasoning_effort: "high" }, "claude");
|
||||
@@ -78,11 +88,27 @@ describe("applyThinking per provider format", () => {
|
||||
const out = apply("gemini", "gemini-3-pro", { reasoning_effort: "auto" }, "gemini");
|
||||
expect(out.generationConfig.thinkingConfig.thinkingLevel).toBe("high");
|
||||
});
|
||||
it("gemini-3 high thinking raises too-small maxOutputTokens", () => {
|
||||
const out = apply("gemini-cli", "gemini-3.1-pro-preview", {
|
||||
request: { generationConfig: { maxOutputTokens: 128 } },
|
||||
reasoning_effort: "high",
|
||||
}, "gemini-cli");
|
||||
expect(out.request.generationConfig.thinkingConfig).toEqual({ thinkingLevel: "high", includeThoughts: true });
|
||||
expect(out.request.generationConfig.maxOutputTokens).toBe(65535);
|
||||
});
|
||||
it("gemini-2.5 → thinkingBudget", () => {
|
||||
const out = apply("gemini", "gemini-2.5-flash", { reasoning_effort: "high" }, "gemini");
|
||||
expect(out.generationConfig.thinkingConfig.thinkingBudget).toBe(24576);
|
||||
expect(out.generationConfig.thinkingConfig.thinkingLevel).toBeUndefined();
|
||||
});
|
||||
it("gemini-2.5 budget thinking keeps enough room for answer tokens", () => {
|
||||
const out = apply("gemini-cli", "gemini-2.5-pro", {
|
||||
request: { generationConfig: { maxOutputTokens: 1024 } },
|
||||
reasoning_effort: "high",
|
||||
}, "gemini-cli");
|
||||
expect(out.request.generationConfig.thinkingConfig).toEqual({ thinkingBudget: 24576, includeThoughts: true });
|
||||
expect(out.request.generationConfig.maxOutputTokens).toBe(32768);
|
||||
});
|
||||
it("GLM off → enable_thinking:false (not thinking.disabled)", () => {
|
||||
const out = apply("openai", "glm-4.6", { reasoning_effort: "none" }, "glm");
|
||||
expect(out.enable_thinking).toBe(false);
|
||||
@@ -106,6 +132,16 @@ describe("applyThinking per provider format", () => {
|
||||
const out = apply("openai", "kimi-k2.6", { reasoning_effort: "high" }, "kimi");
|
||||
expect(out.reasoning_effort).toBe("high");
|
||||
});
|
||||
it("Kimi auto → supported reasoning_effort", () => {
|
||||
const out = apply("openai", "kimi-k2.7", { reasoning_effort: "auto" }, "kimchi");
|
||||
expect(out.reasoning_effort).toBe("high");
|
||||
});
|
||||
it("Kimi unsupported OpenAI levels → supported reasoning_effort", () => {
|
||||
const minimal = apply("openai", "kimi-k2.7", { reasoning_effort: "minimal" }, "kimchi");
|
||||
const xhigh = apply("openai", "kimi-k2.7", { reasoning_effort: "xhigh" }, "kimchi");
|
||||
expect(minimal.reasoning_effort).toBe("low");
|
||||
expect(xhigh.reasoning_effort).toBe("max");
|
||||
});
|
||||
it("MiniMax M3 → adaptive", () => {
|
||||
const out = apply("claude", "MiniMax-M3", { reasoning_effort: "high" }, "minimax");
|
||||
expect(out.thinking).toEqual({ type: "adaptive" });
|
||||
@@ -127,6 +163,25 @@ describe("applyThinking per provider format", () => {
|
||||
const out = apply("openai", "gpt-5.3-codex", { reasoning_effort: "xhigh" }, "codex");
|
||||
expect(out.reasoning_effort).toBe("xhigh");
|
||||
});
|
||||
it.each([
|
||||
["gpt-5.6-sol", "max", "max"],
|
||||
["gpt-5.6-sol", "ultra", "ultra"],
|
||||
["gpt-5.6-terra", "max", "max"],
|
||||
["gpt-5.6-terra", "ultra", "ultra"],
|
||||
["gpt-5.6-luna", "max", "max"],
|
||||
["gpt-5.6-luna", "ultra", "max"],
|
||||
])("normalizes Codex %s effort %s to %s", (model, effort, expected) => {
|
||||
const out = apply("openai-responses", model, { reasoning: { effort } }, "codex");
|
||||
expect(out.reasoning_effort).toBe(expected);
|
||||
});
|
||||
it("applies a supported Codex Ultra suffix", () => {
|
||||
const out = apply("openai-responses", "gpt-5.6-sol(ultra)", {}, "codex");
|
||||
expect(out.reasoning_effort).toBe("ultra");
|
||||
});
|
||||
it("keeps Codex-only GPT-5.6 levels out of Kiro translation", () => {
|
||||
const out = apply("openai", "gpt-5.6-sol", { reasoning_effort: "max" }, "kiro");
|
||||
expect(out.reasoning_effort).toBe("xhigh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractReasoningText (response shapes)", () => {
|
||||
|
||||
45
tests/unit/alibaba-token-plan-provider.test.js
Normal file
45
tests/unit/alibaba-token-plan-provider.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
35
tests/unit/alicode-intl-endpoint-2591.test.js
Normal file
35
tests/unit/alicode-intl-endpoint-2591.test.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// #2591 — Alibaba Intl key types split across two hosts:
|
||||
// - alicode-intl: Coding Plan keys (sk-sp-...) → coding-intl.dashscope.aliyuncs.com
|
||||
// - alims-intl: standard DashScope API keys (sk-...) → dashscope-intl.aliyuncs.com/compatible-mode
|
||||
// The two key types are NOT interchangeable across hosts. Split into two providers
|
||||
// so each key type reaches its own host.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import alicodeIntl from "../../open-sse/providers/registry/alicode-intl.js";
|
||||
import alimsIntl from "../../open-sse/providers/registry/alims-intl.js";
|
||||
|
||||
describe("alicode-intl endpoint (Coding Plan keys)", () => {
|
||||
it("routes to the coding-intl host for Coding Plan keys", () => {
|
||||
expect(alicodeIntl.id).toBe("alicode-intl");
|
||||
expect(alicodeIntl.transport.baseUrl).toBe(
|
||||
"https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the chat/completions path and preserveCacheControl quirk", () => {
|
||||
expect(alicodeIntl.transport.baseUrl).toContain("/v1/chat/completions");
|
||||
expect(alicodeIntl.transport.quirks.preserveCacheControl).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("alims-intl endpoint (standard DashScope keys)", () => {
|
||||
it("routes to the compatible-mode DashScope endpoint for standard keys", () => {
|
||||
expect(alimsIntl.id).toBe("alims-intl");
|
||||
expect(alimsIntl.transport.baseUrl).toBe(
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not use the coding-intl host that rejects standard keys", () => {
|
||||
expect(alimsIntl.transport.baseUrl).not.toContain("coding-intl.dashscope.aliyuncs.com");
|
||||
});
|
||||
});
|
||||
54
tests/unit/antigravity-nonstream-usage-3260.test.js
Normal file
54
tests/unit/antigravity-nonstream-usage-3260.test.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
79
tests/unit/antigravity-quota-gemini-3.6.test.js
Normal file
79
tests/unit/antigravity-quota-gemini-3.6.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
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.6-flash-high": {
|
||||
displayName: "Gemini 3.6 Flash (High)",
|
||||
quotaInfo: { remainingFraction: 0.8, resetTime: "2026-07-25T12:00:00Z" },
|
||||
},
|
||||
"gemini-3.6-flash-medium": {
|
||||
displayName: "Gemini 3.6 Flash (Medium)",
|
||||
quotaInfo: { remainingFraction: 0.5, resetTime: "2026-07-25T12:00:00Z" },
|
||||
},
|
||||
"gemini-3.6-flash-low": {
|
||||
displayName: "Gemini 3.6 Flash (Low)",
|
||||
quotaInfo: { remainingFraction: 0.2, resetTime: "2026-07-25T12:00:00Z" },
|
||||
},
|
||||
"gemini-3.5-flash-low": {
|
||||
displayName: "Gemini 3.5 Flash (Medium)",
|
||||
quotaInfo: { remainingFraction: 0.9, resetTime: "2026-07-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.6 Flash usage bars", () => {
|
||||
beforeEach(() => proxyAwareFetch.mockClear());
|
||||
|
||||
it("returns Gemini 3.6 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.6-flash-high"]).toMatchObject({
|
||||
used: 200,
|
||||
total: 1000,
|
||||
remainingPercentage: 80,
|
||||
displayName: "Gemini 3.6 Flash (High)",
|
||||
});
|
||||
expect(usage.quotas["gemini-3.6-flash-medium"]).toMatchObject({
|
||||
used: 500,
|
||||
total: 1000,
|
||||
remainingPercentage: 50,
|
||||
});
|
||||
expect(usage.quotas["gemini-3.6-flash-low"]).toMatchObject({
|
||||
used: 800,
|
||||
total: 1000,
|
||||
remainingPercentage: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("still filters out internal and non-important models", async () => {
|
||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
||||
|
||||
const usage = await getAntigravityUsage("access-token", {});
|
||||
|
||||
expect(usage.quotas).not.toHaveProperty("internal-model");
|
||||
expect(Object.keys(usage.quotas)).toEqual([
|
||||
"gemini-3.6-flash-high",
|
||||
"gemini-3.6-flash-medium",
|
||||
"gemini-3.6-flash-low",
|
||||
"gemini-3.5-flash-low",
|
||||
]);
|
||||
});
|
||||
});
|
||||
61
tests/unit/antigravity-quota-gemini-3.7.test.js
Normal file
61
tests/unit/antigravity-quota-gemini-3.7.test.js
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// Guards D3: antigravity 429/503 retry merged into base via computeRetryDelay hook.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
|
||||
import antigravity from "../../open-sse/providers/registry/antigravity.js";
|
||||
|
||||
const MAX = 10000;
|
||||
function res(status, headers = {}, body = null) {
|
||||
@@ -66,9 +67,35 @@ describe("antigravity computeRetryDelay hook (D3)", () => {
|
||||
expect(out.request.tools[0].functionDeclarations.map(fn => fn.name)).toEqual(["read_file"]);
|
||||
});
|
||||
|
||||
it("buildHeaders includes cached session id after transformRequest", () => {
|
||||
it("registry uses the daily IDE cloudcode host and user agent", () => {
|
||||
expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]);
|
||||
expect(antigravity.transport.headers["User-Agent"]).toBe("antigravity/ide/2.1.1 darwin/arm64");
|
||||
});
|
||||
|
||||
it("buildHeaders matches official IDE stream headers", () => {
|
||||
ag._lastSessionId = "sess-123";
|
||||
const h = ag.buildHeaders({ accessToken: "tok" }, true);
|
||||
expect(h["X-Machine-Session-Id"]).toBe("sess-123");
|
||||
expect(h["User-Agent"]).toBe("antigravity/ide/2.1.1 darwin/arm64");
|
||||
expect(h["Content-Type"]).toBe("application/json");
|
||||
expect(h["Authorization"]).toBe("Bearer tok");
|
||||
expect(h).not.toHaveProperty("X-Machine-Session-Id");
|
||||
expect(h).not.toHaveProperty("x-request-source");
|
||||
expect(h).not.toHaveProperty("Accept");
|
||||
});
|
||||
|
||||
it("transforms chat requests with official IDE requestId shape and 64000 token cap", () => {
|
||||
const out = ag.transformRequest("claude-opus-4-6-thinking", {
|
||||
request: {
|
||||
contents: [
|
||||
{ role: "user", parts: [{ text: "hi" }] },
|
||||
{ role: "model", parts: [{ text: "hello" }] },
|
||||
],
|
||||
generationConfig: { maxOutputTokens: 90000 },
|
||||
sessionId: "-3750763034362895579",
|
||||
},
|
||||
}, true, { projectId: "project-1", connectionId: "conn-1" });
|
||||
|
||||
expect(out.requestId).toMatch(/^agent\/[0-9a-f-]{36}\/\d{13}\/[0-9a-f-]{36}\/\d+$/);
|
||||
expect(out.request.generationConfig.maxOutputTokens).toBe(64000);
|
||||
});
|
||||
});
|
||||
|
||||
45
tests/unit/antigravity-stream-options.test.js
Normal file
45
tests/unit/antigravity-stream-options.test.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
|
||||
|
||||
const credentials = {
|
||||
projectId: "synthetic-project",
|
||||
connectionId: "synthetic-connection",
|
||||
};
|
||||
|
||||
function requestBody(stream) {
|
||||
return {
|
||||
stream,
|
||||
stream_options: { include_usage: true },
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "Reply only OK" }] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("AntigravityExecutor stream_options normalization", () => {
|
||||
it("removes stream_options from a non-streaming request", () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const output = executor.transformRequest(
|
||||
"gpt-oss-120b-medium",
|
||||
requestBody(false),
|
||||
false,
|
||||
credentials,
|
||||
);
|
||||
|
||||
expect(output.stream).toBe(false);
|
||||
expect(output.stream_options).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves stream_options for a streaming request", () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const output = executor.transformRequest(
|
||||
"gpt-oss-120b-medium",
|
||||
requestBody(true),
|
||||
true,
|
||||
credentials,
|
||||
);
|
||||
|
||||
expect(output.stream).toBe(true);
|
||||
expect(output.stream_options).toEqual({ include_usage: true });
|
||||
});
|
||||
});
|
||||
30
tests/unit/antigravity-usage-headers.test.js
Normal file
30
tests/unit/antigravity-usage-headers.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, 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: {} },
|
||||
text: async () => "{}",
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch,
|
||||
}));
|
||||
|
||||
describe("Antigravity usage headers", () => {
|
||||
beforeEach(() => proxyAwareFetch.mockClear());
|
||||
|
||||
it("uses the official IDE user agent and omits router-only source headers", async () => {
|
||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
||||
|
||||
await getAntigravityUsage("access-token", {});
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(2);
|
||||
for (const [, options] of proxyAwareFetch.mock.calls) {
|
||||
expect(options.headers["User-Agent"]).toBe("antigravity/ide/2.1.1 darwin/arm64");
|
||||
expect(options.headers).not.toHaveProperty("x-request-source");
|
||||
}
|
||||
});
|
||||
});
|
||||
69
tests/unit/auth-status.test.js
Normal file
69
tests/unit/auth-status.test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
json: vi.fn((body, init) => ({
|
||||
status: init?.status || 200,
|
||||
body,
|
||||
})),
|
||||
cookies: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
isOidcConfigured: vi.fn(),
|
||||
getDashboardAuthSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: { json: mocks.json },
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: mocks.cookies,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getSettings: mocks.getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/oidc", () => ({
|
||||
isOidcConfigured: mocks.isOidcConfigured,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/dashboardSession", () => ({
|
||||
getDashboardAuthSession: mocks.getDashboardAuthSession,
|
||||
}));
|
||||
|
||||
const { GET } = await import("../../src/app/api/auth/status/route.js");
|
||||
|
||||
describe("GET /api/auth/status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: true, authMode: "password" });
|
||||
mocks.cookies.mockResolvedValue({ get: vi.fn(() => ({ value: "session-token" })) });
|
||||
mocks.isOidcConfigured.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("reports an authenticated session when the auth cookie is valid", async () => {
|
||||
mocks.getDashboardAuthSession.mockResolvedValue({ authenticated: true });
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.body.authenticated).toBe(true);
|
||||
expect(mocks.getDashboardAuthSession).toHaveBeenCalledWith("session-token");
|
||||
});
|
||||
|
||||
it("reports unauthenticated when the auth cookie is invalid", async () => {
|
||||
mocks.getDashboardAuthSession.mockResolvedValue(null);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.body.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed when status dependencies throw", async () => {
|
||||
mocks.getSettings.mockRejectedValue(new Error("database unavailable"));
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.body.authenticated).toBe(false);
|
||||
expect(response.body.requireLogin).toBe(true);
|
||||
});
|
||||
});
|
||||
203
tests/unit/background-token-refresh.test.js
Normal file
203
tests/unit/background-token-refresh.test.js
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Background OAuth token-refresh scheduler.
|
||||
*
|
||||
* Covers pure selection (selectConnectionsNeedingRefresh) and a fake tick that
|
||||
* exercises checkAndRefreshToken dispatch + fail-open per connection.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const NOW = Date.parse("2026-08-01T12:00:00.000Z");
|
||||
|
||||
function conn(overrides = {}) {
|
||||
return {
|
||||
id: "c1",
|
||||
provider: "grok-cli",
|
||||
authType: "oauth",
|
||||
refreshToken: "rt-1",
|
||||
expiresAt: new Date(NOW + 10 * 60 * 1000).toISOString(),
|
||||
isActive: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectConnectionsNeedingRefresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("selects oauth grok-cli connection expiring in 10 minutes", async () => {
|
||||
const { selectConnectionsNeedingRefresh } = await import(
|
||||
"../../src/sse/services/backgroundTokenRefresh.js"
|
||||
);
|
||||
const list = selectConnectionsNeedingRefresh(
|
||||
[conn({ expiresAt: new Date(NOW + 10 * 60 * 1000).toISOString() })],
|
||||
NOW
|
||||
);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].id).toBe("c1");
|
||||
});
|
||||
|
||||
it("skips connection expiring in 2 hours", async () => {
|
||||
const { selectConnectionsNeedingRefresh } = await import(
|
||||
"../../src/sse/services/backgroundTokenRefresh.js"
|
||||
);
|
||||
const list = selectConnectionsNeedingRefresh(
|
||||
[conn({ expiresAt: new Date(NOW + 2 * 60 * 60 * 1000).toISOString() })],
|
||||
NOW
|
||||
);
|
||||
expect(list).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never selects apikey connections", async () => {
|
||||
const { selectConnectionsNeedingRefresh } = await import(
|
||||
"../../src/sse/services/backgroundTokenRefresh.js"
|
||||
);
|
||||
const list = selectConnectionsNeedingRefresh(
|
||||
[
|
||||
conn({ authType: "apikey", refreshToken: "rt" }),
|
||||
conn({ id: "c2", authType: "api_key", refreshToken: "rt" }),
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(list).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips oauth connection without refreshToken", async () => {
|
||||
const { selectConnectionsNeedingRefresh } = await import(
|
||||
"../../src/sse/services/backgroundTokenRefresh.js"
|
||||
);
|
||||
const list = selectConnectionsNeedingRefresh(
|
||||
[conn({ refreshToken: null }), conn({ id: "c2", refreshToken: undefined })],
|
||||
NOW
|
||||
);
|
||||
expect(list).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("selects already-expired oauth connection", async () => {
|
||||
const { selectConnectionsNeedingRefresh } = await import(
|
||||
"../../src/sse/services/backgroundTokenRefresh.js"
|
||||
);
|
||||
const list = selectConnectionsNeedingRefresh(
|
||||
[conn({ expiresAt: new Date(NOW - 60 * 1000).toISOString() })],
|
||||
NOW
|
||||
);
|
||||
expect(list).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBackgroundTokenRefreshTick", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("calls refresh only for due connections and swallows per-connection errors", async () => {
|
||||
const due = conn({
|
||||
id: "due",
|
||||
expiresAt: new Date(NOW + 10 * 60 * 1000).toISOString(),
|
||||
});
|
||||
const notDue = conn({
|
||||
id: "not-due",
|
||||
expiresAt: new Date(NOW + 2 * 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
const apikey = conn({
|
||||
id: "key",
|
||||
authType: "apikey",
|
||||
expiresAt: new Date(NOW + 60 * 1000).toISOString(),
|
||||
});
|
||||
|
||||
const refreshConnection = vi.fn(async (c) => {
|
||||
if (c.id === "due") throw new Error("boom");
|
||||
return c;
|
||||
});
|
||||
const loadConnections = vi.fn(async () => [due, notDue, apikey]);
|
||||
|
||||
const { runBackgroundTokenRefreshTick } = await import(
|
||||
"../../src/sse/services/backgroundTokenRefresh.js"
|
||||
);
|
||||
|
||||
await expect(
|
||||
runBackgroundTokenRefreshTick({ loadConnections, refreshConnection })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(loadConnections).toHaveBeenCalledTimes(1);
|
||||
expect(refreshConnection).toHaveBeenCalledTimes(1);
|
||||
expect(refreshConnection.mock.calls[0][0].id).toBe("due");
|
||||
});
|
||||
|
||||
it("does not call refresh when nothing is due", async () => {
|
||||
const refreshConnection = vi.fn();
|
||||
const loadConnections = vi.fn(async () => [
|
||||
conn({
|
||||
expiresAt: new Date(NOW + 3 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const { runBackgroundTokenRefreshTick } = await import(
|
||||
"../../src/sse/services/backgroundTokenRefresh.js"
|
||||
);
|
||||
|
||||
await runBackgroundTokenRefreshTick({ loadConnections, refreshConnection });
|
||||
|
||||
expect(refreshConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows top-level load errors", async () => {
|
||||
const refreshConnection = vi.fn();
|
||||
const loadConnections = vi.fn(async () => {
|
||||
throw new Error("db down");
|
||||
});
|
||||
|
||||
const { runBackgroundTokenRefreshTick } = await import(
|
||||
"../../src/sse/services/backgroundTokenRefresh.js"
|
||||
);
|
||||
|
||||
await expect(
|
||||
runBackgroundTokenRefreshTick({ loadConnections, refreshConnection })
|
||||
).resolves.toBeUndefined();
|
||||
expect(refreshConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("start/stop guards", () => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
const mod = await import("../../src/sse/services/backgroundTokenRefresh.js");
|
||||
mod.stopBackgroundTokenRefresh();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("honors DISABLE_BACKGROUND_TOKEN_REFRESH kill-switch", async () => {
|
||||
vi.stubEnv("DISABLE_BACKGROUND_TOKEN_REFRESH", "1");
|
||||
const { startBackgroundTokenRefresh, stopBackgroundTokenRefresh } = await import(
|
||||
"../../src/sse/services/backgroundTokenRefresh.js"
|
||||
);
|
||||
expect(startBackgroundTokenRefresh()).toBe(false);
|
||||
stopBackgroundTokenRefresh();
|
||||
});
|
||||
|
||||
it("is idempotent: second start is no-op", async () => {
|
||||
vi.stubEnv("DISABLE_BACKGROUND_TOKEN_REFRESH", "");
|
||||
const { startBackgroundTokenRefresh, stopBackgroundTokenRefresh } = await import(
|
||||
"../../src/sse/services/backgroundTokenRefresh.js"
|
||||
);
|
||||
const first = startBackgroundTokenRefresh({ intervalMs: 60_000 });
|
||||
const second = startBackgroundTokenRefresh({ intervalMs: 60_000 });
|
||||
expect(first).toBe(true);
|
||||
expect(second).toBe(false);
|
||||
stopBackgroundTokenRefresh();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import { autoDetectFilter } from "../../open-sse/rtk/autodetect.js";
|
||||
import { buildOutput } from "../../open-sse/rtk/filters/buildOutput.js";
|
||||
import { gitDiff } from "../../open-sse/rtk/filters/gitDiff.js";
|
||||
import { gitLog } from "../../open-sse/rtk/filters/gitLog.js";
|
||||
import { gitStatus } from "../../open-sse/rtk/filters/gitStatus.js";
|
||||
import { safeApply } from "../../open-sse/rtk/applyFilter.js";
|
||||
import { compressMessages } from "../../open-sse/rtk/index.js";
|
||||
@@ -279,6 +280,41 @@ describe("PR #1175 - integration with compressMessages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 6.5. GIT-LOG PRIORITY
|
||||
// ============================================================
|
||||
describe("git-log priority", () => {
|
||||
it("git-log chosen over build-output when commit header present in first window", () => {
|
||||
const input = [
|
||||
"commit abc1234def5678abc1234def5678abc1234def5",
|
||||
"Author: Dev One <dev1@example.com>",
|
||||
"Date: Sun Jul 6 10:00:00 2026 +0700",
|
||||
"",
|
||||
" Add auth middleware",
|
||||
"",
|
||||
"diff --git a/src/auth.js b/src/auth.js",
|
||||
"index abc..def 100644",
|
||||
"--- a/src/auth.js",
|
||||
"+++ b/src/auth.js",
|
||||
"@@ -1 +1 @@",
|
||||
"+new line"
|
||||
].join("\n");
|
||||
expect(autoDetectFilter(input)).toBe(gitLog);
|
||||
});
|
||||
|
||||
it("pure git diff still stays git-diff", () => {
|
||||
const input = [
|
||||
"diff --git a/src/auth.js b/src/auth.js",
|
||||
"index abc..def 100644",
|
||||
"--- a/src/auth.js",
|
||||
"+++ b/src/auth.js",
|
||||
"@@ -1 +1 @@",
|
||||
"+new line"
|
||||
].join("\n");
|
||||
expect(autoDetectFilter(input)).toBe(gitDiff);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 7. PORCELAIN REGRESSION DEEPER TESTS
|
||||
// ============================================================
|
||||
|
||||
113
tests/unit/bulk-add-names.test.js
Normal file
113
tests/unit/bulk-add-names.test.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// Guards the bulk-add API-key naming bug: auto-generated "Key N" names used to be
|
||||
// derived from the paste-line index, blind to existing connection names. The
|
||||
// backend upserts apikey connections by name (connectionsRepo), so a colliding
|
||||
// generated name OVERWROTE an existing key instead of adding a new one.
|
||||
// Fix: planBulkAdd gap-fills the smallest free "<base> <n>" against existing
|
||||
// names (and earlier entries in the same batch) so a name is never reused.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { planBulkAdd } from "../../src/shared/utils/bulkAdd.js";
|
||||
|
||||
describe("planBulkAdd: auto-named gap-fill (the replace bug)", () => {
|
||||
it("uses Key 1..N by paste index when nothing exists", () => {
|
||||
const out = planBulkAdd(["sk-a", "sk-b", "sk-c"], []);
|
||||
expect(out.map(o => o.name)).toEqual(["Key 1", "Key 2", "Key 3"]);
|
||||
expect(out.every(o => o.skipped === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("gap-fills around existing names — never reuses an existing name", () => {
|
||||
// Key 3 and Key 5 already exist; user adds 4 keys.
|
||||
// Free slots: 1, 2, 4, 6 -> assign those, never 3 or 5.
|
||||
const out = planBulkAdd(["sk-a", "sk-b", "sk-c", "sk-d"], ["Key 3", "Key 5"]);
|
||||
expect(out.map(o => o.name)).toEqual(["Key 1", "Key 2", "Key 4", "Key 6"]);
|
||||
});
|
||||
|
||||
it("continues past the highest existing index when low slots are taken", () => {
|
||||
const out = planBulkAdd(["sk-a", "sk-b"], ["Key 1", "Key 2"]);
|
||||
expect(out.map(o => o.name)).toEqual(["Key 3", "Key 4"]);
|
||||
});
|
||||
|
||||
it("skips blank/whitespace-only lines but keeps indexing contiguous", () => {
|
||||
const out = planBulkAdd(["sk-a", " ", "", "sk-b"], []);
|
||||
expect(out.map(o => o.name)).toEqual(["Key 1", "Key 2"]);
|
||||
expect(out.map(o => o.apiKey)).toEqual(["sk-a", "sk-b"]);
|
||||
});
|
||||
|
||||
it("within-batch names are unique even for the same free slot", () => {
|
||||
const out = planBulkAdd(["sk-a", "sk-b", "sk-c"], ["Key 1"]);
|
||||
// Key 1 taken; batch gets 2, 3, 4 — no internal dup.
|
||||
const names = out.map(o => o.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names).toEqual(["Key 2", "Key 3", "Key 4"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planBulkAdd: custom name|apiKey", () => {
|
||||
it("uses the literal base name with a gap-filled index", () => {
|
||||
const out = planBulkAdd(["Prod|sk-1", "Prod|sk-2"], []);
|
||||
expect(out.map(o => o.name)).toEqual(["Prod 1", "Prod 2"]);
|
||||
expect(out.map(o => o.apiKey)).toEqual(["sk-1", "sk-2"]);
|
||||
});
|
||||
|
||||
it("custom name avoids an existing same-base name", () => {
|
||||
// "Prod 1" exists -> first new "Prod|.." line becomes "Prod 2".
|
||||
const out = planBulkAdd(["Prod|sk-new"], ["Prod 1"]);
|
||||
expect(out[0].name).toBe("Prod 2");
|
||||
});
|
||||
|
||||
it("apiKey containing pipes is preserved (parts after first rejoined)", () => {
|
||||
const out = planBulkAdd(["Prod|sk|with|pipes"], []);
|
||||
expect(out[0].apiKey).toBe("sk|with|pipes");
|
||||
expect(out[0].name).toBe("Prod 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planBulkAdd: cloudflare-ai (name|apiKey|accountId)", () => {
|
||||
it("parses 3-part lines into name + apiKey + accountId", () => {
|
||||
const out = planBulkAdd(
|
||||
["main|sk-key1|acc123", "main|sk-key2|def789"],
|
||||
[],
|
||||
{ isCloudflareAi: true }
|
||||
);
|
||||
expect(out.map(o => o.name)).toEqual(["main 1", "main 2"]);
|
||||
expect(out[0].apiKey).toBe("sk-key1");
|
||||
expect(out[0].providerSpecificData).toEqual({ accountId: "acc123" });
|
||||
expect(out[1].providerSpecificData).toEqual({ accountId: "def789" });
|
||||
});
|
||||
|
||||
it("2-part cloudflare line is name|apiKey (no accountId)", () => {
|
||||
const out = planBulkAdd(["main|sk-key1"], [], { isCloudflareAi: true });
|
||||
expect(out[0].name).toBe("main 1");
|
||||
expect(out[0].apiKey).toBe("sk-key1");
|
||||
expect(out[0].providerSpecificData).toBeUndefined();
|
||||
});
|
||||
|
||||
it("1-part cloudflare line is auto-named Key N", () => {
|
||||
const out = planBulkAdd(["sk-key1"], [], { isCloudflareAi: true });
|
||||
expect(out[0].name).toBe("Key 1");
|
||||
expect(out[0].apiKey).toBe("sk-key1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planBulkAdd: robustness", () => {
|
||||
it("returns [] for no input", () => {
|
||||
expect(planBulkAdd([], [])).toEqual([]);
|
||||
expect(planBulkAdd(["", " "], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("trims names and apiKeys", () => {
|
||||
const out = planBulkAdd([" Prod | sk-1 "], []);
|
||||
expect(out[0].name).toBe("Prod 1");
|
||||
expect(out[0].apiKey).toBe("sk-1");
|
||||
});
|
||||
|
||||
it("falls back to base 'Key' when name part is empty", () => {
|
||||
const out = planBulkAdd(["|sk-1"], []);
|
||||
expect(out[0].name).toBe("Key 1");
|
||||
expect(out[0].apiKey).toBe("sk-1");
|
||||
});
|
||||
|
||||
it("coerces non-array existingNames gracefully", () => {
|
||||
const out = planBulkAdd(["sk-a"], null);
|
||||
expect(out[0].name).toBe("Key 1");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
|
||||
// Claude Opus 4.6+ ships a 1M-token context window (GA, standard pricing).
|
||||
// The registry exposes dashed ids (claude-opus-4-8, claude-opus-4-7), which
|
||||
// The registry exposes dashed ids (claude-opus-5, claude-opus-4-8, claude-opus-4-7), which
|
||||
// must resolve to the 1M context + adaptive thinking caps rather than falling
|
||||
// through to the generic *claude*opus* pattern (200k / budget thinking).
|
||||
describe("Claude Opus 1M context capabilities", () => {
|
||||
@@ -17,6 +17,10 @@ describe("Claude Opus 1M context capabilities", () => {
|
||||
};
|
||||
|
||||
for (const model of [
|
||||
"claude-opus-5",
|
||||
"claude-opus-5-thinking",
|
||||
"claude-opus-5-agentic",
|
||||
"claude-opus-5-thinking-agentic",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4.8",
|
||||
"claude-opus-4-7",
|
||||
|
||||
@@ -11,6 +11,27 @@ describe("getCapabilitiesForModel", () => {
|
||||
search: true,
|
||||
};
|
||||
|
||||
const kiroGpt56Expected = {
|
||||
contextWindow: 272000,
|
||||
maxOutput: 128000,
|
||||
thinkingFormat: "openai",
|
||||
reasoning: true,
|
||||
vision: true,
|
||||
search: true,
|
||||
};
|
||||
|
||||
it("reports Kiro Claude Opus 5 variants as 1M adaptive-thinking models", () => {
|
||||
for (const model of [
|
||||
"claude-opus-5",
|
||||
"anthropic/claude-opus-5",
|
||||
"claude-opus-5-thinking",
|
||||
"claude-opus-5-agentic",
|
||||
"claude-opus-5-thinking-agentic",
|
||||
]) {
|
||||
expect(getCapabilitiesForModel("kiro", model)).toMatchObject(claudeSonnet5Expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports Kiro Claude Opus 4.8 as a 1M context model", () => {
|
||||
expect(getCapabilitiesForModel("kiro", "claude-opus-4.8").contextWindow).toBe(1000000);
|
||||
expect(getCapabilitiesForModel("kiro", "anthropic/claude-opus-4.8").contextWindow).toBe(1000000);
|
||||
@@ -26,4 +47,12 @@ describe("getCapabilitiesForModel", () => {
|
||||
expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-agentic")).toMatchObject(claudeSonnet5Expected);
|
||||
expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-thinking-agentic")).toMatchObject(claudeSonnet5Expected);
|
||||
});
|
||||
|
||||
it("reports Kiro GPT 5.6 models with the Kiro 272k context window", () => {
|
||||
expect(getCapabilitiesForModel("kiro", "gpt-5.6-sol")).toMatchObject(kiroGpt56Expected);
|
||||
expect(getCapabilitiesForModel("kiro", "openai/gpt-5.6-sol")).toMatchObject(kiroGpt56Expected);
|
||||
expect(getCapabilitiesForModel("kiro", "gpt-5.6-terra-thinking")).toMatchObject(kiroGpt56Expected);
|
||||
expect(getCapabilitiesForModel("kiro", "gpt-5.6-luna-agentic")).toMatchObject(kiroGpt56Expected);
|
||||
expect(getCapabilitiesForModel("kiro", "gpt-5.6-sol-thinking-agentic")).toMatchObject(kiroGpt56Expected);
|
||||
});
|
||||
});
|
||||
|
||||
79
tests/unit/caveman-prompts.test.js
Normal file
79
tests/unit/caveman-prompts.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CAVEMAN_LEVELS, CAVEMAN_PROMPTS } from "../../open-sse/rtk/cavemanPrompts.js";
|
||||
|
||||
const LEVEL_KEYS = [
|
||||
CAVEMAN_LEVELS.LITE,
|
||||
CAVEMAN_LEVELS.FULL,
|
||||
CAVEMAN_LEVELS.ULTRA,
|
||||
CAVEMAN_LEVELS.WENYAN_LITE,
|
||||
CAVEMAN_LEVELS.WENYAN,
|
||||
CAVEMAN_LEVELS.WENYAN_ULTRA,
|
||||
];
|
||||
|
||||
describe("Caveman prompt coverage", () => {
|
||||
it("every level key has matching prompt and vice versa", () => {
|
||||
const levelValues = Object.values(CAVEMAN_LEVELS);
|
||||
for (const key of LEVEL_KEYS) {
|
||||
expect(levelValues).toContain(key);
|
||||
}
|
||||
for (const value of levelValues) {
|
||||
expect(LEVEL_KEYS).toContain(value);
|
||||
}
|
||||
});
|
||||
|
||||
it("has a prompt string for every level", () => {
|
||||
for (const level of LEVEL_KEYS) {
|
||||
expect(typeof CAVEMAN_PROMPTS[level]).toBe("string");
|
||||
expect(CAVEMAN_PROMPTS[level].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds no-invented-abbreviations guidance to every level", () => {
|
||||
for (const level of LEVEL_KEYS) {
|
||||
expect(CAVEMAN_PROMPTS[level]).toContain("No invented abbreviations");
|
||||
}
|
||||
});
|
||||
|
||||
it("adds preserve-user-language guidance to every level", () => {
|
||||
for (const level of LEVEL_KEYS) {
|
||||
expect(CAVEMAN_PROMPTS[level]).toContain("Preserve the user's dominant language");
|
||||
}
|
||||
});
|
||||
|
||||
it("adds no-self-reference guidance to every level", () => {
|
||||
for (const level of LEVEL_KEYS) {
|
||||
expect(CAVEMAN_PROMPTS[level]).toContain("No self-reference");
|
||||
}
|
||||
});
|
||||
|
||||
it("adds no-decorative-emoji guidance to every level", () => {
|
||||
for (const level of LEVEL_KEYS) {
|
||||
expect(CAVEMAN_PROMPTS[level]).toContain("No decorative emoji");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Caveman internal consistency", () => {
|
||||
it("no level uses Unicode arrow (SHARED_NO_DECORATION bans arrow shorthand)", () => {
|
||||
// SHARED_NO_DECORATION uses ASCII -> to quote the banned pattern.
|
||||
// Unicode → is the character old ULTRA used in "Pattern: [thing] → [result]".
|
||||
// Verify no level now uses it.
|
||||
for (const level of LEVEL_KEYS) {
|
||||
expect(CAVEMAN_PROMPTS[level]).not.toContain("→");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Caveman ULTRA targeted sync", () => {
|
||||
it("does not encourage invented abbreviations", () => {
|
||||
const ultra = CAVEMAN_PROMPTS[CAVEMAN_LEVELS.ULTRA];
|
||||
expect(ultra).not.toContain("req/res/fn/impl");
|
||||
expect(ultra).not.toContain("Abbreviate (DB/auth/config/req/res/fn/impl)");
|
||||
});
|
||||
|
||||
it("does not encourage arrow shorthand", () => {
|
||||
const ultra = CAVEMAN_PROMPTS[CAVEMAN_LEVELS.ULTRA];
|
||||
expect(ultra).not.toContain("use arrows for causality");
|
||||
expect(ultra).not.toContain("X → Y");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* Unit tests for Anthropic header caching + forwarding pipeline
|
||||
* Unit tests for Anthropic header forwarding pipeline
|
||||
*
|
||||
* Tests cover:
|
||||
* - claudeHeaderCache: detection, capture, and retrieval of Claude Code headers
|
||||
* - default.js buildHeaders(): live header overlay for "claude" provider
|
||||
* - default.js buildHeaders(): cold-start fallback when cache is empty
|
||||
* - default.js buildHeaders(): static provider defaults + model-gated anthropic-beta
|
||||
* - default.js buildHeaders(): anthropic-compatible non-Anthropic host stripping
|
||||
* - default.js buildHeaders(): anthropic-compatible official host keeps headers
|
||||
* - proxyFetch.js: api.anthropic.com routes through anthropicFetch path
|
||||
@@ -12,109 +10,6 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// ─── claudeHeaderCache ────────────────────────────────────────────────────────
|
||||
|
||||
describe("claudeHeaderCache", () => {
|
||||
let cacheModule;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Re-import fresh module each time to reset singleton state
|
||||
vi.resetModules();
|
||||
cacheModule = await import("open-sse/utils/claudeHeaderCache.js");
|
||||
});
|
||||
|
||||
it("returns null before any headers are cached (cold start)", () => {
|
||||
expect(cacheModule.getCachedClaudeHeaders()).toBeNull();
|
||||
});
|
||||
|
||||
it("caches headers when user-agent contains 'claude-code'", () => {
|
||||
cacheModule.cacheClaudeHeaders({
|
||||
"user-agent": "claude-code/2.1.63 node/24.3.0",
|
||||
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-app": "cli",
|
||||
"x-stainless-os": "MacOS",
|
||||
"x-stainless-arch": "arm64",
|
||||
"x-stainless-lang": "js",
|
||||
"x-stainless-runtime": "node",
|
||||
"x-stainless-runtime-version": "v24.3.0",
|
||||
"x-stainless-package-version": "0.74.0",
|
||||
"x-stainless-helper-method": "stream",
|
||||
"x-stainless-retry-count": "0",
|
||||
"x-stainless-timeout": "600",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
// Non-identity header — should NOT be captured
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
const cached = cacheModule.getCachedClaudeHeaders();
|
||||
expect(cached).not.toBeNull();
|
||||
expect(cached["user-agent"]).toBe("claude-code/2.1.63 node/24.3.0");
|
||||
expect(cached["anthropic-beta"]).toBe("claude-code-20250219,oauth-2025-04-20");
|
||||
expect(cached["x-app"]).toBe("cli");
|
||||
expect(cached["x-stainless-os"]).toBe("MacOS");
|
||||
// Non-identity header must not leak in
|
||||
expect(cached["content-type"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caches headers when user-agent contains 'claude-cli'", () => {
|
||||
cacheModule.cacheClaudeHeaders({
|
||||
"user-agent": "claude-cli/1.0.0",
|
||||
"anthropic-version": "2023-06-01",
|
||||
});
|
||||
expect(cacheModule.getCachedClaudeHeaders()).not.toBeNull();
|
||||
expect(cacheModule.getCachedClaudeHeaders()["user-agent"]).toBe("claude-cli/1.0.0");
|
||||
});
|
||||
|
||||
it("caches headers when x-app is 'cli' (regardless of user-agent)", () => {
|
||||
cacheModule.cacheClaudeHeaders({
|
||||
"user-agent": "axios/1.7.0",
|
||||
"x-app": "cli",
|
||||
"anthropic-version": "2023-06-01",
|
||||
});
|
||||
expect(cacheModule.getCachedClaudeHeaders()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT cache headers for non-Claude clients", () => {
|
||||
cacheModule.cacheClaudeHeaders({
|
||||
"user-agent": "PostmanRuntime/7.43.0",
|
||||
"anthropic-version": "2023-06-01",
|
||||
});
|
||||
expect(cacheModule.getCachedClaudeHeaders()).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes cache on each matching request", () => {
|
||||
cacheModule.cacheClaudeHeaders({
|
||||
"user-agent": "claude-code/2.0.0",
|
||||
"x-stainless-package-version": "0.70.0",
|
||||
});
|
||||
cacheModule.cacheClaudeHeaders({
|
||||
"user-agent": "claude-code/2.1.63",
|
||||
"x-stainless-package-version": "0.74.0",
|
||||
});
|
||||
const cached = cacheModule.getCachedClaudeHeaders();
|
||||
expect(cached["user-agent"]).toBe("claude-code/2.1.63");
|
||||
expect(cached["x-stainless-package-version"]).toBe("0.74.0");
|
||||
});
|
||||
|
||||
it("ignores calls with null or non-object headers", () => {
|
||||
cacheModule.cacheClaudeHeaders(null);
|
||||
cacheModule.cacheClaudeHeaders(undefined);
|
||||
cacheModule.cacheClaudeHeaders("string");
|
||||
expect(cacheModule.getCachedClaudeHeaders()).toBeNull();
|
||||
});
|
||||
|
||||
it("only stores keys that are actually present in the headers object", () => {
|
||||
cacheModule.cacheClaudeHeaders({
|
||||
"user-agent": "claude-code/2.1.63",
|
||||
// Most stainless headers absent
|
||||
});
|
||||
const cached = cacheModule.getCachedClaudeHeaders();
|
||||
expect(cached["x-stainless-os"]).toBeUndefined();
|
||||
expect(cached["user-agent"]).toBe("claude-code/2.1.63");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DefaultExecutor.buildHeaders() ──────────────────────────────────────────
|
||||
|
||||
describe("DefaultExecutor.buildHeaders() — claude provider", () => {
|
||||
@@ -122,55 +17,51 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
// Prime the cache with live client headers before importing executor
|
||||
const cache = await import("open-sse/utils/claudeHeaderCache.js");
|
||||
cache.cacheClaudeHeaders({
|
||||
"user-agent": "claude-code/2.1.63 node/24.3.0",
|
||||
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"x-app": "cli",
|
||||
"x-stainless-os": "MacOS",
|
||||
"x-stainless-arch": "arm64",
|
||||
"x-stainless-lang": "js",
|
||||
"x-stainless-runtime": "node",
|
||||
"x-stainless-runtime-version": "v24.3.0",
|
||||
"x-stainless-package-version": "0.74.0",
|
||||
"x-stainless-helper-method": "stream",
|
||||
"x-stainless-retry-count": "0",
|
||||
"x-stainless-timeout": "600",
|
||||
});
|
||||
const mod = await import("open-sse/executors/default.js");
|
||||
DefaultExecutor = mod.DefaultExecutor || mod.default;
|
||||
});
|
||||
|
||||
it("overlays live cached headers over static provider defaults", () => {
|
||||
it("uses static provider defaults when no model is given", () => {
|
||||
const executor = new DefaultExecutor("claude");
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, true);
|
||||
|
||||
// Live values should win over static providers.js values
|
||||
expect(headers["user-agent"]).toBe("claude-code/2.1.63 node/24.3.0");
|
||||
// Beta flags are MERGED (static + cached) to preserve required flags like oauth
|
||||
const betaFlags = headers["anthropic-beta"].split(",").map(s => s.trim());
|
||||
expect(betaFlags).toContain("claude-code-20250219");
|
||||
expect(betaFlags).toContain("oauth-2025-04-20");
|
||||
expect(betaFlags).toContain("interleaved-thinking-2025-05-14");
|
||||
expect(headers["x-stainless-package-version"]).toBe("0.74.0");
|
||||
expect(headers["x-stainless-os"]).toBe("MacOS");
|
||||
const hasVersion =
|
||||
headers["Anthropic-Version"] === "2023-06-01" ||
|
||||
headers["anthropic-version"] === "2023-06-01";
|
||||
expect(hasVersion).toBe(true);
|
||||
});
|
||||
|
||||
it("removes conflicting Title-Case static keys when cached lowercase keys exist", () => {
|
||||
it("includes heavy-agent beta flags for claude-opus-5", () => {
|
||||
const executor = new DefaultExecutor("claude");
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, true);
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, true, undefined, "claude-opus-5");
|
||||
const betaFlags = headers["Anthropic-Beta"].split(",").map(s => s.trim());
|
||||
expect(betaFlags).toContain("advanced-tool-use-2025-11-20");
|
||||
expect(betaFlags).toContain("effort-2025-11-24");
|
||||
});
|
||||
|
||||
// Title-Case variants from providers.js must be gone
|
||||
expect(headers["Anthropic-Version"]).toBeUndefined();
|
||||
expect(headers["Anthropic-Beta"]).toBeUndefined();
|
||||
expect(headers["User-Agent"]).toBeUndefined();
|
||||
expect(headers["X-App"]).toBeUndefined();
|
||||
// Lowercase variants must be present
|
||||
expect(headers["anthropic-version"]).toBe("2023-06-01");
|
||||
expect(headers["x-app"]).toBe("cli");
|
||||
it("includes heavy-agent beta flags for claude-sonnet-5", () => {
|
||||
const executor = new DefaultExecutor("claude");
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, true, undefined, "claude-sonnet-5");
|
||||
const betaFlags = headers["Anthropic-Beta"].split(",").map(s => s.trim());
|
||||
expect(betaFlags).toContain("advanced-tool-use-2025-11-20");
|
||||
expect(betaFlags).toContain("effort-2025-11-24");
|
||||
});
|
||||
|
||||
it("omits heavy-agent beta flags for claude-haiku-4-5-20251001", () => {
|
||||
const executor = new DefaultExecutor("claude");
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, true, undefined, "claude-haiku-4-5-20251001");
|
||||
const betaFlags = headers["Anthropic-Beta"].split(",").map(s => s.trim());
|
||||
expect(betaFlags).not.toContain("advanced-tool-use-2025-11-20");
|
||||
expect(betaFlags).not.toContain("effort-2025-11-24");
|
||||
expect(betaFlags).toContain("claude-code-20250219");
|
||||
});
|
||||
|
||||
it("omits heavy-agent beta flags for claude-fable-5", () => {
|
||||
const executor = new DefaultExecutor("claude");
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, true, undefined, "claude-fable-5");
|
||||
const betaFlags = headers["Anthropic-Beta"].split(",").map(s => s.trim());
|
||||
expect(betaFlags).not.toContain("advanced-tool-use-2025-11-20");
|
||||
expect(betaFlags).not.toContain("effort-2025-11-24");
|
||||
});
|
||||
|
||||
it("sets x-api-key auth when apiKey is provided", () => {
|
||||
@@ -198,31 +89,8 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => {
|
||||
const headers = executor.buildHeaders({ apiKey: "k" }, false);
|
||||
expect(headers["Accept"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefaultExecutor.buildHeaders() — claude provider cold start (no cache)", () => {
|
||||
let DefaultExecutor;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
// Do NOT prime cache — simulate cold start
|
||||
const mod = await import("open-sse/executors/default.js");
|
||||
DefaultExecutor = mod.DefaultExecutor || mod.default;
|
||||
});
|
||||
|
||||
it("falls back to static provider headers when cache is empty", () => {
|
||||
const executor = new DefaultExecutor("claude");
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, true);
|
||||
|
||||
// Static fallback values from providers.js must still be present
|
||||
// They may be Title-Case since no cache to conflict with them
|
||||
const hasVersion =
|
||||
headers["Anthropic-Version"] === "2023-06-01" ||
|
||||
headers["anthropic-version"] === "2023-06-01";
|
||||
expect(hasVersion).toBe(true);
|
||||
});
|
||||
|
||||
it("does not throw when cache returns null", () => {
|
||||
it("does not throw when no model is given", () => {
|
||||
const executor = new DefaultExecutor("claude");
|
||||
expect(() => executor.buildHeaders({ apiKey: "sk" }, false)).not.toThrow();
|
||||
});
|
||||
|
||||
141
tests/unit/cli-build-artifacts.test.js
Normal file
141
tests/unit/cli-build-artifacts.test.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
let testApi;
|
||||
try {
|
||||
testApi = await import("vitest");
|
||||
} catch (error) {
|
||||
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
|
||||
testApi = await import("node:test");
|
||||
}
|
||||
const { afterEach, describe, it } = testApi;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
assertRequiredApiArtifacts,
|
||||
copyStandaloneBuild,
|
||||
mergeServerArtifacts,
|
||||
} = require("../../cli/scripts/build-cli.js");
|
||||
|
||||
const tempDirs = [];
|
||||
|
||||
function createTempDir() {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-cli-build-"));
|
||||
tempDirs.push(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
function writeFixture(root, relativePath, contents = relativePath) {
|
||||
const filePath = path.join(root, relativePath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, contents);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createCompleteServer(buildDistDir) {
|
||||
const serverDir = path.join(buildDistDir, "server");
|
||||
writeFixture(serverDir, "app/api/v1/chat/completions/route.js", "chat route");
|
||||
writeFixture(serverDir, "app/api/v1/messages/route.js", "messages route");
|
||||
writeFixture(serverDir, "chunks/openai-provider.js", "openai chunk");
|
||||
writeFixture(serverDir, "chunks/anthropic-provider.js", "anthropic chunk");
|
||||
return serverDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("CLI build server artifacts", () => {
|
||||
for (const { name, standalonePath } of [
|
||||
{
|
||||
name: "legacy nested app",
|
||||
standalonePath: (appDir, buildDistDir) => path.join(appDir, ".next", "standalone", "app"),
|
||||
},
|
||||
{
|
||||
name: "Next 16 workspace",
|
||||
standalonePath: (appDir, buildDistDir) => path.join(buildDistDir, "standalone", path.basename(appDir)),
|
||||
},
|
||||
]) {
|
||||
it(`merges complete API routes and provider chunks for the ${name} layout`, () => {
|
||||
const root = createTempDir();
|
||||
const appDir = path.join(root, "9router");
|
||||
const buildDistDir = path.join(appDir, ".next-cli-build");
|
||||
const cliAppDir = path.join(root, "cli-app");
|
||||
const standaloneDir = standalonePath(appDir, buildDistDir);
|
||||
|
||||
writeFixture(standaloneDir, "server.js", "standalone server");
|
||||
writeFixture(
|
||||
standaloneDir,
|
||||
".next-cli-build/server/app/api/v1/chat/completions/route.js",
|
||||
"standalone chat route",
|
||||
);
|
||||
createCompleteServer(buildDistDir);
|
||||
|
||||
copyStandaloneBuild(appDir, buildDistDir, cliAppDir);
|
||||
mergeServerArtifacts(buildDistDir, cliAppDir);
|
||||
assertRequiredApiArtifacts(cliAppDir);
|
||||
|
||||
const packagedServer = path.join(cliAppDir, ".next-cli-build", "server");
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(packagedServer, "app/api/v1/messages/route.js"), "utf8"),
|
||||
"messages route",
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(packagedServer, "chunks/openai-provider.js"), "utf8"),
|
||||
"openai chunk",
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(packagedServer, "chunks/anthropic-provider.js"), "utf8"),
|
||||
"anthropic chunk",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("merges idempotently without removing standalone-generated files", () => {
|
||||
const root = createTempDir();
|
||||
const buildDistDir = path.join(root, ".next-cli-build");
|
||||
const cliAppDir = path.join(root, "cli-app");
|
||||
const packagedServer = path.join(cliAppDir, ".next-cli-build", "server");
|
||||
|
||||
createCompleteServer(buildDistDir);
|
||||
writeFixture(packagedServer, "standalone-only.js", "keep me");
|
||||
|
||||
mergeServerArtifacts(buildDistDir, cliAppDir);
|
||||
mergeServerArtifacts(buildDistDir, cliAppDir);
|
||||
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(packagedServer, "standalone-only.js"), "utf8"),
|
||||
"keep me",
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(packagedServer, "app/api/v1/messages/route.js"), "utf8"),
|
||||
"messages route",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the missing required API route artifact path", () => {
|
||||
const root = createTempDir();
|
||||
const buildDistDir = path.join(root, ".next-cli-build");
|
||||
const cliAppDir = path.join(root, "cli-app");
|
||||
|
||||
writeFixture(
|
||||
path.join(buildDistDir, "server"),
|
||||
"app/api/v1/chat/completions/route.js",
|
||||
"chat route",
|
||||
);
|
||||
mergeServerArtifacts(buildDistDir, cliAppDir);
|
||||
|
||||
assert.throws(
|
||||
() => assertRequiredApiArtifacts(cliAppDir),
|
||||
(error) => error.message.includes(path.join(
|
||||
cliAppDir,
|
||||
".next-cli-build/server/app/api/v1/messages/route.js",
|
||||
)),
|
||||
);
|
||||
});
|
||||
});
|
||||
273
tests/unit/cli-xai-video.test.js
Normal file
273
tests/unit/cli-xai-video.test.js
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Tests for the `9router xai video` CLI command (cli/src/cli/commands/xaiVideo.js)
|
||||
*
|
||||
* Uses a real local HTTP server standing in for the 9router gateway + video CDN.
|
||||
* No real credentials or upstream calls.
|
||||
*
|
||||
* Covers:
|
||||
* - arg parsing (defaults, flags, unknown flag rejection)
|
||||
* - full happy path: create → poll (pending → done) → MP4 download → atomic rename
|
||||
* - x-connection-id pinning from the create response header
|
||||
* - failed job → non-zero exit, no output file, no stray .part
|
||||
* - poll timeout → non-zero exit
|
||||
* - download failure cleans up the .part file
|
||||
* - no Authorization/token material in output
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { run, parseArgs, downloadToFile, sanitizeText, imageInputToUrl } = require("../../cli/src/cli/commands/xaiVideo.js");
|
||||
|
||||
const MP4_BYTES = Buffer.from("FAKE-MP4-DATA-0123456789");
|
||||
|
||||
function startServer(handler) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(handler);
|
||||
server.listen(0, "127.0.0.1", () => resolve({ server, port: server.address().port }));
|
||||
});
|
||||
}
|
||||
|
||||
const closeServer = (server) => new Promise((r) => server.close(r));
|
||||
|
||||
let tmpDir;
|
||||
let server;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "xai-video-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await closeServer(server);
|
||||
server = null;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("parseArgs", () => {
|
||||
it("applies defaults", () => {
|
||||
const opts = parseArgs(["--prompt", "hi"]);
|
||||
expect(opts.prompt).toBe("hi");
|
||||
expect(opts.model).toBe("xai/grok-imagine-video");
|
||||
expect(opts.output).toBe("video.mp4");
|
||||
expect(opts.port).toBe(20128);
|
||||
});
|
||||
|
||||
it("parses all documented flags", () => {
|
||||
const opts = parseArgs([
|
||||
"--prompt", "p", "--output", "o.mp4", "--model", "m",
|
||||
"--duration", "10", "--aspect-ratio", "16:9", "--resolution", "720p",
|
||||
"--image", "https://x/img.png", "--timeout", "30", "--port", "1234", "--api-key", "k",
|
||||
]);
|
||||
expect(opts).toMatchObject({
|
||||
prompt: "p", output: "o.mp4", model: "m", duration: 10,
|
||||
aspectRatio: "16:9", resolution: "720p", image: "https://x/img.png",
|
||||
timeoutSec: 30, port: 1234, apiKey: "k",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown flags", () => {
|
||||
expect(() => parseArgs(["--bogus"])).toThrow(/Unknown option/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageInputToUrl", () => {
|
||||
it("passes URLs and data URLs through", () => {
|
||||
expect(imageInputToUrl("https://example.com/a.png")).toBe("https://example.com/a.png");
|
||||
expect(imageInputToUrl("data:image/png;base64,AAA")).toBe("data:image/png;base64,AAA");
|
||||
});
|
||||
|
||||
it("converts a local file to a base64 data URL", () => {
|
||||
const p = path.join(tmpDir, "in.png");
|
||||
fs.writeFileSync(p, Buffer.from([1, 2, 3]));
|
||||
expect(imageInputToUrl(p)).toBe(`data:image/png;base64,${Buffer.from([1, 2, 3]).toString("base64")}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeText", () => {
|
||||
it("redacts bearer tokens from error output", () => {
|
||||
expect(sanitizeText("boom Bearer abcdefghijklmnop!")).toBe("boom Bearer [redacted]!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("run (against a mock gateway)", () => {
|
||||
it("creates, polls to done, downloads the MP4, and exits 0", async () => {
|
||||
let pollCount = 0;
|
||||
const seen = { createAuth: null, pollConnectionIds: [] };
|
||||
|
||||
({ server } = await startServer((req, res) => {
|
||||
if (req.method === "POST" && req.url === "/v1/videos/generations") {
|
||||
seen.createAuth = req.headers.authorization || null;
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
seen.createBody = JSON.parse(body);
|
||||
res.writeHead(200, { "Content-Type": "application/json", "x-9router-connection-id": "conn-42" });
|
||||
res.end(JSON.stringify({ request_id: "job-1" }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/v1/videos/job-1") {
|
||||
seen.pollConnectionIds.push(req.headers["x-connection-id"] || null);
|
||||
pollCount++;
|
||||
const port = server.address().port;
|
||||
const payload = pollCount < 3
|
||||
? { status: "pending", progress: pollCount * 30 }
|
||||
: { status: "done", video: { url: `http://127.0.0.1:${port}/files/out.mp4`, duration: 8 } };
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(payload));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/files/out.mp4") {
|
||||
res.writeHead(200, { "Content-Type": "video/mp4" });
|
||||
res.end(MP4_BYTES);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
}));
|
||||
|
||||
const output = path.join(tmpDir, "result.mp4");
|
||||
const logs = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...a) => logs.push(a.join(" ")));
|
||||
|
||||
const code = await run([
|
||||
"--prompt", "a neon city",
|
||||
"--output", output,
|
||||
"--port", String(server.address().port),
|
||||
"--api-key", "local-key-secret",
|
||||
"--timeout", "10",
|
||||
"--poll-interval-ms", "20",
|
||||
]);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(fs.readFileSync(output)).toEqual(MP4_BYTES);
|
||||
expect(fs.existsSync(`${output}.part`)).toBe(false);
|
||||
|
||||
// Model prefix forwarded as-is to the gateway (gateway strips it)
|
||||
expect(seen.createBody.model).toBe("xai/grok-imagine-video");
|
||||
expect(seen.createBody.prompt).toBe("a neon city");
|
||||
// Polls pinned to the connection that created the job
|
||||
expect(seen.pollConnectionIds.every((id) => id === "conn-42")).toBe(true);
|
||||
// No token material in user-facing output
|
||||
expect(logs.join("\n")).not.toContain("local-key-secret");
|
||||
expect(logs.join("\n")).not.toContain("Authorization");
|
||||
});
|
||||
|
||||
it("exits non-zero when the job fails, without leaving files", async () => {
|
||||
({ server } = await startServer((req, res) => {
|
||||
if (req.method === "POST") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ request_id: "job-f" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "failed", error: { code: "invalid_argument", message: "bad prompt" } }));
|
||||
}));
|
||||
|
||||
const output = path.join(tmpDir, "nope.mp4");
|
||||
const errors = [];
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
|
||||
|
||||
const code = await run([
|
||||
"--prompt", "x", "--output", output,
|
||||
"--port", String(server.address().port),
|
||||
"--timeout", "10", "--poll-interval-ms", "10",
|
||||
]);
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(errors.join("\n")).toContain("bad prompt");
|
||||
expect(fs.existsSync(output)).toBe(false);
|
||||
expect(fs.existsSync(`${output}.part`)).toBe(false);
|
||||
});
|
||||
|
||||
it("exits non-zero when polling exceeds the timeout", async () => {
|
||||
({ server } = await startServer((req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(req.method === "POST" ? JSON.stringify({ request_id: "job-slow" }) : JSON.stringify({ status: "pending", progress: 1 }));
|
||||
}));
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errors = [];
|
||||
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
|
||||
|
||||
const code = await run([
|
||||
"--prompt", "x", "--output", path.join(tmpDir, "slow.mp4"),
|
||||
"--port", String(server.address().port),
|
||||
"--timeout", "1", "--poll-interval-ms", "50",
|
||||
]);
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(errors.join("\n")).toMatch(/Timed out/i);
|
||||
}, 15000);
|
||||
|
||||
it("reports a helpful error when no xAI account is connected", async () => {
|
||||
({ server } = await startServer((req, res) => {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { message: "No credentials for provider: xai", type: "invalid_request_error" } }));
|
||||
}));
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errors = [];
|
||||
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
|
||||
|
||||
const code = await run([
|
||||
"--prompt", "x", "--output", path.join(tmpDir, "n.mp4"),
|
||||
"--port", String(server.address().port),
|
||||
]);
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(errors.join("\n")).toContain("No credentials");
|
||||
expect(errors.join("\n")).toContain("Connect an xAI account");
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadToFile", () => {
|
||||
it("downloads via .part and renames atomically", async () => {
|
||||
({ server } = await startServer((req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "video/mp4" });
|
||||
res.end(MP4_BYTES);
|
||||
}));
|
||||
|
||||
const out = path.join(tmpDir, "dl.mp4");
|
||||
await downloadToFile(`http://127.0.0.1:${server.address().port}/f.mp4`, out);
|
||||
expect(fs.readFileSync(out)).toEqual(MP4_BYTES);
|
||||
expect(fs.existsSync(`${out}.part`)).toBe(false);
|
||||
});
|
||||
|
||||
it("follows redirects", async () => {
|
||||
({ server } = await startServer((req, res) => {
|
||||
if (req.url === "/start") {
|
||||
res.writeHead(302, { Location: `/final` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200);
|
||||
res.end(MP4_BYTES);
|
||||
}));
|
||||
|
||||
const out = path.join(tmpDir, "redir.mp4");
|
||||
await downloadToFile(`http://127.0.0.1:${server.address().port}/start`, out);
|
||||
expect(fs.readFileSync(out)).toEqual(MP4_BYTES);
|
||||
});
|
||||
|
||||
it("removes the .part file when the download fails", async () => {
|
||||
({ server } = await startServer((req, res) => {
|
||||
res.writeHead(500);
|
||||
res.end("nope");
|
||||
}));
|
||||
|
||||
const out = path.join(tmpDir, "fail.mp4");
|
||||
await expect(downloadToFile(`http://127.0.0.1:${server.address().port}/f.mp4`, out)).rejects.toThrow(/HTTP 500/);
|
||||
expect(fs.existsSync(out)).toBe(false);
|
||||
expect(fs.existsSync(`${out}.part`)).toBe(false);
|
||||
});
|
||||
});
|
||||
101
tests/unit/codex-fast-capacity.test.js
Normal file
101
tests/unit/codex-fast-capacity.test.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CodexExecutor } from "../../open-sse/executors/codex.js";
|
||||
|
||||
function streamFromText(text) {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(text));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("Codex fast tier and capacity handling", () => {
|
||||
it("maps Codex fast tier to priority and max reasoning to xhigh", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const body = executor.transformRequest("gpt-5.5", {
|
||||
model: "gpt-5.5",
|
||||
input: "hi",
|
||||
reasoning_effort: "max",
|
||||
service_tier: "fast",
|
||||
}, true, {});
|
||||
|
||||
expect(body.service_tier).toBe("priority");
|
||||
expect(body.reasoning.effort).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("uses ChatGPT workspace header fallback", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const headers = executor.buildHeaders({
|
||||
accessToken: "token",
|
||||
connectionId: "conn_1",
|
||||
providerSpecificData: { chatgptAccountId: "acct_1" },
|
||||
});
|
||||
|
||||
expect(headers["ChatGPT-Account-ID"]).toBe("acct_1");
|
||||
});
|
||||
|
||||
it("classifies 200-SSE model capacity as account fallback", async () => {
|
||||
const executor = new CodexExecutor();
|
||||
const response = new Response(streamFromText([
|
||||
"event: error",
|
||||
'data: {"error":{"message":"Selected model is at capacity. Please try a different model."}}',
|
||||
"",
|
||||
].join("\n")), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
|
||||
const peek = await executor._peekSseTransientError(response);
|
||||
expect(peek.accountFallback).toBe(true);
|
||||
expect(peek.message).toBe("Selected model is at capacity. Please try a different model.");
|
||||
});
|
||||
|
||||
it("reassembles normal SSE after peeking", async () => {
|
||||
const executor = new CodexExecutor();
|
||||
const text = [
|
||||
"event: response.output_text.delta",
|
||||
'data: {"type":"response.output_text.delta","delta":"OK"}',
|
||||
"",
|
||||
].join("\n");
|
||||
const response = new Response(streamFromText(text), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
|
||||
const peek = await executor._peekSseTransientError(response);
|
||||
expect(peek.matched).toBeNull();
|
||||
await expect(new Response(peek.replacementBody).text()).resolves.toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex reasoning normalization", () => {
|
||||
it.each([
|
||||
["gpt-5.6-sol", "max", "max"],
|
||||
["gpt-5.6-sol", "ultra", "ultra"],
|
||||
["gpt-5.6-terra", "max", "max"],
|
||||
["gpt-5.6-terra", "ultra", "ultra"],
|
||||
["gpt-5.6-luna", "max", "max"],
|
||||
["gpt-5.6-luna", "ultra", "max"],
|
||||
])("normalizes %s effort %s to %s", (model, effort, expected) => {
|
||||
const body = new CodexExecutor().transformRequest(model, {
|
||||
model,
|
||||
input: "hi",
|
||||
reasoning: { effort },
|
||||
}, true, {});
|
||||
|
||||
expect(body.reasoning.effort).toBe(expected);
|
||||
});
|
||||
|
||||
it("resolves review models before applying the reasoning matrix", () => {
|
||||
const body = new CodexExecutor().transformRequest("gpt-5.6-terra-review", {
|
||||
model: "gpt-5.6-terra-review",
|
||||
input: "hi",
|
||||
reasoning_effort: "ultra",
|
||||
}, true, {});
|
||||
|
||||
expect(body.model).toBe("gpt-5.6-terra");
|
||||
expect(body.reasoning.effort).toBe("ultra");
|
||||
});
|
||||
});
|
||||
111
tests/unit/codex-native-passthrough-thinking.test.js
Normal file
111
tests/unit/codex-native-passthrough-thinking.test.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { executeMock, forcedSSEToJsonMock } = vi.hoisted(() => ({
|
||||
executeMock: vi.fn(),
|
||||
forcedSSEToJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/executors/index.js", () => ({
|
||||
getExecutor: () => ({
|
||||
noAuth: true,
|
||||
execute: executeMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/requestLogger.js", () => ({
|
||||
createRequestLogger: async () => ({
|
||||
logClientRawRequest: vi.fn(),
|
||||
logRawRequest: vi.fn(),
|
||||
logTargetRequest: vi.fn(),
|
||||
logProviderResponse: vi.fn(),
|
||||
logConvertedResponse: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/usageDb.js", () => ({
|
||||
trackPendingRequest: vi.fn(),
|
||||
appendRequestLog: vi.fn(async () => {}),
|
||||
saveRequestDetail: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/handlers/chatCore/sseToJsonHandler.js", () => ({
|
||||
handleForcedSSEToJson: forcedSSEToJsonMock,
|
||||
}));
|
||||
|
||||
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js");
|
||||
|
||||
async function runNativeCodexRequest(model, reasoning) {
|
||||
const body = {
|
||||
model,
|
||||
input: "hello",
|
||||
stream: false,
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
};
|
||||
|
||||
await handleChatCore({
|
||||
body,
|
||||
modelInfo: { provider: "codex", model },
|
||||
credentials: { accessToken: "test-token", providerSpecificData: {} },
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
connectionId: "test-connection",
|
||||
rtkEnabled: false,
|
||||
headroomEnabled: false,
|
||||
cavemanEnabled: false,
|
||||
ponytailEnabled: false,
|
||||
pxpipeEnabled: false,
|
||||
sourceFormatOverride: "openai-responses",
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/responses",
|
||||
body,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"user-agent": "codex-cli/0.144.1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return executeMock.mock.calls.at(-1)[0].body;
|
||||
}
|
||||
|
||||
describe("native Codex passthrough thinking suffixes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
executeMock.mockResolvedValue({
|
||||
response: new Response("", { status: 200 }),
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
headers: {},
|
||||
transformedBody: null,
|
||||
});
|
||||
forcedSSEToJsonMock.mockResolvedValue({
|
||||
success: true,
|
||||
response: new Response("{}", { status: 200 }),
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards Ultra for Sol", async () => {
|
||||
const body = await runNativeCodexRequest("gpt-5.6-sol(ultra)");
|
||||
|
||||
expect(body.model).toBe("gpt-5.6-sol");
|
||||
expect(body.reasoning).toEqual({ effort: "ultra" });
|
||||
});
|
||||
|
||||
it("converts unsupported Luna Ultra to Max without dropping reasoning metadata", async () => {
|
||||
const body = await runNativeCodexRequest("gpt-5.6-luna(ultra)", {
|
||||
effort: "low",
|
||||
summary: "detailed",
|
||||
});
|
||||
|
||||
expect(body.model).toBe("gpt-5.6-luna");
|
||||
expect(body.reasoning).toEqual({ effort: "max", summary: "detailed" });
|
||||
});
|
||||
|
||||
it("forwards Ultra through a Terra review alias", async () => {
|
||||
const body = await runNativeCodexRequest("gpt-5.6-terra-review(ultra)", {
|
||||
effort: "low",
|
||||
});
|
||||
|
||||
expect(body.model).toBe("gpt-5.6-terra");
|
||||
expect(body.reasoning).toEqual({ effort: "ultra" });
|
||||
});
|
||||
});
|
||||
@@ -151,8 +151,8 @@ describe("Codex Refresh Token", () => {
|
||||
expect(getRefreshLeadMs("codex")).toBe(5 * 24 * 60 * 60 * 1000); // 5 days
|
||||
expect(getRefreshLeadMs("claude")).toBe(4 * 60 * 60 * 1000); // 4 hours
|
||||
expect(getRefreshLeadMs("iflow")).toBe(24 * 60 * 60 * 1000); // 24 hours
|
||||
expect(getRefreshLeadMs("qwen")).toBe(20 * 60 * 1000); // 20 minutes
|
||||
expect(getRefreshLeadMs("kimi-coding")).toBe(5 * 60 * 1000); // 5 minutes
|
||||
expect(getRefreshLeadMs("kimi")).toBe(5 * 60 * 1000); // 5 minutes
|
||||
expect(getRefreshLeadMs("kimi-coding")).toBe(5 * 60 * 1000); // legacy alias
|
||||
expect(getRefreshLeadMs("antigravity")).toBe(5 * 60 * 1000); // 5 minutes
|
||||
});
|
||||
|
||||
|
||||
80
tests/unit/continuity-strip.test.js
Normal file
80
tests/unit/continuity-strip.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
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 { stripContinuityFields } = await import("../../open-sse/handlers/chatCore.js");
|
||||
const { openaiResponsesToOpenAIRequest } = await import("../../open-sse/translator/request/openai-responses.js");
|
||||
|
||||
// Multi-turn Codex-style Responses input: reasoning item carrying a
|
||||
// store=false encrypted_content continuity blob between tool turns.
|
||||
const makeResponsesBody = () => ({
|
||||
model: "x",
|
||||
instructions: "You are Codex.",
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoning: { effort: "low", summary: "auto" },
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "Run: echo hi" }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "thinking" }],
|
||||
encrypted_content: "B".repeat(5000)
|
||||
},
|
||||
{ type: "function_call", id: "fc_1", call_id: "call_1", name: "shell", arguments: "{\"command\":\"echo hi\"}" },
|
||||
{ type: "function_call_output", call_id: "call_1", output: "hi" },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Now run: echo bye" }] }
|
||||
],
|
||||
tools: [{
|
||||
type: "function",
|
||||
name: "shell",
|
||||
description: "Run a shell command",
|
||||
parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] }
|
||||
}]
|
||||
});
|
||||
|
||||
describe("stripContinuityFields (outbound boundary)", () => {
|
||||
it("removes continuity blobs from assistant messages", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: null, reasoning_content: "thinking",
|
||||
encrypted_content: "B".repeat(500), reasoning_encrypted_content: "alias",
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "shell", arguments: "{}" } }] }
|
||||
]
|
||||
};
|
||||
stripContinuityFields(body);
|
||||
const assistant = body.messages[1];
|
||||
expect(assistant).not.toHaveProperty("encrypted_content");
|
||||
expect(assistant).not.toHaveProperty("reasoning_encrypted_content");
|
||||
// legitimate fields survive
|
||||
expect(assistant.reasoning_content).toBe("thinking");
|
||||
expect(assistant.tool_calls[0].function.name).toBe("shell");
|
||||
});
|
||||
|
||||
it("is a no-op for bodies without a messages array", () => {
|
||||
const body = { input: [], instructions: "x" };
|
||||
expect(stripContinuityFields(body)).toBe(body);
|
||||
expect(stripContinuityFields(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("end-to-end: Responses multi-turn translation stripped before dispatch", () => {
|
||||
const translated = openaiResponsesToOpenAIRequest("x", makeResponsesBody(), false, {});
|
||||
// The translator stashes the blob for internal round-trip symmetry...
|
||||
const assistantBefore = translated.messages.find((m) => m.role === "assistant");
|
||||
expect(assistantBefore.encrypted_content).toBe("B".repeat(5000));
|
||||
// ...and the outbound boundary removes it before it reaches any upstream.
|
||||
stripContinuityFields(translated);
|
||||
expect(JSON.stringify(translated)).not.toContain("encrypted_content");
|
||||
expect(JSON.stringify(translated)).not.toContain("B".repeat(100));
|
||||
const assistant = translated.messages.find((m) => m.role === "assistant");
|
||||
expect(assistant.reasoning_content).toContain("thinking");
|
||||
expect(assistant.tool_calls[0].function.name).toBe("shell");
|
||||
const roles = translated.messages.map((m) => m.role);
|
||||
expect(roles).toEqual(["system", "user", "assistant", "tool", "user"]);
|
||||
});
|
||||
});
|
||||
84
tests/unit/count-tokens.test.js
Normal file
84
tests/unit/count-tokens.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { POST } from "../../src/app/api/v1/messages/count_tokens/route.js";
|
||||
|
||||
async function countTokens(body) {
|
||||
const response = await POST(new Request("https://9router.local/v1/messages/count_tokens", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
describe("Anthropic count_tokens estimator", () => {
|
||||
it("preserves the existing plain text estimate", async () => {
|
||||
const result = await countTokens({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "hello world",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.input_tokens).toBe(3);
|
||||
});
|
||||
|
||||
it("counts tool and thinking content blocks that carry context", async () => {
|
||||
const result = await countTokens({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_01",
|
||||
name: "Read",
|
||||
input: { file_path: "/tmp/example.txt" },
|
||||
},
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "Need to inspect the file before answering.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_01",
|
||||
content: "line1 line2 line3 some file content here",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.input_tokens).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("counts system prompts and tool definitions", async () => {
|
||||
const result = await countTokens({
|
||||
system: "You are a coding assistant.",
|
||||
tools: [
|
||||
{
|
||||
name: "Read",
|
||||
description: "Read a file",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file_path: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.input_tokens).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
114
tests/unit/cursor-agent-exec-request.test.js
Normal file
114
tests/unit/cursor-agent-exec-request.test.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { CursorExecutor } from "../../open-sse/executors/cursor.js";
|
||||
import { encodeField, wrapConnectRPCFrame } from "../../open-sse/utils/cursorProtobuf.js";
|
||||
|
||||
const LEN = 2;
|
||||
|
||||
// agent.v1.AgentServerMessage.exec_request (field 2) carrying one ExecServerMessage variant.
|
||||
function execRequestFrame(execField) {
|
||||
const execServerMessage = Buffer.from(encodeField(execField, LEN, new Uint8Array()));
|
||||
return Buffer.from(wrapConnectRPCFrame(encodeField(2, LEN, execServerMessage)));
|
||||
}
|
||||
|
||||
// agent.v1.AgentServerMessage.interaction_update (field 1) → text delta.
|
||||
function textFrame(text) {
|
||||
const textPart = Buffer.from(encodeField(1, LEN, text));
|
||||
const update = Buffer.from(encodeField(1, LEN, textPart));
|
||||
return Buffer.from(wrapConnectRPCFrame(encodeField(1, LEN, update)));
|
||||
}
|
||||
|
||||
function stubAgentSession(executor, frames) {
|
||||
const written = [];
|
||||
const queue = [...frames];
|
||||
executor.openAgentHttp2Stream = () => ({
|
||||
responseHeaders: Promise.resolve({ ":status": 200 }),
|
||||
write: (frame) => written.push(Buffer.from(frame)),
|
||||
end() {},
|
||||
close() {},
|
||||
async read() {
|
||||
if (!queue.length) return { value: undefined, done: true };
|
||||
return { value: queue.shift(), done: false };
|
||||
},
|
||||
});
|
||||
return written;
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
accessToken: "test-token",
|
||||
providerSpecificData: { machineId: "a".repeat(64) },
|
||||
};
|
||||
|
||||
function parseSSE(text) {
|
||||
return text
|
||||
.split("\n\n")
|
||||
.filter((chunk) => chunk.startsWith("data: "))
|
||||
.map((chunk) => chunk.slice("data: ".length))
|
||||
.filter((data) => data !== "[DONE]")
|
||||
.map((data) => JSON.parse(data));
|
||||
}
|
||||
|
||||
async function runAgent({ frames, stream }) {
|
||||
const executor = new CursorExecutor();
|
||||
const written = stubAgentSession(executor, frames);
|
||||
const result = await executor.executeAgent({
|
||||
model: "gpt-5.2",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream,
|
||||
credentials,
|
||||
});
|
||||
return { result, written };
|
||||
}
|
||||
|
||||
describe("CursorExecutor AgentService exec_request handling", () => {
|
||||
it("acknowledges a request-context exec request without ending the turn", async () => {
|
||||
const { result, written } = await runAgent({
|
||||
frames: [execRequestFrame(10), textFrame("hello")],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(written.length).toBe(2); // run frame + request-context reply
|
||||
const events = parseSSE(await result.response.text());
|
||||
const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join("");
|
||||
expect(content).toBe("hello");
|
||||
});
|
||||
|
||||
it("does not render an unsupported exec request as assistant content", async () => {
|
||||
const { result } = await runAgent({
|
||||
frames: [textFrame("partial answer"), execRequestFrame(2)],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const body = await result.response.text();
|
||||
expect(body).not.toContain("unsupported IDE tool\\n");
|
||||
const events = parseSSE(body);
|
||||
const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join("");
|
||||
expect(content).toBe("partial answer");
|
||||
|
||||
const errorEvent = events.find((e) => e.error);
|
||||
expect(errorEvent?.error?.message).toContain("unsupported IDE tool");
|
||||
expect(events.some((e) => e.choices?.[0]?.finish_reason === "stop")).toBe(false);
|
||||
});
|
||||
|
||||
it("drops frames batched behind an unsupported exec request in the same read", async () => {
|
||||
const { result } = await runAgent({
|
||||
frames: [Buffer.concat([execRequestFrame(2), textFrame("late")])],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const body = await result.response.text();
|
||||
expect(body).toContain("unsupported IDE tool");
|
||||
expect(body).not.toContain("late");
|
||||
});
|
||||
|
||||
it("returns a non-200 error body for an unsupported exec request when not streaming", async () => {
|
||||
const { result } = await runAgent({
|
||||
frames: [execRequestFrame(11)],
|
||||
stream: false,
|
||||
});
|
||||
|
||||
expect(result.response.status).not.toBe(200);
|
||||
const payload = await result.response.json();
|
||||
expect(payload.error.message).toContain("unsupported IDE tool");
|
||||
});
|
||||
});
|
||||
282
tests/unit/cursor-agent-proto.test.js
Normal file
282
tests/unit/cursor-agent-proto.test.js
Normal file
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decodeMessage,
|
||||
encodeField,
|
||||
encodeAgentValue,
|
||||
decodeAgentValue,
|
||||
encodeMcpToolDefinition,
|
||||
encodeMcpTools,
|
||||
decodeMcpArgs,
|
||||
encodeMcpResultSuccess,
|
||||
encodeMcpResultError,
|
||||
encodeMcpResultToolNotFound,
|
||||
} from "../../open-sse/utils/cursorProtobuf.js";
|
||||
import {
|
||||
isAgentCapableRequest,
|
||||
buildAgentRunFrame,
|
||||
} from "../../open-sse/executors/cursor.js";
|
||||
|
||||
// AgentService (agent.v1) codec tests — validate the production implementation
|
||||
// in cursorProtobuf.js + the executor's frame builders. Pure round-trip, no network.
|
||||
// Field numbers verified against Cursor's agent.proto (extracted via @oh-my-pi).
|
||||
|
||||
const LEN = 2;
|
||||
// McpArgs.args map entry { field1: key, field2: Value }
|
||||
const entry = (k, v) => Buffer.concat([
|
||||
Buffer.from(encodeField(2, LEN,
|
||||
Buffer.concat([Buffer.from(encodeField(1, LEN, k)), Buffer.from(encodeField(2, LEN, encodeAgentValue(v)))])
|
||||
)),
|
||||
]);
|
||||
|
||||
describe("Cursor AgentService codec (cursorProtobuf.js)", () => {
|
||||
describe("google.protobuf.Value round-trip", () => {
|
||||
const cases = [
|
||||
["null", null],
|
||||
["bool true", true],
|
||||
["bool false", false],
|
||||
["string", "hello"],
|
||||
["integer", 42],
|
||||
["float", 3.14],
|
||||
["empty object", {}],
|
||||
["flat object", { a: 1, b: "x", c: true }],
|
||||
["nested object", { outer: { inner: [1, 2, "three"] } }],
|
||||
["array of mixed", [1, "two", false, null]],
|
||||
["deeply nested", { a: { b: { c: { d: 1 } } } }],
|
||||
];
|
||||
for (const [label, value] of cases) {
|
||||
it(`encodes/decodes ${label}`, () => {
|
||||
expect(decodeAgentValue(encodeAgentValue(value))).toEqual(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("McpToolDefinition", () => {
|
||||
it("encodes name, description, input_schema (Value), provider, tool_name", () => {
|
||||
const schema = { type: "object", properties: { city: { type: "string" } }, required: ["city"] };
|
||||
const def = encodeMcpToolDefinition({ function: { name: "get_weather", description: "Get weather", parameters: schema } });
|
||||
const msg = decodeMessage(def);
|
||||
expect(Buffer.from(msg.get(1)[0].value).toString("utf8")).toBe("get_weather");
|
||||
expect(Buffer.from(msg.get(2)[0].value).toString("utf8")).toBe("Get weather");
|
||||
expect(Buffer.from(msg.get(4)[0].value).toString("utf8")).toBe("9router");
|
||||
expect(Buffer.from(msg.get(5)[0].value).toString("utf8")).toBe("get_weather");
|
||||
expect(decodeAgentValue(msg.get(3)[0].value)).toEqual(schema);
|
||||
});
|
||||
|
||||
it("preserves nested JSON-schema types", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "search query" },
|
||||
opts: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["query"],
|
||||
};
|
||||
const def = encodeMcpToolDefinition({ function: { name: "search", parameters: schema } });
|
||||
const msg = decodeMessage(def);
|
||||
expect(decodeAgentValue(msg.get(3)[0].value)).toEqual(schema);
|
||||
});
|
||||
|
||||
it("accepts flat tool shape (no .function wrapper)", () => {
|
||||
const def = encodeMcpToolDefinition({ name: "noop", description: "d", inputSchema: { type: "object" } });
|
||||
const msg = decodeMessage(def);
|
||||
expect(Buffer.from(msg.get(1)[0].value).toString("utf8")).toBe("noop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeMcpTools", () => {
|
||||
it("produces empty bytes for no tools", () => {
|
||||
expect(encodeMcpTools([]).length).toBe(0);
|
||||
expect(encodeMcpTools().length).toBe(0);
|
||||
});
|
||||
|
||||
it("wraps multiple tool defs as repeated field 1", () => {
|
||||
const tools = [
|
||||
{ function: { name: "get_weather", parameters: { type: "object" } } },
|
||||
{ function: { name: "calculate", parameters: { type: "object" } } },
|
||||
];
|
||||
const mcpTools = encodeMcpTools(tools);
|
||||
const inner = decodeMessage(mcpTools);
|
||||
expect(inner.get(1).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpArgs decode", () => {
|
||||
it("decodes name, toolName, toolCallId, and typed args map", () => {
|
||||
const argsBytes = Buffer.concat([
|
||||
entry("city", "Hanoi"),
|
||||
entry("count", 5),
|
||||
entry("flag", true),
|
||||
entry("nested", { a: [1, 2] }),
|
||||
]);
|
||||
const mcpArgs = Buffer.concat([
|
||||
Buffer.from(encodeField(1, LEN, "get_weather")),
|
||||
argsBytes,
|
||||
Buffer.from(encodeField(3, LEN, "call_abc")),
|
||||
Buffer.from(encodeField(5, LEN, "get_weather")),
|
||||
]);
|
||||
const decoded = decodeMcpArgs(mcpArgs);
|
||||
expect(decoded.name).toBe("get_weather");
|
||||
expect(decoded.toolName).toBe("get_weather");
|
||||
expect(decoded.toolCallId).toBe("call_abc");
|
||||
expect(decoded.args).toEqual({ city: "Hanoi", count: 5, flag: true, nested: { a: [1, 2] } });
|
||||
});
|
||||
|
||||
it("handles empty args map", () => {
|
||||
const mcpArgs = Buffer.concat([
|
||||
Buffer.from(encodeField(1, LEN, "noop")),
|
||||
Buffer.from(encodeField(5, LEN, "noop")),
|
||||
]);
|
||||
expect(decodeMcpArgs(mcpArgs).args).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpResult success", () => {
|
||||
it("builds success with single text content", () => {
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ['{"temp":32}'], isError: false });
|
||||
const msg = decodeMessage(bytes); // McpResult level
|
||||
expect(msg.has(1)).toBe(true); // success variant
|
||||
const success = decodeMessage(msg.get(1)[0].value);
|
||||
expect(success.get(1).length).toBe(1);
|
||||
expect(success.get(2)[0].value).toBe(0); // is_error=false
|
||||
const item = decodeMessage(success.get(1)[0].value);
|
||||
const textContent = decodeMessage(item.get(1)[0].value);
|
||||
expect(Buffer.from(textContent.get(1)[0].value).toString("utf8")).toBe('{"temp":32}');
|
||||
});
|
||||
|
||||
it("builds success with multiple text items", () => {
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ["line1", "line2"] });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
expect(success.get(1).length).toBe(2);
|
||||
});
|
||||
|
||||
it("marks is_error=true", () => {
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ["fail"], isError: true });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
expect(success.get(2)[0].value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpResult image content", () => {
|
||||
it("builds image item with raw bytes + mime type", () => {
|
||||
const imgBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const bytes = encodeMcpResultSuccess({ imageItems: [{ data: imgBytes, mimeType: "image/png" }] });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
const item = decodeMessage(success.get(1)[0].value);
|
||||
expect(item.has(2)).toBe(true); // image variant
|
||||
const img = decodeMessage(item.get(2)[0].value);
|
||||
expect(Buffer.from(img.get(1)[0].value)).toEqual(Buffer.from(imgBytes));
|
||||
expect(Buffer.from(img.get(2)[0].value).toString("utf8")).toBe("image/png");
|
||||
});
|
||||
|
||||
it("builds mixed text + image content", () => {
|
||||
const imgBytes = new Uint8Array([1, 2, 3]);
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ["see image"], imageItems: [{ data: imgBytes, mimeType: "image/jpeg" }] });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
expect(success.get(1).length).toBe(2);
|
||||
expect(decodeMessage(success.get(1)[0].value).has(1)).toBe(true); // text
|
||||
expect(decodeMessage(success.get(1)[1].value).has(2)).toBe(true); // image
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpResult error / toolNotFound", () => {
|
||||
it("builds error result (field 2)", () => {
|
||||
const bytes = encodeMcpResultError("tool crashed");
|
||||
const msg = decodeMessage(bytes);
|
||||
expect(msg.has(2)).toBe(true);
|
||||
const err = decodeMessage(msg.get(2)[0].value);
|
||||
expect(Buffer.from(err.get(1)[0].value).toString("utf8")).toBe("tool crashed");
|
||||
});
|
||||
|
||||
it("builds toolNotFound result (field 5)", () => {
|
||||
const bytes = encodeMcpResultToolNotFound("missing_tool");
|
||||
const msg = decodeMessage(bytes);
|
||||
expect(msg.has(5)).toBe(true);
|
||||
const tnf = decodeMessage(msg.get(5)[0].value);
|
||||
expect(Buffer.from(tnf.get(1)[0].value).toString("utf8")).toBe("missing_tool");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cursor AgentService executor helpers (cursor.js)", () => {
|
||||
describe("isAgentCapableRequest", () => {
|
||||
it("accepts plain text content", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: "hi" }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts array text content", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts request with tools declared", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: "hi" }], tools: [{ function: { name: "t" } }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts history with assistant tool_calls + tool results", () => {
|
||||
expect(isAgentCapableRequest({
|
||||
messages: [
|
||||
{ role: "user", content: "weather?" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: "{}" } }] },
|
||||
{ role: "tool", tool_call_id: "c1", content: "sunny" },
|
||||
{ role: "user", content: "thanks" },
|
||||
],
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-text (image) content", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: [{ type: "image_url" }] }] })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing messages", () => {
|
||||
expect(isAgentCapableRequest({})).toBe(false);
|
||||
expect(isAgentCapableRequest(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAgentRunFrame", () => {
|
||||
// buildAgentRunFrame returns a wrapped Connect-RPC frame (5-byte header + AgentClientMessage).
|
||||
const unwrap = (frame) => frame.subarray(5);
|
||||
|
||||
it("encodes a text-only run request with system + model", () => {
|
||||
const frame = unwrap(buildAgentRunFrame(
|
||||
[{ role: "system", content: "be brief" }, { role: "user", content: "hi" }],
|
||||
"gpt-5.2",
|
||||
));
|
||||
const clientMsg = decodeMessage(frame);
|
||||
expect(clientMsg.has(1)).toBe(true); // run_request
|
||||
const run = decodeMessage(clientMsg.get(1)[0].value);
|
||||
expect(run.has(2)).toBe(true); // action
|
||||
expect(run.has(9)).toBe(true); // requested_model
|
||||
});
|
||||
|
||||
it("encodes mcp_tools (field 4) when tools are provided", () => {
|
||||
const tools = [{ function: { name: "get_weather", description: "weather", parameters: { type: "object", properties: { city: { type: "string" } } } } }];
|
||||
const frame = unwrap(buildAgentRunFrame([{ role: "user", content: "weather?" }], "gpt-5.2", tools));
|
||||
const run = decodeMessage(decodeMessage(frame).get(1)[0].value);
|
||||
expect(run.has(4)).toBe(true); // mcp_tools
|
||||
const mcpTools = decodeMessage(run.get(4)[0].value);
|
||||
expect(mcpTools.get(1).length).toBe(1);
|
||||
});
|
||||
|
||||
it("omits mcp_tools when no tools provided", () => {
|
||||
const frame = unwrap(buildAgentRunFrame([{ role: "user", content: "hi" }], "gpt-5.2", []));
|
||||
const run = decodeMessage(decodeMessage(frame).get(1)[0].value);
|
||||
expect(run.has(4)).toBe(false);
|
||||
});
|
||||
|
||||
it("encodes conversation_history from prior turns including tool calls/results", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "weather in Tokyo?" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } }] },
|
||||
{ role: "tool", tool_call_id: "c1", content: "18C cloudy" },
|
||||
{ role: "user", content: "thanks" },
|
||||
];
|
||||
const frame = unwrap(buildAgentRunFrame(messages, "gpt-5.2", []));
|
||||
const run = decodeMessage(decodeMessage(frame).get(1)[0].value);
|
||||
const action = decodeMessage(run.get(2)[0].value);
|
||||
const userAction = decodeMessage(action.get(1)[0].value);
|
||||
expect(userAction.has(7)).toBe(true); // conversation_history (field 7)
|
||||
const history = decodeMessage(userAction.get(7)[0].value);
|
||||
expect(history.get(1).length).toBeGreaterThanOrEqual(2); // prior turns
|
||||
});
|
||||
});
|
||||
});
|
||||
103
tests/unit/cursor-models.test.js
Normal file
103
tests/unit/cursor-models.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearCursorModelCache,
|
||||
parseCursorUsableModels,
|
||||
resolveCursorModels,
|
||||
} from "../../open-sse/services/cursorModels.js";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
function varint(value) {
|
||||
const bytes = [];
|
||||
while (value >= 0x80) {
|
||||
bytes.push((value & 0x7f) | 0x80);
|
||||
value >>>= 7;
|
||||
}
|
||||
bytes.push(value);
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
function field(fieldNumber, value) {
|
||||
return Uint8Array.from([(fieldNumber << 3) | 2, ...varint(value.length), ...value]);
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function concat(...parts) {
|
||||
const size = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const result = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function model(id, name) {
|
||||
return field(1, concat(field(1, text(id)), field(4, text(name))));
|
||||
}
|
||||
|
||||
describe("Cursor live model catalog", () => {
|
||||
beforeEach(() => {
|
||||
clearCursorModelCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
clearCursorModelCache();
|
||||
});
|
||||
|
||||
it("decodes the GetUsableModels protobuf response", () => {
|
||||
const payload = concat(
|
||||
model("default", "Auto"),
|
||||
model("gpt-5.3-codex", "GPT 5.3 Codex"),
|
||||
model("gpt-5.3-codex", "Duplicate"),
|
||||
);
|
||||
|
||||
expect(parseCursorUsableModels(payload)).toEqual([
|
||||
{ id: "default", name: "Auto" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("fetches the account-specific catalog and caches it", async () => {
|
||||
const payload = concat(model("claude-4.6-opus", "Claude 4.6 Opus"));
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(payload, { status: 200 }));
|
||||
const credentials = {
|
||||
accessToken: "cursor-token",
|
||||
providerSpecificData: { machineId: "machine-id" },
|
||||
};
|
||||
|
||||
await expect(resolveCursorModels(credentials)).resolves.toEqual({
|
||||
models: [{ id: "claude-4.6-opus", name: "Claude 4.6 Opus" }],
|
||||
});
|
||||
await expect(resolveCursorModels(credentials)).resolves.toEqual({
|
||||
models: [{ id: "claude-4.6-opus", name: "Claude 4.6 Opus" }],
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://agent.api5.cursor.sh/agent.v1.AgentService/GetUsableModels",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.any(Uint8Array),
|
||||
headers: expect.objectContaining({
|
||||
"content-type": "application/proto",
|
||||
accept: "application/proto",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails open when the Cursor catalog request fails", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response("no", { status: 403 }));
|
||||
|
||||
await expect(resolveCursorModels({
|
||||
accessToken: "cursor-token",
|
||||
providerSpecificData: { machineId: "machine-id" },
|
||||
})).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
65
tests/unit/custom-server-h2c.test.cjs
Normal file
65
tests/unit/custom-server-h2c.test.cjs
Normal file
@@ -0,0 +1,65 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const test = require("node:test");
|
||||
|
||||
test("serves h2c POST requests as HTTP/1.1", async () => {
|
||||
const originalCreateServer = http.createServer;
|
||||
delete require.cache[require.resolve("../../custom-server.js")];
|
||||
require("../../custom-server.js");
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
assert.equal(req.url, "/v1/chat/completions");
|
||||
assert.equal(req.headers.upgrade, undefined);
|
||||
assert.equal(req.headers["http2-settings"], undefined);
|
||||
assert.equal(req.headers.connection, "close");
|
||||
const body = [];
|
||||
for await (const chunk of req) body.push(chunk);
|
||||
assert.equal(Buffer.concat(body).toString("utf8"), '{"model":"test","stream":true}');
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.end("data: [DONE]\n\n");
|
||||
});
|
||||
server.on("upgrade", (_req, socket) => socket.destroy());
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = server.address().port;
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port }, () => {
|
||||
const body = '{"model":"test","stream":true}';
|
||||
socket.write([
|
||||
"POST /v1/chat/completions HTTP/1.1",
|
||||
`Host: 127.0.0.1:${port}`,
|
||||
"Connection: Upgrade, HTTP2-Settings",
|
||||
"Upgrade: h2c",
|
||||
"HTTP2-Settings: AAEAAEAAAAIAAAAAAAMAAAAAAAQBAAAAAAUAAEAAAAYABgAA",
|
||||
`Content-Length: ${Buffer.byteLength(body)}`,
|
||||
"Content-Type: application/json",
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"));
|
||||
setImmediate(() => socket.write(body));
|
||||
});
|
||||
socket.setTimeout(2_000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error("h2c fallback response timed out"));
|
||||
});
|
||||
socket.on("data", (chunk) => chunks.push(chunk));
|
||||
socket.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
socket.on("error", reject);
|
||||
});
|
||||
|
||||
assert.match(response, /^HTTP\/1\.1 200 OK\r\n/);
|
||||
assert.match(response, /\r\nContent-Type: text\/event-stream\r\n/i);
|
||||
assert.match(response, /\r\nConnection: close\r\n/i);
|
||||
assert.match(response, /\r\n\r\ndata: \[DONE\]\n\n$/);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
http.createServer = originalCreateServer;
|
||||
}
|
||||
});
|
||||
86
tests/unit/custom-server-peer-headers.test.js
Normal file
86
tests/unit/custom-server-peer-headers.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -101,6 +101,19 @@ describe("DB SQLite layer — public API parity", () => {
|
||||
expect(back.providerSpecificData).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("providerConnections: GitHub OAuth uses account identity as fallback name", async () => {
|
||||
const c = await sqliteDb.createProviderConnection({
|
||||
provider: "github",
|
||||
authType: "oauth",
|
||||
accessToken: "tok",
|
||||
providerSpecificData: { githubLogin: "octocat" },
|
||||
});
|
||||
|
||||
expect(c.name).toBe("octocat");
|
||||
const back = await sqliteDb.getProviderConnectionById(c.id);
|
||||
expect(back.name).toBe("octocat");
|
||||
});
|
||||
|
||||
it("providerNodes: CRUD", async () => {
|
||||
const n = await sqliteDb.createProviderNode({ type: "openai", name: "Test", baseUrl: "https://api.test", apiType: "openai" });
|
||||
expect(n.id).toBeDefined();
|
||||
|
||||
149
tests/unit/deepseek-usage.test.js
Normal file
149
tests/unit/deepseek-usage.test.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.js";
|
||||
import {
|
||||
USAGE_SUPPORTED_PROVIDERS,
|
||||
USAGE_APIKEY_PROVIDERS,
|
||||
} from "../../src/shared/constants/providers.js";
|
||||
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
const BALANCE_URL = "https://api.deepseek.com/user/balance";
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const ACTIVE_BALANCE = {
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "12.50",
|
||||
granted_balance: "2.50",
|
||||
topped_up_balance: "10.00",
|
||||
},
|
||||
{
|
||||
currency: "CNY",
|
||||
total_balance: "0.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "0.00",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("deepseek registry usage flags", () => {
|
||||
it("is listed for apikey quota dashboard", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("deepseek");
|
||||
expect(USAGE_APIKEY_PROVIDERS).toContain("deepseek");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsageForProvider(deepseek)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("GETs /user/balance with Bearer apiKey", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_BALANCE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "deepseek",
|
||||
apiKey: "sk-ds-test",
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("DeepSeek");
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = proxyAwareFetch.mock.calls[0];
|
||||
expect(url).toBe(BALANCE_URL);
|
||||
expect(opts.method).toBe("GET");
|
||||
expect(opts.headers.Authorization).toBe("Bearer sk-ds-test");
|
||||
});
|
||||
|
||||
it("maps balances without absolute remaining (UI treats remaining as %)", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_BALANCE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "deepseek",
|
||||
apiKey: "sk-ds-test",
|
||||
});
|
||||
|
||||
expect(usage.quotas["Balance (USD)"]).toMatchObject({
|
||||
used: 0,
|
||||
total: 12.5,
|
||||
remainingPercentage: 100,
|
||||
});
|
||||
expect(usage.quotas["Balance (USD)"].remaining).toBeUndefined();
|
||||
// Zero CNY still listed so user sees currency row
|
||||
expect(usage.quotas["Balance (CNY)"]).toMatchObject({
|
||||
used: 0,
|
||||
total: 0,
|
||||
remainingPercentage: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks plan unavailable when is_available false", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
is_available: false,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "0",
|
||||
granted_balance: "0",
|
||||
topped_up_balance: "0",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "deepseek",
|
||||
apiKey: "sk-ds-test",
|
||||
});
|
||||
|
||||
expect(usage.plan).toMatch(/insufficient|unavailable/i);
|
||||
expect(usage.quotas["Balance (USD)"].remainingPercentage).toBe(0);
|
||||
});
|
||||
|
||||
it("returns message on missing key / 401", async () => {
|
||||
const missing = await getUsageForProvider({ provider: "deepseek" });
|
||||
expect(missing.message).toMatch(/api key/i);
|
||||
expect(proxyAwareFetch).not.toHaveBeenCalled();
|
||||
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "no" }, 401));
|
||||
const auth = await getUsageForProvider({
|
||||
provider: "deepseek",
|
||||
apiKey: "bad",
|
||||
});
|
||||
expect(auth.message).toMatch(/auth|key|401/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuotaData(deepseek)", () => {
|
||||
it("forwards remainingPercentage for balance rows", () => {
|
||||
const rows = parseQuotaData("deepseek", {
|
||||
plan: "DeepSeek",
|
||||
quotas: {
|
||||
"Balance (USD)": {
|
||||
used: 0,
|
||||
total: 12.5,
|
||||
remainingPercentage: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: "Balance (USD)",
|
||||
total: 12.5,
|
||||
remainingPercentage: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
437
tests/unit/devin-cli-executor.test.js
Normal file
437
tests/unit/devin-cli-executor.test.js
Normal file
@@ -0,0 +1,437 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import os from "node:os";
|
||||
|
||||
// `vi.hoisted` runs before the mocked module is evaluated, so the factory can
|
||||
// safely reference the mock fn.
|
||||
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: (...args) => spawnMock(...args),
|
||||
}));
|
||||
|
||||
const { default: DevinCliExecutor } = await import("open-sse/executors/devin-cli.js");
|
||||
|
||||
// Fake devin ACP subprocess. Mirrors the real CLI's session/new validation:
|
||||
// it requires `mcpServers` to be an array, otherwise returns -32602 — this is
|
||||
// the exact error the dashboard "test" button hit ("Invalid params").
|
||||
function makeFakeChild() {
|
||||
const child = new EventEmitter();
|
||||
child.writes = [];
|
||||
child.stdin = new EventEmitter();
|
||||
child.stdin.destroyed = false;
|
||||
child.stdin.write = (data) => {
|
||||
child.writes.push(String(data));
|
||||
try {
|
||||
const msg = JSON.parse(String(data).trim());
|
||||
handle(msg);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return true;
|
||||
};
|
||||
child.stdin.end = () => {
|
||||
child.stdin.destroyed = true;
|
||||
};
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.killed = false;
|
||||
child.kill = () => {
|
||||
child.killed = true;
|
||||
};
|
||||
|
||||
const send = (obj) =>
|
||||
child.stdout.emit("data", Buffer.from(JSON.stringify(obj) + "\n"));
|
||||
|
||||
function handle(msg) {
|
||||
if (msg.method === "initialize") {
|
||||
send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
|
||||
} else if (msg.method === "session/new") {
|
||||
// Mirror devin 3000.2.x: `mcpServers` is a required sequence.
|
||||
if (Array.isArray(msg.params && msg.params.mcpServers)) {
|
||||
send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "fake-session" } });
|
||||
} else if (!msg.params || msg.params.mcpServers === undefined) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32602, message: "Invalid params", data: { error: "missing field `mcpServers`" } },
|
||||
});
|
||||
} else {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32602, message: "Invalid params", data: { error: "invalid type: map, expected a sequence" } },
|
||||
});
|
||||
}
|
||||
} else if (msg.method === "session/prompt") {
|
||||
// devin 3000.2.x requires `prompt` (a sequence), not `content`.
|
||||
if (Array.isArray(msg.params && msg.params.prompt)) {
|
||||
// Agent requests permission to run a tool before replying.
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: 777,
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
sessionId: "fake-session",
|
||||
options: [
|
||||
{ optionId: "allow-once", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
},
|
||||
});
|
||||
// New ACP shape: streaming via session/update with params.update.sessionUpdate.
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: { sessionId: "fake-session", update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "(thinking)" } } },
|
||||
});
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: { sessionId: "fake-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hello world" } } },
|
||||
});
|
||||
// Stop signal: _cognition.ai/agent_stopped notification.
|
||||
send({ jsonrpc: "2.0", method: "_cognition.ai/agent_stopped", params: { cause: "complete" } });
|
||||
} else {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32602, message: "Invalid params", data: { error: "missing field `prompt`" } },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
async function runExecute(credentials = {}) {
|
||||
const child = makeFakeChild();
|
||||
spawnMock.mockImplementation((bin, args, opts) => {
|
||||
child.bin = bin;
|
||||
child.args = args;
|
||||
child.opts = opts;
|
||||
return child;
|
||||
});
|
||||
const exec = new DevinCliExecutor();
|
||||
const { response } = await exec.execute({
|
||||
model: "swe-1.6-fast",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
credentials,
|
||||
log: { info() {}, debug() {} },
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
let acc = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
acc += new TextDecoder().decode(value);
|
||||
}
|
||||
return { acc, child };
|
||||
}
|
||||
|
||||
describe("DevinCliExecutor ACP session/new", () => {
|
||||
it("sends session/new with mcpServers as an array", async () => {
|
||||
const { child } = await runExecute();
|
||||
const writes = child.writes.map((w) => JSON.parse(w.trim()));
|
||||
const newMsg = writes.find((m) => m.method === "session/new");
|
||||
expect(newMsg).toBeTruthy();
|
||||
expect(Array.isArray(newMsg.params.mcpServers)).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults session/new cwd to os.tmpdir when request has no workspace cwd", async () => {
|
||||
const { child } = await runExecute();
|
||||
const writes = child.writes.map((w) => JSON.parse(w.trim()));
|
||||
const newMsg = writes.find((m) => m.method === "session/new");
|
||||
expect(newMsg.params.cwd).toBe(os.tmpdir());
|
||||
});
|
||||
|
||||
it("uses client <cwd> env context for session/new and spawn", async () => {
|
||||
const child = makeFakeChild();
|
||||
spawnMock.mockImplementation((bin, args, opts) => {
|
||||
child.args = args;
|
||||
child.opts = opts;
|
||||
return child;
|
||||
});
|
||||
const workspace = os.tmpdir(); // known existing absolute dir
|
||||
const exec = new DevinCliExecutor();
|
||||
const { response } = await exec.execute({
|
||||
model: "swe-1.6-fast",
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `<environment_context>\n <cwd>${workspace}</cwd>\n</environment_context>\nhi`,
|
||||
},
|
||||
],
|
||||
},
|
||||
credentials: {},
|
||||
log: { info() {}, debug() {} },
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
expect(child.opts.cwd).toBe(workspace);
|
||||
const writes = child.writes.map((w) => JSON.parse(w.trim()));
|
||||
const newMsg = writes.find((m) => m.method === "session/new");
|
||||
expect(newMsg.params.cwd).toBe(workspace);
|
||||
});
|
||||
|
||||
it("sends session/prompt with prompt (not content) as an array", async () => {
|
||||
const { child } = await runExecute();
|
||||
const writes = child.writes.map((w) => JSON.parse(w.trim()));
|
||||
const promptMsg = writes.find((m) => m.method === "session/prompt");
|
||||
expect(promptMsg).toBeTruthy();
|
||||
expect(Array.isArray(promptMsg.params.prompt)).toBe(true);
|
||||
expect(promptMsg.params.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("completes the prompt without a -32602 Invalid params error", async () => {
|
||||
const { acc } = await runExecute();
|
||||
expect(acc).not.toContain("-32602");
|
||||
expect(acc).not.toContain("Invalid params");
|
||||
expect(acc.toLowerCase()).toContain("hello world");
|
||||
});
|
||||
|
||||
it("emits agent_message_chunk content and skips agent_thought_chunk", async () => {
|
||||
// devin 3000.2.x streams via params.update.sessionUpdate.
|
||||
const { acc } = await runExecute();
|
||||
// Reply text is delivered, finish chunk present, thinking is not surfaced.
|
||||
expect(acc.toLowerCase()).toContain("hello world");
|
||||
expect(acc).toContain("finish_reason");
|
||||
expect(acc.toLowerCase()).not.toContain("(thinking)");
|
||||
expect(acc).toContain("[DONE]");
|
||||
});
|
||||
|
||||
it("spawns the default agent (with built-in tools) by default", async () => {
|
||||
const { child } = await runExecute();
|
||||
expect(child.args).toEqual(["acp"]);
|
||||
});
|
||||
|
||||
it("seeds MCP with tool_result from prior client round-trip", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const child = makeFakeChild();
|
||||
let capturedCfg = null;
|
||||
let capturedPrompt = null;
|
||||
spawnMock.mockImplementation((bin, args, opts) => {
|
||||
child.args = args;
|
||||
child.opts = opts;
|
||||
// Capture config at spawn time (finish() cleans the temp dir).
|
||||
if (opts?.env?.XDG_CONFIG_HOME) {
|
||||
capturedCfg = JSON.parse(
|
||||
fs.readFileSync(opts.env.XDG_CONFIG_HOME + "/devin/config.json", "utf8")
|
||||
);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
const origWrite = child.stdin.write;
|
||||
child.stdin.write = (data) => {
|
||||
const s = String(data);
|
||||
try {
|
||||
const msg = JSON.parse(s.trim());
|
||||
if (msg.method === "session/prompt") {
|
||||
capturedPrompt = msg.params.prompt[0].text;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return origWrite.call(child.stdin, data);
|
||||
};
|
||||
const exec = new DevinCliExecutor();
|
||||
const { response } = await exec.execute({
|
||||
model: "swe-1.6-fast",
|
||||
body: {
|
||||
messages: [
|
||||
{ role: "user", content: "weather?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "get_weather", arguments: '{"city":"Paris"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: "28C sunny" },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
parameters: { type: "object", properties: { city: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
credentials: {},
|
||||
log: { info() {}, debug() {} },
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
expect(capturedCfg).toBeTruthy();
|
||||
const results = JSON.parse(capturedCfg.mcpServers.clientTools.env.DEVIN_MCP_RESULTS);
|
||||
expect(results.mcp_get_weather).toBe("28C sunny");
|
||||
expect(capturedPrompt).toContain("get_weather");
|
||||
expect(capturedPrompt).toContain("28C sunny");
|
||||
});
|
||||
|
||||
it("bridges a client-tool MCP call to an OpenAI tool_use", async () => {
|
||||
// Custom fake: on session/prompt, report devin calling our exposed MCP tool.
|
||||
const child = new EventEmitter();
|
||||
child.writes = [];
|
||||
child.stdin = new EventEmitter();
|
||||
child.stdin.destroyed = false;
|
||||
child.stdin.write = (data) => { child.writes.push(String(data)); handle(JSON.parse(String(data).trim())); return true; };
|
||||
child.stdin.end = () => { child.stdin.destroyed = true; };
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.killed = false;
|
||||
child.kill = () => { child.killed = true; };
|
||||
child.args = ["acp"];
|
||||
child.opts = { env: {} };
|
||||
spawnMock.mockReturnValue(child);
|
||||
const send = (o) => child.stdout.emit("data", Buffer.from(JSON.stringify(o) + "\n"));
|
||||
function handle(msg) {
|
||||
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
|
||||
else if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "s1" } });
|
||||
else if (msg.method === "session/prompt") {
|
||||
// Mirror real ACP: title on first event, rawInput on a later update.
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: { sessionId: "s1", update: { sessionUpdate: "tool_call", toolCallId: "call_abc", title: "Calling mcp_get_weather from clientTools" } },
|
||||
});
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: { sessionId: "s1", update: { sessionUpdate: "tool_call_update", toolCallId: "call_abc", rawInput: { city: "Paris" } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const exec = new DevinCliExecutor();
|
||||
const { response } = await exec.execute({
|
||||
model: "swe-1.6-fast",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "weather?" }],
|
||||
tools: [{ type: "function", function: { name: "get_weather", parameters: { type: "object" } } }],
|
||||
},
|
||||
credentials: {},
|
||||
log: { info() {}, debug() {} },
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
let acc = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
acc += new TextDecoder().decode(value);
|
||||
if (acc.includes("[DONE]")) break;
|
||||
}
|
||||
const tc = JSON.parse(acc.match(/"tool_calls":\[(\{.*?\})\]/)?.[1] ?? "{}");
|
||||
expect(tc.function.name).toBe("get_weather"); // mcp_ prefix stripped, MCP-real untouched
|
||||
expect(tc.id).toBe("call_abc");
|
||||
expect(JSON.parse(tc.function.arguments).city).toBe("Paris");
|
||||
expect(acc).toContain('"finish_reason":"tool_calls"');
|
||||
expect(acc).toContain("[DONE]");
|
||||
});
|
||||
|
||||
it("overrides the agent type via CLI_DEVIN_AGENT_TYPE", async () => {
|
||||
process.env.CLI_DEVIN_AGENT_TYPE = "summarizer";
|
||||
try {
|
||||
const { child } = await runExecute();
|
||||
expect(child.args).toEqual(["acp", "--agent-type", "summarizer"]);
|
||||
} finally {
|
||||
delete process.env.CLI_DEVIN_AGENT_TYPE;
|
||||
}
|
||||
});
|
||||
|
||||
it("sets DEVIN_PERMISSION_MODE=bypass so tool calls don't hang on permission prompts", async () => {
|
||||
const { child } = await runExecute();
|
||||
expect(child.opts.env.DEVIN_PERMISSION_MODE).toBe("bypass");
|
||||
});
|
||||
|
||||
it("does not inject WINDSURF_API_KEY — devin-cli uses stored CLI creds (devin auth login)", async () => {
|
||||
// Provider is noAuth; devin must fall back to ~/.local/share/devin/credentials.toml.
|
||||
// Injecting a bogus WINDSURF_API_KEY makes devin reject stored creds → -32000.
|
||||
const { child } = await runExecute({ accessToken: "bogus-token", apiKey: "bogus-key" });
|
||||
expect(child.opts.env.WINDSURF_API_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects an explicit DEVIN_PERMISSION_MODE override", async () => {
|
||||
process.env.DEVIN_PERMISSION_MODE = "accept-edits";
|
||||
try {
|
||||
const { child } = await runExecute();
|
||||
expect(child.opts.env.DEVIN_PERMISSION_MODE).toBe("accept-edits");
|
||||
} finally {
|
||||
delete process.env.DEVIN_PERMISSION_MODE;
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-approves session/request_permission with the first allow option", async () => {
|
||||
const { child } = await runExecute();
|
||||
const writes = child.writes.map((w) => JSON.parse(w.trim()));
|
||||
const resp = writes.find((m) => m.id === 777 && m.result);
|
||||
expect(resp).toBeTruthy();
|
||||
expect(resp.result.outcome.outcome).toBe("selected");
|
||||
expect(resp.result.outcome.optionId).toBe("allow-once");
|
||||
});
|
||||
|
||||
it("sets XDG_CONFIG_HOME when DEVIN_MCP_SERVERS is provided", async () => {
|
||||
process.env.DEVIN_MCP_SERVERS = JSON.stringify({
|
||||
echo: { command: "/usr/bin/node", args: ["/srv/echo.js"] },
|
||||
});
|
||||
try {
|
||||
const { child } = await runExecute();
|
||||
expect(child.opts.env.XDG_CONFIG_HOME).toBeTruthy();
|
||||
// devin reads $XDG_CONFIG_HOME/devin/config.json (E2E verifies content).
|
||||
} finally {
|
||||
delete process.env.DEVIN_MCP_SERVERS;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not set XDG_CONFIG_HOME when DEVIN_MCP_SERVERS is absent", async () => {
|
||||
const { child } = await runExecute();
|
||||
expect(child.opts.env.XDG_CONFIG_HOME).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes body.tools as an MCP server (sets XDG_CONFIG_HOME + writes script)", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
const child = makeFakeChild();
|
||||
spawnMock.mockImplementation((bin, args, opts) => {
|
||||
child.args = args;
|
||||
child.opts = opts;
|
||||
return child;
|
||||
});
|
||||
const exec = new DevinCliExecutor();
|
||||
const { response } = await exec.execute({
|
||||
model: "swe-1.6-fast",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "weather?" }],
|
||||
tools: [
|
||||
{ type: "function", function: { name: "get_weather", description: "Get weather", parameters: { type: "object", properties: { city: { type: "string" } } } } },
|
||||
],
|
||||
},
|
||||
credentials: {},
|
||||
log: { info() {}, debug() {} },
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
await reader.read();
|
||||
// XDG_CONFIG_HOME set so devin loads the generated config.
|
||||
expect(child.opts.env.XDG_CONFIG_HOME).toBeTruthy();
|
||||
// Static MCP bridge script written to disk.
|
||||
const scriptPath = path.join(os.tmpdir(), "9router-devin-client-tools.mjs");
|
||||
expect(fs.existsSync(scriptPath)).toBe(true);
|
||||
expect(fs.readFileSync(scriptPath, "utf8")).toContain("clientTools");
|
||||
expect(fs.readFileSync(scriptPath, "utf8")).toContain("DEVIN_MCP_TOOLS");
|
||||
});
|
||||
});
|
||||
91
tests/unit/embedding-usage-persistence.test.js
Normal file
91
tests/unit/embedding-usage-persistence.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handleEmbeddingsCore: vi.fn(),
|
||||
saveRequestUsage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/sse/services/auth.js", () => ({
|
||||
getProviderCredentials: async () => ({
|
||||
apiKey: "provider-secret",
|
||||
connectionId: "connection-a",
|
||||
connectionName: "Provider A",
|
||||
}),
|
||||
markAccountUnavailable: vi.fn(),
|
||||
clearAccountError: vi.fn(),
|
||||
extractApiKey: () => "client-key",
|
||||
isValidApiKey: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/localDb", () => ({ getSettings: async () => ({ requireApiKey: false }) }));
|
||||
vi.mock("../../src/sse/services/model.js", () => ({
|
||||
getModelInfo: async () => ({ provider: "openai", model: "text-embedding-3-small" }),
|
||||
}));
|
||||
vi.mock("../../open-sse/handlers/embeddingsCore.js", () => ({
|
||||
handleEmbeddingsCore: mocks.handleEmbeddingsCore,
|
||||
}));
|
||||
vi.mock("../../open-sse/utils/error.js", () => ({
|
||||
errorResponse: (status, message) => Response.json({ error: message }, { status }),
|
||||
unavailableResponse: (status, message) => Response.json({ error: message }, { status }),
|
||||
}));
|
||||
vi.mock("../../src/sse/utils/logger.js", () => ({
|
||||
request: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn(), maskKey: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../src/sse/services/tokenRefresh.js", () => ({
|
||||
updateProviderCredentials: vi.fn(),
|
||||
checkAndRefreshToken: async (_provider, credentials) => credentials,
|
||||
}));
|
||||
vi.mock("@/lib/usageDb.js", () => ({ saveRequestUsage: mocks.saveRequestUsage }));
|
||||
|
||||
import { handleEmbeddings } from "../../src/sse/handlers/embeddings.js";
|
||||
|
||||
describe("embedding usage persistence", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.saveRequestUsage.mockResolvedValue(undefined);
|
||||
mocks.handleEmbeddingsCore.mockResolvedValue({
|
||||
success: true,
|
||||
usage: { prompt_tokens: 12, total_tokens: 12 },
|
||||
response: Response.json({ data: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
it("records exact provider usage for successful embedding requests", async () => {
|
||||
await handleEmbeddings(new Request("http://localhost/v1/embeddings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }),
|
||||
}));
|
||||
|
||||
expect(mocks.saveRequestUsage).toHaveBeenCalledWith(expect.objectContaining({
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
connectionId: "connection-a",
|
||||
apiKey: "client-key",
|
||||
endpoint: "/v1/embeddings",
|
||||
status: "success",
|
||||
tokens: { prompt_tokens: 12, completion_tokens: 0, total_tokens: 12 },
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
{ prompt_tokens: 0, total_tokens: 0 },
|
||||
{ prompt_tokens: "12", total_tokens: 12 },
|
||||
{ prompt_tokens: 12, total_tokens: 13 },
|
||||
{ prompt_tokens: 12, completion_tokens: 1, total_tokens: 12 },
|
||||
{ prompt_tokens: 12, total_tokens: 12, estimated: true },
|
||||
])("does not record inexact usage %#", async (usage) => {
|
||||
mocks.handleEmbeddingsCore.mockResolvedValue({
|
||||
success: true,
|
||||
usage,
|
||||
response: Response.json({ data: [] }),
|
||||
});
|
||||
|
||||
await handleEmbeddings(new Request("http://localhost/v1/embeddings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }),
|
||||
}));
|
||||
|
||||
expect(mocks.saveRequestUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
91
tests/unit/fetch-success-clears-account.test.js
Normal file
91
tests/unit/fetch-success-clears-account.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getProviderCredentials: vi.fn(),
|
||||
markAccountUnavailable: vi.fn(),
|
||||
clearAccountError: vi.fn(),
|
||||
extractApiKey: vi.fn(() => null),
|
||||
isValidApiKey: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getCombos: vi.fn(),
|
||||
handleFetchCore: vi.fn(),
|
||||
checkAndRefreshToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/sse/services/auth.js", () => ({
|
||||
getProviderCredentials: mocks.getProviderCredentials,
|
||||
markAccountUnavailable: mocks.markAccountUnavailable,
|
||||
clearAccountError: mocks.clearAccountError,
|
||||
extractApiKey: mocks.extractApiKey,
|
||||
isValidApiKey: mocks.isValidApiKey,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getSettings: mocks.getSettings,
|
||||
getCombos: mocks.getCombos,
|
||||
}));
|
||||
|
||||
vi.mock("open-sse/handlers/fetch/index.js", () => ({
|
||||
handleFetchCore: mocks.handleFetchCore,
|
||||
}));
|
||||
|
||||
vi.mock("@/sse/services/tokenRefresh.js", () => ({
|
||||
checkAndRefreshToken: mocks.checkAndRefreshToken,
|
||||
updateProviderCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/sse/utils/logger.js", () => ({
|
||||
request: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
maskKey: vi.fn(() => "masked"),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/utils/ssrfGuard.js", () => ({
|
||||
assertPublicUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
import { handleFetch } from "@/sse/handlers/fetch.js";
|
||||
|
||||
describe("web fetch account state", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSettings.mockResolvedValue({ requireApiKey: false });
|
||||
mocks.getCombos.mockResolvedValue([]);
|
||||
mocks.getProviderCredentials.mockResolvedValue({
|
||||
apiKey: "jina-test-key",
|
||||
connectionId: "jina-connection",
|
||||
connectionName: "Jina Test",
|
||||
_connection: {
|
||||
testStatus: "unavailable",
|
||||
lastError: "old error",
|
||||
modelLock___all: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
mocks.checkAndRefreshToken.mockImplementation(async (_provider, credentials) => credentials);
|
||||
mocks.handleFetchCore.mockResolvedValue({
|
||||
success: true,
|
||||
data: { provider: "jina-reader", content: { text: "ok" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a stale provider lock after a successful fetch", async () => {
|
||||
const response = await handleFetch(new Request("http://localhost/v1/web/fetch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "jina-reader",
|
||||
url: "https://example.com/article",
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.clearAccountError).toHaveBeenCalledWith(
|
||||
"jina-connection",
|
||||
expect.objectContaining({ connectionName: "Jina Test" }),
|
||||
);
|
||||
expect(mocks.markAccountUnavailable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
109
tests/unit/fish-audio-tts.test.js
Normal file
109
tests/unit/fish-audio-tts.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
83
tests/unit/fusion-strip-stream-options-3024.test.js
Normal file
83
tests/unit/fusion-strip-stream-options-3024.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
36
tests/unit/gemini-3.7-antigravity.test.js
Normal file
36
tests/unit/gemini-3.7-antigravity.test.js
Normal file
@@ -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"]);
|
||||
});
|
||||
});
|
||||
144
tests/unit/gemini-36-integration.test.js
Normal file
144
tests/unit/gemini-36-integration.test.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { getModelUpstreamId } from "../../open-sse/config/providerModels.js";
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
|
||||
import { applyThinking, stripThinkingSuffix } from "../../open-sse/translator/concerns/thinkingUnified.js";
|
||||
import antigravity from "../../open-sse/providers/registry/antigravity.js";
|
||||
import geminiCli from "../../open-sse/providers/registry/gemini-cli.js";
|
||||
import gemini from "../../open-sse/providers/registry/gemini.js";
|
||||
import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
|
||||
import {
|
||||
getProjectIdForConnection,
|
||||
removeConnection,
|
||||
} from "../../open-sse/services/projectId.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const mitmConfig = require("../../src/mitm/config.js");
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function cloudCodeResponse(projectId) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ cloudaicompanionProject: { id: projectId } }),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Gemini Cloud Code endpoint isolation", () => {
|
||||
it("keeps Gemini CLI on the official cloudcode host", async () => {
|
||||
const connectionId = "gemini-cli-endpoint-test";
|
||||
const fetchMock = vi.fn(async () => cloudCodeResponse("gemini-project"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await getProjectIdForConnection(connectionId, "token", "gemini-cli");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expect(geminiCli.transport.baseUrl).toBe("https://cloudcode-pa.googleapis.com/v1internal");
|
||||
removeConnection(connectionId);
|
||||
});
|
||||
|
||||
it("uses the prod cloudcode host for Antigravity discovery but daily for chat", async () => {
|
||||
const connectionId = "antigravity-endpoint-test";
|
||||
const fetchMock = vi.fn(async () => cloudCodeResponse("antigravity-project"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await getProjectIdForConnection(connectionId, "token", "antigravity");
|
||||
|
||||
// Discovery (loadCodeAssist) on PROD — daily host rejects auth/onboarding calls.
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
// Chat transport still uses the daily host to bypass prod 429.
|
||||
expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]);
|
||||
removeConnection(connectionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemini 3.6 Antigravity tiers", () => {
|
||||
it.each(["high", "medium", "low"])(
|
||||
"maps the %s tier to the shared upstream model with matching thinking level",
|
||||
(tier) => {
|
||||
const publicModel = `gemini-3.6-flash-${tier}`;
|
||||
const upstreamModel = getModelUpstreamId("ag", publicModel);
|
||||
const body = {
|
||||
model: stripThinkingSuffix(upstreamModel),
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "hello" }] }],
|
||||
generationConfig: {},
|
||||
},
|
||||
};
|
||||
|
||||
applyThinking("antigravity", upstreamModel, body, "antigravity");
|
||||
const finalBody = new AntigravityExecutor().transformRequest(
|
||||
publicModel,
|
||||
body,
|
||||
true,
|
||||
{ projectId: "project", connectionId: "connection" }
|
||||
);
|
||||
|
||||
expect(upstreamModel).toBe(`gemini-3.6-flash-tiered(${tier})`);
|
||||
expect(finalBody.model).toBe("gemini-3.6-flash-tiered");
|
||||
expect(finalBody.request.generationConfig.thinkingConfig).toEqual({
|
||||
thinkingLevel: tier,
|
||||
includeThoughts: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("Gemini 3.6 MITM model extraction", () => {
|
||||
it("exports the model extractor from the side-effect-free MITM config module", () => {
|
||||
expect(mitmConfig.extractModel).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it.each(["high", "medium", "low"])("extracts the %s thinking tier", (tier) => {
|
||||
const body = Buffer.from(JSON.stringify({
|
||||
request: { generationConfig: { thinkingConfig: { thinkingLevel: tier } } },
|
||||
}));
|
||||
|
||||
expect(mitmConfig.extractModel(
|
||||
"/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent",
|
||||
body
|
||||
)).toBe(`gemini-3.6-flash-${tier}`);
|
||||
});
|
||||
|
||||
it("defaults invalid or missing thinking levels to medium", () => {
|
||||
const body = Buffer.from(JSON.stringify({
|
||||
request: { generationConfig: { thinkingConfig: { thinkingLevel: "unknown" } } },
|
||||
}));
|
||||
|
||||
expect(mitmConfig.extractModel(
|
||||
"/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent",
|
||||
body
|
||||
)).toBe("gemini-3.6-flash-medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemini 3.6 catalogs and pricing", () => {
|
||||
it("exposes the direct Gemini API models and their pricing", () => {
|
||||
const ids = gemini.models.map((model) => model.id);
|
||||
expect(ids).toContain("gemini-3.6-flash");
|
||||
expect(ids).toContain("gemini-3.5-flash-lite");
|
||||
expect(MODEL_PRICING["gemini-3.6-flash"]).toMatchObject({ input: 1.5, output: 7.5 });
|
||||
expect(MODEL_PRICING["gemini-3.5-flash-lite"]).toMatchObject({ input: 0.3, output: 2.5 });
|
||||
});
|
||||
|
||||
it("keeps the standalone CLI Gemini catalog synchronized", () => {
|
||||
const source = readFileSync(join(here, "../../cli/src/cli/menus/providers.js"), "utf8");
|
||||
const geminiCatalog = source.match(/\n gemini: \[([\s\S]*?)\n \],/)?.[1] || "";
|
||||
|
||||
expect(geminiCatalog).toContain("gemini-3.6-flash");
|
||||
expect(geminiCatalog).toContain("gemini-3.5-flash-lite");
|
||||
});
|
||||
});
|
||||
86
tests/unit/github-monthly-usage-lock.test.js
Normal file
86
tests/unit/github-monthly-usage-lock.test.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const dbMocks = vi.hoisted(() => ({
|
||||
getProviderConnections: vi.fn(),
|
||||
updateProviderConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/localDb", () => dbMocks);
|
||||
vi.mock("@/lib/network/connectionProxy", () => ({
|
||||
pickProxyPoolId: vi.fn(),
|
||||
resolveConnectionProxyConfig: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/shared/constants/providers.js", () => ({
|
||||
FREE_PROVIDERS: {},
|
||||
resolveProviderId: (provider) => provider,
|
||||
}));
|
||||
vi.mock("@/sse/utils/logger.js", () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn() }));
|
||||
|
||||
const { markAccountUnavailable } = await import("../../src/sse/services/auth.js");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMocks.getProviderConnections.mockResolvedValue([{
|
||||
id: "github-a",
|
||||
provider: "github",
|
||||
name: "github-a",
|
||||
backoffLevel: 4,
|
||||
}]);
|
||||
});
|
||||
|
||||
describe("GitHub monthly usage exhaustion", () => {
|
||||
it("locks the whole account until the next UTC month", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-04T19:30:00.000Z"));
|
||||
|
||||
try {
|
||||
await markAccountUnavailable(
|
||||
"github-a",
|
||||
402,
|
||||
"You've reached your additional usage limit for your plan. Go to GitHub settings for details.",
|
||||
"github",
|
||||
"claude-fable-5",
|
||||
);
|
||||
|
||||
expect(dbMocks.updateProviderConnection).toHaveBeenCalledWith(
|
||||
"github-a",
|
||||
expect.objectContaining({
|
||||
modelLock___all: "2026-09-01T00:00:00.000Z",
|
||||
testStatus: "unavailable",
|
||||
errorCode: 402,
|
||||
backoffLevel: 0,
|
||||
}),
|
||||
);
|
||||
expect(dbMocks.updateProviderConnection.mock.calls[0][1])
|
||||
.not.toHaveProperty("modelLock_claude-fable-5");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps unrelated GitHub 402 errors model-scoped", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-04T19:30:00.000Z"));
|
||||
|
||||
try {
|
||||
await markAccountUnavailable(
|
||||
"github-a",
|
||||
402,
|
||||
"Payment required",
|
||||
"github",
|
||||
"claude-fable-5",
|
||||
);
|
||||
|
||||
expect(dbMocks.updateProviderConnection).toHaveBeenCalledWith(
|
||||
"github-a",
|
||||
expect.objectContaining({
|
||||
"modelLock_claude-fable-5": "2026-08-04T19:32:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(dbMocks.updateProviderConnection.mock.calls[0][1])
|
||||
.not.toHaveProperty("modelLock___all");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
176
tests/unit/grok-build-config.test.js
Normal file
176
tests/unit/grok-build-config.test.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyGrokBuildConfig,
|
||||
getGrokSubagentSlot,
|
||||
parseGrokBuildConfig,
|
||||
resetGrokBuildConfig,
|
||||
} from "../../src/lib/grokBuildConfig.js";
|
||||
|
||||
const BASE_CONFIG = `[cli]
|
||||
installer = "internal"
|
||||
|
||||
[ui]
|
||||
yolo = false
|
||||
|
||||
[models]
|
||||
default = "grok-4.5"
|
||||
default_reasoning_effort = "high"
|
||||
|
||||
[subagents]
|
||||
enabled = true
|
||||
|
||||
[subagents.models]
|
||||
general-purpose = "grok-4.5"
|
||||
explore = "grok-build"
|
||||
plan = "grok-4.5"
|
||||
|
||||
[mcp_servers.example]
|
||||
url = "https://example.com/mcp"
|
||||
enabled = true
|
||||
`;
|
||||
|
||||
const APPLY_INPUT = {
|
||||
baseUrl: "http://127.0.0.1:20128/v1",
|
||||
apiKey: "sk-test",
|
||||
model: "cx/gpt-5.6-sol",
|
||||
contextWindow: 400000,
|
||||
subagentModels: {
|
||||
"general-purpose": { model: "cc/claude-sonnet-5", contextWindow: 1000000 },
|
||||
explore: { model: "gemini/gemini-3-flash", contextWindow: 1048576 },
|
||||
},
|
||||
};
|
||||
|
||||
describe("grokBuildConfig", () => {
|
||||
it("creates independent main and per-type subagent model slots", () => {
|
||||
const result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
const parsed = parseGrokBuildConfig(result);
|
||||
|
||||
expect(parsed.default).toBe("9router");
|
||||
expect(parsed.model).toMatchObject({
|
||||
model: "cx/gpt-5.6-sol",
|
||||
base_url: "http://127.0.0.1:20128/v1",
|
||||
context_window: 400000,
|
||||
});
|
||||
expect(parsed.subagentMappings).toMatchObject({
|
||||
"general-purpose": "9router-general-purpose",
|
||||
explore: "9router-explore",
|
||||
plan: "grok-4.5",
|
||||
});
|
||||
expect(parsed.subagentModels["general-purpose"]).toMatchObject({
|
||||
model: "cc/claude-sonnet-5",
|
||||
context_window: 1000000,
|
||||
});
|
||||
expect(parsed.subagentModels.explore).toMatchObject({
|
||||
model: "gemini/gemini-3-flash",
|
||||
context_window: 1048576,
|
||||
});
|
||||
expect(parsed.subagentModels.plan).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves unrelated config sections", () => {
|
||||
const result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
expect(result).toContain("[cli]\ninstaller = \"internal\"");
|
||||
expect(result).toContain("[ui]\nyolo = false");
|
||||
expect(result).toContain("default_reasoning_effort = \"high\"");
|
||||
expect(result).toContain("[mcp_servers.example]");
|
||||
expect(result).toContain("url = \"https://example.com/mcp\"");
|
||||
});
|
||||
|
||||
it("is idempotent and updates owned slots without duplicate sections", () => {
|
||||
let result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
result = applyGrokBuildConfig(result, {
|
||||
...APPLY_INPUT,
|
||||
model: "cc/claude-opus-4.8",
|
||||
contextWindow: 1000000,
|
||||
subagentModels: {
|
||||
...APPLY_INPUT.subagentModels,
|
||||
explore: { model: "mimo/mimo", contextWindow: 262144 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.match(/^\[model\.9router\]$/gm)).toHaveLength(1);
|
||||
expect(result.match(/^\[model\.9router-general-purpose\]$/gm)).toHaveLength(1);
|
||||
expect(result.match(/^\[model\.9router-explore\]$/gm)).toHaveLength(1);
|
||||
expect(result.match(/^# 9router-prev-subagent-explore/gm)).toHaveLength(1);
|
||||
expect(parseGrokBuildConfig(result).model).toMatchObject({
|
||||
model: "cc/claude-opus-4.8",
|
||||
context_window: 1000000,
|
||||
});
|
||||
expect(parseGrokBuildConfig(result).subagentModels.explore).toMatchObject({
|
||||
model: "mimo/mimo",
|
||||
context_window: 262144,
|
||||
});
|
||||
});
|
||||
|
||||
it("blank override restores previous subagent mapping and removes owned slot", () => {
|
||||
let result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
result = applyGrokBuildConfig(result, {
|
||||
...APPLY_INPUT,
|
||||
subagentModels: {
|
||||
"general-purpose": APPLY_INPUT.subagentModels["general-purpose"],
|
||||
// explore omitted => inherit / restore previous
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = parseGrokBuildConfig(result);
|
||||
expect(parsed.subagentMappings.explore).toBe("grok-build");
|
||||
expect(parsed.subagentModels.explore).toBeNull();
|
||||
expect(result).not.toContain("[model.9router-explore]");
|
||||
expect(parsed.subagentMappings["general-purpose"]).toBe("9router-general-purpose");
|
||||
});
|
||||
|
||||
it("reset restores previous default and all previous subagent mappings", () => {
|
||||
const applied = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
const reset = resetGrokBuildConfig(applied);
|
||||
const parsed = parseGrokBuildConfig(reset);
|
||||
|
||||
expect(parsed.default).toBe("grok-4.5");
|
||||
expect(parsed.model).toBeNull();
|
||||
expect(parsed.subagentMappings).toEqual({
|
||||
"general-purpose": "grok-4.5",
|
||||
explore: "grok-build",
|
||||
plan: "grok-4.5",
|
||||
});
|
||||
expect(reset).not.toContain("[model.9router-");
|
||||
expect(reset).not.toContain("9router-prev-");
|
||||
expect(reset).toContain("[mcp_servers.example]");
|
||||
});
|
||||
|
||||
it("removes mappings that were originally unset", () => {
|
||||
const config = `[models]\ndefault = "grok-build"\n\n[mcp_servers.x]\nenabled = true\n`;
|
||||
const applied = applyGrokBuildConfig(config, {
|
||||
...APPLY_INPUT,
|
||||
subagentModels: {
|
||||
plan: { model: "cc/claude-sonnet-5", contextWindow: 1000000 },
|
||||
},
|
||||
});
|
||||
const reset = resetGrokBuildConfig(applied);
|
||||
|
||||
expect(parseGrokBuildConfig(applied).subagentMappings.plan).toBe("9router-plan");
|
||||
expect(parseGrokBuildConfig(reset).subagentMappings.plan).toBeNull();
|
||||
expect(reset).not.toContain("[subagents.models]");
|
||||
expect(reset).toContain("[mcp_servers.x]");
|
||||
});
|
||||
|
||||
it("legacy callers without subagentModels leave existing overrides untouched", () => {
|
||||
const applied = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
const updatedMainOnly = applyGrokBuildConfig(applied, {
|
||||
baseUrl: APPLY_INPUT.baseUrl,
|
||||
apiKey: APPLY_INPUT.apiKey,
|
||||
model: "gemini/gemini-3.1-pro",
|
||||
contextWindow: 1048576,
|
||||
});
|
||||
|
||||
const parsed = parseGrokBuildConfig(updatedMainOnly);
|
||||
expect(parsed.model.model).toBe("gemini/gemini-3.1-pro");
|
||||
expect(parsed.subagentMappings.explore).toBe("9router-explore");
|
||||
expect(parsed.subagentModels.explore.model).toBe("gemini/gemini-3-flash");
|
||||
});
|
||||
|
||||
it("returns stable slot names only for supported subagent types", () => {
|
||||
expect(getGrokSubagentSlot("general-purpose")).toBe("9router-general-purpose");
|
||||
expect(getGrokSubagentSlot("explore")).toBe("9router-explore");
|
||||
expect(getGrokSubagentSlot("plan")).toBe("9router-plan");
|
||||
expect(getGrokSubagentSlot("unknown")).toBeNull();
|
||||
});
|
||||
});
|
||||
495
tests/unit/grok-cli-executor.test.js
Normal file
495
tests/unit/grok-cli-executor.test.js
Normal file
@@ -0,0 +1,495 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
GrokCliExecutor,
|
||||
countGrokCliUserTurns,
|
||||
resolveGrokCliTurnIdx,
|
||||
_resetGrokCliTurnStore,
|
||||
_getGrokCliTurnStoreSize,
|
||||
normalizeGrokCliEffort,
|
||||
supportsGrokCliReasoningEffort,
|
||||
} from "../../open-sse/executors/grok-cli.js";
|
||||
import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.js";
|
||||
import { PROVIDERS, PROVIDER_OAUTH, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
|
||||
import { getModelUpstreamId } from "../../open-sse/config/providerModels.js";
|
||||
import { getModelInfoCore, resolveProviderAlias } from "../../open-sse/services/model.js";
|
||||
import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers.js";
|
||||
|
||||
describe("grok-cli registry", () => {
|
||||
it("registers transport + oauth + models", () => {
|
||||
const cfg = PROVIDERS["grok-cli"];
|
||||
expect(cfg).toBeTruthy();
|
||||
expect(cfg.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1/responses");
|
||||
expect(cfg.format).toBe("openai-responses");
|
||||
expect(cfg.forceStream).toBe(true);
|
||||
expect(cfg.tokenAuth).toBe("xai-grok-cli");
|
||||
|
||||
const oauth = PROVIDER_OAUTH["grok-cli"];
|
||||
expect(oauth.clientId).toBe("b1a00492-073a-47ea-816f-4c329264a828");
|
||||
expect(oauth.deviceCodeUrl).toContain("auth.x.ai");
|
||||
expect(oauth.scope).toContain("grok-cli:access");
|
||||
expect(oauth.scope).toContain("conversations:write");
|
||||
expect(oauth.referrer).toBe("grok-build");
|
||||
|
||||
expect(PROVIDER_MODELS.gcli?.some((m) => m.id === "grok-build")).toBe(true);
|
||||
});
|
||||
|
||||
it("is listed as oauth provider for dashboard", () => {
|
||||
expect(OAUTH_PROVIDERS["grok-cli"]).toBeTruthy();
|
||||
expect(OAUTH_PROVIDERS["grok-cli"].name).toMatch(/Grok CLI/i);
|
||||
});
|
||||
|
||||
it("resolves aliases to provider id", () => {
|
||||
expect(resolveProviderAlias("gcli")).toBe("grok-cli");
|
||||
expect(resolveProviderAlias("gb")).toBe("grok-cli");
|
||||
expect(resolveProviderAlias("grok-build")).toBe("grok-cli");
|
||||
expect(resolveProviderAlias("grok-cli")).toBe("grok-cli");
|
||||
});
|
||||
|
||||
it("routes bare grok-build to the subscription provider", async () => {
|
||||
await expect(getModelInfoCore("grok-build", {})).resolves.toEqual({
|
||||
provider: "grok-cli",
|
||||
model: "grok-build",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps effort virtual models to upstream grok-4.5", () => {
|
||||
expect(getModelUpstreamId("gcli", "grok-4.5-high")).toBe("grok-4.5");
|
||||
expect(getModelUpstreamId("gcli", "grok-4.5-medium")).toBe("grok-4.5");
|
||||
expect(getModelUpstreamId("gcli", "grok-4.5-low")).toBe("grok-4.5");
|
||||
expect(getModelUpstreamId("gcli", "grok-4.5")).toBe("grok-4.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GrokCliExecutor", () => {
|
||||
let executor;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetGrokCliTurnStore();
|
||||
executor = new GrokCliExecutor();
|
||||
});
|
||||
|
||||
it("is registered on executor map (id + aliases)", () => {
|
||||
expect(hasSpecializedExecutor("grok-cli")).toBe(true);
|
||||
expect(getExecutor("grok-cli")).toBeInstanceOf(GrokCliExecutor);
|
||||
expect(getExecutor("gcli")).toBeInstanceOf(GrokCliExecutor);
|
||||
expect(getExecutor("gb")).toBeInstanceOf(GrokCliExecutor);
|
||||
});
|
||||
|
||||
it("buildUrl points at cli-chat-proxy responses", () => {
|
||||
expect(executor.buildUrl()).toBe("https://cli-chat-proxy.grok.com/v1/responses");
|
||||
});
|
||||
|
||||
it("buildHeaders sets CLI fingerprint + session headers", () => {
|
||||
executor._currentSessionId = "sess-abc";
|
||||
executor._currentReqId = "req-xyz";
|
||||
executor._agentId = "agent-1";
|
||||
executor._currentModel = "grok-4.5";
|
||||
executor._currentTurnIdx = 3;
|
||||
|
||||
const headers = executor.buildHeaders(
|
||||
{
|
||||
accessToken: "tok_test",
|
||||
providerSpecificData: { email: "u@example.com", userId: "uid-1" },
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
expect(headers.Authorization).toBe("Bearer tok_test");
|
||||
expect(headers.Accept).toBe("text/event-stream");
|
||||
expect(headers["x-xai-token-auth"]).toBeUndefined();
|
||||
expect(headers["x-grok-client-identifier"]).toBe("grok-shell");
|
||||
expect(headers["x-grok-client-version"]).toBe("0.2.99");
|
||||
expect(headers["x-grok-session-id"]).toBe("sess-abc");
|
||||
expect(headers["x-grok-conv-id"]).toBe("sess-abc");
|
||||
expect(headers["x-grok-req-id"]).toBe("req-xyz");
|
||||
expect(headers["x-grok-turn-idx"]).toBe("3");
|
||||
expect(headers["x-grok-agent-id"]).toBe("agent-1");
|
||||
expect(headers["x-grok-model-override"]).toBe("grok-4.5");
|
||||
expect(headers["x-compaction-at"]).toBeUndefined();
|
||||
expect(headers["x-email"]).toBe("u@example.com");
|
||||
expect(headers["x-userid"]).toBe("uid-1");
|
||||
expect(headers["x-authenticateresponse"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("buildHeaders falls back to top-level email/userId (OAuth mapTokens shape)", () => {
|
||||
executor._currentSessionId = "sess-top";
|
||||
executor._currentReqId = "req-top";
|
||||
|
||||
const headers = executor.buildHeaders(
|
||||
{
|
||||
accessToken: "tok_test",
|
||||
email: "top@example.com",
|
||||
// userId only top-level; psd has neither email nor userId
|
||||
providerSpecificData: { authMethod: "device_code" },
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
expect(headers["x-email"]).toBe("top@example.com");
|
||||
expect(headers["x-userid"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("transformRequest normalizes Responses body like official CLI", () => {
|
||||
const body = {
|
||||
model: "grok-4.5-high",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
stream: false,
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "run_terminal_command",
|
||||
description: "Run bash",
|
||||
parameters: { type: "object", properties: { command: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
{ type: "web_search" },
|
||||
{ type: "x_search" },
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 100,
|
||||
user: "cursor-user",
|
||||
};
|
||||
|
||||
// Simulate translator already converting messages→input; also test messages fallback
|
||||
const out = executor.transformRequest("grok-4.5-high", { ...body }, true, {
|
||||
connectionId: "conn-1",
|
||||
});
|
||||
|
||||
expect(out.model).toBe("grok-4.5");
|
||||
expect(out.stream).toBe(true);
|
||||
expect(out.store).toBe(false);
|
||||
expect(out.include).toContain("reasoning.encrypted_content");
|
||||
expect(out.reasoning).toEqual({ effort: "high", summary: "concise" });
|
||||
expect(out.messages).toBeUndefined();
|
||||
expect(out.max_tokens).toBeUndefined();
|
||||
expect(out.user).toBeUndefined();
|
||||
expect(Array.isArray(out.input)).toBe(true);
|
||||
expect(out.input.length).toBeGreaterThan(0);
|
||||
expect(executor._currentTurnIdx).toBe(1);
|
||||
|
||||
// tools flattened + hosted tools kept
|
||||
expect(out.tools).toHaveLength(3);
|
||||
expect(out.tools[0]).toMatchObject({
|
||||
type: "function",
|
||||
name: "run_terminal_command",
|
||||
});
|
||||
expect(out.tools[0].parameters).toBeTruthy();
|
||||
expect(out.tools[0].function).toBeUndefined();
|
||||
expect(out.tools[1]).toEqual({ type: "web_search" });
|
||||
expect(out.tools[2]).toEqual({ type: "x_search" });
|
||||
});
|
||||
|
||||
it("transformRequest keeps role:system (HAR parity) and strips server ids", () => {
|
||||
const body = {
|
||||
model: "grok-4.5",
|
||||
input: [
|
||||
{ type: "message", role: "system", content: "You are Grok" },
|
||||
{ type: "message", role: "user", content: "hi", id: "msg_server_id" },
|
||||
{ type: "item_reference", id: "rs_abc" },
|
||||
"rs_should_drop",
|
||||
],
|
||||
reasoning_effort: "medium",
|
||||
};
|
||||
|
||||
const out = executor.transformRequest("grok-4.5", body, true, { connectionId: "c1" });
|
||||
expect(out.input).toHaveLength(2);
|
||||
// Official CLI sends system, not developer (Codex converts; Grok does not)
|
||||
expect(out.input[0].role).toBe("system");
|
||||
expect(out.input[1].id).toBeUndefined();
|
||||
expect(out.reasoning.effort).toBe("medium");
|
||||
});
|
||||
|
||||
it("normalizes Codex cross-provider tool and reasoning history", () => {
|
||||
const out = executor.transformRequest("grok-4.5", {
|
||||
model: "grok-4.5",
|
||||
input: [
|
||||
{ type: "message", role: "user", content: "continue" },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_07fe505b3114f180016a5698411c448191bdcdcba678464461",
|
||||
encrypted_content: "openai-ciphertext",
|
||||
summary: [],
|
||||
internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
|
||||
},
|
||||
{
|
||||
type: "custom_tool_call",
|
||||
id: "ctc_openai",
|
||||
call_id: "call-custom",
|
||||
name: "exec",
|
||||
input: "run this",
|
||||
internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
|
||||
},
|
||||
{
|
||||
type: "custom_tool_call_output",
|
||||
call_id: "call-custom",
|
||||
output: [{ type: "input_text", text: "first" }, { type: "input_text", text: "second" }],
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call-function",
|
||||
output: [{ type: "input_text", text: "function result" }],
|
||||
},
|
||||
],
|
||||
tools: [{ type: "custom", name: "exec", description: "Run command" }],
|
||||
}, true, { connectionId: "cross-provider" });
|
||||
|
||||
expect(out.input.some((item) => item.type === "reasoning")).toBe(false);
|
||||
expect(out.input[1]).toEqual({
|
||||
type: "function_call",
|
||||
call_id: "call-custom",
|
||||
name: "exec",
|
||||
arguments: JSON.stringify({ input: "run this" }),
|
||||
});
|
||||
expect(out.input[2]).toEqual({
|
||||
type: "function_call_output",
|
||||
call_id: "call-custom",
|
||||
output: JSON.stringify([{ type: "input_text", text: "first" }, { type: "input_text", text: "second" }]),
|
||||
});
|
||||
expect(out.input.some((item) => item.call_id === "call-function")).toBe(false);
|
||||
expect(out.tools[0].parameters).toEqual({
|
||||
type: "object",
|
||||
properties: { input: { type: "string" } },
|
||||
required: ["input"],
|
||||
});
|
||||
});
|
||||
|
||||
it("stringifies structured outputs and removes orphaned output items", () => {
|
||||
const out = executor.transformRequest("grok-4.5", {
|
||||
model: "grok-4.5",
|
||||
input: [
|
||||
{ type: "function_call", call_id: "call-array", name: "array_tool", arguments: "{}" },
|
||||
{ type: "function_call_output", call_id: "call-array", output: [1, 2] },
|
||||
{ type: "function_call", call_id: "call-null", name: "null_tool", arguments: "{}" },
|
||||
{ type: "function_call_output", call_id: "call-null", output: null },
|
||||
{ type: "custom_tool_call", call_id: "call-invalid", input: "missing name" },
|
||||
{ type: "custom_tool_call_output", call_id: "call-invalid", output: "orphan" },
|
||||
],
|
||||
}, true, { connectionId: "structured-output" });
|
||||
|
||||
const outputs = out.input.filter((item) => item.type === "function_call_output");
|
||||
expect(outputs).toEqual([
|
||||
{ type: "function_call_output", call_id: "call-array", output: "[1,2]" },
|
||||
{ type: "function_call_output", call_id: "call-null", output: "null" },
|
||||
]);
|
||||
expect(out.input.some((item) => item.call_id === "call-invalid")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves native Grok encrypted reasoning and item ids", () => {
|
||||
const reasoningId = "rs_3e3f6187-892a-96db-893b-904eff019e19";
|
||||
const messageId = "msg_3e3f6187-892a-96db-893b-904eff019e19";
|
||||
const functionId = "fc_3e3f6187-892a-96db-893b-904eff019e19";
|
||||
const out = executor.transformRequest("grok-4.5", {
|
||||
model: "grok-4.5",
|
||||
input: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: reasoningId,
|
||||
status: "completed",
|
||||
encrypted_content: "grok-ciphertext",
|
||||
summary: [],
|
||||
internal_chat_message_metadata_passthrough: { turn_id: "turn-2" },
|
||||
},
|
||||
{ type: "message", id: messageId, role: "assistant", content: "done" },
|
||||
{ type: "function_call", id: functionId, call_id: "native-call", name: "wait", arguments: "{}" },
|
||||
{ type: "function_call_output", call_id: "native-call", output: "done" },
|
||||
{ type: "message", role: "user", content: "next" },
|
||||
],
|
||||
}, true, { connectionId: "native-grok" });
|
||||
|
||||
expect(out.input[0]).toMatchObject({
|
||||
type: "reasoning",
|
||||
id: reasoningId,
|
||||
encrypted_content: "grok-ciphertext",
|
||||
});
|
||||
expect(out.input[0].internal_chat_message_metadata_passthrough).toBeUndefined();
|
||||
expect(out.input[1].id).toBe(messageId);
|
||||
expect(out.input[2].id).toBe(functionId);
|
||||
});
|
||||
|
||||
it("normalizes official effort aliases", () => {
|
||||
expect(normalizeGrokCliEffort("none")).toBe("high");
|
||||
expect(normalizeGrokCliEffort("minimal")).toBe("high");
|
||||
expect(normalizeGrokCliEffort("max")).toBe("xhigh");
|
||||
expect(normalizeGrokCliEffort("xhigh")).toBe("xhigh");
|
||||
expect(normalizeGrokCliEffort("ultra")).toBe("high");
|
||||
|
||||
const out = executor.transformRequest("grok-4.5", {
|
||||
model: "grok-4.5",
|
||||
input: "hi",
|
||||
reasoning: { effort: "max", summary: "detailed" },
|
||||
}, true, { connectionId: "effort-conn" });
|
||||
expect(out.reasoning).toEqual({ effort: "xhigh", summary: "detailed" });
|
||||
});
|
||||
|
||||
it("omits reasoning effort for models that reject it", () => {
|
||||
expect(supportsGrokCliReasoningEffort("grok-4.5")).toBe(true);
|
||||
expect(supportsGrokCliReasoningEffort("grok-build")).toBe(false);
|
||||
expect(supportsGrokCliReasoningEffort("grok-composer-2.5-fast")).toBe(false);
|
||||
|
||||
for (const model of ["grok-build", "grok-composer-2.5-fast"]) {
|
||||
const out = executor.transformRequest(model, {
|
||||
model,
|
||||
input: "hi",
|
||||
reasoning: { effort: "max" },
|
||||
}, true, { connectionId: `effort-${model}` });
|
||||
expect(out.reasoning).toEqual({ summary: "concise" });
|
||||
expect(out.include).toContain("reasoning.encrypted_content");
|
||||
}
|
||||
});
|
||||
|
||||
it("drops stale tool_choice and normalizes converted custom choices", () => {
|
||||
const noTools = executor.transformRequest("grok-build", {
|
||||
model: "grok-build",
|
||||
input: "hi",
|
||||
tool_choice: "auto",
|
||||
}, true, { connectionId: "tools-none" });
|
||||
expect(noTools.tool_choice).toBeUndefined();
|
||||
|
||||
const custom = executor.transformRequest("grok-build", {
|
||||
model: "grok-build",
|
||||
input: "hi",
|
||||
tools: [{ type: "custom", name: "apply_patch", description: "Patch files" }],
|
||||
tool_choice: { type: "custom", name: "apply_patch" },
|
||||
}, true, { connectionId: "tools-custom" });
|
||||
expect(custom.tools).toEqual([
|
||||
expect.objectContaining({ type: "function", name: "apply_patch" }),
|
||||
]);
|
||||
expect(custom.tool_choice).toEqual({ type: "function", name: "apply_patch" });
|
||||
});
|
||||
|
||||
it("increments x-grok-turn-idx from user-message count and stays monotonic", () => {
|
||||
const creds = {
|
||||
connectionId: "turn-conn",
|
||||
rawHeaders: { "x-session-id": "stable-session-xyz" },
|
||||
};
|
||||
|
||||
// Turn 1: one user message
|
||||
executor.transformRequest(
|
||||
"grok-4.5",
|
||||
{
|
||||
model: "grok-4.5",
|
||||
input: [
|
||||
{ type: "message", role: "system", content: "sys" },
|
||||
{ type: "message", role: "user", content: "hi" },
|
||||
],
|
||||
},
|
||||
true,
|
||||
creds
|
||||
);
|
||||
expect(executor._currentSessionId).toBeTruthy();
|
||||
expect(executor._currentTurnIdx).toBe(1);
|
||||
let headers = executor.buildHeaders({ accessToken: "t" }, true);
|
||||
expect(headers["x-grok-turn-idx"]).toBe("1");
|
||||
expect(headers["x-grok-session-id"]).toBe(executor._currentSessionId);
|
||||
expect(headers["x-grok-conv-id"]).toBe(executor._currentSessionId);
|
||||
|
||||
const sessionId = executor._currentSessionId;
|
||||
|
||||
// Turn 2: full history with two user messages
|
||||
executor.transformRequest(
|
||||
"grok-4.5",
|
||||
{
|
||||
model: "grok-4.5",
|
||||
input: [
|
||||
{ type: "message", role: "system", content: "sys" },
|
||||
{ type: "message", role: "user", content: "hi" },
|
||||
{ type: "message", role: "assistant", content: "hello" },
|
||||
{ type: "message", role: "user", content: "next" },
|
||||
],
|
||||
},
|
||||
true,
|
||||
creds
|
||||
);
|
||||
expect(executor._currentSessionId).toBe(sessionId);
|
||||
expect(executor._currentTurnIdx).toBe(2);
|
||||
headers = executor.buildHeaders({ accessToken: "t" }, true);
|
||||
expect(headers["x-grok-turn-idx"]).toBe("2");
|
||||
|
||||
// Same session, a new delta-style request advances without relying on full history.
|
||||
executor.transformRequest(
|
||||
"grok-4.5",
|
||||
{
|
||||
model: "grok-4.5",
|
||||
input: [{ type: "message", role: "user", content: "only latest" }],
|
||||
},
|
||||
true,
|
||||
creds
|
||||
);
|
||||
expect(executor._currentTurnIdx).toBe(3);
|
||||
});
|
||||
|
||||
it("countGrokCliUserTurns / resolveGrokCliTurnIdx helpers", () => {
|
||||
expect(countGrokCliUserTurns(null)).toBe(1);
|
||||
expect(
|
||||
countGrokCliUserTurns([
|
||||
{ type: "message", role: "system", content: "s" },
|
||||
{ type: "message", role: "user", content: "a" },
|
||||
{ type: "message", role: "assistant", content: "b" },
|
||||
{ type: "message", role: "user", content: "c" },
|
||||
])
|
||||
).toBe(2);
|
||||
|
||||
expect(resolveGrokCliTurnIdx("s1", [{ role: "user", type: "message", content: "a" }])).toBe(1);
|
||||
expect(
|
||||
resolveGrokCliTurnIdx("s1", [
|
||||
{ role: "user", type: "message", content: "a" },
|
||||
{ role: "user", type: "message", content: "b" },
|
||||
])
|
||||
).toBe(2);
|
||||
// monotonic
|
||||
expect(resolveGrokCliTurnIdx("s1", [{ role: "user", type: "message", content: "a" }])).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps fallback session stable when assistant history appears", () => {
|
||||
const creds = { connectionId: "fallback-conn", rawHeaders: {} };
|
||||
executor.transformRequest("grok-build", {
|
||||
model: "grok-build",
|
||||
input: [{ type: "message", role: "user", content: "first" }],
|
||||
}, true, creds);
|
||||
const firstSession = executor._currentSessionId;
|
||||
|
||||
executor.transformRequest("grok-build", {
|
||||
model: "grok-build",
|
||||
input: [
|
||||
{ type: "message", role: "user", content: "first" },
|
||||
{ type: "message", role: "assistant", content: "x".repeat(100) },
|
||||
{ type: "message", role: "user", content: "second" },
|
||||
],
|
||||
}, true, creds);
|
||||
expect(executor._currentSessionId).toBe(firstSession);
|
||||
expect(executor._currentTurnIdx).toBe(2);
|
||||
});
|
||||
|
||||
it("does not advance turn index when retrying the same request body", () => {
|
||||
const body = {
|
||||
model: "grok-build",
|
||||
input: [{ type: "message", role: "user", content: "retry me" }],
|
||||
};
|
||||
const creds = { connectionId: "retry-conn" };
|
||||
executor.transformRequest("grok-build", body, true, creds);
|
||||
const firstTurn = executor._currentTurnIdx;
|
||||
executor.transformRequest("grok-build", body, true, creds);
|
||||
expect(executor._currentTurnIdx).toBe(firstTurn);
|
||||
});
|
||||
|
||||
it("bounds per-session turn state", () => {
|
||||
for (let i = 0; i < 5100; i += 1) {
|
||||
resolveGrokCliTurnIdx(`session-${i}`, [{ role: "user", content: "hi" }]);
|
||||
}
|
||||
expect(_getGrokCliTurnStoreSize()).toBe(5000);
|
||||
});
|
||||
|
||||
it("parseError surfaces 402 spending-limit", () => {
|
||||
const err = executor.parseError(
|
||||
{ status: 402 },
|
||||
JSON.stringify({
|
||||
code: "personal-team-blocked:spending-limit",
|
||||
error: "You have run out of credits",
|
||||
})
|
||||
);
|
||||
expect(err.status).toBe(402);
|
||||
expect(err.code).toBe("personal-team-blocked:spending-limit");
|
||||
expect(err.message).toMatch(/credits/i);
|
||||
});
|
||||
});
|
||||
57
tests/unit/grok-cli-expiresat-2546.test.js
Normal file
57
tests/unit/grok-cli-expiresat-2546.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Regression test for issue #2546: Grok CLI (xAI) token refresh not used,
|
||||
* session dies 40-45 min after login.
|
||||
*
|
||||
* Root cause: grok-cli mapTokens stored `expiresIn` but never `expiresAt`.
|
||||
* shouldRefreshCredentials() only reads expiresAt/tokenExpiresAt, so the
|
||||
* proactive refresh path never fired and only the reactive 401 path could
|
||||
* refresh — causing intermittent "token expired" failures.
|
||||
*
|
||||
* This test exercises the proactive-refresh decision path for grok-cli with
|
||||
* an absolute expiresAt. (The mapTokens unit portion cannot run in this
|
||||
* checkout because src/lib/oauth/providers.js self-imports the bare
|
||||
* "open-sse/index.js" specifier which vitest here does not resolve — a
|
||||
* pre-existing harness gap unrelated to this fix.)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe("Grok CLI (xAI) token expiry propagation (#2546)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("proactive refresh fires for a near-expiry grok-cli token (expiresAt present)", async () => {
|
||||
const { shouldRefreshCredentials } = await import(
|
||||
"../../open-sse/services/oauthCredentialManager.js"
|
||||
);
|
||||
const soon = new Date(Date.now() + 60 * 1000).toISOString();
|
||||
const creds = {
|
||||
connectionId: "grok-1",
|
||||
refreshToken: "rt",
|
||||
expiresIn: 60,
|
||||
expiresAt: soon,
|
||||
};
|
||||
expect(shouldRefreshCredentials("grok-cli", creds)).toBe(true);
|
||||
});
|
||||
|
||||
it("proactive refresh does NOT fire for a far-future grok-cli token", async () => {
|
||||
const { shouldRefreshCredentials } = await import(
|
||||
"../../open-sse/services/oauthCredentialManager.js"
|
||||
);
|
||||
const farFuture = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const creds = {
|
||||
connectionId: "grok-2",
|
||||
refreshToken: "rt",
|
||||
expiresIn: 86400,
|
||||
expiresAt: farFuture,
|
||||
};
|
||||
expect(shouldRefreshCredentials("grok-cli", creds)).toBe(false);
|
||||
});
|
||||
});
|
||||
80
tests/unit/grok-cli-models.test.js
Normal file
80
tests/unit/grok-cli-models.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/services/oauthCredentialManager.js", () => ({
|
||||
refreshProviderCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
import { refreshProviderCredentials } from "../../open-sse/services/oauthCredentialManager.js";
|
||||
import {
|
||||
parseGrokCliModels,
|
||||
resolveGrokCliModels,
|
||||
} from "../../open-sse/services/grokCliModels.js";
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Grok CLI live models", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("normalizes official model metadata", () => {
|
||||
expect(parseGrokCliModels({
|
||||
models: [{
|
||||
model_id: "grok-build",
|
||||
display_name: "Grok Build",
|
||||
context_window: 500000,
|
||||
max_output_tokens: 64000,
|
||||
supported_in_api: false,
|
||||
}],
|
||||
})).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "grok-build",
|
||||
name: "Grok Build",
|
||||
contextLength: 500000,
|
||||
maxOutputTokens: 64000,
|
||||
supported_in_api: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refreshes and retries through selected proxy", async () => {
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "expired" }, 401))
|
||||
.mockResolvedValueOnce(jsonResponse({ data: [{ id: "grok-build" }] }));
|
||||
const onCredentialsRefreshed = vi.fn();
|
||||
const proxyOptions = {
|
||||
connectionProxyEnabled: true,
|
||||
connectionProxyUrl: "http://proxy.test:8080",
|
||||
strictProxy: true,
|
||||
};
|
||||
refreshProviderCredentials.mockResolvedValue({ accessToken: "new-token" });
|
||||
|
||||
const result = await resolveGrokCliModels({
|
||||
accessToken: "old-token",
|
||||
refreshToken: "refresh-token",
|
||||
providerSpecificData: { email: "user@example.com" },
|
||||
}, { fetchFn, proxyOptions, onCredentialsRefreshed });
|
||||
|
||||
expect(result.models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "grok-build",
|
||||
contextLength: 500000,
|
||||
maxOutputTokens: 64000,
|
||||
}),
|
||||
]);
|
||||
expect(refreshProviderCredentials).toHaveBeenCalledWith(
|
||||
"grok-cli",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
proxyOptions,
|
||||
);
|
||||
expect(onCredentialsRefreshed).toHaveBeenCalledWith({ accessToken: "new-token" });
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
expect(fetchFn.mock.calls[0][2]).toBe(proxyOptions);
|
||||
expect(fetchFn.mock.calls[1][1].headers.Authorization).toBe("Bearer new-token");
|
||||
expect(fetchFn.mock.calls[1][1].headers["x-grok-client-version"]).toBe("0.2.99");
|
||||
});
|
||||
});
|
||||
50
tests/unit/grok-cli-oauth-probe.test.js
Normal file
50
tests/unit/grok-cli-oauth-probe.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Grok CLI connection-test semantics: 402 spending-limit is soft success (auth OK).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { classifyOAuthProbeResult } from "../../src/app/api/providers/[id]/test/testUtils.js";
|
||||
import { PROVIDERS } from "../../open-sse/providers/index.js";
|
||||
|
||||
const GROK_CLI_PROBE = {
|
||||
url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
|
||||
method: "GET",
|
||||
acceptStatuses: [402],
|
||||
softFailMessage: {
|
||||
402: "Connected, but Grok Build credits are exhausted (spending limit). Add credits or upgrade SuperGrok.",
|
||||
},
|
||||
};
|
||||
|
||||
describe("classifyOAuthProbeResult (grok-cli)", () => {
|
||||
it("treats 200 as hard success", () => {
|
||||
const r = classifyOAuthProbeResult({ ok: true, status: 200 }, GROK_CLI_PROBE, "");
|
||||
expect(r).toEqual({ valid: true, error: null, soft: false });
|
||||
});
|
||||
|
||||
it("treats 402 spending-limit as soft success (connected, out of credits)", () => {
|
||||
const body = JSON.stringify({
|
||||
code: "personal-team-blocked:spending-limit",
|
||||
error: "You have run out of credits",
|
||||
});
|
||||
const r = classifyOAuthProbeResult({ ok: false, status: 402 }, GROK_CLI_PROBE, body);
|
||||
expect(r.valid).toBe(true);
|
||||
expect(r.soft).toBe(true);
|
||||
expect(r.error).toMatch(/credits|SuperGrok|spending/i);
|
||||
});
|
||||
|
||||
it("treats 401 as hard auth failure", () => {
|
||||
const r = classifyOAuthProbeResult({ ok: false, status: 401 }, GROK_CLI_PROBE, "unauthorized");
|
||||
expect(r).toEqual({ valid: false, error: "Token invalid or revoked", soft: false });
|
||||
});
|
||||
|
||||
it("treats 403 as access denied", () => {
|
||||
const r = classifyOAuthProbeResult({ ok: false, status: 403 }, GROK_CLI_PROBE, "");
|
||||
expect(r.valid).toBe(false);
|
||||
expect(r.error).toMatch(/Access denied/i);
|
||||
});
|
||||
|
||||
it("Codex-style acceptStatuses 400 stays silent success (no soft warning)", () => {
|
||||
const codex = { acceptStatuses: [400] };
|
||||
const r = classifyOAuthProbeResult({ ok: false, status: 400 }, codex, "bad request");
|
||||
expect(r).toEqual({ valid: true, error: null, soft: false });
|
||||
});
|
||||
});
|
||||
229
tests/unit/grok-cli-quota-frame.test.js
Normal file
229
tests/unit/grok-cli-quota-frame.test.js
Normal file
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
decodeGrokCreditsFrame,
|
||||
probeFrameHeader,
|
||||
} from "../../open-sse/services/usage/grokCliQuotaFrame.js";
|
||||
|
||||
/**
|
||||
* Minimal protobuf encoder for fixtures — real GetGrokCreditsConfig wire shape
|
||||
* (nested field 1 / fixed32 ratio / Timestamp reset + optional trailer 0x80).
|
||||
*/
|
||||
|
||||
function encodeVarint(value) {
|
||||
const bytes = [];
|
||||
let v = BigInt(value);
|
||||
do {
|
||||
let byte = Number(v & 0x7fn);
|
||||
v >>= 7n;
|
||||
if (v !== 0n) byte |= 0x80;
|
||||
bytes.push(byte);
|
||||
} while (v !== 0n);
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function encodeTag(fieldNumber, wireType) {
|
||||
return encodeVarint((fieldNumber << 3) | wireType);
|
||||
}
|
||||
|
||||
function encodeFixed32Field(fieldNumber, value) {
|
||||
const body = Buffer.alloc(4);
|
||||
body.writeFloatLE(value, 0);
|
||||
return Buffer.concat([encodeTag(fieldNumber, 5), body]);
|
||||
}
|
||||
|
||||
function encodeLengthDelimited(fieldNumber, body) {
|
||||
return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]);
|
||||
}
|
||||
|
||||
function encodeVarintField(fieldNumber, value) {
|
||||
return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]);
|
||||
}
|
||||
|
||||
function encodeTimestampField(fieldNumber, seconds, nanos) {
|
||||
const parts = [];
|
||||
if (seconds !== 0) parts.push(encodeVarintField(1, seconds));
|
||||
if (nanos !== 0) parts.push(encodeVarintField(2, nanos));
|
||||
return encodeLengthDelimited(fieldNumber, Buffer.concat(parts));
|
||||
}
|
||||
|
||||
function encodeCreditsInfo(shape) {
|
||||
const parts = [];
|
||||
if (shape.usageRatio !== undefined) parts.push(encodeFixed32Field(1, shape.usageRatio));
|
||||
if (shape.asOfSeconds !== undefined) {
|
||||
parts.push(encodeTimestampField(4, shape.asOfSeconds, shape.asOfNanos ?? 0));
|
||||
}
|
||||
if (shape.resetSeconds !== undefined) {
|
||||
parts.push(encodeTimestampField(5, shape.resetSeconds, shape.resetNanos ?? 0));
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function encodeTopLevelMessage(creditsInfo) {
|
||||
return encodeLengthDelimited(1, creditsInfo);
|
||||
}
|
||||
|
||||
function frameData(payload) {
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0x00;
|
||||
header.writeUInt32BE(payload.length, 1);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function frameTrailer(statusText = "grpc-status:0\r\n") {
|
||||
const body = Buffer.from(statusText, "utf8");
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0x80;
|
||||
header.writeUInt32BE(body.length, 1);
|
||||
return Buffer.concat([header, body]);
|
||||
}
|
||||
|
||||
const REAL_USAGE_RATIO = 1.0;
|
||||
const REAL_ASOF_SECONDS = 1784221140;
|
||||
const REAL_ASOF_NANOS = 867850000;
|
||||
const REAL_RESET_SECONDS = 1784825940;
|
||||
const REAL_RESET_NANOS = 867850000;
|
||||
const PERCENT_TOLERANCE = 1e-4;
|
||||
|
||||
function isoFromEpoch(seconds, nanos) {
|
||||
return new Date(seconds * 1000 + Math.round(nanos / 1_000_000)).toISOString();
|
||||
}
|
||||
|
||||
describe("decodeGrokCreditsFrame", () => {
|
||||
it("decodes real GetGrokCreditsConfig shape (nested, fixed32, Timestamp, trailer)", () => {
|
||||
const creditsInfo = encodeCreditsInfo({
|
||||
usageRatio: REAL_USAGE_RATIO,
|
||||
asOfSeconds: REAL_ASOF_SECONDS,
|
||||
asOfNanos: REAL_ASOF_NANOS,
|
||||
resetSeconds: REAL_RESET_SECONDS,
|
||||
resetNanos: REAL_RESET_NANOS,
|
||||
});
|
||||
const buffer = Buffer.concat([frameData(encodeTopLevelMessage(creditsInfo)), frameTrailer()]);
|
||||
|
||||
const result = decodeGrokCreditsFrame(buffer);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.percentUsed).toBe(100);
|
||||
expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS));
|
||||
});
|
||||
|
||||
it("ignores trailing gRPC-web trailer frame (flag 0x80)", () => {
|
||||
const creditsInfo = encodeCreditsInfo({
|
||||
usageRatio: 0.5,
|
||||
resetSeconds: REAL_RESET_SECONDS,
|
||||
resetNanos: 0,
|
||||
});
|
||||
const topMessage = encodeTopLevelMessage(creditsInfo);
|
||||
const withoutTrailer = frameData(topMessage);
|
||||
const withTrailer = Buffer.concat([frameData(topMessage), frameTrailer()]);
|
||||
|
||||
const a = decodeGrokCreditsFrame(withoutTrailer);
|
||||
const b = decodeGrokCreditsFrame(withTrailer);
|
||||
expect(a).toBeTruthy();
|
||||
expect(b).toBeTruthy();
|
||||
expect(b.percentUsed).toBe(a.percentUsed);
|
||||
expect(b.resetAt).toBe(a.resetAt);
|
||||
expect(b.percentUsed).toBe(50);
|
||||
});
|
||||
|
||||
it("decodes raw unframed protobuf payload", () => {
|
||||
const creditsInfo = encodeCreditsInfo({
|
||||
usageRatio: 0.75,
|
||||
resetSeconds: REAL_RESET_SECONDS,
|
||||
resetNanos: REAL_RESET_NANOS,
|
||||
});
|
||||
const payload = encodeTopLevelMessage(creditsInfo);
|
||||
expect(probeFrameHeader(payload)).toBeNull();
|
||||
|
||||
const result = decodeGrokCreditsFrame(payload);
|
||||
expect(result).toBeTruthy();
|
||||
expect(Math.abs(result.percentUsed - 75)).toBeLessThan(PERCENT_TOLERANCE);
|
||||
expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS));
|
||||
});
|
||||
|
||||
it("treats omitted usage-ratio as 0% (proto3 default)", () => {
|
||||
const creditsInfo = encodeCreditsInfo({
|
||||
resetSeconds: REAL_RESET_SECONDS,
|
||||
resetNanos: REAL_RESET_NANOS,
|
||||
});
|
||||
const result = decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)));
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.percentUsed).toBe(0);
|
||||
expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS));
|
||||
});
|
||||
|
||||
it("clamps usage ratio above 1.0 to percentUsed 100", () => {
|
||||
const creditsInfo = encodeCreditsInfo({ usageRatio: 1.5 });
|
||||
const result = decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)));
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.percentUsed).toBe(100);
|
||||
});
|
||||
|
||||
it("returns null for negative usage ratio", () => {
|
||||
const creditsInfo = encodeCreditsInfo({ usageRatio: -0.1 });
|
||||
expect(decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when top-level field 1 is not length-delimited", () => {
|
||||
expect(decodeGrokCreditsFrame(frameData(encodeVarintField(1, 42)))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when nested usage-ratio has unexpected wire type", () => {
|
||||
const creditsInfo = encodeLengthDelimited(1, Buffer.from("not-a-float", "utf8"));
|
||||
expect(decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when top-level has no field 1", () => {
|
||||
expect(decodeGrokCreditsFrame(frameData(encodeVarintField(9, 1)))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for truncated buffer", () => {
|
||||
const creditsInfo = encodeCreditsInfo({
|
||||
usageRatio: 0.5,
|
||||
resetSeconds: REAL_RESET_SECONDS,
|
||||
resetNanos: REAL_RESET_NANOS,
|
||||
});
|
||||
const buffer = frameData(encodeTopLevelMessage(creditsInfo));
|
||||
expect(decodeGrokCreditsFrame(buffer.subarray(0, buffer.length - 3))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for trailer-only body", () => {
|
||||
expect(decodeGrokCreditsFrame(frameTrailer())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty buffer", () => {
|
||||
expect(decodeGrokCreditsFrame(Buffer.alloc(0))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeFrameHeader", () => {
|
||||
it("rejects declared length that exceeds body", () => {
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0x00;
|
||||
header.writeUInt32BE(9999, 1);
|
||||
expect(probeFrameHeader(Buffer.concat([header, Buffer.from([0x01, 0x02])]))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid compression flag", () => {
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0x07;
|
||||
expect(probeFrameHeader(header)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts trailer frame header (flag 0x80)", () => {
|
||||
const result = probeFrameHeader(frameTrailer());
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.flag).toBe(0x80);
|
||||
});
|
||||
|
||||
it("reads frame header at non-zero offset", () => {
|
||||
const creditsInfo = encodeCreditsInfo({ usageRatio: 0.5 });
|
||||
const buffer = Buffer.concat([
|
||||
frameData(encodeTopLevelMessage(creditsInfo)),
|
||||
frameTrailer(),
|
||||
]);
|
||||
const first = probeFrameHeader(buffer);
|
||||
expect(first).toBeTruthy();
|
||||
const second = probeFrameHeader(buffer, first.payloadStart + first.payloadLength);
|
||||
expect(second).toBeTruthy();
|
||||
expect(second.flag).toBe(0x80);
|
||||
});
|
||||
});
|
||||
426
tests/unit/grok-cli-usage.test.js
Normal file
426
tests/unit/grok-cli-usage.test.js
Normal file
@@ -0,0 +1,426 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.js";
|
||||
import { parseGrokCliBilling } from "../../open-sse/services/usage/grok-cli.js";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.js";
|
||||
import { PROVIDERS } from "../../open-sse/providers/index.js";
|
||||
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const EXHAUSTED_BILLING = {
|
||||
config: {
|
||||
currentPeriod: {
|
||||
type: "USAGE_PERIOD_TYPE_WEEKLY",
|
||||
start: "2026-07-08T00:00:00+00:00",
|
||||
end: "2026-07-15T00:00:00+00:00",
|
||||
},
|
||||
onDemandCap: { val: 0 },
|
||||
onDemandUsed: { val: 0 },
|
||||
isUnifiedBillingUser: true,
|
||||
prepaidBalance: { val: 0 },
|
||||
topUpMethod: "TOP_UP_METHOD_SAVED_PAYMENT_METHOD",
|
||||
billingPeriodStart: "2026-07-08T00:00:00+00:00",
|
||||
billingPeriodEnd: "2026-07-15T00:00:00+00:00",
|
||||
},
|
||||
};
|
||||
|
||||
const ACTIVE_BILLING = {
|
||||
config: {
|
||||
currentPeriod: {
|
||||
type: "USAGE_PERIOD_TYPE_WEEKLY",
|
||||
start: "2026-07-08T00:00:00+00:00",
|
||||
end: "2026-07-15T00:00:00+00:00",
|
||||
},
|
||||
onDemandCap: { val: 100 },
|
||||
onDemandUsed: { val: 35 },
|
||||
isUnifiedBillingUser: true,
|
||||
prepaidBalance: { val: 12.5 },
|
||||
billingPeriodStart: "2026-07-08T00:00:00+00:00",
|
||||
billingPeriodEnd: "2026-07-15T00:00:00+00:00",
|
||||
},
|
||||
};
|
||||
|
||||
const USER_PROFILE = {
|
||||
userId: "d84768dd-224d-4052-ba49-0d336fa9160c",
|
||||
email: "user@example.com",
|
||||
hasGrokCodeAccess: true,
|
||||
subscriptionTier: null,
|
||||
};
|
||||
|
||||
describe("grok-cli registry usage flag", () => {
|
||||
it("exposes transport.usage urls", () => {
|
||||
const cfg = PROVIDERS["grok-cli"];
|
||||
expect(cfg.usage?.url).toContain("/v1/billing");
|
||||
expect(cfg.usage?.userUrl).toContain("/v1/user");
|
||||
});
|
||||
|
||||
it("is listed in USAGE_SUPPORTED_PROVIDERS", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("grok-cli");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGrokCliBilling", () => {
|
||||
it("maps on-demand cap/used + prepaid balance", () => {
|
||||
const parsed = parseGrokCliBilling(ACTIVE_BILLING, USER_PROFILE);
|
||||
expect(parsed.plan).toBe("Grok Code");
|
||||
expect(parsed.quotas["On-demand"]).toMatchObject({
|
||||
used: 35,
|
||||
total: 100,
|
||||
remainingPercentage: 65,
|
||||
});
|
||||
// Prepaid is remaining-balance style: 0 used of current pot
|
||||
expect(parsed.quotas.Prepaid).toMatchObject({
|
||||
used: 0,
|
||||
total: 12.5,
|
||||
remainingPercentage: 100,
|
||||
});
|
||||
expect(parsed.exhausted).toBe(false);
|
||||
});
|
||||
|
||||
it("marks depleted free/promo account as exhausted", () => {
|
||||
const parsed = parseGrokCliBilling(EXHAUSTED_BILLING, USER_PROFILE);
|
||||
expect(parsed.quotas["On-demand"].remainingPercentage).toBe(0);
|
||||
expect(parsed.exhausted).toBe(true);
|
||||
});
|
||||
|
||||
it("uses subscriptionTier for plan when present", () => {
|
||||
const parsed = parseGrokCliBilling(ACTIVE_BILLING, {
|
||||
...USER_PROFILE,
|
||||
subscriptionTier: "super_grok",
|
||||
});
|
||||
expect(parsed.plan).toBe("Super Grok");
|
||||
});
|
||||
|
||||
it("does not report paid subscription access as depleted on-demand credit", () => {
|
||||
const parsed = parseGrokCliBilling(EXHAUSTED_BILLING, {
|
||||
...USER_PROFILE,
|
||||
subscriptionTier: "XPremiumPlus",
|
||||
});
|
||||
expect(parsed.plan).toBe("XPremiumPlus");
|
||||
expect(parsed.subscriptionAccess).toBe(true);
|
||||
expect(parsed.quotas).toEqual({});
|
||||
expect(parsed.exhausted).toBe(false);
|
||||
});
|
||||
|
||||
it("maps creditUsagePercent to a single Weekly SuperGrok bar (not productUsage)", () => {
|
||||
const parsed = parseGrokCliBilling(
|
||||
{
|
||||
config: {
|
||||
currentPeriod: {
|
||||
type: "USAGE_PERIOD_TYPE_WEEKLY",
|
||||
start: "2026-07-17T12:42:26.494595+00:00",
|
||||
end: "2026-07-24T12:42:26.494595+00:00",
|
||||
},
|
||||
creditUsagePercent: 99.0,
|
||||
onDemandCap: { val: 0 },
|
||||
onDemandUsed: { val: 0 },
|
||||
productUsage: [
|
||||
{ product: "GrokBuild", usagePercent: 97.0 },
|
||||
{ product: "GrokImagine", usagePercent: 2.0 },
|
||||
],
|
||||
isUnifiedBillingUser: true,
|
||||
prepaidBalance: { val: 0 },
|
||||
billingPeriodStart: "2026-07-17T12:42:26.494595+00:00",
|
||||
billingPeriodEnd: "2026-07-24T12:42:26.494595+00:00",
|
||||
},
|
||||
},
|
||||
{ subscriptionTier: "XPremiumPlus", hasGrokCodeAccess: true },
|
||||
);
|
||||
// Single shared-pool bar from creditUsagePercent
|
||||
expect(parsed.quotas["Weekly SuperGrok"]).toMatchObject({
|
||||
used: 99,
|
||||
total: 100,
|
||||
remainingPercentage: 1,
|
||||
resetAt: "2026-07-24T12:42:26.494Z",
|
||||
unlimited: false,
|
||||
});
|
||||
// productUsage must NOT become independent quota bars
|
||||
expect(Object.keys(parsed.quotas)).toEqual(["Weekly SuperGrok"]);
|
||||
expect(parsed.exhausted).toBe(false);
|
||||
});
|
||||
|
||||
it("maps current monthly fields and snake-case subscription tier", () => {
|
||||
const parsed = parseGrokCliBilling({
|
||||
monthlyLimit: { val: 1000 },
|
||||
includedUsed: { val: 275 },
|
||||
totalUsed: { val: 300 },
|
||||
resetAt: "2026-08-01T00:00:00Z",
|
||||
}, {
|
||||
subscription_tier: "premium_plus",
|
||||
});
|
||||
expect(parsed.plan).toBe("Premium Plus");
|
||||
expect(parsed.quotas["Monthly included"]).toMatchObject({
|
||||
used: 275,
|
||||
total: 1000,
|
||||
remainingPercentage: 72.5,
|
||||
resetAt: "2026-08-01T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function encodeVarint(value) {
|
||||
const bytes = [];
|
||||
let v = BigInt(value);
|
||||
do {
|
||||
let byte = Number(v & 0x7fn);
|
||||
v >>= 7n;
|
||||
if (v !== 0n) byte |= 0x80;
|
||||
bytes.push(byte);
|
||||
} while (v !== 0n);
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function encodeTag(fieldNumber, wireType) {
|
||||
return encodeVarint((fieldNumber << 3) | wireType);
|
||||
}
|
||||
|
||||
function encodeFixed32Field(fieldNumber, value) {
|
||||
const body = Buffer.alloc(4);
|
||||
body.writeFloatLE(value, 0);
|
||||
return Buffer.concat([encodeTag(fieldNumber, 5), body]);
|
||||
}
|
||||
|
||||
function encodeLengthDelimited(fieldNumber, body) {
|
||||
return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]);
|
||||
}
|
||||
|
||||
function encodeVarintField(fieldNumber, value) {
|
||||
return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]);
|
||||
}
|
||||
|
||||
function encodeTimestampField(fieldNumber, seconds, nanos) {
|
||||
const parts = [];
|
||||
if (seconds !== 0) parts.push(encodeVarintField(1, seconds));
|
||||
if (nanos !== 0) parts.push(encodeVarintField(2, nanos));
|
||||
return encodeLengthDelimited(fieldNumber, Buffer.concat(parts));
|
||||
}
|
||||
|
||||
/** Framed GetGrokCreditsConfig response for a usage ratio 0..1. */
|
||||
function buildCreditsResponseBuffer(usageRatio, resetSeconds = 1784825940, resetNanos = 867850000) {
|
||||
const creditsInfo = Buffer.concat([
|
||||
encodeFixed32Field(1, usageRatio),
|
||||
encodeTimestampField(5, resetSeconds, resetNanos),
|
||||
]);
|
||||
const topMessage = encodeLengthDelimited(1, creditsInfo);
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0x00;
|
||||
header.writeUInt32BE(topMessage.length, 1);
|
||||
return Buffer.concat([header, topMessage]);
|
||||
}
|
||||
|
||||
function binaryResponse(buffer, status = 200) {
|
||||
return new Response(buffer, {
|
||||
status,
|
||||
headers: { "content-type": "application/grpc-web+proto" },
|
||||
});
|
||||
}
|
||||
|
||||
function accessTokenWithTier(tier) {
|
||||
const payload = Buffer.from(JSON.stringify({ tier })).toString("base64url");
|
||||
return `header.${payload}.signature`;
|
||||
}
|
||||
|
||||
const EMPTY_GRPC_WEB_FRAME = Buffer.from([0, 0, 0, 0, 0]);
|
||||
const GRPC_CREDITS_URL =
|
||||
"https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig";
|
||||
|
||||
describe("getUsageForProvider(grok-cli)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns normalized quotas from billing + user endpoints", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(ACTIVE_BILLING))
|
||||
.mockResolvedValueOnce(jsonResponse(USER_PROFILE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "grok-cli",
|
||||
accessToken: "test-token",
|
||||
providerSpecificData: {
|
||||
email: "user@example.com",
|
||||
userId: "d84768dd-224d-4052-ba49-0d336fa9160c",
|
||||
},
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("Grok Code");
|
||||
expect(usage.quotas["On-demand"]).toMatchObject({
|
||||
used: 35,
|
||||
total: 100,
|
||||
remainingPercentage: 65,
|
||||
});
|
||||
expect(usage.quotas.Prepaid).toMatchObject({
|
||||
used: 0,
|
||||
total: 12.5,
|
||||
remainingPercentage: 100,
|
||||
});
|
||||
|
||||
// Official CLI fingerprint headers
|
||||
const billingCall = proxyAwareFetch.mock.calls[0];
|
||||
expect(billingCall[0]).toContain("/v1/billing");
|
||||
expect(billingCall[1].headers.Authorization).toBe("Bearer test-token");
|
||||
expect(billingCall[1].headers["x-xai-token-auth"]).toBe("xai-grok-cli");
|
||||
expect(billingCall[1].headers["x-grok-client-version"]).toBe("0.2.99");
|
||||
expect(billingCall[1].headers["x-grok-client-identifier"]).toBe("grok-shell");
|
||||
expect(billingCall[1].headers["x-userid"]).toBe(
|
||||
"d84768dd-224d-4052-ba49-0d336fa9160c",
|
||||
);
|
||||
// REST already has numeric quotas — do not hit gRPC fallback
|
||||
expect(proxyAwareFetch.mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("surfaces auth-expired message on 401", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401))
|
||||
.mockResolvedValueOnce(jsonResponse(USER_PROFILE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "grok-cli",
|
||||
accessToken: "expired",
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/expired|re-authorize/i);
|
||||
// Auth failure must not attempt gRPC fallback
|
||||
expect(proxyAwareFetch.mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns depleted on-demand bar without blocking message when cap is zero", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING))
|
||||
.mockResolvedValueOnce(jsonResponse(USER_PROFILE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "grok-cli",
|
||||
accessToken: "test-token",
|
||||
});
|
||||
|
||||
// Dashboard hides QuotaTable when `message` is set — keep message empty
|
||||
// so the 0% bar still renders for exhausted free/promo accounts.
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.quotas["On-demand"].remainingPercentage).toBe(0);
|
||||
expect(usage.quotas["On-demand"].total).toBe(1);
|
||||
// Exhausted free already has a quota bar — no gRPC fallback
|
||||
expect(proxyAwareFetch.mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("falls back to GetGrokCreditsConfig gRPC when paid sub has no REST numeric quota", async () => {
|
||||
const accessToken = accessTokenWithTier(5);
|
||||
const resetSeconds = 1784825940;
|
||||
const resetNanos = 867850000;
|
||||
const resetAt = new Date(
|
||||
resetSeconds * 1000 + Math.round(resetNanos / 1_000_000),
|
||||
).toISOString();
|
||||
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
...USER_PROFILE,
|
||||
subscriptionTier: "XPremiumPlus",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(binaryResponse(buildCreditsResponseBuffer(0.35, resetSeconds, resetNanos)));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "grok-cli",
|
||||
accessToken,
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("SuperGrok Heavy");
|
||||
expect(usage.quotas["Weekly SuperGrok"]).toMatchObject({
|
||||
used: 35,
|
||||
total: 100,
|
||||
remainingPercentage: 65,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
});
|
||||
|
||||
const grpcCall = proxyAwareFetch.mock.calls[2];
|
||||
expect(grpcCall[0]).toBe(GRPC_CREDITS_URL);
|
||||
expect(grpcCall[1].method).toBe("POST");
|
||||
expect(grpcCall[1].headers.Authorization).toBe(`Bearer ${accessToken}`);
|
||||
expect(grpcCall[1].headers["Content-Type"]).toBe("application/grpc-web+proto");
|
||||
expect(grpcCall[1].headers["X-Grpc-Web"]).toBe("1");
|
||||
// Empty gRPC-web request frame is required (flag 0 + length 0)
|
||||
expect(Buffer.from(grpcCall[1].body)).toEqual(EMPTY_GRPC_WEB_FRAME);
|
||||
});
|
||||
|
||||
it("keeps subscription message when REST empty and gRPC fails open", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
...USER_PROFILE,
|
||||
subscriptionTier: "XPremiumPlus",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(binaryResponse(Buffer.alloc(0), 500));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "grok-cli",
|
||||
accessToken: "test-token",
|
||||
});
|
||||
|
||||
expect(usage.plan).toBe("XPremiumPlus");
|
||||
expect(usage.message).toMatch(/active.*numeric included quota/i);
|
||||
expect(usage.quotas).toEqual({});
|
||||
});
|
||||
|
||||
it("does not throw when gRPC network fails after empty REST quotas", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
...USER_PROFILE,
|
||||
subscriptionTier: "XPremiumPlus",
|
||||
}),
|
||||
)
|
||||
.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "grok-cli",
|
||||
accessToken: "test-token",
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/active.*numeric included quota/i);
|
||||
expect(usage.quotas).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuotaData(grok-cli)", () => {
|
||||
it("forwards remainingPercentage for dashboard bars", () => {
|
||||
const rows = parseQuotaData("grok-cli", {
|
||||
plan: "Grok Code",
|
||||
quotas: {
|
||||
"On-demand": {
|
||||
used: 35,
|
||||
total: 100,
|
||||
remaining: 65,
|
||||
remainingPercentage: 65,
|
||||
resetAt: "2026-07-15T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: "On-demand",
|
||||
used: 35,
|
||||
total: 100,
|
||||
remainingPercentage: 65,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -250,4 +250,48 @@ describe("handleChatCore Headroom diagnostics", () => {
|
||||
expect.stringContaining("reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload")
|
||||
);
|
||||
});
|
||||
|
||||
it("bypasses token savers when requested by the client", async () => {
|
||||
const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn() };
|
||||
const pxpipeTransform = vi.fn();
|
||||
const messages = [{ role: "user", content: "Write polished prose." }];
|
||||
|
||||
global.fetch = vi.fn(async (url) => {
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
await handleChatCore({
|
||||
body: { model: "gpt-4o", stream: false, messages },
|
||||
modelInfo: { provider: "openai", model: "gpt-4o" },
|
||||
credentials: { apiKey: "test-key", providerSpecificData: {} },
|
||||
log,
|
||||
connectionId: "test-conn",
|
||||
headroomEnabled: true,
|
||||
headroomUrl: "http://localhost:8787",
|
||||
headroomCompressUserMessages: true,
|
||||
rtkEnabled: true,
|
||||
cavemanEnabled: true,
|
||||
cavemanLevel: "full",
|
||||
ponytailEnabled: true,
|
||||
ponytailLevel: "full",
|
||||
pxpipeEnabled: true,
|
||||
pxpipeTransform,
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/chat/completions",
|
||||
body: {},
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"x-9router-token-saver": "off",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(pxpipeTransform).not.toHaveBeenCalled();
|
||||
expect(executeMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
messages: [{ role: "user", content: "Write polished prose." }],
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,21 +2,92 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execSync: vi.fn(() => { throw new Error("not found"); }),
|
||||
execFile: vi.fn(() => ({ toString: () => "[object Object]" })),
|
||||
execFileSync: vi.fn(() => Buffer.from(JSON.stringify([
|
||||
{ name: "headroom-ai", version: "0.26.0" },
|
||||
{ name: "tree-sitter", version: "0.25.0" },
|
||||
]))),
|
||||
}));
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
execSync: mocks.execSync,
|
||||
execFile: mocks.execFile,
|
||||
execFileSync: mocks.execFileSync,
|
||||
}));
|
||||
|
||||
import { getHeadroomStatus, isLoopbackHeadroomUrl } from "../../src/lib/headroom/detect.js";
|
||||
import { findPython310, getHeadroomStatus, getInstalledHeadroomExtras, isLoopbackHeadroomUrl } from "../../src/lib/headroom/detect.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("headroom detect", () => {
|
||||
it("detects installed headroom version and extras from pip list", () => {
|
||||
const result = getInstalledHeadroomExtras("python3");
|
||||
|
||||
expect(mocks.execFileSync).toHaveBeenCalledWith(
|
||||
"python3",
|
||||
["-m", "pip", "list", "--format=json", "--disable-pip-version-check"],
|
||||
expect.objectContaining({ windowsHide: true, timeout: 8000 }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
installed: true,
|
||||
version: "0.26.0",
|
||||
extras: { code: true, ml: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the interpreter that actually has headroom-ai installed", () => {
|
||||
// headroom binary lives in a bin dir; the python next to it has headroom-ai.
|
||||
const binPython = "/opt/hr/bin/python3";
|
||||
mocks.execSync.mockImplementation((cmd) => {
|
||||
if (String(cmd).includes("where") || String(cmd).includes("which")) return Buffer.from("/opt/hr/bin/headroom\n");
|
||||
if (String(cmd).includes("--version")) return Buffer.from("Python 3.13.0\n");
|
||||
throw new Error("unexpected execSync");
|
||||
});
|
||||
mocks.execFileSync.mockImplementation((py, args) => {
|
||||
if (args.join(" ") === "-m pip show headroom-ai") {
|
||||
if (py === binPython) return Buffer.from("Name: headroom-ai\nVersion: 0.26.0\n");
|
||||
throw new Error(`not installed in ${py}`);
|
||||
}
|
||||
throw new Error(`unexpected execFileSync: ${py} ${args.join(" ")}`);
|
||||
});
|
||||
|
||||
expect(findPython310()).toBe(binPython);
|
||||
});
|
||||
|
||||
it("keeps top-level installed flag true when extras are readable", async () => {
|
||||
global.fetch = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
mocks.execSync.mockImplementation((cmd) => {
|
||||
if (String(cmd).includes("where") || String(cmd).includes("which")) return Buffer.from("C:/Python/Scripts/headroom.exe\n");
|
||||
if (String(cmd).includes("python3 --version")) return Buffer.from("Python 3.13.0\n");
|
||||
if (String(cmd).includes("python --version")) return Buffer.from("Python 3.13.0\n");
|
||||
throw new Error("unexpected execSync");
|
||||
});
|
||||
mocks.execFileSync.mockImplementation((py, args) => {
|
||||
if (py === "python3" && args.join(" ") === "-m pip show headroom-ai") throw new Error("not installed in python3");
|
||||
if (py === "python" && args.join(" ") === "-m pip show headroom-ai") return Buffer.from("Name: headroom-ai\nVersion: 0.26.0\n");
|
||||
if (py === "python" && args.join(" ").startsWith("-m pip list ")) return Buffer.from(JSON.stringify([
|
||||
{ name: "headroom-ai", version: "0.26.0" },
|
||||
{ name: "tree-sitter", version: "0.25.0" },
|
||||
]));
|
||||
throw new Error(`unexpected execFileSync: ${py} ${args.join(" ")}`);
|
||||
});
|
||||
|
||||
const status = await getHeadroomStatus("http://localhost:8787");
|
||||
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.version).toBe("0.26.0");
|
||||
expect(status.extras).toEqual({ code: true, ml: false });
|
||||
});
|
||||
|
||||
it("treats a reachable external proxy as running without local CLI", async () => {
|
||||
global.fetch = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
mocks.execSync.mockImplementation((cmd) => {
|
||||
if (String(cmd).includes("where") || String(cmd).includes("which")) throw new Error("not found");
|
||||
throw new Error("unexpected execSync");
|
||||
});
|
||||
mocks.execFileSync.mockImplementation(() => { throw new Error("pip unavailable"); });
|
||||
|
||||
const status = await getHeadroomStatus("http://headroom:8787");
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { compressWithHeadroom, formatHeadroomLog } from "../../open-sse/rtk/headroom.js";
|
||||
import { compressWithHeadroom, formatHeadroomLog, formatHeadroomSizeLog } from "../../open-sse/rtk/headroom.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -31,6 +31,10 @@ describe("compressWithHeadroom", () => {
|
||||
expect(body.messages[0].content).toBe("short");
|
||||
expect(stats.tokens_saved).toBe(80);
|
||||
expect(global.fetch).toHaveBeenCalledWith("http://headroom:8787/v1/compress", expect.objectContaining({ method: "POST" }));
|
||||
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toMatchObject({
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "long" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("compresses responses input in-place", async () => {
|
||||
@@ -44,6 +48,141 @@ describe("compressWithHeadroom", () => {
|
||||
expect(body.input[0].content).toBe("short");
|
||||
});
|
||||
|
||||
it("compresses Kiro conversationState history/currentMessage in-place", async () => {
|
||||
let requestPayload;
|
||||
global.fetch = vi.fn(async (_url, init) => {
|
||||
requestPayload = JSON.parse(init.body);
|
||||
return new Response(JSON.stringify({
|
||||
messages: [
|
||||
{ role: "user", content: "compressed earlier user" },
|
||||
{ role: "assistant", content: "compressed assistant", tool_calls: [{ id: "tool_1", type: "function", function: { name: "read_file", arguments: "{\"path\":\"a.js\"}" } }] },
|
||||
{ role: "system", content: "compressed system instruction" },
|
||||
{ role: "user", content: "compressed current user" },
|
||||
{ role: "tool", content: [{ type: "text", text: "compressed tool output" }], tool_call_id: "tool_1" },
|
||||
],
|
||||
tokens_before: 100,
|
||||
tokens_after: 40,
|
||||
tokens_saved: 60,
|
||||
}), { status: 200 });
|
||||
});
|
||||
const body = {
|
||||
profileArn: "arn:test",
|
||||
conversationState: {
|
||||
chatTriggerType: "MANUAL",
|
||||
conversationId: "conv-1",
|
||||
history: [
|
||||
{
|
||||
userInputMessage: {
|
||||
content: "earlier user",
|
||||
modelId: "claude-sonnet-4.5",
|
||||
},
|
||||
},
|
||||
{
|
||||
assistantResponseMessage: {
|
||||
content: "assistant response",
|
||||
toolUses: [
|
||||
{
|
||||
toolUseId: "tool_1",
|
||||
name: "read_file",
|
||||
input: { path: "a.js" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
content: "current user",
|
||||
modelId: "claude-sonnet-4.5",
|
||||
systemInstruction: "native system instruction",
|
||||
userInputMessageContext: {
|
||||
tools: [{ toolSpecification: { name: "read_file" } }],
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: "tool_1",
|
||||
status: "success",
|
||||
content: [{ text: "long tool output" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stats = await compressWithHeadroom(body, {
|
||||
enabled: true,
|
||||
url: "http://localhost:8787",
|
||||
model: "claude-sonnet-4.5",
|
||||
format: "kiro",
|
||||
compressUserMessages: true,
|
||||
});
|
||||
|
||||
expect(stats.tokens_saved).toBe(60);
|
||||
expect(requestPayload).toEqual({
|
||||
model: "claude-sonnet-4.5",
|
||||
config: { compress_user_messages: true },
|
||||
messages: [
|
||||
{ role: "user", content: "earlier user" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "assistant response",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "tool_1",
|
||||
type: "function",
|
||||
function: { name: "read_file", arguments: "{\"path\":\"a.js\"}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "system", content: "native system instruction" },
|
||||
{ role: "user", content: "current user" },
|
||||
{ role: "tool", content: "long tool output", tool_call_id: "tool_1" },
|
||||
],
|
||||
});
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe("compressed earlier user");
|
||||
expect(body.conversationState.history[1].assistantResponseMessage.content).toBe("compressed assistant");
|
||||
expect(body.conversationState.currentMessage.userInputMessage.systemInstruction).toBe("compressed system instruction");
|
||||
expect(body.conversationState.currentMessage.userInputMessage.content).toBe("compressed current user");
|
||||
expect(body.conversationState.currentMessage.userInputMessage.userInputMessageContext.toolResults[0].content[0].text)
|
||||
.toBe("compressed tool output");
|
||||
expect(body.profileArn).toBe("arn:test");
|
||||
expect(body.conversationState.currentMessage.userInputMessage.userInputMessageContext.tools)
|
||||
.toEqual([{ toolSpecification: { name: "read_file" } }]);
|
||||
});
|
||||
|
||||
it("fails open when Kiro Headroom output does not preserve message order", async () => {
|
||||
global.fetch = vi.fn(async () => new Response(JSON.stringify({
|
||||
messages: [{ role: "assistant", content: "wrong role" }],
|
||||
tokens_saved: 10,
|
||||
}), { status: 200 }));
|
||||
const body = {
|
||||
conversationState: {
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
content: "original",
|
||||
modelId: "claude-sonnet-4.5",
|
||||
},
|
||||
},
|
||||
history: [],
|
||||
},
|
||||
};
|
||||
const original = structuredClone(body);
|
||||
const diagnostics = {};
|
||||
|
||||
const stats = await compressWithHeadroom(body, {
|
||||
enabled: true,
|
||||
url: "http://localhost:8787",
|
||||
model: "claude-sonnet-4.5",
|
||||
format: "kiro",
|
||||
diagnostics,
|
||||
});
|
||||
|
||||
expect(stats).toBeNull();
|
||||
expect(body).toEqual(original);
|
||||
expect(diagnostics.reason).toBe("proxy response did not preserve Kiro message order");
|
||||
});
|
||||
|
||||
it("fails open on bad response", async () => {
|
||||
global.fetch = vi.fn(async () => new Response(JSON.stringify({ error: "bad" }), { status: 500 }));
|
||||
const body = { messages: [{ role: "user", content: "long" }] };
|
||||
@@ -70,4 +209,11 @@ describe("formatHeadroomLog", () => {
|
||||
expect(formatHeadroomLog({ tokens_before: 100, tokens_after: 25, tokens_saved: 75 }))
|
||||
.toBe("reported token delta=75 before=100 after=25 (75.0%)");
|
||||
});
|
||||
|
||||
it("reports effective payload, tool-schema, and tool-history sizes", () => {
|
||||
expect(formatHeadroomSizeLog({
|
||||
before: { bodyBytes: 1000, messageBytes: 800, toolSchemaBytes: 100, toolHistoryBytes: 500 },
|
||||
after: { bodyBytes: 900, messageBytes: 700, toolSchemaBytes: 100, toolHistoryBytes: 400 },
|
||||
})).toContain("tools=100B→100B toolHistory=500B→400B effective=10.0%");
|
||||
});
|
||||
});
|
||||
|
||||
115
tests/unit/hermes-vision-detection.test.js
Normal file
115
tests/unit/hermes-vision-detection.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
66
tests/unit/jina-reader-fetch.test.js
Normal file
66
tests/unit/jina-reader-fetch.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleFetchCore } from "../../open-sse/handlers/fetch/index.js";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe("Jina Reader fetch", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("uses Jina's JSON POST API instead of embedding the URL in the path", async () => {
|
||||
global.fetch.mockResolvedValueOnce(new Response([
|
||||
"Title: Example page",
|
||||
"",
|
||||
"URL Source: https://example.com/article",
|
||||
"",
|
||||
"Markdown Content:",
|
||||
"Hello",
|
||||
].join("\n")));
|
||||
|
||||
const result = await handleFetchCore({
|
||||
url: "https://example.com/article",
|
||||
format: "markdown",
|
||||
provider: "jina-reader",
|
||||
providerConfig: { timeoutMs: 30000 },
|
||||
credentials: { apiKey: "jina-test-key" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.title).toBe("Example page");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [requestUrl, init] = global.fetch.mock.calls[0];
|
||||
expect(requestUrl).toBe("https://r.jina.ai/");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toEqual({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer jina-test-key",
|
||||
});
|
||||
expect(JSON.parse(init.body)).toEqual({ url: "https://example.com/article" });
|
||||
});
|
||||
|
||||
it("returns the upstream status and error body", async () => {
|
||||
global.fetch.mockResolvedValueOnce(new Response(
|
||||
JSON.stringify({ detail: "Payment required" }),
|
||||
{ status: 402, headers: { "Content-Type": "application/json" } },
|
||||
));
|
||||
|
||||
const result = await handleFetchCore({
|
||||
url: "https://example.com/article",
|
||||
provider: "jina-reader",
|
||||
providerConfig: { timeoutMs: 30000 },
|
||||
credentials: { apiKey: "jina-test-key" },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
status: 402,
|
||||
});
|
||||
expect(result.error).toContain("Payment required");
|
||||
});
|
||||
});
|
||||
299
tests/unit/kimi-usage.test.js
Normal file
299
tests/unit/kimi-usage.test.js
Normal file
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.js";
|
||||
import { USAGE_SUPPORTED_PROVIDERS, USAGE_APIKEY_PROVIDERS } from "../../src/shared/constants/providers.js";
|
||||
import { PROVIDERS } from "../../open-sse/providers/index.js";
|
||||
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const ACTIVE_USAGE = {
|
||||
user: {
|
||||
membership: { level: "LEVEL_ADVANCED" },
|
||||
},
|
||||
usage: {
|
||||
limit: "100",
|
||||
used: "35",
|
||||
remaining: "65",
|
||||
resetTime: "2026-08-01T00:00:00Z",
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
window: { type: "rate" },
|
||||
detail: {
|
||||
limit: "60",
|
||||
remaining: "40",
|
||||
resetTime: "2026-07-29T12:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("kimi registry usage flags", () => {
|
||||
it("exposes usage + usageApikey so OAuth and apikey cards appear on /quota", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("kimi");
|
||||
expect(USAGE_APIKEY_PROVIDERS).toContain("kimi");
|
||||
});
|
||||
|
||||
it("registers transport.usage url when present (optional)", () => {
|
||||
// Provider may or may not put usage url on transport; handler has its own constant.
|
||||
expect(PROVIDERS.kimi).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsageForProvider(kimi) auth selection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("OAuth path: Bearer + X-Msh-* (not chat x-api-key)", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "tok-abc",
|
||||
providerSpecificData: { deviceId: "stable-device-1" },
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("Allegro");
|
||||
expect(usage.quotas.Weekly).toMatchObject({
|
||||
used: 35,
|
||||
total: 100,
|
||||
remainingPercentage: 65,
|
||||
});
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = proxyAwareFetch.mock.calls[0];
|
||||
expect(url).toBe(KIMI_USAGE_URL);
|
||||
expect(opts.method).toBe("GET");
|
||||
expect(opts.headers.Authorization).toBe("Bearer tok-abc");
|
||||
expect(opts.headers["x-api-key"]).toBeUndefined();
|
||||
expect(opts.headers["X-Msh-Platform"]).toBe("9router");
|
||||
expect(opts.headers["X-Msh-Device-Id"]).toBe("stable-device-1");
|
||||
expect(opts.headers["X-Msh-Version"]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("apikey path: x-api-key only (no Bearer / X-Msh)", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
apiKey: "sk-test-123",
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.quotas.Weekly.used).toBe(35);
|
||||
|
||||
const [, opts] = proxyAwareFetch.mock.calls[0];
|
||||
expect(opts.headers["x-api-key"]).toBe("sk-test-123");
|
||||
expect(opts.headers.Authorization).toBeUndefined();
|
||||
expect(opts.headers["X-Msh-Platform"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers apiKey over accessToken when both present", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE));
|
||||
|
||||
await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "tok-abc",
|
||||
apiKey: "sk-prefer-me",
|
||||
});
|
||||
|
||||
const [, opts] = proxyAwareFetch.mock.calls[0];
|
||||
expect(opts.headers["x-api-key"]).toBe("sk-prefer-me");
|
||||
expect(opts.headers.Authorization).toBeUndefined();
|
||||
expect(opts.headers["X-Msh-Platform"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps membership levels to plan display names", async () => {
|
||||
for (const [level, plan] of [
|
||||
["LEVEL_BASIC", "Moderato"],
|
||||
["LEVEL_INTERMEDIATE", "Allegretto"],
|
||||
["LEVEL_ADVANCED", "Allegro"],
|
||||
["LEVEL_STANDARD", "Vivace"],
|
||||
]) {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
user: { membership: { level } },
|
||||
usage: { limit: "10", used: "1", remaining: "9" },
|
||||
}),
|
||||
);
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "t",
|
||||
});
|
||||
expect(usage.plan).toBe(plan);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses Weekly + Ratelimit; does not put absolute remaining on quota rows", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "tok",
|
||||
});
|
||||
|
||||
// Absolute remaining would break getRemainingPercentage (treats it as 0-100 %)
|
||||
expect(usage.quotas.Weekly.remaining).toBeUndefined();
|
||||
expect(usage.quotas.Weekly.remainingPercentage).toBe(65);
|
||||
expect(usage.quotas.Ratelimit).toMatchObject({
|
||||
used: 20,
|
||||
total: 60,
|
||||
remainingPercentage: expect.closeTo(40 / 60 * 100, 5),
|
||||
});
|
||||
expect(usage.quotas.Ratelimit.remaining).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces re-authorize message only on 401 unauthenticated", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
code: "unauthenticated",
|
||||
details: [
|
||||
{
|
||||
debug: {
|
||||
reason: "REASON_INVALID_AUTH_TOKEN",
|
||||
localizedMessage: { message: "Invalid auth token" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
401,
|
||||
),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "expired",
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/expired|re-authorize/i);
|
||||
expect(usage.message).not.toMatch(/subscribe|permission/i);
|
||||
expect(usage.quotas).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps 403 REASON_FEATURE_NO_PERMISSION to subscribe message (not expired)", async () => {
|
||||
// Live capture: valid OAuth JWT still returns 403 permission_denied when
|
||||
// the account has no Kimi Code usage entitlement.
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
code: "permission_denied",
|
||||
details: [
|
||||
{
|
||||
type: "common.error.v1.ErrorDetail",
|
||||
debug: {
|
||||
reason: "REASON_FEATURE_NO_PERMISSION",
|
||||
localizedMessage: {
|
||||
locale: "en-US",
|
||||
message:
|
||||
"You do not have permission to use this feature. Please subscribe to access.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
403,
|
||||
),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "valid-but-no-sub",
|
||||
providerSpecificData: { deviceId: "stable-device-1" },
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/permission|subscribe/i);
|
||||
expect(usage.message).not.toMatch(/expired|re-authorize/i);
|
||||
// Must not trip usage-route AUTH_EXPIRED_PATTERNS force-refresh loop
|
||||
expect(usage.message.toLowerCase()).not.toMatch(/expired|re-authorize|unauthorized|401/);
|
||||
});
|
||||
|
||||
it("formatKimiUsageError distinguishes 401 vs 403 feature gate", async () => {
|
||||
const { formatKimiUsageError } = await import(
|
||||
"../../open-sse/services/usage/kimi.js"
|
||||
);
|
||||
expect(formatKimiUsageError(401, '{"code":"unauthenticated"}')).toMatch(
|
||||
/expired|re-authorize/i,
|
||||
);
|
||||
expect(
|
||||
formatKimiUsageError(
|
||||
403,
|
||||
JSON.stringify({
|
||||
code: "permission_denied",
|
||||
details: [
|
||||
{
|
||||
debug: {
|
||||
reason: "REASON_FEATURE_NO_PERMISSION",
|
||||
localizedMessage: {
|
||||
message: "You do not have permission to use this feature.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toMatch(/permission|subscribe/i);
|
||||
});
|
||||
|
||||
it("returns tracked-per-request message when usage limit missing", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
user: { membership: { level: "LEVEL_BASIC" } },
|
||||
usage: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "tok",
|
||||
});
|
||||
|
||||
expect(usage.plan).toBe("Moderato");
|
||||
expect(usage.message).toMatch(/tracked per request/i);
|
||||
});
|
||||
|
||||
it("returns missing-credentials message when neither token nor key", async () => {
|
||||
const usage = await getUsageForProvider({ provider: "kimi" });
|
||||
expect(usage.message).toMatch(/token|key|credential/i);
|
||||
expect(proxyAwareFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuotaData(kimi)", () => {
|
||||
it("forwards remainingPercentage for dashboard bars", () => {
|
||||
const rows = parseQuotaData("kimi", {
|
||||
plan: "Allegro",
|
||||
quotas: {
|
||||
Weekly: {
|
||||
used: 35,
|
||||
total: 100,
|
||||
remainingPercentage: 65,
|
||||
resetAt: "2026-08-01T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: "Weekly",
|
||||
used: 35,
|
||||
total: 100,
|
||||
remainingPercentage: 65,
|
||||
});
|
||||
});
|
||||
});
|
||||
67
tests/unit/kiro-api-key-endpoint-routing.test.js
Normal file
67
tests/unit/kiro-api-key-endpoint-routing.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { KiroExecutor } from "../../open-sse/executors/kiro.js";
|
||||
|
||||
const RUNTIME = "https://runtime.us-east-1.kiro.dev/generateAssistantResponse";
|
||||
const CODEWHISPERER = "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse";
|
||||
const Q = "https://q.us-east-1.amazonaws.com/generateAssistantResponse";
|
||||
|
||||
function credentials(authMethod, region = "us-east-1") {
|
||||
return { providerSpecificData: { authMethod, region } };
|
||||
}
|
||||
|
||||
describe("Kiro auth-aware endpoint routing", () => {
|
||||
const executor = new KiroExecutor();
|
||||
|
||||
it("routes API-key inference through Amazon Q before other surfaces", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("api_key"))).toEqual([
|
||||
Q,
|
||||
CODEWHISPERER,
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps Builder ID OAuth on the Kiro runtime surface", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("builder-id"))).toEqual([
|
||||
RUNTIME,
|
||||
CODEWHISPERER,
|
||||
Q,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps external IdP on CodeWhisperer before Amazon Q", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("external_idp"))).toEqual([
|
||||
CODEWHISPERER,
|
||||
Q,
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("regionalizes AWS endpoints for IDC without changing Kiro runtime", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("idc", "eu-west-1"))).toEqual([
|
||||
"https://codewhisperer.eu-west-1.amazonaws.com/generateAssistantResponse",
|
||||
"https://q.eu-west-1.amazonaws.com/generateAssistantResponse",
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries only endpoint/auth-surface failures, not payload-invalid 400s", () => {
|
||||
expect(executor.shouldRetry(400, 0)).toBe(false);
|
||||
expect(executor.shouldRetry(401, 1)).toBe(true);
|
||||
expect(executor.shouldRetry(403, 2)).toBe(false);
|
||||
expect(executor.shouldRetry(422, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("builds endpoint-specific headers", () => {
|
||||
const auth = { accessToken: "test-key", providerSpecificData: { authMethod: "api_key" } };
|
||||
const qHeaders = executor.buildHeaders(auth, true, Q);
|
||||
const codeWhispererHeaders = executor.buildHeaders(auth, true, CODEWHISPERER);
|
||||
const runtimeHeaders = executor.buildHeaders(auth, true, RUNTIME);
|
||||
|
||||
expect(qHeaders.TokenType).toBe("API_KEY");
|
||||
expect(qHeaders["X-Amz-Target"]).toBeUndefined();
|
||||
expect(codeWhispererHeaders["X-Amz-Target"]).toBe(
|
||||
"AmazonCodeWhispererStreamingService.GenerateAssistantResponse"
|
||||
);
|
||||
expect(runtimeHeaders["X-Amz-Target"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
372
tests/unit/kiro-conversation-canonicalization.test.js
Normal file
372
tests/unit/kiro-conversation-canonicalization.test.js
Normal file
@@ -0,0 +1,372 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canonicalizeKiroConversation,
|
||||
normalizeKiroToolSpecs,
|
||||
validateKiroConversation,
|
||||
} from "../../open-sse/translator/concerns/kiroConversation.js";
|
||||
import { clearKiroSessionReplayStore } from "../../open-sse/utils/kiroSessionReplay.js";
|
||||
import { clearSessionStore } from "../../open-sse/utils/sessionManager.js";
|
||||
import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js";
|
||||
import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js";
|
||||
|
||||
const modelId = "claude-opus-5";
|
||||
|
||||
function tool(name, schema = { type: "object", properties: {} }) {
|
||||
return { name, description: `Tool ${name}`, input_schema: schema };
|
||||
}
|
||||
|
||||
function specState(names = ["first", "second"]) {
|
||||
const source = names.map((name) => tool(name));
|
||||
return normalizeKiroToolSpecs(source);
|
||||
}
|
||||
|
||||
function user(content, toolResults = []) {
|
||||
return {
|
||||
userInputMessage: {
|
||||
content,
|
||||
modelId,
|
||||
...(toolResults.length > 0 && { userInputMessageContext: { toolResults } }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistant(content, toolUses = []) {
|
||||
return {
|
||||
assistantResponseMessage: {
|
||||
content,
|
||||
...(toolUses.length > 0 && { toolUses }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function result(toolUseId, value, status = "success") {
|
||||
return { toolUseId, status, content: [{ text: value }] };
|
||||
}
|
||||
|
||||
describe("Kiro conversation canonicalizer", () => {
|
||||
beforeEach(() => {
|
||||
clearKiroSessionReplayStore();
|
||||
clearSessionStore();
|
||||
});
|
||||
|
||||
it("keeps complete parallel tool pairs structured", () => {
|
||||
const { specs, nameMap } = specState();
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "t1", name: "first", input: { n: 1 } },
|
||||
{ toolUseId: "t2", name: "second", input: { n: 2 } },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const calls = canonical.history[1].assistantResponseMessage.toolUses;
|
||||
const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults;
|
||||
expect(calls.map((call) => call.toolUseId)).toEqual(["t1", "t2"]);
|
||||
expect(results.map((item) => item.toolUseId)).toEqual(["t1", "t2"]);
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the answered parallel call and flattens only the missing one", () => {
|
||||
const { specs, nameMap } = specState();
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "t1", name: "first", input: {} },
|
||||
{ toolUseId: "t2", name: "second", input: {} },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const assistantMessage = canonical.history[1].assistantResponseMessage;
|
||||
expect(assistantMessage.toolUses).toHaveLength(1);
|
||||
expect(assistantMessage.toolUses[0].toolUseId).toBe("t1");
|
||||
expect(assistantMessage.content).toContain("[Tool call: second(");
|
||||
expect(canonical.repairs.missingResults).toBe(1);
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens non-adjacent and orphaned tool results", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
user("result missing here"),
|
||||
assistant("later"),
|
||||
],
|
||||
currentMessage: user("late result", [result("t1", "too late")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(canonical)).not.toContain('"toolUseId":"t1"');
|
||||
expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:");
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("too late");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("remaps duplicate tool IDs together with their adjacent results", () => {
|
||||
const { specs, nameMap } = specState();
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "duplicate", name: "first", input: {} },
|
||||
{ toolUseId: "duplicate", name: "second", input: {} },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [
|
||||
result("duplicate", "one"),
|
||||
result("duplicate", "two"),
|
||||
]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const calls = canonical.history[1].assistantResponseMessage.toolUses;
|
||||
const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults;
|
||||
expect(new Set(calls.map((call) => call.toolUseId)).size).toBe(2);
|
||||
expect(results.map((item) => item.toolUseId)).toEqual(calls.map((call) => call.toolUseId));
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("deduplicates extra results without losing their text", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one"), result("t1", "duplicate")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const current = canonical.currentMessage.userInputMessage;
|
||||
expect(current.userInputMessageContext.toolResults).toHaveLength(1);
|
||||
expect(current.content).toContain("duplicate");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens a trailing unanswered assistant tool call and creates a current user turn", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [user("start")],
|
||||
currentMessage: assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(canonical.currentMessage.userInputMessage.content).toBe("continue");
|
||||
expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined();
|
||||
expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens malformed input and tool uses missing from the current specs", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "t1", name: "first", input: "{bad json" },
|
||||
{ toolUseId: "t2", name: "removed_tool", input: {} },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined();
|
||||
expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toBeUndefined();
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("one");
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("two");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("repairs a 30-call parallel turn with one missing result", () => {
|
||||
const names = Array.from({ length: 30 }, (_, index) => `tool_${index}`);
|
||||
const { specs, nameMap } = specState(names);
|
||||
const calls = names.map((name, index) => ({
|
||||
toolUseId: `t${index}`,
|
||||
name,
|
||||
input: { index },
|
||||
}));
|
||||
const results = names.slice(0, -1).map((_, index) => result(`t${index}`, `r${index}`));
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [user("start"), assistant("run", calls)],
|
||||
currentMessage: user("continue", results),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(canonical.history[1].assistantResponseMessage.toolUses).toHaveLength(29);
|
||||
expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toHaveLength(29);
|
||||
expect(canonical.repairs.missingResults).toBe(1);
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens structured history when the client sent no tool specs", () => {
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one")]),
|
||||
modelId,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(canonical)).not.toContain("toolUses");
|
||||
expect(JSON.stringify(canonical)).not.toContain("toolResults");
|
||||
expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:");
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("[Tool result:");
|
||||
});
|
||||
|
||||
it("normalizes names and recursively removes unsupported schema fields", () => {
|
||||
const longDescription = "x".repeat(11000);
|
||||
const { specs, nameMap } = normalizeKiroToolSpecs([{
|
||||
name: "bad tool/name",
|
||||
description: longDescription,
|
||||
input_schema: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nested: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
}]);
|
||||
|
||||
const specification = specs[0].toolSpecification;
|
||||
expect(nameMap.get("bad tool/name")).toBe("bad_tool_name");
|
||||
expect(specification.name.length).toBeLessThanOrEqual(64);
|
||||
expect(specification.description.length).toBe(10237);
|
||||
expect(JSON.stringify(specification.inputSchema.json)).not.toContain("additionalProperties");
|
||||
expect(JSON.stringify(specification.inputSchema.json)).not.toContain('"required":[]');
|
||||
});
|
||||
|
||||
it("does not mutate the source conversation or tool definitions", () => {
|
||||
const sourceTools = [tool("first")];
|
||||
const sourceHistory = [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
];
|
||||
const sourceCurrent = user("continue", [result("t1", "one")]);
|
||||
const before = JSON.stringify({ sourceTools, sourceHistory, sourceCurrent });
|
||||
const { specs, nameMap } = normalizeKiroToolSpecs(sourceTools);
|
||||
|
||||
canonicalizeKiroConversation({
|
||||
history: sourceHistory,
|
||||
currentMessage: sourceCurrent,
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(JSON.stringify({ sourceTools, sourceHistory, sourceCurrent })).toBe(before);
|
||||
});
|
||||
|
||||
it("preserves Claude tool_result errors", () => {
|
||||
const output = claudeToKiroRequest(modelId, {
|
||||
tools: [tool("first")],
|
||||
messages: [
|
||||
{ role: "user", content: "start" },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", is_error: true, content: "failed" }] },
|
||||
],
|
||||
}, true, {});
|
||||
|
||||
const item = output.conversationState.currentMessage.userInputMessage
|
||||
.userInputMessageContext.toolResults[0];
|
||||
expect(item.status).toBe("error");
|
||||
});
|
||||
|
||||
it("repairs partial parallel results in both direct translators", () => {
|
||||
const claude = claudeToKiroRequest(modelId, {
|
||||
tools: [tool("first"), tool("second")],
|
||||
messages: [
|
||||
{ role: "user", content: "start" },
|
||||
{ role: "assistant", content: [
|
||||
{ type: "tool_use", id: "t1", name: "first", input: {} },
|
||||
{ type: "tool_use", id: "t2", name: "second", input: {} },
|
||||
] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "one" }] },
|
||||
],
|
||||
}, true, {});
|
||||
const openai = openaiToKiroRequest(modelId, {
|
||||
tools: [
|
||||
{ type: "function", function: { name: "first", parameters: { type: "object", properties: {} } } },
|
||||
{ type: "function", function: { name: "second", parameters: { type: "object", properties: {} } } },
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: "start" },
|
||||
{ role: "assistant", content: "", tool_calls: [
|
||||
{ id: "t1", type: "function", function: { name: "first", arguments: "{}" } },
|
||||
{ id: "t2", type: "function", function: { name: "second", arguments: "{}" } },
|
||||
] },
|
||||
{ role: "tool", tool_call_id: "t1", content: "one" },
|
||||
],
|
||||
}, true, {});
|
||||
|
||||
for (const payload of [claude, openai]) {
|
||||
const state = payload.conversationState;
|
||||
const validation = validateKiroConversation(
|
||||
state.history,
|
||||
state.currentMessage,
|
||||
state.currentMessage.userInputMessage.userInputMessageContext.tools
|
||||
);
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(state.history[1].assistantResponseMessage.toolUses).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let session replay replace a tool-result turn", () => {
|
||||
const credentials = {
|
||||
rawHeaders: { "x-session-id": "kiro-replay-tool-result-regression" },
|
||||
connectionId: "kiro-account",
|
||||
};
|
||||
claudeToKiroRequest(modelId, {
|
||||
messages: [{ role: "user", content: "frozen session start" }],
|
||||
}, true, credentials);
|
||||
|
||||
const output = claudeToKiroRequest(modelId, {
|
||||
tools: [tool("first")],
|
||||
messages: [
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "kept" }] },
|
||||
],
|
||||
}, true, credentials);
|
||||
const state = output.conversationState;
|
||||
const allText = JSON.stringify(state);
|
||||
|
||||
expect(allText).toContain("frozen session start");
|
||||
expect(allText).toContain("kept");
|
||||
expect(validateKiroConversation(
|
||||
state.history,
|
||||
state.currentMessage,
|
||||
state.currentMessage.userInputMessage.userInputMessageContext.tools
|
||||
).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -25,6 +32,13 @@ describe("Kiro MITM model slots", () => {
|
||||
expect(simpleTask).toBeTruthy();
|
||||
expect(simpleTask.alias).toBe("simple-task");
|
||||
});
|
||||
|
||||
it("offers mappable slots for GPT-5.6 family models", () => {
|
||||
const models = new Map(kiro.defaultModels.map((m) => [m.id, m]));
|
||||
expect(models.get("gpt-5.6-sol")).toMatchObject({ alias: "gpt-5.6-sol", contextLength: 272000, rateMultiplier: 2.4 });
|
||||
expect(models.get("gpt-5.6-terra")).toMatchObject({ alias: "gpt-5.6-terra", contextLength: 272000, rateMultiplier: 1.2 });
|
||||
expect(models.get("gpt-5.6-luna")).toMatchObject({ alias: "gpt-5.6-luna", contextLength: 272000, rateMultiplier: 0.6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kiro static provider models", () => {
|
||||
@@ -37,4 +51,47 @@ describe("Kiro static provider models", () => {
|
||||
"claude-sonnet-5-thinking-agentic",
|
||||
]));
|
||||
});
|
||||
|
||||
it("includes GPT-5.6 family and synthetic Kiro variants", () => {
|
||||
const models = new Map((PROVIDER_MODELS.kr || []).map((model) => [model.id, model]));
|
||||
const ids = [...models.keys()];
|
||||
expect(ids).toEqual(expect.arrayContaining([
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-sol-thinking",
|
||||
"gpt-5.6-sol-agentic",
|
||||
"gpt-5.6-sol-thinking-agentic",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.6-terra-thinking",
|
||||
"gpt-5.6-terra-agentic",
|
||||
"gpt-5.6-terra-thinking-agentic",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.6-luna-thinking",
|
||||
"gpt-5.6-luna-agentic",
|
||||
"gpt-5.6-luna-thinking-agentic",
|
||||
]));
|
||||
|
||||
for (const [id, rateMultiplier] of [
|
||||
["gpt-5.6-sol", 2.4],
|
||||
["gpt-5.6-sol-thinking", 2.4],
|
||||
["gpt-5.6-sol-agentic", 2.4],
|
||||
["gpt-5.6-sol-thinking-agentic", 2.4],
|
||||
["gpt-5.6-terra", 1.2],
|
||||
["gpt-5.6-terra-thinking", 1.2],
|
||||
["gpt-5.6-terra-agentic", 1.2],
|
||||
["gpt-5.6-terra-thinking-agentic", 1.2],
|
||||
["gpt-5.6-luna", 0.6],
|
||||
["gpt-5.6-luna-thinking", 0.6],
|
||||
["gpt-5.6-luna-agentic", 0.6],
|
||||
["gpt-5.6-luna-thinking-agentic", 0.6],
|
||||
]) {
|
||||
const model = models.get(id);
|
||||
const upstreamModelId = id.replace(/-(thinking-agentic|thinking|agentic)$/, "");
|
||||
expect(model).toMatchObject({
|
||||
contextLength: 272000,
|
||||
rateMultiplier,
|
||||
upstreamModelId,
|
||||
});
|
||||
expect(model.description).toContain("272k context window");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
64
tests/unit/kiro-nonstream-error.test.js
Normal file
64
tests/unit/kiro-nonstream-error.test.js
Normal file
@@ -0,0 +1,64 @@
|
||||
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 { FORMATS } = await import("../../open-sse/translator/formats.js");
|
||||
const {
|
||||
handleForcedSSEToJson,
|
||||
parseSSEToOpenAIResponse
|
||||
} = await import("../../open-sse/handlers/chatCore/sseToJsonHandler.js");
|
||||
|
||||
describe("Kiro non-streaming error propagation", () => {
|
||||
it("prefers a terminal SSE error over earlier semantic chunks", () => {
|
||||
const raw = [
|
||||
'data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}',
|
||||
'data: {"error":{"message":"Kiro transport failed","code":"kiro_missing_terminal"}}',
|
||||
"data: [DONE]"
|
||||
].join("\n\n");
|
||||
|
||||
expect(parseSSEToOpenAIResponse(raw, "kiro")).toEqual({
|
||||
error: {
|
||||
message: "Kiro transport failed",
|
||||
code: "kiro_missing_terminal"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 502 instead of collapsing a failed Kiro SSE stream into stop", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const raw = [
|
||||
'data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}',
|
||||
'data: {"error":{"message":"Kiro stream ended incompletely","code":"kiro_missing_terminal"}}',
|
||||
"data: [DONE]",
|
||||
""
|
||||
].join("\n\n");
|
||||
const result = await handleForcedSSEToJson({
|
||||
providerResponse: new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(raw));
|
||||
controller.close();
|
||||
}
|
||||
}), { headers: { "content-type": "text/event-stream" } }),
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
provider: "kiro",
|
||||
model: "kr/claude-opus-4.8",
|
||||
body: { model: "kr/claude-opus-4.8", messages: [] },
|
||||
stream: false,
|
||||
requestStartTime: Date.now(),
|
||||
connectionId: "test-connection",
|
||||
clientRawRequest: { endpoint: "/v1/chat/completions" },
|
||||
trackDone: vi.fn(),
|
||||
appendLog: vi.fn()
|
||||
});
|
||||
const json = await result.response.json();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.response.status).toBe(502);
|
||||
expect(json.error.message).toContain("Kiro stream ended incompletely");
|
||||
expect(json).not.toHaveProperty("choices");
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,8 @@ import { KiroService } from "../../src/lib/oauth/services/kiro.js";
|
||||
/**
|
||||
* Regression tests for Kiro API-key auth.
|
||||
*
|
||||
* KiroService.validateApiKey resolves a profileArn with the key (via
|
||||
* CodeWhisperer ListAvailableProfiles) and returns a credential shaped for
|
||||
* persistence with authMethod="api_key". The response profile field name
|
||||
* varies (`arn` vs `profileArn`) — both are accepted by listAvailableProfiles.
|
||||
* KiroService.validateApiKey validates against the Amazon Q model catalog and
|
||||
* returns an account-bound credential without inventing a profileArn.
|
||||
*
|
||||
* Note: OAuth (Builder ID / IDC) profileArn resolution is handled upstream by
|
||||
* fetchKiroProfileArn in providers.js and is covered there — not here.
|
||||
@@ -16,11 +14,10 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("validates an API key and resolves a credential with profileArn", async () => {
|
||||
const expectedArn = "arn:aws:codewhisperer:us-east-1:444:profile/KEY";
|
||||
it("validates an API key against Amazon Q without inventing profileArn", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ profiles: [{ arn: expectedArn }] }),
|
||||
json: async () => ({ models: [{ modelId: "claude-opus-5" }] }),
|
||||
});
|
||||
|
||||
const svc = new KiroService();
|
||||
@@ -29,17 +26,18 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => {
|
||||
expect(cred).toEqual({
|
||||
accessToken: "my-secret-key",
|
||||
refreshToken: null,
|
||||
profileArn: expectedArn,
|
||||
profileArn: null,
|
||||
region: "us-east-1",
|
||||
authMethod: "api_key",
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://codewhisperer.us-east-1.amazonaws.com");
|
||||
expect(init.headers.Authorization).toBe("Bearer my-secret-key");
|
||||
expect(init.headers["x-amz-target"]).toBe(
|
||||
"AmazonCodeWhispererService.ListAvailableProfiles"
|
||||
expect(url).toBe(
|
||||
"https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR"
|
||||
);
|
||||
expect(init.method).toBe("GET");
|
||||
expect(init.headers.Authorization).toBe("Bearer my-secret-key");
|
||||
expect(init.headers.TokenType).toBe("API_KEY");
|
||||
});
|
||||
|
||||
it("rejects an empty API key without a network call", async () => {
|
||||
@@ -60,4 +58,15 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => {
|
||||
/API key validation failed/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a 200 response with an empty model catalog", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ models: [] }),
|
||||
});
|
||||
const svc = new KiroService();
|
||||
await expect(svc.validateApiKey("empty-key")).rejects.toThrow(
|
||||
/returned no available models/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
778
tests/unit/kiro-terminal-integrity.test.js
Normal file
778
tests/unit/kiro-terminal-integrity.test.js
Normal file
@@ -0,0 +1,778 @@
|
||||
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 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 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 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 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" });
|
||||
}
|
||||
|
||||
function controlledResponse(frames = []) {
|
||||
let controller;
|
||||
const value = new Response(new ReadableStream({
|
||||
start(streamController) {
|
||||
controller = streamController;
|
||||
for (const item of frames) controller.enqueue(item);
|
||||
}
|
||||
}), { status: 200 });
|
||||
return {
|
||||
value,
|
||||
enqueue(item) {
|
||||
controller.enqueue(item);
|
||||
},
|
||||
close() {
|
||||
controller.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function text(stream) {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let output = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return output + decoder.decode();
|
||||
output += decoder.decode(value, { stream: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(executor = new KiroExecutor(), overrides = {}) {
|
||||
return executor.execute({
|
||||
model: "kr/claude-opus-4.8",
|
||||
body: { systemPrompt: "base", conversationState: {} },
|
||||
stream: true,
|
||||
credentials,
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES;
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS;
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES;
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS;
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
describe("Kiro terminal integrity recovery", () => {
|
||||
it("keeps semantic output private behind a heartbeat until clean EOF", async () => {
|
||||
const upstream = controlledResponse([
|
||||
frame("assistantResponseEvent", { content: "private until validated" })
|
||||
]);
|
||||
fetchMock.mockResolvedValueOnce(upstream.value);
|
||||
|
||||
const result = await execute();
|
||||
const reader = result.response.body.getReader();
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toBe(": kiro-validation\n\n");
|
||||
|
||||
let settled = false;
|
||||
const semantic = reader.read().then((value) => {
|
||||
settled = true;
|
||||
return value;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
upstream.close();
|
||||
expect(new TextDecoder().decode((await semantic).value)).toContain("private until validated");
|
||||
await reader.cancel();
|
||||
});
|
||||
|
||||
it("accepts CLI-compatible text and usage frames at clean EOF without messageStop", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Complete answer." }),
|
||||
frame("meteringEvent", { usage: 2, unit: "credit" }),
|
||||
frame("contextUsageEvent", { contextUsagePercentage: 10 })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("Complete answer.");
|
||||
expect(body).toContain('"finish_reason":"stop"');
|
||||
expect(body).toContain('"kiro_credits":2');
|
||||
});
|
||||
|
||||
it("parses frames split across chunks and multiple frames in one chunk", async () => {
|
||||
const first = frame("assistantResponseEvent", { content: "split " });
|
||||
const second = frame("assistantResponseEvent", { content: "boundaries" });
|
||||
const combined = concat([first, second]);
|
||||
fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(combined.slice(0, 9));
|
||||
controller.enqueue(combined.slice(9, first.byteLength + 5));
|
||||
controller.enqueue(combined.slice(first.byteLength + 5));
|
||||
controller.close();
|
||||
}
|
||||
})));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(body).toContain('"content":"split "');
|
||||
expect(body).toContain('"content":"boundaries"');
|
||||
expect(body).toContain('"finish_reason":"stop"');
|
||||
});
|
||||
|
||||
it("accepts messageStop without semantic output as explicit completion", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response([frame("messageStopEvent", {})]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain('"finish_reason":"stop"');
|
||||
expect(body).not.toContain("kiro_missing_terminal");
|
||||
});
|
||||
|
||||
it.each(["...", "…"])("repairs exact ellipsis final %s without leaking it", async (ellipsis) => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: ellipsis })]))
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "Recovered answer." })]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Recovered answer.");
|
||||
expect(body).not.toContain(`"content":"${ellipsis}"`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"接下來我只再確認部署結果。",
|
||||
"我會重新抓取最新日誌並確認結果。",
|
||||
"目前證據顯示只在 **03:48:30–03:49:00 TPE** 出現少量 NonKA 504;主池 106/106、副池 50/50,且兩池都沒有重啟。最後補查 504 access log,確認 host/路徑與是否為集中流量。",
|
||||
"Next I'll verify the deployment logs.",
|
||||
"Let me check the remaining failures."
|
||||
])("repairs conservative future-action final: %s", async (progress) => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: progress })]))
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "Verification completed." })]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Verification completed.");
|
||||
expect(body).not.toContain(progress);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Working...",
|
||||
"I'll check the logs. They show no errors and deployment succeeded.",
|
||||
"Let me check: status is 200 and the checksum matches abc123.",
|
||||
"我會檢查版本。版本是 1.2.3。",
|
||||
"接下來請你先批准部署,我會等待你的確認。",
|
||||
"已完成驗證,所有測試均通過。",
|
||||
"目前證據顯示只有少量 504,且主副池均未重啟。",
|
||||
"目前證據顯示只有少量 504。最後補查結果顯示沒有集中流量。",
|
||||
"目前證據顯示只有少量 504。最後補查,結果顯示沒有集中流量。",
|
||||
"目前證據顯示只有少量 504。最後補查:結果顯示沒有集中流量。",
|
||||
"目前證據顯示只有少量 504。最後補查 504 access log,結果顯示沒有集中流量。",
|
||||
"目前證據顯示只有少量 504。最後補查 504 access log,確認 host/路徑與有無集中流量:無集中流量。",
|
||||
"目前證據顯示只有少量 504。最後補查 504 access log,確認 host/路徑與是否為集中流量(答案是否定的)。",
|
||||
"目前證據顯示只有少量 504。最後補充兩點已確認的結果。",
|
||||
"The verification is complete and all tests passed."
|
||||
])("does not retry legitimate final: %s", async (finalText) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: finalText })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain(finalText);
|
||||
});
|
||||
|
||||
it("bounds incomplete-final repair to one retry", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "..." })]))
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "…" })]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("kiro_ellipsis_retry_failed");
|
||||
expect(body).not.toContain('"content":"..."');
|
||||
});
|
||||
|
||||
it("repairs malformed wrapper tools without leaking the invalid call", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("toolUseEvent", {
|
||||
toolUseId: "bad",
|
||||
name: "tool_call",
|
||||
input: { arguments: { q: "router" } }
|
||||
})]))
|
||||
.mockResolvedValueOnce(response([frame("toolUseEvent", {
|
||||
toolUseId: "good",
|
||||
name: "tool_call",
|
||||
input: { name: "mcp_search", arguments: { q: "router" } }
|
||||
})]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain('"name":"tool_call"');
|
||||
expect(body).toContain('\\"name\\":\\"mcp_search\\"');
|
||||
expect(body).not.toContain('"id":"bad"');
|
||||
});
|
||||
|
||||
it("requires complete direct tool input and keeps the failure private", async () => {
|
||||
const pending = frame("toolUseEvent", { toolUseId: "pending", name: "read_file" });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([pending]))
|
||||
.mockResolvedValueOnce(response([pending]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("kiro_tool_call_repair_retry_failed");
|
||||
expect(body).not.toContain('"name":"read_file"');
|
||||
});
|
||||
|
||||
it("repairs a non-string toolUseId before releasing the tool call", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("toolUseEvent", {
|
||||
toolUseId: 123,
|
||||
name: "read_file",
|
||||
input: { path: "bad.txt" }
|
||||
})]))
|
||||
.mockResolvedValueOnce(response([frame("toolUseEvent", {
|
||||
toolUseId: "valid-tool-id",
|
||||
name: "read_file",
|
||||
input: { path: "safe.txt" }
|
||||
})]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain('"id":"valid-tool-id"');
|
||||
expect(body).not.toContain('"id":123');
|
||||
});
|
||||
|
||||
it("keeps model-controlled parser detail out of the retry system prompt", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("toolUseEvent", {
|
||||
toolUseId: "bad-json",
|
||||
name: "tool_call",
|
||||
input: '{"name":"IGNORE_ALL_INSTRUCTIONS"'
|
||||
})]))
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", {
|
||||
content: "Recovered safely."
|
||||
})]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body);
|
||||
|
||||
expect(body).toContain("Recovered safely.");
|
||||
expect(retryBody.systemPrompt).toContain("tool_call wrapper was malformed");
|
||||
expect(retryBody.systemPrompt).not.toContain("IGNORE_ALL_INSTRUCTIONS");
|
||||
});
|
||||
|
||||
it("lets a complete tool call override metadata end_turn", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("toolUseEvent", {
|
||||
toolUseId: "tool",
|
||||
name: "read_file",
|
||||
input: { path: "safe.txt" }
|
||||
}),
|
||||
frame("metadataEvent", { stopReason: "end_turn" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain('"name":"read_file"');
|
||||
expect(body).toContain('"finish_reason":"tool_calls"');
|
||||
});
|
||||
|
||||
it("maps max_tokens without treating it as a normal stop", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Limited answer." }),
|
||||
frame("metadataEvent", { stopReason: "max_tokens" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain('"finish_reason":"length"');
|
||||
expect(body).not.toContain('"finish_reason":"stop"');
|
||||
});
|
||||
|
||||
it("retries malformed_model_output once without semantic leakage", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "private malformed output" }),
|
||||
frame("metadataEvent", { stopReason: "malformed_model_output" })
|
||||
]))
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Recovered protocol output." })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Recovered protocol output.");
|
||||
expect(body).not.toContain("private malformed output");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cancelled", "kiro_terminal_incomplete"],
|
||||
["pause_turn", "kiro_terminal_incomplete"],
|
||||
["content_filtered", "kiro_terminal_refusal"],
|
||||
["novel_reason", "kiro_unknown_stop_reason"]
|
||||
])("fails closed for stop reason %s", async (stopReason, code) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: `private-${stopReason}` }),
|
||||
frame("metadataEvent", { stopReason })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain(code);
|
||||
expect(body).not.toContain(`private-${stopReason}`);
|
||||
expect(body).not.toContain('"finish_reason":"stop"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
frame("messageStopEvent", { stopReason: "content_filtered" }),
|
||||
frame("metadataEvent", { stopReason: "end_turn" })
|
||||
],
|
||||
[
|
||||
frame("metadataEvent", { stopReason: "end_turn" }),
|
||||
frame("messageStopEvent", { stopReason: "content_filtered" })
|
||||
]
|
||||
])("preserves the most restrictive conflicting stop reason", async (...stopFrames) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "private filtered output" }),
|
||||
...stopFrames
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("kiro_terminal_refusal");
|
||||
expect(body).not.toContain("private filtered output");
|
||||
});
|
||||
|
||||
it("prefers a non-retryable terminal reason over an earlier retryable reason", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "private malformed output" }),
|
||||
frame("metadataEvent", { stopReason: "malformed_model_output" }),
|
||||
frame("messageStopEvent", { stopReason: "cancelled" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("kiro_terminal_incomplete");
|
||||
expect(body).toContain('"stop_reason":"cancelled"');
|
||||
expect(body).not.toContain("private malformed output");
|
||||
});
|
||||
|
||||
it("preserves an authoritative refusal returned by the bounded retry", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([]))
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "private filtered retry" }),
|
||||
frame("metadataEvent", { stopReason: "content_filtered" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("kiro_terminal_refusal");
|
||||
expect(body).not.toContain("kiro_missing_terminal_retry_failed");
|
||||
expect(body).not.toContain("private filtered retry");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["max_tokens", "kiro_terminal_incomplete"],
|
||||
["cancelled", "kiro_terminal_incomplete"],
|
||||
["content_filtered", "kiro_terminal_refusal"],
|
||||
["novel_reason", "kiro_unknown_stop_reason"]
|
||||
])("does not let a valid tool override failure stop reason %s", async (stopReason, code) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("toolUseEvent", {
|
||||
toolUseId: "blocked-tool",
|
||||
name: "read_file",
|
||||
input: { path: "secret.txt" }
|
||||
}),
|
||||
frame("metadataEvent", { stopReason })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain(code);
|
||||
expect(body).not.toContain('"name":"read_file"');
|
||||
});
|
||||
|
||||
it.each(["content_filtered", "cancelled", "max_tokens"])(
|
||||
"classifies failure %s before validating a malformed deferred tool",
|
||||
async (stopReason) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("toolUseEvent", { toolUseId: "bad-tool", name: "read_file" }),
|
||||
frame("metadataEvent", { stopReason })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain(stopReason === "content_filtered"
|
||||
? "kiro_terminal_refusal"
|
||||
: "kiro_terminal_incomplete");
|
||||
expect(body).not.toContain("kiro_tool_call_repair_retry_failed");
|
||||
expect(body).not.toContain('"name":"read_file"');
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
["content_filtered", [frame("toolUseEvent", {
|
||||
toolUseId: 123,
|
||||
name: "read_file",
|
||||
input: { path: "bad.txt" }
|
||||
})], "kiro_terminal_refusal"],
|
||||
["cancelled", [frame("toolUseEvent", {
|
||||
toolUseId: "missing-name",
|
||||
input: { path: "bad.txt" }
|
||||
})], "kiro_terminal_incomplete"],
|
||||
["max_tokens", [
|
||||
frame("toolUseEvent", { toolUseId: "changing", name: "read_file" }),
|
||||
frame("toolUseEvent", { toolUseId: "changing", name: "write_file" })
|
||||
], "kiro_terminal_incomplete"]
|
||||
])("continues past eager tool-shape errors to authoritative stop %s", async (stopReason, toolFrames, code) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
...toolFrames,
|
||||
frame("metadataEvent", { stopReason })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain(code);
|
||||
expect(body).not.toContain("kiro_tool_call_repair_retry_failed");
|
||||
expect(body).not.toContain('"tool_calls"');
|
||||
});
|
||||
|
||||
it("retries a TTFT timeout once while preserving cancellation semantics", async () => {
|
||||
process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS = "1";
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(controlledResponse().value)
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Recovered after timeout." })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Recovered after timeout.");
|
||||
});
|
||||
|
||||
it("treats validated non-semantic frames as watchdog activity", async () => {
|
||||
process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS = "30";
|
||||
process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS = "30";
|
||||
const upstream = controlledResponse();
|
||||
fetchMock.mockResolvedValueOnce(upstream.value);
|
||||
setTimeout(() => upstream.enqueue(frame("meteringEvent", { usage: 1 })), 20);
|
||||
setTimeout(() => upstream.enqueue(frame("contextUsageEvent", { contextUsagePercentage: 5 })), 40);
|
||||
setTimeout(() => {
|
||||
upstream.enqueue(frame("assistantResponseEvent", { content: "Completed after active frames." }));
|
||||
upstream.close();
|
||||
}, 60);
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("Completed after active frames.");
|
||||
});
|
||||
|
||||
it("retries a response-body read failure once", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(new Error("socket reset"));
|
||||
}
|
||||
})))
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Recovered after read failure." })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Recovered after read failure.");
|
||||
expect(body).not.toContain("socket reset");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["message CRC", () => {
|
||||
const corrupt = frame("assistantResponseEvent", { content: "corrupt CRC" });
|
||||
corrupt[corrupt.byteLength - 1] ^= 0xff;
|
||||
return [corrupt];
|
||||
}],
|
||||
["prelude CRC", () => {
|
||||
const corrupt = frame("assistantResponseEvent", { content: "corrupt prelude" });
|
||||
corrupt[8] ^= 0xff;
|
||||
return [corrupt];
|
||||
}],
|
||||
["truncated frame", () => {
|
||||
const truncated = frame("assistantResponseEvent", { content: "truncated" });
|
||||
return [truncated.slice(0, -3)];
|
||||
}],
|
||||
["out-of-bounds headers", () => {
|
||||
const corrupt = frame("assistantResponseEvent", { content: "bad headers" });
|
||||
new DataView(corrupt.buffer).setUint32(4, corrupt.byteLength - 15, false);
|
||||
return [checksum(corrupt)];
|
||||
}],
|
||||
["duplicate headers", () => [
|
||||
frameFromEntries([
|
||||
[":event-type", "assistantResponseEvent"],
|
||||
[":event-type", "metadataEvent"]
|
||||
], { content: "duplicate" })
|
||||
]]
|
||||
])("retries %s and releases only the valid attempt", async (_name, invalidFrames) => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "must stay private" }),
|
||||
...invalidFrames()
|
||||
]))
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Recovered after validation." })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Recovered after validation.");
|
||||
expect(body).not.toContain("must stay private");
|
||||
});
|
||||
|
||||
it("reports corrupt-frame provenance when the bounded retry also fails", async () => {
|
||||
const corruptFrame = () => {
|
||||
const corrupt = frame("assistantResponseEvent", { content: "corrupt" });
|
||||
corrupt[corrupt.byteLength - 1] ^= 0xff;
|
||||
return corrupt;
|
||||
};
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([corruptFrame()]))
|
||||
.mockResolvedValueOnce(response([corruptFrame()]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(body).toContain("kiro_missing_terminal_retry_failed");
|
||||
expect(body).toContain('"terminal_provenance":"corrupt_eventstream_frame"');
|
||||
expect(body).toContain('"transport_state":"corrupt_frame"');
|
||||
});
|
||||
|
||||
it("caps diagnostic event-type cardinality", async () => {
|
||||
let terminal;
|
||||
const executor = new KiroExecutor();
|
||||
const frames = Array.from({ length: 100 }, (_, index) =>
|
||||
frame(`unknownEvent${index}`, { index })
|
||||
);
|
||||
frames.push(frame("assistantResponseEvent", { content: "done" }));
|
||||
const transformed = executor.transformEventStreamToSSE(
|
||||
response(frames),
|
||||
"kr/claude-opus-4.8",
|
||||
{ onTerminalState: (value) => { terminal = value; } }
|
||||
);
|
||||
|
||||
await transformed.text();
|
||||
|
||||
expect(terminal.event_counts).toEqual({
|
||||
other: 100,
|
||||
assistantResponseEvent: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a raw chunk before concatenating beyond the protocol bound", async () => {
|
||||
let terminal;
|
||||
const executor = new KiroExecutor();
|
||||
const transformed = executor.transformEventStreamToSSE(
|
||||
response([new Uint8Array(65)]),
|
||||
"kr/claude-opus-4.8",
|
||||
{
|
||||
maxRawBytes: 64,
|
||||
onTerminalState: (value) => { terminal = value; }
|
||||
}
|
||||
);
|
||||
|
||||
const body = await transformed.text();
|
||||
|
||||
expect(body).toContain("buffered bytes exceed the protocol bound");
|
||||
expect(terminal.terminal_provenance).toBe("corrupt_eventstream_frame");
|
||||
});
|
||||
|
||||
it.each(["error", "exception"])("propagates EventStream %s without retry or leakage", async (messageType) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "must stay private" }),
|
||||
frameFromEntries([
|
||||
[":message-type", messageType],
|
||||
...(messageType === "exception" ? [[":exception-type", "InternalServerException"]] : [])
|
||||
], { message: "upstream failed" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("kiro_upstream_eventstream_error");
|
||||
expect(body).toContain("upstream failed");
|
||||
expect(body).not.toContain("must stay private");
|
||||
});
|
||||
|
||||
it("surfaces retry HTTP failures as SSE after heartbeat commits headers", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([]))
|
||||
.mockResolvedValueOnce(new Response("unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized"
|
||||
}));
|
||||
|
||||
const result = await execute();
|
||||
const body = await result.response.text();
|
||||
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(body).toContain("kiro_integrity_retry_upstream_error");
|
||||
expect(body).toContain("unauthorized");
|
||||
});
|
||||
|
||||
it("bounds the retry HTTP error body", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([]))
|
||||
.mockResolvedValueOnce(new Response(`error-start-${"x".repeat(10_000)}-error-tail`, {
|
||||
status: 401,
|
||||
statusText: "Unauthorized"
|
||||
}));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(body).toContain("error-start-");
|
||||
expect(body).not.toContain("error-tail");
|
||||
expect(body.length).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it("propagates cancellation while validation is waiting for EOF", async () => {
|
||||
const upstream = controlledResponse([
|
||||
frame("assistantResponseEvent", { content: "waiting" })
|
||||
]);
|
||||
fetchMock.mockResolvedValueOnce(upstream.value);
|
||||
const abort = new AbortController();
|
||||
|
||||
const result = await execute(new KiroExecutor(), { signal: abort.signal });
|
||||
const reader = result.response.body.getReader();
|
||||
await reader.read();
|
||||
abort.abort("client cancelled");
|
||||
|
||||
await expect(reader.read()).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("fails safely when the private gate exceeds its configured bound", async () => {
|
||||
process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES = "8";
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "larger than eight bytes" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("integrity buffer exceeded");
|
||||
expect(body).not.toContain("larger than eight bytes");
|
||||
});
|
||||
|
||||
it("counts deferred tool fragments against the private memory bound", async () => {
|
||||
process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES = "128";
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("toolUseEvent", {
|
||||
toolUseId: "large-tool",
|
||||
name: "read_file",
|
||||
input: { path: "x".repeat(200) }
|
||||
})
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("kiro_integrity_buffer_exceeded");
|
||||
expect(body).not.toContain('"name":"read_file"');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { KiroExecutor } from "../../open-sse/executors/kiro.js";
|
||||
import "../translator/registerAll.js";
|
||||
|
||||
function createMockFrame(eventType, payloadObj) {
|
||||
const payloadStr = JSON.stringify(payloadObj);
|
||||
@@ -31,10 +32,23 @@ function createMockFrame(eventType, payloadObj) {
|
||||
offset += headerValueBytes.length;
|
||||
|
||||
buffer.set(payloadBytes, offset);
|
||||
|
||||
|
||||
view.setUint32(8, crc32(buffer.subarray(0, 8)), false);
|
||||
view.setUint32(totalLength - 4, crc32(buffer.subarray(0, totalLength - 4)), false);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function readAllSSE(stream) {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -47,6 +61,13 @@ async function readAllSSE(stream) {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readNextWithTimeout(reader) {
|
||||
return Promise.race([
|
||||
reader.read(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("timed out waiting for SSE chunk")), 100)),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("KiroExecutor thinking tag stripping", () => {
|
||||
it("strips <thinking> tags from assistantResponseEvent", async () => {
|
||||
const executor = new KiroExecutor();
|
||||
@@ -121,4 +142,60 @@ describe("KiroExecutor thinking tag stripping", () => {
|
||||
const contentChunks = objects.filter(obj => obj.choices[0].delta.content !== undefined);
|
||||
expect(contentChunks.length).toBe(0);
|
||||
});
|
||||
|
||||
it("waits for clean EOF before emitting stop after messageStop", async () => {
|
||||
const executor = new KiroExecutor();
|
||||
|
||||
const f1 = createMockFrame("assistantResponseEvent", { content: "OK" });
|
||||
const f2 = createMockFrame("messageStopEvent", {});
|
||||
|
||||
let upstreamController;
|
||||
const readableStream = new ReadableStream({
|
||||
start(controller) {
|
||||
upstreamController = controller;
|
||||
controller.enqueue(f1);
|
||||
controller.enqueue(f2);
|
||||
}
|
||||
});
|
||||
|
||||
const transformedResponse = executor.transformEventStreamToSSE({ body: readableStream }, "claude-test");
|
||||
const reader = transformedResponse.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let output = "";
|
||||
const { value } = await readNextWithTimeout(reader);
|
||||
output += decoder.decode(value, { stream: true });
|
||||
expect(output).not.toContain("\"finish_reason\":\"stop\"");
|
||||
|
||||
upstreamController.close();
|
||||
while (!output.includes("\"finish_reason\":\"stop\"")) {
|
||||
const { value: nextValue, done } = await readNextWithTimeout(reader);
|
||||
if (done) break;
|
||||
output += decoder.decode(nextValue, { stream: true });
|
||||
}
|
||||
|
||||
expect(output).toContain("\"finish_reason\":\"stop\"");
|
||||
});
|
||||
|
||||
it("uses tool_calls finish reason for tool streams without messageStop", async () => {
|
||||
const executor = new KiroExecutor();
|
||||
|
||||
const f1 = createMockFrame("toolUseEvent", { toolUseId: "tool-1", name: "read_file", input: { path: "a.txt" } });
|
||||
|
||||
const readableStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(f1);
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
const transformedResponse = executor.transformEventStreamToSSE({ body: readableStream }, "claude-test");
|
||||
const output = await readAllSSE(transformedResponse.body);
|
||||
const objects = output
|
||||
.split("\n")
|
||||
.filter(line => line.startsWith("data: ") && !line.includes("[DONE]"))
|
||||
.map(line => JSON.parse(line.slice(6)));
|
||||
|
||||
const finalChunk = objects.at(-1);
|
||||
expect(finalChunk.choices[0].finish_reason).toBe("tool_calls");
|
||||
});
|
||||
});
|
||||
|
||||
369
tests/unit/kiro-usage-and-tool-integrity.test.js
Normal file
369
tests/unit/kiro-usage-and-tool-integrity.test.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
211
tests/unit/local-request-peer-trust-3294.test.js
Normal file
211
tests/unit/local-request-peer-trust-3294.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// Guards C2: regex name fallback (no catalog). Terse entries derive name; existing names untouched.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { deriveModelName } from "../../open-sse/providers/models/namePatterns.js";
|
||||
import { normalizeModel } from "../../open-sse/providers/models/schema.js";
|
||||
import { normalizeModel, normalizeModelId } from "../../open-sse/providers/models/schema.js";
|
||||
|
||||
describe("model name regex fallback (C2)", () => {
|
||||
it("derives display name from id per family", () => {
|
||||
@@ -25,4 +25,26 @@ describe("model name regex fallback (C2)", () => {
|
||||
expect(m.id).toBe("glm-5");
|
||||
expect(m.name).toBe("GLM 5");
|
||||
});
|
||||
|
||||
it("normalizeModelId: dash between digits becomes a dot (version separator)", () => {
|
||||
expect(normalizeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4.5");
|
||||
expect(normalizeModelId("minimax-m2-5")).toBe("minimax-m2.5");
|
||||
expect(normalizeModelId("deepseek-3-2")).toBe("deepseek-3.2");
|
||||
});
|
||||
|
||||
it("normalizeModelId: preserves word-suffix hyphens (-thinking, -agentic)", () => {
|
||||
expect(normalizeModelId("claude-sonnet-4-5-thinking")).toBe("claude-sonnet-4.5-thinking");
|
||||
expect(normalizeModelId("claude-sonnet-4-5-thinking-agentic")).toBe("claude-sonnet-4.5-thinking-agentic");
|
||||
});
|
||||
|
||||
it("normalizeModelId: leaves ids with no digit-digit hyphen untouched", () => {
|
||||
expect(normalizeModelId("qwen3-coder-next")).toBe("qwen3-coder-next");
|
||||
expect(normalizeModelId("claude-sonnet-5")).toBe("claude-sonnet-5");
|
||||
expect(normalizeModelId("glm-5")).toBe("glm-5");
|
||||
});
|
||||
|
||||
it("normalizeModelId: non-string input passes through", () => {
|
||||
expect(normalizeModelId(undefined)).toBeUndefined();
|
||||
expect(normalizeModelId(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
165
tests/unit/ollama-usage.test.js
Normal file
165
tests/unit/ollama-usage.test.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.js";
|
||||
import {
|
||||
USAGE_SUPPORTED_PROVIDERS,
|
||||
USAGE_APIKEY_PROVIDERS,
|
||||
} from "../../src/shared/constants/providers.js";
|
||||
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
const USAGE_URL = "https://ollama.com/api/usage";
|
||||
const ME_URL = "https://ollama.com/api/me";
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const SAMPLE_USAGE = {
|
||||
activity: {
|
||||
cost: "0.00000",
|
||||
period: {
|
||||
type: "last_4_weeks",
|
||||
starting_at: "2026-07-01T00:00:00Z",
|
||||
ending_at: "2026-07-29T00:00:00Z",
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
limits: {
|
||||
session: { usage: 0, models: [] },
|
||||
weekly: {
|
||||
usage: 1,
|
||||
models: [
|
||||
{ name: "glm-5.2", request_count: 5967 },
|
||||
{ name: "kimi-k2.5", request_count: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const SAMPLE_ME = {
|
||||
Plan: "max",
|
||||
};
|
||||
|
||||
describe("ollama registry usage flags", () => {
|
||||
it("is listed for apikey quota dashboard", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("ollama");
|
||||
expect(USAGE_APIKEY_PROVIDERS).toContain("ollama");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsageForProvider(ollama)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("GETs /api/usage with Bearer apiKey and POSTs /api/me for plan", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(SAMPLE_USAGE))
|
||||
.mockResolvedValueOnce(jsonResponse(SAMPLE_ME));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "ollama",
|
||||
apiKey: "k",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("Max");
|
||||
expect(usage.quotas["Session (5h)"]).toMatchObject({
|
||||
used: 0,
|
||||
total: 100,
|
||||
remainingPercentage: 100,
|
||||
unlimited: false,
|
||||
});
|
||||
expect(usage.quotas["Weekly (7d)"]).toMatchObject({
|
||||
used: 100,
|
||||
total: 100,
|
||||
remainingPercentage: 0,
|
||||
unlimited: false,
|
||||
});
|
||||
// Must not set absolute remaining — UI treats remaining as %
|
||||
expect(usage.quotas["Session (5h)"].remaining).toBeUndefined();
|
||||
expect(usage.quotas["Weekly (7d)"].remaining).toBeUndefined();
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [usageUrl, usageOpts] = proxyAwareFetch.mock.calls[0];
|
||||
expect(usageUrl).toBe(USAGE_URL);
|
||||
expect(usageOpts.headers.Authorization).toBe("Bearer k");
|
||||
expect(usageOpts.headers.Accept).toBe("application/json");
|
||||
|
||||
const [meUrl, meOpts] = proxyAwareFetch.mock.calls[1];
|
||||
expect(meUrl).toBe(ME_URL);
|
||||
expect(meOpts.method).toBe("POST");
|
||||
expect(meOpts.headers.Authorization).toBe("Bearer k");
|
||||
expect(meOpts.headers["Content-Length"]).toBe("0");
|
||||
});
|
||||
|
||||
it("surfaces invalid key message on 401", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ error: "unauthorized" }, 401),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "ollama",
|
||||
apiKey: "bad",
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/invalid/i);
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns message when apiKey missing", async () => {
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "ollama",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/api key/i);
|
||||
expect(proxyAwareFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuotaData(ollama)", () => {
|
||||
it("forwards remainingPercentage for dashboard bars", () => {
|
||||
const rows = parseQuotaData("ollama", {
|
||||
plan: "Max",
|
||||
quotas: {
|
||||
"Session (5h)": {
|
||||
used: 0,
|
||||
total: 100,
|
||||
remainingPercentage: 100,
|
||||
resetAt: null,
|
||||
},
|
||||
"Weekly (7d)": {
|
||||
used: 100,
|
||||
total: 100,
|
||||
remainingPercentage: 0,
|
||||
resetAt: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: "Session (5h)",
|
||||
used: 0,
|
||||
total: 100,
|
||||
remainingPercentage: 100,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
name: "Weekly (7d)",
|
||||
used: 100,
|
||||
total: 100,
|
||||
remainingPercentage: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
38
tests/unit/open-package-external.test.js
Normal file
38
tests/unit/open-package-external.test.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
// Regression guard for the Windows xAI/Grok token refresh failure.
|
||||
//
|
||||
// `open` computes its own directory from `import.meta.url`. When it is bundled, webpack
|
||||
// replaces that with the BUILD machine's absolute path as a string literal, so a release
|
||||
// built on macOS ships `fileURLToPath("file:///Users/.../open/index.js")`. On Windows that
|
||||
// throws ERR_INVALID_FILE_URL_PATH ("File URL path must be absolute" — no drive letter) at
|
||||
// module scope, which kills every importer. `refreshXaiToken` imports the xAI OAuth
|
||||
// service, which imports `open`, so no Grok refresh could ever reach auth.x.ai on Windows.
|
||||
//
|
||||
// Keeping the package external preserves the real `import.meta.url` at runtime.
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
describe("open must not be bundled into the server build", () => {
|
||||
const config = readFileSync(path.join(repoRoot, "next.config.mjs"), "utf8");
|
||||
|
||||
it("is declared in serverExternalPackages", () => {
|
||||
const match = config.match(/serverExternalPackages:\s*\[([^\]]*)\]/);
|
||||
expect(match, "serverExternalPackages not found in next.config.mjs").toBeTruthy();
|
||||
const packages = match[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, ""));
|
||||
expect(packages).toContain("open");
|
||||
});
|
||||
|
||||
it("stays a runtime dependency so the standalone output can resolve it", async () => {
|
||||
const pkg = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
||||
expect(pkg.dependencies?.open).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resolves its own directory from import.meta.url, which is why it must stay external", async () => {
|
||||
const entry = path.join(repoRoot, "node_modules", "open", "index.js");
|
||||
const source = readFileSync(entry, "utf8");
|
||||
expect(source).toMatch(/import\.meta\.url/);
|
||||
});
|
||||
});
|
||||
76
tests/unit/openai-compatible-apitype-resolution.test.js
Normal file
76
tests/unit/openai-compatible-apitype-resolution.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// Locks openai-compatible apiType resolution: the stored apiType on the
|
||||
// connection's providerSpecificData (kept in sync with the node) is
|
||||
// authoritative, and the node-ID substring is only a legacy fallback.
|
||||
//
|
||||
// Regression for: editing a node's apiType to "responses" had no effect because
|
||||
// runtime derived chat/responses from the immutable node ID string
|
||||
// (`openai-compatible-<chat|responses>-<uuid>`) instead of the stored value.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveOpenAICompatibleApiType, getTargetFormat } from "open-sse/services/provider.js";
|
||||
import { DefaultExecutor } from "open-sse/executors/default.js";
|
||||
import { BaseExecutor } from "open-sse/executors/base.js";
|
||||
|
||||
const CHAT_ID = "openai-compatible-chat-3d8d3de8-1206-47ee-a42f-22113a5f2387";
|
||||
const RESPONSES_ID = "openai-compatible-responses-11111111-2222-3333-4444-555555555555";
|
||||
const BASE = "https://api.ericding.io.vn/v1";
|
||||
|
||||
function creds(apiType) {
|
||||
return { providerSpecificData: apiType === undefined ? { baseUrl: BASE } : { baseUrl: BASE, apiType } };
|
||||
}
|
||||
|
||||
describe("resolveOpenAICompatibleApiType", () => {
|
||||
it("prefers stored apiType over the ID substring (edited node on a legacy -chat- ID)", () => {
|
||||
expect(resolveOpenAICompatibleApiType(CHAT_ID, creds("responses"))).toBe("responses");
|
||||
expect(resolveOpenAICompatibleApiType(RESPONSES_ID, creds("chat"))).toBe("chat");
|
||||
});
|
||||
|
||||
it("falls back to the ID substring when apiType is absent", () => {
|
||||
expect(resolveOpenAICompatibleApiType(CHAT_ID, creds(undefined))).toBe("chat");
|
||||
expect(resolveOpenAICompatibleApiType(RESPONSES_ID, creds(undefined))).toBe("responses");
|
||||
expect(resolveOpenAICompatibleApiType(CHAT_ID, null)).toBe("chat");
|
||||
expect(resolveOpenAICompatibleApiType(RESPONSES_ID, null)).toBe("responses");
|
||||
});
|
||||
|
||||
it("ignores an invalid stored apiType and falls back to the ID", () => {
|
||||
expect(resolveOpenAICompatibleApiType(RESPONSES_ID, creds("bogus"))).toBe("responses");
|
||||
expect(resolveOpenAICompatibleApiType(CHAT_ID, creds(""))).toBe("chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTargetFormat", () => {
|
||||
it("selects openai-responses when the stored apiType is responses (even on a -chat- ID)", () => {
|
||||
expect(getTargetFormat(CHAT_ID, creds("responses"))).toBe("openai-responses");
|
||||
});
|
||||
|
||||
it("selects openai for chat, and honors stored chat over a -responses- ID", () => {
|
||||
expect(getTargetFormat(CHAT_ID, creds(undefined))).toBe("openai");
|
||||
expect(getTargetFormat(RESPONSES_ID, creds("chat"))).toBe("openai");
|
||||
});
|
||||
|
||||
it("keeps the ID-based fallback when credentials are absent", () => {
|
||||
expect(getTargetFormat(RESPONSES_ID)).toBe("openai-responses");
|
||||
expect(getTargetFormat(CHAT_ID)).toBe("openai");
|
||||
});
|
||||
});
|
||||
|
||||
describe("executor buildUrl endpoint path", () => {
|
||||
for (const [name, Ex] of [["DefaultExecutor", DefaultExecutor], ["BaseExecutor", BaseExecutor]]) {
|
||||
describe(name, () => {
|
||||
const ex = new Ex(CHAT_ID);
|
||||
|
||||
it("routes to /responses when stored apiType is responses, despite the -chat- ID", () => {
|
||||
expect(ex.buildUrl("cx/gpt-5.6-sol", true, 0, creds("responses"))).toBe(`${BASE}/responses`);
|
||||
});
|
||||
|
||||
it("routes to /chat/completions when apiType is chat", () => {
|
||||
expect(ex.buildUrl("cx/gpt-5.6-sol", true, 0, creds("chat"))).toBe(`${BASE}/chat/completions`);
|
||||
});
|
||||
|
||||
it("falls back to the ID substring (legacy) when apiType is absent", () => {
|
||||
expect(ex.buildUrl("cx/gpt-5.6-sol", true, 0, creds(undefined))).toBe(`${BASE}/chat/completions`);
|
||||
const exResp = new Ex(RESPONSES_ID);
|
||||
expect(exResp.buildUrl("m", true, 0, creds(undefined))).toBe(`${BASE}/responses`);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
153
tests/unit/openai-responses-custom-tools.test.js
Normal file
153
tests/unit/openai-responses-custom-tools.test.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
openaiResponsesToOpenAIRequest,
|
||||
} from "../../open-sse/translator/request/openai-responses.js";
|
||||
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";
|
||||
|
||||
const EXEC_TOOL = {
|
||||
type: "custom",
|
||||
name: "exec",
|
||||
description: "Run JavaScript code to orchestrate tool calls.",
|
||||
format: {
|
||||
type: "grammar",
|
||||
syntax: "lark",
|
||||
definition: "start: /(.|\\n)+/",
|
||||
},
|
||||
};
|
||||
|
||||
describe("Codex Responses Lite custom tools → OpenAI Chat", () => {
|
||||
it("promotes additional_tools custom declarations into Chat tools", () => {
|
||||
const out = openaiResponsesToOpenAIRequest("cx/gpt-5.6-sol", {
|
||||
input: [
|
||||
{ type: "additional_tools", role: "developer", tools: [EXEC_TOOL] },
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Run pwd" }] },
|
||||
],
|
||||
tool_choice: "auto",
|
||||
}, true, null);
|
||||
|
||||
expect(out.tools).toHaveLength(1);
|
||||
expect(out.tools[0]).toMatchObject({
|
||||
type: "function",
|
||||
function: {
|
||||
name: "exec",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["input"],
|
||||
properties: { input: { type: "string" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(out._customToolNames).toEqual(["exec"]);
|
||||
expect(out.messages.some((message) => message.role === "developer")).toBe(false);
|
||||
});
|
||||
|
||||
it("translates custom tool call/output history into Chat assistant/tool messages", () => {
|
||||
const program = "const result = await tools.shell({command: 'pwd'});\nreturn result;";
|
||||
const out = openaiResponsesToOpenAIRequest("cx/gpt-5.6-sol", {
|
||||
input: [
|
||||
{ type: "additional_tools", role: "developer", tools: [EXEC_TOOL] },
|
||||
{ type: "custom_tool_call", call_id: "call_exec_1", name: "exec", input: program },
|
||||
{ type: "custom_tool_call_output", call_id: "call_exec_1", output: "/srv/app" },
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||
],
|
||||
}, true, null);
|
||||
|
||||
const assistant = out.messages.find((message) => message.role === "assistant");
|
||||
expect(assistant.tool_calls[0]).toMatchObject({
|
||||
id: "call_exec_1",
|
||||
type: "function",
|
||||
function: { name: "exec" },
|
||||
});
|
||||
expect(JSON.parse(assistant.tool_calls[0].function.arguments)).toEqual({ input: program });
|
||||
expect(out.messages.find((message) => message.role === "tool")).toEqual({
|
||||
role: "tool",
|
||||
tool_call_id: "call_exec_1",
|
||||
content: "/srv/app",
|
||||
});
|
||||
});
|
||||
|
||||
it("merges additional_tools with normal top-level function tools", () => {
|
||||
const out = openaiResponsesToOpenAIRequest("cx/gpt-5.6-sol", {
|
||||
input: [{ type: "additional_tools", role: "developer", tools: [EXEC_TOOL] }],
|
||||
tools: [{ type: "function", name: "search", parameters: { type: "object", properties: {} } }],
|
||||
}, true, null);
|
||||
|
||||
expect(out.tools.map((tool) => tool.function.name)).toEqual(["search", "exec"]);
|
||||
expect(out._customToolNames).toEqual(["exec"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAI Chat stream → Codex custom_tool_call", () => {
|
||||
it("unwraps the Chat input parameter and emits custom-tool events", () => {
|
||||
const state = initState(FORMATS.OPENAI_RESPONSES);
|
||||
state.customToolNames = new Set(["exec"]);
|
||||
const chunks = [
|
||||
{
|
||||
id: "chatcmpl-custom",
|
||||
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_exec_2", type: "function", function: { name: "exec", arguments: "" } }] }, finish_reason: null }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-custom",
|
||||
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: "{\"input\":\"const x = await tools.shell({command: 'pwd'});\"}" } }] }, finish_reason: null }],
|
||||
},
|
||||
{ id: "chatcmpl-custom", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
|
||||
];
|
||||
|
||||
const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
|
||||
const added = events.find((event) => event.event === "response.output_item.added");
|
||||
const delta = events.find((event) => event.event === "response.custom_tool_call_input.delta");
|
||||
const done = events.find((event) => event.event === "response.output_item.done");
|
||||
|
||||
expect(added.data.item).toMatchObject({
|
||||
type: "custom_tool_call",
|
||||
call_id: "call_exec_2",
|
||||
name: "exec",
|
||||
input: "",
|
||||
});
|
||||
expect(delta.data.delta).toBe("const x = await tools.shell({command: 'pwd'});");
|
||||
expect(done.data.item).toMatchObject({
|
||||
type: "custom_tool_call",
|
||||
call_id: "call_exec_2",
|
||||
name: "exec",
|
||||
input: "const x = await tools.shell({command: 'pwd'});",
|
||||
});
|
||||
expect(events.some((event) => event.event === "response.function_call_arguments.delta")).toBe(false);
|
||||
});
|
||||
|
||||
it("waits for the function name when id and name arrive in separate chunks", () => {
|
||||
const state = initState(FORMATS.OPENAI_RESPONSES);
|
||||
state.customToolNames = new Set(["exec"]);
|
||||
const chunks = [
|
||||
{ id: "chatcmpl-split", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_split", type: "function", function: { arguments: "" } }] }, finish_reason: null }] },
|
||||
{ id: "chatcmpl-split", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { name: "exec", arguments: "{\"input\":\"return 1;\"}" } }] }, finish_reason: null }] },
|
||||
{ id: "chatcmpl-split", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
|
||||
];
|
||||
|
||||
const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
|
||||
const added = events.filter((event) => event.event === "response.output_item.added");
|
||||
expect(added).toHaveLength(1);
|
||||
expect(added[0].data.item).toMatchObject({
|
||||
type: "custom_tool_call",
|
||||
call_id: "call_split",
|
||||
name: "exec",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves normal Chat tool calls as Responses function_call events", () => {
|
||||
const state = initState(FORMATS.OPENAI_RESPONSES);
|
||||
state.customToolNames = new Set(["exec"]);
|
||||
const events = [
|
||||
{ id: "chatcmpl-normal", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_search", type: "function", function: { name: "search", arguments: "{\"q\":\"x\"}" } }] }, finish_reason: null }] },
|
||||
{ id: "chatcmpl-normal", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
|
||||
].flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
|
||||
|
||||
expect(events.find((event) => event.event === "response.output_item.added").data.item.type).toBe("function_call");
|
||||
expect(events.find((event) => event.event === "response.output_item.done").data.item).toMatchObject({
|
||||
type: "function_call",
|
||||
name: "search",
|
||||
arguments: "{\"q\":\"x\"}",
|
||||
});
|
||||
});
|
||||
});
|
||||
52
tests/unit/openai-responses-empty-toolcalls.test.js
Normal file
52
tests/unit/openai-responses-empty-toolcalls.test.js
Normal file
@@ -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.");
|
||||
});
|
||||
});
|
||||
181
tests/unit/openai-responses-multiturn.test.js
Normal file
181
tests/unit/openai-responses-multiturn.test.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Multi-turn continuity for store=false Responses backends (Grok CLI / Codex).
|
||||
* Prior-turn reasoning (+ encrypted_content) must survive Chat Completions ↔ Responses.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
openaiToOpenAIResponsesRequest,
|
||||
openaiResponsesToOpenAIRequest,
|
||||
} from "../../open-sse/translator/request/openai-responses.js";
|
||||
import { GrokCliExecutor, _resetGrokCliTurnStore } from "../../open-sse/executors/grok-cli.js";
|
||||
import { translateRequest } from "../../open-sse/translator/index.js";
|
||||
|
||||
describe("openai ↔ responses multi-turn reasoning", () => {
|
||||
it("openai→responses re-emits reasoning item with summary + encrypted_content", () => {
|
||||
const body = {
|
||||
model: "grok-4.5",
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
reasoning_content: "thinking hard about greeting",
|
||||
encrypted_content: "enc_blob_turn1",
|
||||
},
|
||||
{ role: "user", content: "next" },
|
||||
],
|
||||
};
|
||||
|
||||
const out = openaiToOpenAIResponsesRequest("grok-4.5", body, true, null);
|
||||
expect(out.store).toBe(false);
|
||||
|
||||
const reasoning = out.input.filter((i) => i.type === "reasoning");
|
||||
expect(reasoning).toHaveLength(1);
|
||||
expect(reasoning[0].encrypted_content).toBe("enc_blob_turn1");
|
||||
expect(reasoning[0].summary?.[0]?.text).toMatch(/thinking hard/);
|
||||
|
||||
// Order: user → reasoning → assistant → user
|
||||
const types = out.input.map((i) => i.type || i.role);
|
||||
expect(types).toEqual(["message", "reasoning", "message", "message"]);
|
||||
expect(out.input[0].role).toBe("user");
|
||||
expect(out.input[2].role).toBe("assistant");
|
||||
expect(out.input[3].role).toBe("user");
|
||||
});
|
||||
|
||||
it("accepts reasoning_encrypted_content alias on assistant messages", () => {
|
||||
const out = openaiToOpenAIResponsesRequest(
|
||||
"m",
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "ok",
|
||||
reasoning_encrypted_content: "alt_enc",
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
null
|
||||
);
|
||||
expect(out.input.find((i) => i.type === "reasoning")?.encrypted_content).toBe("alt_enc");
|
||||
});
|
||||
|
||||
it("responses→openai attaches reasoning_content + encrypted_content to assistant", () => {
|
||||
const body = {
|
||||
model: "grok-4.5",
|
||||
input: [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: "plan A" }],
|
||||
encrypted_content: "enc_xyz",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "hello" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const out = openaiResponsesToOpenAIRequest("grok-4.5", body, true, null);
|
||||
const assistant = out.messages.find((m) => m.role === "assistant");
|
||||
expect(assistant).toBeTruthy();
|
||||
expect(assistant.reasoning_content).toBe("plan A");
|
||||
expect(assistant.encrypted_content).toBe("enc_xyz");
|
||||
});
|
||||
|
||||
it("round-trips encrypted_content through openai → responses → openai", () => {
|
||||
const original = {
|
||||
model: "grok-4.5",
|
||||
messages: [
|
||||
{ role: "user", content: "q1" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "a1",
|
||||
reasoning_content: "r1",
|
||||
encrypted_content: "ENC_KEEP_ME",
|
||||
},
|
||||
{ role: "user", content: "q2" },
|
||||
],
|
||||
};
|
||||
|
||||
const responses = openaiToOpenAIResponsesRequest("grok-4.5", structuredClone(original), true, null);
|
||||
const back = openaiResponsesToOpenAIRequest("grok-4.5", responses, true, null);
|
||||
const again = openaiToOpenAIResponsesRequest("grok-4.5", back, true, null);
|
||||
|
||||
const enc = again.input.find((i) => i.type === "reasoning")?.encrypted_content;
|
||||
expect(enc).toBe("ENC_KEEP_ME");
|
||||
});
|
||||
|
||||
it("translateRequest openai→openai-responses preserves encrypted blob", () => {
|
||||
const body = {
|
||||
model: "grok-4.5",
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "yo",
|
||||
reasoning_content: "why",
|
||||
encrypted_content: "blob_via_registry",
|
||||
},
|
||||
{ role: "user", content: "go" },
|
||||
],
|
||||
};
|
||||
const out = translateRequest(
|
||||
"openai",
|
||||
"openai-responses",
|
||||
"grok-4.5",
|
||||
structuredClone(body),
|
||||
true,
|
||||
{},
|
||||
"grok-cli"
|
||||
);
|
||||
expect(out.input.some((i) => i.type === "reasoning" && i.encrypted_content === "blob_via_registry")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GrokCliExecutor multi-turn input", () => {
|
||||
it("keeps native Grok reasoning and item ids", () => {
|
||||
_resetGrokCliTurnStore();
|
||||
const executor = new GrokCliExecutor();
|
||||
const body = {
|
||||
model: "grok-4.5",
|
||||
input: [
|
||||
{ type: "message", role: "system", content: "You are Grok" },
|
||||
{ type: "message", role: "user", content: "hi", id: "msg_3e3f6187-892a-96db-893b-904eff019e19" },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_3e3f6187-892a-96db-893b-904eff019e19",
|
||||
summary: [{ type: "summary_text", text: "prior plan" }],
|
||||
encrypted_content: "enc_from_cli",
|
||||
},
|
||||
{ type: "message", role: "assistant", content: "hello", id: "msg_4e3f6187-892a-96db-893b-904eff019e19" },
|
||||
{ type: "message", role: "user", content: "again" },
|
||||
],
|
||||
include: ["reasoning.encrypted_content"],
|
||||
};
|
||||
|
||||
const out = executor.transformRequest("grok-4.5", structuredClone(body), true, {
|
||||
connectionId: "mt-1",
|
||||
});
|
||||
|
||||
const reasoning = out.input.filter((i) => i.type === "reasoning");
|
||||
expect(reasoning).toHaveLength(1);
|
||||
expect(reasoning[0].encrypted_content).toBe("enc_from_cli");
|
||||
expect(reasoning[0].summary?.[0]?.text).toBe("prior plan");
|
||||
expect(reasoning[0].id).toBe("rs_3e3f6187-892a-96db-893b-904eff019e19");
|
||||
|
||||
// system preserved (not developer)
|
||||
expect(out.input[0].role).toBe("system");
|
||||
// Native Grok IDs are required for encrypted continuity.
|
||||
for (const item of out.input) {
|
||||
if (item.type === "message" && item.id) expect(item.id).toMatch(/^msg_[0-9a-f-]{36}$/);
|
||||
}
|
||||
expect(out.include).toContain("reasoning.encrypted_content");
|
||||
expect(out.store).toBe(false);
|
||||
expect(executor._currentTurnIdx).toBe(2);
|
||||
});
|
||||
});
|
||||
148
tests/unit/openai-responses-nonstream.test.js
Normal file
148
tests/unit/openai-responses-nonstream.test.js
Normal file
@@ -0,0 +1,148 @@
|
||||
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 { FORMATS } = await import("../../open-sse/translator/formats.js");
|
||||
const { translateNonStreamingResponse } = await import("../../open-sse/handlers/chatCore/nonStreamingHandler.js");
|
||||
const { handleForcedSSEToJson } = await import("../../open-sse/handlers/chatCore/sseToJsonHandler.js");
|
||||
|
||||
// A chat.completion body as returned by a chat-native upstream (e.g. op-ericding)
|
||||
const CHAT_TOOL_BODY = {
|
||||
id: "chatcmpl-abc123",
|
||||
object: "chat.completion",
|
||||
created: 1700000000,
|
||||
model: "cl/claude-haiku-4-5",
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "shell", arguments: "{\"cmd\":\"ls\"}" } }]
|
||||
},
|
||||
finish_reason: "tool_calls"
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
|
||||
};
|
||||
|
||||
describe("non-stream Chat upstream for a Responses-API client (op-ericding bug)", () => {
|
||||
it("translates chat.completion tool_calls into Responses function_call output", () => {
|
||||
// translateNonStreamingResponse(body, targetFormat=PROVIDER format, sourceFormat=CLIENT format)
|
||||
const out = translateNonStreamingResponse(CHAT_TOOL_BODY, FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES);
|
||||
expect(out.object).toBe("response");
|
||||
expect(out).not.toHaveProperty("choices");
|
||||
const fc = (out.output || []).find((o) => o.type === "function_call");
|
||||
expect(fc).toBeTruthy();
|
||||
expect(fc.call_id).toBe("call_1");
|
||||
expect(fc.name).toBe("shell");
|
||||
expect(fc.arguments).toBe("{\"cmd\":\"ls\"}");
|
||||
});
|
||||
|
||||
it("translates marked Chat tools into Responses custom_tool_call output", () => {
|
||||
const customBody = structuredClone(CHAT_TOOL_BODY);
|
||||
customBody.choices[0].message.tool_calls[0] = {
|
||||
id: "call_exec",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "exec",
|
||||
arguments: "{\"input\":\"return await tools.shell({command: 'pwd'});\"}"
|
||||
}
|
||||
};
|
||||
const out = translateNonStreamingResponse(
|
||||
customBody,
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
new Set(["exec"])
|
||||
);
|
||||
const call = (out.output || []).find((item) => item.type === "custom_tool_call");
|
||||
expect(call).toMatchObject({
|
||||
call_id: "call_exec",
|
||||
name: "exec",
|
||||
input: "return await tools.shell({command: 'pwd'});"
|
||||
});
|
||||
expect(out.output.some((item) => item.type === "function_call")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps chat.completion text content as a Responses message item", () => {
|
||||
const body = {
|
||||
...CHAT_TOOL_BODY,
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "hello" }, finish_reason: "stop" }]
|
||||
};
|
||||
const out = translateNonStreamingResponse(body, FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES);
|
||||
const msg = (out.output || []).find((o) => o.type === "message");
|
||||
expect(msg).toBeTruthy();
|
||||
expect(msg.content[0].type).toBe("output_text");
|
||||
expect(msg.content[0].text).toBe("hello");
|
||||
});
|
||||
|
||||
it("leaves chat->chat untouched", () => {
|
||||
const out = translateNonStreamingResponse(CHAT_TOOL_BODY, FORMATS.OPENAI, FORMATS.OPENAI);
|
||||
expect(out.object).toBe("chat.completion");
|
||||
expect(out.choices[0].message.tool_calls[0].function.name).toBe("shell");
|
||||
});
|
||||
});
|
||||
|
||||
describe("forced-SSE JSON path for a Responses-API client behind a chat upstream", () => {
|
||||
const sseCtx = (sourceFormat, targetFormat) => {
|
||||
const encoder = new TextEncoder();
|
||||
const raw = [
|
||||
'data: {"id":"chatcmpl-sse","object":"chat.completion.chunk","created":1700000000,"model":"gpt-x","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_9","type":"function","function":{"name":"shell","arguments":""}}]},"finish_reason":null}]}',
|
||||
'data: {"id":"chatcmpl-sse","object":"chat.completion.chunk","created":1700000000,"model":"gpt-x","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"cmd\\":\\"pwd\\"}"}}]},"finish_reason":null}]}',
|
||||
'data: {"id":"chatcmpl-sse","object":"chat.completion.chunk","created":1700000000,"model":"gpt-x","choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
|
||||
"data: [DONE]",
|
||||
""
|
||||
].join("\n\n");
|
||||
return {
|
||||
providerResponse: new Response(new ReadableStream({
|
||||
start(controller) { controller.enqueue(encoder.encode(raw)); controller.close(); }
|
||||
}), { headers: { "content-type": "text/event-stream" } }),
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
provider: "op-test-chat",
|
||||
model: "gpt-x",
|
||||
body: { model: "gpt-x", messages: [] },
|
||||
stream: false,
|
||||
requestStartTime: Date.now(),
|
||||
connectionId: "test-connection",
|
||||
clientRawRequest: { endpoint: "/v1/responses" },
|
||||
trackDone: vi.fn(),
|
||||
appendLog: vi.fn()
|
||||
};
|
||||
};
|
||||
|
||||
it("parses chat SSE chunks and returns a Responses function_call body", async () => {
|
||||
const result = await handleForcedSSEToJson(sseCtx(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI));
|
||||
expect(result.success).toBe(true);
|
||||
const json = await result.response.json();
|
||||
expect(json.object).toBe("response");
|
||||
const fc = (json.output || []).find((o) => o.type === "function_call");
|
||||
expect(fc).toBeTruthy();
|
||||
expect(fc.name).toBe("shell");
|
||||
expect(fc.arguments).toBe("{\"cmd\":\"pwd\"}");
|
||||
});
|
||||
|
||||
it("returns a custom_tool_call for a marked tool", async () => {
|
||||
const ctx = sseCtx(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI);
|
||||
ctx.customToolNames = new Set(["shell"]);
|
||||
const result = await handleForcedSSEToJson(ctx);
|
||||
expect(result.success).toBe(true);
|
||||
const json = await result.response.json();
|
||||
const call = (json.output || []).find((item) => item.type === "custom_tool_call");
|
||||
expect(call).toMatchObject({
|
||||
call_id: "call_9",
|
||||
name: "shell",
|
||||
input: "{\"cmd\":\"pwd\"}"
|
||||
});
|
||||
});
|
||||
|
||||
it("still returns chat.completion for a plain chat client", async () => {
|
||||
const result = await handleForcedSSEToJson(sseCtx(FORMATS.OPENAI, FORMATS.OPENAI));
|
||||
expect(result.success).toBe(true);
|
||||
const json = await result.response.json();
|
||||
expect(json.object).toBe("chat.completion");
|
||||
expect(json.choices[0].message.tool_calls[0].function.name).toBe("shell");
|
||||
});
|
||||
});
|
||||
91
tests/unit/openai-to-claude-tools-no-type.test.js
Normal file
91
tests/unit/openai-to-claude-tools-no-type.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Regression: tools WITHOUT an explicit `type:"function"` wrapper were
|
||||
* forwarded to the upstream Claude-compatible gateway with `name:"undefined"`,
|
||||
* because openai-to-claude only unwrapped `tool.function` when BOTH
|
||||
* `tool.type === "function"` AND `tool.function` were truthy.
|
||||
*
|
||||
* Repro path (v0.5.20):
|
||||
* tools: [{ function: { name: "echo", parameters: {...} } }] // no parent type
|
||||
* → originalName = undefined
|
||||
* → upstream body: { name: "undefined", description: "", input_schema: {...} }
|
||||
*
|
||||
* Pragmatic OpenAI clients and some library generators emit the bare
|
||||
* `function` wrapper shape; when this lands on a strict Anthropic-compatible
|
||||
* gateway (e.g. MiniMax's `api.minimaxi.com/anthropic/v1/messages`), the
|
||||
* payload is rejected with an "invalid tool type" / "(2013)" error, which
|
||||
* is the same family of failure that PR #2463 was diagnosing from the
|
||||
* runtimeTransport side. PR #2463 fixes the combo-path transport
|
||||
* selection; this regression closes the translator-side shape gap so
|
||||
* single-connection OpenAI clients aren't bit by it once #2463 lands.
|
||||
*
|
||||
* See: #2435, follow-up to PR #2463.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openaiToClaudeRequest } from "../../open-sse/translator/request/openai-to-claude.js";
|
||||
|
||||
const baseBody = (extra = {}) => ({
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("openai→claude: tools shape fidelity", () => {
|
||||
it("tool WITH explicit type:'function' is rewritten to Anthropic shape", () => {
|
||||
const out = openaiToClaudeRequest("claude-sonnet-4.5", baseBody({
|
||||
tools: [
|
||||
{ type: "function", function: { name: "echo", parameters: { type: "object" } } },
|
||||
],
|
||||
}), false);
|
||||
|
||||
expect(out.tools).toHaveLength(1);
|
||||
expect(out.tools[0].name).toBe("echo");
|
||||
expect(out.tools[0].input_schema).toEqual({ type: "object" });
|
||||
// Anthropic-shape has no top-level `type` and no nested `function`.
|
||||
expect(out.tools[0]).not.toHaveProperty("type");
|
||||
expect(out.tools[0]).not.toHaveProperty("function");
|
||||
});
|
||||
|
||||
it("tool WITHOUT explicit type but WITH function wrapper preserves the original name (was 'undefined' in v0.5.20)", () => {
|
||||
const out = openaiToClaudeRequest("claude-sonnet-4.5", baseBody({
|
||||
tools: [
|
||||
{ function: { name: "echo", parameters: { type: "object" } } },
|
||||
],
|
||||
}), false);
|
||||
|
||||
expect(out.tools).toHaveLength(1);
|
||||
expect(out.tools[0].name).toBe("echo");
|
||||
expect(out.tools[0].input_schema).toEqual({ type: "object" });
|
||||
// The Anthropic-shape envelope strips the OpenAI `function` wrapper entirely.
|
||||
expect(out.tools[0]).not.toHaveProperty("function");
|
||||
expect(out.tools[0]).not.toHaveProperty("type");
|
||||
});
|
||||
|
||||
it("flat Anthropic-shape tool (no function wrapper) is passed through with name preserved", () => {
|
||||
const out = openaiToClaudeRequest("claude-sonnet-4.5", baseBody({
|
||||
tools: [
|
||||
{ name: "echo", description: "echo input", input_schema: { type: "object" } },
|
||||
],
|
||||
}), false);
|
||||
|
||||
expect(out.tools).toHaveLength(1);
|
||||
expect(out.tools[0].name).toBe("echo");
|
||||
expect(out.tools[0].description).toBe("echo input");
|
||||
expect(out.tools[0].input_schema).toEqual({ type: "object" });
|
||||
});
|
||||
|
||||
it("non-function built-in tool types are passed through (cache_control tag is OK)", () => {
|
||||
// 9router adds a `cache_control` tag to the last tool for prompt caching;
|
||||
// the test asserts the original shape is preserved alongside it rather
|
||||
// than checking strict equality. This is the existing buildHeaders /
|
||||
// cache_control behavior unchanged by this fix.
|
||||
const out = openaiToClaudeRequest("claude-sonnet-4.5", baseBody({
|
||||
tools: [
|
||||
{ type: "web_search_20250305", name: "web_search" },
|
||||
],
|
||||
}), false);
|
||||
|
||||
expect(out.tools).toHaveLength(1);
|
||||
expect(out.tools[0].type).toBe("web_search_20250305");
|
||||
expect(out.tools[0].name).toBe("web_search");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to
|
||||
|
||||
const contentOf = (result) =>
|
||||
result.conversationState.currentMessage.userInputMessage.content;
|
||||
const systemPromptOf = (result) => result.systemPrompt || "";
|
||||
|
||||
describe("openaiToKiroRequest", () => {
|
||||
describe("basic message conversion", () => {
|
||||
@@ -293,7 +294,11 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>1024</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>1024</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "low" },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps reasoning_effort high to max_thinking_length 24576", () => {
|
||||
@@ -304,7 +309,218 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["high", "gpt-5.6-sol"],
|
||||
["medium", "kiro/gpt-5.6-terra"],
|
||||
["low", "gpt-5.6-luna"],
|
||||
])("maps GPT-5.6 reasoning.effort %s without legacy prompt tags", (effort, model) => {
|
||||
const body = {
|
||||
reasoning: { effort },
|
||||
messages: [{ role: "user", content: "Use the requested effort" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest(model, body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
reasoning: { effort },
|
||||
});
|
||||
expect(systemPromptOf(result)).not.toContain("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
expect(contentOf(result)).not.toContain("<thinking_mode>");
|
||||
expect(contentOf(result)).not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["xhigh", "gpt-5.6-terra", "xhigh"],
|
||||
["max", "gpt-5.6-sol", "xhigh"],
|
||||
])("preserves GPT-5.6 effort %s as supported wire effort %s", (effort, model, wireEffort) => {
|
||||
const body = {
|
||||
reasoning: { effort },
|
||||
messages: [{ role: "user", content: "Use extended effort" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest(model, body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
reasoning: { effort: wireEffort },
|
||||
});
|
||||
expect(systemPromptOf(result)).not.toContain("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
it("omits GPT-5.6 effort fields and legacy prompt tags when effort is absent", () => {
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "No explicit reasoning effort" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("gpt-5.6-sol", body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
expect(systemPromptOf(result)).not.toContain("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
it.each(["auto", "minimal", "ultra"])(
|
||||
"keeps the legacy thinking fallback for unsupported GPT-5.6 effort %s",
|
||||
(effort) => {
|
||||
const body = {
|
||||
reasoning: { effort },
|
||||
messages: [{ role: "user", content: "Use legacy thinking" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("gpt-5.6-luna", body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
expect(systemPromptOf(result)).toContain("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>");
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["none", "off", "disabled"])(
|
||||
"keeps GPT-5.6 reasoning intentionally disabled for effort %s",
|
||||
(effort) => {
|
||||
const body = {
|
||||
reasoning: { effort },
|
||||
messages: [{ role: "user", content: "Do not reason" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("gpt-5.6-luna", body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
expect(systemPromptOf(result)).not.toContain("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps the thinking-alias fallback when GPT effort is blank", () => {
|
||||
const body = {
|
||||
reasoning: { effort: "" },
|
||||
messages: [{ role: "user", content: "Use the thinking alias" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("gpt-5.6-sol-thinking", body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
expect(systemPromptOf(result)).toContain("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for legacy Kiro model ids", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Legacy model id should not get adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.5", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude-sonnet-4.5-thinking-agentic(high)", "claude-sonnet-4.5"],
|
||||
["glm-5-thinking-agentic(medium)", "glm-5"],
|
||||
])("normalizes unsupported Kiro intensity suffix for %s", (model, upstream) => {
|
||||
const result = openaiToKiroRequest(model, {
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}, true, {});
|
||||
|
||||
expect(result.conversationState.currentMessage.userInputMessage.modelId).toBe(upstream);
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
expect(systemPromptOf(result)).toContain("CHUNKED WRITE PROTOCOL");
|
||||
});
|
||||
|
||||
it("maps a supported Kiro Claude intensity suffix to native effort fields", () => {
|
||||
const result = openaiToKiroRequest("claude-sonnet-5-thinking-agentic(high)", {
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}, true, {});
|
||||
|
||||
expect(result.conversationState.currentMessage.userInputMessage.modelId).toBe("claude-sonnet-5");
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for date-suffixed Claude 4 model ids", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Date-suffixed Claude 4 should stay legacy" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4-20250514", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for pre-4 legacy Kiro model ids", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Older model id should not get adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-3.7", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for prefixed pre-4 legacy Kiro model ids", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Prefixed older model id should not get adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("kiro/claude-3-7-sonnet-20250219", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not send Claude-specific additionalModelRequestFields for prefixed non-Claude aliases", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Prefixed non-Claude alias should not get adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("kiro/gpt-4o", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not send Claude-specific additionalModelRequestFields for non-Claude aliases", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Non-Claude aliases should not get Claude adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("gpt-4o", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults future Kiro model ids to additionalModelRequestFields support", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Future model id should get adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.60", body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps reasoning_effort max to Kiro max_thinking_length 32000", () => {
|
||||
@@ -315,7 +531,8 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields?.output_config?.effort).toBe("high");
|
||||
});
|
||||
|
||||
it("clamps OpenAI Responses reasoning.effort xhigh to max_thinking_length 32000", () => {
|
||||
@@ -326,7 +543,8 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields?.output_config?.effort).toBe("high");
|
||||
});
|
||||
|
||||
it("uses Claude thinking.budget_tokens as max_thinking_length", () => {
|
||||
@@ -337,7 +555,7 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>4096</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>4096</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("uses the default budget for synthetic -thinking models with no explicit config", () => {
|
||||
@@ -347,7 +565,54 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6-thinking", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>16000</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>16000</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("keeps top-level systemPrompt stable across turns", () => {
|
||||
const first = openaiToKiroRequest(
|
||||
"claude-sonnet-4.6-thinking",
|
||||
{ messages: [{ role: "user", content: "first" }] },
|
||||
true,
|
||||
{}
|
||||
);
|
||||
const second = openaiToKiroRequest(
|
||||
"claude-sonnet-4.6-thinking",
|
||||
{ messages: [{ role: "user", content: "second" }] },
|
||||
true,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(first.systemPrompt).toBe(second.systemPrompt);
|
||||
expect(first.systemPrompt).not.toContain("Current time");
|
||||
expect(first.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
|
||||
});
|
||||
|
||||
it("replays frozen msg0 for explicit Kiro sessions while keeping current time fresh", () => {
|
||||
const credentials = {
|
||||
connectionId: "kiro-account-openai-replay",
|
||||
rawHeaders: { "x-session-id": "hermes-session-openai-replay" },
|
||||
};
|
||||
const first = openaiToKiroRequest(
|
||||
"claude-sonnet-4.6",
|
||||
{ messages: [{ role: "user", content: "first turn" }] },
|
||||
true,
|
||||
credentials
|
||||
);
|
||||
const second = openaiToKiroRequest(
|
||||
"claude-sonnet-4.6",
|
||||
{ messages: [{ role: "user", content: "second turn" }] },
|
||||
true,
|
||||
credentials
|
||||
);
|
||||
|
||||
expect(second.conversationState.conversationId).toBe("hermes-session-openai-replay");
|
||||
expect(second.conversationState.agentContinuationId).toBe(first.conversationState.agentContinuationId);
|
||||
expect(second.conversationState.history[0].userInputMessage.content).toBe(
|
||||
first.conversationState.currentMessage.userInputMessage.content
|
||||
);
|
||||
expect(second.conversationState.history[0].userInputMessage.modelId).toBe("claude-sonnet-4.6");
|
||||
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
|
||||
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("second turn");
|
||||
});
|
||||
|
||||
it("does not inject thinking prefix for reasoning_effort none", () => {
|
||||
@@ -358,8 +623,9 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).not.toContain("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(contentOf(result)).not.toContain("<max_thinking_length>");
|
||||
expect(systemPromptOf(result)).not.toContain("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,4 +28,28 @@ describe("stripUnsupportedParams", () => {
|
||||
|
||||
expect(body).toEqual({ top_p: 1 });
|
||||
});
|
||||
|
||||
it("clamps VolcEngine Ark GLM max token fields to the model output ceiling", () => {
|
||||
const body = {
|
||||
max_tokens: 131072,
|
||||
max_completion_tokens: 131072,
|
||||
max_output_tokens: 131072,
|
||||
};
|
||||
|
||||
stripUnsupportedParams("volcengine-ark", "GLM-5.2", body);
|
||||
|
||||
expect(body).toEqual({
|
||||
max_tokens: 128000,
|
||||
max_completion_tokens: 128000,
|
||||
max_output_tokens: 128000,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps VolcEngine Ark GLM max tokens when already under the ceiling", () => {
|
||||
const body = { max_tokens: 64000 };
|
||||
|
||||
stripUnsupportedParams("volcengine-ark", "GLM-5.2", body);
|
||||
|
||||
expect(body.max_tokens).toBe(64000);
|
||||
});
|
||||
});
|
||||
|
||||
77
tests/unit/ping-reasoning-models-3010.test.js
Normal file
77
tests/unit/ping-reasoning-models-3010.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -31,4 +31,10 @@ describe("provider display split (E1)", () => {
|
||||
expect(m.ALIAS_TO_ID.kr).toBe("kiro");
|
||||
expect(m.getProvidersByKind("tts").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("Cloudflare exposes API-key authentication on its free-tier card", async () => {
|
||||
const { FREE_TIER_PROVIDERS } = await import("../../src/shared/constants/providers.js");
|
||||
expect(FREE_TIER_PROVIDERS["cloudflare-ai"].authType).toBe("apikey");
|
||||
expect(FREE_TIER_PROVIDERS["cloudflare-ai"].authModes).toEqual(["apikey"]);
|
||||
});
|
||||
});
|
||||
|
||||
55
tests/unit/provider-quota-visibility.test.js
Normal file
55
tests/unit/provider-quota-visibility.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterQuotasByVisibility,
|
||||
getHiddenQuotaRows,
|
||||
parseQuotaData,
|
||||
} from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
describe("provider quota visibility", () => {
|
||||
const data = {
|
||||
quotas: {
|
||||
"gemini-pro-agent": {
|
||||
displayName: "Gemini 3.1 Pro (High)",
|
||||
used: 200,
|
||||
total: 1000,
|
||||
resetAt: "2026-07-04T00:00:00Z",
|
||||
},
|
||||
"claude-opus-4-6-thinking": {
|
||||
displayName: "Claude Opus 4.6 (Thinking)",
|
||||
used: 100,
|
||||
total: 1000,
|
||||
resetAt: "2026-07-04T00:00:00Z",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("keeps Antigravity modelKey so hidden settings use stable quota ids", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
expect(quotas.map((q) => q.modelKey)).toEqual([
|
||||
"gemini-pro-agent",
|
||||
"claude-opus-4-6-thinking",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows all quotas by default and hides configured provider rows", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
expect(filterQuotasByVisibility("antigravity", quotas, {})).toHaveLength(2);
|
||||
|
||||
const visibility = {
|
||||
antigravity: { hidden: ["claude-opus-4-6-thinking"] },
|
||||
};
|
||||
const visible = filterQuotasByVisibility("antigravity", quotas, visibility);
|
||||
const hidden = getHiddenQuotaRows("antigravity", quotas, visibility);
|
||||
|
||||
expect(visible.map((q) => q.modelKey)).toEqual(["gemini-pro-agent"]);
|
||||
expect(hidden.map((q) => q.modelKey)).toEqual(["claude-opus-4-6-thinking"]);
|
||||
});
|
||||
|
||||
it("does not apply one provider hidden list to another provider", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const visibility = {
|
||||
codex: { hidden: ["gemini-pro-agent"] },
|
||||
};
|
||||
expect(filterQuotasByVisibility("antigravity", quotas, visibility)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
95
tests/unit/pxpipe.test.js
Normal file
95
tests/unit/pxpipe.test.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { compressWithPxpipe, formatPxpipeLog } from "../../open-sse/rtk/pxpipe.js";
|
||||
|
||||
const bigText = "x".repeat(30000);
|
||||
const claudeBody = () => ({
|
||||
model: "claude-fable-5",
|
||||
max_tokens: 100,
|
||||
messages: [{ role: "user", content: bigText }],
|
||||
});
|
||||
|
||||
// A transform double mimicking pxpipe-proxy/transform's contract.
|
||||
const appliedTransform = (outBody) => async () => ({
|
||||
applied: true,
|
||||
reason: "applied",
|
||||
body: new TextEncoder().encode(JSON.stringify(outBody)),
|
||||
info: { compressedChars: 25000, imageCount: 2, imageBytes: 5000, imagePixels: 1500000 },
|
||||
cache: { ownsCacheControl: true, markerCount: 1 },
|
||||
});
|
||||
|
||||
describe("compressWithPxpipe gates", () => {
|
||||
it("skips when disabled", async () => {
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), { enabled: false });
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("disabled");
|
||||
});
|
||||
|
||||
it("skips when transform is unavailable (not installed)", async () => {
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), { enabled: true, format: "claude", transform: null });
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("not_installed");
|
||||
});
|
||||
|
||||
it("skips non-Claude formats", async () => {
|
||||
const transform = vi.fn();
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), { enabled: true, format: "openai", transform });
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("unsupported_format");
|
||||
expect(transform).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bypasses small prompts below minChars", async () => {
|
||||
const transform = vi.fn();
|
||||
const small = { model: "claude-fable-5", messages: [{ role: "user", content: "hi" }] };
|
||||
const { body, summary } = await compressWithPxpipe(small, { enabled: true, format: "claude", minChars: 25000, transform });
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("below_threshold");
|
||||
expect(transform).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the transform and reports savings", async () => {
|
||||
const compressed = { model: "claude-fable-5", messages: [{ role: "user", content: "imaged" }] };
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), {
|
||||
enabled: true, format: "claude", minChars: 1000, transform: appliedTransform(compressed),
|
||||
});
|
||||
expect(body).toEqual(compressed);
|
||||
expect(summary.applied).toBe(true);
|
||||
expect(summary.imageCount).toBe(2);
|
||||
expect(summary.tokensBeforeEst).toBeGreaterThan(summary.tokensAfterEst);
|
||||
expect(summary.savedPct).toBeGreaterThan(0);
|
||||
expect(formatPxpipeLog(summary)).toContain("2 image(s)");
|
||||
});
|
||||
|
||||
it("passes through when the transform declines (not_profitable)", async () => {
|
||||
const transform = async () => ({ applied: false, reason: "not_profitable", body: new Uint8Array(), info: {} });
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), {
|
||||
enabled: true, format: "claude", minChars: 1000, transform,
|
||||
});
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("not_profitable");
|
||||
});
|
||||
|
||||
it("fails open when the transform throws", async () => {
|
||||
const transform = async () => { throw new Error("boom"); };
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), {
|
||||
enabled: true, format: "claude", minChars: 1000, transform,
|
||||
});
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("transform_error");
|
||||
expect(summary.detail).toBe("boom");
|
||||
});
|
||||
|
||||
it("fails open on timeout", async () => {
|
||||
const transform = () => new Promise(() => {}); // never resolves
|
||||
const { body, summary } = await compressWithPxpipe(claudeBody(), {
|
||||
enabled: true, format: "claude", minChars: 1000, timeoutMs: 50, transform,
|
||||
});
|
||||
expect(body).toBeNull();
|
||||
expect(summary.reason).toBe("timeout");
|
||||
});
|
||||
|
||||
it("does not log skipped requests as savings", () => {
|
||||
expect(formatPxpipeLog({ applied: false, reason: "below_threshold" })).toBeNull();
|
||||
expect(formatPxpipeLog(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
147
tests/unit/qoder-billing.test.js
Normal file
147
tests/unit/qoder-billing.test.js
Normal file
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ vi.mock("open-sse/executors/index.js", () => ({
|
||||
|
||||
describe("quota auto-ping", () => {
|
||||
let runQuotaAutoPingTick;
|
||||
let configureQuotaAutoPing;
|
||||
let deps;
|
||||
let state;
|
||||
let getCodexUsage;
|
||||
@@ -83,11 +84,12 @@ describe("quota auto-ping", () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
delete global.__quotaAutoPing;
|
||||
|
||||
({ getCodexUsage } = await import("open-sse/services/usage/codex.js"));
|
||||
({ getClaudeUsage } = await import("open-sse/services/usage/claude.js"));
|
||||
({ getExecutor } = await import("open-sse/executors/index.js"));
|
||||
({ runQuotaAutoPingTick } = await import("../../src/shared/services/quotaAutoPing.js"));
|
||||
({ runQuotaAutoPingTick, configureQuotaAutoPing } = await import("../../src/shared/services/quotaAutoPing.js"));
|
||||
|
||||
deps = {
|
||||
getSettings: vi.fn(),
|
||||
@@ -117,6 +119,25 @@ describe("quota auto-ping", () => {
|
||||
expect(deps.proxyAwareFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the scheduler only when an account opts in", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
configureQuotaAutoPing({ codexAutoPing: { connections: {} } });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
configureQuotaAutoPing({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("stops the scheduler when the last account opts out", () => {
|
||||
vi.useFakeTimers();
|
||||
configureQuotaAutoPing({ claudeAutoPing: { connections: { "claude-1": true } } });
|
||||
|
||||
configureQuotaAutoPing({ claudeAutoPing: { connections: { "claude-1": false } } });
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not ping Codex on the first resetAt observation", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
|
||||
54
tests/unit/request-details-redaction.test.js
Normal file
54
tests/unit/request-details-redaction.test.js
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user