Merge origin/master (v0.5.91) into gitea/new_feature
Resolve conflicts: - streamingHandler.js: adopt upstreamResponseHeaders while keeping 0-token detail row avoidance - capabilities.js: preserve user-asserted caps and globalThis slots without local caching of catalogSource - AddCustomModelModal.js & providers/[id]/page.js: wire STT transport marker with custom model edits/assertions - models/custom/route.js & aliasRepo.js: persist custom model transport and invalidate user caps - usageRepo.js: key byApiKey live stats by full API key and keep tail in maskApiKey - UsageStats.js: lazy load charts dynamically
This commit is contained in:
137
tests/unit/aggregator-providers-batch.test.js
Normal file
137
tests/unit/aggregator-providers-batch.test.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import REGISTRY from "../../open-sse/providers/registry/index.js";
|
||||
import { PROVIDERS } from "../../open-sse/providers/index.js";
|
||||
import { getExecutor } from "../../open-sse/executors/index.js";
|
||||
import { DefaultExecutor } from "../../open-sse/executors/default.js";
|
||||
|
||||
/**
|
||||
* OpenAI-compatible aggregator providers. Each was verified by probing the
|
||||
* live /v1/models endpoint: a 401 with a structured error body confirms a
|
||||
* real API behind the host, and Dahl/Kira answer 200 with no credentials.
|
||||
*/
|
||||
const BATCH = [
|
||||
{
|
||||
id: "dahl",
|
||||
category: "apikey",
|
||||
baseUrl: "https://inference.dahl.global/v1/chat/completions",
|
||||
modelsUrl: "https://inference.dahl.global/v1/models",
|
||||
aliases: ["dahl-inference"],
|
||||
},
|
||||
{
|
||||
id: "atria",
|
||||
category: "apikey",
|
||||
baseUrl: "https://api.atria-asi.ai/v1/chat/completions",
|
||||
modelsUrl: "https://api.atria-asi.ai/v1/models",
|
||||
aliases: ["atria-asi"],
|
||||
},
|
||||
{
|
||||
id: "agnes",
|
||||
category: "freeTier",
|
||||
baseUrl: "https://apihub.agnes-ai.com/v1/chat/completions",
|
||||
modelsUrl: "https://apihub.agnes-ai.com/v1/models",
|
||||
aliases: ["agnes-ai"],
|
||||
},
|
||||
{
|
||||
id: "bai",
|
||||
category: "apikey",
|
||||
baseUrl: "https://api.b.ai/v1/chat/completions",
|
||||
modelsUrl: "https://api.b.ai/v1/models",
|
||||
aliases: ["b-ai"],
|
||||
},
|
||||
];
|
||||
|
||||
describe.each(BATCH)("$id provider", (p) => {
|
||||
const entry = REGISTRY.find((e) => e.id === p.id);
|
||||
|
||||
it("is registered with the expected category and base URL", () => {
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.category).toBe(p.category);
|
||||
expect(PROVIDERS[p.id].baseUrl).toBe(p.baseUrl);
|
||||
expect(PROVIDERS[p.id].format).toBe("openai");
|
||||
});
|
||||
|
||||
it("exposes its aliases and a UI display name", () => {
|
||||
for (const a of p.aliases) expect(entry.aliases).toContain(a);
|
||||
expect(entry.display?.name).toBeTruthy();
|
||||
expect(entry.display?.textIcon).toBeTruthy();
|
||||
});
|
||||
|
||||
it("routes through the shared DefaultExecutor", () => {
|
||||
expect(getExecutor(p.id)).toBeInstanceOf(DefaultExecutor);
|
||||
});
|
||||
|
||||
it("accepts arbitrary model ids via passthrough", () => {
|
||||
expect(entry.passthroughModels).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Atria Dawn specifics", () => {
|
||||
const entry = REGISTRY.find((e) => e.id === "atria");
|
||||
|
||||
it("is named after the service, not just the host", () => {
|
||||
expect(entry.display.name).toBe("Atria Dawn");
|
||||
});
|
||||
|
||||
it("pins the single documented preview model", () => {
|
||||
expect(entry.models.map((m) => m.id)).toEqual(["Atria-Dawn-Preview"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider icons", () => {
|
||||
// This file lives in tests/unit/, so resolve icons against the repo root.
|
||||
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
it("ships a /public/providers/{id}.png for every provider in the batch", () => {
|
||||
// getProviderIconSrc() resolves /providers/{id}.png and falls back to the
|
||||
// textIcon tile when the file 404s, so a missing icon is silent in the UI.
|
||||
for (const p of BATCH.map((x) => x.id)) {
|
||||
expect(existsSync(join(REPO_ROOT, "public", "providers", `${p}.png`))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticated model discovery", () => {
|
||||
const ROUTE = join(
|
||||
dirname(fileURLToPath(import.meta.url)), "..", "..",
|
||||
"src", "app", "api", "providers", "[id]", "models", "route.js"
|
||||
);
|
||||
const source = readFileSync(ROUTE, "utf8");
|
||||
|
||||
it("registers every batch provider in the /models resolver", () => {
|
||||
// Without an entry here the route answers
|
||||
// 400 "Provider X does not support models listing" and live discovery
|
||||
// silently fails once a key is saved.
|
||||
for (const p of BATCH.map((x) => x.id)) {
|
||||
expect(source).toContain(`${p}: createOpenAIModelsConfig(`);
|
||||
}
|
||||
});
|
||||
|
||||
it("points each entry at that provider's own /models URL", () => {
|
||||
for (const p of BATCH) {
|
||||
const line = source.split("\n").find((l) => l.trim().startsWith(`${p.id}: createOpenAIModelsConfig(`));
|
||||
expect(line, `no models entry for ${p.id}`).toBeTruthy();
|
||||
expect(line).toContain(p.modelsUrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("batch invariants", () => {
|
||||
it("keeps every registry id unique", () => {
|
||||
const ids = REGISTRY.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("does not claim vision for any provider in the batch", () => {
|
||||
// These aggregators relay third-party models; none of them has documented
|
||||
// image input, so no registry entry may assert it.
|
||||
for (const p of BATCH) {
|
||||
const entry = REGISTRY.find((e) => e.id === p.id);
|
||||
expect(entry.serviceKinds ?? ["llm"]).toContain("llm");
|
||||
expect(entry.imageToTextConfig).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
80
tests/unit/anthropic-gateway-passthrough.test.js
Normal file
80
tests/unit/anthropic-gateway-passthrough.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { mergeAnthropicBeta } from "open-sse/providers/shared.js";
|
||||
import { upstreamResponseHeaders } from "open-sse/utils/upstreamHeaders.js";
|
||||
import { createErrorResult, unavailableResponse } from "open-sse/utils/error.js";
|
||||
|
||||
const betaFlags = (headers) => (headers["Anthropic-Beta"] || "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
describe("mergeAnthropicBeta", () => {
|
||||
it("unions and dedupes comma lists, ignoring blanks", () => {
|
||||
expect(mergeAnthropicBeta("a,b", " b , c ,", undefined, "")).toBe("a,b,c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefaultExecutor.buildHeaders() forwards client anthropic-beta", () => {
|
||||
let DefaultExecutor;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ DefaultExecutor } = await import("open-sse/executors/default.js"));
|
||||
});
|
||||
|
||||
it("keeps unknown client flags alongside the pinned set on claude", () => {
|
||||
const executor = new DefaultExecutor("claude");
|
||||
const rawHeaders = { "anthropic-beta": "safeguards-2026-09-01,context-1m-2025-08-07" };
|
||||
const flags = betaFlags(executor.buildHeaders({ apiKey: "k", rawHeaders }, true, undefined, "claude-opus-5"));
|
||||
expect(flags).toContain("safeguards-2026-09-01");
|
||||
expect(flags).toContain("context-1m-2025-08-07");
|
||||
expect(flags).toContain("context-management-2025-06-27");
|
||||
expect(new Set(flags).size).toBe(flags.length);
|
||||
});
|
||||
|
||||
it("forwards client flags on anthropic-compatible Claude models", () => {
|
||||
const executor = new DefaultExecutor("anthropic-compatible-custom");
|
||||
const creds = { apiKey: "k", rawHeaders: { "anthropic-beta": "safeguards-2026-09-01" }, providerSpecificData: { baseUrl: "https://gw.example.com/v1" } };
|
||||
const flags = betaFlags(executor.buildHeaders(creds, true, undefined, "claude-sonnet-5"));
|
||||
expect(flags).toContain("safeguards-2026-09-01");
|
||||
expect(flags).not.toContain("claude-code-20250219");
|
||||
});
|
||||
|
||||
it("forwards client flags on the anthropic provider", () => {
|
||||
const executor = new DefaultExecutor("anthropic");
|
||||
const flags = betaFlags(executor.buildHeaders({ apiKey: "k", rawHeaders: { "anthropic-beta": "safeguards-2026-09-01" } }, true, undefined, "claude-sonnet-5"));
|
||||
expect(flags).toContain("safeguards-2026-09-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upstream response header forwarding", () => {
|
||||
const upstream = new Headers({
|
||||
"retry-after": "12",
|
||||
"x-should-retry": "false",
|
||||
"anthropic-ratelimit-unified-status": "rejected",
|
||||
"anthropic-ratelimit-unified-reset": "1790000000",
|
||||
"set-cookie": "secret=1",
|
||||
"content-length": "99",
|
||||
});
|
||||
|
||||
it("picks only retry and ratelimit headers", () => {
|
||||
expect(upstreamResponseHeaders(upstream)).toEqual({
|
||||
"retry-after": "12",
|
||||
"x-should-retry": "false",
|
||||
"anthropic-ratelimit-unified-status": "rejected",
|
||||
"anthropic-ratelimit-unified-reset": "1790000000",
|
||||
});
|
||||
expect(upstreamResponseHeaders(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("attaches them to error results", () => {
|
||||
const { response } = createErrorResult(429, "limited", undefined, upstreamResponseHeaders(upstream));
|
||||
expect(response.headers.get("x-should-retry")).toBe("false");
|
||||
expect(response.headers.get("anthropic-ratelimit-unified-status")).toBe("rejected");
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the gateway retry-after on all-accounts-limited responses", () => {
|
||||
const retryAt = new Date(Date.now() + 30000).toISOString();
|
||||
const res = unavailableResponse(503, "busy", retryAt, "30s", upstreamResponseHeaders(upstream));
|
||||
expect(Number(res.headers.get("retry-after"))).toBeGreaterThan(20);
|
||||
expect(res.headers.get("anthropic-ratelimit-unified-reset")).toBe("1790000000");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import { CLAUDE_TOOL_SUFFIX } from "../../open-sse/config/appConstants.js";
|
||||
|
||||
it("advertises a Claude Code version accepted by Fable 5.1", () => {
|
||||
const body = applyCloaking({ messages: [] }, "sk-ant-oat-test", "session-id");
|
||||
expect(body.system[0].text).toMatch(/^x-anthropic-billing-header: cc_version=2.1.258\./);
|
||||
expect(body.system[0].text).toMatch(/^x-anthropic-billing-header: cc_version=2.1.280\./);
|
||||
});
|
||||
|
||||
describe("cloakClaudeTools", () => {
|
||||
@@ -117,7 +117,8 @@ describe("decloakStreamChunk", () => {
|
||||
|
||||
it("tolerates null chunks and missing maps (stream flush path)", () => {
|
||||
expect(decloakStreamChunk(null, toolNameMap)).toBeNull();
|
||||
expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), null).content_block.name).toBe("run_code" + CLAUDE_TOOL_SUFFIX);
|
||||
expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), new Map()).content_block.name).toBe("run_code" + CLAUDE_TOOL_SUFFIX);
|
||||
expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), null).content_block.name).toBe("run_code");
|
||||
expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), new Map()).content_block.name).toBe("run_code");
|
||||
expect(decloakStreamChunk(toolUseStart("uncloaked_tool"), null).content_block.name).toBe("uncloaked_tool");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => {
|
||||
headers["Anthropic-Version"] === "2023-06-01" ||
|
||||
headers["anthropic-version"] === "2023-06-01";
|
||||
expect(hasVersion).toBe(true);
|
||||
expect(headers["User-Agent"]).toBe("claude-cli/2.1.258 (external, sdk-cli)");
|
||||
expect(headers["User-Agent"]).toBe("claude-cli/2.1.280 (external, sdk-cli)");
|
||||
});
|
||||
|
||||
it("includes heavy-agent beta flags for claude-opus-5", () => {
|
||||
@@ -95,6 +95,38 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => {
|
||||
const executor = new DefaultExecutor("claude");
|
||||
expect(() => executor.buildHeaders({ apiKey: "sk" }, false)).not.toThrow();
|
||||
});
|
||||
|
||||
it("sets x-claude-code-session-id from metadata.user_id on Claude OAuth", () => {
|
||||
const executor = new DefaultExecutor("claude");
|
||||
const headers = executor.buildHeaders(
|
||||
{ accessToken: "sk-ant-oat-test-token" },
|
||||
true,
|
||||
undefined,
|
||||
"claude-opus-5",
|
||||
{
|
||||
metadata: {
|
||||
user_id: '{"device_id":"d","account_uuid":"a","session_id":"sess-abc"}',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(headers["x-claude-code-session-id"]).toBe("sess-abc");
|
||||
});
|
||||
|
||||
it("omits x-claude-code-session-id for non-OAuth API keys", () => {
|
||||
const executor = new DefaultExecutor("claude");
|
||||
const headers = executor.buildHeaders(
|
||||
{ apiKey: "sk-ant-api03-xxx" },
|
||||
true,
|
||||
undefined,
|
||||
"claude-opus-5",
|
||||
{
|
||||
metadata: {
|
||||
user_id: '{"device_id":"d","account_uuid":"a","session_id":"sess-abc"}',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(headers["x-claude-code-session-id"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── anthropic-compatible header stripping ────────────────────────────────────
|
||||
|
||||
23
tests/unit/claude-reset-grants.test.js
Normal file
23
tests/unit/claude-reset-grants.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseClaudeResetGrants } from "../../open-sse/services/usage/claude.js";
|
||||
|
||||
describe("parseClaudeResetGrants", () => {
|
||||
it("sums usable grants and picks next_grant_id", () => {
|
||||
const r = parseClaudeResetGrants({
|
||||
eligible: true,
|
||||
next_grant_id: "g2",
|
||||
grants: [
|
||||
{ id: "g1", resets_left: 1, ends_at: "2026-10-01T00:00:00Z" },
|
||||
{ id: "g2", resets_left: 2, ends_at: "2026-10-22T00:00:00Z", clears: ["five_hour", "seven_day"] },
|
||||
{ id: "g3", resets_left: 5, paused: true },
|
||||
],
|
||||
});
|
||||
expect(r).toMatchObject({ availableCount: 3, nextGrantId: "g2", expiresAt: "2026-10-22T00:00:00Z" });
|
||||
expect(r.grants.map((g) => g.id)).toEqual(["g1", "g2", "g3"]); // modal lists paused too
|
||||
expect(r.grants[1].clears).toEqual(["five_hour", "seven_day"]);
|
||||
});
|
||||
it("returns null when ineligible or missing", () => {
|
||||
expect(parseClaudeResetGrants(undefined)).toBeNull();
|
||||
expect(parseClaudeResetGrants({ eligible: false, grants: [] })).toBeNull();
|
||||
});
|
||||
});
|
||||
175
tests/unit/claude-thinking-stream-boundaries.test.js
Normal file
175
tests/unit/claude-thinking-stream-boundaries.test.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// Thinking/answer boundaries across the OpenAI pivot.
|
||||
//
|
||||
// claude-to-openai used to mark a Claude thinking block with literal "<think>" /
|
||||
// "</think>" chunks in delta.content while the thinking text itself went out in
|
||||
// reasoning_content. The pair always arrived empty and adjacent, so OpenAI-format
|
||||
// clients (opencode, DeepSeek Harness, ...) rendered a bare "<think></think>" above
|
||||
// every answer (#3399, #4199).
|
||||
//
|
||||
// The Responses translators leaned on that "</think>" marker as their only signal
|
||||
// to close the reasoning item before the answer. Dropping the marker therefore
|
||||
// requires closing reasoning when the first message text or tool call arrives —
|
||||
// which also fixes item ordering for every reasoning_content provider (DeepSeek,
|
||||
// GLM, Qwen, Kimi), not just Claude.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { claudeToOpenAIResponse } from "../../open-sse/translator/response/claude-to-openai.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
import { createSSETransformStreamWithLogger } from "../../open-sse/utils/stream.js";
|
||||
import { createResponsesApiTransformStream } from "../../open-sse/transformer/responsesTransformer.js";
|
||||
|
||||
const THINKING = "391 factors as 17 times 23, so it's not prime.";
|
||||
const ANSWER = "No — 391 = 17 × 23.";
|
||||
|
||||
function claudeThinkingStream({ thinkingText = THINKING, answer = ANSWER } = {}) {
|
||||
const thinkingDeltas = thinkingText
|
||||
? [{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: thinkingText } }]
|
||||
: [];
|
||||
return [
|
||||
{ type: "message_start", message: { id: "msg_1", model: "claude-opus-5", role: "assistant", content: [], usage: { input_tokens: 10, output_tokens: 0 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "", signature: "" } },
|
||||
...thinkingDeltas,
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_abc" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: answer } },
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 20 } },
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
}
|
||||
|
||||
function runClaudeToOpenAI(events) {
|
||||
const state = {};
|
||||
const out = [];
|
||||
for (const ev of events) {
|
||||
const r = claudeToOpenAIResponse(ev, state);
|
||||
if (Array.isArray(r)) out.push(...r);
|
||||
else if (r) out.push(r);
|
||||
}
|
||||
const deltas = out.map((c) => c.choices?.[0]?.delta || {});
|
||||
return {
|
||||
content: deltas.map((d) => d.content || "").join(""),
|
||||
reasoning: deltas.map((d) => d.reasoning_content || "").join(""),
|
||||
contentChunks: deltas.map((d) => d.content).filter((c) => c != null),
|
||||
};
|
||||
}
|
||||
|
||||
async function drain(stream) {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += typeof value === "string" ? value : decoder.decode(value, { stream: true });
|
||||
}
|
||||
return text + decoder.decode();
|
||||
}
|
||||
|
||||
function sseStream(chunks) {
|
||||
const encoder = new TextEncoder();
|
||||
const body = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("") + "data: [DONE]\n\n";
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(body));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Upstream speaks `upstream`, client speaks the Responses API.
|
||||
async function viaResponsesTranslator(chunks, upstream, provider, model) {
|
||||
const out = sseStream(chunks).pipeThrough(
|
||||
createSSETransformStreamWithLogger(upstream, FORMATS.OPENAI_RESPONSES, provider, null, null, model),
|
||||
);
|
||||
return parseEvents(await drain(out));
|
||||
}
|
||||
|
||||
async function viaResponsesTransformer(chunks) {
|
||||
return parseEvents(await drain(sseStream(chunks).pipeThrough(createResponsesApiTransformStream(null))));
|
||||
}
|
||||
|
||||
function parseEvents(text) {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("data: ") && l.slice(6).trim() !== "[DONE]")
|
||||
.map((l) => {
|
||||
try { return JSON.parse(l.slice(6)); } catch { return null; }
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Index of the event announcing/finishing an output item of the given type.
|
||||
function itemEventIndex(events, eventType, itemType) {
|
||||
return events.findIndex((e) => e.type === eventType && e.item?.type === itemType);
|
||||
}
|
||||
|
||||
function expectReasoningClosedBefore(events, nextItemType) {
|
||||
const reasoningDone = itemEventIndex(events, "response.output_item.done", "reasoning");
|
||||
const nextAdded = itemEventIndex(events, "response.output_item.added", nextItemType);
|
||||
expect(reasoningDone).toBeGreaterThanOrEqual(0);
|
||||
expect(nextAdded).toBeGreaterThanOrEqual(0);
|
||||
expect(reasoningDone).toBeLessThan(nextAdded);
|
||||
}
|
||||
|
||||
describe("claude-to-openai: thinking never leaks markers into content", () => {
|
||||
it("summarized thinking goes to reasoning_content, answer to content, no <think> text", () => {
|
||||
const { content, reasoning, contentChunks } = runClaudeToOpenAI(claudeThinkingStream());
|
||||
expect(reasoning).toBe(THINKING);
|
||||
expect(content).toBe(ANSWER);
|
||||
expect(contentChunks.some((c) => c.includes("<think>") || c.includes("</think>"))).toBe(false);
|
||||
});
|
||||
|
||||
it("signature-only (redacted) thinking yields no stray markers", () => {
|
||||
const { content, reasoning } = runClaudeToOpenAI(claudeThinkingStream({ thinkingText: "" }));
|
||||
expect(reasoning).toBe("");
|
||||
expect(content).toBe(ANSWER);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Responses translator: reasoning closes before the answer", () => {
|
||||
it("Claude upstream: reasoning item is done before the message item opens", async () => {
|
||||
const events = await viaResponsesTranslator(claudeThinkingStream(), FORMATS.CLAUDE, "claude", "claude-opus-5");
|
||||
expectReasoningClosedBefore(events, "message");
|
||||
const summary = events.find((e) => e.type === "response.reasoning_summary_text.done");
|
||||
expect(summary?.text).toBe(THINKING);
|
||||
});
|
||||
|
||||
it("reasoning_content upstream: reasoning item is done before the message item opens", async () => {
|
||||
const events = await viaResponsesTranslator([
|
||||
{ id: "c1", choices: [{ index: 0, delta: { role: "assistant", reasoning_content: THINKING } }] },
|
||||
{ id: "c1", choices: [{ index: 0, delta: { content: ANSWER } }] },
|
||||
{ id: "c1", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] },
|
||||
], FORMATS.OPENAI, "deepseek", "deepseek-flash");
|
||||
expectReasoningClosedBefore(events, "message");
|
||||
});
|
||||
|
||||
it("reasoning_content upstream: reasoning item is done before a tool call opens", async () => {
|
||||
const events = await viaResponsesTranslator([
|
||||
{ id: "c2", choices: [{ index: 0, delta: { role: "assistant", reasoning_content: THINKING } }] },
|
||||
{ id: "c2", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }] } }] },
|
||||
{ id: "c2", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
|
||||
], FORMATS.OPENAI, "deepseek", "deepseek-flash");
|
||||
expectReasoningClosedBefore(events, "function_call");
|
||||
});
|
||||
});
|
||||
|
||||
describe("responsesTransformer (/v1/responses handler): reasoning closes before the answer", () => {
|
||||
it("reasoning item is done before the message item opens", async () => {
|
||||
const events = await viaResponsesTransformer([
|
||||
{ id: "c3", choices: [{ index: 0, delta: { role: "assistant", reasoning_content: THINKING } }] },
|
||||
{ id: "c3", choices: [{ index: 0, delta: { content: ANSWER } }] },
|
||||
{ id: "c3", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] },
|
||||
]);
|
||||
expectReasoningClosedBefore(events, "message");
|
||||
});
|
||||
|
||||
it("reasoning item is done before a tool call opens", async () => {
|
||||
const events = await viaResponsesTransformer([
|
||||
{ id: "c4", choices: [{ index: 0, delta: { role: "assistant", reasoning_content: THINKING } }] },
|
||||
{ id: "c4", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_2", type: "function", function: { name: "lookup", arguments: "{}" } }] } }] },
|
||||
{ id: "c4", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
|
||||
]);
|
||||
expectReasoningClosedBefore(events, "function_call");
|
||||
});
|
||||
});
|
||||
125
tests/unit/cline-free-tier-models.test.js
Normal file
125
tests/unit/cline-free-tier-models.test.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// Cline's free tier lives in the `cline-free/` namespace and is published only
|
||||
// by the recommended-models feed, not by /api/v1/models. These tests pin that
|
||||
// resolveClineModels() merges the feed's `free[]` into its catalog so the free
|
||||
// models reach /v1/models and the dashboard picker.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const MODELS_URL = "https://api.cline.bot/api/v1/models";
|
||||
const FEED_URL = "https://api.cline.bot/api/v1/ai/cline/recommended-models";
|
||||
|
||||
const MODELS_RESPONSE = [
|
||||
{ id: "meta/muse-spark-1.3-contributor" },
|
||||
{ id: "deepseek/deepseek-v4.1-flash" },
|
||||
{ id: "stealth/space-bunny-alpha" },
|
||||
];
|
||||
|
||||
const FEED_RESPONSE = {
|
||||
recommended: [{ id: "anthropic/claude-opus-5", name: "Claude Opus 5", description: "", tags: ["NEW"] }],
|
||||
free: [
|
||||
{ id: "stealth/space-bunny-alpha", name: "Space Bunny Alpha", description: "", tags: [] },
|
||||
{ id: "cline-free/muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", description: "", tags: [] },
|
||||
{ id: "cline-free/deepseek-v4.1-flash", name: "Deepseek V4.1 Flash", description: "", tags: [] },
|
||||
{ id: "cline-free/gemini-3.8-flash", name: "Gemini 3.8 Flash", description: "", tags: [] },
|
||||
{ id: "cline-free/mimo-v2.6-flash", name: "Mimo V2.6 Flash", description: "", tags: [] },
|
||||
],
|
||||
clinePass: [{ id: "cline-pass/glm-5.3", name: "GLM-5.3", description: "", tags: [] }],
|
||||
};
|
||||
|
||||
let fetchMock;
|
||||
|
||||
function jsonResponse(obj) {
|
||||
return { ok: true, status: 200, json: async () => obj, text: async () => JSON.stringify(obj) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn(async (url) => {
|
||||
if (String(url) === MODELS_URL) return jsonResponse(MODELS_RESPONSE);
|
||||
if (String(url) === FEED_URL) return jsonResponse(FEED_RESPONSE);
|
||||
throw new Error("unexpected fetch: " + url);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("resolveClineModels free-tier merge", () => {
|
||||
it("includes the cline-free/* models that /api/v1/models omits", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
const ids = result.models.map((m) => m.id);
|
||||
expect(ids).toContain("cline-free/muse-spark-1.3-contributor");
|
||||
expect(ids).toContain("cline-free/deepseek-v4.1-flash");
|
||||
expect(ids).toContain("cline-free/gemini-3.8-flash");
|
||||
expect(ids).toContain("cline-free/mimo-v2.6-flash");
|
||||
});
|
||||
|
||||
it("keeps every /api/v1/models entry (feed is additive)", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
const ids = result.models.map((m) => m.id);
|
||||
expect(ids).toContain("meta/muse-spark-1.3-contributor");
|
||||
expect(ids).toContain("deepseek/deepseek-v4.1-flash");
|
||||
});
|
||||
|
||||
it("deduplicates ids present in both sources", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
const ids = result.models.map((m) => m.id);
|
||||
expect(ids.filter((id) => id === "stealth/space-bunny-alpha")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns {id, name} for feed entries", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
const entry = result.models.find((m) => m.id === "cline-free/muse-spark-1.3-contributor");
|
||||
expect(entry.name).toBe("Muse Spark 1.3 Contributor");
|
||||
});
|
||||
|
||||
it("survives a failing feed and still returns the /models catalog", async () => {
|
||||
fetchMock.mockImplementation(async (url) => {
|
||||
if (String(url) === MODELS_URL) return jsonResponse(MODELS_RESPONSE);
|
||||
return { ok: false, status: 503, json: async () => ({}), text: async () => "" };
|
||||
});
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
expect(result.models.map((m) => m.id)).toEqual(MODELS_RESPONSE.map((m) => m.id));
|
||||
});
|
||||
|
||||
it("does not leak the cline-pass/ subscription tier into the cline list", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
expect(result.models.map((m) => m.id)).not.toContain("cline-pass/glm-5.3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cline-free namespace pricing", () => {
|
||||
it("bills cline-free/* at zero", async () => {
|
||||
const { getPricingForModel } = await import("../../open-sse/providers/pricing.js");
|
||||
const pricing = getPricingForModel("cline", "cline-free/deepseek-v4.1-flash");
|
||||
expect(pricing).toMatchObject({
|
||||
input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("bills cline-free/* muse-spark at zero", async () => {
|
||||
const { getPricingForModel } = await import("../../open-sse/providers/pricing.js");
|
||||
expect(getPricingForModel("cline", "cline-free/muse-spark-1.3-contributor").input).toBe(0);
|
||||
});
|
||||
|
||||
it("still bills the paid twin at its published rate", async () => {
|
||||
const { getPricingForModel } = await import("../../open-sse/providers/pricing.js");
|
||||
expect(getPricingForModel("cline", "deepseek/deepseek-v4.1-flash").input).toBe(0.14);
|
||||
expect(getPricingForModel("cline", "meta/muse-spark-1.3-contributor")).toBeNull();
|
||||
});
|
||||
|
||||
it("zero price survives cost calculation over a large usage", async () => {
|
||||
const { getPricingForModel, calculateCostFromTokens } = await import("../../open-sse/providers/pricing.js");
|
||||
const pricing = getPricingForModel("cline", "cline-free/deepseek-v4.1-flash");
|
||||
const cost = calculateCostFromTokens(
|
||||
{ prompt_tokens: 1_000_000, completion_tokens: 1_000_000, reasoning_tokens: 500_000 },
|
||||
pricing
|
||||
);
|
||||
expect(cost).toBe(0);
|
||||
});
|
||||
});
|
||||
44
tests/unit/codex-current-provider-base-url.test.js
Normal file
44
tests/unit/codex-current-provider-base-url.test.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getCurrentCodexProviderBaseUrl, getCurrentCodexProviderSettings } from "../../src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js";
|
||||
|
||||
describe("Codex current provider base URL", () => {
|
||||
it("uses the base URL from the configured model provider, not an earlier provider", () => {
|
||||
const config = `model = "gpt-5"
|
||||
model_provider = "9router"
|
||||
|
||||
[model_providers.omniroute]
|
||||
base_url = "https://omniroute.example/v1"
|
||||
|
||||
[model_providers.9router]
|
||||
base_url = "http://127.0.0.1:20128/v1"
|
||||
`;
|
||||
|
||||
expect(getCurrentCodexProviderBaseUrl(config)).toBe("http://127.0.0.1:20128/v1");
|
||||
});
|
||||
|
||||
it("reads the active provider URL and bearer key when another provider appears first", () => {
|
||||
const config = `model_provider = "9router"
|
||||
|
||||
[model_providers.omniroute]
|
||||
base_url = "https://omniroute.example/v1"
|
||||
|
||||
[model_providers.omniroute.http_headers]
|
||||
Authorization = "Bearer placeholder-omniroute-key"
|
||||
|
||||
[model_providers.9router]
|
||||
base_url = "https://9router.example/v1/"
|
||||
|
||||
[model_providers.9router.http_headers]
|
||||
Authorization = "Bearer placeholder-9router-key"
|
||||
`;
|
||||
|
||||
expect(getCurrentCodexProviderSettings(config)).toEqual({
|
||||
baseUrl: "https://9router.example/v1/",
|
||||
apiKey: "placeholder-9router-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty settings when no active provider is configured", () => {
|
||||
expect(getCurrentCodexProviderSettings("model = \"gpt-5\"\n")).toEqual({ baseUrl: "", apiKey: "" });
|
||||
});
|
||||
});
|
||||
105
tests/unit/codex-gpt6-lite.test.js
Normal file
105
tests/unit/codex-gpt6-lite.test.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CodexExecutor } from "../../open-sse/executors/codex.js";
|
||||
import { getModelsByProviderId } from "../../open-sse/config/providerModels.js";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js";
|
||||
import * as proxyFetchModule from "../../open-sse/utils/proxyFetch.js";
|
||||
|
||||
const credentials = { connectionId: "fixture", accessToken: "fixture-token" };
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("Codex GPT-6 Sol/Luna transport", () => {
|
||||
it.each(["gpt-6-sol", "gpt-6-luna"])("lists %s with Codex capabilities", (model) => {
|
||||
const entry = getModelsByProviderId("codex").find((item) => item.id === model);
|
||||
expect(entry?.responsesLite).toBe(true);
|
||||
expect(entry?.thinkingLevels).toEqual(["low", "medium", "high", "xhigh", "max"]);
|
||||
expect(getCapabilitiesForModel("codex", model)).toMatchObject({
|
||||
vision: true,
|
||||
reasoning: true,
|
||||
thinkingFormat: "openai",
|
||||
});
|
||||
expect(getThinkingLevels("codex", model)).toEqual(["low", "medium", "high", "xhigh", "max"]);
|
||||
expect(getThinkingLevels("codex", `${model}(high)`)).toEqual(entry.thinkingLevels);
|
||||
});
|
||||
|
||||
it("keeps a native Responses Lite request intact", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const input = [
|
||||
{ type: "additional_tools", role: "developer", tools: [{ type: "function", name: "run", parameters: { type: "object", properties: {} } }] },
|
||||
{ type: "message", id: "msg_native", role: "developer", content: [{ type: "input_text", text: "Native instructions" }] },
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] },
|
||||
];
|
||||
const body = executor.transformRequest("gpt-6-luna", {
|
||||
model: "gpt-6-luna", input: structuredClone(input), instructions: "", tools: null, parallel_tool_calls: false,
|
||||
reasoning: { effort: "high", context: "all_turns" },
|
||||
}, true, credentials);
|
||||
const headers = executor.buildHeaders(credentials, true, null, "gpt-6-luna");
|
||||
|
||||
expect(headers["x-openai-internal-codex-responses-lite"]).toBe("true");
|
||||
expect(body.instructions).toBe("");
|
||||
expect(body.tools).toBeNull();
|
||||
expect(body.parallel_tool_calls).toBe(false);
|
||||
expect(body.input).toEqual(input);
|
||||
expect(body.reasoning).toEqual({ effort: "high", context: "all_turns" });
|
||||
});
|
||||
|
||||
it("converts an ordinary Responses request to the Lite shape", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const tool = { type: "function", name: "run", parameters: { type: "object", properties: {} } };
|
||||
const body = executor.transformRequest("gpt-6-sol", {
|
||||
model: "gpt-6-sol", input: "hello", instructions: "Do the task", tools: [tool],
|
||||
}, true, credentials);
|
||||
|
||||
expect(body.instructions).toBe("");
|
||||
expect(body.tools).toBeNull();
|
||||
expect(body.parallel_tool_calls).toBe(false);
|
||||
expect(body.reasoning).toEqual({ effort: "medium", context: "all_turns" });
|
||||
expect(body.input[0]).toEqual({ type: "additional_tools", role: "developer", tools: [tool] });
|
||||
expect(body.input[1]).toEqual({ type: "message", role: "developer", content: [{ type: "input_text", text: "Do the task" }] });
|
||||
expect(executor.buildHeaders(credentials, true, null, "gpt-6-sol")["x-openai-internal-codex-responses-lite"]).toBe("true");
|
||||
});
|
||||
|
||||
it("clamps unsupported GPT-6 reasoning values to Codex's lowest supported level", () => {
|
||||
const body = new CodexExecutor().transformRequest("gpt-6-luna", {
|
||||
model: "gpt-6-luna", input: "hello", reasoning: { effort: "none" },
|
||||
}, true, credentials);
|
||||
|
||||
expect(body.reasoning.effort).toBe("low");
|
||||
expect(body.reasoning.context).toBe("all_turns");
|
||||
});
|
||||
|
||||
it("sends the Lite shape and header in the actual outbound request", async () => {
|
||||
const fetchMock = vi.spyOn(proxyFetchModule, "proxyAwareFetch").mockResolvedValue({
|
||||
ok: true, status: 200, headers: new Map(),
|
||||
});
|
||||
await new CodexExecutor().execute({
|
||||
model: "gpt-6-luna",
|
||||
body: { model: "gpt-6-luna", input: "hello", instructions: "Do the task" },
|
||||
stream: true,
|
||||
credentials,
|
||||
});
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
const body = JSON.parse(options.body);
|
||||
expect(url).toBe("https://chatgpt.com/backend-api/codex/responses");
|
||||
expect(options.headers["x-openai-internal-codex-responses-lite"]).toBe("true");
|
||||
expect(options.headers.version).toBe("0.155.0");
|
||||
expect(body.model).toBe("gpt-6-luna");
|
||||
expect(body.instructions).toBe("");
|
||||
expect(body.input[0].type).toBe("additional_tools");
|
||||
expect(body.reasoning.context).toBe("all_turns");
|
||||
});
|
||||
|
||||
it("keeps the legacy transport for other models", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const body = executor.transformRequest("gpt-5.5", { model: "gpt-5.5", input: "hello" }, true, credentials);
|
||||
|
||||
expect(body.instructions).toBeTruthy();
|
||||
expect(body.input[0].type).not.toBe("additional_tools");
|
||||
expect(body.reasoning.context).toBeUndefined();
|
||||
expect(executor.buildHeaders(credentials, true, null, "gpt-5.5")["x-openai-internal-codex-responses-lite"]).toBeUndefined();
|
||||
expect(getThinkingLevels("codex", "gpt-6-astra")).toContain("none");
|
||||
expect(executor.buildHeaders(credentials, true, null, "gpt-6-astra")["x-openai-internal-codex-responses-lite"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
28
tests/unit/codex-profiles.test.js
Normal file
28
tests/unit/codex-profiles.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveProfileNameFromModel,
|
||||
buildCodexProfileToml,
|
||||
parseCodexProfileModel,
|
||||
} from "../../src/app/(dashboard)/dashboard/cli-tools/components/codexConfig.js";
|
||||
|
||||
describe("Codex profiles configuration", () => {
|
||||
it("derives provider name as profile name and avoids conflicts", () => {
|
||||
expect(deriveProfileNameFromModel("anthropic/claude-3-7-sonnet")).toBe("anthropic");
|
||||
expect(deriveProfileNameFromModel("anthropic/claude-3-5-haiku", ["anthropic"])).toBe("anthropic-2");
|
||||
expect(deriveProfileNameFromModel("anthropic/claude-3-5-haiku", ["anthropic", "anthropic-2"])).toBe("anthropic-3");
|
||||
expect(deriveProfileNameFromModel("deepseek/deepseek-chat")).toBe("deepseek");
|
||||
expect(deriveProfileNameFromModel("google/gemini-2.5-pro")).toBe("google");
|
||||
});
|
||||
|
||||
it("derives model name when model has no slash", () => {
|
||||
expect(deriveProfileNameFromModel("claude-3-7-sonnet")).toBe("claude-3-7-sonnet");
|
||||
expect(deriveProfileNameFromModel("gpt-4o")).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("builds and parses profile TOML", () => {
|
||||
const toml = buildCodexProfileToml({ name: "claude", model: "anthropic/claude-3-7-sonnet" });
|
||||
expect(toml).toContain('model = "anthropic/claude-3-7-sonnet"');
|
||||
expect(toml).toContain('model_provider = "9router"');
|
||||
expect(parseCodexProfileModel(toml)).toBe("anthropic/claude-3-7-sonnet");
|
||||
});
|
||||
});
|
||||
30
tests/unit/codex-settings-refresh.test.js
Normal file
30
tests/unit/codex-settings-refresh.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readSource = (relativePath) =>
|
||||
readFile(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
|
||||
|
||||
describe("Codex settings refresh", () => {
|
||||
it("bypasses cached status after applying a selected endpoint", async () => {
|
||||
const [routeSource, cardSource] = await Promise.all([
|
||||
readSource("../../src/app/api/cli-tools/codex-settings/route.js"),
|
||||
readSource("../../src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js"),
|
||||
]);
|
||||
|
||||
// Route Handlers already run on the server; a Server Action directive would reject this export.
|
||||
expect(routeSource).not.toContain('"use server";');
|
||||
expect(routeSource).toContain('export const dynamic = "force-dynamic";');
|
||||
expect(cardSource).toContain('fetch("/api/cli-tools/codex-settings", { cache: "no-store" })');
|
||||
expect(cardSource).toContain("setSelectedApiKey(apiKey);");
|
||||
expect(cardSource).toContain("setCustomBaseUrl(baseUrl);");
|
||||
});
|
||||
|
||||
it("keeps an unmatched active URL in the custom endpoint slot", async () => {
|
||||
const selectorSource = await readSource("../../src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js");
|
||||
|
||||
expect(selectorSource).toContain("if (current) {");
|
||||
expect(selectorSource).toContain("setCustomInput(current);");
|
||||
expect(selectorSource).toContain("onChange(current);");
|
||||
});
|
||||
});
|
||||
67
tests/unit/combo-caps-resolver.test.js
Normal file
67
tests/unit/combo-caps-resolver.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { aggregateComboCapabilities, getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
|
||||
// A combo's limits are the conservative aggregate of its members: ctx = min,
|
||||
// maxOutput = max. Resolving those members needs the synced model catalog, which
|
||||
// is server-only (it reads a file), so the browser bundle falls back to the
|
||||
// generic patterns. The dashboard computed its badges there and under-reported:
|
||||
// /v1/models and pi-settings (both server-side) said 1M while the badge said 200k.
|
||||
//
|
||||
// resolveCaps lets a caller hand in the server's answer. It must only override
|
||||
// what it carries — the local tables still own tools/pdf/audio/video/thinking*.
|
||||
const GLM53_FED = { vision: true, search: false, reasoning: true, contextWindow: 1_000_000, maxOutput: 131_072 };
|
||||
|
||||
describe("aggregateComboCapabilities: resolveCaps override", () => {
|
||||
const models = ["glm-cn/glm-5.3", "deepseek-v4.1-flash"];
|
||||
|
||||
it("falls back to the pattern default without a resolver", () => {
|
||||
const caps = aggregateComboCapabilities(models);
|
||||
// glm-5.3 has no exact entry, so the *glm-5.3* pattern gives 200k and caps the combo.
|
||||
expect(caps.contextWindow).toBe(200_000);
|
||||
});
|
||||
|
||||
it("uses the fed limits when a resolver supplies them", () => {
|
||||
const resolver = (fullId) => (fullId === "glm-cn/glm-5.3" ? GLM53_FED : null);
|
||||
const caps = aggregateComboCapabilities(models, null, resolver);
|
||||
expect(caps.contextWindow).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("keeps the fields the override does not carry", () => {
|
||||
const plain = aggregateComboCapabilities(models);
|
||||
const fed = aggregateComboCapabilities(models, null, (id) => (id === "glm-cn/glm-5.3" ? GLM53_FED : null));
|
||||
// The override carries no tools/pdf/thinking fields, so those must be unchanged.
|
||||
for (const field of ["tools", "pdf", "audioInput", "videoInput", "imageOutput", "audioOutput", "thinkingFormat"]) {
|
||||
expect(fed[field]).toEqual(plain[field]);
|
||||
}
|
||||
});
|
||||
|
||||
it("still applies the conservative rule across members", () => {
|
||||
const resolver = (fullId) => (fullId === "glm-cn/glm-5.3" ? GLM53_FED : null);
|
||||
const caps = aggregateComboCapabilities(models, null, resolver);
|
||||
// Only glm-5.3 was fed 1M; deepseek-v4.1-flash resolves locally to 1M, so min stays 1M.
|
||||
// Feeding a *smaller* value for one member must pull the aggregate down.
|
||||
const smaller = aggregateComboCapabilities(models, null, (id) => (id === "glm-cn/glm-5.3" ? { ...GLM53_FED, contextWindow: 64_000 } : null));
|
||||
expect(smaller.contextWindow).toBe(64_000);
|
||||
expect(caps.maxOutput).toBe(384_000); // max across members, from deepseek
|
||||
});
|
||||
|
||||
it("passes the resolver into nested combos", () => {
|
||||
const lookup = {
|
||||
zap: ["deepseek-v4.1-flash", "glm-cn/glm-5.3-flash"],
|
||||
"deepseek-v4.1-flash": ["cmc/deepseek/deepseek-v4.1-flash", "ocg/deepseek-v4.1-flash"],
|
||||
};
|
||||
const seen = [];
|
||||
const resolver = (fullId) => { seen.push(fullId); return fullId === "glm-cn/glm-5.3-flash" ? { contextWindow: 1_000_000 } : null; };
|
||||
aggregateComboCapabilities(lookup.zap, lookup, resolver);
|
||||
// The nested combo's own members were resolved with the same resolver.
|
||||
expect(seen).toContain("cmc/deepseek/deepseek-v4.1-flash");
|
||||
expect(seen).toContain("ocg/deepseek-v4.1-flash");
|
||||
});
|
||||
|
||||
it("leaves the plain two-argument call unchanged", () => {
|
||||
const caps = aggregateComboCapabilities(["kimi/kimi-k3"], null);
|
||||
expect(caps).toEqual(aggregateComboCapabilities(["kimi/kimi-k3"]));
|
||||
expect(caps.contextWindow).toBe(getCapabilitiesForModel("kimi", "kimi-k3").contextWindow);
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,30 @@ describe("inspectAndWrapCommandCodeResponse", () => {
|
||||
expect(text).toContain("data: [DONE]");
|
||||
});
|
||||
|
||||
it("preserves all lines in a multi-line packet when inspecting tool-input-start", async () => {
|
||||
const packet = [
|
||||
JSON.stringify({ type: "start" }),
|
||||
JSON.stringify({ type: "start-step" }),
|
||||
JSON.stringify({ type: "tool-input-start", id: "call_1", toolName: "terminal" }),
|
||||
JSON.stringify({ type: "tool-input-delta", id: "call_1", delta: '{"command": "ls"}' }),
|
||||
JSON.stringify({ type: "finish-step", finishReason: "tool-calls" }),
|
||||
JSON.stringify({ type: "finish", finishReason: "tool-calls" }),
|
||||
].join("\n") + "\n";
|
||||
|
||||
const ndjsonBody = createNdjsonStream([packet]);
|
||||
|
||||
const fakeResponse = new Response(ndjsonBody, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
|
||||
const result = await inspectAndWrapCommandCodeResponse(fakeResponse, "cmc/deepseek/deepseek-v4.1-flash");
|
||||
expect(result.ok).toBe(true);
|
||||
const text = await result.text();
|
||||
expect(text).toContain('"name":"terminal"');
|
||||
expect(text).toContain('"arguments":"{\\"command\\": \\"ls\\"}"');
|
||||
});
|
||||
|
||||
it("retries when initial stream yields an error and succeeds on second attempt", async () => {
|
||||
let callCount = 0;
|
||||
const executor = new CommandCodeExecutor();
|
||||
|
||||
141
tests/unit/gemini-contents-normalization.test.js
Normal file
141
tests/unit/gemini-contents-normalization.test.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeGeminiContents } from "../../open-sse/translator/formats/gemini.js";
|
||||
|
||||
describe("normalizeGeminiContents terminal turn guards", () => {
|
||||
it("appends user Continue turn when ending with model text turn", () => {
|
||||
const contents = [
|
||||
{ role: "user", parts: [{ text: "hi" }] },
|
||||
{ role: "model", parts: [{ text: "hello" }] }
|
||||
];
|
||||
const out = normalizeGeminiContents(contents);
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out[2]).toEqual({ role: "user", parts: [{ text: "Continue." }] });
|
||||
});
|
||||
|
||||
it("appends functionResponse user turn when ending with functionCall", () => {
|
||||
const contents = [
|
||||
{ role: "user", parts: [{ text: "run" }] },
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "call_1", name: "search", args: { q: "test" } } }
|
||||
]
|
||||
}
|
||||
];
|
||||
const out = normalizeGeminiContents(contents);
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out[2]).toEqual({
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_1",
|
||||
name: "search",
|
||||
response: { result: "Continue." }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("handles multiple functionCalls in terminal model turn", () => {
|
||||
const contents = [
|
||||
{ role: "user", parts: [{ text: "run" }] },
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "call_1", name: "fn_1" } },
|
||||
{ functionCall: { id: "call_2", name: "fn_2" } }
|
||||
]
|
||||
}
|
||||
];
|
||||
const out = normalizeGeminiContents(contents);
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out[2].parts).toHaveLength(2);
|
||||
expect(out[2].parts[0].functionResponse.id).toBe("call_1");
|
||||
expect(out[2].parts[1].functionResponse.id).toBe("call_2");
|
||||
});
|
||||
|
||||
it("handles terminal model turn with both text and functionCall", () => {
|
||||
const contents = [
|
||||
{ role: "user", parts: [{ text: "run" }] },
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "Executing..." },
|
||||
{ functionCall: { id: "call_3", name: "exec" } }
|
||||
]
|
||||
}
|
||||
];
|
||||
const out = normalizeGeminiContents(contents);
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out[2].parts[0].functionResponse.id).toBe("call_3");
|
||||
});
|
||||
|
||||
it("handles single model turn by prepending user prompt and appending terminal user", () => {
|
||||
const contents = [{ role: "model", parts: [{ text: "prefill" }] }];
|
||||
const out = normalizeGeminiContents(contents);
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out[0]).toEqual({ role: "user", parts: [{ text: "..." }] });
|
||||
expect(out[1]).toEqual({ role: "model", parts: [{ text: "prefill" }] });
|
||||
expect(out[2]).toEqual({ role: "user", parts: [{ text: "Continue." }] });
|
||||
});
|
||||
|
||||
it("does not mutate payloads already ending with a user turn", () => {
|
||||
const contents = [{ role: "user", parts: [{ text: "question" }] }];
|
||||
const out = normalizeGeminiContents(contents);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].role).toBe("user");
|
||||
});
|
||||
|
||||
it("handles functionCall without name or id with fallback defaults", () => {
|
||||
const contents = [
|
||||
{ role: "user", parts: [{ text: "Go" }] },
|
||||
{ role: "model", parts: [{ functionCall: {} }] }
|
||||
];
|
||||
const out = normalizeGeminiContents(contents);
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out[2].parts[0]).toEqual({
|
||||
functionResponse: {
|
||||
name: "tool",
|
||||
response: { result: "Continue." }
|
||||
}
|
||||
});
|
||||
expect(out[2].parts[0].functionResponse.id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges adjacent model turns before appending terminal user turn", () => {
|
||||
const contents = [
|
||||
{ role: "user", parts: [{ text: "Prompt" }] },
|
||||
{ role: "model", parts: [{ text: "Part A" }] },
|
||||
{ role: "model", parts: [{ text: "Part B" }] }
|
||||
];
|
||||
const out = normalizeGeminiContents(contents);
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out[1].role).toBe("model");
|
||||
expect(out[1].parts).toHaveLength(2);
|
||||
expect(out[2]).toEqual({ role: "user", parts: [{ text: "Continue." }] });
|
||||
});
|
||||
|
||||
it("appends user Continue turn when terminal model turn has thought parts", () => {
|
||||
const contents = [
|
||||
{ role: "user", parts: [{ text: "Solve math" }] },
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ thought: true, text: "Let 2x = 4..." },
|
||||
{ thoughtSignature: "sig123", text: "" }
|
||||
]
|
||||
}
|
||||
];
|
||||
const out = normalizeGeminiContents(contents);
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out[2]).toEqual({ role: "user", parts: [{ text: "Continue." }] });
|
||||
});
|
||||
|
||||
it("handles empty, null, and undefined inputs gracefully", () => {
|
||||
expect(normalizeGeminiContents([])).toEqual([]);
|
||||
expect(normalizeGeminiContents(null)).toEqual([]);
|
||||
expect(normalizeGeminiContents(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
538
tests/unit/gemini-live-stt.test.js
Normal file
538
tests/unit/gemini-live-stt.test.js
Normal file
@@ -0,0 +1,538 @@
|
||||
// Gemini Live (realtime bidi) STT transport contract.
|
||||
//
|
||||
// Black-box tests against open-sse/handlers/sttCore.js. Wire observables only:
|
||||
// - transport marker drives dispatch (caller param / registry entry), never a
|
||||
// hardcoded model id;
|
||||
// - session opens with a setup frame declaring the model; audio rides
|
||||
// realtimeInput frames only AFTER the server's setup-complete ack;
|
||||
// - inputTranscription deltas accumulate into {text}; verbose_json adds
|
||||
// segments {id,text} with NO timing keys (protocol carries none);
|
||||
// - error frame → gateway error envelope (any 4xx/5xx, shape only);
|
||||
// - system_instruction / prompt override setup instruction (substring);
|
||||
// - client-supplied setup_timeout_ms (tiny) bounds the wait (error occurs);
|
||||
// - response_format never reaches the session setup;
|
||||
// - custom-model transport persists via POST /api/models/custom (whitelist)
|
||||
// with unknown values silently dropped;
|
||||
// - the persisted custom transport is resolved by the app layer (stt.js)
|
||||
// and reaches engine dispatch end-to-end (handleStt → WS, not REST).
|
||||
//
|
||||
// NOT pinned (unstated or implementation-only): goAway/reconnect semantics,
|
||||
// timeout clamp ceilings, specific status codes, byte-exact WS URLs (only
|
||||
// wss:// + bidiGenerateContent + model-id substrings), exact frame JSON paths.
|
||||
import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { handleSttCore } from "open-sse/handlers/sttCore.js";
|
||||
import { PROVIDER_MODELS } from "open-sse/config/providerModels.js";
|
||||
|
||||
// ── fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
const STTCFG = {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
authType: "apikey",
|
||||
authHeader: "key",
|
||||
format: "gemini-stt",
|
||||
};
|
||||
const CRED = { apiKey: "AIza-TEST" };
|
||||
|
||||
const LIVE_ID = "probe-live-capability-1";
|
||||
|
||||
function mkFile() {
|
||||
return new File([new Uint8Array([1, 2, 3, 4])], "a.wav", { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function mkFormData(extra = {}) {
|
||||
const fd = new FormData();
|
||||
fd.set("file", mkFile());
|
||||
for (const [k, v] of Object.entries(extra)) fd.set(k, v);
|
||||
return fd;
|
||||
}
|
||||
|
||||
// ── fake WebSocket ────────────────────────────────────────────────────────
|
||||
|
||||
class FakeWS {
|
||||
static instances = [];
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.sent = []; // JSON.parsed frames, in send order
|
||||
this.readyState = FakeWS.CONNECTING;
|
||||
this.closed = false;
|
||||
this.closeCalls = []; // {code, reason} recordings
|
||||
this._listeners = {};
|
||||
FakeWS.instances.push(this);
|
||||
queueMicrotask(() => {
|
||||
if (this.closed) return;
|
||||
this.readyState = FakeWS.OPEN;
|
||||
if (typeof this.onopen === "function") this.onopen({});
|
||||
(this._listeners.open || []).forEach((f) => f({}));
|
||||
});
|
||||
}
|
||||
|
||||
addEventListener(type, fn) {
|
||||
(this._listeners[type] = this._listeners[type] || []).push(fn);
|
||||
}
|
||||
|
||||
send(data) {
|
||||
this.sent.push(JSON.parse(data));
|
||||
}
|
||||
|
||||
// Fire a server frame through both supported binding styles.
|
||||
emit(obj) {
|
||||
const ev = { data: JSON.stringify(obj) };
|
||||
if (typeof this.onmessage === "function") this.onmessage(ev);
|
||||
(this._listeners.message || []).forEach((f) => f(ev));
|
||||
}
|
||||
|
||||
// Fire a server-initiated close through both supported binding styles.
|
||||
// Distinct from close(), which only records the client-side shutdown.
|
||||
emitClose(code = 1000) {
|
||||
this.closed = true;
|
||||
this.readyState = FakeWS.CLOSED;
|
||||
const ev = { code, reason: "" };
|
||||
if (typeof this.onclose === "function") this.onclose(ev);
|
||||
(this._listeners.close || []).forEach((f) => f(ev));
|
||||
}
|
||||
|
||||
close(code, reason) {
|
||||
this.closed = true;
|
||||
this.readyState = FakeWS.CLOSED;
|
||||
this.closeCalls.push({ code: code ?? 1000, reason: reason ?? "" });
|
||||
}
|
||||
}
|
||||
|
||||
function stubWs() {
|
||||
vi.stubGlobal("WebSocket", FakeWS);
|
||||
}
|
||||
|
||||
// Fetch spy that records calls; handler defaults to "REST must not happen".
|
||||
function stubFetch(handler = () => { throw new Error("REST must not be used for live transport"); }) {
|
||||
const calls = [];
|
||||
vi.stubGlobal("fetch", async (url, opts) => {
|
||||
calls.push(String(url && url.url ? url.url : url));
|
||||
return handler(url, opts);
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
// Server drives a completed session: setup ack → transcription deltas → turn done.
|
||||
function serverScript(instance, texts) {
|
||||
instance.emit({ serverContent: { setupComplete: true } });
|
||||
for (const t of texts) instance.emit({ serverContent: { inputTranscription: { text: t } } });
|
||||
instance.emit({ serverContent: { turnComplete: true } });
|
||||
}
|
||||
|
||||
async function liveSession({ model = LIVE_ID, formData = mkFormData(), transport = "gemini-live" } = {}) {
|
||||
stubWs();
|
||||
const fetchCalls = stubFetch();
|
||||
const pending = handleSttCore({ provider: "gemini", model, formData, credentials: CRED, sttConfig: STTCFG, transport });
|
||||
await vi.waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
const ws = FakeWS.instances[0];
|
||||
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThanOrEqual(1)); // setup frame sent
|
||||
return { pending, ws, fetchCalls };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
FakeWS.instances.length = 0;
|
||||
});
|
||||
|
||||
// ── S1/S2/S3: transport marker dispatch, text envelope, no REST ───────────
|
||||
|
||||
describe("Live transport dispatch via caller marker", () => {
|
||||
it("T3: transport 'gemini-live' opens a WebSocket, never REST; text = accumulated deltas", async () => {
|
||||
// REST-fallback contrast (folded from T1): a live-capability id with no
|
||||
// transport marker falls to REST and fails cleanly — the live path is opt-in.
|
||||
stubFetch(() => ({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => JSON.stringify({ error: { message: "live models require the streaming endpoint" } }),
|
||||
}));
|
||||
const restResult = await handleSttCore({
|
||||
provider: "gemini",
|
||||
model: LIVE_ID,
|
||||
formData: mkFormData(),
|
||||
credentials: CRED,
|
||||
sttConfig: STTCFG,
|
||||
});
|
||||
expect(restResult.success).toBe(false);
|
||||
|
||||
// Caller-marker dispatch: explicit transport "gemini-live" opens the WS,
|
||||
// never REST; text = accumulated inputTranscription deltas.
|
||||
const { pending, ws, fetchCalls } = await liveSession();
|
||||
serverScript(ws, ["hello ", "world"]);
|
||||
const result = await pending;
|
||||
expect(result.success).toBe(true);
|
||||
await expect(result.response.json()).resolves.toEqual({ text: "hello world" });
|
||||
expect(fetchCalls).toHaveLength(0);
|
||||
|
||||
// Registry-marker dispatch: the live entry's transport field alone — no
|
||||
// caller transport param — routes to the WS path; id derived from the
|
||||
// registry, never a literal.
|
||||
const regId = (PROVIDER_MODELS.gemini || []).find(
|
||||
(m) => m && m.kind === "stt" && m.transport === "gemini-live",
|
||||
)?.id;
|
||||
FakeWS.instances.length = 0;
|
||||
stubWs();
|
||||
const regFetchCalls = stubFetch();
|
||||
const regPending = handleSttCore({
|
||||
provider: "gemini",
|
||||
model: regId,
|
||||
formData: mkFormData(),
|
||||
credentials: CRED,
|
||||
sttConfig: STTCFG,
|
||||
});
|
||||
await vi.waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
const regWs = FakeWS.instances[0];
|
||||
await vi.waitFor(() => expect(regWs.sent.length).toBeGreaterThanOrEqual(1));
|
||||
serverScript(regWs, ["reg ", "live"]);
|
||||
const regResult = await regPending;
|
||||
expect(regResult.success).toBe(true);
|
||||
await expect(regResult.response.json()).resolves.toEqual({ text: "reg live" });
|
||||
expect(regFetchCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("T4: setup frame first (carries model id); audio only in realtimeInput at index >=1; WS URL is the bidi endpoint", async () => {
|
||||
const { pending, ws, fetchCalls } = await liveSession();
|
||||
const setup = ws.sent[0];
|
||||
expect(setup.setup).toBeTruthy();
|
||||
expect(JSON.stringify(setup.setup)).toContain(LIVE_ID);
|
||||
expect(setup.realtimeInput).toBeUndefined();
|
||||
|
||||
serverScript(ws, ["x"]);
|
||||
const result = await pending;
|
||||
expect(result.success).toBe(true);
|
||||
expect(fetchCalls).toHaveLength(0);
|
||||
|
||||
const audioIdx = ws.sent.findIndex((f) => f.realtimeInput);
|
||||
expect(audioIdx).toBeGreaterThanOrEqual(1);
|
||||
const media = ws.sent[audioIdx].realtimeInput.mediaChunks;
|
||||
expect(Array.isArray(media)).toBe(true);
|
||||
expect(typeof media[0].data).toBe("string");
|
||||
expect(media[0].data.length).toBeGreaterThan(0);
|
||||
expect(Buffer.from(media[0].data, "base64").length).toBeGreaterThan(0);
|
||||
|
||||
expect(ws.url).toContain("wss://");
|
||||
expect(ws.url).toContain("bidiGenerateContent");
|
||||
expect(ws.url).toContain(LIVE_ID);
|
||||
|
||||
// REST contrast (folded from T2): an ordinary gemini model still transcribes
|
||||
// over REST generateContent — the live path is opt-in, never the default.
|
||||
stubFetch(() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ candidates: [{ content: { parts: [{ text: "hello rest" }] } }] }),
|
||||
text: async () => "",
|
||||
}));
|
||||
const restResult = await handleSttCore({
|
||||
provider: "gemini",
|
||||
model: "gemini-2.0-flash",
|
||||
formData: mkFormData(),
|
||||
credentials: CRED,
|
||||
sttConfig: STTCFG,
|
||||
});
|
||||
expect(restResult.success).toBe(true);
|
||||
await expect(restResult.response.json()).resolves.toEqual({ text: "hello rest" });
|
||||
});
|
||||
|
||||
it("T5: server error frame yields the gateway error envelope (any 4xx/5xx, no text pin)", async () => {
|
||||
const { pending, ws } = await liveSession();
|
||||
ws.emit({ error: { code: "X", message: "Y" } });
|
||||
const result = await pending;
|
||||
expect(result.success).toBe(false);
|
||||
expect(typeof result.status).toBe("number");
|
||||
expect(result.status).toBeGreaterThanOrEqual(400);
|
||||
expect(result.status).toBeLessThanOrEqual(599);
|
||||
});
|
||||
});
|
||||
|
||||
// ── S7: client knobs (instruction overrides ride the setup frame) ─────────
|
||||
|
||||
describe("Setup frame knobs", () => {
|
||||
it("T6: client prompt overrides the setup instruction", async () => {
|
||||
const { pending, ws } = await liveSession({ formData: mkFormData({ prompt: "Say it back" }) });
|
||||
const setupJson = JSON.stringify(ws.sent[0]);
|
||||
expect(setupJson).toContain("Say it back");
|
||||
serverScript(ws, ["ok"]);
|
||||
const result = await pending;
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("T7: system_instruction override appears in the setup frame", async () => {
|
||||
const { pending, ws } = await liveSession({ formData: mkFormData({ system_instruction: "TRANSCRIBE-VERBATIM-OVERRIDE-42" }) });
|
||||
const setupJson = JSON.stringify(ws.sent[0]);
|
||||
expect(setupJson).toContain("TRANSCRIBE-VERBATIM-OVERRIDE-42");
|
||||
serverScript(ws, ["ok"]);
|
||||
const result = await pending;
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("T8: response_format is client-only and never reaches the session setup", async () => {
|
||||
const { pending, ws } = await liveSession({ formData: mkFormData({ response_format: "verbose_json" }) });
|
||||
expect(JSON.stringify(ws.sent[0])).not.toContain("response_format");
|
||||
serverScript(ws, ["a", "b"]);
|
||||
const result = await pending;
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("T9: tiny setup_timeout_ms with no server ack errors out within the bound", async () => {
|
||||
const { pending } = await liveSession({ formData: mkFormData({ setup_timeout_ms: "5" }) });
|
||||
// deliberately emit nothing — the client knob must end the wait
|
||||
const result = await pending;
|
||||
expect(result.success).toBe(false);
|
||||
expect(typeof result.status).toBe("number");
|
||||
expect(result.status).toBeGreaterThanOrEqual(400);
|
||||
expect(result.status).toBeLessThanOrEqual(599);
|
||||
});
|
||||
});
|
||||
|
||||
// ── S8: verbose_json shaping ──────────────────────────────────────────────
|
||||
|
||||
describe("Response shaping", () => {
|
||||
it("T10: verbose_json adds {id,text} segments in arrival order with NO timing fields", async () => {
|
||||
const { pending, ws } = await liveSession({ formData: mkFormData({ response_format: "verbose_json" }) });
|
||||
serverScript(ws, ["hello ", "world"]);
|
||||
const result = await pending;
|
||||
const body = await result.response.json();
|
||||
expect(body.text).toBe("hello world");
|
||||
expect(Array.isArray(body.segments)).toBe(true);
|
||||
expect(body.segments).toHaveLength(2);
|
||||
const [s0, s1] = body.segments;
|
||||
expect(s0.id).toBe(0);
|
||||
expect(s1.id).toBe(1);
|
||||
for (const seg of body.segments) {
|
||||
expect(Object.keys(seg)).toContain("id");
|
||||
expect(Object.keys(seg)).toContain("text");
|
||||
expect("start" in seg).toBe(false);
|
||||
expect("end" in seg).toBe(false);
|
||||
expect("duration" in seg).toBe(false);
|
||||
}
|
||||
expect(s0.text).toBe("hello ");
|
||||
expect(s1.text).toBe("world");
|
||||
expect("duration" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("T11: default format carries text only, no segments", async () => {
|
||||
const { pending, ws } = await liveSession();
|
||||
serverScript(ws, ["one", "two"]);
|
||||
const result = await pending;
|
||||
const body = await result.response.json();
|
||||
expect(Object.keys(body)).toContain("text");
|
||||
expect("segments" in body).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── S2a: registry marks the live family (data, not code) ─────────────────
|
||||
|
||||
describe("Registry family marking", () => {
|
||||
it("T12: gemini stt catalog includes a live-transport entry advertising the lifecycle params", () => {
|
||||
const live = (PROVIDER_MODELS.gemini || []).find(
|
||||
(m) => m && m.kind === "stt" && m.transport === "gemini-live",
|
||||
);
|
||||
expect(live).toBeTruthy();
|
||||
expect(live.id).toBeTruthy();
|
||||
expect(Array.isArray(live.params)).toBe(true);
|
||||
for (const p of ["language", "prompt", "system_instruction", "setup_timeout_ms", "turn_timeout_ms"]) {
|
||||
expect(live.params).toContain(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── S2b: custom-model transport persistence (route level) ─────────────────
|
||||
|
||||
describe("Custom-model transport persistence via POST /api/models/custom", () => {
|
||||
let tempDir;
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
// paths.js freezes DATA_DIR at module load — re-evaluate the db chain per test
|
||||
vi.resetModules();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-gemini-live-"));
|
||||
process.env.DATA_DIR = tempDir;
|
||||
delete global._dbAdapter;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { global._dbAdapter?.instance?.close?.(); } catch { /* already closed */ }
|
||||
delete global._dbAdapter;
|
||||
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
async function postCustom(payload) {
|
||||
const { POST } = await import("@/app/api/models/custom/route.js");
|
||||
const res = await POST({ json: async () => payload });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function customRows() {
|
||||
const { getCustomModels } = await import("@/lib/db/repos/aliasRepo.js");
|
||||
return getCustomModels();
|
||||
}
|
||||
|
||||
it("T13: whitelisted transport persists on the saved row", { timeout: 30000 }, async () => {
|
||||
const body = await postCustom({
|
||||
providerAlias: "gemini",
|
||||
id: "probe-custom-capability-9",
|
||||
type: "stt",
|
||||
transport: "gemini-live",
|
||||
});
|
||||
expect(body.success).toBe(true);
|
||||
const row = (await customRows()).find((m) => m && m.providerAlias === "gemini" && m.id === "probe-custom-capability-9");
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.type).toBe("stt");
|
||||
expect(row.transport).toBe("gemini-live");
|
||||
});
|
||||
|
||||
it("T14: unknown transport is silently dropped — prior whitelisted transport survives re-save", { timeout: 30000 }, async () => {
|
||||
const first = await postCustom({
|
||||
providerAlias: "gemini",
|
||||
id: "probe-custom-capability-10",
|
||||
type: "stt",
|
||||
transport: "gemini-live",
|
||||
});
|
||||
expect(first.success).toBe(true);
|
||||
// Re-saving the same model with an unknown transport must not clobber
|
||||
// the persisted marker: silent-drop keeps the stored value (merge keeps
|
||||
// omitted fields, per addCustomModel).
|
||||
const second = await postCustom({
|
||||
providerAlias: "gemini",
|
||||
id: "probe-custom-capability-10",
|
||||
type: "stt",
|
||||
transport: "nope",
|
||||
});
|
||||
expect(second.success).toBe(true);
|
||||
const row = (await customRows()).find((m) => m && m.providerAlias === "gemini" && m.id === "probe-custom-capability-10");
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.transport).toBe("gemini-live");
|
||||
});
|
||||
});
|
||||
|
||||
// ── S2c (T15): app-layer custom-transport resolution, end-to-end ─────────
|
||||
//
|
||||
// Gate C M1 closure. Only handleStt (src/sse/handlers/stt.js) maps a
|
||||
// persisted custom-model transport onto the handleSttCore dispatch; T13/T14
|
||||
// stop at repo persistence. Fake model id is absent from the registry, so a
|
||||
// WebSocket opening here is observable proof the caller-supplied transport
|
||||
// marker was resolved and passed — deleting that resolution fails T15.
|
||||
|
||||
describe("App-layer custom transport resolution (stt.js)", () => {
|
||||
it("T15: persisted custom gemini-live transport reaches WS dispatch through real handleStt", async () => {
|
||||
const LOCALDB = "@/lib/localDb";
|
||||
const AUTH = "../../src/sse/services/auth.js";
|
||||
try {
|
||||
vi.resetModules();
|
||||
vi.doMock(LOCALDB, () => ({
|
||||
getSettings: async () => ({ requireApiKey: false }),
|
||||
getCustomModels: async () => ([{
|
||||
providerAlias: "gemini", id: "probe-sttjs-1", type: "stt", transport: "gemini-live",
|
||||
}]),
|
||||
getProviderConnections: async () => [{ id: "c1", provider: "gemini", isActive: true }],
|
||||
getProviderNodes: async () => [],
|
||||
getModelAliases: async () => ({}),
|
||||
getComboByName: async () => null,
|
||||
}));
|
||||
vi.doMock(AUTH, () => ({
|
||||
extractApiKey: () => null,
|
||||
isValidApiKey: async () => true,
|
||||
getProviderCredentials: async () => ({
|
||||
apiKey: "AIza-TEST", connectionId: "c1", connectionName: "t", providerSpecificData: {},
|
||||
}),
|
||||
markAccountUnavailable: async () => ({ shouldFallback: false }),
|
||||
}));
|
||||
// getModelInfo stays REAL: "gemini/..." is a reserved-prefix passthrough,
|
||||
// so the parse→route hop in the chain is exercised, not stubbed.
|
||||
const { handleStt } = await import("../../src/sse/handlers/stt.js");
|
||||
|
||||
stubWs();
|
||||
const fetchCalls = stubFetch(); // default handler throws: REST must not happen
|
||||
const fd = mkFormData();
|
||||
fd.set("model", "gemini/probe-sttjs-1");
|
||||
|
||||
const pending = handleStt({ formData: async () => fd });
|
||||
await vi.waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
const ws = FakeWS.instances[0];
|
||||
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThanOrEqual(1)); // setup first
|
||||
serverScript(ws, ["hello ", "world"]);
|
||||
const res = await pending;
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.text).toBe("hello world");
|
||||
expect(fetchCalls).toHaveLength(0);
|
||||
} finally {
|
||||
vi.doUnmock(LOCALDB);
|
||||
vi.doUnmock(AUTH);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── S5/S6/S9: lifecycle gates (setup ack, turn timeout, turn completion) ──
|
||||
|
||||
// Setup/turn gating, graceful close, and transcript accumulation hygiene:
|
||||
// padding-only frames are dropped, and a whitespace-only run is a failure
|
||||
// rather than a blank success.
|
||||
describe("Lifecycle gates", () => {
|
||||
it("T16: audio streaming waits for the server setup-complete reply", async () => {
|
||||
const { pending, ws } = await liveSession();
|
||||
// deliberately do NOT emit setupComplete
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
const audioFrames = ws.sent.filter((f) => f.realtimeInput);
|
||||
expect(audioFrames).toHaveLength(0);
|
||||
// clean up: let the pending promise settle so afterEach unstub works cleanly
|
||||
ws.emit({ serverContent: { setupComplete: true } });
|
||||
ws.emit({ serverContent: { turnComplete: true } });
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("T17: tiny turn_timeout_ms with setup-complete but no turn-complete errors out", async () => {
|
||||
const { pending, ws } = await liveSession({ formData: mkFormData({ turn_timeout_ms: "5" }) });
|
||||
ws.emit({ serverContent: { setupComplete: true } });
|
||||
// deliberately do NOT emit turnComplete
|
||||
const result = await pending;
|
||||
expect(result.success).toBe(false);
|
||||
expect(typeof result.status).toBe("number");
|
||||
expect(result.status).toBeGreaterThanOrEqual(400);
|
||||
expect(result.status).toBeLessThanOrEqual(599);
|
||||
});
|
||||
|
||||
it("T18: server turnComplete closes the WebSocket gracefully", async () => {
|
||||
const { pending, ws } = await liveSession();
|
||||
serverScript(ws, ["done"]);
|
||||
await pending;
|
||||
expect(ws.closeCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(ws.closeCalls[0].code).toBe(1000);
|
||||
});
|
||||
|
||||
it("T19: whitespace-only transcription frames are not appended to the transcript", async () => {
|
||||
const { pending, ws } = await liveSession();
|
||||
ws.emit({ serverContent: { setupComplete: true } });
|
||||
// a padding-only frame must contribute nothing to the transcript
|
||||
ws.emit({ serverContent: { inputTranscription: { text: " " } } });
|
||||
ws.emit({ serverContent: { inputTranscription: { text: "done" } } });
|
||||
ws.emit({ serverContent: { turnComplete: true } });
|
||||
const result = await pending;
|
||||
const body = await result.response.json();
|
||||
expect(body.text).toBe("done");
|
||||
});
|
||||
|
||||
it("T20: a run that receives only whitespace frames errors instead of returning a blank transcript", async () => {
|
||||
const { pending, ws } = await liveSession();
|
||||
ws.emit({ serverContent: { setupComplete: true } });
|
||||
ws.emit({ serverContent: { inputTranscription: { text: " " } } });
|
||||
// server closes before any real transcript arrived: a partial success would
|
||||
// hand the client a whitespace-only transcript
|
||||
ws.emitClose(1000);
|
||||
const result = await pending;
|
||||
expect(result.success).toBe(false);
|
||||
expect(typeof result.status).toBe("number");
|
||||
expect(result.status).toBeGreaterThanOrEqual(400);
|
||||
expect(result.status).toBeLessThanOrEqual(599);
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,22 @@ describe("model catalog", () => {
|
||||
}
|
||||
expect(globalThis.__9rCatalogSource).toBeNull();
|
||||
});
|
||||
|
||||
it("detaches the source from a copy that already resolved through it", async () => {
|
||||
capabilities.setCatalogSource({
|
||||
getModalities: (provider) => (provider === "gateway-a" ? { vision: true } : null),
|
||||
getLimits: () => null,
|
||||
});
|
||||
const other = await import("../../open-sse/providers/capabilities.js?copy=3");
|
||||
try {
|
||||
expect(other.getCapabilitiesForModel("gateway-a", "laguna-9-preview").vision).toBe(true);
|
||||
} finally {
|
||||
capabilities.setCatalogSource(null);
|
||||
}
|
||||
// the sync resets the source before rebuilding; a copy that has read the
|
||||
// slot once must not keep serving the uninstalled reader
|
||||
expect(other.getCapabilitiesForModel("gateway-a", "laguna-9-preview").vision).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog schema", () => {
|
||||
|
||||
@@ -4,13 +4,14 @@ import { PROVIDERS } from "../../open-sse/config/providers.js";
|
||||
import { resolveTransport } from "../../open-sse/services/provider.js";
|
||||
|
||||
// Chat-only models (no /messages, no /responses support on opencode-go)
|
||||
const CHAT_ONLY = ["glm-5.3", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "kimi-k3",
|
||||
"deepseek-flash", "longcat-2.0", "mimo-v2.5", "mimo-v2.5-pro", "hy4-preview", "hy3"];
|
||||
const CHAT_ONLY = ["glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "kimi-k3",
|
||||
"deepseek-flash", "longcat-2.0", "mimo-v2.6-flash", "mimo-v2.6-pro",
|
||||
"mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni", "hy4-preview", "hy3", "hy3-preview", "omen-alpha"];
|
||||
// Models that also expose the Anthropic /messages endpoint
|
||||
const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5",
|
||||
"qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"];
|
||||
const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", "space-bunny-free",
|
||||
"qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus"];
|
||||
// Models that also expose the OpenAI /responses endpoint
|
||||
const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"];
|
||||
const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4.1-flash"];
|
||||
|
||||
// Mirror of chatCore's per-model transport guard: use the sourceFormat-matched
|
||||
// transport only when the model declares support for that sourceFormat.
|
||||
@@ -25,18 +26,42 @@ describe("OpenCode Go model catalog", () => {
|
||||
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id);
|
||||
expect(ids).toEqual([
|
||||
"deepseek-flash",
|
||||
"glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "kimi-k3",
|
||||
"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp",
|
||||
"longcat-2.0", "mimo-v2.5", "mimo-v2.5-pro",
|
||||
"minimax-m3", "minimax-m2.7", "minimax-m2.5",
|
||||
"qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
|
||||
"hy4-preview", "hy3",
|
||||
"grok-4.6", "gpt-5.6-luna",
|
||||
"glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "kimi-k3",
|
||||
"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", "deepseek-v4.1-flash",
|
||||
"longcat-2.0", "mimo-v2.6-flash", "mimo-v2.6-pro", "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni",
|
||||
"minimax-m3", "minimax-m2.7", "minimax-m2.5", "space-bunny-free",
|
||||
"qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus",
|
||||
"hy4-preview", "hy3", "hy3-preview", "omen-alpha",
|
||||
"grok-4.7", "grok-4.6", "grok-4.5", "gpt-5.6-luna", "gpt-6-luna",
|
||||
"muse-spark-1.2-contributor", "muse-spark-1.3-contributor",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Go family fallback (unknown/passthrough ids)", () => {
|
||||
it("routes unknown grok/gpt ids to the responses lane", () => {
|
||||
expect(getModelSupportedFormats("opencode-go", "grok-4.8")).toEqual(["openai-responses"]);
|
||||
expect(getModelTargetFormat("opencode-go", "gpt-6-foo")).toBe("openai-responses");
|
||||
});
|
||||
|
||||
it("gives unknown chat-family ids the chat-only lane, never /messages", () => {
|
||||
for (const m of ["kimi-k4", "glm-6", "mimo-v3", "omen-beta"]) {
|
||||
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps unknown minimax/qwen ids on the /messages lane too", () => {
|
||||
for (const m of ["minimax-m9", "qwen4-max"]) {
|
||||
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("curated entries win over the family regex", () => {
|
||||
expect(getModelSupportedFormats("opencode-go", "deepseek-flash")).toEqual(["openai"]);
|
||||
expect(getModelSupportedFormats("opencode-go", "deepseek-v4-pro")).toEqual(["openai", "claude", "openai-responses"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Go thinking-suffix model lookup", () => {
|
||||
it("preserves Responses routing for gpt-5.6-luna thinking variants", () => {
|
||||
expect(getModelSupportedFormats("opencode-go", "gpt-5.6-luna(high)")).toEqual(["openai-responses"]);
|
||||
@@ -108,7 +133,7 @@ describe("OpenCode Go per-model transport guard (chatCore logic)", () => {
|
||||
});
|
||||
|
||||
it("routes Muse Spark (responses-only) to /responses, never to /messages", () => {
|
||||
for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor", "grok-4.6", "gpt-5.6-luna"]) {
|
||||
for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor", "grok-4.7", "grok-4.6", "grok-4.5", "gpt-5.6-luna", "gpt-6-luna"]) {
|
||||
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai-responses"]);
|
||||
expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull();
|
||||
|
||||
138
tests/unit/provider-priority-insert-cost.test.js
Normal file
138
tests/unit/provider-priority-insert-cost.test.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createProviderConnection,
|
||||
getProviderConnections,
|
||||
deleteProviderConnection,
|
||||
updateProviderConnection,
|
||||
} from "../../src/lib/db/index.js";
|
||||
|
||||
// #4311: POST /api/providers was O(pool) per insert. Inside one transaction it
|
||||
// read the whole pool AND renumbered every row's priority, so a 5k-key import
|
||||
// was O(n*m) — ~25M statements at a 5k pool — and every parallel writer
|
||||
// serialized on the same transaction. On top of that, an apikey name collision
|
||||
// silently overwrote the stored key with no 409.
|
||||
//
|
||||
// The test DB persists across tests in a file, so each case uses its own
|
||||
// provider alias; priorities are per-provider.
|
||||
|
||||
async function seed(provider, n) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
await createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: `seed-${i}`,
|
||||
apiKey: `k${i}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("provider insert is O(1) in pool size (#4311)", () => {
|
||||
it("assigns sequential priorities without a renumber pass", async () => {
|
||||
const P = `openai-compatible-seq-${Date.now()}`;
|
||||
await seed(P, 3);
|
||||
const list = await getProviderConnections({ provider: P });
|
||||
expect(list.map((c) => c.name)).toEqual(["seed-0", "seed-1", "seed-2"]);
|
||||
expect(list.map((c) => c.priority)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("keeps a large pool in insertion order", async () => {
|
||||
const P = `openai-compatible-ord-${Date.now()}`;
|
||||
await seed(P, 60);
|
||||
const list = await getProviderConnections({ provider: P });
|
||||
expect(list).toHaveLength(60);
|
||||
// The bug showed up as reordering once the pool grew past a few rows.
|
||||
expect(list[0].name).toBe("seed-0");
|
||||
expect(list[59].name).toBe("seed-59");
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect(list[i].priority).toBeGreaterThan(list[i - 1].priority);
|
||||
}
|
||||
});
|
||||
|
||||
it("still renumbers on delete, so gaps do not accumulate", async () => {
|
||||
const P = `openai-compatible-del-${Date.now()}`;
|
||||
await seed(P, 4);
|
||||
const before = await getProviderConnections({ provider: P });
|
||||
await deleteProviderConnection(before[0].id);
|
||||
const after = await getProviderConnections({ provider: P });
|
||||
expect(after.map((c) => c.priority)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("still renumbers on an explicit priority update", async () => {
|
||||
// Unique alias per run: the DB persists across runs, so a fixed alias
|
||||
// would accumulate rows and make this assertion depend on test order.
|
||||
const P = `openai-compatible-upd-${Date.now()}`;
|
||||
await seed(P, 4);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const list = await getProviderConnections({ provider: P });
|
||||
// Move the last one to the front.
|
||||
await updateProviderConnection(list[3].id, { priority: 1 });
|
||||
const after = await getProviderConnections({ provider: P });
|
||||
expect(after[0].name).toBe("seed-3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("name collision no longer destroys a key silently (#4311)", () => {
|
||||
// Seeded once: these cases each mutate the SAME row, so a per-test seed
|
||||
// would make the later assertions depend on earlier ones.
|
||||
const P = `openai-compatible-clash-${Date.now()}`;
|
||||
const original = (async () => {
|
||||
await seed(P, 1);
|
||||
return (await getProviderConnections({ provider: P }))[0];
|
||||
})();
|
||||
|
||||
it("throws a typed conflict instead of overwriting, when overwrite is refused", async () => {
|
||||
const orig = await original;
|
||||
await expect(
|
||||
createProviderConnection({
|
||||
provider: P,
|
||||
authType: "apikey",
|
||||
name: orig.name,
|
||||
apiKey: "REPLACEMENT-KEY",
|
||||
allowOverwrite: false,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "PROVIDER_NAME_CONFLICT", existingId: orig.id });
|
||||
|
||||
// The stored key must be untouched.
|
||||
const after = (await getProviderConnections({ provider: P }))[0];
|
||||
expect(after.apiKey).toBe(orig.apiKey);
|
||||
});
|
||||
|
||||
it("still overwrites when the caller opts in", async () => {
|
||||
const orig = await original;
|
||||
const updated = await createProviderConnection({
|
||||
provider: P,
|
||||
authType: "apikey",
|
||||
name: orig.name,
|
||||
apiKey: "REPLACEMENT-KEY",
|
||||
allowOverwrite: true,
|
||||
});
|
||||
expect(updated.id).toBe(orig.id);
|
||||
const after = (await getProviderConnections({ provider: P }))[0];
|
||||
expect(after.apiKey).toBe("REPLACEMENT-KEY");
|
||||
});
|
||||
|
||||
it("defaults to the previous overwrite behaviour for existing callers", async () => {
|
||||
// Every other call site in the repo (oauth routes, bulk import) omits the
|
||||
// flag, so they must keep working exactly as before.
|
||||
const orig = await original;
|
||||
const updated = await createProviderConnection({
|
||||
provider: P,
|
||||
authType: "apikey",
|
||||
name: orig.name,
|
||||
apiKey: "LEGACY-PATH-KEY",
|
||||
});
|
||||
expect(updated.id).toBe(orig.id);
|
||||
});
|
||||
|
||||
it("does not collide across different providers", async () => {
|
||||
const orig = await original;
|
||||
const other = await createProviderConnection({
|
||||
provider: "openai-compatible-other",
|
||||
authType: "apikey",
|
||||
name: orig.name,
|
||||
apiKey: "other-key",
|
||||
});
|
||||
expect(other.id).not.toBe(orig.id);
|
||||
});
|
||||
});
|
||||
151
tests/unit/responses-completed-output.test.js
Normal file
151
tests/unit/responses-completed-output.test.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
import { initState } from "../../open-sse/translator/index.js";
|
||||
import { openaiToOpenAIResponsesResponse } from "../../open-sse/translator/response/openai-responses.js";
|
||||
|
||||
// targetFormat === OPENAI is the direct openai -> openai-responses route, which is
|
||||
// the only one where flush() reaches this translator (see the flushReachesUs note
|
||||
// above the finish_reason branch).
|
||||
function newState() {
|
||||
return { ...initState(FORMATS.OPENAI_RESPONSES), targetFormat: FORMATS.OPENAI };
|
||||
}
|
||||
|
||||
function textChunk(text, index = 0) {
|
||||
return { id: "chatcmpl-1", choices: [{ index, delta: { content: text } }] };
|
||||
}
|
||||
|
||||
function reasoningChunk(text, index = 0) {
|
||||
return { id: "chatcmpl-1", choices: [{ index, delta: { reasoning_content: text } }] };
|
||||
}
|
||||
|
||||
function finishChunk(usage) {
|
||||
return { id: "chatcmpl-1", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage };
|
||||
}
|
||||
|
||||
function runChunks(chunks) {
|
||||
const state = newState();
|
||||
const events = [];
|
||||
for (const chunk of chunks) {
|
||||
for (const event of openaiToOpenAIResponsesResponse(chunk, state)) events.push(event);
|
||||
}
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
function completedResponse(events) {
|
||||
const completed = events.find((event) => event.event === "response.completed");
|
||||
expect(completed, "expected a response.completed event").toBeTruthy();
|
||||
return completed.data.response;
|
||||
}
|
||||
|
||||
function doneItems(events) {
|
||||
return events
|
||||
.filter((event) => event.event === "response.output_item.done")
|
||||
.map((event) => event.data.item);
|
||||
}
|
||||
|
||||
describe("response.completed output (issue #4307)", () => {
|
||||
// The regression: sendCompleted() built the response object without an `output`
|
||||
// key at all, so response.completed arrived with no output even though the
|
||||
// message had already been streamed. Clients that build the final result from
|
||||
// the terminal event (GitHub Copilot CLI 1.0.89 with a BYOK provider) printed
|
||||
// the text and then failed with "No response was returned".
|
||||
it("repeats the streamed message in response.completed", () => {
|
||||
const state = newState();
|
||||
openaiToOpenAIResponsesResponse(textChunk("O"), state);
|
||||
openaiToOpenAIResponsesResponse(textChunk("K"), state);
|
||||
const response = completedResponse(openaiToOpenAIResponsesResponse(null, state));
|
||||
|
||||
expect(response.status).toBe("completed");
|
||||
expect(Array.isArray(response.output)).toBe(true);
|
||||
expect(response.output).toHaveLength(1);
|
||||
expect(response.output[0]).toMatchObject({ type: "message", role: "assistant" });
|
||||
expect(response.output[0].content[0]).toMatchObject({ type: "output_text", text: "OK" });
|
||||
});
|
||||
|
||||
it("matches exactly the items already delivered in response.output_item.done", () => {
|
||||
const { events } = runChunks([
|
||||
textChunk("hello"),
|
||||
finishChunk({ prompt_tokens: 7, completion_tokens: 2, total_tokens: 9 }),
|
||||
]);
|
||||
const response = completedResponse(events);
|
||||
const streamed = doneItems(events);
|
||||
|
||||
expect(streamed).toHaveLength(1);
|
||||
expect(response.output).toEqual(streamed);
|
||||
});
|
||||
|
||||
it("includes a function_call item", () => {
|
||||
const { events } = runChunks([
|
||||
{
|
||||
id: "chatcmpl-1",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "call_1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finishChunk({ prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
|
||||
]);
|
||||
const response = completedResponse(events);
|
||||
|
||||
expect(response.output).toHaveLength(1);
|
||||
expect(response.output[0]).toMatchObject({
|
||||
type: "function_call",
|
||||
name: "get_weather",
|
||||
arguments: '{"city":"Paris"}',
|
||||
call_id: "call_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("orders output by output_index", () => {
|
||||
const { events } = runChunks([
|
||||
reasoningChunk("thinking", 0),
|
||||
textChunk("answer", 1),
|
||||
finishChunk({ prompt_tokens: 4, completion_tokens: 3, total_tokens: 7 }),
|
||||
]);
|
||||
const response = completedResponse(events);
|
||||
|
||||
expect(response.output.map((item) => item.type)).toEqual(["reasoning", "message"]);
|
||||
expect(response.output[1].content[0]).toMatchObject({ type: "output_text", text: "answer" });
|
||||
});
|
||||
|
||||
it("reports an empty output array when nothing was produced", () => {
|
||||
const state = newState();
|
||||
const response = completedResponse(openaiToOpenAIResponsesResponse(null, state));
|
||||
expect(response.output).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the usage block alongside output", () => {
|
||||
const { events } = runChunks([
|
||||
textChunk("OK"),
|
||||
finishChunk({ prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 }),
|
||||
]);
|
||||
const response = completedResponse(events);
|
||||
|
||||
expect(response.usage).toMatchObject({ input_tokens: 3, output_tokens: 1, total_tokens: 4 });
|
||||
expect(response.output).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("leaves the in-progress response.created output empty", () => {
|
||||
const { events } = runChunks([textChunk("hi")]);
|
||||
const created = events.find((event) => event.event === "response.created");
|
||||
expect(created.data.response.status).toBe("in_progress");
|
||||
expect(created.data.response.output).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not duplicate items when flush runs more than once", () => {
|
||||
const state = newState();
|
||||
openaiToOpenAIResponsesResponse(textChunk("once"), state);
|
||||
openaiToOpenAIResponsesResponse(null, state);
|
||||
const second = openaiToOpenAIResponsesResponse(null, state);
|
||||
|
||||
expect(second).toEqual([]);
|
||||
expect(state.completedOutputItems.size).toBe(1);
|
||||
});
|
||||
});
|
||||
79
tests/unit/tokenharbor-provider.test.js
Normal file
79
tests/unit/tokenharbor-provider.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import REGISTRY from "../../open-sse/providers/registry/index.js";
|
||||
import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
import { getExecutor } from "../../open-sse/executors/index.js";
|
||||
import { DefaultExecutor } from "../../open-sse/executors/default.js";
|
||||
|
||||
describe("Token Harbor provider", () => {
|
||||
const entry = REGISTRY.find((e) => e.id === "tokenharbor");
|
||||
|
||||
it("is registered as an OpenAI-compatible apikey provider", () => {
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.category).toBe("apikey");
|
||||
expect(entry.authType).toBe("apikey");
|
||||
expect(entry.alias).toBe("tokenharbor");
|
||||
expect(entry.aliases).toContain("th");
|
||||
});
|
||||
|
||||
it("points at the verified OpenAI-compatible base URL", () => {
|
||||
expect(PROVIDERS.tokenharbor.baseUrl).toBe("https://tokenharbor.ai/v1/chat/completions");
|
||||
expect(PROVIDERS.tokenharbor.validateUrl).toBe("https://tokenharbor.ai/v1/models");
|
||||
// transport.format defaults to "openai" via the shared provider default
|
||||
expect(PROVIDERS.tokenharbor.format).toBe("openai");
|
||||
});
|
||||
|
||||
it("declares no provider-wide thinkingFormat so each model resolves its own", () => {
|
||||
// Token Harbor forwards bodies verbatim. A provider-wide thinkingFormat
|
||||
// would override capabilities.js and force one wire format (e.g.
|
||||
// claude-adaptive) onto every model, which an OpenAI endpoint rejects.
|
||||
expect(PROVIDERS.tokenharbor.thinkingFormat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables dynamic model discovery and passthrough", () => {
|
||||
expect(entry.passthroughModels).toBe(true);
|
||||
expect(entry.modelsFetcher).toMatchObject({
|
||||
url: "https://tokenharbor.ai/v1/models",
|
||||
type: "openai",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a small seed of bare (unprefixed) model ids", () => {
|
||||
const ids = (PROVIDER_MODELS.tokenharbor || []).map((m) => m.id);
|
||||
expect(ids.length).toBeGreaterThan(0);
|
||||
expect(ids).toContain("claude-opus-5.5");
|
||||
// Token Harbor does not prefix ids by upstream vendor
|
||||
expect(ids.every((id) => !id.includes("/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("routes through the shared DefaultExecutor (no custom adapter)", () => {
|
||||
expect(getExecutor("tokenharbor")).toBeInstanceOf(DefaultExecutor);
|
||||
});
|
||||
|
||||
it("resolves per-model capabilities from the shared tables", () => {
|
||||
// Bare ids must still reach the canonical family patterns.
|
||||
expect(getCapabilitiesForModel("tokenharbor", "claude-opus-5.5")).toMatchObject({
|
||||
vision: true,
|
||||
reasoning: true,
|
||||
thinkingFormat: "claude-adaptive",
|
||||
});
|
||||
expect(getCapabilitiesForModel("tokenharbor", "gpt-6-astra")).toMatchObject({
|
||||
reasoning: true,
|
||||
thinkingFormat: "openai",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invent capabilities for an uncatalogued model", () => {
|
||||
// Vision/reasoning must not be blanket-granted across the provider.
|
||||
const caps = getCapabilitiesForModel("tokenharbor", "some-unknown-model-x");
|
||||
expect(caps.vision).toBe(false);
|
||||
expect(caps.reasoning).toBe(false);
|
||||
expect(caps.thinkingFormat).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps every registry id unique after adding tokenharbor", () => {
|
||||
const ids = REGISTRY.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
57
tests/unit/usage-api-key-attribution.test.js
Normal file
57
tests/unit/usage-api-key-attribution.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
let tempDir;
|
||||
let db;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-api-key-"));
|
||||
process.env.DATA_DIR = tempDir;
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/db/index.js");
|
||||
await db.initDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
describe("Usage stats API key attribution", () => {
|
||||
it("keeps API keys with the same masked prefix in separate buckets", async () => {
|
||||
const apiKeyA = "sk-machine-aaaaaa-11111111";
|
||||
const apiKeyB = "sk-machine-bbbbbb-22222222";
|
||||
|
||||
await db.saveRequestUsage({
|
||||
provider: "openai",
|
||||
model: "gpt-4",
|
||||
connectionId: "c1",
|
||||
apiKey: apiKeyA,
|
||||
tokens: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
endpoint: "/v1/chat",
|
||||
status: "ok",
|
||||
});
|
||||
|
||||
await db.saveRequestUsage({
|
||||
provider: "openai",
|
||||
model: "gpt-4",
|
||||
connectionId: "c1",
|
||||
apiKey: apiKeyB,
|
||||
tokens: { prompt_tokens: 20, completion_tokens: 10 },
|
||||
endpoint: "/v1/chat",
|
||||
status: "ok",
|
||||
});
|
||||
|
||||
const stats = await db.getUsageStats("24h");
|
||||
const apiKeyEntries = Object.values(stats.byApiKey);
|
||||
|
||||
expect(apiKeyEntries).toHaveLength(2);
|
||||
|
||||
expect(
|
||||
apiKeyEntries
|
||||
.map((entry) => entry.promptTokens)
|
||||
.sort((a, b) => a - b)
|
||||
).toEqual([10, 20]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
// Route-level acceptance for the Zed live-model wiring:
|
||||
// GET /api/providers/[connectionId]/models → resolveZedModels → UI rows
|
||||
// RUN WITH AN ISOLATED DB: DATA_DIR=$(mktemp -d) npx vitest run ...
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { GET } from "@/app/api/providers/[id]/models/route.js";
|
||||
import { createProviderConnection } from "@/models/index.js";
|
||||
// Self-isolating: DATA_DIR points at a temp dir so seeding never touches ~/.9router.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// Transport stub BELOW resolveZedModels: proxyAwareFetch captures the native
|
||||
// fetch at import time, so stubbing globalThis.fetch cannot intercept it.
|
||||
@@ -72,6 +73,24 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Imports must be dynamic so DATA_DIR is set before the DB layer loads.
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
let GET;
|
||||
let createProviderConnection;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "9router-zed-live-"));
|
||||
vi.resetModules();
|
||||
({ GET } = await import("@/app/api/providers/[id]/models/route.js"));
|
||||
({ createProviderConnection } = await import("@/models/index.js"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
async function seedZed(n) {
|
||||
return createProviderConnection({
|
||||
provider: "zed",
|
||||
|
||||
Reference in New Issue
Block a user