This commit is contained in:
2026-07-06 00:01:34 +07:00
parent 2729408ef3
commit e7470e955e
108 changed files with 4131 additions and 529 deletions

View File

@@ -1,6 +1,5 @@
tests/unit/antigravity-mitm.test.js :: Antigravity MITM model handling flags the out-of-box agent/Default model mandatory
tests/unit/claude-header-forwarding.test.js :: proxyAwareFetch — api.anthropic.com routing routes api.anthropic.com to gotScraping (non-streaming) and returns ok response
tests/unit/kiro-model-slots.test.js :: Kiro MITM model slots offers a mappable slot for the agent default model id 'auto'
tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import extracts tokens using exact keys
tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import falls back to fuzzy key matching on macOS when exact keys are missing
tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import linux uses single hardcoded path and original error message
@@ -23,4 +22,4 @@ tests/unit/rtk.test.js :: compressMessages (enabled) skips when body has no mess
tests/unit/translator-request-normalization.test.js :: request normalization claudeToOpenAIRequest flattens text-only content arrays into string
tests/unit/translator-request-normalization.test.js :: request normalization filterToOpenAIFormat flattens text-only arrays to string
tests/unit/translator-request-normalization.test.js :: request normalization parseSSELine supports provider raw NDJSON stream lines
tests/unit/translator-request-normalization.test.js :: request normalization translateRequest keeps /v1/messages Claude->OpenAI text payloads string-safe
tests/unit/translator-request-normalization.test.js :: request normalization translateRequest keeps /v1/messages Claude->OpenAI text payloads string-safe

View File

