merge: resolve conflicts with origin/master - keep both local and remote features
This commit is contained in:
50
tests/unit/antigravity-oauth-client.test.js
Normal file
50
tests/unit/antigravity-oauth-client.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Guards the deduped Antigravity OAuth client: same values across all 3 sources after refactor.
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const EXPECTED = {
|
||||
clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||||
clientSecret: "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",
|
||||
};
|
||||
const GOOGLE = {
|
||||
clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
|
||||
clientSecret: "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",
|
||||
};
|
||||
|
||||
describe("antigravity oauth client (deduped)", () => {
|
||||
it("shared source holds the canonical credentials", async () => {
|
||||
const { ANTIGRAVITY_OAUTH_CLIENT } = await import("../../open-sse/providers/shared.js");
|
||||
expect(ANTIGRAVITY_OAUTH_CLIENT).toEqual(EXPECTED);
|
||||
});
|
||||
|
||||
it("registry transport keeps clientId/clientSecret", async () => {
|
||||
const ag = (await import("../../open-sse/providers/registry/antigravity.js")).default;
|
||||
expect(ag.transport.clientId).toBe(EXPECTED.clientId);
|
||||
expect(ag.transport.clientSecret).toBe(EXPECTED.clientSecret);
|
||||
});
|
||||
|
||||
it("google client shared by gemini + gemini-cli", async () => {
|
||||
const { GOOGLE_OAUTH_CLIENT } = await import("../../open-sse/providers/shared.js");
|
||||
expect(GOOGLE_OAUTH_CLIENT).toEqual(GOOGLE);
|
||||
const gemini = (await import("../../open-sse/providers/registry/gemini.js")).default;
|
||||
const gc = (await import("../../open-sse/providers/registry/gemini-cli.js")).default;
|
||||
expect(gemini.transport.clientSecret).toBe(GOOGLE.clientSecret);
|
||||
expect(gc.transport.clientSecret).toBe(GOOGLE.clientSecret);
|
||||
});
|
||||
|
||||
// Guard: oauth.js must spread shared clients + derive from registry (PROVIDER_OAUTH).
|
||||
it("src oauth.js imports shared client + keeps full shape", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const { dirname, join } = await import("node:path");
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, "../../src/lib/oauth/constants/oauth.js"), "utf8");
|
||||
expect(src).toContain('import { ANTIGRAVITY_OAUTH_CLIENT, GOOGLE_OAUTH_CLIENT } from "open-sse/providers/shared.js"');
|
||||
expect(src).toContain("...ANTIGRAVITY_OAUTH_CLIENT");
|
||||
expect(src).toContain("...GOOGLE_OAUTH_CLIENT");
|
||||
// authorizeUrl now lives in registry; oauth.js derives via PROVIDER_OAUTH spread
|
||||
expect(src).toContain('PROVIDER_OAUTH["antigravity"]');
|
||||
expect(src).toContain('PROVIDER_OAUTH["gemini-cli"]');
|
||||
expect(src).not.toContain(EXPECTED.clientSecret); // antigravity secret no longer hardcoded here
|
||||
expect(src).not.toContain(GOOGLE.clientSecret); // gemini secret no longer hardcoded here
|
||||
});
|
||||
});
|
||||
44
tests/unit/antigravity-retry-hook.test.js
Normal file
44
tests/unit/antigravity-retry-hook.test.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Guards D3: antigravity 429/503 retry merged into base via computeRetryDelay hook.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
|
||||
|
||||
const MAX = 10000;
|
||||
function res(status, headers = {}, body = null) {
|
||||
return {
|
||||
status,
|
||||
headers: { get: (k) => headers[k.toLowerCase()] ?? null },
|
||||
clone: () => ({ text: async () => (body == null ? "" : JSON.stringify(body)) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("antigravity computeRetryDelay hook (D3)", () => {
|
||||
const ag = new AntigravityExecutor();
|
||||
|
||||
it("uses Retry-After header (seconds → ms) when within cap", async () => {
|
||||
expect(await ag.computeRetryDelay(res(429, { "retry-after": "5" }), 1)).toBe(5000);
|
||||
});
|
||||
|
||||
it("vetoes (false) when Retry-After exceeds cap", async () => {
|
||||
expect(await ag.computeRetryDelay(res(429, { "retry-after": "60" }), 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("parses retry time from error body when no header", async () => {
|
||||
const r = res(429, {}, { error: { message: "quota will reset after 3s" } });
|
||||
expect(await ag.computeRetryDelay(r, 1)).toBe(3000);
|
||||
});
|
||||
|
||||
it("exponential backoff for 429 when no retry info", async () => {
|
||||
expect(await ag.computeRetryDelay(res(429), 1)).toBe(Math.min(1000 * 2 ** 1, MAX));
|
||||
expect(await ag.computeRetryDelay(res(429), 3)).toBe(Math.min(1000 * 2 ** 3, MAX));
|
||||
});
|
||||
|
||||
it("503 without retry info → veto (no auto backoff)", async () => {
|
||||
expect(await ag.computeRetryDelay(res(503), 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("buildHeaders includes cached session id after transformRequest", () => {
|
||||
ag._lastSessionId = "sess-123";
|
||||
const h = ag.buildHeaders({ accessToken: "tok" }, true);
|
||||
expect(h["X-Machine-Session-Id"]).toBe("sess-123");
|
||||
});
|
||||
});
|
||||
97
tests/unit/base-executor-retry.test.js
Normal file
97
tests/unit/base-executor-retry.test.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// Locks BaseExecutor.execute retry/fallback behavior (docs 04 GAP #1, docs 11 §7).
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the network layer so we can script upstream responses.
|
||||
const fetchMock = vi.fn();
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: (...args) => fetchMock(...args),
|
||||
}));
|
||||
|
||||
const { BaseExecutor } = await import("../../open-sse/executors/base.js");
|
||||
|
||||
function res(status) {
|
||||
return { status, headers: { get: () => "" } };
|
||||
}
|
||||
|
||||
function makeExec(config) {
|
||||
const ex = new BaseExecutor("test", config);
|
||||
// make headers trivial; credentials empty
|
||||
return ex;
|
||||
}
|
||||
|
||||
const creds = { apiKey: "k" };
|
||||
|
||||
beforeEach(() => fetchMock.mockReset());
|
||||
|
||||
describe("BaseExecutor.execute — retry by status (config-driven)", () => {
|
||||
it("retries 502 `attempts` times then succeeds", async () => {
|
||||
const ex = makeExec({ baseUrl: "https://x/api", retry: { 502: { attempts: 3, delayMs: 0 } } });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(res(502))
|
||||
.mockResolvedValueOnce(res(502))
|
||||
.mockResolvedValueOnce(res(200));
|
||||
const out = await ex.execute({ model: "m", body: {}, stream: false, credentials: creds });
|
||||
expect(out.response.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("stops after exhausting 502 attempts on a single url and throws", async () => {
|
||||
const ex = makeExec({ baseUrl: "https://x/api", retry: { 502: { attempts: 2, delayMs: 0 } } });
|
||||
fetchMock.mockResolvedValue(res(502));
|
||||
// single url: 1 initial + 2 retries = 3 calls, then returns the 502 response (no fallback url)
|
||||
const out = await ex.execute({ model: "m", body: {}, stream: false, credentials: creds });
|
||||
expect(out.response.status).toBe(502);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseExecutor.execute — baseUrls fallback", () => {
|
||||
it("falls over to the next url on 429 (shouldRetry)", async () => {
|
||||
const ex = makeExec({ baseUrls: ["https://a/api", "https://b/api"], retry: { 429: { attempts: 0 } } });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(res(429)) // url[0] → fallback
|
||||
.mockResolvedValueOnce(res(200)); // url[1] ok
|
||||
const out = await ex.execute({ model: "m", body: {}, stream: false, credentials: creds });
|
||||
expect(out.response.status).toBe(200);
|
||||
expect(out.url).toBe("https://b/api");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseExecutor.execute — network error retry/fallback", () => {
|
||||
it("maps network exception to 502 retry config", async () => {
|
||||
const ex = makeExec({ baseUrl: "https://x/api", retry: { 502: { attempts: 1, delayMs: 0 } } });
|
||||
fetchMock
|
||||
.mockImplementationOnce(async () => { throw new Error("ECONNRESET"); })
|
||||
.mockResolvedValueOnce(res(200));
|
||||
const out = await ex.execute({ model: "m", body: {}, stream: false, credentials: creds });
|
||||
expect(out.response.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws when the only url fails with network error and no retries left", async () => {
|
||||
const ex = makeExec({ baseUrl: "https://x/api", retry: { 502: { attempts: 0 } } });
|
||||
// mockImplementationOnce (not persistent) avoids vitest flagging a reused rejection.
|
||||
fetchMock.mockImplementationOnce(async () => { throw new Error("boom"); });
|
||||
let thrown = null;
|
||||
try {
|
||||
await ex.execute({ model: "m", body: {}, stream: false, credentials: creds });
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown?.message).toBe("boom");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseExecutor.execute — computeRetryDelay hook veto", () => {
|
||||
it("hook returning false skips retry (uses fallback path)", async () => {
|
||||
const ex = makeExec({ baseUrl: "https://x/api", retry: { 429: { attempts: 5, delayMs: 0 } } });
|
||||
ex.computeRetryDelay = vi.fn().mockResolvedValue(false);
|
||||
fetchMock.mockResolvedValueOnce(res(429));
|
||||
const out = await ex.execute({ model: "m", body: {}, stream: false, credentials: creds });
|
||||
// hook vetoes retry → no fallback url → returns the 429 response as-is
|
||||
expect(out.response.status).toBe(429);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
15
tests/unit/capabilities-service-kind.test.js
Normal file
15
tests/unit/capabilities-service-kind.test.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { capabilitiesFromServiceKind } from "../../open-sse/providers/capabilities.js";
|
||||
|
||||
describe("capabilitiesFromServiceKind", () => {
|
||||
it("maps imageToText custom models to vision-capable runtime models", () => {
|
||||
expect(capabilitiesFromServiceKind("imageToText")).toMatchObject({ vision: true });
|
||||
});
|
||||
|
||||
it("maps media output/input custom model kinds to runtime capabilities", () => {
|
||||
expect(capabilitiesFromServiceKind("image")).toMatchObject({ imageOutput: true });
|
||||
expect(capabilitiesFromServiceKind("stt")).toMatchObject({ audioInput: true });
|
||||
expect(capabilitiesFromServiceKind("tts")).toMatchObject({ audioOutput: true });
|
||||
});
|
||||
});
|
||||
@@ -9,24 +9,40 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// Mock DNS so the SSRF guard treats example.com as public.
|
||||
vi.mock("node:dns/promises", () => ({ lookup: async () => ({ address: "93.184.216.34" }) }));
|
||||
|
||||
import { CodexExecutor } from "../../open-sse/executors/codex.js";
|
||||
import * as proxyFetchModule from "../../open-sse/utils/proxyFetch.js";
|
||||
|
||||
const IMAGE_1MB_BYTES = 1024 * 1024;
|
||||
const REMOTE_URL = "https://example.com/big.jpg";
|
||||
const DATA_URI = "data:image/png;base64,iVBORw0KGgo=";
|
||||
// JPEG magic bytes (FF D8 FF) so magic-byte verification passes.
|
||||
const JPEG_MAGIC = [0xff, 0xd8, 0xff];
|
||||
|
||||
function makeImageBuffer(sizeBytes) {
|
||||
const buf = new Uint8Array(sizeBytes);
|
||||
for (let i = 0; i < sizeBytes; i++) buf[i] = i & 0xff;
|
||||
return buf.buffer;
|
||||
for (let i = 0; i < JPEG_MAGIC.length; i++) buf[i] = JPEG_MAGIC[i];
|
||||
for (let i = JPEG_MAGIC.length; i < sizeBytes; i++) buf[i] = i & 0xff;
|
||||
return buf;
|
||||
}
|
||||
|
||||
function mockImageFetch(sizeBytes, mimeType = "image/jpeg") {
|
||||
// Mock a streaming Response body (getReader) as the hardened fetcher expects.
|
||||
function mockImageFetch(sizeBytes) {
|
||||
const bytes = makeImageBuffer(sizeBytes);
|
||||
return {
|
||||
ok: true,
|
||||
headers: { get: (k) => (k === "Content-Type" ? mimeType : null) },
|
||||
arrayBuffer: async () => makeImageBuffer(sizeBytes),
|
||||
body: {
|
||||
getReader() {
|
||||
let sent = false;
|
||||
return {
|
||||
read: async () => sent ? { done: true } : (sent = true, { done: false, value: bytes }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
99
tests/unit/codex-tool-normalization.test.js
Normal file
99
tests/unit/codex-tool-normalization.test.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CodexExecutor } from "../../open-sse/executors/codex.js";
|
||||
|
||||
function normalizeTools(tools) {
|
||||
const executor = new CodexExecutor();
|
||||
const body = {
|
||||
model: "gpt-5.5",
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "probe" }] }],
|
||||
tools,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
executor.transformRequest("gpt-5.5", body, true, {
|
||||
connectionId: "test-codex-tools",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
return body.tools;
|
||||
}
|
||||
|
||||
describe("CodexExecutor tool normalization", () => {
|
||||
it("preserves Responses-native tool_search tools", () => {
|
||||
const tools = normalizeTools([
|
||||
{
|
||||
type: "tool_search",
|
||||
execution: "sync",
|
||||
description: "Discover deferred tools",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
type: "namespace",
|
||||
name: "codex_app",
|
||||
description: "app tools",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "automation_update",
|
||||
description: "automation",
|
||||
parameters: { type: "object", properties: {} },
|
||||
defer_loading: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
name: "plain_fn",
|
||||
description: "plain",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(tools.map((tool) => `${tool.type}:${tool.name || ""}`)).toEqual([
|
||||
"tool_search:",
|
||||
"namespace:codex_app",
|
||||
"function:plain_fn",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves hosted Responses tools", () => {
|
||||
const tools = normalizeTools([
|
||||
{ type: "web_search", search_context_size: "medium" },
|
||||
{ type: "image_generation", size: "1024x1024" },
|
||||
{ type: "mcp", server_label: "docs", server_url: "https://example.com/mcp" },
|
||||
{ type: "local_shell" },
|
||||
{ type: "code_interpreter", container: { type: "auto" } },
|
||||
{ type: "computer", display_width: 1024, display_height: 768, environment: "browser" },
|
||||
]);
|
||||
|
||||
expect(tools.map((tool) => tool.type)).toEqual([
|
||||
"web_search",
|
||||
"image_generation",
|
||||
"mcp",
|
||||
"local_shell",
|
||||
"code_interpreter",
|
||||
"computer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves custom freeform tools with format payloads", () => {
|
||||
const tools = normalizeTools([
|
||||
{
|
||||
type: "custom",
|
||||
name: "apply_patch",
|
||||
description: "patch",
|
||||
format: { type: "grammar", syntax: "lark", definition: "start: /.+/" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(tools).toEqual([
|
||||
{
|
||||
type: "custom",
|
||||
name: "apply_patch",
|
||||
description: "patch",
|
||||
format: { type: "grammar", syntax: "lark", definition: "start: /.+/" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
78
tests/unit/combo-autoswitch.test.js
Normal file
78
tests/unit/combo-autoswitch.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { detectRequiredCapabilities, reorderByCapabilities } from "../../open-sse/services/combo.js";
|
||||
|
||||
describe("detectRequiredCapabilities", () => {
|
||||
it("text-only -> empty", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: "hi" }] });
|
||||
expect(r.size).toBe(0);
|
||||
});
|
||||
|
||||
it("openai image_url -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: [
|
||||
{ type: "image_url", image_url: { url: "x" } },
|
||||
] }] });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
|
||||
it("openai file -> pdf", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: [
|
||||
{ type: "file", file: { file_data: "data:application/pdf;base64,x" } },
|
||||
] }] });
|
||||
expect(r.has("pdf")).toBe(true);
|
||||
});
|
||||
|
||||
it("gemini inlineData image -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
] }] });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
|
||||
it("antigravity request.contents image -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ request: { contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/jpeg", data: "x" } },
|
||||
] }] } });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
|
||||
it("web_search tool -> search", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: "q" }], tools: [
|
||||
{ type: "web_search" },
|
||||
] });
|
||||
expect(r.has("search")).toBe(true);
|
||||
});
|
||||
|
||||
it("responses input_image -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ input: [{ role: "user", content: [
|
||||
{ type: "input_image", image_url: "x" },
|
||||
] }] });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderByCapabilities", () => {
|
||||
it("no required -> unchanged", () => {
|
||||
const models = ["a/x", "b/y"];
|
||||
expect(reorderByCapabilities(models, new Set())).toBe(models);
|
||||
});
|
||||
|
||||
it("floats vision-capable model to front, keeps fallback", () => {
|
||||
// deepseek-chat = no vision; claude-sonnet = vision
|
||||
const models = ["deepseek/deepseek-chat", "anthropic/claude-sonnet-4.6"];
|
||||
const out = reorderByCapabilities(models, new Set(["vision"]));
|
||||
expect(out[0]).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(out).toContain("deepseek/deepseek-chat"); // not dropped
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps order when no model matches", () => {
|
||||
const models = ["deepseek/deepseek-chat", "deepseek/deepseek-reasoner"];
|
||||
const out = reorderByCapabilities(models, new Set(["vision"]));
|
||||
expect(out).toBe(models);
|
||||
});
|
||||
|
||||
it("single model -> unchanged", () => {
|
||||
const models = ["a/x"];
|
||||
expect(reorderByCapabilities(models, new Set(["vision"]))).toBe(models);
|
||||
});
|
||||
});
|
||||
216
tests/unit/combo-fusion.test.js
Normal file
216
tests/unit/combo-fusion.test.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { handleFusionChat } from "../../open-sse/services/combo.js";
|
||||
|
||||
const log = { info: () => {}, warn: () => {}, debug: () => {} };
|
||||
|
||||
// Minimal OpenAI-chat Response stub with the .ok + .clone().json() surface the engine uses.
|
||||
function okResponse(content, { delayMs = 0 } = {}) {
|
||||
const json = { choices: [{ message: { role: "assistant", content } }] };
|
||||
const make = () => ({ ok: true, status: 200, clone: make, json: async () => json });
|
||||
const res = make();
|
||||
return delayMs > 0 ? new Promise((r) => setTimeout(() => r(res), delayMs)) : res;
|
||||
}
|
||||
|
||||
function errResponse(status = 500) {
|
||||
const make = () => ({ ok: false, status, clone: make, json: async () => ({ error: { message: "boom" } }) });
|
||||
return make();
|
||||
}
|
||||
|
||||
describe("fusion combo", () => {
|
||||
it("answers directly with a single-model panel (nothing to fuse)", async () => {
|
||||
const handleSingleModel = vi.fn(async () => okResponse("solo"));
|
||||
await handleFusionChat({
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
models: ["p/only"],
|
||||
handleSingleModel,
|
||||
log,
|
||||
});
|
||||
expect(handleSingleModel).toHaveBeenCalledTimes(1);
|
||||
expect(handleSingleModel.mock.calls[0][1]).toBe("p/only");
|
||||
});
|
||||
|
||||
it("fans out to the panel then routes a synthesis turn to the judge", async () => {
|
||||
const seen = [];
|
||||
const handleSingleModel = vi.fn(async (body, model, isPanel) => {
|
||||
seen.push(model);
|
||||
if (model === "p/judge") return okResponse("FINAL");
|
||||
return okResponse(`ans-${model}`);
|
||||
});
|
||||
|
||||
const res = await handleFusionChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }], stream: true, tools: [{ name: "x" }] },
|
||||
models: ["p/a", "p/b", "p/c"],
|
||||
handleSingleModel,
|
||||
log,
|
||||
judgeModel: "p/judge",
|
||||
});
|
||||
|
||||
// 3 panel calls + 1 judge call.
|
||||
expect(handleSingleModel).toHaveBeenCalledTimes(4);
|
||||
expect(seen.slice(0, 3).sort()).toEqual(["p/a", "p/b", "p/c"]);
|
||||
expect(seen[3]).toBe("p/judge");
|
||||
|
||||
// Panel calls are non-streaming with tools stripped.
|
||||
for (const [body, model, isPanel] of handleSingleModel.mock.calls.filter(([, m]) => m !== "p/judge")) {
|
||||
expect(body.stream).toBe(false);
|
||||
expect(body.tools).toBeUndefined();
|
||||
expect(isPanel).toBe(true);
|
||||
}
|
||||
|
||||
// Judge call carries every panel answer + keeps the client's stream flag.
|
||||
const [judgeBody, , isPanel] = handleSingleModel.mock.calls.find(([, m]) => m === "p/judge");
|
||||
const judgeText = judgeBody.messages.at(-1).content;
|
||||
expect(judgeText).toContain("ans-p/a");
|
||||
expect(judgeText).toContain("ans-p/b");
|
||||
expect(judgeText).toContain("ans-p/c");
|
||||
expect(judgeText).toContain("Source 1");
|
||||
expect(judgeBody.stream).toBe(true);
|
||||
expect(isPanel).toBeUndefined();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults the judge to the first panel model when none is set", async () => {
|
||||
const seen = [];
|
||||
const handleSingleModel = vi.fn(async (_body, model) => { seen.push(model); return okResponse(`ans-${model}`); });
|
||||
await handleFusionChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
models: ["p/first", "p/second"],
|
||||
handleSingleModel,
|
||||
log,
|
||||
});
|
||||
// Last call is the judge; defaults to panel[0].
|
||||
expect(seen.at(-1)).toBe("p/first");
|
||||
});
|
||||
|
||||
it("proceeds on quorum without waiting for a straggler (grace window)", async () => {
|
||||
const handleSingleModel = vi.fn(async (_body, model) => {
|
||||
if (model === "p/slow") return okResponse("slow", { delayMs: 5000 });
|
||||
if (model === "p/judge") return okResponse("FINAL");
|
||||
return okResponse(`fast-${model}`);
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
await handleFusionChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
models: ["p/x", "p/y", "p/slow"],
|
||||
handleSingleModel,
|
||||
log,
|
||||
judgeModel: "p/judge",
|
||||
tuning: { minPanel: 2, stragglerGraceMs: 50, panelHardTimeoutMs: 10000 },
|
||||
});
|
||||
const elapsed = Date.now() - t0;
|
||||
|
||||
// Two fast answers reach quorum; grace is 50ms, so we never wait ~5s for p/slow.
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
|
||||
const judgeCall = handleSingleModel.mock.calls.find(([, m]) => m === "p/judge");
|
||||
const judgeText = judgeCall[0].messages.at(-1).content;
|
||||
expect(judgeText).toContain("fast-p/x");
|
||||
expect(judgeText).toContain("fast-p/y");
|
||||
expect(judgeText).not.toContain("slow");
|
||||
});
|
||||
|
||||
it("returns the lone survivor directly when only one panel model succeeds", async () => {
|
||||
const handleSingleModel = vi.fn(async (_body, model) => {
|
||||
if (model === "p/ok") return okResponse("lone");
|
||||
return errResponse(500);
|
||||
});
|
||||
await handleFusionChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
models: ["p/ok", "p/bad"],
|
||||
handleSingleModel,
|
||||
log,
|
||||
judgeModel: "p/judge",
|
||||
tuning: { minPanel: 2, stragglerGraceMs: 50, panelHardTimeoutMs: 5000 },
|
||||
});
|
||||
// No judge call — single answer means there is nothing to fuse.
|
||||
const judged = handleSingleModel.mock.calls.some(([, m]) => m === "p/judge");
|
||||
expect(judged).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 503 when the whole panel fails", async () => {
|
||||
const handleSingleModel = vi.fn(async () => errResponse(500));
|
||||
const res = await handleFusionChat({
|
||||
body: { messages: [{ role: "user", content: "Q" }] },
|
||||
models: ["p/a", "p/b"],
|
||||
handleSingleModel,
|
||||
log,
|
||||
tuning: { minPanel: 2, stragglerGraceMs: 50, panelHardTimeoutMs: 5000 },
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("flattens previous tool history and assistant tool_calls into prose for panel calls", async () => {
|
||||
const handleSingleModel = vi.fn(async () => okResponse("ans"));
|
||||
await handleFusionChat({
|
||||
body: {
|
||||
messages: [
|
||||
{ role: "user", content: "find files" },
|
||||
{ role: "assistant", content: "", tool_calls: [{ id: "c1", type: "function", function: { name: "find" } }] },
|
||||
{ role: "tool", tool_call_id: "c1", content: "['a.js']" },
|
||||
{ role: "user", content: "describe it" }
|
||||
],
|
||||
tools: [{ type: "function" }]
|
||||
},
|
||||
models: ["p/a", "p/b"],
|
||||
handleSingleModel,
|
||||
log,
|
||||
judgeModel: "p/judge"
|
||||
});
|
||||
|
||||
// Panel calls keep every turn but tool turns are flattened to assistant prose.
|
||||
const panelCalls = handleSingleModel.mock.calls.filter(([,, isPanel]) => isPanel === true);
|
||||
expect(panelCalls.length).toBe(2);
|
||||
for (const [panelBody] of panelCalls) {
|
||||
expect(panelBody.tools).toBeUndefined();
|
||||
expect(panelBody.messages.length).toBe(4);
|
||||
expect(panelBody.messages[0]).toEqual({ role: "user", content: "find files" });
|
||||
expect(panelBody.messages[1].tool_calls).toBeUndefined();
|
||||
expect(panelBody.messages[1].content).toContain("find");
|
||||
expect(panelBody.messages[2].role).toBe("assistant");
|
||||
expect(panelBody.messages[2].content).toContain("['a.js']");
|
||||
expect(panelBody.messages[3]).toEqual({ role: "user", content: "describe it" });
|
||||
}
|
||||
|
||||
// Judge call still receives the unmodified history + synthesis prompt.
|
||||
const judgeCall = handleSingleModel.mock.calls.find(([, m]) => m === "p/judge");
|
||||
expect(judgeCall).toBeDefined();
|
||||
const judgeBody = judgeCall[0];
|
||||
expect(judgeBody.messages.length).toBe(5); // original 4 + judge prompt turn
|
||||
expect(judgeBody.messages[1].tool_calls).toBeDefined();
|
||||
expect(judgeBody.messages[2].role).toBe("tool");
|
||||
});
|
||||
|
||||
it("flattens Anthropic-style tool_use and tool_result blocks in arrays", async () => {
|
||||
const handleSingleModel = vi.fn(async () => okResponse("ans"));
|
||||
await handleFusionChat({
|
||||
body: {
|
||||
messages: [
|
||||
{ role: "user", content: "do it" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "ok" }, { type: "tool_use", id: "t1", name: "run" }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "done" }] }
|
||||
],
|
||||
tools: [{ name: "run", description: "d" }]
|
||||
},
|
||||
models: ["p/a", "p/b"],
|
||||
handleSingleModel,
|
||||
log,
|
||||
judgeModel: "p/judge"
|
||||
});
|
||||
|
||||
const panelCalls = handleSingleModel.mock.calls.filter(([,, isPanel]) => isPanel === true);
|
||||
expect(panelCalls.length).toBe(2);
|
||||
const panelBody = panelCalls[0][0];
|
||||
|
||||
expect(panelBody.tools).toBeUndefined();
|
||||
expect(panelBody.messages.length).toBe(3);
|
||||
|
||||
// Flattened tool_use
|
||||
expect(panelBody.messages[1].content).toBe("ok\n[Called tools: run]");
|
||||
|
||||
// Flattened tool_result
|
||||
expect(panelBody.messages[2].content).toBe("[Tool result: done]");
|
||||
});
|
||||
});
|
||||
@@ -9,13 +9,13 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { convertCommandCodeToOpenAI } from "../../open-sse/translator/response/commandcode-to-openai.js";
|
||||
import { commandCodeToOpenAIResponse } from "../../open-sse/translator/response/commandcode-to-openai.js";
|
||||
|
||||
function feed(events) {
|
||||
const state = {};
|
||||
const all = [];
|
||||
for (const e of events) {
|
||||
const out = convertCommandCodeToOpenAI(JSON.stringify(e), state);
|
||||
const out = commandCodeToOpenAIResponse(JSON.stringify(e), state);
|
||||
if (out) for (const c of out) all.push(c);
|
||||
}
|
||||
return { state, chunks: all };
|
||||
|
||||
@@ -61,6 +61,26 @@ describe("dashboard guard public LLM API access", () => {
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
|
||||
const response = await proxy(request("/v1/chat/completions", {
|
||||
host: "localhost",
|
||||
"x-9r-real-ip": "10.204.111.34",
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("allows loopback peer IP regardless of Host", async () => {
|
||||
const response = await proxy(request("/v1/chat/completions", {
|
||||
host: "localhost:20128",
|
||||
"x-9r-real-ip": "127.0.0.1",
|
||||
}));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects remote rewritten public LLM API without API key", async () => {
|
||||
const response = await proxy(request("/api/v1/chat/completions", { host: "router.example.com" }));
|
||||
|
||||
@@ -89,6 +109,25 @@ describe("dashboard guard public LLM API access", () => {
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("rejects remote codex rewrite without API key", async () => {
|
||||
const response = await proxy(request("/codex/x", { host: "router.example.com" }));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("allows remote codex rewrite with valid API key", async () => {
|
||||
mocks.validateApiKey.mockResolvedValue(true);
|
||||
|
||||
const response = await proxy(request("/codex/x", {
|
||||
host: "router.example.com",
|
||||
authorization: "Bearer sk-valid",
|
||||
}));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid");
|
||||
});
|
||||
|
||||
it("allows remote public LLM API with valid bearer API key", async () => {
|
||||
mocks.validateApiKey.mockResolvedValue(true);
|
||||
|
||||
|
||||
48
tests/unit/executor-const-guard.test.js
Normal file
48
tests/unit/executor-const-guard.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// A5 (cases #7/#9/#10): lock hardcode->config no-op values.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
OPENAI_COMPAT_BASE,
|
||||
ANTHROPIC_COMPAT_BASE,
|
||||
ANTHROPIC_API_VERSION,
|
||||
} from "../../open-sse/providers/shared.js";
|
||||
import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../open-sse/config/runtimeConfig.js";
|
||||
import mimoFree from "../../open-sse/providers/registry/mimo-free.js";
|
||||
import opencode from "../../open-sse/providers/registry/opencode.js";
|
||||
import antigravity from "../../open-sse/providers/registry/antigravity.js";
|
||||
|
||||
describe("compat base URLs / version", () => {
|
||||
it("OPENAI_COMPAT_BASE", () => {
|
||||
expect(OPENAI_COMPAT_BASE).toBe("https://api.openai.com/v1");
|
||||
});
|
||||
it("ANTHROPIC_COMPAT_BASE", () => {
|
||||
expect(ANTHROPIC_COMPAT_BASE).toBe("https://api.anthropic.com/v1");
|
||||
});
|
||||
it("ANTHROPIC_API_VERSION", () => {
|
||||
expect(ANTHROPIC_API_VERSION).toBe("2023-06-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default token limits", () => {
|
||||
it("max/min", () => {
|
||||
expect(DEFAULT_MAX_TOKENS).toBe(64000);
|
||||
expect(DEFAULT_MIN_TOKENS).toBe(32000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider baseUrl const (full path, no trailing slash)", () => {
|
||||
it("mimo-free full path", () => {
|
||||
expect(mimoFree.transport.baseUrl).toBe("https://api.xiaomimimo.com/api/free-ai/openai/chat");
|
||||
});
|
||||
it("opencode no trailing slash", () => {
|
||||
expect(opencode.transport.baseUrl).toBe("https://opencode.ai");
|
||||
});
|
||||
});
|
||||
|
||||
describe("antigravity retry (intentional change: 429=6, 503=3)", () => {
|
||||
it("429 attempts = 6", () => {
|
||||
expect(antigravity.transport.retry["429"].attempts).toBe(6);
|
||||
});
|
||||
it("503 attempts = 3", () => {
|
||||
expect(antigravity.transport.retry["503"].attempts).toBe(3);
|
||||
});
|
||||
});
|
||||
57
tests/unit/file-block-routing.test.js
Normal file
57
tests/unit/file-block-routing.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { convertOpenAIContentToParts } from "../../open-sse/translator/formats/gemini.js";
|
||||
import { openaiToClaudeRequest } from "../../open-sse/translator/request/openai-to-claude.js";
|
||||
import { VALID_OPENAI_CONTENT_TYPES, OPENAI_BLOCK, CLAUDE_BLOCK } from "../../open-sse/translator/schema/index.js";
|
||||
|
||||
const PDF_DATA = "data:application/pdf;base64,JVBERi0xLjE=";
|
||||
const PNG_DATA = "data:image/png;base64,iVBORw0KGgo=";
|
||||
|
||||
describe("file/document block support", () => {
|
||||
it("schema: file is a valid openai content type", () => {
|
||||
expect(VALID_OPENAI_CONTENT_TYPES).toContain(OPENAI_BLOCK.FILE);
|
||||
expect(OPENAI_BLOCK.FILE).toBe("file");
|
||||
expect(CLAUDE_BLOCK.DOCUMENT).toBe("document");
|
||||
});
|
||||
|
||||
it("gemini: openai file block -> inlineData", () => {
|
||||
const parts = convertOpenAIContentToParts([
|
||||
{ type: "text", text: "read this" },
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: PDF_DATA } },
|
||||
]);
|
||||
const inline = parts.find((p) => p.inlineData);
|
||||
expect(inline).toBeTruthy();
|
||||
expect(inline.inlineData.mime_type).toBe("application/pdf");
|
||||
expect(inline.inlineData.data).toBe("JVBERi0xLjE=");
|
||||
});
|
||||
|
||||
it("gemini: ignores non-data-uri file", () => {
|
||||
const parts = convertOpenAIContentToParts([
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: "https://x/d.pdf" } },
|
||||
]);
|
||||
expect(parts.some((p) => p.inlineData)).toBe(false);
|
||||
});
|
||||
|
||||
it("claude: openai file (pdf) -> document block", () => {
|
||||
const out = openaiToClaudeRequest("claude-x", {
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "read" },
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: PDF_DATA } },
|
||||
] }],
|
||||
}, false);
|
||||
const blocks = out.messages[0].content;
|
||||
const doc = blocks.find((b) => b.type === "document");
|
||||
expect(doc).toBeTruthy();
|
||||
expect(doc.source.media_type).toBe("application/pdf");
|
||||
});
|
||||
|
||||
it("claude: non-pdf file is dropped (not a document)", () => {
|
||||
const out = openaiToClaudeRequest("claude-x", {
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "read" },
|
||||
{ type: "file", file: { filename: "i.png", file_data: PNG_DATA } },
|
||||
] }],
|
||||
}, false);
|
||||
const blocks = out.messages[0].content;
|
||||
expect(blocks.some((b) => b.type === "document")).toBe(false);
|
||||
});
|
||||
});
|
||||
83
tests/unit/finish-reason-concern.test.js
Normal file
83
tests/unit/finish-reason-concern.test.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// A1: locks toOpenAIFinish/fromOpenAIFinish behavior changes vs open-sse.old.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { toOpenAIFinish, fromOpenAIFinish } from "../../open-sse/translator/concerns/finishReason.js";
|
||||
import { OPENAI_FINISH, CLAUDE_STOP, GEMINI_FINISH } from "../../open-sse/translator/schema/finishReasons.js";
|
||||
|
||||
describe("toOpenAIFinish - gemini", () => {
|
||||
it.each([
|
||||
["SAFETY", "content_filter"],
|
||||
["RECITATION", "content_filter"],
|
||||
["BLOCKLIST", "content_filter"],
|
||||
["PROHIBITED_CONTENT", "content_filter"],
|
||||
["OTHER", "stop"],
|
||||
["UNKNOWN_XYZ", "stop"],
|
||||
["STOP", "stop"],
|
||||
["MAX_TOKENS", "length"],
|
||||
])("%s -> %s", (input, expected) => {
|
||||
expect(toOpenAIFinish(input, "gemini")).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toOpenAIFinish - ollama", () => {
|
||||
it.each([
|
||||
["length", "length"],
|
||||
["max_tokens", "length"],
|
||||
["tool_calls", "tool_calls"],
|
||||
["unknown_xyz", "stop"],
|
||||
])("%s -> %s", (input, expected) => {
|
||||
expect(toOpenAIFinish(input, "ollama")).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toOpenAIFinish - kiro", () => {
|
||||
it("tool_use -> tool_calls", () => {
|
||||
expect(toOpenAIFinish("tool_use", "kiro")).toBe("tool_calls");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toOpenAIFinish - claude", () => {
|
||||
it.each([
|
||||
["end_turn", "stop"],
|
||||
["max_tokens", "length"],
|
||||
["tool_use", "tool_calls"],
|
||||
])("%s -> %s", (input, expected) => {
|
||||
expect(toOpenAIFinish(input, "claude")).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toOpenAIFinish - commandcode", () => {
|
||||
it("tool-calls -> tool_calls", () => {
|
||||
expect(toOpenAIFinish("tool-calls", "commandcode")).toBe("tool_calls");
|
||||
});
|
||||
it("unknown passthrough", () => {
|
||||
expect(toOpenAIFinish("xyz", "commandcode")).toBe("xyz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromOpenAIFinish round-trip - claude", () => {
|
||||
it("tool_calls -> tool_use", () => {
|
||||
expect(fromOpenAIFinish("tool_calls", "claude")).toBe("tool_use");
|
||||
});
|
||||
it("length -> max_tokens", () => {
|
||||
expect(fromOpenAIFinish("length", "claude")).toBe("max_tokens");
|
||||
});
|
||||
});
|
||||
|
||||
describe("enum literals (catch drift)", () => {
|
||||
it("OPENAI_FINISH literals", () => {
|
||||
expect(OPENAI_FINISH.STOP).toBe("stop");
|
||||
expect(OPENAI_FINISH.LENGTH).toBe("length");
|
||||
expect(OPENAI_FINISH.TOOL_CALLS).toBe("tool_calls");
|
||||
expect(OPENAI_FINISH.CONTENT_FILTER).toBe("content_filter");
|
||||
});
|
||||
it("CLAUDE_STOP literals", () => {
|
||||
expect(CLAUDE_STOP.END_TURN).toBe("end_turn");
|
||||
expect(CLAUDE_STOP.MAX_TOKENS).toBe("max_tokens");
|
||||
expect(CLAUDE_STOP.TOOL_USE).toBe("tool_use");
|
||||
});
|
||||
it("GEMINI_FINISH literals", () => {
|
||||
expect(GEMINI_FINISH.STOP).toBe("STOP");
|
||||
expect(GEMINI_FINISH.MAX_TOKENS).toBe("MAX_TOKENS");
|
||||
expect(GEMINI_FINISH.SAFETY).toBe("SAFETY");
|
||||
});
|
||||
});
|
||||
17
tests/unit/force-stream-config.test.js
Normal file
17
tests/unit/force-stream-config.test.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// Guards forceStream moved from chatCore hardcode → PROVIDERS schema (#5).
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const FORCED = ["openai", "codex", "commandcode"];
|
||||
|
||||
describe("forceStream provider config", () => {
|
||||
it("only openai/codex/commandcode force streaming", async () => {
|
||||
const { PROVIDERS } = await import("../../open-sse/config/providers.js");
|
||||
for (const id of FORCED) {
|
||||
expect(PROVIDERS[id]?.forceStream, `${id} forced`).toBe(true);
|
||||
}
|
||||
// a sample of others must NOT force
|
||||
for (const id of ["deepseek", "claude", "gemini", "openrouter"]) {
|
||||
expect(PROVIDERS[id]?.forceStream, `${id} not forced`).not.toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
36
tests/unit/headroom-detect.test.js
Normal file
36
tests/unit/headroom-detect.test.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execSync: vi.fn(() => { throw new Error("not found"); }),
|
||||
}));
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
execSync: mocks.execSync,
|
||||
}));
|
||||
|
||||
import { getHeadroomStatus, isLoopbackHeadroomUrl } from "../../src/lib/headroom/detect.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("headroom detect", () => {
|
||||
it("treats a reachable external proxy as running without local CLI", async () => {
|
||||
global.fetch = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
|
||||
const status = await getHeadroomStatus("http://headroom:8787");
|
||||
|
||||
expect(status.installed).toBe(false);
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.localUrl).toBe(false);
|
||||
expect(status.canStart).toBe(false);
|
||||
expect(global.fetch).toHaveBeenCalledWith("http://headroom:8787/health", expect.any(Object));
|
||||
});
|
||||
|
||||
it("recognizes loopback URLs for managed local mode", () => {
|
||||
expect(isLoopbackHeadroomUrl("http://localhost:8787")).toBe(true);
|
||||
expect(isLoopbackHeadroomUrl("http://127.0.0.1:8787")).toBe(true);
|
||||
expect(isLoopbackHeadroomUrl("http://headroom:8787")).toBe(false);
|
||||
expect(isLoopbackHeadroomUrl("not-a-url")).toBe(false);
|
||||
});
|
||||
});
|
||||
73
tests/unit/headroom.test.js
Normal file
73
tests/unit/headroom.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { compressWithHeadroom, formatHeadroomLog } from "../../open-sse/rtk/headroom.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("compressWithHeadroom", () => {
|
||||
it("no-ops when disabled", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
const stats = await compressWithHeadroom(body, { enabled: false, url: "http://localhost:8787" });
|
||||
|
||||
expect(stats).toBeNull();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(body.messages[0].content).toBe("hello");
|
||||
});
|
||||
|
||||
it("compresses messages in-place", async () => {
|
||||
global.fetch = vi.fn(async () => new Response(JSON.stringify({
|
||||
messages: [{ role: "user", content: "short" }],
|
||||
tokens_before: 100,
|
||||
tokens_after: 20,
|
||||
tokens_saved: 80,
|
||||
}), { status: 200 }));
|
||||
const body = { messages: [{ role: "user", content: "long" }] };
|
||||
|
||||
const stats = await compressWithHeadroom(body, { enabled: true, url: "http://headroom:8787/", model: "gpt-4o" });
|
||||
|
||||
expect(body.messages[0].content).toBe("short");
|
||||
expect(stats.tokens_saved).toBe(80);
|
||||
expect(global.fetch).toHaveBeenCalledWith("http://headroom:8787/v1/compress", expect.objectContaining({ method: "POST" }));
|
||||
});
|
||||
|
||||
it("compresses responses input in-place", async () => {
|
||||
global.fetch = vi.fn(async () => new Response(JSON.stringify({
|
||||
messages: [{ role: "user", content: "short" }],
|
||||
}), { status: 200 }));
|
||||
const body = { input: [{ role: "user", content: "long" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787" });
|
||||
|
||||
expect(body.input[0].content).toBe("short");
|
||||
});
|
||||
|
||||
it("fails open on bad response", async () => {
|
||||
global.fetch = vi.fn(async () => new Response(JSON.stringify({ error: "bad" }), { status: 500 }));
|
||||
const body = { messages: [{ role: "user", content: "long" }] };
|
||||
|
||||
const stats = await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787" });
|
||||
|
||||
expect(stats).toBeNull();
|
||||
expect(body.messages[0].content).toBe("long");
|
||||
});
|
||||
|
||||
it("skips unknown shapes", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const body = { contents: [{ parts: [{ text: "long" }] }] };
|
||||
|
||||
const stats = await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787" });
|
||||
|
||||
expect(stats).toBeNull();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHeadroomLog", () => {
|
||||
it("formats savings", () => {
|
||||
expect(formatHeadroomLog({ tokens_before: 100, tokens_after: 25, tokens_saved: 75 }))
|
||||
.toBe("saved 75 tokens / 100 (75.0%) after=25");
|
||||
});
|
||||
});
|
||||
77
tests/unit/image-fetch-hardening.test.js
Normal file
77
tests/unit/image-fetch-hardening.test.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock DNS lookup so we control which host resolves to what IP.
|
||||
const lookupMock = vi.fn();
|
||||
vi.mock("node:dns/promises", () => ({ lookup: (...a) => lookupMock(...a) }));
|
||||
|
||||
import { fetchImageAsBase64 } from "../../open-sse/translator/concerns/image.js";
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
function mockFetchOnce(bytes, ok = true) {
|
||||
const body = {
|
||||
getReader() {
|
||||
let sent = false;
|
||||
return {
|
||||
read: async () => sent ? { done: true } : (sent = true, { done: false, value: new Uint8Array(bytes) }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
globalThis.fetch = vi.fn(async () => ({ ok, body }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lookupMock.mockReset();
|
||||
lookupMock.mockResolvedValue({ address: "93.184.216.34" }); // public by default
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
describe("fetchImageAsBase64 hardening", () => {
|
||||
it("rejects non-http url", async () => {
|
||||
expect(await fetchImageAsBase64("ftp://x/y.png")).toBeNull();
|
||||
expect(await fetchImageAsBase64("data:image/png;base64,xx")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects private IP (10.x)", async () => {
|
||||
lookupMock.mockResolvedValue({ address: "10.0.0.5" });
|
||||
expect(await fetchImageAsBase64("http://internal.example/x.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects cloud metadata 169.254.169.254", async () => {
|
||||
lookupMock.mockResolvedValue({ address: "169.254.169.254" });
|
||||
expect(await fetchImageAsBase64("http://metadata/x.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects blocked hostname localhost", async () => {
|
||||
expect(await fetchImageAsBase64("http://localhost/x.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects IPv6 loopback", async () => {
|
||||
lookupMock.mockResolvedValue({ address: "::1" });
|
||||
expect(await fetchImageAsBase64("http://x/y.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts valid PNG from public host", async () => {
|
||||
mockFetchOnce(PNG);
|
||||
const r = await fetchImageAsBase64("https://example.com/a.png");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r.mimeType).toBe("image/png");
|
||||
expect(r.url.startsWith("data:image/png;base64,")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects disguised non-image payload (magic byte mismatch)", async () => {
|
||||
mockFetchOnce(Buffer.from("<?php system($_GET[c]); ?>"));
|
||||
expect(await fetchImageAsBase64("https://example.com/evil.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects payload over size cap", async () => {
|
||||
mockFetchOnce(Buffer.alloc(1024));
|
||||
expect(await fetchImageAsBase64("https://example.com/big.png", { maxBytes: 100 })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when fetch not ok", async () => {
|
||||
mockFetchOnce(PNG, false);
|
||||
expect(await fetchImageAsBase64("https://example.com/404.png")).toBeNull();
|
||||
});
|
||||
});
|
||||
63
tests/unit/kiro-profile-arn.test.js
Normal file
63
tests/unit/kiro-profile-arn.test.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { KiroService } from "../../src/lib/oauth/services/kiro.js";
|
||||
|
||||
/**
|
||||
* Regression tests for Kiro API-key auth.
|
||||
*
|
||||
* KiroService.validateApiKey resolves a profileArn with the key (via
|
||||
* CodeWhisperer ListAvailableProfiles) and returns a credential shaped for
|
||||
* persistence with authMethod="api_key". The response profile field name
|
||||
* varies (`arn` vs `profileArn`) — both are accepted by listAvailableProfiles.
|
||||
*
|
||||
* Note: OAuth (Builder ID / IDC) profileArn resolution is handled upstream by
|
||||
* fetchKiroProfileArn in providers.js and is covered there — not here.
|
||||
*/
|
||||
describe("kiro API-key auth (KiroService.validateApiKey)", () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("validates an API key and resolves a credential with profileArn", async () => {
|
||||
const expectedArn = "arn:aws:codewhisperer:us-east-1:444:profile/KEY";
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ profiles: [{ arn: expectedArn }] }),
|
||||
});
|
||||
|
||||
const svc = new KiroService();
|
||||
const cred = await svc.validateApiKey(" my-secret-key ");
|
||||
|
||||
expect(cred).toEqual({
|
||||
accessToken: "my-secret-key",
|
||||
refreshToken: null,
|
||||
profileArn: expectedArn,
|
||||
region: "us-east-1",
|
||||
authMethod: "api_key",
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://codewhisperer.us-east-1.amazonaws.com");
|
||||
expect(init.headers.Authorization).toBe("Bearer my-secret-key");
|
||||
expect(init.headers["x-amz-target"]).toBe(
|
||||
"AmazonCodeWhispererService.ListAvailableProfiles"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an empty API key without a network call", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch");
|
||||
const svc = new KiroService();
|
||||
await expect(svc.validateApiKey(" ")).rejects.toThrow("API key is required");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a validation error when the key is rejected", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => "Unauthorized",
|
||||
});
|
||||
const svc = new KiroService();
|
||||
await expect(svc.validateApiKey("bad-key")).rejects.toThrow(
|
||||
/API key validation failed/
|
||||
);
|
||||
});
|
||||
});
|
||||
60
tests/unit/mimo-free.live.test.js
Normal file
60
tests/unit/mimo-free.live.test.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Live repro for issue #1933: MiMo Code Free returns HTTP 502 "MiMo bootstrap failed: 403".
|
||||
* Root cause: upstream gates on Chrome-like User-Agent. Without UA → 403 "Illegal access".
|
||||
* Hits real endpoints — no mocks. Free provider, safe to call.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import { __test__ } from "../../open-sse/executors/mimo-free.js";
|
||||
|
||||
const { BOOTSTRAP_URL, CHAT_URL, generateFingerprint, MIMO_SYSTEM_MARKER } = __test__;
|
||||
|
||||
const CHROME_UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
async function bootstrapWith(ua) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (ua) headers["User-Agent"] = ua;
|
||||
const r = await proxyAwareFetch(BOOTSTRAP_URL, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ client: generateFingerprint() }),
|
||||
});
|
||||
const data = await r.json();
|
||||
return { status: r.status, jwt: data.jwt };
|
||||
}
|
||||
|
||||
async function chatWith(jwt, ua) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Mimo-Source": "mimocode-cli-free",
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (ua) headers["User-Agent"] = ua;
|
||||
const body = {
|
||||
model: "mimo-auto",
|
||||
messages: [
|
||||
{ role: "system", content: MIMO_SYSTEM_MARKER },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
stream: false,
|
||||
};
|
||||
return proxyAwareFetch(CHAT_URL, { method: "POST", headers, body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
describe("MiMo Free bootstrap (live)", () => {
|
||||
it("bootstrap returns 200 with JWT", async () => {
|
||||
const { status, jwt } = await bootstrapWith(CHROME_UA);
|
||||
expect(status).toBe(200);
|
||||
expect(jwt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MiMo Free anti-abuse gate (live)", () => {
|
||||
it("chat WITH Chrome User-Agent → 200", async () => {
|
||||
const { jwt } = await bootstrapWith(CHROME_UA);
|
||||
const r = await chatWith(jwt, CHROME_UA);
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
});
|
||||
108
tests/unit/modality-strip.test.js
Normal file
108
tests/unit/modality-strip.test.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { stripUnsupportedModalities } from "../../open-sse/translator/concerns/modality.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
const NO_VISION = { vision: false, audioInput: true, pdf: true };
|
||||
const NO_AUDIO = { vision: true, audioInput: false, pdf: true };
|
||||
const NO_PDF = { vision: true, audioInput: true, pdf: false };
|
||||
const ALL = { vision: true, audioInput: true, pdf: true };
|
||||
|
||||
describe("stripUnsupportedModalities", () => {
|
||||
it("fast-exits when model supports all modalities", () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "x" } }] }] };
|
||||
expect(stripUnsupportedModalities(body, FORMATS.OPENAI, ALL)).toBe(false);
|
||||
expect(body.messages[0].content).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("openai: strips image when vision:false, leaves placeholder", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,xx" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_VISION);
|
||||
const types = body.messages[0].content.map((b) => b.type);
|
||||
expect(types).toContain("text");
|
||||
expect(types).not.toContain("image_url");
|
||||
expect(body.messages[0].content.some((b) => b.type === "text" && /image omitted/.test(b.text))).toBe(true);
|
||||
});
|
||||
|
||||
it("openai: strips input_audio when audioInput:false", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "input_audio", input_audio: { data: "x", format: "wav" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_AUDIO);
|
||||
expect(body.messages[0].content.some((b) => b.type === "input_audio")).toBe(false);
|
||||
expect(body.messages[0].content.some((b) => /audio omitted/.test(b.text || ""))).toBe(true);
|
||||
});
|
||||
|
||||
it("openai: strips file when pdf:false", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: "data:application/pdf;base64,x" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_PDF);
|
||||
expect(body.messages[0].content.some((b) => b.type === "file")).toBe(false);
|
||||
});
|
||||
|
||||
it("openai: keeps image when vision:true", () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "x" } }] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_AUDIO);
|
||||
expect(body.messages[0].content.some((b) => b.type === "image_url")).toBe(true);
|
||||
});
|
||||
|
||||
it("claude: strips image + document by capability", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "x" } },
|
||||
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "x" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.CLAUDE, { vision: false, audioInput: true, pdf: false });
|
||||
const types = body.messages[0].content.map((b) => b.type);
|
||||
expect(types).not.toContain("image");
|
||||
expect(types).not.toContain("document");
|
||||
expect(types).toContain("text");
|
||||
});
|
||||
|
||||
it("gemini: strips inlineData image by mime when vision:false", () => {
|
||||
const body = { contents: [{ role: "user", parts: [
|
||||
{ text: "hi" },
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.GEMINI, NO_VISION);
|
||||
expect(body.contents[0].parts.some((p) => p.inlineData)).toBe(false);
|
||||
expect(body.contents[0].parts.some((p) => /image omitted/.test(p.text || ""))).toBe(true);
|
||||
});
|
||||
|
||||
it("gemini: keeps inlineData pdf when pdf:true, strips image when vision:false", () => {
|
||||
const body = { contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
{ inlineData: { mimeType: "application/pdf", data: "y" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.GEMINI, NO_VISION);
|
||||
const mimes = body.contents[0].parts.filter((p) => p.inlineData).map((p) => p.inlineData.mimeType);
|
||||
expect(mimes).toEqual(["application/pdf"]);
|
||||
});
|
||||
|
||||
it("antigravity: strips inside request.contents", () => {
|
||||
const body = { request: { contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
] }] } };
|
||||
stripUnsupportedModalities(body, FORMATS.ANTIGRAVITY, NO_VISION);
|
||||
expect(body.request.contents[0].parts.some((p) => p.inlineData)).toBe(false);
|
||||
});
|
||||
|
||||
it("responses: strips input_image when vision:false", () => {
|
||||
const body = { input: [{ role: "user", content: [
|
||||
{ type: "input_text", text: "hi" },
|
||||
{ type: "input_image", image_url: "data:image/png;base64,x" },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI_RESPONSES, NO_VISION);
|
||||
expect(body.input[0].content.some((b) => b.type === "input_image")).toBe(false);
|
||||
expect(body.input[0].content.some((b) => b.type === "input_text" && /image omitted/.test(b.text))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles missing/empty body safely", () => {
|
||||
expect(stripUnsupportedModalities(null, FORMATS.OPENAI, NO_VISION)).toBe(false);
|
||||
expect(stripUnsupportedModalities({}, FORMATS.OPENAI, null)).toBe(false);
|
||||
expect(stripUnsupportedModalities({ messages: [] }, FORMATS.OPENAI, NO_VISION)).toBe(true);
|
||||
});
|
||||
});
|
||||
28
tests/unit/model-name-regex.test.js
Normal file
28
tests/unit/model-name-regex.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Guards C2: regex name fallback (no catalog). Terse entries derive name; existing names untouched.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { deriveModelName } from "../../open-sse/providers/models/namePatterns.js";
|
||||
import { normalizeModel } from "../../open-sse/providers/models/schema.js";
|
||||
|
||||
describe("model name regex fallback (C2)", () => {
|
||||
it("derives display name from id per family", () => {
|
||||
expect(deriveModelName("kimi-k2.5")).toBe("Kimi K2.5");
|
||||
expect(deriveModelName("glm-4.6v")).toBe("GLM 4.6V (Vision)");
|
||||
expect(deriveModelName("minimax-m2.7")).toBe("MiniMax M2.7");
|
||||
expect(deriveModelName("gpt-5.4-mini")).toBe("GPT 5.4 Mini");
|
||||
expect(deriveModelName("grok-4")).toBe("Grok 4");
|
||||
});
|
||||
|
||||
it("falls back to id verbatim when no pattern matches", () => {
|
||||
expect(deriveModelName("some-unknown-model")).toBe("some-unknown-model");
|
||||
});
|
||||
|
||||
it("normalizeModel: explicit name always wins over regex", () => {
|
||||
expect(normalizeModel({ id: "kimi-k2.5", name: "Custom" }).name).toBe("Custom");
|
||||
});
|
||||
|
||||
it("normalizeModel: terse string id becomes object with derived name", () => {
|
||||
const m = normalizeModel("glm-5");
|
||||
expect(m.id).toBe("glm-5");
|
||||
expect(m.name).toBe("GLM 5");
|
||||
});
|
||||
});
|
||||
80
tests/unit/model-routing.test.js
Normal file
80
tests/unit/model-routing.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
|
||||
async function setupDb() {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-model-routing-"));
|
||||
process.env.DATA_DIR = tempDir;
|
||||
vi.resetModules();
|
||||
|
||||
const { createProviderNode } = await import("@/models/index.js");
|
||||
const { getModelInfo } = await import("@/sse/services/model.js");
|
||||
|
||||
return {
|
||||
createProviderNode,
|
||||
getModelInfo,
|
||||
cleanup() {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("model routing", () => {
|
||||
let cleanup = () => {};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
cleanup = () => {};
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
it("keeps built-in provider aliases ahead of compatible node prefixes", async () => {
|
||||
const ctx = await setupDb();
|
||||
cleanup = ctx.cleanup;
|
||||
|
||||
await ctx.createProviderNode({
|
||||
id: "openai-compatible-chat-test",
|
||||
type: "openai-compatible",
|
||||
name: "Compatible CF Collision",
|
||||
prefix: "cf",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://compatible.test/v1",
|
||||
});
|
||||
|
||||
await expect(ctx.getModelInfo("cf/@cf/black-forest-labs/flux-2-klein-9b"))
|
||||
.resolves.toEqual({
|
||||
provider: "cloudflare-ai",
|
||||
model: "@cf/black-forest-labs/flux-2-klein-9b",
|
||||
});
|
||||
});
|
||||
|
||||
it("still routes non-reserved compatible node prefixes", async () => {
|
||||
const ctx = await setupDb();
|
||||
cleanup = ctx.cleanup;
|
||||
|
||||
await ctx.createProviderNode({
|
||||
id: "openai-compatible-chat-test",
|
||||
type: "openai-compatible",
|
||||
name: "Compatible OCT",
|
||||
prefix: "oct",
|
||||
apiType: "chat",
|
||||
baseUrl: "https://compatible.test/v1",
|
||||
});
|
||||
|
||||
await expect(ctx.getModelInfo("oct/gpt-image-1"))
|
||||
.resolves.toEqual({
|
||||
provider: "openai-compatible-chat-test",
|
||||
model: "gpt-image-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
58
tests/unit/multimodal-drop-lock.test.js
Normal file
58
tests/unit/multimodal-drop-lock.test.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// Locks multimodal quirks flagged in docs 11 §4: image_url.detail drop + input_audio per-format.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openaiToClaudeRequest } from "../../open-sse/translator/request/openai-to-claude.js";
|
||||
import { convertOpenAIContentToParts } from "../../open-sse/translator/formats/gemini.js";
|
||||
|
||||
function userImage(detail) {
|
||||
return {
|
||||
model: "claude-sonnet-4-6",
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAB", detail } },
|
||||
],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
describe("openai→claude: image_url.detail is dropped (docs 11 §4)", () => {
|
||||
it("converts image to base64 source WITHOUT a detail field", () => {
|
||||
const out = openaiToClaudeRequest("claude-sonnet-4-6", userImage("high"), false);
|
||||
const imgBlock = out.messages[0].content.find((b) => b.type === "image");
|
||||
expect(imgBlock).toBeTruthy();
|
||||
expect(imgBlock.source).toEqual({ type: "base64", media_type: "image/png", data: "AAAB" });
|
||||
expect("detail" in imgBlock).toBe(false);
|
||||
expect("detail" in imgBlock.source).toBe(false);
|
||||
});
|
||||
|
||||
it("drops input_audio entirely (claude has no audio block)", () => {
|
||||
const body = {
|
||||
model: "claude-sonnet-4-6",
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "input_audio", input_audio: { data: "ZZZ", format: "wav" } },
|
||||
] }],
|
||||
};
|
||||
const out = openaiToClaudeRequest("claude-sonnet-4-6", body, false);
|
||||
const blocks = out.messages[0].content;
|
||||
expect(blocks.some((b) => b.type === "audio" || b.type === "input_audio")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai→gemini: input_audio is mapped to inlineData (docs 11 §4)", () => {
|
||||
it("maps wav → audio/wav inlineData", () => {
|
||||
const parts = convertOpenAIContentToParts([{ type: "input_audio", input_audio: { data: "ZZZ", format: "wav" } }]);
|
||||
expect(parts).toEqual([{ inlineData: { mime_type: "audio/wav", data: "ZZZ" } }]);
|
||||
});
|
||||
|
||||
it("maps mp3 → audio/mpeg inlineData", () => {
|
||||
const parts = convertOpenAIContentToParts([{ type: "input_audio", input_audio: { data: "ZZZ", format: "mp3" } }]);
|
||||
expect(parts[0].inlineData.mime_type).toBe("audio/mpeg");
|
||||
});
|
||||
|
||||
it("drops image_url.detail (not carried into inlineData)", () => {
|
||||
const parts = convertOpenAIContentToParts([{ type: "image_url", image_url: { url: "data:image/png;base64,AAAB", detail: "high" } }]);
|
||||
expect(parts).toEqual([{ inlineData: { mime_type: "image/png", data: "AAAB" } }]);
|
||||
});
|
||||
});
|
||||
@@ -9,13 +9,13 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openaiToCommandCode } from "../../open-sse/translator/request/openai-to-commandcode.js";
|
||||
import { openaiToCommandCodeRequest } from "../../open-sse/translator/request/openai-to-commandcode.js";
|
||||
|
||||
const MODEL = "moonshotai/Kimi-K2.6";
|
||||
|
||||
describe("openaiToCommandCode — basic envelope", () => {
|
||||
describe("openaiToCommandCodeRequest — basic envelope", () => {
|
||||
it("returns the expected top-level envelope shape", () => {
|
||||
const out = openaiToCommandCode(MODEL, {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}, true);
|
||||
|
||||
@@ -28,9 +28,9 @@ describe("openaiToCommandCode — basic envelope", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCode — system handling", () => {
|
||||
describe("openaiToCommandCodeRequest — system handling", () => {
|
||||
it("hoists system messages to params.system (string), not messages[]", () => {
|
||||
const out = openaiToCommandCode(MODEL, {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: "hi" },
|
||||
@@ -44,7 +44,7 @@ describe("openaiToCommandCode — system handling", () => {
|
||||
});
|
||||
|
||||
it("joins multiple system messages with blank line", () => {
|
||||
const out = openaiToCommandCode(MODEL, {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [
|
||||
{ role: "system", content: "A" },
|
||||
{ role: "system", content: "B" },
|
||||
@@ -56,16 +56,16 @@ describe("openaiToCommandCode — system handling", () => {
|
||||
});
|
||||
|
||||
it("omits params.system when no system messages", () => {
|
||||
const out = openaiToCommandCode(MODEL, {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}, true);
|
||||
expect(out.params.system).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCode — content shape", () => {
|
||||
describe("openaiToCommandCodeRequest — content shape", () => {
|
||||
it("MUST always emit content as Array (never string) for user", () => {
|
||||
const out = openaiToCommandCode(MODEL, {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}, true);
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("openaiToCommandCode — content shape", () => {
|
||||
});
|
||||
|
||||
it("MUST always emit content as Array for assistant", () => {
|
||||
const out = openaiToCommandCode(MODEL, {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [
|
||||
{ role: "user", content: "a" },
|
||||
{ role: "assistant", content: "b" },
|
||||
@@ -87,9 +87,9 @@ describe("openaiToCommandCode — content shape", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCode — tool role / tool-result (AI SDK)", () => {
|
||||
describe("openaiToCommandCodeRequest — tool role / tool-result (AI SDK)", () => {
|
||||
it("converts role:\"tool\" to role:\"tool\" with tool-result block; output is {type:\"text\",value}", () => {
|
||||
const out = openaiToCommandCode(MODEL, {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [
|
||||
{ role: "user", content: "run X" },
|
||||
{
|
||||
@@ -113,9 +113,9 @@ describe("openaiToCommandCode — tool role / tool-result (AI SDK)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCode — assistant tool_calls / tool-call", () => {
|
||||
describe("openaiToCommandCodeRequest — assistant tool_calls / tool-call", () => {
|
||||
it("converts assistant.tool_calls[] into content blocks of type tool-call", () => {
|
||||
const out = openaiToCommandCode(MODEL, {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [
|
||||
{ role: "user", content: "go" },
|
||||
{
|
||||
@@ -138,9 +138,9 @@ describe("openaiToCommandCode — assistant tool_calls / tool-call", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCode — tools schema conversion", () => {
|
||||
describe("openaiToCommandCodeRequest — tools schema conversion", () => {
|
||||
it("converts OpenAI {type:\"function\", function:{...}} to Anthropic plain {name, input_schema}", () => {
|
||||
const out = openaiToCommandCode(MODEL, {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{
|
||||
@@ -163,7 +163,7 @@ describe("openaiToCommandCode — tools schema conversion", () => {
|
||||
});
|
||||
|
||||
it("preserves description on converted tool", () => {
|
||||
const out = openaiToCommandCode(MODEL, {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{ type: "function", function: { name: "ping", description: "Ping the server", parameters: { type: "object" } } },
|
||||
@@ -173,7 +173,7 @@ describe("openaiToCommandCode — tools schema conversion", () => {
|
||||
});
|
||||
|
||||
it("does not include tools field when input has none", () => {
|
||||
const out = openaiToCommandCode(MODEL, {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}, true);
|
||||
expect(out.params.tools).toBeUndefined();
|
||||
|
||||
@@ -2,21 +2,24 @@
|
||||
* Unit tests for open-sse/translator/request/openai-to-kiro.js
|
||||
*
|
||||
* Tests cover:
|
||||
* - buildKiroPayload() - basic message conversion
|
||||
* - openaiToKiroRequest() - basic message conversion
|
||||
* - Image forwarding fix: images in currentMessage must be included in payload
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildKiroPayload } from "../../open-sse/translator/request/openai-to-kiro.js";
|
||||
import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js";
|
||||
|
||||
describe("buildKiroPayload", () => {
|
||||
const contentOf = (result) =>
|
||||
result.conversationState.currentMessage.userInputMessage.content;
|
||||
|
||||
describe("openaiToKiroRequest", () => {
|
||||
describe("basic message conversion", () => {
|
||||
it("should convert a simple text message", () => {
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "Hello" }]
|
||||
};
|
||||
|
||||
const result = buildKiroPayload("claude-sonnet-4.6", body, true, {});
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
const currentMsg = result.conversationState.currentMessage;
|
||||
expect(currentMsg.userInputMessage.content).toContain("Hello");
|
||||
@@ -29,7 +32,7 @@ describe("buildKiroPayload", () => {
|
||||
messages: [{ role: "user", content: "No images here" }]
|
||||
};
|
||||
|
||||
const result = buildKiroPayload("claude-sonnet-4.6", body, true, {});
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
const currentMsg = result.conversationState.currentMessage;
|
||||
expect(currentMsg.userInputMessage.images).toBeUndefined();
|
||||
@@ -51,7 +54,7 @@ describe("buildKiroPayload", () => {
|
||||
]
|
||||
};
|
||||
|
||||
const result = buildKiroPayload("claude-sonnet-4.6", body, true, {});
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
const currentMsg = result.conversationState.currentMessage;
|
||||
expect(currentMsg.userInputMessage.images).toBeDefined();
|
||||
@@ -75,7 +78,7 @@ describe("buildKiroPayload", () => {
|
||||
]
|
||||
};
|
||||
|
||||
const result = buildKiroPayload("claude-sonnet-4.6", body, true, {});
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
const currentMsg = result.conversationState.currentMessage;
|
||||
expect(currentMsg.userInputMessage.images).toHaveLength(2);
|
||||
@@ -95,7 +98,7 @@ describe("buildKiroPayload", () => {
|
||||
]
|
||||
};
|
||||
|
||||
const result = buildKiroPayload("claude-sonnet-4.6", body, true, {});
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
const currentMsg = result.conversationState.currentMessage;
|
||||
expect(currentMsg.userInputMessage.images).toBeUndefined();
|
||||
@@ -115,7 +118,7 @@ describe("buildKiroPayload", () => {
|
||||
]
|
||||
};
|
||||
|
||||
const result = buildKiroPayload("claude-sonnet-4.6", body, true, {});
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
const currentMsg = result.conversationState.currentMessage;
|
||||
expect(currentMsg.userInputMessage.content).toContain("What is in this image?");
|
||||
@@ -135,7 +138,7 @@ describe("buildKiroPayload", () => {
|
||||
]
|
||||
};
|
||||
|
||||
const result = buildKiroPayload("claude-sonnet-4.6", body, true, {});
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
const currentMsg = result.conversationState.currentMessage;
|
||||
// HTTP URLs are not supported by Kiro — converted to text placeholder
|
||||
@@ -166,7 +169,7 @@ describe("buildKiroPayload", () => {
|
||||
// note: no `tools`
|
||||
};
|
||||
|
||||
const result = buildKiroPayload("claude-sonnet-4.6", body, true, {});
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
const cs = result.conversationState;
|
||||
|
||||
// No structured tool content anywhere
|
||||
@@ -201,7 +204,7 @@ describe("buildKiroPayload", () => {
|
||||
]
|
||||
};
|
||||
|
||||
const result = buildKiroPayload("claude-sonnet-4.6", body, true, {});
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
const cs = result.conversationState;
|
||||
|
||||
const allJson = JSON.stringify(cs);
|
||||
@@ -233,7 +236,7 @@ describe("buildKiroPayload", () => {
|
||||
]
|
||||
};
|
||||
|
||||
const result = buildKiroPayload("claude-sonnet-4.6", body, true, {});
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
const cs = result.conversationState;
|
||||
|
||||
// Structured tool spec carried on currentMessage
|
||||
@@ -270,7 +273,7 @@ describe("buildKiroPayload", () => {
|
||||
]
|
||||
};
|
||||
|
||||
const result = buildKiroPayload("claude-sonnet-4.6", body, true, {});
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
const cs = result.conversationState;
|
||||
const allJson = JSON.stringify(cs);
|
||||
|
||||
@@ -280,4 +283,83 @@ describe("buildKiroPayload", () => {
|
||||
expect(allJson).toContain("[Tool result: important orphaned output]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinking budget", () => {
|
||||
it("maps reasoning_effort low to max_thinking_length 1024", () => {
|
||||
const body = {
|
||||
reasoning_effort: "low",
|
||||
messages: [{ role: "user", content: "Think lightly" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>1024</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("maps reasoning_effort high to max_thinking_length 24576", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Think deeply" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("clamps reasoning_effort max to Kiro max_thinking_length 32000", () => {
|
||||
const body = {
|
||||
reasoning_effort: "max",
|
||||
messages: [{ role: "user", content: "Think as much as possible" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("clamps OpenAI Responses reasoning.effort xhigh to max_thinking_length 32000", () => {
|
||||
const body = {
|
||||
reasoning: { effort: "xhigh" },
|
||||
messages: [{ role: "user", content: "Think extra deeply" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("uses Claude thinking.budget_tokens as max_thinking_length", () => {
|
||||
const body = {
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
messages: [{ role: "user", content: "Use a fixed budget" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>4096</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("uses the default budget for synthetic -thinking models with no explicit config", () => {
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "Think by model suffix" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6-thinking", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>16000</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("does not inject thinking prefix for reasoning_effort none", () => {
|
||||
const body = {
|
||||
reasoning_effort: "none",
|
||||
messages: [{ role: "user", content: "Do not think" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).not.toContain("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(contentOf(result)).not.toContain("<max_thinking_length>");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
30
tests/unit/openai-to-ollama-malformed.test.js
Normal file
30
tests/unit/openai-to-ollama-malformed.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// A4 (case #10): malformed tool_calls args must not throw -> safeParseJSON returns {}.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openaiToOllamaRequest } from "../../open-sse/translator/request/openai-to-ollama.js";
|
||||
|
||||
function reqWith(args) {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: args } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("openaiToOllamaRequest - tool_calls arguments parsing", () => {
|
||||
it("malformed JSON args -> {} (no throw)", () => {
|
||||
let out;
|
||||
expect(() => {
|
||||
out = openaiToOllamaRequest("m", reqWith("{invalid json"), true);
|
||||
}).not.toThrow();
|
||||
expect(out.messages[0].tool_calls[0].function.arguments).toEqual({});
|
||||
});
|
||||
|
||||
it("valid JSON args -> parsed object", () => {
|
||||
const out = openaiToOllamaRequest("m", reqWith('{"a":1}'), true);
|
||||
expect(out.messages[0].tool_calls[0].function.arguments).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
71
tests/unit/opencode-go-models.test.js
Normal file
71
tests/unit/opencode-go-models.test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PROVIDER_MODELS, getModelTargetFormat } from "../../open-sse/config/providerModels.js";
|
||||
import { OpenCodeGoExecutor } from "../../open-sse/executors/opencode-go.js";
|
||||
|
||||
const CHAT_MODELS = [
|
||||
"glm-5.2",
|
||||
"glm-5.1",
|
||||
// OpenCode Go docs' endpoint table currently says kimi-k2.7, but its
|
||||
// config example and the live API use kimi-k2.7-code.
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.6",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"mimo-v2.5",
|
||||
"mimo-v2.5-pro",
|
||||
];
|
||||
|
||||
const MESSAGES_MODELS = [
|
||||
"minimax-m3",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.6-plus",
|
||||
];
|
||||
|
||||
describe("OpenCode Go official model catalog", () => {
|
||||
it("matches the documented OpenCode Go model IDs", () => {
|
||||
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((model) => model.id);
|
||||
|
||||
expect(ids).toEqual([...CHAT_MODELS, ...MESSAGES_MODELS]);
|
||||
});
|
||||
|
||||
it("marks documented Qwen and MiniMax models as Anthropic messages format", () => {
|
||||
for (const model of MESSAGES_MODELS) {
|
||||
expect(getModelTargetFormat("opencode-go", model)).toBe("claude");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps GLM, Kimi, DeepSeek, and MiMo on OpenAI-compatible chat format", () => {
|
||||
for (const model of CHAT_MODELS) {
|
||||
expect(getModelTargetFormat("opencode-go", model)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Go endpoint routing", () => {
|
||||
it("routes Qwen and MiniMax models to the messages endpoint with x-api-key auth", () => {
|
||||
const executor = new OpenCodeGoExecutor();
|
||||
|
||||
for (const model of MESSAGES_MODELS) {
|
||||
expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/messages");
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, false);
|
||||
expect(headers["x-api-key"]).toBe("sk-test");
|
||||
expect(headers["anthropic-version"]).toBeDefined();
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes GLM, Kimi, DeepSeek, and MiMo models to chat/completions with bearer auth", () => {
|
||||
const executor = new OpenCodeGoExecutor();
|
||||
|
||||
for (const model of CHAT_MODELS) {
|
||||
expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/chat/completions");
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, false);
|
||||
expect(headers.Authorization).toBe("Bearer sk-test");
|
||||
expect(headers["x-api-key"]).toBeUndefined();
|
||||
expect(headers["anthropic-version"]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
58
tests/unit/prefetch-images.test.js
Normal file
58
tests/unit/prefetch-images.test.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/translator/concerns/image.js", async (orig) => {
|
||||
const actual = await orig();
|
||||
return {
|
||||
...actual,
|
||||
fetchImageAsBase64: vi.fn(async () => ({ url: "data:image/png;base64,QUJD", mimeType: "image/png" })),
|
||||
};
|
||||
});
|
||||
|
||||
import { prefetchRemoteImages } from "../../open-sse/translator/concerns/prefetch.js";
|
||||
import { fetchImageAsBase64 } from "../../open-sse/translator/concerns/image.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
beforeEach(() => { fetchImageAsBase64.mockClear(); });
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
describe("prefetchRemoteImages", () => {
|
||||
it("no-op for targets that accept remote URLs (openai)", async () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://x/a.png" } }] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.OPENAI, FORMATS.OPENAI);
|
||||
expect(n).toBe(0);
|
||||
expect(body.messages[0].content[0].image_url.url).toBe("https://x/a.png");
|
||||
});
|
||||
|
||||
it("openai source -> ollama target: converts remote URL to base64", async () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://x/a.png" } }] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.OPENAI, FORMATS.OLLAMA);
|
||||
expect(n).toBe(1);
|
||||
expect(body.messages[0].content[0].image_url.url.startsWith("data:image/png;base64,")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips data URI (already inline)", async () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64,xx" } }] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.OPENAI, FORMATS.OLLAMA);
|
||||
expect(n).toBe(0);
|
||||
expect(fetchImageAsBase64).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gemini source -> gemini target: fileData URL -> inlineData base64", async () => {
|
||||
const body = { contents: [{ role: "user", parts: [
|
||||
{ fileData: { mimeType: "image/png", fileUri: "https://x/a.png" } },
|
||||
] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.GEMINI, FORMATS.GEMINI);
|
||||
expect(n).toBe(1);
|
||||
expect(body.contents[0].parts[0].inlineData).toBeTruthy();
|
||||
expect(body.contents[0].parts[0].fileData).toBeUndefined();
|
||||
});
|
||||
|
||||
it("claude source -> kiro target: source.url -> base64", async () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "image", source: { type: "url", url: "https://x/a.png" } },
|
||||
] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.CLAUDE, FORMATS.KIRO);
|
||||
expect(n).toBe(1);
|
||||
expect(body.messages[0].content[0].source.type).toBe("base64");
|
||||
});
|
||||
});
|
||||
84
tests/unit/provider-custom-models.test.js
Normal file
84
tests/unit/provider-custom-models.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels.js";
|
||||
|
||||
describe("provider custom model rows", () => {
|
||||
it("keeps identical model IDs separate per provider", () => {
|
||||
const customModels = [
|
||||
{ providerAlias: "ollama", id: "minimax-m2.5", type: "llm", name: "MiniMax M2.5" },
|
||||
{ providerAlias: "opencode-go", id: "minimax-m2.5", type: "llm", name: "MiniMax M2.5" },
|
||||
];
|
||||
|
||||
expect(getProviderCustomModelRows({ customModels, providerAlias: "ollama" })).toEqual([
|
||||
{
|
||||
id: "minimax-m2.5",
|
||||
name: "MiniMax M2.5",
|
||||
fullModel: "ollama/minimax-m2.5",
|
||||
source: "custom",
|
||||
type: "llm",
|
||||
},
|
||||
]);
|
||||
expect(getProviderCustomModelRows({ customModels, providerAlias: "opencode-go" })).toEqual([
|
||||
{
|
||||
id: "minimax-m2.5",
|
||||
name: "MiniMax M2.5",
|
||||
fullModel: "opencode-go/minimax-m2.5",
|
||||
source: "custom",
|
||||
type: "llm",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps legacy alias-backed models visible without duplicating custom models", () => {
|
||||
const rows = getProviderCustomModelRows({
|
||||
customModels: [
|
||||
{ providerAlias: "ollama", id: "custom-a", type: "llm", name: "Custom A" },
|
||||
],
|
||||
modelAliases: {
|
||||
"custom-a": "ollama/custom-a",
|
||||
"legacy-b": "ollama/legacy-b",
|
||||
"other-provider": "opencode-go/legacy-b",
|
||||
},
|
||||
providerAlias: "ollama",
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: "custom-a",
|
||||
name: "Custom A",
|
||||
fullModel: "ollama/custom-a",
|
||||
source: "custom",
|
||||
type: "llm",
|
||||
},
|
||||
{
|
||||
id: "legacy-b",
|
||||
alias: "legacy-b",
|
||||
fullModel: "ollama/legacy-b",
|
||||
source: "legacyAlias",
|
||||
type: "llm",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters built-in models and typed custom models", () => {
|
||||
const rows = getProviderCustomModelRows({
|
||||
customModels: [
|
||||
{ providerAlias: "ollama", id: "llama3", type: "llm", name: "Llama 3" },
|
||||
{ providerAlias: "ollama", id: "custom-image", type: "image", name: "Custom Image" },
|
||||
{ providerAlias: "ollama", id: "custom-llm", type: "llm", name: "Custom LLM" },
|
||||
],
|
||||
providerAlias: "ollama",
|
||||
builtInModels: [{ id: "llama3" }],
|
||||
type: "llm",
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: "custom-llm",
|
||||
name: "Custom LLM",
|
||||
fullModel: "ollama/custom-llm",
|
||||
source: "custom",
|
||||
type: "llm",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
34
tests/unit/provider-display-split.test.js
Normal file
34
tests/unit/provider-display-split.test.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// Guards E1: display fields live in providersDisplay.js, merged back into AI_PROVIDERS (shape unchanged).
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const DISPLAY_FIELDS = ["name", "icon", "color"];
|
||||
|
||||
describe("provider display split (E1)", () => {
|
||||
it("AI_PROVIDERS entries still carry merged display + transport", async () => {
|
||||
const { AI_PROVIDERS } = await import("../../src/shared/constants/providers.js");
|
||||
const kiro = AI_PROVIDERS.kiro;
|
||||
// display merged
|
||||
expect(kiro.name).toBe("Kiro AI");
|
||||
expect(kiro.icon).toBe("psychology_alt");
|
||||
// transport kept
|
||||
expect(kiro.id).toBe("kiro");
|
||||
expect(kiro.alias).toBe("kr");
|
||||
// transport-heavy provider keeps its config
|
||||
expect(AI_PROVIDERS.gemini.serviceKinds).toContain("tts");
|
||||
expect(AI_PROVIDERS.gemini.ttsConfig).toBeTruthy();
|
||||
});
|
||||
|
||||
it("display fields source from providersDisplay.js", async () => {
|
||||
const { PROVIDER_DISPLAY } = await import("../../src/shared/constants/providersDisplay.js");
|
||||
const { AI_PROVIDERS } = await import("../../src/shared/constants/providers.js");
|
||||
for (const f of DISPLAY_FIELDS) {
|
||||
expect(PROVIDER_DISPLAY.kiro[f]).toBe(AI_PROVIDERS.kiro[f]);
|
||||
}
|
||||
});
|
||||
|
||||
it("helpers still work after split", async () => {
|
||||
const m = await import("../../src/shared/constants/providers.js");
|
||||
expect(m.ALIAS_TO_ID.kr).toBe("kiro");
|
||||
expect(m.getProvidersByKind("tts").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { MODEL_PRICING } from "../../src/shared/constants/pricing.js";
|
||||
import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
|
||||
|
||||
describe("MiniMax-M3 pricing", () => {
|
||||
it("includes MiniMax-M3 in MODEL_PRICING", () => {
|
||||
|
||||
@@ -95,9 +95,9 @@ describe("RTK filters", () => {
|
||||
const input = makeFindOutput();
|
||||
const out = find(input);
|
||||
expect(out).toContain("55 files in 3 dirs:");
|
||||
expect(out).toContain("./src/a/ (30):");
|
||||
expect(out).toContain("./src/b/ (20):");
|
||||
expect(out).toContain("./ (5):");
|
||||
expect(out).toContain("./src/a/ (30)");
|
||||
expect(out).toContain("./src/b/ (20)");
|
||||
expect(out).toContain("./ (5)");
|
||||
expect(out.length).toBeLessThan(input.length);
|
||||
});
|
||||
|
||||
|
||||
62
tests/unit/session-manager.test.js
Normal file
62
tests/unit/session-manager.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// A2: locks resolveSessionId priority/stickiness (codex/kiro/antigravity centralization).
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { resolveSessionId, deriveSessionId, clearSessionStore } from "../../open-sse/utils/sessionManager.js";
|
||||
|
||||
// Assistant text must reach ASSISTANT_MIN_LEN (80) to use assistant anchor; else first user message.
|
||||
const longAssistant = "x".repeat(80);
|
||||
const bodyWithAssistant = { messages: [{ role: "assistant", content: longAssistant }] };
|
||||
const bodyWithUserOnly = { messages: [{ role: "user", content: "hello from first user message anchor" }] };
|
||||
|
||||
beforeEach(() => clearSessionStore());
|
||||
|
||||
describe("resolveSessionId", () => {
|
||||
it("stickiness: same body+connectionId+scope -> same id", () => {
|
||||
const opts = { body: bodyWithAssistant, connectionId: "conn1", scope: "codex" };
|
||||
expect(resolveSessionId(opts)).toBe(resolveSessionId(opts));
|
||||
});
|
||||
|
||||
it("different connectionId -> different id", () => {
|
||||
const a = resolveSessionId({ body: bodyWithAssistant, connectionId: "connA", scope: "codex" });
|
||||
const b = resolveSessionId({ body: bodyWithAssistant, connectionId: "connB", scope: "codex" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("different scope -> different id", () => {
|
||||
const a = resolveSessionId({ body: bodyWithAssistant, connectionId: "conn1", scope: "codex" });
|
||||
const b = resolveSessionId({ body: bodyWithAssistant, connectionId: "conn1", scope: "kiro" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("first user message anchor when assistant text below cap", () => {
|
||||
const opts = { body: bodyWithUserOnly, connectionId: "conn1", scope: "codex" };
|
||||
expect(resolveSessionId(opts)).toBe(resolveSessionId(opts));
|
||||
});
|
||||
|
||||
it("assistant anchor wins once assistant text reaches cap", () => {
|
||||
const shortAssistant = { messages: [{ role: "user", content: "same user" }, { role: "assistant", content: "y".repeat(80) }] };
|
||||
const a = resolveSessionId({ body: shortAssistant, connectionId: "conn1", scope: "codex" });
|
||||
const b = resolveSessionId({ body: shortAssistant, connectionId: "conn1", scope: "codex" });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("fallback: empty body+no header+no workspaceId -> deriveSessionId(connectionId)", () => {
|
||||
const got = resolveSessionId({ body: {}, connectionId: "connFallback" });
|
||||
expect(got).toBe(deriveSessionId("connFallback"));
|
||||
});
|
||||
|
||||
it("client override: x-session-id header wins, skips later steps", () => {
|
||||
const got = resolveSessionId({
|
||||
headers: { "x-session-id": "client-sess-123" },
|
||||
body: bodyWithAssistant,
|
||||
connectionId: "conn1",
|
||||
workspaceId: "ws1",
|
||||
scope: "codex",
|
||||
});
|
||||
expect(got).toBe("client-sess-123");
|
||||
});
|
||||
|
||||
it("workspaceId path: empty body + workspaceId set -> normalized workspaceId", () => {
|
||||
const got = resolveSessionId({ body: {}, connectionId: "conn1", workspaceId: "ws-abc" });
|
||||
expect(got).toBe("ws-abc");
|
||||
});
|
||||
});
|
||||
22
tests/unit/token-refresh-dispatch.test.js
Normal file
22
tests/unit/token-refresh-dispatch.test.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// Guards the refactored REFRESH_HANDLERS dispatch: null-guards + the two different defaults.
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const load = () => import("../../open-sse/services/tokenRefresh.js");
|
||||
|
||||
describe("tokenRefresh dispatch", () => {
|
||||
it("getAccessToken returns null for missing/invalid refreshToken", async () => {
|
||||
const mod = await load();
|
||||
expect(await mod.getAccessToken("claude", {}, null)).toBeNull();
|
||||
expect(await mod.getAccessToken("claude", { refreshToken: 123 }, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("getAccessToken default: unsupported provider → null", async () => {
|
||||
const mod = await load();
|
||||
expect(await mod.getAccessToken("totally-unknown", { refreshToken: "x" }, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshTokenByProvider returns null without refreshToken", async () => {
|
||||
const mod = await load();
|
||||
expect(await mod.refreshTokenByProvider("claude", {}, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
47
tests/unit/translator-helpers-edge.test.js
Normal file
47
tests/unit/translator-helpers-edge.test.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Locks edge cases flagged in docs 11 §1/§4 that were only covered indirectly.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeClaudePassthrough } from "../../open-sse/translator/formats/claude.js";
|
||||
import { parseDataUri, encodeDataUri } from "../../open-sse/translator/concerns/image.js";
|
||||
|
||||
describe("normalizeClaudePassthrough — haiku adaptive thinking (docs 11 §1)", () => {
|
||||
it("downgrades adaptive thinking to enabled+budget for haiku models", () => {
|
||||
const out = normalizeClaudePassthrough({ thinking: { type: "adaptive" } }, "claude-haiku-4-5");
|
||||
expect(out.thinking).toEqual({ type: "enabled", budget_tokens: 10000 });
|
||||
});
|
||||
|
||||
it("keeps adaptive thinking for sonnet/opus", () => {
|
||||
const out = normalizeClaudePassthrough({ thinking: { type: "adaptive" } }, "claude-sonnet-4-6");
|
||||
expect(out.thinking).toEqual({ type: "adaptive" });
|
||||
});
|
||||
|
||||
it("hoists mid-conversation system messages into top-level system", () => {
|
||||
const out = normalizeClaudePassthrough({
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "system", content: "be brief" },
|
||||
],
|
||||
});
|
||||
expect(out.system).toEqual([{ type: "text", text: "be brief" }]);
|
||||
expect(out.messages.every((m) => m.role !== "system")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDataUri / encodeDataUri (docs 11 §4)", () => {
|
||||
it("parses a base64 data uri", () => {
|
||||
expect(parseDataUri("data:image/png;base64,AAAB")).toEqual({ mimeType: "image/png", base64: "AAAB" });
|
||||
});
|
||||
|
||||
it("tolerates newlines inside base64 payload", () => {
|
||||
expect(parseDataUri("data:image/jpeg;base64,AA\nBB")?.base64).toBe("AA\nBB");
|
||||
});
|
||||
|
||||
it("returns null for http urls and non-strings", () => {
|
||||
expect(parseDataUri("https://x/y.png")).toBeNull();
|
||||
expect(parseDataUri(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("encode/parse roundtrip", () => {
|
||||
const uri = encodeDataUri("image/webp", "ZZZ");
|
||||
expect(parseDataUri(uri)).toEqual({ mimeType: "image/webp", base64: "ZZZ" });
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
import { translateRequest } from "../../open-sse/translator/index.js";
|
||||
import { claudeToOpenAIRequest } from "../../open-sse/translator/request/claude-to-openai.js";
|
||||
import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.js";
|
||||
import { filterToOpenAIFormat } from "../../open-sse/translator/formats/openai.js";
|
||||
import { parseSSELine } from "../../open-sse/utils/streamHelpers.js";
|
||||
|
||||
describe("request normalization", () => {
|
||||
|
||||
69
tests/unit/usage-concern.test.js
Normal file
69
tests/unit/usage-concern.test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// A3: locks toOpenAIUsage per-provider token math (claude/gemini/kiro/ollama/commandcode).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { toOpenAIUsage } from "../../open-sse/translator/concerns/usage.js";
|
||||
|
||||
describe("toOpenAIUsage", () => {
|
||||
it("claude: folds cache read+create into prompt, exposes details", () => {
|
||||
const u = toOpenAIUsage(
|
||||
{ input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 30, cache_creation_input_tokens: 10 },
|
||||
"claude"
|
||||
);
|
||||
expect(u.prompt_tokens).toBe(140);
|
||||
expect(u.completion_tokens).toBe(20);
|
||||
expect(u.total_tokens).toBe(160);
|
||||
expect(u.prompt_tokens_details.cached_tokens).toBe(30);
|
||||
expect(u.prompt_tokens_details.cache_creation_tokens).toBe(10);
|
||||
});
|
||||
|
||||
it("claude: no cache -> no prompt_tokens_details", () => {
|
||||
const u = toOpenAIUsage({ input_tokens: 50, output_tokens: 5 }, "claude");
|
||||
expect(u.prompt_tokens).toBe(50);
|
||||
expect(u.prompt_tokens_details).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gemini: full fields, completion = candidates + thoughts", () => {
|
||||
const u = toOpenAIUsage(
|
||||
{ promptTokenCount: 100, candidatesTokenCount: 40, thoughtsTokenCount: 10, totalTokenCount: 150 },
|
||||
"gemini"
|
||||
);
|
||||
expect(u.prompt_tokens).toBe(100);
|
||||
expect(u.completion_tokens).toBe(50);
|
||||
expect(u.total_tokens).toBe(150);
|
||||
expect(u.completion_tokens_details.reasoning_tokens).toBe(10);
|
||||
});
|
||||
|
||||
it("gemini fallback: candidates=0 -> derive from total - prompt - thoughts", () => {
|
||||
const u = toOpenAIUsage(
|
||||
{ promptTokenCount: 100, candidatesTokenCount: 0, thoughtsTokenCount: 10, totalTokenCount: 150 },
|
||||
"gemini"
|
||||
);
|
||||
// candidates derived = 150 - 100 - 10 = 40 ; completion = 40 + 10
|
||||
expect(u.completion_tokens).toBe(50);
|
||||
});
|
||||
|
||||
it("kiro: input/output straight", () => {
|
||||
const u = toOpenAIUsage({ inputTokens: 12, outputTokens: 3 }, "kiro");
|
||||
expect(u.prompt_tokens).toBe(12);
|
||||
expect(u.completion_tokens).toBe(3);
|
||||
expect(u.total_tokens).toBe(15);
|
||||
});
|
||||
|
||||
it("ollama: prompt_eval_count/eval_count", () => {
|
||||
const u = toOpenAIUsage({ prompt_eval_count: 7, eval_count: 4 }, "ollama");
|
||||
expect(u.prompt_tokens).toBe(7);
|
||||
expect(u.completion_tokens).toBe(4);
|
||||
expect(u.total_tokens).toBe(11);
|
||||
});
|
||||
|
||||
it("commandcode: keeps totalTokens fallback", () => {
|
||||
const u = toOpenAIUsage({ inputTokens: 8, outputTokens: 2, totalTokens: 99 }, "commandcode");
|
||||
expect(u.prompt_tokens).toBe(8);
|
||||
expect(u.completion_tokens).toBe(2);
|
||||
expect(u.total_tokens).toBe(99);
|
||||
});
|
||||
|
||||
it("unknown kind / null raw -> null", () => {
|
||||
expect(toOpenAIUsage({}, "nope")).toBeNull();
|
||||
expect(toOpenAIUsage(null, "claude")).toBeNull();
|
||||
});
|
||||
});
|
||||
39
tests/unit/usage-dispatch.test.js
Normal file
39
tests/unit/usage-dispatch.test.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Guards the refactored USAGE_HANDLERS dispatch: unsupported → message, supported → routed.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Stub network so handlers don't hit real APIs; each call resolves an empty 200.
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
text: async () => "{}",
|
||||
})),
|
||||
}));
|
||||
|
||||
const load = () => import("../../open-sse/services/usage.js");
|
||||
const SUPPORTED = [
|
||||
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
|
||||
"qoder", "qwen", "iflow", "ollama", "glm", "glm-cn",
|
||||
"minimax", "minimax-cn", "vercel-ai-gateway",
|
||||
];
|
||||
|
||||
describe("usage dispatch", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("unsupported provider → not-implemented message", async () => {
|
||||
const { getUsageForProvider } = await load();
|
||||
const res = await getUsageForProvider({ provider: "totally-unknown" });
|
||||
expect(res).toEqual({ message: "Usage API not implemented for totally-unknown" });
|
||||
});
|
||||
|
||||
it("every supported provider routes to its handler (no fallback message)", async () => {
|
||||
const { getUsageForProvider } = await load();
|
||||
for (const provider of SUPPORTED) {
|
||||
const res = await getUsageForProvider({ provider, accessToken: "t", apiKey: "k" });
|
||||
// Routed handler must return an object and never the unsupported fallback
|
||||
expect(res, `${provider} routed`).toBeTypeOf("object");
|
||||
expect(res?.message).not.toBe(`Usage API not implemented for ${provider}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user