end
This commit is contained in:
66
tests/unit/alicode-cache-control-2069.test.js
Normal file
66
tests/unit/alicode-cache-control-2069.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
84
tests/unit/cached-token-e2e.test.js
Normal file
84
tests/unit/cached-token-e2e.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
188
tests/unit/cached-token-usage.test.js
Normal file
188
tests/unit/cached-token-usage.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
30
tests/unit/codebuddy-cn-bonus-recurring.test.js
Normal file
30
tests/unit/codebuddy-cn-bonus-recurring.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
198
tests/unit/codex-reset-credits.test.js
Normal file
198
tests/unit/codex-reset-credits.test.js
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
128
tests/unit/kimchi-strip-reasoning.test.js
Normal file
128
tests/unit/kimchi-strip-reasoning.test.js
Normal 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
234
tests/unit/kimchi.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
124
tests/unit/kiro-thinking-strip.test.js
Normal file
124
tests/unit/kiro-thinking-strip.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
35
tests/unit/mitm-root-ca.test.js
Normal file
35
tests/unit/mitm-root-ca.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
|
||||
351
tests/unit/quota-auto-ping.test.js
Normal file
351
tests/unit/quota-auto-ping.test.js
Normal 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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user