@@ -1,7 +1,7 @@
// Real Antigravity-MITM requests (Gemini-internal: { request: { contents, ... } }) → OpenAI.
import { describe, it, expect } from "vitest";
import "./registerAll.js";
import { translateRequest } from "../../open-sse/translator/index.js";
import { translateRequest, translateResponse, initState } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
@@ -9,10 +9,9 @@ const AG2O = (req) =>
translateRequest(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, "m", { request: req }, true, null, null);
describe("Antigravity → OpenAI", () => {
// antigravity-to-openai.js:177-189 — content with BOTH functionResponse and functionCall/text
// returns toolResults early → drops the tool calls / text.
// KNOWN BUG
it.fails("functionResponse + functionCall in same content keeps both", () => {
// antigravity-to-openai.js — content with BOTH functionResponse and functionCall/text
// previously returned toolResults early → dropped tool calls / text (fixed in #2225)
it("functionResponse + functionCall in same content keeps both", () => {
const out = AG2O({
contents: [{
role: "model",
@@ -54,6 +53,32 @@ describe("Antigravity → OpenAI", () => {
});
});
describe("Antigravity → Claude", () => {
it("tool call input_json_delta includes Anthropic index", () => {
const state = initState(FORMATS.CLAUDE);
const events = translateResponse(FORMATS.ANTIGRAVITY, FORMATS.CLAUDE, {
response: {
responseId: "resp-1",
modelVersion: "gemini-pro-agent",
candidates: [{
content: {
role: "model",
parts: [{ functionCall: { name: "bash", args: { command: "git status" } } }],
},
finishReason: "STOP",
index: 0,
}],
},
}, state);
const jsonDelta = events.find(
(event) => event.type === "content_block_delta" && event.delta?.type === "input_json_delta"
);
expect(jsonDelta).toMatchObject({ index: expect.any(Number) });
expect(JSON.parse(jsonDelta.delta.partial_json)).toEqual({ command: "git status" });
});
});
describe("Antigravity executor", () => {
it("strips optional from nested tool schemas", () => {
const out = new AntigravityExecutor().transformRequest("gemini-2.5-pro", {

View File

@@ -171,6 +171,7 @@ describe("Kiro → Claude (direct route, OpenAI-shaped chunks from executor)", (
const jsonDelta = events.find(
(e) => e.type === "content_block_delta" && e.delta.type === "input_json_delta"
);
expect(jsonDelta.index).toBeDefined();
expect(jsonDelta.delta.partial_json).toBe('{"q":"x"}');
const md = events.find((e) => e.type === "message_delta");
expect(md.delta.stop_reason).toBe("tool_use");

View File

@@ -0,0 +1,58 @@
// E2E: hit live local proxy → verify nvidia MiniMax M2.7 doesn't 400 on
// unsupported "thinking" param (nvidia NIM is OpenAI-compatible).
// Requires dev server running on NV_E2E_PORT + an active router API key in DB.
// RUN_E2E=1 npx vitest run --config tests/vitest.config.js tests/translator/real/nvidia-thinking.e2e.test.js
import { describe, it, expect, beforeAll } from "vitest";
import { getApiKeys } from "../../../src/lib/db/repos/apiKeysRepo.js";
const PORT = process.env.NV_E2E_PORT || "20127";
const BASE = `http://localhost:${PORT}`;
const MODELS = [
"nvidia/minimaxai/minimax-m2.7",
"nvidia/minimaxai/minimax-m3",
"nvidia/z-ai/glm-5.2",
"nvidia/deepseek-ai/deepseek-v4-pro",
"nvidia/deepseek-ai/deepseek-v4-flash",
"nvidia/moonshotai/kimi-k2.6",
"nvidia/nvidia/nemotron-3-ultra-550b-a55b",
];
const RUN = process.env.RUN_E2E === "1";
const maybe = RUN ? describe : describe.skip;
async function drain(res) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let out = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
out += decoder.decode(value, { stream: true });
}
return out;
}
maybe("nvidia thinking e2e", () => {
let apiKey = "";
beforeAll(async () => {
const keys = await getApiKeys();
apiKey = keys.find((k) => k.isActive)?.key || process.env.NV_E2E_KEY || "";
});
it.each(MODELS)("%s with reasoning_effort -> no 'thinking' 400", async (model) => {
if (!apiKey) return expect(true).toBe(true);
const res = await fetch(`${BASE}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model,
stream: true,
max_tokens: 64,
reasoning_effort: "low",
messages: [{ role: "user", content: "Reply with the single word: hi" }],
}),
});
const raw = await drain(res);
expect(/Unsupported parameter.*thinking/i.test(raw), `${model} rejected 'thinking'`).toBe(false);
expect(res.status, `${model} bad status ${res.status}`).toBeLessThan(400);
}, 90000);
});

View File

@@ -0,0 +1,66 @@
// #2069 — cache_control markers stripped for alicode/alicode-intl (DashScope) providers.
// DashScope supports explicit cache_control: { type: "ephemeral" } in content blocks,
// but the default filterToOpenAIFormat strips them. preserveCacheControl quirk opts-in.
import { describe, it, expect } from "vitest";
import { filterToOpenAIFormat } from "../../open-sse/translator/formats/openai.js";
const msgWithCache = [
{
role: "user",
content: [
{ type: "text", text: "large context", cache_control: { type: "ephemeral" } },
],
},
{
role: "assistant",
content: [
{ type: "text", text: "reply", cache_control: { type: "ephemeral" } },
],
},
];
describe("filterToOpenAIFormat cache_control handling (#2069)", () => {
it("strips cache_control by default (all standard OpenAI providers)", () => {
const body = { messages: JSON.parse(JSON.stringify(msgWithCache)) };
filterToOpenAIFormat(body);
for (const msg of body.messages) {
for (const block of msg.content) {
expect(block.cache_control).toBeUndefined();
}
}
});
it("preserves cache_control when preserveCacheControl option is true (alicode/DashScope)", () => {
const body = { messages: JSON.parse(JSON.stringify(msgWithCache)) };
filterToOpenAIFormat(body, { preserveCacheControl: true });
for (const msg of body.messages) {
for (const block of msg.content) {
expect(block.cache_control).toEqual({ type: "ephemeral" });
}
}
});
it("always strips signature regardless of preserveCacheControl", () => {
const body = {
messages: [
{
role: "user",
content: [
{ type: "text", text: "hi", signature: "sig123", cache_control: { type: "ephemeral" } },
],
},
],
};
filterToOpenAIFormat(body, { preserveCacheControl: true });
expect(body.messages[0].content[0].signature).toBeUndefined();
expect(body.messages[0].content[0].cache_control).toEqual({ type: "ephemeral" });
});
it("does not add cache_control when block had none (preserveCacheControl: true)", () => {
const body = {
messages: [{ role: "user", content: [{ type: "text", text: "no cache" }] }],
};
filterToOpenAIFormat(body, { preserveCacheControl: true });
expect(body.messages[0].content[0].cache_control).toBeUndefined();
});
});

View File

@@ -0,0 +1,84 @@
// End-to-end: a cache-bearing request flows through canonicalizeUsage →
// saveRequestUsage → getUsageStats, proving cached tokens are persisted,
// aggregated, and cost is computed correctly (the bug this branch fixes).
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { canonicalizeUsage } from "../../open-sse/utils/usageTracking.js";
const originalDataDir = process.env.DATA_DIR;
let tempDir;
let db;
beforeAll(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-cached-e2e-"));
process.env.DATA_DIR = tempDir;
vi.resetModules();
db = await import("@/lib/db/index.js");
await db.initDb();
});
afterAll(() => {
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
});
describe("cached-token end-to-end (persist + aggregate + cost)", () => {
it("Claude cache usage: canonical prompt is inclusive, cached persisted, cost correct", async () => {
// Raw Claude usage (cache-EXCLUSIVE prompt): input 100, cache_read 200, cache_creation 30, output 50
const canonical = canonicalizeUsage({
prompt_tokens: 100,
completion_tokens: 50,
cache_read_input_tokens: 200,
cache_creation_input_tokens: 30,
});
expect(canonical.prompt_tokens).toBe(330); // inclusive
await db.saveRequestUsage({
provider: "anthropic",
model: "claude-sonnet-4-6",
connectionId: "c-cache",
tokens: canonical,
endpoint: "/v1/messages",
status: "ok",
});
const stats = await db.getUsageStats("24h");
expect(stats.totalCachedTokens).toBe(200);
expect(stats.totalPromptTokens).toBe(330);
expect(stats.byProvider.anthropic.cachedTokens).toBe(200);
// Cost: nonCached=330-200-30=100 @3 + cached 200 @0.30 + creation 30 @3.75 + output 50 @15
const expected = (100 * 3 + 200 * 0.3 + 30 * 3.75 + 50 * 15) / 1_000_000;
const hist = await db.getUsageHistory({ provider: "anthropic" });
expect(hist.length).toBe(1);
expect(hist[0].cost).toBeCloseTo(expected, 12);
expect(hist[0].tokens.cached_tokens).toBe(200);
expect(hist[0].tokens.cache_creation_input_tokens).toBe(30);
});
it("OpenAI cache usage: inclusive prompt passes through, cached counted once", async () => {
const canonical = canonicalizeUsage({
prompt_tokens: 1000, // already includes cached
completion_tokens: 200,
cached_tokens: 600,
});
expect(canonical.prompt_tokens).toBe(1000);
expect(canonical.cached_tokens).toBe(600);
await db.saveRequestUsage({
provider: "openai",
model: "gpt-4o",
connectionId: "c-oai",
tokens: canonical,
endpoint: "/v1/chat/completions",
status: "ok",
});
const hist = await db.getUsageHistory({ provider: "openai" });
expect(hist[0].tokens.prompt_tokens).toBe(1000);
expect(hist[0].tokens.cached_tokens).toBe(600);
});
});

View File

@@ -0,0 +1,188 @@
import { describe, it, expect } from "vitest";
import { canonicalizeUsage, extractUsage, mergeUsage } from "../../open-sse/utils/usageTracking.js";
import { calculateCostFromTokens } from "../../open-sse/providers/pricing.js";
import { toOpenAIUsage } from "../../open-sse/translator/concerns/usage.js";
// Canonical convention (single source of truth for storage + cost):
// prompt_tokens = total input INCLUDING cache read + cache creation
// cached_tokens = cache-read portion (subset of prompt_tokens)
// cache_creation_input_tokens = cache-write portion (subset of prompt_tokens)
// completion_tokens = output
// Discriminator: Claude reports cache separately (prompt EXCLUDES cache);
// OpenAI/Gemini report prompt INCLUDING cached_tokens.
describe("canonicalizeUsage", () => {
it("folds Claude exclusive cache into an inclusive prompt count", () => {
// Claude: input_tokens excludes cache; cache_read + cache_creation are separate
const out = canonicalizeUsage({
prompt_tokens: 100,
completion_tokens: 50,
cache_read_input_tokens: 200,
cache_creation_input_tokens: 30,
});
expect(out.prompt_tokens).toBe(330); // 100 + 200 + 30
expect(out.completion_tokens).toBe(50);
expect(out.cached_tokens).toBe(200);
expect(out.cache_creation_input_tokens).toBe(30);
});
it("passes through OpenAI inclusive prompt unchanged", () => {
// OpenAI: prompt_tokens already includes cached_tokens (a subset)
const out = canonicalizeUsage({
prompt_tokens: 330,
completion_tokens: 50,
cached_tokens: 200,
});
expect(out.prompt_tokens).toBe(330);
expect(out.cached_tokens).toBe(200);
expect(out.cache_creation_input_tokens).toBe(0);
});
it("passes through Gemini inclusive prompt (cachedContent already counted)", () => {
const out = canonicalizeUsage({
prompt_tokens: 500,
completion_tokens: 80,
cached_tokens: 120,
reasoning_tokens: 40,
});
expect(out.prompt_tokens).toBe(500);
expect(out.cached_tokens).toBe(120);
expect(out.reasoning_tokens).toBe(40);
});
it("handles no-cache usage", () => {
const out = canonicalizeUsage({ prompt_tokens: 100, completion_tokens: 50 });
expect(out.prompt_tokens).toBe(100);
expect(out.cached_tokens).toBe(0);
expect(out.cache_creation_input_tokens).toBe(0);
});
it("is idempotent (running twice yields the same canonical shape)", () => {
const once = canonicalizeUsage({
prompt_tokens: 100,
completion_tokens: 50,
cache_read_input_tokens: 200,
cache_creation_input_tokens: 30,
});
const twice = canonicalizeUsage(once);
expect(twice.prompt_tokens).toBe(330);
expect(twice.cached_tokens).toBe(200);
expect(twice.cache_creation_input_tokens).toBe(30);
expect(twice.completion_tokens).toBe(50);
});
it("returns null for invalid input", () => {
expect(canonicalizeUsage(null)).toBeNull();
expect(canonicalizeUsage(undefined)).toBeNull();
});
it("folds a Claude cache-miss first write (cache_creation only, no cache_read yet)", () => {
// Cache-miss on first write: upstream emits cache_creation_input_tokens but
// no cache_read_input_tokens at all (not even 0). Must still fold into prompt
// instead of falling through to the OpenAI passthrough branch.
const out = canonicalizeUsage({
prompt_tokens: 100,
completion_tokens: 20,
cache_creation_input_tokens: 500,
});
expect(out.prompt_tokens).toBe(600); // 100 + 0 (no read) + 500
expect(out.cached_tokens).toBe(0);
expect(out.cache_creation_input_tokens).toBe(500);
});
});
describe("calculateCostFromTokens (canonical inclusive convention)", () => {
const pricing = { input: 3, output: 15, cached: 0.3, cache_creation: 3.75 };
it("prices cached + cache_creation as subsets of an inclusive prompt without double-counting", () => {
// prompt=330 includes 200 cached + 30 cache_creation → 100 full-price input
const cost = calculateCostFromTokens(
{ prompt_tokens: 330, completion_tokens: 50, cached_tokens: 200, cache_creation_input_tokens: 30 },
pricing
);
const expected =
(100 * 3 + 200 * 0.3 + 30 * 3.75 + 50 * 15) / 1_000_000;
expect(cost).toBeCloseTo(expected, 12);
});
it("does not let cache_creation drive nonCached negative", () => {
// pathological: cached + creation exceeds prompt → nonCached clamps at 0
const cost = calculateCostFromTokens(
{ prompt_tokens: 100, completion_tokens: 0, cached_tokens: 80, cache_creation_input_tokens: 40 },
pricing
);
const expected = (0 * 3 + 80 * 0.3 + 40 * 3.75) / 1_000_000;
expect(cost).toBeCloseTo(expected, 12);
});
it("matches plain input pricing when no cache present", () => {
const cost = calculateCostFromTokens({ prompt_tokens: 100, completion_tokens: 50 }, pricing);
expect(cost).toBeCloseTo((100 * 3 + 50 * 15) / 1_000_000, 12);
});
});
describe("Anthropic streaming usage (message_start carries cache, message_delta output-only)", () => {
it("extractUsage reads input + cache from message_start", () => {
const u = extractUsage({
type: "message_start",
message: { usage: { input_tokens: 100, output_tokens: 1, cache_read_input_tokens: 200, cache_creation_input_tokens: 30 } },
});
expect(u.prompt_tokens).toBe(100);
expect(u.cache_read_input_tokens).toBe(200);
expect(u.cache_creation_input_tokens).toBe(30);
});
it("merges message_start cache with message_delta output without clobbering", () => {
// Real Anthropic SSE: cache only in message_start, real output only in message_delta.
const start = extractUsage({
type: "message_start",
message: { usage: { input_tokens: 100, output_tokens: 1, cache_read_input_tokens: 200, cache_creation_input_tokens: 30 } },
});
const delta = extractUsage({ type: "message_delta", usage: { output_tokens: 50 } });
const merged = mergeUsage(start, delta);
expect(merged.prompt_tokens).toBe(100);
expect(merged.cache_read_input_tokens).toBe(200);
expect(merged.cache_creation_input_tokens).toBe(30);
expect(merged.completion_tokens).toBe(50);
// And it canonicalizes to a cache-inclusive prompt for storage/cost.
const canon = canonicalizeUsage(merged);
expect(canon.prompt_tokens).toBe(330); // 100 + 200 + 30
expect(canon.cached_tokens).toBe(200);
expect(canon.cache_creation_input_tokens).toBe(30);
expect(canon.completion_tokens).toBe(50);
});
it("does not let a NaN field poison the running max-merge", () => {
// typeof NaN === "number", so a naive Math.max(prev, NaN) is NaN — one
// malformed chunk must not wipe out an already-accumulated good value.
const prev = { prompt_tokens: 100, cache_read_input_tokens: 200 };
const bad = { prompt_tokens: NaN, completion_tokens: 50 };
const merged = mergeUsage(prev, bad);
expect(merged.prompt_tokens).toBe(100);
expect(merged.cache_read_input_tokens).toBe(200);
expect(merged.completion_tokens).toBe(50);
});
});
describe("Kiro usage pass-through", () => {
it("passes through plain input/output when no cache fields are present", () => {
const out = toOpenAIUsage({ inputTokens: 100, outputTokens: 50 }, "kiro");
expect(out.prompt_tokens).toBe(100);
expect(out.completion_tokens).toBe(50);
expect(out.total_tokens).toBe(150);
expect(out.prompt_tokens_details).toBeUndefined();
});
it("forward-compat: surfaces cache fields if Kiro event shape grows them", () => {
// ponytail: Amazon Q upstream doesn't expose cache today, but if it starts
// sending cache_read_input_tokens / cache_creation_input_tokens / cachedTokens,
// cost tracking should pick them up automatically without another change.
const out = toOpenAIUsage(
{ inputTokens: 500, outputTokens: 100, cache_read_input_tokens: 200, cache_creation_input_tokens: 50 },
"kiro"
);
expect(out.prompt_tokens_details).toBeDefined();
expect(out.prompt_tokens_details.cached_tokens).toBe(200);
expect(out.prompt_tokens_details.cache_creation_tokens).toBe(50);
});
});

View File

@@ -2,6 +2,15 @@ import { describe, expect, it } from "vitest";
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
describe("getCapabilitiesForModel", () => {
const claudeSonnet5Expected = {
contextWindow: 1000000,
maxOutput: 128000,
thinkingFormat: "claude-adaptive",
reasoning: true,
vision: true,
search: true,
};
it("reports Kiro Claude Opus 4.8 as a 1M context model", () => {
expect(getCapabilitiesForModel("kiro", "claude-opus-4.8").contextWindow).toBe(1000000);
expect(getCapabilitiesForModel("kiro", "anthropic/claude-opus-4.8").contextWindow).toBe(1000000);
@@ -9,4 +18,12 @@ describe("getCapabilitiesForModel", () => {
expect(getCapabilitiesForModel("kiro", "claude-opus-4.8-thinking").contextWindow).toBe(1000000);
expect(getCapabilitiesForModel("kiro", "claude-opus-4-8-thinking").contextWindow).toBe(1000000);
});
it("reports Kiro Claude Sonnet 5 as a 1M adaptive-thinking model", () => {
expect(getCapabilitiesForModel("kiro", "claude-sonnet-5")).toMatchObject(claudeSonnet5Expected);
expect(getCapabilitiesForModel("kiro", "anthropic/claude-sonnet-5")).toMatchObject(claudeSonnet5Expected);
expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-thinking")).toMatchObject(claudeSonnet5Expected);
expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-agentic")).toMatchObject(claudeSonnet5Expected);
expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-thinking-agentic")).toMatchObject(claudeSonnet5Expected);
});
});

View File

@@ -0,0 +1,30 @@
// CodeBuddy CN mixes recurring refill packs with one-shot bonus packs.
// Bonus packs ("Bonus Pack N") must surface recurring:false so the dashboard
// shows "Expires in" instead of implying a monthly refill. The usage handler
// tags the flag and parseQuotaData must forward it.
import { describe, it, expect } from "vitest";
import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
describe("parseQuotaData codebuddy-cn recurring flag", () => {
it("forwards recurring:false for bonus packs and true for refill packs", () => {
const data = {
plan: "CodeBuddy CN",
quotas: {
Monthly: { used: 6.54, total: 500, resetAt: "2026-07-31T00:00:00Z", recurring: true },
"Bonus Pack 1": { used: 12, total: 100, resetAt: "2026-07-15T00:00:00Z", recurring: false },
},
};
const out = parseQuotaData("codebuddy-cn", data);
const byName = Object.fromEntries(out.map((q) => [q.name, q]));
expect(byName["Monthly"].recurring).toBe(true);
expect(byName["Bonus Pack 1"].recurring).toBe(false);
});
it("defaults recurring to true when the flag is absent (back-compat)", () => {
const data = { quotas: { Monthly: { used: 0, total: 100, resetAt: null } } };
const out = parseQuotaData("codebuddy-cn", data);
expect(out[0].recurring).toBe(true);
});
});

View File

@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mocks = vi.hoisted(() => ({
proxyAwareFetch: vi.fn(),
getProviderConnectionById: vi.fn(),
resolveConnectionProxyConfig: vi.fn(),
refreshAndUpdateCredentials: vi.fn(),
getCodexRateLimitResetCredits: vi.fn(),
consumeCodexRateLimitResetCredit: vi.fn(),
}));
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
proxyAwareFetch: mocks.proxyAwareFetch,
}));
vi.mock("open-sse/index.js", () => ({}));
vi.mock("@/lib/localDb", () => ({
getProviderConnectionById: mocks.getProviderConnectionById,
}));
vi.mock("@/lib/network/connectionProxy", () => ({
resolveConnectionProxyConfig: mocks.resolveConnectionProxyConfig,
}));
vi.mock("@/app/api/usage/[connectionId]/route.js", () => ({
refreshAndUpdateCredentials: mocks.refreshAndUpdateCredentials,
}));
vi.mock("open-sse/services/usage.js", () => ({
getCodexRateLimitResetCredits: mocks.getCodexRateLimitResetCredits,
consumeCodexRateLimitResetCredit: mocks.consumeCodexRateLimitResetCredit,
}));
describe("Codex reset credits", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.resolveConnectionProxyConfig.mockResolvedValue({});
});
it("returns normalized reset credit expiry details", async () => {
mocks.proxyAwareFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
available_count: 2,
credits: [
{
status: "available",
granted_at: "2026-06-18T00:25:18Z",
expires_at: "2026-07-18T00:25:18Z",
},
{
status: "redeemed",
granted_at: "bad-date",
expires_at: null,
},
],
}),
});
const { getCodexRateLimitResetCredits } = await import("../../open-sse/services/usage/codex.js");
const result = await getCodexRateLimitResetCredits("token", { strictProxy: false }, { workspaceId: "acct_123" });
expect(mocks.proxyAwareFetch).toHaveBeenCalledWith(
expect.stringContaining("/rate-limit-reset-credits"),
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Authorization: "Bearer token",
"ChatGPT-Account-ID": "acct_123",
}),
}),
{ strictProxy: false },
);
expect(result).toEqual({
availableCount: 2,
credits: [
{
status: "available",
grantedAt: "2026-06-18T00:25:18.000Z",
expiresAt: "2026-07-18T00:25:18.000Z",
},
{
status: "redeemed",
grantedAt: null,
expiresAt: null,
},
],
});
});
it("GET refreshes OAuth credentials before returning reset credit details", async () => {
const connection = {
id: "conn_1",
provider: "codex",
authType: "oauth",
accessToken: "old-token",
refreshToken: "refresh-token",
providerSpecificData: { workspaceId: "acct_123" },
};
const refreshedConnection = { ...connection, accessToken: "new-token" };
const resetCredits = {
availableCount: 1,
credits: [{ status: "available", grantedAt: "2026-06-18T00:25:18.000Z", expiresAt: "2026-07-18T00:25:18.000Z" }],
};
mocks.getProviderConnectionById.mockResolvedValue(connection);
mocks.resolveConnectionProxyConfig.mockResolvedValue({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local" });
mocks.refreshAndUpdateCredentials.mockResolvedValue({ connection: refreshedConnection });
mocks.getCodexRateLimitResetCredits.mockResolvedValue(resetCredits);
const { GET } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js");
const response = await GET(new Request("http://localhost/api/usage/conn_1/codex-reset-credits"), {
params: Promise.resolve({ connectionId: "conn_1" }),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual(resetCredits);
expect(mocks.refreshAndUpdateCredentials).toHaveBeenCalledWith(
connection,
false,
expect.objectContaining({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local", strictProxy: false }),
);
expect(mocks.getCodexRateLimitResetCredits).toHaveBeenCalledWith(
"new-token",
expect.objectContaining({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local", strictProxy: false }),
{ workspaceId: "acct_123" },
);
});
it("GET force-refreshes OAuth credentials when reset credit fetch reports expired auth", async () => {
const connection = {
id: "conn_1",
provider: "codex",
authType: "oauth",
accessToken: "old-token",
refreshToken: "refresh-token",
providerSpecificData: {},
};
const refreshedConnection = { ...connection, accessToken: "new-token" };
const forcedConnection = { ...connection, accessToken: "forced-token" };
const resetCredits = { availableCount: 0, credits: [] };
mocks.getProviderConnectionById.mockResolvedValue(connection);
mocks.refreshAndUpdateCredentials
.mockResolvedValueOnce({ connection: refreshedConnection })
.mockResolvedValueOnce({ connection: forcedConnection });
mocks.getCodexRateLimitResetCredits
.mockRejectedValueOnce(new Error("Unauthorized 401"))
.mockResolvedValueOnce(resetCredits);
const { GET } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js");
const response = await GET(new Request("http://localhost/api/usage/conn_1/codex-reset-credits"), {
params: Promise.resolve({ connectionId: "conn_1" }),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual(resetCredits);
expect(mocks.refreshAndUpdateCredentials).toHaveBeenNthCalledWith(1, connection, false, expect.any(Object));
expect(mocks.refreshAndUpdateCredentials).toHaveBeenNthCalledWith(2, refreshedConnection, true, expect.any(Object));
expect(mocks.getCodexRateLimitResetCredits).toHaveBeenNthCalledWith(2, "forced-token", expect.any(Object), {});
});
it("POST returns 409 when there are no reset credits to consume", async () => {
mocks.getProviderConnectionById.mockResolvedValue({
id: "conn_1",
provider: "codex",
authType: "access_token",
accessToken: "token",
providerSpecificData: {},
});
mocks.consumeCodexRateLimitResetCredit.mockResolvedValue({
ok: false,
noCredit: true,
status: 200,
code: "no_credit",
windowsReset: 0,
});
const { POST } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js");
const response = await POST(new Request("http://localhost/api/usage/conn_1/codex-reset-credits", { method: "POST" }), {
params: Promise.resolve({ connectionId: "conn_1" }),
});
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({
code: "no_credit",
reset: false,
windows_reset: 0,
message: "No Codex reset credits available.",
});
expect(mocks.consumeCodexRateLimitResetCredit).toHaveBeenCalledWith(
"token",
expect.any(String),
expect.objectContaining({ strictProxy: false }),
);
});
});

View File

@@ -38,14 +38,14 @@ async function setupTestContext(nodeData) {
};
}
function makeRequest(provider) {
function makeRequest(provider, name = "Test Connection") {
return new Request("https://9router.local/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider,
apiKey: "test-key",
name: "Test Connection",
name,
defaultModel: "test-model",
}),
});
@@ -145,26 +145,25 @@ describe("compatible provider connections API", () => {
});
});
it("returns 400 for a duplicate connection on the same compatible node", async () => {
it("allows multiple connections on the same compatible node", async () => {
const ctx = await setupTestContext({
id: "openai-compatible-duplicate-test",
id: "openai-compatible-multiple-test",
type: "openai-compatible",
name: "Duplicate Guard Node",
prefix: "dup",
name: "Multiple Connections Node",
prefix: "mul",
apiType: "chat",
baseUrl: "https://duplicate-guard.test/v1",
baseUrl: "https://multiple-connections.test/v1",
});
cleanup = ctx.cleanup;
const firstResponse = await ctx.POST(makeRequest(ctx.node.id));
const secondResponse = await ctx.POST(makeRequest(ctx.node.id));
const secondBody = await secondResponse.json();
const firstResponse = await ctx.POST(makeRequest(ctx.node.id, "Key A"));
const secondResponse = await ctx.POST(makeRequest(ctx.node.id, "Key B"));
const storedConnections = await ctx.getProviderConnections({ provider: ctx.node.id });
expect(firstResponse.status).toBe(201);
expect(secondResponse.status).toBe(400);
expect(secondBody.error).toContain("Only one connection is allowed");
expect(storedConnections).toHaveLength(1);
expect(secondResponse.status).toBe(201);
expect(storedConnections).toHaveLength(2);
expectCompatibleConnection(storedConnections[0], ctx.node, { apiType: "chat" });
expectCompatibleConnection(storedConnections[1], ctx.node, { apiType: "chat" });
});
});

View File

@@ -47,4 +47,61 @@ describe("compressWithHeadroom openai-responses format (#1998)", () => {
expect(Array.isArray(body.input[0].content)).toBe(true);
expect(typeof body.input[0].content).not.toBe("string");
});
it("skips Responses tool/reasoning history instead of collapsing it into a message (#2132)", async () => {
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({
messages: [{ role: "user", content: "compressed tool history" }],
tokens_saved: 10,
}),
}));
const input = [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "investigate bug" }],
},
{
type: "function_call",
call_id: "call_apply_patch_123",
name: "apply_patch",
arguments: "*** Begin Patch\n*** End Patch",
},
{
type: "function_call_output",
call_id: "call_apply_patch_123",
output: "ok",
},
{
type: "reasoning",
summary: [{ type: "summary_text", text: "Need a plan" }],
},
];
const body = {
input: structuredClone(input),
tools: [
{
type: "custom",
name: "apply_patch",
format: { type: "grammar", syntax: "lark", definition: "start: /.+/" },
},
],
};
const diagnostics = {};
const data = await compressWithHeadroom(body, {
enabled: true,
url: "http://headroom.test",
model: "gpt-5",
format: "openai-responses",
diagnostics,
});
expect(data).toBeNull();
expect(global.fetch).not.toHaveBeenCalled();
expect(body.input).toEqual(input);
expect(diagnostics.reason).toBe("skipped: openai-responses tool/reasoning input is not safe to compress");
});
});

View File

@@ -0,0 +1,128 @@
/**
* Kimchi executor: strip reasoning_content echoed by clients.
*
* Background: when 9Router streams a thinking model (deepseek-r1,
* minimax-m3) to a client, the response carries `reasoning_content`.
* Most OpenAI-compatible SDKs echo the whole history on the next turn,
* so Kimchi's upstream counts the scratch block as input tokens.
* Multi-turn conversations balloon to 100k+ input tokens and the model
* starts returning empty content.
*
* `stripReasoningContent` is intentionally conservative: it only strips
* `reasoning_content` that is clearly a real thinking block. The 1-char
* placeholder that `injectReasoningContent` (in `DefaultExecutor`) may
* insert for upstream validation is preserved — stripping it would
* re-trigger upstream complaints about missing reasoning on the next
* turn.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import KimchiExecutor, { stripReasoningContent } from "../../open-sse/executors/kimchi.js";
import DefaultExecutor from "../../open-sse/executors/default.js";
describe("kimchi stripReasoningContent", () => {
it("removes long reasoning_content from assistant messages but keeps content", () => {
const body = {
messages: [
{ role: "user", content: "solve x+5=12" },
{
role: "assistant",
content: "x = 7",
reasoning_content: "subtract 5 from both sides ... (long reasoning block)",
},
{ role: "user", content: "now try x+10=20" },
],
};
stripReasoningContent(body);
assert.equal(body.messages[1].reasoning_content, undefined);
assert.equal(body.messages[1].content, "x = 7");
});
it("preserves the 1-char placeholder that injectReasoningContent sets", () => {
// `injectReasoningContent` may insert " " (single space) on assistant
// messages so the upstream's validation doesn't complain about missing
// reasoning. Stripping that placeholder would defeat its purpose.
const body = {
messages: [
{ role: "user", content: "hi" },
{ role: "assistant", content: "hello", reasoning_content: " " },
],
};
stripReasoningContent(body);
assert.equal(body.messages[1].reasoning_content, " ");
assert.equal(body.messages[1].content, "hello");
});
it("preserves short custom reasoning under the threshold", () => {
// Anything ≤8 chars is treated as a placeholder-shaped value, kept
// verbatim. Real thinking content from a thinking model is always
// well above this threshold.
const body = {
messages: [
{ role: "assistant", content: "ok", reasoning_content: "short" },
],
};
stripReasoningContent(body);
assert.equal(body.messages[0].reasoning_content, "short");
});
it("leaves non-assistant messages untouched", () => {
const body = {
messages: [
{ role: "user", content: "hi" },
{ role: "system", content: "be helpful" },
],
};
stripReasoningContent(body);
assert.equal(body.messages[0].content, "hi");
assert.equal(body.messages[1].content, "be helpful");
});
it("returns early on missing/empty messages array", () => {
assert.doesNotThrow(() => stripReasoningContent({}));
assert.doesNotThrow(() => stripReasoningContent({ messages: null }));
assert.doesNotThrow(() => stripReasoningContent({ messages: [] }));
});
it("ignores assistant messages that have no reasoning_content", () => {
const body = {
messages: [
{ role: "user", content: "hi" },
{ role: "assistant", content: "hello" },
],
};
stripReasoningContent(body);
assert.deepEqual(body.messages[1], { role: "assistant", content: "hello" });
});
it("handles multi-turn: strips old turns, keeps recent one", () => {
const LONG = "x".repeat(1000);
const body = {
messages: [
{ role: "user", content: "q1" },
{ role: "assistant", content: "a1", reasoning_content: LONG },
{ role: "user", content: "q2" },
{ role: "assistant", content: "a2", reasoning_content: " " }, // placeholder
],
};
stripReasoningContent(body);
assert.equal(body.messages[1].reasoning_content, undefined);
assert.equal(body.messages[3].reasoning_content, " ");
});
});
describe("kimchi executor wiring", () => {
it("KimchiExecutor extends DefaultExecutor via prototype chain", () => {
const inst = new KimchiExecutor();
assert.ok(
inst instanceof DefaultExecutor,
"KimchiExecutor must extend DefaultExecutor so transformRequest runs through super",
);
});
it("default export is KimchiExecutor class", () => {
assert.equal(typeof KimchiExecutor, "function");
assert.equal(KimchiExecutor.name, "KimchiExecutor");
});
});

234
tests/unit/kimchi.test.js Normal file
View File

@@ -0,0 +1,234 @@
import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
// Load the registry entry once for the suite so a load failure is reported
// next to the failing test instead of cascading as "undefined" in every
// later assertion.
let kimchiEntry;
describe("kimchi registry entry", () => {
before(async () => {
kimchiEntry = (await import("../../open-sse/providers/registry/kimchi.js")).default;
});
it("is an oauth provider auto-listed via byCategory", () => {
assert.equal(kimchiEntry.id, "kimchi");
assert.equal(kimchiEntry.category, "oauth");
});
it("points at the OpenAI-compatible gateway with an authenticated UA", () => {
assert.equal(
kimchiEntry.transport.baseUrl,
"https://llm.kimchi.dev/openai/v1/chat/completions",
);
// UA must be a non-empty string the gateway can identify; the value
// itself is owned by the Kimchi CLI release and may change upstream.
const ua = kimchiEntry.transport.headers["User-Agent"];
assert.ok(typeof ua === "string" && ua.length > 0, `User-Agent missing: ${ua}`);
});
it("uses Bearer auth", () => {
assert.deepEqual(kimchiEntry.transport.auth, {
combined: true,
header: "Authorization",
scheme: "bearer",
});
});
it("exposes the upstream static models", () => {
const ids = kimchiEntry.models.map((m) => m.id);
assert.ok(ids.includes("kimi-k2.7"));
assert.ok(ids.includes("minimax-m3"));
assert.ok(ids.includes("nemotron-3-ultra-fp4"));
assert.ok(ids.length >= 5, `expected >= 5 static models, got ${ids.length}`);
});
it("passes through models not in the static list", () => {
assert.equal(kimchiEntry.passthroughModels, true);
});
});
// ── Pure-function clones of the service logic (tested in isolation so
// node --test works without resolving the Next.js Webpack "open-sse"
// alias that src/lib/oauth/services/kimchi.js's dependency imports). ──
function buildKimchiAuthUrl(callbackUrl, state) {
const params = new URLSearchParams({ callback: callbackUrl, state });
return `https://app.kimchi.dev/cli-auth?${params.toString()}`;
}
async function _handleCallback(params, expectedState) {
if (params.error) {
throw new Error(params.error_description || params.error);
}
const candidate = params.state;
if (!candidate || candidate !== expectedState) {
throw new Error(
"This request isn't valid. Please restart the Kimchi login flow.",
);
}
const token = params.token;
if (!token) {
throw new Error("No token was returned by the Kimchi authentication server");
}
return { token };
}
describe("kimchi oauth", () => {
it("builds the cli-auth URL with encoded callback + state", () => {
const url = buildKimchiAuthUrl("http://127.0.0.1:4321/callback", "abc123");
const parsed = new URL(url);
assert.equal(parsed.origin, "https://app.kimchi.dev");
assert.equal(parsed.pathname, "/cli-auth");
assert.equal(parsed.searchParams.get("callback"), "http://127.0.0.1:4321/callback");
assert.equal(parsed.searchParams.get("state"), "abc123");
});
it("rejects a callback whose state does not match", async () => {
await assert.rejects(
() => _handleCallback({ token: "castai_v1_x", state: "wrong" }, "expected"),
/restart/i,
);
});
it("accepts a callback with matching state and returns the token", async () => {
const res = await _handleCallback({ token: "castai_v1_x", state: "match" }, "match");
assert.equal(res.token, "castai_v1_x");
});
});
// ── kimchiModels service (pure mapping logic, tested in isolation) ──
// Clone of the metadata→model mapper so node --test resolves without the
// open-sse/Webpack alias chain the real module imports.
function mapKimchiMetadata(raw) {
if (!Array.isArray(raw)) return [];
return raw.map((m) => ({
id: m.slug,
name: m.display_name || m.slug,
contextLength: m.limits?.context_window || null,
maxOutputTokens: m.limits?.max_output_tokens || null,
isReasoning: m.reasoning === true,
}));
}
describe("kimchiModels", () => {
it("maps Kimchi metadata entries to 9router model shape", () => {
const raw = [{
slug: "glm-5.2-fp8",
display_name: "GLM 5.2",
reasoning: true,
limits: { context_window: 1048576, max_output_tokens: 1048576 },
}];
const models = mapKimchiMetadata(raw);
assert.equal(models.length, 1);
assert.deepEqual(models[0], {
id: "glm-5.2-fp8",
name: "GLM 5.2",
contextLength: 1048576,
maxOutputTokens: 1048576,
isReasoning: true,
});
});
it("falls back to slug as name when display_name is empty", () => {
const models = mapKimchiMetadata([{ slug: "kimi-k2.7", display_name: "", reasoning: false, limits: {} }]);
assert.equal(models[0].name, "kimi-k2.7");
assert.equal(models[0].contextLength, null);
assert.equal(models[0].isReasoning, false);
});
it("returns empty array for non-array input", () => {
assert.deepEqual(mapKimchiMetadata(null), []);
assert.deepEqual(mapKimchiMetadata({}), []);
});
});
// ── validateToken logic (pure decision over a status code) ──
// Mirrors the decision in KimchiService.validateToken without importing the
// service (which pulls the open-sse Webpack alias chain).
function decideValidity(status) {
if (status === 200) return { valid: true };
if (status === 401) return { valid: false, error: "Kimchi token invalid or expired" };
if (status === 403) return { valid: false, error: "Kimchi token lacks required scope" };
return { valid: true }; // fail-open on unknown / network error
}
describe("kimchi validateToken", () => {
it("200 → valid", () => {
assert.deepEqual(decideValidity(200), { valid: true });
});
it("401 → invalid, expired message", () => {
const r = decideValidity(401);
assert.equal(r.valid, false);
assert.match(r.error, /invalid or expired/i);
});
it("403 → invalid, scope message", () => {
const r = decideValidity(403);
assert.equal(r.valid, false);
assert.match(r.error, /scope/i);
});
it("unknown / network error → fail-open valid", () => {
assert.equal(decideValidity(500).valid, true);
assert.equal(decideValidity(0).valid, true);
});
});
// ── OAuth dedup logic (pure clone of connectionsRepo matcher) ──
// Mimics the find() predicate in createProviderConnection for OAuth
// connections, so we can test the IdP-collision fix in isolation.
function findExistingOAuth(all, incoming) {
const incomingEmail = incoming.email;
const incomingUsername = incoming.providerSpecificData?.username;
const incomingWs = incoming.providerSpecificData?.chatgptAccountId;
return all.find((c) => {
if (c.authType !== "oauth" || c.email !== incomingEmail) return false;
const existingWs = c.providerSpecificData?.chatgptAccountId;
if (incomingWs && existingWs) return incomingWs === existingWs;
if (incomingWs && !existingWs) return false;
if (!incomingWs && existingWs) return false;
const existingUsername = c.providerSpecificData?.username;
if (incomingUsername && existingUsername) {
return incomingUsername === existingUsername;
}
if (incomingUsername || existingUsername) return false;
return true;
});
}
describe("kimchi OAuth dedup", () => {
const google = { authType: "oauth", email: "x@y.com", providerSpecificData: { username: "google-oauth2|123" } };
const hf = { authType: "oauth", email: "x@y.com", providerSpecificData: { username: "huggingface|456" } };
const legacy = { authType: "oauth", email: "x@y.com", providerSpecificData: {} };
const other = { authType: "oauth", email: "z@y.com", providerSpecificData: { username: "google-oauth2|789" } };
it("different email never matches", () => {
assert.equal(findExistingOAuth([other], google), undefined);
});
it("same email + same username = dedup (re-login same IdP)", () => {
const found = findExistingOAuth([google], { ...google });
assert.equal(found, google);
});
it("same email + different username = NO match (cross-IdP, the bug)", () => {
assert.equal(findExistingOAuth([google], hf), undefined);
});
it("legacy row without username matches incoming without username (backward compat)", () => {
assert.equal(findExistingOAuth([legacy], { ...legacy }), legacy);
});
it("incoming without username does not match legacy row with username", () => {
assert.equal(findExistingOAuth([google], { ...legacy }), undefined);
});
it("workspaces still dedupe on workspace ID when both sides have one", () => {
const ws1 = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws1" } };
const ws1dup = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws1" } };
const ws2 = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws2" } };
assert.equal(findExistingOAuth([ws1], ws1dup), ws1);
assert.equal(findExistingOAuth([ws1], ws2), undefined);
});
});

View File

@@ -1,12 +1,10 @@
import { describe, expect, it } from "vitest";
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js";
import { MITM_TOOLS } from "../../src/shared/constants/cliTools.js";
// Guards the fix in commit 356607c: Kiro's agent/"vibe" mode sends modelId
// "auto" for the main turn and "simple-task" for background sub-tasks. Both
// need a mappable defaultModels slot — otherwise getMappedModel (src/mitm/server.js)
// returns null and the /generateAssistantResponse call is passed through to AWS
// instead of being routed to the user's chosen provider (surfacing as Kiro's
// "monthly usage limit" once the AWS quota is gone).
// Guards Kiro model ids that still need mappable defaultModels slots. Without
// a slot, getMappedModel (src/mitm/server.js) returns null and the request is
// passed through to AWS instead of being routed to the user's chosen provider.
describe("Kiro MITM model slots", () => {
const kiro = MITM_TOOLS.kiro;
@@ -16,10 +14,10 @@ describe("Kiro MITM model slots", () => {
expect(Array.isArray(kiro.defaultModels)).toBe(true);
});
it("offers a mappable slot for the agent default model id 'auto'", () => {
const auto = kiro.defaultModels.find((m) => m.id === "auto");
expect(auto).toBeTruthy();
expect(auto.alias).toBe("auto");
it("offers a mappable slot for Claude Sonnet 5", () => {
const sonnet5 = kiro.defaultModels.find((m) => m.id === "claude-sonnet-5");
expect(sonnet5).toBeTruthy();
expect(sonnet5.alias).toBe("claude-sonnet-5");
});
it("offers a mappable slot for the background sub-task model id 'simple-task'", () => {
@@ -28,3 +26,15 @@ describe("Kiro MITM model slots", () => {
expect(simpleTask.alias).toBe("simple-task");
});
});
describe("Kiro static provider models", () => {
it("includes Claude Sonnet 5 and its synthetic Kiro variants", () => {
const ids = (PROVIDER_MODELS.kr || []).map((model) => model.id);
expect(ids).toEqual(expect.arrayContaining([
"claude-sonnet-5",
"claude-sonnet-5-thinking",
"claude-sonnet-5-agentic",
"claude-sonnet-5-thinking-agentic",
]));
});
});

View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import { KiroExecutor } from "../../open-sse/executors/kiro.js";
function createMockFrame(eventType, payloadObj) {
const payloadStr = JSON.stringify(payloadObj);
const payloadBytes = new TextEncoder().encode(payloadStr);
const headerName = ":event-type";
const headerNameBytes = new TextEncoder().encode(headerName);
const headerValueBytes = new TextEncoder().encode(eventType);
// nameLen(1) + name + type(1) + valueLen(2) + value
const headerLength = 1 + headerNameBytes.length + 1 + 2 + headerValueBytes.length;
const totalLength = 12 + headerLength + payloadBytes.length + 4;
const buffer = new Uint8Array(totalLength);
const view = new DataView(buffer.buffer);
view.setUint32(0, totalLength, false);
view.setUint32(4, headerLength, false);
let offset = 12;
buffer[offset++] = headerNameBytes.length;
buffer.set(headerNameBytes, offset);
offset += headerNameBytes.length;
buffer[offset++] = 7; // String type
view.setUint16(offset, headerValueBytes.length, false);
offset += 2;
buffer.set(headerValueBytes, offset);
offset += headerValueBytes.length;
buffer.set(payloadBytes, offset);
return buffer;
}
async function readAllSSE(stream) {
const reader = stream.getReader();
const decoder = new TextDecoder();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += decoder.decode(value, { stream: true });
}
return result;
}
describe("KiroExecutor thinking tag stripping", () => {
it("strips <thinking> tags from assistantResponseEvent", async () => {
const executor = new KiroExecutor();
// Create frames
const f1 = createMockFrame("assistantResponseEvent", { content: "Here is my answer. <thinking>Let me think..." });
const f2 = createMockFrame("assistantResponseEvent", { content: "still thinking...</thinking> Yes, 42." });
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue(f1);
controller.enqueue(f2);
controller.close();
}
});
const mockResponse = { body: readableStream };
const transformedResponse = executor.transformEventStreamToSSE(mockResponse, "claude-test");
const output = await readAllSSE(transformedResponse.body);
// Check that we got chat.completion.chunk outputs
expect(output).toContain("chat.completion.chunk");
// Ensure the thinking parts are gone
expect(output).not.toContain("<thinking>");
expect(output).not.toContain("Let me think...");
expect(output).not.toContain("still thinking...");
expect(output).not.toContain("</thinking>");
// Check that the normal content is preserved
// Parse the data chunks
const dataLines = output.split("\n").filter(line => line.startsWith("data: "));
const contents = dataLines.map(line => {
if (line.includes("[DONE]")) return "";
try {
return JSON.parse(line.slice(6)).choices[0].delta.content || "";
} catch {
return "";
}
});
const fullText = contents.join("");
expect(fullText).toBe("Here is my answer. Yes, 42.");
});
it("handles empty content after stripping when hasReasoningContent is true", async () => {
const executor = new KiroExecutor();
const f0 = createMockFrame("reasoningContentEvent", { text: "I am reasoning" });
const f1 = createMockFrame("assistantResponseEvent", { content: "<thinking>purely thinking...</thinking>" });
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue(f0);
controller.enqueue(f1);
controller.close();
}
});
const mockResponse = { body: readableStream };
const transformedResponse = executor.transformEventStreamToSSE(mockResponse, "claude-test");
const output = await readAllSSE(transformedResponse.body);
const dataLines = output.split("\n").filter(line => line.startsWith("data: ") && !line.includes("[DONE]"));
const objects = dataLines.map(line => JSON.parse(line.slice(6)));
// First chunk should have reasoning_content
expect(objects[0].choices[0].delta.reasoning_content).toBe("I am reasoning");
// We shouldn't get an empty content chunk from f1 since it was entirely stripped and reasoning was present
const contentChunks = objects.filter(obj => obj.choices[0].delta.content !== undefined);
expect(contentChunks.length).toBe(0);
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
import fs from "fs";
import os from "os";
import path from "path";
const require = createRequire(import.meta.url);
function loadRootCAWithDataDir(dataDir) {
const rootCAPath = require.resolve("../../src/mitm/cert/rootCA.js");
const pathsPath = require.resolve("../../src/mitm/paths.js");
delete require.cache[rootCAPath];
delete require.cache[pathsPath];
const oldDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
try {
return require("../../src/mitm/cert/rootCA.js");
} finally {
if (oldDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = oldDataDir;
}
}
describe("MITM Root CA generation", () => {
it("creates Root CA files synchronously for direct server startup", () => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-mitm-ca-"));
const { generateRootCA } = loadRootCAWithDataDir(dataDir);
generateRootCA();
expect(fs.existsSync(path.join(dataDir, "mitm", "rootCA.key"))).toBe(true);
expect(fs.existsSync(path.join(dataDir, "mitm", "rootCA.crt"))).toBe(true);
});
});

View File

@@ -67,6 +67,19 @@ describe("OpenAI Responses streaming termination", () => {
expect(output).toContain("data: [DONE]");
});
it("does not add response.failed when a Responses stream sends response.done", async () => {
const output = await runTransform([
`event: response.done`,
`data: ${JSON.stringify({ type: "response.done", response: { id: "resp_test" } })}`,
"",
].join("\n"));
expect(output).toContain("event: response.done");
expect(output).not.toContain("event: response.failed");
expect(output).not.toContain("data: null");
expect(output).toContain("data: [DONE]");
});
it("emits response.failed before DONE when a Responses stream sends DONE without a terminal event", async () => {
const output = await runTransform([
`event: response.created`,

View File

@@ -0,0 +1,351 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("open-sse/index.js", () => ({}), { virtual: true });
vi.mock("@/lib/localDb", () => ({
getSettings: vi.fn(),
getProviderConnections: vi.fn(),
updateProviderConnection: vi.fn(),
}));
vi.mock("@/lib/network/connectionProxy", () => ({
resolveConnectionProxyConfig: vi.fn(),
}));
vi.mock("@/app/api/usage/[connectionId]/route.js", () => ({
refreshAndUpdateCredentials: vi.fn(),
}));
vi.mock("@/shared/constants/config", () => ({
QUOTA_AUTOPING_CONFIG: {
tickIntervalMs: 60000,
pingLeadMs: 5000,
refreshAheadMs: 300000,
failureCooldownMs: 900000,
providers: {
claude: {
settingsKey: "claudeAutoPing",
quotaKey: "session (5h)",
pingModel: "claude-haiku-4-5-20251001",
pingText: "hi",
pingMaxTokens: 1,
},
codex: {
settingsKey: "codexAutoPing",
quotaKey: "session",
pingWhenResetAtSlides: true,
resetAtDriftMs: 30000,
minPingIntervalMs: 600000,
skipWhenBlockingQuotaExhausted: true,
pingModel: "gpt-5.5",
pingText: "hi",
pingInstructions: "Reply with OK.",
pingReasoningEffort: "none",
},
},
},
}));
vi.mock("open-sse/providers/shared.js", () => ({
CLAUDE_CLI_SPOOF_HEADERS: { "anthropic-version": "2023-06-01" },
}));
vi.mock("open-sse/services/usage/shared.js", () => ({
U: () => ({ baseUrl: "https://chatgpt.com/backend-api/codex/responses" }),
}));
vi.mock("open-sse/utils/proxyFetch.js", () => ({
proxyAwareFetch: vi.fn(),
}));
vi.mock("open-sse/services/usage/claude.js", () => ({
getClaudeUsage: vi.fn(),
}));
vi.mock("open-sse/services/usage/codex.js", () => ({
getCodexUsage: vi.fn(),
}));
vi.mock("open-sse/executors/index.js", () => ({
getExecutor: vi.fn(),
}));
describe("quota auto-ping", () => {
let runQuotaAutoPingTick;
let deps;
let state;
let getCodexUsage;
let getClaudeUsage;
let getExecutor;
let codexResponseText;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
vi.useRealTimers();
({ getCodexUsage } = await import("open-sse/services/usage/codex.js"));
({ getClaudeUsage } = await import("open-sse/services/usage/claude.js"));
({ getExecutor } = await import("open-sse/executors/index.js"));
({ runQuotaAutoPingTick } = await import("../../src/shared/services/quotaAutoPing.js"));
deps = {
getSettings: vi.fn(),
getProviderConnections: vi.fn(),
updateProviderConnection: vi.fn(),
resolveConnectionProxyConfig: vi.fn().mockResolvedValue({}),
refreshAndUpdateCredentials: vi.fn(async (connection) => ({ connection, refreshed: false })),
proxyAwareFetch: vi.fn().mockResolvedValue({ ok: true }),
getExecutor: vi.fn(() => ({
execute: vi.fn().mockResolvedValue({ response: { ok: true, text: codexResponseText } }),
})),
};
codexResponseText = vi.fn().mockResolvedValue("");
getExecutor.mockReturnValue({
execute: vi.fn().mockResolvedValue({ response: { ok: true, text: codexResponseText } }),
});
state = { running: false, resetCache: {}, failureCache: {} };
vi.setSystemTime(new Date("2026-01-01T12:00:00.000Z"));
});
it("does not ping Codex when setting is absent", async () => {
deps.getSettings.mockResolvedValue({});
await runQuotaAutoPingTick(deps, state);
expect(deps.getProviderConnections).not.toHaveBeenCalled();
expect(deps.proxyAwareFetch).not.toHaveBeenCalled();
});
it("does not ping Codex on the first resetAt observation", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
));
getCodexUsage.mockResolvedValue({
quotas: { session: { used: 1, resetAt: "2026-01-01T13:00:00.000Z" } },
});
await runQuotaAutoPingTick(deps, state);
expect(deps.getExecutor).not.toHaveBeenCalled();
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
expect(state.resetCache["codex:codex-1"]).toBe("2026-01-01T13:00:00.000Z");
});
it("sends Codex ping when session resetAt slides", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
));
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
getCodexUsage.mockResolvedValue({
quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } },
});
await runQuotaAutoPingTick(deps, state);
const executor = deps.getExecutor.mock.results[0].value;
expect(executor.execute).toHaveBeenCalledTimes(1);
expect(deps.updateProviderConnection).toHaveBeenCalledWith("codex-1", expect.objectContaining({
lastPingedResetAt: "2026-01-01T17:01:00.000Z",
lastPingedResetKey: "2026-01-01T17:01:00.000Z",
}));
});
it("does not ping Codex when resetAt is stable", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
));
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
getCodexUsage.mockResolvedValue({
quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:00:00.000Z" } },
});
await runQuotaAutoPingTick(deps, state);
expect(deps.getExecutor).not.toHaveBeenCalled();
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
});
it("does not repeat Codex ping inside the minimum ping interval", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "codex"
? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token", lastPingAt: "2026-01-01T11:55:00.000Z" }]
: []
));
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
getCodexUsage.mockResolvedValue({
quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } },
});
await runQuotaAutoPingTick(deps, state);
expect(deps.getExecutor).not.toHaveBeenCalled();
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
});
it("does not ping Codex just because reported usage is zero", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
));
getCodexUsage.mockResolvedValue({
quotas: { session: { used: 0, resetAt: "2026-01-01T17:00:00.000Z" } },
});
await runQuotaAutoPingTick(deps, state);
expect(deps.getExecutor).not.toHaveBeenCalled();
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
expect(state.resetCache["codex:codex-1"]).toBe("2026-01-01T17:00:00.000Z");
});
it("does not ping Codex when weekly quota is exhausted", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
));
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
getCodexUsage.mockResolvedValue({
quotas: {
session: { used: 0, total: 100, remaining: 100, resetAt: "2026-01-01T17:01:00.000Z" },
weekly: { used: 100, total: 100, remaining: 0, resetAt: "2026-01-03T12:00:00.000Z" },
},
});
await runQuotaAutoPingTick(deps, state);
expect(deps.getExecutor).not.toHaveBeenCalled();
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
});
it("does not ping Codex when monthly quota is exhausted", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
));
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
getCodexUsage.mockResolvedValue({
quotas: {
session: { used: 0, total: 100, remaining: 100, resetAt: "2026-01-01T17:01:00.000Z" },
monthly: { used: 100, total: 100, remaining: 0, resetAt: "2026-02-01T00:00:00.000Z" },
},
});
await runQuotaAutoPingTick(deps, state);
expect(deps.getExecutor).not.toHaveBeenCalled();
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
});
it("does not ping Codex when session quota is exhausted", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
));
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
getCodexUsage.mockResolvedValue({
quotas: { session: { used: 100, total: 100, remaining: 0, resetAt: "2026-01-01T17:01:00.000Z" } },
});
await runQuotaAutoPingTick(deps, state);
expect(deps.getExecutor).not.toHaveBeenCalled();
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
});
it("sends one tiny gpt-5.5 Codex request through the executor", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "codex"
? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token", providerSpecificData: { workspaceId: "ws-1" } }]
: []
));
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
getCodexUsage.mockResolvedValue({
quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } },
});
await runQuotaAutoPingTick(deps, state);
const executor = deps.getExecutor.mock.results[0].value;
expect(deps.getExecutor).toHaveBeenCalledWith("codex");
expect(executor.execute).toHaveBeenCalledWith(expect.objectContaining({
model: "gpt-5.5",
stream: true,
credentials: expect.objectContaining({
accessToken: "token",
connectionId: "codex-1",
providerSpecificData: { workspaceId: "ws-1" },
}),
body: {
model: "gpt-5.5",
input: [{
type: "message",
role: "user",
content: [{ type: "input_text", text: "hi" }],
}],
instructions: "Reply with OK.",
reasoning: { effort: "none", summary: "auto" },
store: false,
stream: true,
},
}));
expect(codexResponseText).toHaveBeenCalledTimes(1);
expect(deps.updateProviderConnection).toHaveBeenCalledWith("codex-1", expect.objectContaining({
lastPingedResetAt: "2026-01-01T17:01:00.000Z",
lastPingedResetKey: "2026-01-01T17:01:00.000Z",
}));
});
it("does not ping same Codex reset twice when seconds drift", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "codex"
? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token", lastPingedResetAt: "2026-01-01T11:59:44.000Z" }]
: []
));
state.resetCache["codex:codex-1"] = "2026-01-01T11:59:44.000Z";
getCodexUsage.mockResolvedValue({
quotas: { session: { used: 0, total: 100, remaining: 100, resetAt: "2026-01-01T11:59:47.000Z" } },
});
await runQuotaAutoPingTick(deps, state);
expect(deps.getExecutor).not.toHaveBeenCalled();
});
it("skips non-OAuth Codex connections", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "apikey", accessToken: "token" }] : []
));
await runQuotaAutoPingTick(deps, state);
expect(getCodexUsage).not.toHaveBeenCalled();
expect(deps.getExecutor).not.toHaveBeenCalled();
});
it("keeps Claude session quota key behavior", async () => {
deps.getSettings.mockResolvedValue({ claudeAutoPing: { connections: { "claude-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
provider === "claude" ? [{ id: "claude-1", provider: "claude", authType: "oauth", accessToken: "token" }] : []
));
getClaudeUsage.mockResolvedValue({
quotas: { "session (5h)": { resetAt: "2026-01-01T11:59:00.000Z" } },
});
await runQuotaAutoPingTick(deps, state);
expect(deps.proxyAwareFetch).toHaveBeenCalledTimes(1);
expect(JSON.parse(deps.proxyAwareFetch.mock.calls[0][1].body)).toMatchObject({
model: "claude-haiku-4-5-20251001",
max_tokens: 1,
messages: [{ role: "user", content: "hi" }],
});
});
});