merge: integrate origin/master (v0.5.50) into gitea/new_feature
- Resolve conflicts in chatCore handlers: keep apiKey/streamErrorPatterns from the details-filters feature, adopt origin's stripContinuityFields, customToolNames, cache-inclusive usage accounting, and Responses-API SSE→JSON conversion - Adopt origin's provider usage handlers (codebuddy-intl, qoder creds) and modality detection (audio/video inputs) - Keep requestDetails apiKey column (schema v2) + masked key persistence Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
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,7 +3,7 @@
|
||||
"cc": "claude",
|
||||
"cx": "codex",
|
||||
"gc": "gemini-cli",
|
||||
"qw": "qwen",
|
||||
"qw": "qw",
|
||||
"if": "iflow",
|
||||
"ag": "antigravity",
|
||||
"gh": "github",
|
||||
@@ -112,8 +112,8 @@
|
||||
"qianfan": "baidu",
|
||||
"baidu": "baidu",
|
||||
"ernie": "baidu",
|
||||
"dv": "devin-cli",
|
||||
"devin": "devin-cli",
|
||||
"dv": "dv",
|
||||
"devin": "devin",
|
||||
"devin-cli": "devin-cli",
|
||||
"morph": "morph",
|
||||
"morphllm": "morph"
|
||||
@@ -146,7 +146,6 @@
|
||||
"cursor": "cu",
|
||||
"deepgram": "deepgram",
|
||||
"deepseek": "deepseek",
|
||||
"devin-cli": "dv",
|
||||
"featherless": "featherless",
|
||||
"fireworks": "fireworks",
|
||||
"gemini": "gemini",
|
||||
@@ -184,12 +183,13 @@
|
||||
"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",
|
||||
@@ -227,7 +227,6 @@
|
||||
"cx",
|
||||
"deepgram",
|
||||
"deepseek",
|
||||
"dv",
|
||||
"edge-tts",
|
||||
"elevenlabs-tts-models",
|
||||
"fal-ai",
|
||||
@@ -275,16 +274,20 @@
|
||||
"perplexity",
|
||||
"perplexity-agent",
|
||||
"perplexity-web",
|
||||
"poolside",
|
||||
"qd",
|
||||
"qianfan",
|
||||
"qw",
|
||||
"recraft",
|
||||
"runwayml",
|
||||
"samba",
|
||||
"sdwebui",
|
||||
"selfhosted-embedding",
|
||||
"selfhosted-stt",
|
||||
"selfhosted-tts",
|
||||
"siliconflow",
|
||||
"stability-ai",
|
||||
"together",
|
||||
"tokenrouter",
|
||||
"venice",
|
||||
"vertex",
|
||||
"vertex-partner",
|
||||
@@ -292,6 +295,7 @@
|
||||
"voyage-ai",
|
||||
"xai",
|
||||
"xiaomi-mimo",
|
||||
"xiaomi-mimo-tts-models",
|
||||
"xiaomi-tokenplan",
|
||||
"zd"
|
||||
]
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"token": "https://api.anthropic.com/v1/oauth/token",
|
||||
"auth": "https://api.anthropic.com/v1/oauth/authorize"
|
||||
},
|
||||
"qwen": {
|
||||
"token": "https://chat.qwen.ai/api/v1/oauth2/token",
|
||||
"auth": "https://chat.qwen.ai/api/v1/oauth2/device/code"
|
||||
},
|
||||
"iflow": {
|
||||
"token": "https://iflow.cn/oauth/token",
|
||||
"auth": "https://iflow.cn/oauth"
|
||||
@@ -29,7 +25,6 @@
|
||||
"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",
|
||||
@@ -49,7 +44,6 @@
|
||||
"clientIds": {
|
||||
"claude": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
"codex": "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
"qwen": "f0304373b74a44d2b584a3fb70ca9e56",
|
||||
"iflow": "10009311001",
|
||||
"kimi": "17e5f671-d194-4dfb-9706-5516cb48c098",
|
||||
"grok-cli": "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
|
||||
@@ -115,10 +115,7 @@
|
||||
"oauth": {
|
||||
"header": "Authorization",
|
||||
"scheme": "bearer"
|
||||
},
|
||||
"hooks": [
|
||||
"claudeOverlay"
|
||||
]
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"oauthUrl": "https://api.anthropic.com/api/oauth/usage",
|
||||
@@ -548,7 +545,6 @@
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/vnd.amazon.eventstream",
|
||||
"X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse",
|
||||
"User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0",
|
||||
"X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0"
|
||||
},
|
||||
@@ -756,12 +752,6 @@
|
||||
},
|
||||
"format": "openai"
|
||||
},
|
||||
"qwen": {
|
||||
"baseUrl": "https://portal.qwen.ai/v1/chat/completions",
|
||||
"format": "openai",
|
||||
"clientId": "f0304373b74a44d2b584a3fb70ca9e56",
|
||||
"tokenUrl": "https://chat.qwen.ai/api/v1/oauth2/token"
|
||||
},
|
||||
"siliconflow": {
|
||||
"baseUrl": "https://api.siliconflow.com/v1/chat/completions",
|
||||
"validateUrl": "https://api.siliconflow.com/v1/models",
|
||||
@@ -898,6 +888,9 @@
|
||||
"header": "Authorization",
|
||||
"scheme": "bearer"
|
||||
},
|
||||
"usage": {
|
||||
"url": "https://www.codebuddy.ai/v2/billing/meter/get-user-resource"
|
||||
},
|
||||
"format": "openai",
|
||||
"tokenUrl": "https://www.codebuddy.ai/v2/plugin/auth/token"
|
||||
},
|
||||
@@ -967,8 +960,15 @@
|
||||
"validateUrl": "https://api.morphllm.com/v1/models",
|
||||
"format": "openai"
|
||||
},
|
||||
"devin-cli": {
|
||||
"baseUrl": "devin://acp/stdio",
|
||||
"poolside": {
|
||||
"baseUrl": "https://inference.poolside.ai/v1/chat/completions",
|
||||
"validateUrl": "https://inference.poolside.ai/v1/models",
|
||||
"format": "openai"
|
||||
},
|
||||
"tokenrouter": {
|
||||
"baseUrl": "https://api.tokenrouter.com/v1/chat/completions",
|
||||
"validateUrl": "https://api.tokenrouter.com/v1/models",
|
||||
"thinkingFormat": "openai",
|
||||
"format": "openai"
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ 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,
|
||||
@@ -25,7 +24,6 @@ const resolved = {
|
||||
kimi: PROVIDERS.kimi?.tokenUrl,
|
||||
},
|
||||
authUrls: {
|
||||
qwen: PROVIDERS.qwen?.authUrl,
|
||||
iflow: PROVIDERS.iflow?.authUrl,
|
||||
kiro: PROVIDERS.kiro?.authUrl,
|
||||
},
|
||||
@@ -38,7 +36,6 @@ const resolved = {
|
||||
clientIds: {
|
||||
claude: PROVIDERS.claude?.clientId,
|
||||
codex: PROVIDERS.codex?.clientId,
|
||||
qwen: PROVIDERS.qwen?.clientId,
|
||||
iflow: PROVIDERS.iflow?.clientId,
|
||||
kimi: PROVIDERS.kimi?.clientId,
|
||||
"grok-cli": PROVIDERS["grok-cli"]?.clientId,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 } }],
|
||||
});
|
||||
|
||||
@@ -67,6 +67,27 @@ 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
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
@@ -157,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)", () => {
|
||||
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
)),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -69,3 +69,33 @@ describe("Codex fast tier and capacity handling", () => {
|
||||
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,7 +151,6 @@ 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")).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"]);
|
||||
});
|
||||
});
|
||||
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/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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -226,6 +226,11 @@ function binaryResponse(buffer, status = 200) {
|
||||
});
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -311,6 +316,7 @@ describe("getUsageForProvider(grok-cli)", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
@@ -329,11 +335,11 @@ describe("getUsageForProvider(grok-cli)", () => {
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "grok-cli",
|
||||
accessToken: "test-token",
|
||||
accessToken,
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("XPremiumPlus");
|
||||
expect(usage.plan).toBe("SuperGrok Heavy");
|
||||
expect(usage.quotas["Weekly SuperGrok"]).toMatchObject({
|
||||
used: 35,
|
||||
total: 100,
|
||||
@@ -345,7 +351,7 @@ describe("getUsageForProvider(grok-cli)", () => {
|
||||
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 test-token");
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
@@ -209,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%");
|
||||
});
|
||||
});
|
||||
|
||||
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\"}",
|
||||
});
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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/standalone-assets.test.js
Normal file
55
tests/unit/standalone-assets.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { copyStandaloneAssets } from "../../scripts/copy-standalone-assets.mjs";
|
||||
|
||||
function createBuildFixture(distDir) {
|
||||
const projectRoot = mkdtempSync(join(tmpdir(), "9router-standalone-assets-"));
|
||||
const buildRoot = join(projectRoot, distDir);
|
||||
mkdirSync(join(buildRoot, "standalone"), { recursive: true });
|
||||
mkdirSync(join(buildRoot, "static", "chunks"), { recursive: true });
|
||||
mkdirSync(join(projectRoot, "public"), { recursive: true });
|
||||
writeFileSync(join(buildRoot, "static", "chunks", "app.js"), "static asset");
|
||||
writeFileSync(join(projectRoot, "public", "favicon.svg"), "public asset");
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
describe("standalone build assets", () => {
|
||||
it("copies static and public assets into the default standalone layout", () => {
|
||||
const projectRoot = createBuildFixture(".next");
|
||||
|
||||
copyStandaloneAssets({ projectRoot, distDir: ".next" });
|
||||
|
||||
expect(readFileSync(join(projectRoot, ".next", "standalone", ".next", "static", "chunks", "app.js"), "utf8"))
|
||||
.toBe("static asset");
|
||||
expect(readFileSync(join(projectRoot, ".next", "standalone", "public", "favicon.svg"), "utf8"))
|
||||
.toBe("public asset");
|
||||
});
|
||||
|
||||
it("uses a custom Next dist directory", () => {
|
||||
const projectRoot = createBuildFixture(".next-cli-build");
|
||||
|
||||
copyStandaloneAssets({ projectRoot, distDir: ".next-cli-build" });
|
||||
|
||||
expect(readFileSync(join(projectRoot, ".next-cli-build", "standalone", ".next-cli-build", "static", "chunks", "app.js"), "utf8"))
|
||||
.toBe("static asset");
|
||||
});
|
||||
|
||||
it("does not modify workspace-traced CLI builds", () => {
|
||||
const projectRoot = createBuildFixture(".next-cli-build");
|
||||
const previousMode = process.env.NEXT_TRACING_ROOT_MODE;
|
||||
process.env.NEXT_TRACING_ROOT_MODE = "workspace";
|
||||
|
||||
try {
|
||||
copyStandaloneAssets({ projectRoot, distDir: ".next-cli-build" });
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.NEXT_TRACING_ROOT_MODE;
|
||||
else process.env.NEXT_TRACING_ROOT_MODE = previousMode;
|
||||
}
|
||||
|
||||
expect(() => readFileSync(join(projectRoot, ".next-cli-build", "standalone", ".next-cli-build", "static", "chunks", "app.js")))
|
||||
.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,21 @@ import { describe, it, expect } from "vitest";
|
||||
import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js";
|
||||
|
||||
describe("getThinkingLevels", () => {
|
||||
it("adds max for gpt-5.6-sol on codex", () => {
|
||||
const levels = getThinkingLevels("codex", "gpt-5.6-sol");
|
||||
expect(levels).toContain("max");
|
||||
expect(levels).toContain("xhigh");
|
||||
expect(levels).not.toContain("ultra");
|
||||
it.each([
|
||||
["gpt-5.6-sol", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]],
|
||||
["gpt-5.6-terra", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]],
|
||||
["gpt-5.6-luna", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]],
|
||||
["gpt-5.6-sol-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]],
|
||||
["gpt-5.6-terra-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]],
|
||||
["gpt-5.6-luna-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]],
|
||||
])("returns Codex levels for %s", (model, expected) => {
|
||||
expect(getThinkingLevels("codex", model)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("does not expose Codex-only GPT-5.6 overrides on Kiro", () => {
|
||||
expect(getThinkingLevels("kiro", "gpt-5.6-sol")).toEqual([
|
||||
"none", "minimal", "low", "medium", "high", "xhigh",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not add max for other codex models", () => {
|
||||
@@ -14,7 +24,7 @@ describe("getThinkingLevels", () => {
|
||||
expect(levels).toEqual(["low", "medium", "high", "xhigh"]);
|
||||
});
|
||||
|
||||
it("does not add max for other gpt-5.6 models", () => {
|
||||
it("does not add max for other Codex models", () => {
|
||||
const levels = getThinkingLevels("codex", "gpt-5.5");
|
||||
expect(levels || []).not.toContain("max");
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Generic OAuth2 token refresh — config-driven profiles.
|
||||
*
|
||||
* Verifies refreshAccessToken() handles the 5 foldable providers
|
||||
* (qwen, iflow, github, kimi, claude) via a REFRESH_PROFILES table,
|
||||
* Verifies refreshAccessToken() handles the 4 foldable providers
|
||||
* (iflow, github, kimi, claude) via a REFRESH_PROFILES table,
|
||||
* while preserving the legacy generic path for unknown providers.
|
||||
*/
|
||||
|
||||
@@ -25,32 +25,6 @@ describe("refreshAccessToken — config-driven profiles", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; });
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it("qwen: form body + clientId, surfaces resource_url as providerSpecificData", async () => {
|
||||
const fm = mockFetchOnce({
|
||||
access_token: "qw-acc",
|
||||
refresh_token: "qw-refresh-rotated",
|
||||
expires_in: 7200,
|
||||
resource_url: "https://dashscope.aliyuncs.com",
|
||||
});
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
|
||||
const out = await refreshAccessToken("qwen", "qw-old-refresh", {}, console);
|
||||
|
||||
expect(out).toEqual({
|
||||
accessToken: "qw-acc",
|
||||
refreshToken: "qw-refresh-rotated",
|
||||
expiresIn: 7200,
|
||||
providerSpecificData: { resourceUrl: "https://dashscope.aliyuncs.com" },
|
||||
});
|
||||
const [url, init] = fm.mock.calls[0];
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
||||
const body = new URLSearchParams(init.body);
|
||||
expect(body.get("grant_type")).toBe("refresh_token");
|
||||
expect(body.get("refresh_token")).toBe("qw-old-refresh");
|
||||
expect(body.get("client_id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("iflow: Basic Auth header from clientId:clientSecret, form body keeps client_secret", async () => {
|
||||
const fm = mockFetchOnce({ access_token: "if-acc", refresh_token: "if-rot", expires_in: 3600 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
@@ -107,13 +81,13 @@ describe("refreshAccessToken — config-driven profiles", () => {
|
||||
it("returns null on non-ok response", async () => {
|
||||
mockFetchOnce({ error: "invalid_grant" }, { ok: false, status: 400 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
const out = await refreshAccessToken("qwen", "dead", {}, console);
|
||||
const out = await refreshAccessToken("iflow", "dead", {}, console);
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when refreshToken missing", async () => {
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
const out = await refreshAccessToken("qwen", "", {}, console);
|
||||
const out = await refreshAccessToken("iflow", "", {}, console);
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
const load = () => import("../../open-sse/services/usage.js");
|
||||
const SUPPORTED = [
|
||||
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
|
||||
"qoder", "qwen", "iflow", "ollama", "glm", "glm-cn",
|
||||
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi", "xai",
|
||||
"qoder", "iflow", "ollama", "glm", "glm-cn",
|
||||
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
|
||||
"deepseek",
|
||||
];
|
||||
|
||||
|
||||
150
tests/unit/xiaomi-mimo-tts.test.js
Normal file
150
tests/unit/xiaomi-mimo-tts.test.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { handleTtsCore } from "../../open-sse/handlers/ttsCore.js";
|
||||
import { buildTtsProviderModels, getTtsVoicesForModel } from "../../open-sse/config/ttsModels.js";
|
||||
import { AI_PROVIDERS } from "../../src/shared/constants/providers.js";
|
||||
import { getTtsAdapter } from "../../open-sse/handlers/ttsProviders/index.js";
|
||||
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js";
|
||||
import { TTS_PROVIDER_CONFIG } from "../../src/shared/constants/ttsProviders.js";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
function mockMiMoAudioResponse() {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
audio: {
|
||||
data: Buffer.from([0, 1, 2, 3]).toString("base64"),
|
||||
format: "wav",
|
||||
transcript: "Hello from MiMo",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
describe("Xiaomi MiMo TTS", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("posts to chat completions with text as assistant message and voice in audio.voice", async () => {
|
||||
mockMiMoAudioResponse();
|
||||
|
||||
const result = await handleTtsCore({
|
||||
provider: "xiaomi-mimo",
|
||||
model: "mimo-v2.5-tts/冰糖",
|
||||
input: "Hello from MiMo",
|
||||
credentials: { apiKey: "test-key" },
|
||||
responseFormat: "json",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(global.fetch.mock.calls[0][0]).toBe("https://api.xiaomimimo.com/v1/chat/completions");
|
||||
expect(global.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer test-key");
|
||||
|
||||
const sent = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(sent.model).toBe("mimo-v2.5-tts");
|
||||
expect(sent.stream).toBe(false);
|
||||
expect(sent.audio.format).toBe("wav");
|
||||
expect(sent.audio.voice).toBe("冰糖");
|
||||
const assistant = sent.messages.find((m) => m.role === "assistant");
|
||||
expect(assistant.content).toBe("Hello from MiMo");
|
||||
|
||||
const body = await result.response.json();
|
||||
expect(body.format).toBe("wav");
|
||||
expect(body.audio).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("uses mimo_default when no voice is provided", async () => {
|
||||
mockMiMoAudioResponse();
|
||||
|
||||
await handleTtsCore({
|
||||
provider: "xiaomi-mimo",
|
||||
model: "mimo-v2.5-tts",
|
||||
input: "Hello from MiMo",
|
||||
credentials: { apiKey: "test-key" },
|
||||
responseFormat: "json",
|
||||
});
|
||||
|
||||
const sent = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(sent.model).toBe("mimo-v2.5-tts");
|
||||
expect(sent.audio.voice).toBe("mimo_default");
|
||||
});
|
||||
|
||||
it("threads the style field into the role:user message", async () => {
|
||||
mockMiMoAudioResponse();
|
||||
|
||||
await handleTtsCore({
|
||||
provider: "xiaomi-mimo",
|
||||
model: "mimo-v2.5-tts/Chloe",
|
||||
input: "Hello from MiMo",
|
||||
style: "a calm, warm female voice speaking slowly",
|
||||
credentials: { apiKey: "test-key" },
|
||||
responseFormat: "json",
|
||||
});
|
||||
|
||||
const sent = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
const user = sent.messages.find((m) => m.role === "user");
|
||||
expect(user.content).toBe("a calm, warm female voice speaking slowly");
|
||||
});
|
||||
|
||||
it("exposes MiMo TTS models + preset voices in the TTS catalog", () => {
|
||||
const entries = buildTtsProviderModels();
|
||||
|
||||
expect(entries["xiaomi-mimo-tts-models"].map((model) => model.id)).toEqual(["mimo-v2.5-tts"]);
|
||||
|
||||
const voices = getTtsVoicesForModel("xiaomi-mimo", "mimo-v2.5-tts");
|
||||
expect(voices.map((v) => v.id)).toEqual([
|
||||
"mimo_default", "冰糖", "茉莉", "苏打", "白桦", "Mia", "Chloe", "Milo", "Dean",
|
||||
]);
|
||||
expect(voices.find((v) => v.id === "冰糖")).toMatchObject({ name: "冰糖" });
|
||||
expect(voices.find((v) => v.id === "Dean")).toMatchObject({ name: "Dean" });
|
||||
// Voices are language-independent and gender-neutral (no language/gender labels in the UI)
|
||||
expect(voices[0]).not.toHaveProperty("language");
|
||||
expect(voices[0]).not.toHaveProperty("gender");
|
||||
expect(getTtsVoicesForModel("xiaomi-mimo", "mimo-v2.5-tts-voiceclone")).toBeNull();
|
||||
});
|
||||
|
||||
it("threads the language hint into a role:user instruction", async () => {
|
||||
mockMiMoAudioResponse();
|
||||
|
||||
await handleTtsCore({
|
||||
provider: "xiaomi-mimo",
|
||||
model: "mimo-v2.5-tts/冰糖",
|
||||
input: "Hello from MiMo",
|
||||
language: "English",
|
||||
credentials: { apiKey: "test-key" },
|
||||
responseFormat: "json",
|
||||
});
|
||||
|
||||
const sent = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
const user = sent.messages.find((m) => m.role === "user");
|
||||
expect(user.content).toBe("Speak in English.");
|
||||
});
|
||||
|
||||
it("wires the provider into media-providers TTS (serviceKind, adapter, UI config)", () => {
|
||||
expect(AI_PROVIDERS["xiaomi-mimo"].serviceKinds).toContain("tts");
|
||||
expect(AI_PROVIDERS["xiaomi-mimo"].ttsConfig.baseUrl).toBe("https://api.xiaomimimo.com/v1/chat/completions");
|
||||
expect(getTtsAdapter("xiaomi-mimo")).toBeTruthy();
|
||||
|
||||
const ttsModels = PROVIDER_MODELS["xiaomi-mimo"].filter((m) => (m.kind || m.type) === "tts").map((m) => m.id);
|
||||
expect(ttsModels).toEqual(["mimo-v2.5-tts"]);
|
||||
|
||||
expect(TTS_PROVIDER_CONFIG["xiaomi-mimo"].hasStyleInput).toBe(true);
|
||||
expect(TTS_PROVIDER_CONFIG["xiaomi-mimo"].hasLanguageHint).toBe(true);
|
||||
expect(TTS_PROVIDER_CONFIG["xiaomi-mimo"].languageOptions).toEqual(["Chinese", "English"]);
|
||||
expect(TTS_PROVIDER_CONFIG["xiaomi-mimo"].hasVoiceIdInput).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user