Merge remote-tracking branch 'origin/master' into gitea/new_feature
# Conflicts: # open-sse/executors/qoder.js # open-sse/handlers/chatCore.js # open-sse/handlers/chatCore/sseToJsonHandler.js # open-sse/providers/registry/commandcode.js # src/app/(dashboard)/dashboard/combos/page.js # src/app/api/v1/models/route.js # src/lib/db/repos/usageRepo.js # src/shared/components/UsageStats.js
This commit is contained in:
@@ -82,17 +82,97 @@ describe("Antigravity dashboard normalization with weekly quotas", () => {
|
||||
expect(weeklyRows[1].name).toMatch(/Weekly/);
|
||||
});
|
||||
|
||||
it("order: gemini family, claude family, weekly, then other", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
it("order: session, weekly, then other models", () => {
|
||||
const dataWithBoth = {
|
||||
quotas: {
|
||||
gemini_session: {
|
||||
displayName: "Gemini (5h)",
|
||||
used: 100,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-08T05:00:00Z",
|
||||
remainingPercentage: 90,
|
||||
},
|
||||
gemini_weekly: {
|
||||
displayName: "Gemini (Weekly)",
|
||||
used: 250,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-15T00:00:00Z",
|
||||
remainingPercentage: 75,
|
||||
},
|
||||
claude_gpt_session: {
|
||||
displayName: "Claude & GPT (5h)",
|
||||
used: 50,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-08T05:00:00Z",
|
||||
remainingPercentage: 95,
|
||||
},
|
||||
claude_gpt_weekly: {
|
||||
displayName: "Claude & GPT (Weekly)",
|
||||
used: 500,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-14T00:00:00Z",
|
||||
remainingPercentage: 50,
|
||||
},
|
||||
},
|
||||
};
|
||||
const quotas = parseQuotaData("antigravity", dataWithBoth);
|
||||
const keys = quotas.map((q) => q.modelKey);
|
||||
|
||||
const geminiIdx = keys.indexOf("gemini");
|
||||
const claudeIdx = keys.indexOf("claude");
|
||||
const geminiSessionIdx = keys.indexOf("gemini_session");
|
||||
const geminiWeeklyIdx = keys.indexOf("gemini_weekly");
|
||||
const claudeSessionIdx = keys.indexOf("claude_gpt_session");
|
||||
const claudeWeeklyIdx = keys.indexOf("claude_gpt_weekly");
|
||||
|
||||
expect(geminiIdx).toBeLessThan(geminiWeeklyIdx);
|
||||
expect(claudeIdx).toBeLessThan(claudeWeeklyIdx);
|
||||
expect(geminiSessionIdx).toBeLessThan(geminiWeeklyIdx);
|
||||
expect(claudeSessionIdx).toBeLessThan(claudeWeeklyIdx);
|
||||
});
|
||||
|
||||
it("excludes redundant duplicates when individual models mirror weekly reset and summary is present", () => {
|
||||
// Exact scenario from Christian's account:
|
||||
const liveLikeData = {
|
||||
quotas: {
|
||||
"gemini-3.8-flash-high": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-23T06:00:17Z", displayName: "Gemini 3.8 Flash (High)" },
|
||||
"claude-sonnet-4-6": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-20T19:00:21Z", displayName: "Claude 3.7 Sonnet" },
|
||||
"gpt-oss-120b-medium": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-20T19:00:21Z", displayName: "GPT-OSS 120B (Medium)" },
|
||||
"gemini-3.1-flash-image": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-23T06:00:17Z", displayName: "Gemini 3.1 Flash Image" },
|
||||
gemini_weekly: {
|
||||
displayName: "Gemini (Weekly)",
|
||||
used: 1000,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-23T06:00:17Z",
|
||||
remainingPercentage: 0,
|
||||
},
|
||||
claude_gpt_weekly: {
|
||||
displayName: "Claude & GPT (Weekly)",
|
||||
used: 807,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-24T18:09:46Z",
|
||||
remainingPercentage: 19.3,
|
||||
},
|
||||
claude_gpt_session: {
|
||||
displayName: "Claude & GPT (5h)",
|
||||
used: 1000,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-20T19:00:21Z",
|
||||
remainingPercentage: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const quotas = parseQuotaData("antigravity", liveLikeData);
|
||||
const names = quotas.map((q) => q.name);
|
||||
|
||||
// Should contain unique usages:
|
||||
expect(names).toContain("Claude & GPT (5h)");
|
||||
expect(names).toContain("Claude & GPT (Weekly)");
|
||||
expect(names).toContain("Gemini (Weekly)");
|
||||
expect(names).toContain("Gemini 3.1 Flash Image");
|
||||
|
||||
// Should NOT contain redundant duplicate entries:
|
||||
expect(names).not.toContain("Gemini (Flash / Pro)"); // duplicate of Gemini (Weekly)
|
||||
expect(names).not.toContain("Claude (Sonnet / Opus)"); // duplicate of Claude & GPT (5h)
|
||||
expect(names).not.toContain("GPT-OSS 120B (Medium)"); // covered by Claude & GPT family
|
||||
expect(quotas).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("works with no weekly keys present (backward compat)", () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ const NESTED_RESPONSE = {
|
||||
|
||||
// — parseWeeklyQuotaSummary ———————————————————————————————
|
||||
describe("parseWeeklyQuotaSummary", () => {
|
||||
it("extracts Gemini weekly quota from top-level groups", () => {
|
||||
it("extracts Gemini weekly and session quotas from top-level groups", () => {
|
||||
const result = parseWeeklyQuotaSummary(FULL_RESPONSE);
|
||||
expect(result.gemini_weekly).toMatchObject({
|
||||
used: 250,
|
||||
@@ -63,6 +63,14 @@ describe("parseWeeklyQuotaSummary", () => {
|
||||
unlimited: false,
|
||||
});
|
||||
expect(result.gemini_weekly.resetAt).toBe("2026-09-15T00:00:00.000Z");
|
||||
expect(result.gemini_session).toMatchObject({
|
||||
used: 100,
|
||||
total: 1000,
|
||||
remainingPercentage: 90,
|
||||
displayName: "Gemini (5h)",
|
||||
unlimited: false,
|
||||
});
|
||||
expect(result.gemini_session.resetAt).toBe("2026-09-09T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("extracts Claude & GPT weekly quota", () => {
|
||||
@@ -85,14 +93,14 @@ describe("parseWeeklyQuotaSummary", () => {
|
||||
expect(result.claude_gpt_weekly.remainingPercentage).toBe(50);
|
||||
});
|
||||
|
||||
it("skips non-weekly buckets", () => {
|
||||
it("skips unrecognized non-weekly non-session buckets", () => {
|
||||
const data = {
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [
|
||||
{
|
||||
bucketId: "gemini-daily-bucket",
|
||||
displayName: "Daily Limit",
|
||||
bucketId: "gemini-monthly-bucket",
|
||||
displayName: "Monthly Limit",
|
||||
remainingFraction: 0.9,
|
||||
resetTime: "2026-09-09T00:00:00Z",
|
||||
},
|
||||
@@ -404,7 +412,7 @@ describe("weekly quota isolation from existing quota", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reconciles weekly quota to 0% when all paid-tier family models are exhausted", async () => {
|
||||
it("reconciles 5h session quota to 0% when all paid-tier family models are exhausted without clobbering weekly quota", async () => {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
if (url.includes(":loadCodeAssist")) {
|
||||
return {
|
||||
@@ -421,7 +429,7 @@ describe("weekly quota isolation from existing quota", () => {
|
||||
models: {
|
||||
"gemini-3.8-flash-high": {
|
||||
displayName: "Gemini 3.8 Flash (High)",
|
||||
// Exhausted model: no remainingFraction, future resetTime
|
||||
// Exhausted model: no remainingFraction, future resetTime (5h window reset)
|
||||
quotaInfo: { resetTime: "2026-09-13T12:00:00Z" },
|
||||
},
|
||||
},
|
||||
@@ -435,30 +443,49 @@ describe("weekly quota isolation from existing quota", () => {
|
||||
json: async () => ({
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [{
|
||||
bucketId: "gemini-weekly",
|
||||
displayName: "Weekly Limit Remaining",
|
||||
remainingFraction: 1,
|
||||
resetTime: "2026-09-15T00:00:00Z",
|
||||
}],
|
||||
buckets: [
|
||||
{
|
||||
bucketId: "gemini-weekly",
|
||||
displayName: "Weekly Limit Remaining",
|
||||
window: "weekly",
|
||||
remainingFraction: 0.75,
|
||||
resetTime: "2026-09-15T00:00:00Z",
|
||||
},
|
||||
{
|
||||
bucketId: "gemini-5h",
|
||||
displayName: "Five Hour Limit Remaining",
|
||||
window: "5h",
|
||||
remainingFraction: 1,
|
||||
resetTime: "2026-09-13T11:00:00Z",
|
||||
},
|
||||
],
|
||||
}],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
});
|
||||
|
||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
||||
const result = await getAntigravityUsage("token", {});
|
||||
const result = await getAntigravityUsage("token-exhausted", null);
|
||||
|
||||
// Per-model quota should show exhausted
|
||||
expect(result.quotas["gemini-3.8-flash-high"].remainingPercentage).toBe(0);
|
||||
// Weekly quota should be reconciled to 0% with the family reset time
|
||||
expect(result.quotas.gemini_weekly).toMatchObject({
|
||||
|
||||
// 5h session quota should be reconciled to 0% with the family reset time
|
||||
expect(result.quotas.gemini_session).toMatchObject({
|
||||
used: 1000,
|
||||
total: 1000,
|
||||
remainingPercentage: 0,
|
||||
resetAt: "2026-09-13T12:00:00.000Z",
|
||||
});
|
||||
|
||||
// Weekly quota should remain intact and NOT be clobbered to 0% or steal the 5h resetAt
|
||||
expect(result.quotas.gemini_weekly).toMatchObject({
|
||||
used: 250,
|
||||
total: 1000,
|
||||
remainingPercentage: 75,
|
||||
resetAt: "2026-09-15T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,3 +114,139 @@ describe("getCapabilitiesForModel", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilitiesForModel — MiMo (<think>-tag reasoning, always-on)", () => {
|
||||
it("mimo-v2.5 has vision + reasoning + deepseek format, cannot disable", () => {
|
||||
const caps = getCapabilitiesForModel(null, "mimo-v2.5");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("mimo-v2.5-pro has vision (matches *mimo*v2.5* pattern)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "mimo-v2.5-pro");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("xiaomi/mimo-v2.5-pro (vendor-prefixed) has vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "xiaomi/mimo-v2.5-pro");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
});
|
||||
|
||||
it("mimo-omni-x has audioInput via the omni pattern", () => {
|
||||
const caps = getCapabilitiesForModel(null, "mimo-omni-x");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.audioInput).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("generic mimo has vision + reasoning (fallback pattern)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "mimo");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilitiesForModel — Qwen max/plus vision", () => {
|
||||
it("qwen3.7-max has vision (*qwen*max* fires before *qwen3.7*)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "qwen3.7-max");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("Qwen3.6-Max-Preview has vision (case-insensitive pattern match)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "Qwen3.6-Max-Preview");
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
|
||||
it("qwen3.7-plus has vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "qwen3.7-plus");
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
|
||||
it("qwen3.7 has vision from the qwen3.7 pattern", () => {
|
||||
const caps = getCapabilitiesForModel(null, "qwen3.7");
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
|
||||
it("qwq has no vision (thinking-only model)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "qwq-32b");
|
||||
expect(caps.vision).toBe(false);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilitiesForModel — MiniMax M2.x vision", () => {
|
||||
it("minimax-m2.7 has vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "minimax-m2.7");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("minimax-m2.5 has vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "minimax-m2.5");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("MiniMax-M2.7 has vision (vendor prefix MiniMaxAI/ stripped by route)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "MiniMaxAI/MiniMax-M2.7");
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
|
||||
it("minimax-m3 has vision (separate pattern)", () => {
|
||||
const caps = getCapabilitiesForModel(null, "minimax-m3");
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilitiesForModel — DeepSeek V4 text-only", () => {
|
||||
it("deepseek-v4-pro has no vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "deepseek-v4-pro");
|
||||
expect(caps.vision).toBe(false);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
});
|
||||
|
||||
it("deepseek-v4-flash has no vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "deepseek-v4-flash");
|
||||
expect(caps.vision).toBe(false);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("deepseek/deepseek-v4-pro (vendor-prefixed) has no vision", () => {
|
||||
const caps = getCapabilitiesForModel(null, "deepseek/deepseek-v4-pro");
|
||||
expect(caps.vision).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilitiesForModel — codebuddy-cn provider overrides", () => {
|
||||
it("deepseek-v4-pro via codebuddy-cn uses openai thinking format", () => {
|
||||
const caps = getCapabilitiesForModel("codebuddy-cn", "deepseek-v4-pro");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("openai");
|
||||
expect(caps.thinkingCanDisable).toBe(true);
|
||||
});
|
||||
|
||||
it("minimax-m3 via codebuddy-cn has vision (provider override)", () => {
|
||||
const caps = getCapabilitiesForModel("codebuddy-cn", "minimax-m3");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("openai");
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
});
|
||||
|
||||
it("unknown provider falls through to pattern matching", () => {
|
||||
const caps = getCapabilitiesForModel("unknown-provider", "mimo-v2.5");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
});
|
||||
});
|
||||
|
||||
76
tests/unit/claude-refusal-stream.test.js
Normal file
76
tests/unit/claude-refusal-stream.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// A refusal from the Anthropic API (stop_reason "refusal", zero output tokens, no
|
||||
// content blocks) must reach an OpenAI-format client as finish_reason
|
||||
// "content_filter" carrying Anthropic's explanation — not as a clean, empty "stop".
|
||||
// Captured live on 2026-09-20 against claude-opus-5 via a Claude Code OAuth
|
||||
// connection: 9Router logged "Model succeeded · OUT 0" and the client saw nothing.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { claudeToOpenAIResponse } from "../../open-sse/translator/response/claude-to-openai.js";
|
||||
|
||||
const EXPLANATION =
|
||||
"This request was blocked as it seems to violate Anthropic's Terms of Service restrictions on reverse engineering or duplicating model outputs.";
|
||||
|
||||
function runStream(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);
|
||||
}
|
||||
return { state, out };
|
||||
}
|
||||
|
||||
const refusalStream = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_refusal", model: "claude-opus-5", role: "assistant", content: [],
|
||||
usage: { input_tokens: 637, cache_creation_input_tokens: 206779, cache_read_input_tokens: 0, output_tokens: 0 }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: {
|
||||
stop_reason: "refusal",
|
||||
stop_sequence: null,
|
||||
stop_details: { type: "refusal", category: "reasoning_extraction", explanation: EXPLANATION }
|
||||
},
|
||||
usage: { input_tokens: 637, cache_creation_input_tokens: 206779, cache_read_input_tokens: 0, output_tokens: 0 }
|
||||
},
|
||||
{ type: "message_stop" }
|
||||
];
|
||||
|
||||
describe("claude-to-openai: refusal stop_reason", () => {
|
||||
it("finishes with content_filter, not stop", () => {
|
||||
const { out } = runStream(refusalStream);
|
||||
const finishes = out.map(c => c.choices?.[0]?.finish_reason).filter(Boolean);
|
||||
expect(finishes).toEqual(["content_filter"]);
|
||||
});
|
||||
|
||||
it("surfaces Anthropic's explanation as message content", () => {
|
||||
const { out } = runStream(refusalStream);
|
||||
const text = out.map(c => c.choices?.[0]?.delta?.content || "").join("");
|
||||
expect(text).toBe(EXPLANATION);
|
||||
});
|
||||
|
||||
it("keeps usage on the final chunk (prompt tokens were billed)", () => {
|
||||
const { out } = runStream(refusalStream);
|
||||
const final = out.find(c => c.choices?.[0]?.finish_reason === "content_filter");
|
||||
expect(final.usage.prompt_tokens).toBe(637 + 206779);
|
||||
expect(final.usage.completion_tokens).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves a normal end_turn untouched", () => {
|
||||
const { out } = runStream([
|
||||
{ type: "message_start", message: { id: "m", model: "claude-opus-5", role: "assistant", content: [], usage: { input_tokens: 5, output_tokens: 0 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null, stop_details: null }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" }
|
||||
]);
|
||||
const finishes = out.map(c => c.choices?.[0]?.finish_reason).filter(Boolean);
|
||||
expect(finishes).toEqual(["stop"]);
|
||||
expect(out.map(c => c.choices?.[0]?.delta?.content || "").join("")).toBe("ok");
|
||||
});
|
||||
});
|
||||
155
tests/unit/combo-capabilities.test.js
Normal file
155
tests/unit/combo-capabilities.test.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { aggregateComboCapabilities } from "../../open-sse/providers/capabilities.js";
|
||||
|
||||
describe("aggregateComboCapabilities — null / empty", () => {
|
||||
it("returns null for null", () => {
|
||||
expect(aggregateComboCapabilities(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty array", () => {
|
||||
expect(aggregateComboCapabilities([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — single model passthrough", () => {
|
||||
it("single model returns its own capabilities", () => {
|
||||
const caps = aggregateComboCapabilities(["opencode-go/mimo-v2.5"]);
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
expect(caps.thinkingCanDisable).toBe(false);
|
||||
expect(caps.contextWindow).toBe(1048576);
|
||||
expect(caps.maxOutput).toBe(131072);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — union fields (vision, audioInput, search)", () => {
|
||||
it("vision is true if any backend has it", () => {
|
||||
// deepseek-v4-pro: no vision; mimo-v2.5: vision
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/deepseek-v4-pro",
|
||||
"opencode-go/mimo-v2.5",
|
||||
]);
|
||||
expect(caps.vision).toBe(true);
|
||||
});
|
||||
|
||||
it("vision is false if no backend has it", () => {
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/deepseek-v4-pro",
|
||||
"opencode-go/deepseek-v4-flash",
|
||||
]);
|
||||
expect(caps.vision).toBe(false);
|
||||
});
|
||||
|
||||
it("audioInput is true if any backend has it", () => {
|
||||
// mimo-omni has audioInput; mimo-v2.5 does not
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/mimo-v2.5",
|
||||
"opencode-go/mimo-omni-test",
|
||||
]);
|
||||
expect(caps.audioInput).toBe(true);
|
||||
});
|
||||
|
||||
it("search is true if any backend has it", () => {
|
||||
// gpt-5: search; mimo-v2.5: no search
|
||||
const caps = aggregateComboCapabilities([
|
||||
"openai/gpt-5",
|
||||
"opencode-go/mimo-v2.5",
|
||||
]);
|
||||
expect(caps.search).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — intersection: tools", () => {
|
||||
it("tools is false if any backend lacks it", () => {
|
||||
// gpt-image-1: tools:false; gpt-5: tools:true
|
||||
const caps = aggregateComboCapabilities([
|
||||
"openai/gpt-5",
|
||||
"openai/gpt-image-1",
|
||||
]);
|
||||
expect(caps.tools).toBe(false);
|
||||
});
|
||||
|
||||
it("tools is true when all backends support it", () => {
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/mimo-v2.5",
|
||||
"opencode-go/kimi-k2.5",
|
||||
]);
|
||||
expect(caps.tools).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — primary model drives reasoning fields", () => {
|
||||
it("thinkingFormat comes from the first model", () => {
|
||||
// primary: mimo-v2.5 (deepseek); secondary: kimi-k2.5 (kimi)
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/mimo-v2.5",
|
||||
"opencode-go/kimi-k2.5",
|
||||
]);
|
||||
expect(caps.thinkingFormat).toBe("deepseek");
|
||||
expect(caps.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("flipping order changes thinkingFormat to the new primary", () => {
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/kimi-k2.5",
|
||||
"opencode-go/mimo-v2.5",
|
||||
]);
|
||||
expect(caps.thinkingFormat).toBe("kimi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — context/output limits", () => {
|
||||
it("contextWindow is the minimum across all models", () => {
|
||||
// mimo-v2.5: 1048576; kimi-k2.5 (*kimi*k2* pattern): 262144
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/mimo-v2.5",
|
||||
"opencode-go/kimi-k2.5",
|
||||
]);
|
||||
expect(caps.contextWindow).toBe(262144);
|
||||
});
|
||||
|
||||
it("maxOutput is the maximum across all models", () => {
|
||||
// mimo-v2.5: 131072; kimi-k2.5 (*kimi*k2* pattern): 262144
|
||||
const caps = aggregateComboCapabilities([
|
||||
"opencode-go/mimo-v2.5",
|
||||
"opencode-go/kimi-k2.5",
|
||||
]);
|
||||
expect(caps.maxOutput).toBe(262144);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateComboCapabilities — nested combo resolution via comboLookup", () => {
|
||||
it("resolves nested combo and unions vision from its members", () => {
|
||||
const lookup = { "inner-combo": ["opencode-go/deepseek-v4-pro", "opencode-go/mimo-v2.5"] };
|
||||
const caps = aggregateComboCapabilities(["inner-combo"], lookup);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.vision).toBe(true); // mimo brings vision through the lookup
|
||||
});
|
||||
|
||||
it("outer combo gets vision via nested combo containing mimo", () => {
|
||||
const lookup = { "deepseek-v4-pro-fusion": ["opencode-go/deepseek-v4-pro", "opencode-go/mimo-v2.5"] };
|
||||
const caps = aggregateComboCapabilities(["deepseek-v4-pro-fusion", "openai/gpt-5"], lookup);
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("contextWindow is min across all resolved leaves", () => {
|
||||
// deepseek-v4-pro (*deepseek-v4*): 1000000; mimo-v2.5: 1048576 → min = 1000000
|
||||
const lookup = { "inner": ["opencode-go/deepseek-v4-pro"] };
|
||||
const caps = aggregateComboCapabilities(["inner", "opencode-go/mimo-v2.5"], lookup);
|
||||
expect(caps.contextWindow).toBe(1000000);
|
||||
});
|
||||
|
||||
it("handles cycles without throwing", () => {
|
||||
const lookup = { "a": ["b"], "b": ["a"] };
|
||||
expect(() => aggregateComboCapabilities(["a"], lookup)).not.toThrow();
|
||||
});
|
||||
|
||||
it("without comboLookup bare combo name falls through to pattern match", () => {
|
||||
// *deepseek-v4* pattern: reasoning true, vision false
|
||||
const caps = aggregateComboCapabilities(["deepseek-v4-pro-fusion"]);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.vision).toBe(false);
|
||||
});
|
||||
});
|
||||
102
tests/unit/combo-presets.test.js
Normal file
102
tests/unit/combo-presets.test.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildPresetItems,
|
||||
buildCursorPresetItems,
|
||||
buildClaudePresetItems,
|
||||
isValidComboPresetName,
|
||||
} from "../../src/lib/comboPresets.js";
|
||||
|
||||
describe("combo presets", () => {
|
||||
it("rejects combo names with slashes or invalid chars", () => {
|
||||
expect(isValidComboPresetName("composer-2.5")).toBe(true);
|
||||
expect(isValidComboPresetName("claude-opus-5")).toBe(true);
|
||||
expect(isValidComboPresetName("cu/composer-2.5")).toBe(false);
|
||||
expect(isValidComboPresetName("bad name")).toBe(false);
|
||||
expect(isValidComboPresetName("")).toBe(false);
|
||||
});
|
||||
|
||||
it("Cursor live ids become unprefixed names seeded with cu/…", () => {
|
||||
const items = buildCursorPresetItems({
|
||||
liveModels: [
|
||||
{ id: "composer-2.5", name: "Composer 2.5" },
|
||||
{ id: "cursor-grok-4.6-high-fast", name: "Grok" },
|
||||
{ id: "bad/with-slash", name: "Invalid" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{ name: "composer-2.5", models: ["cu/composer-2.5"] },
|
||||
{ name: "cursor-grok-4.6-high-fast", models: ["cu/cursor-grok-4.6-high-fast"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("Cursor falls back to static cu registry when live catalog is empty", () => {
|
||||
const items = buildCursorPresetItems({ liveModels: [] });
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
expect(items.every((i) => i.models[0].startsWith("cu/"))).toBe(true);
|
||||
expect(items.some((i) => i.name === "default")).toBe(true);
|
||||
// No slash in combo name
|
||||
expect(items.every((i) => !i.name.includes("/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("Claude aliases map opus → cc/claude-opus-5 and registry models seed cc/…", () => {
|
||||
const items = buildClaudePresetItems();
|
||||
const byName = Object.fromEntries(items.map((i) => [i.name, i]));
|
||||
|
||||
expect(byName["claude-opus-5"]).toEqual({
|
||||
name: "claude-opus-5",
|
||||
models: ["cc/claude-opus-5"],
|
||||
});
|
||||
expect(byName.opus).toEqual({
|
||||
name: "opus",
|
||||
models: ["cc/claude-opus-5"],
|
||||
});
|
||||
expect(byName.sonnet).toEqual({
|
||||
name: "sonnet",
|
||||
models: ["cc/claude-sonnet-5"],
|
||||
});
|
||||
expect(byName.haiku).toEqual({
|
||||
name: "haiku",
|
||||
models: ["cc/claude-haiku-4-5-20251001"],
|
||||
});
|
||||
expect(byName.fable).toEqual({
|
||||
name: "fable",
|
||||
models: ["cc/claude-fable-5"],
|
||||
});
|
||||
expect(byName.default).toEqual({
|
||||
name: "default",
|
||||
models: ["cc/claude-sonnet-5"],
|
||||
});
|
||||
expect(byName.opusplan).toEqual({
|
||||
name: "opusplan",
|
||||
models: ["cc/claude-opus-5"],
|
||||
});
|
||||
});
|
||||
|
||||
it("marks existing names with exists: true", () => {
|
||||
const items = buildPresetItems("cursor", {
|
||||
liveModels: [
|
||||
{ id: "composer-2.5" },
|
||||
{ id: "gpt-5.3-codex" },
|
||||
],
|
||||
existingNames: ["composer-2.5"],
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{ name: "composer-2.5", models: ["cu/composer-2.5"], exists: true },
|
||||
{ name: "gpt-5.3-codex", models: ["cu/gpt-5.3-codex"], exists: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty for unknown source", () => {
|
||||
expect(buildPresetItems("unknown")).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops invalid names from Claude/Cursor catalogs", () => {
|
||||
const cursor = buildPresetItems("cursor", {
|
||||
liveModels: [{ id: "ok-model" }, { id: "no/slash" }, { id: "has space" }],
|
||||
existingNames: [],
|
||||
});
|
||||
expect(cursor.map((i) => i.name)).toEqual(["ok-model"]);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,18 @@ function textFrame(text) {
|
||||
return Buffer.from(wrapConnectRPCFrame(encodeField(1, LEN, update)));
|
||||
}
|
||||
|
||||
// InteractionUpdate.thinking_delta (field 4) + turn_ended (field 14).
|
||||
function thinkingFrame(text) {
|
||||
const thinkingPart = Buffer.from(encodeField(1, LEN, text));
|
||||
const update = Buffer.from(encodeField(4, LEN, thinkingPart));
|
||||
return Buffer.from(wrapConnectRPCFrame(encodeField(1, LEN, update)));
|
||||
}
|
||||
|
||||
function turnEndedFrame() {
|
||||
const update = Buffer.from(encodeField(14, LEN, new Uint8Array()));
|
||||
return Buffer.from(wrapConnectRPCFrame(encodeField(1, LEN, update)));
|
||||
}
|
||||
|
||||
function stubAgentSession(executor, frames) {
|
||||
const written = [];
|
||||
const queue = [...frames];
|
||||
@@ -48,12 +60,12 @@ function parseSSE(text) {
|
||||
.map((data) => JSON.parse(data));
|
||||
}
|
||||
|
||||
async function runAgent({ frames, stream }) {
|
||||
async function runAgent({ frames, stream, model = "gpt-5.2", tools }) {
|
||||
const executor = new CursorExecutor();
|
||||
const written = stubAgentSession(executor, frames);
|
||||
const result = await executor.executeAgent({
|
||||
model: "gpt-5.2",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
model,
|
||||
body: { messages: [{ role: "user", content: "hi" }], ...(tools ? { tools } : {}) },
|
||||
stream,
|
||||
credentials,
|
||||
});
|
||||
@@ -73,32 +85,45 @@ describe("CursorExecutor AgentService exec_request handling", () => {
|
||||
expect(content).toBe("hello");
|
||||
});
|
||||
|
||||
it("does not echo client tools on the request_context ack", async () => {
|
||||
const { written, result } = await runAgent({
|
||||
tools: [{ function: { name: "read_file", parameters: { type: "object" } } }],
|
||||
frames: [execRequestFrame(10), textFrame("hello")],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(written.length).toBe(2);
|
||||
expect(written[1].toString("utf8")).not.toContain("read_file");
|
||||
const content = parseSSE(await result.response.text())
|
||||
.map((e) => e.choices?.[0]?.delta?.content || "")
|
||||
.join("");
|
||||
expect(content).toBe("hello");
|
||||
});
|
||||
|
||||
it("does not render an unsupported exec request as assistant content", async () => {
|
||||
const { result } = await runAgent({
|
||||
frames: [textFrame("partial answer"), execRequestFrame(2)],
|
||||
const { result, written } = await runAgent({
|
||||
frames: [textFrame("partial answer"), execRequestFrame(2), textFrame(" more")],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const body = await result.response.text();
|
||||
expect(body).not.toContain("unsupported IDE tool\\n");
|
||||
expect(body).not.toContain("unsupported IDE tool");
|
||||
const events = parseSSE(body);
|
||||
const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join("");
|
||||
expect(content).toBe("partial answer");
|
||||
|
||||
const errorEvent = events.find((e) => e.error);
|
||||
expect(errorEvent?.error?.message).toContain("unsupported IDE tool");
|
||||
expect(events.some((e) => e.choices?.[0]?.finish_reason === "stop")).toBe(false);
|
||||
expect(content).toBe("partial answer more");
|
||||
expect(events.some((e) => e.error)).toBe(false);
|
||||
expect(written.length).toBe(2); // run frame + IDE rejection
|
||||
});
|
||||
|
||||
it("drops frames batched behind an unsupported exec request in the same read", async () => {
|
||||
it("still emits later text after rejecting an IDE exec in the same read", async () => {
|
||||
const { result } = await runAgent({
|
||||
frames: [Buffer.concat([execRequestFrame(2), textFrame("late")])],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const body = await result.response.text();
|
||||
expect(body).toContain("unsupported IDE tool");
|
||||
expect(body).not.toContain("late");
|
||||
expect(body).not.toContain("unsupported IDE tool");
|
||||
expect(body).toContain("late");
|
||||
});
|
||||
|
||||
it("returns a non-200 error body for an unsupported exec request when not streaming", async () => {
|
||||
@@ -111,4 +136,32 @@ describe("CursorExecutor AgentService exec_request handling", () => {
|
||||
const payload = await result.response.json();
|
||||
expect(payload.error.message).toContain("unsupported IDE tool");
|
||||
});
|
||||
|
||||
it("streams Composer visible content from thinking_delta after </think>", async () => {
|
||||
const { result } = await runAgent({
|
||||
model: "composer-2.5",
|
||||
frames: [
|
||||
thinkingFrame("private reasoning that must not leak</think>OK"),
|
||||
turnEndedFrame(),
|
||||
],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const events = parseSSE(await result.response.text());
|
||||
const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join("");
|
||||
expect(content).toBe("OK");
|
||||
expect(JSON.stringify(events)).not.toContain("private reasoning");
|
||||
});
|
||||
|
||||
it("flushes Grok thinking as visible content when the turn has no text_delta", async () => {
|
||||
const { result } = await runAgent({
|
||||
model: "grok-4.5",
|
||||
frames: [thinkingFrame("hello from grok"), turnEndedFrame()],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const events = parseSSE(await result.response.text());
|
||||
const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join("");
|
||||
expect(content).toBe("hello from grok");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,6 +246,15 @@ describe("Cursor AgentService executor helpers (cursor.js)", () => {
|
||||
const run = decodeMessage(clientMsg.get(1)[0].value);
|
||||
expect(run.has(2)).toBe(true); // action
|
||||
expect(run.has(9)).toBe(true); // requested_model
|
||||
// custom_system_prompt (field 8) makes AgentService return an empty turn.
|
||||
expect(run.has(8)).toBe(false);
|
||||
expect(run.has(3)).toBe(true); // ModelDetails — required for thinking variants
|
||||
const action = decodeMessage(run.get(2)[0].value);
|
||||
const userAction = decodeMessage(action.get(1)[0].value);
|
||||
const userMessage = decodeMessage(userAction.get(1)[0].value);
|
||||
const userText = Buffer.from(userMessage.get(1)[0].value).toString("utf8");
|
||||
expect(userText).toContain("be brief");
|
||||
expect(userText).toContain("hi");
|
||||
});
|
||||
|
||||
it("encodes mcp_tools (field 4) when tools are provided", () => {
|
||||
|
||||
@@ -40,6 +40,9 @@ describe("toOpenAIFinish - claude", () => {
|
||||
["end_turn", "stop"],
|
||||
["max_tokens", "length"],
|
||||
["tool_use", "tool_calls"],
|
||||
["stop_sequence", "stop"],
|
||||
["refusal", "content_filter"],
|
||||
["unknown_xyz", "stop"],
|
||||
])("%s -> %s", (input, expected) => {
|
||||
expect(toOpenAIFinish(input, "claude")).toBe(expected);
|
||||
});
|
||||
@@ -58,6 +61,9 @@ describe("fromOpenAIFinish round-trip - claude", () => {
|
||||
it("tool_calls -> tool_use", () => {
|
||||
expect(fromOpenAIFinish("tool_calls", "claude")).toBe("tool_use");
|
||||
});
|
||||
it("content_filter -> refusal", () => {
|
||||
expect(fromOpenAIFinish("content_filter", "claude")).toBe("refusal");
|
||||
});
|
||||
it("length -> max_tokens", () => {
|
||||
expect(fromOpenAIFinish("length", "claude")).toBe("max_tokens");
|
||||
});
|
||||
|
||||
144
tests/unit/huggingface-image-end-to-end.test.js
Normal file
144
tests/unit/huggingface-image-end-to-end.test.js
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* HuggingFace image generation — end-to-end through the real core handler.
|
||||
*
|
||||
* The registry/adapter tests pin the URL and payload in isolation. These tests
|
||||
* drive `handleImageGenerationCore` — the same function the `/v1/images/generations`
|
||||
* route calls — so the whole seam is exercised: adapter selection, buildUrl /
|
||||
* buildBody / buildHeaders, the fetch call, and the binary response parse.
|
||||
*
|
||||
* The mocked `fetch` asserts on the exact request the router would receive, which
|
||||
* is the strongest check available without burning live Inference Providers credits
|
||||
* (the router bills before validating the payload, so a live probe can only prove
|
||||
* the path exists, never that the body is right).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { handleImageGenerationCore } from "../../open-sse/handlers/imageGenerationCore.js";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const CREDS = { apiKey: "hf_test_token" };
|
||||
|
||||
// A 1x1 transparent PNG — enough to prove the bytes survive the round trip.
|
||||
const PNG_1X1 = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||||
"base64"
|
||||
);
|
||||
|
||||
function mockBinaryResponse() {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () => PNG_1X1.buffer.slice(PNG_1X1.byteOffset, PNG_1X1.byteOffset + PNG_1X1.byteLength),
|
||||
};
|
||||
}
|
||||
|
||||
async function generate(body, model) {
|
||||
return handleImageGenerationCore({
|
||||
body,
|
||||
modelInfo: { provider: "huggingface", model },
|
||||
credentials: CREDS,
|
||||
log: null,
|
||||
});
|
||||
}
|
||||
|
||||
describe("HuggingFace image generation — end to end", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn().mockResolvedValue(mockBinaryResponse());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("posts a text-to-image request to the fal-ai router path", async () => {
|
||||
const result = await generate({ prompt: "a lighthouse at dusk" }, "black-forest-labs/FLUX.1-schnell");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe("https://router.huggingface.co/fal-ai/fal-ai/flux/schnell");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body)).toEqual({ inputs: "a lighthouse at dusk" });
|
||||
});
|
||||
|
||||
it("authenticates with the connection's API key", async () => {
|
||||
await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell");
|
||||
|
||||
const [, init] = global.fetch.mock.calls[0];
|
||||
expect(init.headers.Authorization).toBe("Bearer hf_test_token");
|
||||
});
|
||||
|
||||
it("never touches the dead api-inference host", async () => {
|
||||
await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell");
|
||||
|
||||
expect(global.fetch.mock.calls[0][0]).not.toContain("api-inference.huggingface.co");
|
||||
});
|
||||
|
||||
it("posts an image-to-image request with the source image in inputs", async () => {
|
||||
const result = await generate(
|
||||
{ prompt: "make it snow", image: "data:image/png;base64,AAAB" },
|
||||
"Qwen/Qwen-Image-Edit"
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe("https://router.huggingface.co/fal-ai/fal-ai/qwen-image-edit");
|
||||
// The router takes raw base64 in inputs and the prompt under parameters —
|
||||
// the data-URL prefix must be stripped, not forwarded.
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
inputs: "AAAB",
|
||||
parameters: { prompt: "make it snow" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an image-to-image model that was given no source image", async () => {
|
||||
const result = await generate({ prompt: "make it snow" }, "Qwen/Qwen-Image-Edit");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/requires a source image/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a model with no router mapping before calling upstream", async () => {
|
||||
const result = await generate({ prompt: "x" }, "some-org/unmapped-model");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/no HuggingFace router mapping/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the generated image as base64 to the client", async () => {
|
||||
const result = await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell");
|
||||
|
||||
const payload = await result.response.json();
|
||||
expect(payload.data[0].b64_json).toBe(PNG_1X1.toString("base64"));
|
||||
});
|
||||
|
||||
it("routes a self-hosted connection to its own endpoint", async () => {
|
||||
await handleImageGenerationCore({
|
||||
body: { prompt: "x" },
|
||||
modelInfo: { provider: "huggingface", model: "my-org/my-tgi-model" },
|
||||
credentials: { apiKey: "k", providerSpecificData: { baseUrl: "https://tgi.internal" } },
|
||||
log: null,
|
||||
});
|
||||
|
||||
expect(global.fetch.mock.calls[0][0]).toBe("https://tgi.internal/my-org/my-tgi-model");
|
||||
});
|
||||
|
||||
it("surfaces an upstream error instead of a broken image", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 402,
|
||||
text: async () => JSON.stringify({ error: "You have depleted your monthly included credits." }),
|
||||
json: async () => ({ error: "You have depleted your monthly included credits." }),
|
||||
});
|
||||
|
||||
const result = await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(402);
|
||||
});
|
||||
});
|
||||
349
tests/unit/huggingface-router-migration.test.js
Normal file
349
tests/unit/huggingface-router-migration.test.js
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* HuggingFace registry migration to router.huggingface.co
|
||||
*
|
||||
* The legacy base URL `https://api-inference.huggingface.co` no longer resolves
|
||||
* (DNS ENOTFOUND), so every HuggingFace image/STT request failed at the fetch
|
||||
* layer. The replacement is `https://router.huggingface.co`, which routes by
|
||||
* `<provider>/<providerResolvedModelId>` — the provider-resolved id is NOT the
|
||||
* Hub model id and must be resolved from the Hub API's inferenceProviderMapping.
|
||||
*
|
||||
* Covers:
|
||||
* - imageConfig base URL is the live router host, not the dead legacy host
|
||||
* - image URL builder emits the provider-resolved id, not the Hub id
|
||||
* - image URL builder throws a descriptive error for unmapped models
|
||||
* - sttConfig exists and points at the live router host
|
||||
* - every registered public model resolves through a provider the router serves
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import huggingface from "../../open-sse/providers/registry/huggingface.js";
|
||||
import imageAdapter from "../../open-sse/handlers/imageProviders/huggingface.js";
|
||||
|
||||
const DEAD_HOST = "api-inference.huggingface.co";
|
||||
const LIVE_ROUTER = "router.huggingface.co";
|
||||
|
||||
// Providers the router actually forwards to. replicate/wavespeed/deepinfra appear
|
||||
// in the Hub's inferenceProviderMapping but reject every router request with
|
||||
// "Model not supported by provider <name>", so they must not be used here.
|
||||
const ROUTABLE_PROVIDERS = new Set(["fal-ai", "hf-inference", "nscale", "together", "novita", "hyperbolic"]);
|
||||
|
||||
const imageConfig = huggingface.imageConfig;
|
||||
const modelMap = imageConfig.modelMap || {};
|
||||
|
||||
// modelMap values are either a bare path (text-to-image) or
|
||||
// { path, task: "image-to-image" } for models that require a source image.
|
||||
const mappingPath = (value) => (typeof value === "string" ? value : value.path);
|
||||
const mappingTask = (value) => (typeof value === "string" ? "text-to-image" : value.task || "text-to-image");
|
||||
|
||||
describe("HuggingFace registry — legacy host removal", () => {
|
||||
it("does not use the dead api-inference host for images", () => {
|
||||
expect(imageConfig.baseUrl).not.toContain(DEAD_HOST);
|
||||
});
|
||||
|
||||
it("points imageConfig at the live router host", () => {
|
||||
expect(imageConfig.baseUrl).toContain(LIVE_ROUTER);
|
||||
});
|
||||
|
||||
it("does not use the dead api-inference host for STT", () => {
|
||||
expect(huggingface.sttConfig?.baseUrl).not.toContain(DEAD_HOST);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HuggingFace STT dispatch", () => {
|
||||
it("declares an sttConfig so sttCore can dispatch", () => {
|
||||
expect(huggingface.sttConfig).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses the HuggingFace ASR wire format", () => {
|
||||
expect(huggingface.sttConfig.format).toBe("huggingface-asr");
|
||||
});
|
||||
|
||||
it("authenticates with a bearer API key", () => {
|
||||
expect(huggingface.sttConfig.authType).toBe("apikey");
|
||||
expect(huggingface.sttConfig.authHeader).toBe("bearer");
|
||||
});
|
||||
|
||||
it("advertises stt in serviceKinds", () => {
|
||||
expect(huggingface.serviceKinds).toContain("stt");
|
||||
});
|
||||
|
||||
it("points sttConfig at the hf-inference model route", () => {
|
||||
expect(huggingface.sttConfig.baseUrl).toBe("https://router.huggingface.co/hf-inference/models");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HuggingFace image URL builder", () => {
|
||||
it("routes FLUX.1-schnell through its fal-ai provider id", () => {
|
||||
expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell")).toBe(
|
||||
"https://router.huggingface.co/fal-ai/fal-ai/flux/schnell"
|
||||
);
|
||||
});
|
||||
|
||||
it("routes SDXL through its fal-ai provider id", () => {
|
||||
expect(imageAdapter.buildUrl("stabilityai/stable-diffusion-xl-base-1.0")).toBe(
|
||||
"https://router.huggingface.co/fal-ai/fal-ai/fast-sdxl"
|
||||
);
|
||||
});
|
||||
|
||||
it("never leaks the dead host into a built URL", () => {
|
||||
expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell")).not.toContain(DEAD_HOST);
|
||||
});
|
||||
|
||||
it("throws a descriptive error for a model with no provider mapping", () => {
|
||||
expect(() => imageAdapter.buildUrl("some-org/not-mapped-model")).toThrow(/no HuggingFace router mapping/i);
|
||||
});
|
||||
|
||||
it("lets a connection override the endpoint for a self-hosted model", () => {
|
||||
const creds = { providerSpecificData: { baseUrl: "https://tgi.internal/" } };
|
||||
|
||||
expect(imageAdapter.buildUrl("my-org/my-tgi-model", creds)).toBe("https://tgi.internal/my-org/my-tgi-model");
|
||||
});
|
||||
|
||||
it("does not apply the router mapping when a custom endpoint is set", () => {
|
||||
const creds = { providerSpecificData: { baseUrl: "https://tgi.internal" } };
|
||||
|
||||
// The custom endpoint knows its own model ids — the Hub id passes through verbatim.
|
||||
expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell", creds)).toBe(
|
||||
"https://tgi.internal/black-forest-labs/FLUX.1-schnell"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a blank custom endpoint", () => {
|
||||
expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell", { providerSpecificData: { baseUrl: " " } })).toBe(
|
||||
"https://router.huggingface.co/fal-ai/fal-ai/flux/schnell"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects traversal or query injection in the model id on a custom endpoint", () => {
|
||||
const creds = { providerSpecificData: { baseUrl: "https://tgi.internal" } };
|
||||
|
||||
for (const model of ["x/../../admin", "org//model", "model?x=1", "model#f"]) {
|
||||
expect(() => imageAdapter.buildUrl(model, creds), model).toThrow(/invalid model ID/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HuggingFace registry model table", () => {
|
||||
const modelsById = Object.fromEntries(huggingface.models.map((m) => [m.id, m]));
|
||||
const imageModels = huggingface.models.filter((m) => m.kind === "image");
|
||||
const sttModels = huggingface.models.filter((m) => m.kind === "stt");
|
||||
|
||||
it("every image model is present in imageConfig.modelMap", () => {
|
||||
for (const model of imageModels) {
|
||||
expect(modelMap[model.id], `model ${model.id} is missing from imageConfig.modelMap`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("every modelMap entry points at a routable provider", () => {
|
||||
for (const [hubId, value] of Object.entries(modelMap)) {
|
||||
const provider = String(mappingPath(value)).split("/")[0];
|
||||
expect(ROUTABLE_PROVIDERS.has(provider), `${hubId} -> unsupported provider ${provider}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every modelMap entry has a provider/model path shape", () => {
|
||||
for (const [hubId, value] of Object.entries(modelMap)) {
|
||||
expect(String(mappingPath(value)), `${hubId} has a malformed target`).toMatch(/^[a-z0-9-]+\/[A-Za-z0-9._/-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not advertise whisper-small, which has no live provider", () => {
|
||||
expect(modelsById["openai/whisper-small"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("advertises whisper-large-v3-turbo as its STT replacement", () => {
|
||||
expect(modelsById["openai/whisper-large-v3-turbo"]?.kind).toBe("stt");
|
||||
});
|
||||
|
||||
it("exposes the FLUX family image models", () => {
|
||||
for (const id of [
|
||||
"black-forest-labs/FLUX.1-schnell",
|
||||
"black-forest-labs/FLUX.1-dev",
|
||||
"black-forest-labs/FLUX.1-Krea-dev",
|
||||
"black-forest-labs/FLUX.1-Kontext-dev",
|
||||
"black-forest-labs/FLUX.2-dev",
|
||||
"black-forest-labs/FLUX.2-klein-9B",
|
||||
"black-forest-labs/FLUX.2-klein-4B",
|
||||
"black-forest-labs/FLUX.2-klein-base-9B",
|
||||
"black-forest-labs/FLUX.2-klein-base-4B",
|
||||
]) {
|
||||
expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image");
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the Qwen-Image family", () => {
|
||||
for (const id of [
|
||||
"Qwen/Qwen-Image",
|
||||
"Qwen/Qwen-Image-2512",
|
||||
"Qwen/Qwen-Image-Edit",
|
||||
"Qwen/Qwen-Image-Edit-2509",
|
||||
"Qwen/Qwen-Image-Edit-2511",
|
||||
]) {
|
||||
expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image");
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the Stable Diffusion family", () => {
|
||||
for (const id of [
|
||||
"stabilityai/stable-diffusion-xl-base-1.0",
|
||||
"stabilityai/stable-diffusion-3.5-large",
|
||||
"stabilityai/stable-diffusion-3.5-large-turbo",
|
||||
]) {
|
||||
expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image");
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the HuggingFace ASR models", () => {
|
||||
for (const id of ["openai/whisper-large-v3", "openai/whisper-large-v3-turbo"]) {
|
||||
expect(modelsById[id]?.kind, `${id} should be registered as an stt model`).toBe("stt");
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the remaining third-party image models", () => {
|
||||
for (const id of [
|
||||
"tencent/HunyuanImage-3.0",
|
||||
"Tongyi-MAI/Z-Image-Turbo",
|
||||
"krea/Krea-2-Turbo",
|
||||
"HiDream-ai/HiDream-I1-Fast",
|
||||
"playgroundai/playground-v2.5-1024px-aesthetic",
|
||||
"ideogram-ai/ideogram-4-fp8",
|
||||
]) {
|
||||
expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the model table free of duplicates", () => {
|
||||
const ids = huggingface.models.map((m) => m.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("keeps STT models free of image-only router mappings", () => {
|
||||
for (const model of sttModels) {
|
||||
expect(modelMap[model.id], `STT model ${model.id} should not be in the image model map`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The router is a switchboard in front of many providers; every Hub model that is
|
||||
// `pipeline_tag: image-to-image` needs a source image, and the request shape differs
|
||||
// from text-to-image: `inputs` carries the base64 source image and the prompt moves
|
||||
// under `parameters.prompt`. Verified against
|
||||
// https://huggingface.co/docs/inference-providers/tasks/image-to-image
|
||||
describe("HuggingFace image-to-image models", () => {
|
||||
const IMAGE_TO_IMAGE = [
|
||||
"black-forest-labs/FLUX.2-dev",
|
||||
"black-forest-labs/FLUX.1-Kontext-dev",
|
||||
"black-forest-labs/FLUX.2-klein-9B",
|
||||
"black-forest-labs/FLUX.2-klein-4B",
|
||||
"black-forest-labs/FLUX.2-klein-base-9B",
|
||||
"black-forest-labs/FLUX.2-klein-base-4B",
|
||||
"Qwen/Qwen-Image-Edit",
|
||||
"Qwen/Qwen-Image-Edit-2509",
|
||||
"Qwen/Qwen-Image-Edit-2511",
|
||||
];
|
||||
|
||||
it("marks every image-to-image model as such in modelMap", () => {
|
||||
for (const hubId of IMAGE_TO_IMAGE) {
|
||||
expect(mappingTask(modelMap[hubId]), `${hubId} must be declared image-to-image`).toBe("image-to-image");
|
||||
}
|
||||
});
|
||||
|
||||
it("declares text-to-image as the default for the remaining image models", () => {
|
||||
for (const [hubId, value] of Object.entries(modelMap)) {
|
||||
if (IMAGE_TO_IMAGE.includes(hubId)) continue;
|
||||
expect(mappingTask(value), `${hubId} should default to text-to-image`).toBe("text-to-image");
|
||||
}
|
||||
});
|
||||
|
||||
it("sends the source image as inputs and the prompt under parameters", async () => {
|
||||
const body = await imageAdapter.buildBody("Qwen/Qwen-Image-Edit", {
|
||||
prompt: "make it snow",
|
||||
image: "data:image/png;base64,AAAA",
|
||||
});
|
||||
|
||||
expect(body.inputs).toBe("AAAA");
|
||||
expect(body.parameters).toEqual({ prompt: "make it snow" });
|
||||
});
|
||||
|
||||
it("accepts a source image given as a bare base64 payload", async () => {
|
||||
const body = await imageAdapter.buildBody("black-forest-labs/FLUX.2-dev", {
|
||||
prompt: "winter",
|
||||
image: "AAAA",
|
||||
});
|
||||
|
||||
expect(body.inputs).toBe("AAAA");
|
||||
});
|
||||
|
||||
it("accepts a source image given as an array", async () => {
|
||||
const body = await imageAdapter.buildBody("Qwen/Qwen-Image-Edit-2509", {
|
||||
prompt: "winter",
|
||||
images: ["data:image/png;base64,BBBB"],
|
||||
});
|
||||
|
||||
expect(body.inputs).toBe("BBBB");
|
||||
});
|
||||
|
||||
it("throws a descriptive error when an image-to-image model gets no source image", async () => {
|
||||
await expect(
|
||||
imageAdapter.buildBody("black-forest-labs/FLUX.1-Kontext-dev", { prompt: "winter" })
|
||||
).rejects.toThrow(/requires a source image/i);
|
||||
});
|
||||
|
||||
it("keeps the text-to-image shape prompt-only", async () => {
|
||||
const body = await imageAdapter.buildBody("black-forest-labs/FLUX.1-schnell", {
|
||||
prompt: "a lighthouse",
|
||||
image: "data:image/png;base64,AAAA",
|
||||
});
|
||||
|
||||
expect(body).toEqual({ inputs: "a lighthouse" });
|
||||
});
|
||||
|
||||
it("still throws for a model with no router mapping", () => {
|
||||
expect(() => imageAdapter.buildUrl("some-org/unknown")).toThrow(/no HuggingFace router mapping/i);
|
||||
});
|
||||
});
|
||||
|
||||
// The dashboard's GenericExampleCard only renders the source-image field when the
|
||||
// selected model declares capabilities: ["edit"] (GenericExampleCard.js:47), and it
|
||||
// then sends the value as `image`. Without the flag the edit models are unusable
|
||||
// from the UI even though the adapter supports them.
|
||||
describe("HuggingFace edit models reach the dashboard", () => {
|
||||
const IMAGE_TO_IMAGE = ["black-forest-labs/FLUX.2-dev", "Qwen/Qwen-Image-Edit"];
|
||||
|
||||
it("declares the edit capability on image-to-image models", () => {
|
||||
const modelsById = Object.fromEntries(huggingface.models.map((m) => [m.id, m]));
|
||||
|
||||
for (const hubId of IMAGE_TO_IMAGE) {
|
||||
expect(modelsById[hubId]?.capabilities, `${hubId} must declare the edit capability`).toContain("edit");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the capability on text-to-image models that do not take a source image", () => {
|
||||
const modelsById = Object.fromEntries(huggingface.models.map((m) => [m.id, m]));
|
||||
|
||||
expect(modelsById["black-forest-labs/FLUX.1-schnell"]?.capabilities || []).not.toContain("edit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HuggingFace registry prototype safety", () => {
|
||||
it("does not resolve inherited object keys as models", () => {
|
||||
// A plain-object map returns a truthy inherited value for these, which would
|
||||
// build a URL like `<base>/function Object() { [native code] }`.
|
||||
for (const key of ["toString", "constructor", "__proto__", "hasOwnProperty"]) {
|
||||
expect(() => imageAdapter.buildUrl(key)).toThrow(/no HuggingFace router mapping/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HuggingFace STT model parameters", () => {
|
||||
const sttModels = huggingface.models.filter((m) => m.kind === "stt");
|
||||
|
||||
it("does not advertise a language parameter the ASR route cannot carry", () => {
|
||||
// transcribeHuggingFace posts raw audio bytes and never reads formData, and the
|
||||
// router's ASR payload has no `language` field — so a UI-declared "language"
|
||||
// param is silently dropped. Declaring it lies to the dashboard.
|
||||
for (const model of sttModels) {
|
||||
expect(model.params, `${model.id} advertises an unusable language param`).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
@@ -44,6 +44,27 @@ const SAMPLE_USAGE = {
|
||||
},
|
||||
};
|
||||
|
||||
const SAMPLE_FREE_USAGE = {
|
||||
activity: {
|
||||
cost: "0.00000",
|
||||
period: {
|
||||
type: "last_4_weeks",
|
||||
starting_at: "2026-08-24T00:00:00Z",
|
||||
ending_at: "2026-09-18T15:03:00Z",
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
limits: {
|
||||
monthly: {
|
||||
usage: 0.021,
|
||||
models: [
|
||||
{ name: "gpt-oss:120b", request_count: 6 },
|
||||
{ name: "gemma4:31b", request_count: 6 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const SAMPLE_ME = {
|
||||
Plan: "max",
|
||||
};
|
||||
@@ -103,6 +124,98 @@ describe("getUsageForProvider(ollama)", () => {
|
||||
expect(meOpts.headers["Content-Length"]).toBe("0");
|
||||
});
|
||||
|
||||
it("maps the free plan's monthly window", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(SAMPLE_FREE_USAGE))
|
||||
.mockResolvedValueOnce(jsonResponse({ Plan: "free" }));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "ollama",
|
||||
apiKey: "k",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("Free");
|
||||
expect(Object.keys(usage.quotas)).toEqual(["Monthly"]);
|
||||
expect(usage.quotas["Monthly"]).toMatchObject({
|
||||
used: 2,
|
||||
total: 100,
|
||||
remainingPercentage: 98,
|
||||
unlimited: false,
|
||||
});
|
||||
expect(usage.quotas["Monthly"].remaining).toBeUndefined();
|
||||
expect(usage.quotas["Monthly"].resetAt).toBeNull();
|
||||
});
|
||||
|
||||
describe("free plan monthly reset from signup date", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function monthlyResetAt(createdAt, now) {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(now));
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(SAMPLE_FREE_USAGE))
|
||||
.mockResolvedValueOnce(jsonResponse({ Plan: "free", CreatedAt: createdAt }));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "ollama",
|
||||
apiKey: "k",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
return usage.quotas["Monthly"].resetAt;
|
||||
}
|
||||
|
||||
it("uses the signup day of the next month", async () => {
|
||||
expect(await monthlyResetAt("2025-09-06T22:15:39.871687Z", "2026-09-18T15:03:00Z"))
|
||||
.toBe("2026-10-06T22:15:39.000Z");
|
||||
});
|
||||
|
||||
it("stays in the current month when the signup day is still ahead", async () => {
|
||||
expect(await monthlyResetAt("2026-09-18T09:50:49.514335Z", "2026-09-18T15:33:33Z"))
|
||||
.toBe("2026-10-18T09:50:49.000Z");
|
||||
expect(await monthlyResetAt("2025-09-25T10:00:00Z", "2026-09-18T15:33:33Z"))
|
||||
.toBe("2026-09-25T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("clamps the signup day to shorter months", async () => {
|
||||
expect(await monthlyResetAt("2026-01-31T12:00:00Z", "2026-02-10T00:00:00Z"))
|
||||
.toBe("2026-02-28T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("skips the reset when the plan is not free", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-09-18T15:03:00Z"));
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(SAMPLE_FREE_USAGE))
|
||||
.mockResolvedValueOnce(jsonResponse({ Plan: "pro", CreatedAt: "2025-09-06T22:15:39Z" }));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "ollama",
|
||||
apiKey: "k",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
expect(usage.quotas["Monthly"].resetAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no limits when no known window is present", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse({ activity: {}, limits: {} }))
|
||||
.mockResolvedValueOnce(jsonResponse({ Plan: "free" }));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "ollama",
|
||||
apiKey: "k",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/no usage limits/i);
|
||||
expect(usage.quotas).toEqual({});
|
||||
});
|
||||
|
||||
it("surfaces invalid key message on 401", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ error: "unauthorized" }, 401),
|
||||
|
||||
162
tests/unit/openai-responses-usage-completed.test.js
Normal file
162
tests/unit/openai-responses-usage-completed.test.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
import { createSSETransformStreamWithLogger } from "../../open-sse/utils/stream.js";
|
||||
|
||||
/**
|
||||
* Upstream chunks -> client Responses API events.
|
||||
*
|
||||
* The converter under test is openaiToOpenAIResponsesResponse(), reached through
|
||||
* the registered OPENAI:OPENAI_RESPONSES pair. Without it, /v1/responses never
|
||||
* reports usage and Responses clients (Codex CLI) keep their context gauge at 0,
|
||||
* so they never auto-compact and eventually hit the upstream context limit.
|
||||
*
|
||||
* Signature is (targetFormat, sourceFormat, ...) — targetFormat is what the
|
||||
* UPSTREAM speaks, sourceFormat is what the CLIENT speaks.
|
||||
*/
|
||||
async function runTransform(chunks, targetFormat = FORMATS.OPENAI) {
|
||||
const encoder = new TextEncoder();
|
||||
const input = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("");
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(input));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const output = stream.pipeThrough(
|
||||
createSSETransformStreamWithLogger(
|
||||
targetFormat,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"deepseek",
|
||||
null,
|
||||
null,
|
||||
"deepseek-flash",
|
||||
),
|
||||
);
|
||||
|
||||
const reader = output.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
|
||||
text += decoder.decode();
|
||||
return text;
|
||||
}
|
||||
|
||||
function completedEvents(output) {
|
||||
return output
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("data: ") && l.includes('"type":"response.completed"'));
|
||||
}
|
||||
|
||||
function completedResponse(output) {
|
||||
const lines = completedEvents(output);
|
||||
expect(lines.length, "expected exactly one response.completed").toBe(1);
|
||||
return JSON.parse(lines[0].slice(6)).response;
|
||||
}
|
||||
|
||||
const TEXT_CHUNK = {
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1700000000,
|
||||
model: "deepseek-flash",
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: "好" } }],
|
||||
};
|
||||
|
||||
const FINISH_CHUNK = {
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1700000000,
|
||||
model: "deepseek-flash",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
};
|
||||
|
||||
// Usage-only trailer: `choices` is empty, exactly as OpenAI emits it when
|
||||
// stream_options.include_usage is set.
|
||||
const USAGE_ONLY_CHUNK = {
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1700000000,
|
||||
model: "deepseek-flash",
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: 884,
|
||||
completion_tokens: 37,
|
||||
total_tokens: 921,
|
||||
prompt_tokens_details: { cached_tokens: 256 },
|
||||
},
|
||||
};
|
||||
|
||||
const EXPECTED_USAGE = {
|
||||
input_tokens: 884,
|
||||
output_tokens: 37,
|
||||
total_tokens: 921,
|
||||
input_tokens_details: { cached_tokens: 256 },
|
||||
};
|
||||
|
||||
// Claude-shaped stream with NO usage anywhere: the only way the client gets a
|
||||
// terminal event is the finish_reason branch, because the pivot never reaches
|
||||
// flushEvents() with the terminal null chunk.
|
||||
const CLAUDE_CHUNKS = [
|
||||
{ type: "message_start", message: { id: "msg_1", model: "claude-x" } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
|
||||
describe("OpenAI Responses usage on response.completed", () => {
|
||||
it("maps usage reported on the finish chunk", async () => {
|
||||
const output = await runTransform([
|
||||
TEXT_CHUNK,
|
||||
{
|
||||
...FINISH_CHUNK,
|
||||
usage: {
|
||||
prompt_tokens: 884,
|
||||
completion_tokens: 37,
|
||||
total_tokens: 921,
|
||||
prompt_tokens_details: { cached_tokens: 256 },
|
||||
completion_tokens_details: { reasoning_tokens: 12 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(completedResponse(output).usage).toEqual({
|
||||
...EXPECTED_USAGE,
|
||||
output_tokens_details: { reasoning_tokens: 12 },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps usage reported on a trailing usage-only chunk with empty choices", async () => {
|
||||
const output = await runTransform([TEXT_CHUNK, FINISH_CHUNK, USAGE_ONLY_CHUNK]);
|
||||
|
||||
expect(completedResponse(output).usage).toEqual(EXPECTED_USAGE);
|
||||
});
|
||||
|
||||
it("still completes when the upstream reports no usage at all", async () => {
|
||||
const output = await runTransform([TEXT_CHUNK, FINISH_CHUNK]);
|
||||
|
||||
const response = completedResponse(output);
|
||||
expect(response.status).toBe("completed");
|
||||
expect(response).not.toHaveProperty("usage");
|
||||
});
|
||||
|
||||
// Regression guard for the pivot: with a Claude upstream the converter runs as
|
||||
// the second hop, translateResponse() drops the terminal null chunk before it
|
||||
// reaches this converter, so flushEvents() never runs. Deferring completion
|
||||
// there would leave the client without any terminal event.
|
||||
it("completes on a pivoted stream whose upstream never reports usage", async () => {
|
||||
const output = await runTransform(CLAUDE_CHUNKS, FORMATS.CLAUDE);
|
||||
|
||||
const response = completedResponse(output);
|
||||
expect(response.status).toBe("completed");
|
||||
});
|
||||
});
|
||||
191
tests/unit/opencode-fingerprint.test.js
Normal file
191
tests/unit/opencode-fingerprint.test.js
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
applyFingerprintTools,
|
||||
concealFingerprintToolNames,
|
||||
appendMissingFingerprintTools,
|
||||
fingerprintToolKey,
|
||||
restoreToolNames,
|
||||
takeRenamedToolNames,
|
||||
OPENCODE_FINGERPRINT_TOOLS,
|
||||
} from "open-sse/utils/opencodeFingerprint.js";
|
||||
|
||||
const CC_TOOLS = ["Task", "Bash", "Glob", "Grep", "Read", "Edit", "Write", "WebFetch"];
|
||||
const flat = (names) => names.map((name) => ({ type: "function", name }));
|
||||
const chat = (names) => names.map((name) => ({ type: "function", function: { name } }));
|
||||
|
||||
describe("opencodeFingerprint — request side", () => {
|
||||
it("renames capitalised quartet members to lowercase", () => {
|
||||
const body = { tools: flat(CC_TOOLS) };
|
||||
const map = applyFingerprintTools(body, true);
|
||||
const names = body.tools.map((tool) => tool.name);
|
||||
|
||||
expect(names).toContain("bash");
|
||||
expect(names).not.toContain("Bash");
|
||||
expect(names).toContain("Edit");
|
||||
expect(map.get("bash")).toBe("Bash");
|
||||
});
|
||||
|
||||
it("removes quartet case duplicates without dropping unrelated case variants", () => {
|
||||
const body = { tools: flat(["Bash", "bash", "Glob", "grep", "Read", "Foo", "foo"]) };
|
||||
applyFingerprintTools(body, true);
|
||||
|
||||
const names = body.tools.map((tool) => tool.name);
|
||||
expect(names.filter((name) => name === "bash")).toHaveLength(1);
|
||||
expect(names).toContain("Foo");
|
||||
expect(names).toContain("foo");
|
||||
});
|
||||
|
||||
it("preserves tool count when a complete quartet is only renamed", () => {
|
||||
const body = { tools: flat(CC_TOOLS) };
|
||||
applyFingerprintTools(body, true);
|
||||
expect(body.tools).toHaveLength(CC_TOOLS.length);
|
||||
});
|
||||
|
||||
it("handles the nested chat shape without dropping .function", () => {
|
||||
const body = { tools: chat(CC_TOOLS) };
|
||||
applyFingerprintTools(body, false);
|
||||
|
||||
const names = body.tools.map((tool) => tool.function.name);
|
||||
expect(names).toContain("bash");
|
||||
expect(names).not.toContain("Bash");
|
||||
expect(body.tools[1].function.name).toBe("bash");
|
||||
});
|
||||
|
||||
it("injects all four fingerprint tools when the body carries no tools", () => {
|
||||
const body = { tools: [] };
|
||||
applyFingerprintTools(body, true);
|
||||
|
||||
expect(body.tools.map((tool) => tool.name).sort()).toEqual([...OPENCODE_FINGERPRINT_TOOLS].sort());
|
||||
expect(body.tool_choice).toBe("auto");
|
||||
});
|
||||
|
||||
it("preserves the chat no-tool default tool_choice=none", () => {
|
||||
const body = {};
|
||||
applyFingerprintTools(body, false);
|
||||
|
||||
expect(body.tools.map((tool) => tool.function.name)).toEqual(OPENCODE_FINGERPRINT_TOOLS);
|
||||
expect(body.tool_choice).toBe("none");
|
||||
});
|
||||
|
||||
it("does not invent a chat tool_choice when the caller already supplied tools", () => {
|
||||
const body = { tools: chat(["Edit"]) };
|
||||
applyFingerprintTools(body, false);
|
||||
expect(body.tool_choice).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appends only genuinely missing quartet members", () => {
|
||||
const body = { tools: flat(["Bash", "Read", "terminal"]) };
|
||||
applyFingerprintTools(body, true);
|
||||
|
||||
const names = body.tools.map((tool) => tool.name);
|
||||
expect(names).toContain("glob");
|
||||
expect(names).toContain("grep");
|
||||
expect(names).toContain("terminal");
|
||||
expect(body.tools).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("retargets flat tool_choice that points at a renamed tool", () => {
|
||||
const body = { tools: flat(CC_TOOLS), tool_choice: { type: "function", name: "Bash" } };
|
||||
applyFingerprintTools(body, true);
|
||||
expect(body.tool_choice.name).toBe("bash");
|
||||
});
|
||||
|
||||
it("retargets nested tool_choice that points at a renamed tool", () => {
|
||||
const body = {
|
||||
tools: chat(CC_TOOLS),
|
||||
tool_choice: { type: "function", function: { name: "Read" } },
|
||||
};
|
||||
applyFingerprintTools(body, false);
|
||||
expect(body.tool_choice.function.name).toBe("read");
|
||||
});
|
||||
|
||||
it("records the rename map against the body for the response side", () => {
|
||||
const body = { tools: flat(CC_TOOLS) };
|
||||
const map = applyFingerprintTools(body, true);
|
||||
expect(takeRenamedToolNames(body)).toBe(map);
|
||||
});
|
||||
|
||||
it("never throws on malformed tools", () => {
|
||||
for (const tools of [null, undefined, "nope", [null, 42, []], [{}, { name: "" }]]) {
|
||||
expect(() => concealFingerprintToolNames(tools)).not.toThrow();
|
||||
expect(() => appendMissingFingerprintTools(tools, true)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("opencodeFingerprint — response side", () => {
|
||||
const map = new Map([["bash", "Bash"], ["grep", "Grep"], ["read", "Read"]]);
|
||||
|
||||
it("restores names in Claude content_block_start chunks", () => {
|
||||
const chunk = {
|
||||
type: "content_block_start",
|
||||
content_block: { type: "tool_use", name: "bash", id: "t1" },
|
||||
};
|
||||
const out = restoreToolNames(chunk, map);
|
||||
|
||||
expect(out.content_block.name).toBe("Bash");
|
||||
expect(chunk.content_block.name).toBe("bash");
|
||||
});
|
||||
|
||||
it("recursively restores streaming chunks inside arrays", () => {
|
||||
const chunks = [{
|
||||
choices: [{ delta: { tool_calls: [{ function: { name: "grep", arguments: "{}" } }] } }],
|
||||
}];
|
||||
const out = restoreToolNames(chunks, map);
|
||||
expect(out[0].choices[0].delta.tool_calls[0].function.name).toBe("Grep");
|
||||
});
|
||||
|
||||
it("restores names in Claude non-streaming bodies", () => {
|
||||
const body = { type: "message", content: [{ type: "tool_use", name: "bash", input: {} }] };
|
||||
expect(restoreToolNames(body, map).content[0].name).toBe("Bash");
|
||||
});
|
||||
|
||||
it("restores names in Chat Completions message and delta shapes", () => {
|
||||
const body = {
|
||||
choices: [
|
||||
{ message: { tool_calls: [{ function: { name: "grep", arguments: "{}" } }] } },
|
||||
{ delta: { tool_calls: [{ function: { name: "read", arguments: "{}" } }] } },
|
||||
],
|
||||
};
|
||||
const out = restoreToolNames(body, map);
|
||||
|
||||
expect(out.choices[0].message.tool_calls[0].function.name).toBe("Grep");
|
||||
expect(out.choices[1].delta.tool_calls[0].function.name).toBe("Read");
|
||||
});
|
||||
|
||||
it("restores names in Responses final output items", () => {
|
||||
const body = { output: [{ type: "function_call", name: "bash", call_id: "c1" }] };
|
||||
expect(restoreToolNames(body, map).output[0].name).toBe("Bash");
|
||||
});
|
||||
|
||||
it("restores names in Responses streaming output_item events", () => {
|
||||
const event = {
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", name: "read", call_id: "c1" },
|
||||
};
|
||||
expect(restoreToolNames(event, map).item.name).toBe("Read");
|
||||
});
|
||||
|
||||
it("is a no-op without a map or with an empty map", () => {
|
||||
const body = { choices: [{ message: { tool_calls: [{ function: { name: "bash" } }] } }] };
|
||||
expect(restoreToolNames(body, null)).toBe(body);
|
||||
expect(restoreToolNames(body, new Map())).toBe(body);
|
||||
});
|
||||
|
||||
it("leaves unknown tool names untouched", () => {
|
||||
const body = { output: [{ type: "function_call", name: "Edit" }] };
|
||||
expect(restoreToolNames(body, map).output[0].name).toBe("Edit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fingerprintToolKey", () => {
|
||||
it("maps quartet case/whitespace variants and rejects other tools", () => {
|
||||
expect(fingerprintToolKey("Bash")).toBe("bash");
|
||||
expect(fingerprintToolKey(" bash ")).toBe("bash");
|
||||
expect(fingerprintToolKey("GLOB")).toBe("glob");
|
||||
expect(fingerprintToolKey("Read")).toBe("read");
|
||||
expect(fingerprintToolKey("Edit")).toBe("");
|
||||
expect(fingerprintToolKey("terminal")).toBe("");
|
||||
expect(fingerprintToolKey(null)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -277,39 +277,68 @@ describe("OpenCode Stable Session Reuse (429 follow-up)", () => {
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("cloaks free-tier requests with bash and read decoy tools", () => {
|
||||
it("applies the full lowercase free-tier fingerprint quartet", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
|
||||
const chatNoTools = executor.transformRequest("nemotron-3-ultra-free", {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(chatNoTools.stream).toBe(true);
|
||||
expect(chatNoTools.tool_choice).toBe("none");
|
||||
expect(chatNoTools.tools.map((t) => t.function?.name)).toEqual([
|
||||
"bash", "glob", "grep", "read",
|
||||
]);
|
||||
|
||||
const chatWithTools = executor.transformRequest("nemotron-3-ultra-free", {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{ type: "function", function: { name: "Bash", description: "Claude Code tool" } },
|
||||
{ type: "function", function: { name: "Glob", description: "Claude Code tool" } },
|
||||
{ type: "function", function: { name: "Grep", description: "Claude Code tool" } },
|
||||
{ type: "function", function: { name: "Read", description: "Claude Code tool" } },
|
||||
],
|
||||
tool_choice: "auto",
|
||||
});
|
||||
expect(chatWithTools.tool_choice).toBe("auto");
|
||||
expect(chatWithTools.tools.map((t) => t.function?.name)).toEqual([
|
||||
"bash", "glob", "grep", "read",
|
||||
]);
|
||||
|
||||
const chatPartial = executor.transformRequest("nemotron-3-ultra-free", {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{ type: "function", function: { name: "bash", description: "existing" } },
|
||||
{ type: "function", function: { name: "read", description: "existing" } },
|
||||
],
|
||||
});
|
||||
expect(chatPartial.tools.map((t) => t.function?.name)).toEqual([
|
||||
"bash", "read", "glob", "grep",
|
||||
]);
|
||||
expect(chatPartial.tools[0].function.description).toBe("existing");
|
||||
});
|
||||
|
||||
it("cloaks Muse Responses requests even when the client already supplies tools", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
|
||||
// Case 1: no tools sent by client -> injects bash + read with tool_choice none
|
||||
const chatNoTools = executor.transformRequest("nemotron-3-ultra-free", {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(chatNoTools.stream).toBe(true);
|
||||
expect(chatNoTools.tool_choice).toBe("none");
|
||||
expect(chatNoTools.tools.map((t) => t.function?.name)).toEqual(["bash", "read"]);
|
||||
|
||||
// Case 2: external CLI tools (e.g. Claude Code Bash) -> preserves Bash, appends read
|
||||
const chatWithTools = executor.transformRequest("nemotron-3-ultra-free", {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [{ type: "function", function: { name: "Bash", description: "Claude Code tool" } }],
|
||||
const transformed = executor.transformRequest("muse-spark-1.3-contributor-free(xhigh)", {
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
||||
tools: [{
|
||||
type: "function",
|
||||
name: "zcode_search",
|
||||
description: "client-provided tool",
|
||||
parameters: { type: "object", properties: {} },
|
||||
}],
|
||||
tool_choice: "auto",
|
||||
});
|
||||
expect(chatWithTools.tool_choice).toBe("auto");
|
||||
const names = chatWithTools.tools.map((t) => t.function?.name);
|
||||
expect(names).toContain("Bash");
|
||||
reasoning_effort: "xhigh",
|
||||
}, true, {});
|
||||
|
||||
expect(transformed.stream).toBe(true);
|
||||
expect(transformed.reasoning?.effort).toBe("xhigh");
|
||||
const names = transformed.tools.map((tool) => tool.name);
|
||||
expect(names).toContain("zcode_search");
|
||||
expect(names).toContain("bash");
|
||||
expect(names).toContain("read");
|
||||
|
||||
// Case 3: already has both bash and read -> do not insert anything
|
||||
const chatFull = executor.transformRequest("nemotron-3-ultra-free", {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{ type: "function", function: { name: "bash", description: "existing" } },
|
||||
{ type: "function", function: { name: "read", description: "existing" } },
|
||||
],
|
||||
});
|
||||
expect(chatFull.tools.length).toBe(2);
|
||||
expect(chatFull.tools[0].function.description).toBe("existing");
|
||||
expect(names.filter((name) => name === "bash")).toHaveLength(1);
|
||||
expect(names.filter((name) => name === "read")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("declares forceStream on the opencode transport so chatCore serves SSE upstream", async () => {
|
||||
|
||||
123
tests/unit/opencode-zen-models.test.js
Normal file
123
tests/unit/opencode-zen-models.test.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PROVIDER_MODELS, getModelSupportedFormats } from "../../open-sse/config/providerModels.js";
|
||||
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-zen)
|
||||
const CHAT_ONLY = ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp",
|
||||
"glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5",
|
||||
"minimax-m3", "minimax-m2.7", "minimax-m2.5",
|
||||
"kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5",
|
||||
"big-pickle", "deepseek-v4-flash-free",
|
||||
"mimo-v2.6-flash-free", "mimo-v2.5-free", "ling-3.0-flash-fin-free", "nemotron-3-ultra-free", "nemotron-3.5-lightning-free"];
|
||||
// Models that also expose the Anthropic /messages endpoint
|
||||
const CLAUDE_CAPABLE = ["claude-fable-5", "claude-fable-5-1", "claude-opus-5",
|
||||
"claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5",
|
||||
"claude-sonnet-5", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-sonnet-4",
|
||||
"claude-haiku-4-5", "qwen3.6-plus", "qwen3.5-plus", "union-alpha"];
|
||||
// Models that also expose the OpenAI /responses endpoint
|
||||
const RESPONSES_CAPABLE = ["gpt-6-astra",
|
||||
"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
|
||||
"gpt-5.5", "gpt-5.5-pro",
|
||||
"gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini", "gpt-5.4-nano",
|
||||
"gpt-5.3-codex-spark", "gpt-5.3-codex",
|
||||
"gpt-5.2", "gpt-5.2-codex",
|
||||
"gpt-5.1", "gpt-5.1-codex-max", "gpt-5.1-codex", "gpt-5.1-codex-mini",
|
||||
"gpt-5", "gpt-5-codex", "gpt-5-nano",
|
||||
"grok-build-0.1", "grok-4.6", "grok-4.5",
|
||||
"muse-spark-1.3", "muse-spark-1.2",
|
||||
"muse-spark-1.3-contributor-free", "muse-spark-1.2-contributor-free"];
|
||||
|
||||
// Mirror of chatCore's per-model transport guard: use the sourceFormat-matched
|
||||
// transport only when the model declares support for that sourceFormat.
|
||||
function pickTransport(provider, sourceFormat, alias, model) {
|
||||
const supported = getModelSupportedFormats(alias, model);
|
||||
const rt = resolveTransport(provider, sourceFormat);
|
||||
return supported?.includes(sourceFormat) ? rt : null;
|
||||
}
|
||||
|
||||
describe("OpenCode Zen model catalog", () => {
|
||||
it("matches the documented model IDs", () => {
|
||||
const ids = (PROVIDER_MODELS["ocz"] || []).map((m) => m.id);
|
||||
expect(ids).toContain("muse-spark-1.3-contributor-free");
|
||||
expect(ids).toContain("gpt-5.5");
|
||||
expect(ids).toContain("claude-opus-5");
|
||||
expect(ids).toContain("kimi-k3");
|
||||
expect(ids).toContain("deepseek-v4-pro");
|
||||
expect(ids.length).toBeGreaterThan(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Zen per-model supportedFormats", () => {
|
||||
it("declares [claude] for Claude + Qwen + union-alpha models", () => {
|
||||
for (const m of CLAUDE_CAPABLE) {
|
||||
expect(getModelSupportedFormats("ocz", m)).toEqual(["claude"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares [openai-responses] for GPT/Grok/Spark responses models", () => {
|
||||
for (const m of RESPONSES_CAPABLE) {
|
||||
expect(getModelSupportedFormats("ocz", m)).toEqual(["openai-responses"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares [openai] only for chat-only models (GLM/Kimi/MiMo) → guards /messages routing", () => {
|
||||
for (const m of CHAT_ONLY) {
|
||||
expect(getModelSupportedFormats("ocz", m)).toEqual(["openai"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Zen multi-endpoint transports", () => {
|
||||
it("declares openai / claude / openai-responses transports", () => {
|
||||
const formats = (PROVIDERS["opencode-zen"].transports || []).map((t) => t.format);
|
||||
expect(formats).toEqual(["openai", "claude", "openai-responses"]);
|
||||
});
|
||||
|
||||
it("resolveTransport picks the endpoint matching the client sourceFormat", () => {
|
||||
expect(resolveTransport("opencode-zen", "claude").baseUrl).toBe("https://opencode.ai/zen/v1/messages");
|
||||
expect(resolveTransport("opencode-zen", "openai-responses").baseUrl).toBe("https://opencode.ai/zen/v1/responses");
|
||||
expect(resolveTransport("opencode-zen", "openai").baseUrl).toBe("https://opencode.ai/zen/v1/chat/completions");
|
||||
});
|
||||
|
||||
it("uses x-api-key + anthropicVersion on the claude transport", () => {
|
||||
const t = resolveTransport("opencode-zen", "claude");
|
||||
expect(t.auth.header).toBe("x-api-key");
|
||||
expect(t.auth.anthropicVersion).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Zen per-model transport guard (chatCore logic)", () => {
|
||||
it("routes MiniMax/Qwen + claude-format client to /messages", () => {
|
||||
for (const m of CLAUDE_CAPABLE) {
|
||||
expect(pickTransport("opencode-zen", "claude", "ocz", m)?.baseUrl).toBe("https://opencode.ai/zen/v1/messages");
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT route chat-only models to /messages on a claude-format request", () => {
|
||||
for (const m of CHAT_ONLY) {
|
||||
expect(pickTransport("opencode-zen", "claude", "ocz", m)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes DeepSeek + responses-format client to /responses", () => {
|
||||
for (const m of RESPONSES_CAPABLE) {
|
||||
expect(pickTransport("opencode-zen", "openai-responses", "ocz", m)?.baseUrl).toBe("https://opencode.ai/zen/v1/responses");
|
||||
}
|
||||
});
|
||||
|
||||
it("routes Muse Spark (responses-only) to /responses, never to /messages", () => {
|
||||
for (const m of ["muse-spark-1.2", "muse-spark-1.3", "muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free", "grok-4.6", "gpt-5.6-luna"]) {
|
||||
expect(getModelSupportedFormats("ocz", m)).toEqual(["openai-responses"]);
|
||||
expect(pickTransport("opencode-zen", "openai-responses", "ocz", m)?.baseUrl).toBe("https://opencode.ai/zen/v1/responses");
|
||||
expect(pickTransport("opencode-zen", "claude", "ocz", m)).toBeNull();
|
||||
expect(pickTransport("opencode-zen", "openai", "ocz", m)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT route MiniMax (no responses support) to /responses", () => {
|
||||
for (const m of CLAUDE_CAPABLE) {
|
||||
expect(pickTransport("opencode-zen", "openai-responses", "ocz", m)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -52,4 +52,48 @@ describe("stripUnsupportedParams", () => {
|
||||
|
||||
expect(body.max_tokens).toBe(64000);
|
||||
});
|
||||
|
||||
it("drops replayed reasoning fields from assistant messages for strict providers", () => {
|
||||
const makeBody = () => ({
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
reasoning_content: "thinking...",
|
||||
reasoning: "thinking...",
|
||||
reasoning_details: [{ text: "thinking..." }],
|
||||
tool_calls: [{ id: "c1", type: "function", function: { name: "f", arguments: "{}" } }],
|
||||
},
|
||||
{ role: "user", content: "again", reasoning_content: "user-side field stays" },
|
||||
],
|
||||
});
|
||||
|
||||
for (const [provider, model] of [
|
||||
["groq", "openai/gpt-oss-120b"],
|
||||
["mistral", "codestral-latest"],
|
||||
["cerebras", "gpt-oss-120b"],
|
||||
]) {
|
||||
const body = makeBody();
|
||||
stripUnsupportedParams(provider, model, body);
|
||||
expect(body.messages[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
tool_calls: [{ id: "c1", type: "function", function: { name: "f", arguments: "{}" } }],
|
||||
});
|
||||
// only assistant turns are touched
|
||||
expect(body.messages[2].reasoning_content).toBe("user-side field stays");
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves reasoning fields alone for providers that accept or require them", () => {
|
||||
const body = {
|
||||
messages: [{ role: "assistant", content: "hello", reasoning_content: "thinking..." }],
|
||||
};
|
||||
|
||||
stripUnsupportedParams("deepseek", "deepseek-reasoner", body);
|
||||
stripUnsupportedParams("openrouter", "nvidia/nemotron-3-ultra-550b-a55b:free", body);
|
||||
|
||||
expect(body.messages[0].reasoning_content).toBe("thinking...");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,148 @@ describe("wrapQoderSSE billing detection", () => {
|
||||
expect(wrapped.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 403 response when first frame is billing block (code 110 string)", async () => {
|
||||
const billingEnv = JSON.stringify({
|
||||
statusCodeValue: 403,
|
||||
body: '{"code":"110","message":"Billing daily count exceeded"}',
|
||||
});
|
||||
const upstream = `data: ${billingEnv}\n\n`;
|
||||
|
||||
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||
|
||||
expect(wrapped.status).toBe(403);
|
||||
expect(wrapped.ok).toBe(false);
|
||||
const json = await wrapped.json();
|
||||
expect(json.error.message).toContain("Billing daily count exceeded");
|
||||
});
|
||||
|
||||
it("returns 403 response when first frame is billing block (code 110 numeric)", async () => {
|
||||
const billingEnv = JSON.stringify({
|
||||
statusCodeValue: 403,
|
||||
body: '{"code":110,"message":"Billing daily count exceeded"}',
|
||||
});
|
||||
const upstream = `data: ${billingEnv}\n\n`;
|
||||
|
||||
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||
|
||||
expect(wrapped.status).toBe(403);
|
||||
expect(wrapped.ok).toBe(false);
|
||||
});
|
||||
it("returns 403 response when statusCodeValue is string \"403\" (code 110)", async () => {
|
||||
const billingEnv = JSON.stringify({
|
||||
statusCodeValue: "403",
|
||||
body: '{"code":"110","message":"Billing daily count exceeded"}',
|
||||
});
|
||||
const upstream = `data: ${billingEnv}\n\n`;
|
||||
|
||||
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||
|
||||
expect(wrapped.status).toBe(403);
|
||||
expect(wrapped.ok).toBe(false);
|
||||
const json = await wrapped.json();
|
||||
expect(json.error.message).toContain("Billing daily count exceeded");
|
||||
});
|
||||
|
||||
it("emits structured 403 error chunk for object-body billing after a data frame (peek miss)", async () => {
|
||||
const okEnv = JSON.stringify({
|
||||
statusCodeValue: 200,
|
||||
body: JSON.stringify({ choices: [{ delta: { content: "hi" } }] }),
|
||||
});
|
||||
const billingEnv = JSON.stringify({
|
||||
statusCodeValue: 403,
|
||||
body: { code: "110", message: "Billing daily count exceeded" },
|
||||
});
|
||||
const upstream = `data: ${okEnv}\n\ndata: ${billingEnv}\n\n`;
|
||||
|
||||
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||
|
||||
const reader = wrapped.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
}
|
||||
buf += decoder.decode();
|
||||
|
||||
expect(buf).not.toContain("[qoder error");
|
||||
const errLine = buf.split("\n").find((l) => l.includes('"error"'));
|
||||
expect(errLine).toBeDefined();
|
||||
const errChunk = JSON.parse(errLine.slice(5).trim());
|
||||
expect(errChunk.error.status).toBe(403);
|
||||
expect(errChunk.error.message).toContain("Billing daily count exceeded");
|
||||
expect(errChunk.choices).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
it("does not treat legitimate assistant text mentioning code 110 as billing", async () => {
|
||||
const inner = JSON.stringify({
|
||||
choices: [{ delta: { content: "error 110 means billing daily count exceeded in docs" } }],
|
||||
});
|
||||
const successEnv = JSON.stringify({ statusCodeValue: 200, body: inner });
|
||||
const upstream = `data: ${successEnv}\n\n`;
|
||||
|
||||
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||
|
||||
expect(wrapped.status).toBe(200);
|
||||
const reader = wrapped.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
}
|
||||
buf += decoder.decode();
|
||||
|
||||
expect(buf).toContain("billing daily count exceeded");
|
||||
expect(buf).not.toContain("[qoder error");
|
||||
});
|
||||
|
||||
it("emits structured 403 error chunk for billing envelope after a data frame (peek miss)", async () => {
|
||||
const okEnv = JSON.stringify({
|
||||
statusCodeValue: 200,
|
||||
body: JSON.stringify({ choices: [{ delta: { content: "hi" } }] }),
|
||||
});
|
||||
const billingEnv = JSON.stringify({
|
||||
statusCodeValue: 403,
|
||||
body: '{"code":"110","message":"Billing daily count exceeded"}',
|
||||
});
|
||||
const upstream = `data: ${okEnv}\n\ndata: ${billingEnv}\n\n`;
|
||||
|
||||
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||
|
||||
const reader = wrapped.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
}
|
||||
buf += decoder.decode();
|
||||
|
||||
expect(buf).not.toContain("[qoder error");
|
||||
const errLine = buf.split("\n").find((l) => l.includes('"error"'));
|
||||
expect(errLine).toBeDefined();
|
||||
const errChunk = JSON.parse(errLine.slice(5).trim());
|
||||
expect(errChunk.error.status).toBe(403);
|
||||
expect(errChunk.error.message).toContain("Billing daily count exceeded");
|
||||
});
|
||||
|
||||
it("emits structured 403 error chunk for object-body billing envelope (peek miss)", async () => {
|
||||
const billingEnv = JSON.stringify({
|
||||
statusCodeValue: 403,
|
||||
body: { code: "110", message: "Billing daily count exceeded" },
|
||||
});
|
||||
const upstream = `data: ${billingEnv}\n\n`;
|
||||
|
||||
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||
|
||||
expect(wrapped.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 403 response when first frame has pricingUrl", async () => {
|
||||
const billingEnv = JSON.stringify({
|
||||
statusCodeValue: 402,
|
||||
@@ -94,7 +236,7 @@ describe("wrapQoderSSE billing detection", () => {
|
||||
expect(wrapped.status).toBe(403);
|
||||
});
|
||||
|
||||
it("passes through normal errors (non-billing) as wrapped SSE", async () => {
|
||||
it("returns non-billing errors with their upstream HTTP status", async () => {
|
||||
const errorEnv = JSON.stringify({
|
||||
statusCodeValue: 500,
|
||||
body: "Internal server error",
|
||||
@@ -103,22 +245,11 @@ describe("wrapQoderSSE billing detection", () => {
|
||||
|
||||
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
|
||||
|
||||
// Normal error: still 200 response, error text in SSE body
|
||||
expect(wrapped.status).toBe(200);
|
||||
expect(wrapped.ok).toBe(true);
|
||||
|
||||
const reader = wrapped.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
}
|
||||
buf += decoder.decode();
|
||||
|
||||
expect(buf).toContain("[qoder error 500");
|
||||
expect(buf).toContain("data: [DONE]");
|
||||
expect(wrapped.status).toBe(500);
|
||||
expect(wrapped.ok).toBe(false);
|
||||
expect(await wrapped.json()).toEqual({
|
||||
error: { message: "Internal server error", code: 500 },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through successful responses unchanged", async () => {
|
||||
|
||||
97
tests/unit/qoder-proxy-replay.test.js
Normal file
97
tests/unit/qoder-proxy-replay.test.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/services/qoderModels.js", () => ({
|
||||
getQoderModelConfig: vi.fn(async () => ({ key: "auto", max_output_tokens: 32 })),
|
||||
resolveQoderModels: vi.fn(),
|
||||
isQoderPat: () => false,
|
||||
resolveQoderCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
const request = {
|
||||
model: "auto",
|
||||
body: { messages: [{ role: "user", content: "hello" }], max_tokens: 32 },
|
||||
stream: true,
|
||||
credentials: {
|
||||
accessToken: "dt-test-token",
|
||||
providerSpecificData: { userId: "test-user", machineId: "test-machine" },
|
||||
},
|
||||
};
|
||||
|
||||
function success() {
|
||||
return new Response('data: {"statusCodeValue":200,"body":"[DONE]"}\n\n', {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
async function loadExecutor(fetchMock, useProxy = true) {
|
||||
vi.resetModules();
|
||||
for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]) {
|
||||
vi.stubEnv(key, "");
|
||||
}
|
||||
if (useProxy) vi.stubEnv("HTTPS_PROXY", "http://proxy.test:3128");
|
||||
// Exercise the real proxyAwareFetch: it captures fetch when imported.
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { QoderExecutor } = await import("../../open-sse/executors/qoder.js");
|
||||
return new QoderExecutor();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("Qoder signed inference transport", () => {
|
||||
it.each([null, { strictProxy: false }])("does not replay a signed POST after proxy response loss (%j)", async (proxyOptions) => {
|
||||
const seen = new Set();
|
||||
const fetchMock = vi.fn(async (_url, options) => {
|
||||
const authorization = options.headers.Authorization;
|
||||
if (seen.has(authorization)) {
|
||||
return new Response('data: {"statusCodeValue":403,"body":"{\\"code\\":\\"103\\",\\"message\\":\\"Duplicate request\\"}"}\n\n');
|
||||
}
|
||||
seen.add(authorization);
|
||||
throw new TypeError("response lost after upstream accepted request");
|
||||
});
|
||||
const executor = await loadExecutor(fetchMock);
|
||||
await expect(executor.execute({ ...request, proxyOptions })).rejects.toThrow("response lost");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][1].dispatcher).toBeDefined();
|
||||
if (proxyOptions) expect(proxyOptions.strictProxy).toBe(false);
|
||||
});
|
||||
|
||||
it("generates a fresh COSY identity when the caller retries after transport failure", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockRejectedValueOnce(new TypeError("response lost"))
|
||||
.mockResolvedValueOnce(success());
|
||||
const executor = await loadExecutor(fetchMock);
|
||||
await expect(executor.execute(request)).rejects.toThrow("response lost");
|
||||
const result = await executor.execute(request);
|
||||
expect(result.response.ok).toBe(true);
|
||||
await result.response.text();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const ids = fetchMock.mock.calls.map(([, options]) => JSON.parse(
|
||||
Buffer.from(options.headers.Authorization.split(".")[1], "base64").toString(),
|
||||
).requestId);
|
||||
expect(ids[0]).not.toBe(ids[1]);
|
||||
});
|
||||
|
||||
it.each([true, false])("still supports successful inference with proxy=%s", async (useProxy) => {
|
||||
const fetchMock = vi.fn(async () => success());
|
||||
const executor = await loadExecutor(fetchMock, useProxy);
|
||||
const result = await executor.execute(request);
|
||||
expect(result.response.ok).toBe(true);
|
||||
await result.response.text();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(!!fetchMock.mock.calls[0][1].dispatcher).toBe(useProxy);
|
||||
});
|
||||
|
||||
it("preserves caller cancellation without replaying the request", async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn(async (_url, options) => {
|
||||
controller.abort();
|
||||
throw options.signal.reason;
|
||||
});
|
||||
const executor = await loadExecutor(fetchMock);
|
||||
await expect(executor.execute({ ...request, signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
81
tests/unit/qoder-stream-errors.test.js
Normal file
81
tests/unit/qoder-stream-errors.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { __test__ } from "../../open-sse/executors/qoder.js";
|
||||
|
||||
const { wrapQoderSSE } = __test__;
|
||||
const duplicate = '{"code":"103","message":"Duplicate request"}';
|
||||
const frame = (statusCodeValue, body) => `data: ${JSON.stringify({ statusCodeValue, body })}\n\n`;
|
||||
|
||||
function upstream(chunks, { keepOpen = false } = {}) {
|
||||
const cancel = vi.fn();
|
||||
const response = new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk));
|
||||
if (!keepOpen) controller.close();
|
||||
},
|
||||
cancel,
|
||||
}));
|
||||
return { response, cancel };
|
||||
}
|
||||
|
||||
describe("Qoder first-frame errors", () => {
|
||||
it.each([
|
||||
["one chunk", [frame(403, duplicate)]],
|
||||
["fragmented frame", [frame(403, duplicate).slice(0, 35), frame(403, duplicate).slice(35)]],
|
||||
["heartbeat prefix", [": keepalive\r\n\r\n", frame(403, duplicate)]],
|
||||
["prefix and frame in one chunk", [": keepalive\n\nevent: message\n" + frame(403, duplicate)]],
|
||||
["EOF without newline", [frame(403, duplicate).trimEnd()]],
|
||||
["object body", [frame(403, JSON.parse(duplicate))]],
|
||||
])("surfaces duplicate-request errors as HTTP 403: %s", async (_name, chunks) => {
|
||||
const { response } = upstream(chunks);
|
||||
const wrapped = await wrapQoderSSE(response, "qoder/kmodel_latest");
|
||||
expect(wrapped.status).toBe(403);
|
||||
expect(wrapped.ok).toBe(false);
|
||||
expect(wrapped.headers.get("content-type")).toBe("application/json");
|
||||
const body = await wrapped.json();
|
||||
expect(body.error.message).toBe(duplicate);
|
||||
expect(body).not.toHaveProperty("choices");
|
||||
});
|
||||
|
||||
it("cancels the upstream keepalive immediately after an error", async () => {
|
||||
const { response, cancel } = upstream([": keepalive\n\n", frame(403, duplicate)], { keepOpen: true });
|
||||
const wrapped = await wrapQoderSSE(response, "qoder/kmodel_latest");
|
||||
expect(wrapped.status).toBe(403);
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([401, 429, 500, 503])("preserves non-billing HTTP status %s", async (status) => {
|
||||
const { response } = upstream([frame(status, "upstream failure")]);
|
||||
const wrapped = await wrapQoderSSE(response, "qoder/auto");
|
||||
expect(wrapped.status).toBe(status);
|
||||
expect((await wrapped.json()).error.message).toBe("upstream failure");
|
||||
});
|
||||
|
||||
it.each([0, 302, 600, 403.5])("maps invalid error status %s to 502", async (status) => {
|
||||
const { response } = upstream([frame(status, "invalid upstream status")]);
|
||||
const wrapped = await wrapQoderSSE(response, "qoder/auto");
|
||||
expect(wrapped.status).toBe(502);
|
||||
});
|
||||
|
||||
it("replays successful frames after a heartbeat without losing or duplicating content", async () => {
|
||||
const first = JSON.stringify({ choices: [{ delta: { content: "hello" } }] });
|
||||
const second = JSON.stringify({ choices: [{ delta: { content: "world" } }] });
|
||||
const { response } = upstream([": keepalive\n\n", frame(200, first) + frame(200, second) + "data: [DONE]\n\n"]);
|
||||
const wrapped = await wrapQoderSSE(response, "qoder/auto");
|
||||
expect(wrapped.status).toBe(200);
|
||||
expect(await wrapped.text()).toBe(`data: ${first}\n\ndata: ${second}\n\ndata: [DONE]\n\n`);
|
||||
});
|
||||
|
||||
it("starts forwarding success without waiting for the upstream to close", async () => {
|
||||
const inner = JSON.stringify({ choices: [{ delta: { content: "hello" } }] });
|
||||
const { response, cancel } = upstream([": keepalive\n\n", frame(200, inner)], { keepOpen: true });
|
||||
const wrapped = await wrapQoderSSE(response, "qoder/auto");
|
||||
const reader = wrapped.body.getReader();
|
||||
try {
|
||||
const { value } = await reader.read();
|
||||
expect(new TextDecoder().decode(value)).toBe(`data: ${inner}\n\n`);
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
}
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -506,14 +506,14 @@ describe("wrapQoderSSE", () => {
|
||||
|
||||
// Regression for review finding #3: chunks could leak past [DONE] when
|
||||
// the success branch had no doneEmitted guard. We synthesize an error
|
||||
// envelope (which sets doneEmitted=true) followed by a valid envelope
|
||||
// envelope after content (which sets doneEmitted=true), followed by a valid envelope
|
||||
// and assert the second envelope is NOT forwarded.
|
||||
it("does not forward chunks after [DONE] has been emitted", async () => {
|
||||
const errorEnv = JSON.stringify({ statusCodeValue: 500, body: "boom" });
|
||||
const validInner = JSON.stringify({ choices: [{ delta: { content: "leak" } }] });
|
||||
const validEnv = JSON.stringify({ statusCodeValue: 200, body: validInner });
|
||||
const wrapped = await wrapQoderSSE(
|
||||
makeResponse([`data: ${errorEnv}\n\ndata: ${validEnv}\n\n`]),
|
||||
makeResponse([envelope(JSON.stringify({ choices: [{ delta: { content: "hi" } }] })) + `data: ${errorEnv}\n\ndata: ${validEnv}\n\n`]),
|
||||
"qoder/auto",
|
||||
);
|
||||
const out = await drain(wrapped);
|
||||
@@ -539,12 +539,13 @@ describe("wrapQoderSSE", () => {
|
||||
expect(() => JSON.parse(dataLine.slice("data: ".length))).not.toThrow();
|
||||
});
|
||||
|
||||
it("upstream error envelope produces an error chunk + [DONE]", async () => {
|
||||
it("upstream first-frame error envelope produces an HTTP error", async () => {
|
||||
const env = JSON.stringify({ statusCodeValue: 503, body: "service unavailable" });
|
||||
const wrapped = await wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/lite");
|
||||
const out = await drain(wrapped);
|
||||
expect(out).toContain("[qoder error 503");
|
||||
expect(out).toContain("data: [DONE]\n\n");
|
||||
expect(wrapped.status).toBe(503);
|
||||
expect(await wrapped.json()).toEqual({
|
||||
error: { message: "service unavailable", code: 503 },
|
||||
});
|
||||
});
|
||||
|
||||
it("non-ok responses are returned unchanged (no transform)", async () => {
|
||||
@@ -651,6 +652,11 @@ describe("qoderInferenceBase", () => {
|
||||
expect(qoderInferenceBase({ accessToken: "jt-abc" })).toContain("api2.qoder.sh");
|
||||
expect(qoderInferenceBase({ accessToken: "dt-abc" })).toContain("api3.qoder.sh");
|
||||
});
|
||||
|
||||
it("serves every token kind from the CN gateway for the qoder-cn region", () => {
|
||||
expect(qoderInferenceBase({ accessToken: "jt-abc" }, "cn")).toContain("gateway.qoder.com.cn");
|
||||
expect(qoderInferenceBase({ accessToken: "dt-abc" }, "cn")).toContain("gateway.qoder.com.cn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteQoderMessageAttachments", () => {
|
||||
|
||||
131
tests/unit/rtk-cursor-pretranslate.test.js
Normal file
131
tests/unit/rtk-cursor-pretranslate.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { executeMock } = vi.hoisted(() => ({
|
||||
executeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/executors/index.js", () => ({
|
||||
getExecutor: () => ({
|
||||
noAuth: true,
|
||||
execute: executeMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/requestLogger.js", () => ({
|
||||
createRequestLogger: async () => ({
|
||||
logClientRawRequest: vi.fn(),
|
||||
logRawRequest: vi.fn(),
|
||||
logTargetRequest: vi.fn(),
|
||||
logProviderResponse: vi.fn(),
|
||||
logConvertedResponse: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/stream.js", () => ({
|
||||
COLORS: { red: "", reset: "" },
|
||||
createPassthroughStreamWithLogger: vi.fn(() => new TransformStream()),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/usageDb.js", () => ({
|
||||
trackPendingRequest: vi.fn(),
|
||||
appendRequestLog: vi.fn(async () => {}),
|
||||
saveRequestDetail: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js");
|
||||
|
||||
function makeLongDiff() {
|
||||
const lines = ["diff --git a/foo.js b/foo.js", "index abc..def 100644", "--- a/foo.js", "+++ b/foo.js", "@@ -1,3 +1,200 @@"];
|
||||
for (let i = 0; i < 200; i++) lines.push(`+added line ${i} UNIQUE_PADDING_${i} ${"x".repeat(20)}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
describe("token savers on Cursor (pre-translate RTK)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn(async (url, init) => {
|
||||
if (String(url).includes("/v1/compress")) {
|
||||
const payload = JSON.parse(init.body);
|
||||
return new Response(JSON.stringify({
|
||||
messages: payload.messages,
|
||||
tokens_before: 8000,
|
||||
tokens_after: 2500,
|
||||
tokens_saved: 5500,
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
executeMock.mockResolvedValue({
|
||||
response: new Response(JSON.stringify({
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion",
|
||||
choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop", index: 0 }],
|
||||
}), { status: 200, headers: { "content-type": "application/json" } }),
|
||||
url: "https://api2.cursor.sh/agent",
|
||||
headers: {},
|
||||
transformedBody: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("compresses role:tool git diffs before openai→cursor rewrite, then injects Headroom/Caveman/Ponytail", async () => {
|
||||
const diff = makeLongDiff();
|
||||
const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), line: vi.fn() };
|
||||
|
||||
await handleChatCore({
|
||||
body: {
|
||||
model: "cu/default",
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: "system", content: "hi" },
|
||||
{ role: "user", content: "run git diff" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "Bash", arguments: JSON.stringify({ command: "git diff" }) } }],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: diff },
|
||||
{ role: "user", content: "summarize" },
|
||||
],
|
||||
},
|
||||
modelInfo: { provider: "cursor", model: "default" },
|
||||
credentials: { apiKey: "test-key", providerSpecificData: {} },
|
||||
log,
|
||||
connectionId: "test-conn",
|
||||
rtkEnabled: true,
|
||||
headroomEnabled: true,
|
||||
headroomUrl: "http://localhost:8787",
|
||||
cavemanEnabled: true,
|
||||
cavemanLevel: "full",
|
||||
ponytailEnabled: true,
|
||||
ponytailLevel: "full",
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/chat/completions",
|
||||
body: { model: "cu/default" },
|
||||
headers: { accept: "application/json" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(executeMock).toHaveBeenCalled();
|
||||
const dispatched = executeMock.mock.calls[0][0].body;
|
||||
const blob = JSON.stringify(dispatched.messages);
|
||||
|
||||
expect(dispatched.messages.some((m) => m.role === "tool")).toBe(false);
|
||||
expect(blob).toContain("<tool_result>");
|
||||
expect(blob).toContain("lines truncated");
|
||||
expect(blob).not.toContain("UNIQUE_PADDING_150");
|
||||
expect(blob).toContain("lazy senior developer");
|
||||
expect(blob).toMatch(/Respond like a caveman|drop filler|ACTIVE EVERY RESPONSE/i);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"http://localhost:8787/v1/compress",
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
const xf = log.line.mock.calls.find((c) => c[1] === "⚙");
|
||||
expect(xf, "expected ⚙ saver log").toBeTruthy();
|
||||
expect(xf[2]).toContain("RTK:");
|
||||
expect(xf[2]).toContain("CAVEMAN:full");
|
||||
expect(xf[2]).toContain("PONYTAIL:full");
|
||||
});
|
||||
});
|
||||
39
tests/unit/thinking-budget-max-level.test.js
Normal file
39
tests/unit/thinking-budget-max-level.test.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { budgetToLevel } from "../../open-sse/translator/concerns/thinking.js";
|
||||
import { applyThinking } from "../../open-sse/translator/concerns/thinkingUnified.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
// Reverse map must be able to reach "max": LEVEL_TO_BUDGET.max = 128000 and
|
||||
// xhigh = 32768, so the xhigh/max threshold is their midpoint (80384).
|
||||
// Previously any budget > 28672 collapsed to "xhigh", making "max"
|
||||
// unreachable from Claude Code budget_tokens — its default thinking budget
|
||||
// (MAX_THINKING_TOKENS) could never produce effort "max".
|
||||
describe("budgetToLevel reaches max tier", () => {
|
||||
it("budget 98304 → \"max\"", () => {
|
||||
expect(budgetToLevel(98304)).toBe("max");
|
||||
});
|
||||
|
||||
it("budget 128000 → \"max\"", () => {
|
||||
expect(budgetToLevel(128000)).toBe("max");
|
||||
});
|
||||
|
||||
it("budget 80385 → \"max\" (just above midpoint)", () => {
|
||||
expect(budgetToLevel(80385)).toBe("max");
|
||||
});
|
||||
|
||||
it("budget 80384 → \"xhigh\" (midpoint still xhigh)", () => {
|
||||
expect(budgetToLevel(80384)).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("budget 31999 stays \"xhigh\"", () => {
|
||||
expect(budgetToLevel(31999)).toBe("xhigh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyThinking (openai-responses): large budgets map to max effort", () => {
|
||||
it("budget 98304 → reasoning_effort \"max\" for gpt-5.6-sol (openai wire)", () => {
|
||||
const body = { thinking: { type: "enabled", budget_tokens: 98304 } };
|
||||
const out = applyThinking(FORMATS.OPENAI_RESPONSES, "gpt-5.6-sol", body, "codex");
|
||||
expect(out?.reasoning_effort).toBe("max");
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
const load = () => import("../../open-sse/services/usage.js");
|
||||
const SUPPORTED = [
|
||||
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
|
||||
"qoder", "iflow", "ollama", "glm", "glm-cn",
|
||||
"qoder", "qoder-cn", "iflow", "ollama", "glm", "glm-cn",
|
||||
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
|
||||
"deepseek", "opencode-go", "zed", "commandcode",
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { XiaomiMimoExecutor, __test__ } from "../../open-sse/executors/xiaomi-mimo.js";
|
||||
import { getExecutor } from "../../open-sse/executors/index.js";
|
||||
import * as mimoAccount from "../../open-sse/shared/mimoAccount.js";
|
||||
|
||||
const { bareModel, COOKIE_KEY } = __test__;
|
||||
|
||||
@@ -17,22 +18,52 @@ describe("xiaomi-mimo executor", () => {
|
||||
expect(getExecutor("xiaomi-mimo")).toBeInstanceOf(XiaomiMimoExecutor);
|
||||
});
|
||||
|
||||
it("routes Preview models to the account-service route regardless of transport", () => {
|
||||
const expected = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions";
|
||||
expect(ex.buildUrl("mimo-x-pro-preview", true, 0, OPENAI_T)).toBe(expected);
|
||||
expect(ex.buildUrl("mimo-x-pro-preview", true, 0, CLAUDE_T)).toBe(expected);
|
||||
// body.model arrives as `xiaomi/<id>` via upstreamModelId
|
||||
expect(ex.buildUrl("xiaomi/mimo-x-flash-preview", true, 0, OPENAI_T)).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps the sourceFormat-matched endpoint for cloud models", () => {
|
||||
// Regression: a Claude client must reach /anthropic/v1/messages, not /v1/chat/completions.
|
||||
expect(ex.buildUrl("mimo-v2.5-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl);
|
||||
expect(ex.buildUrl("mimo-v2.5-pro", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl);
|
||||
});
|
||||
|
||||
it("authenticates Preview calls with the account cookie", () => {
|
||||
const headers = ex.buildHeaders({ [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" }, true, "u", "mimo-x-pro-preview");
|
||||
it("routes v2.6 models to account route when desktop credentials are present", () => {
|
||||
// No region → SGP default
|
||||
const expected = "https://mimo-server-sgp.xiaomimimo.com/api/route/chat/completions";
|
||||
const credsWithToken = { providerSpecificData: { mimoPassToken: "token123" } };
|
||||
const credsWithCookie = { [COOKIE_KEY]: "serviceToken=abc" };
|
||||
|
||||
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, credsWithToken)).toBe(expected);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, credsWithCookie)).toBe(expected);
|
||||
expect(ex.buildUrl("xiaomi/mimo-v2.6-flash", true, 0, credsWithToken)).toBe(expected);
|
||||
});
|
||||
|
||||
it("routes v2.6 models to cloud API when no desktop credentials are present", () => {
|
||||
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl);
|
||||
});
|
||||
|
||||
it("resolves the account-service cluster per connection region", () => {
|
||||
const cn = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions";
|
||||
const sgp = "https://mimo-server-sgp.xiaomimimo.com/api/route/chat/completions";
|
||||
const ams = "https://mimo-server-ams.xiaomimimo.com/api/route/chat/completions";
|
||||
const ru = "https://mimo-server-ru.xiaomimimo.com/api/route/chat/completions";
|
||||
const inRegion = "https://mimo-server-in.xiaomimimo.com/api/route/chat/completions";
|
||||
// default (no region) falls back to SGP (the international cluster)
|
||||
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, { providerSpecificData: { mimoPassToken: "t" } })).toBe(sgp);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "cn", mimoPassToken: "t" } })).toBe(cn);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "sgp", mimoPassToken: "t" } })).toBe(sgp);
|
||||
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, { providerSpecificData: { region: "SGP", mimoPassToken: "t" } })).toBe(sgp);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "ams", mimoPassToken: "t" } })).toBe(ams);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "ru", mimoPassToken: "t" } })).toBe(ru);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "in", mimoPassToken: "t" } })).toBe(inRegion);
|
||||
// unknown region falls back to SGP
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "eu", mimoPassToken: "t" } })).toBe(sgp);
|
||||
});
|
||||
|
||||
it("authenticates v2.6 calls with account cookie when on account route", () => {
|
||||
const headers = ex.buildHeaders(
|
||||
{ [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" },
|
||||
true,
|
||||
"u",
|
||||
"mimo-v2.6-flash",
|
||||
);
|
||||
expect(headers.Cookie).toBe("serviceToken=abc");
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
@@ -43,38 +74,55 @@ describe("xiaomi-mimo executor", () => {
|
||||
expect(headers.Cookie).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails fast when a Preview call has no account session", async () => {
|
||||
await expect(
|
||||
ex.execute({ model: "mimo-x-pro-preview", body: {}, stream: true, credentials: {}, log: null }),
|
||||
).rejects.toThrow(/account session unavailable/);
|
||||
});
|
||||
|
||||
it("flattens content-part arrays to plain strings", () => {
|
||||
it("preserves content-part arrays for multimodal inputs", () => {
|
||||
const parts = [{ type: "image_url", image_url: { url: "data:image/png;base64,xyz" } }, { type: "text", text: "hi" }];
|
||||
const out = ex.transformRequest(
|
||||
"mimo-x-pro-preview",
|
||||
{ messages: [{ role: "user", content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }] },
|
||||
"mimo-v2.6-pro",
|
||||
{ messages: [{ role: "user", content: parts }] },
|
||||
true,
|
||||
{},
|
||||
{ providerSpecificData: { mimoPassToken: "token" } },
|
||||
);
|
||||
expect(out.messages[0].content).toBe("ab");
|
||||
expect(out.messages[0].content).toEqual(parts);
|
||||
});
|
||||
|
||||
it("applies Preview defaults without overriding explicit values", () => {
|
||||
it("bridges reasoning_effort to official output_config.effort", () => {
|
||||
const creds = { providerSpecificData: { mimoPassToken: "token" } };
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "solve" }],
|
||||
reasoning_effort: "high",
|
||||
};
|
||||
const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds);
|
||||
expect(out.reasoning_effort).toBeUndefined();
|
||||
expect(out.output_config).toEqual({ effort: "high" });
|
||||
});
|
||||
|
||||
it("normalizes xhigh reasoning_effort to high in output_config.effort", () => {
|
||||
const creds = { providerSpecificData: { mimoPassToken: "token" } };
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "complex" }],
|
||||
reasoning_effort: "xhigh",
|
||||
};
|
||||
const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds);
|
||||
expect(out.reasoning_effort).toBeUndefined();
|
||||
expect(out.output_config).toEqual({ effort: "high" });
|
||||
});
|
||||
|
||||
it("applies defaults without overriding explicit values", () => {
|
||||
const creds = { providerSpecificData: { mimoPassToken: "token" } };
|
||||
const body = { messages: [{ role: "user", content: "hi" }], temperature: 0.2 };
|
||||
const out = ex.transformRequest("mimo-x-pro-preview", body, true, {});
|
||||
expect(out.temperature).toBe(0.2); // caller's value kept
|
||||
expect(out.top_p).toBe(0.95); // default filled in
|
||||
expect(out.max_tokens).toBe(4096);
|
||||
const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds);
|
||||
expect(out.temperature).toBe(0.2);
|
||||
expect(out.top_p).toBe(0.95);
|
||||
});
|
||||
|
||||
it("leaves cloud bodies free of Preview defaults", () => {
|
||||
it("leaves cloud bodies free of account defaults", () => {
|
||||
const out = ex.transformRequest("mimo-v2.5-pro", { messages: [{ role: "user", content: "hi" }] }, true, {});
|
||||
expect(out.thinking).toBeUndefined();
|
||||
expect(out.max_tokens).toBeUndefined();
|
||||
expect(out.output_config).toBeUndefined();
|
||||
expect(out.temperature).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips a provider/model prefix when testing preview ids", () => {
|
||||
expect(bareModel("xiaomi/mimo-x-pro-preview")).toBe("mimo-x-pro-preview");
|
||||
expect(bareModel("mimo-x-pro-preview")).toBe("mimo-x-pro-preview");
|
||||
it("strips a provider/model prefix when testing model ids", () => {
|
||||
expect(bareModel("xiaomi/mimo-v2.6-pro")).toBe("mimo-v2.6-pro");
|
||||
expect(bareModel("mimo-v2.6-flash")).toBe("mimo-v2.6-flash");
|
||||
});
|
||||
});
|
||||
|
||||
40
tests/unit/xiaomi-mimo-login-session-security.test.js
Normal file
40
tests/unit/xiaomi-mimo-login-session-security.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Security invariants of the server-assisted MiMo login proxy
|
||||
* (src/lib/mimoLoginSession.js):
|
||||
* - credentials bound to 9router's own origin are never forwarded upstream
|
||||
* - upstream Set-Cookie is never replayed onto the app's own cookie jar
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { __test__ } from "../../src/lib/mimoLoginSession.js";
|
||||
|
||||
const { STRIP_UPSTREAM_HEADERS, buildBrowserResponse } = __test__;
|
||||
|
||||
describe("mimo login proxy security", () => {
|
||||
it("strips auth credentials and session cookies before forwarding upstream", () => {
|
||||
for (const h of ["authorization", "proxy-authorization", "cookie", "host"]) {
|
||||
expect(STRIP_UPSTREAM_HEADERS.has(h)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not replay upstream Set-Cookie onto the app origin", async () => {
|
||||
const upstream = new Response("ok", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/html",
|
||||
"set-cookie": "userId=123; Path=/", // plain object header: visible via getSetCookie
|
||||
},
|
||||
});
|
||||
const out = await buildBrowserResponse({ jar: new Map() }, upstream, "http://localhost:20128", "/pass/");
|
||||
expect(out.headers.getSetCookie()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps ordinary response headers intact", async () => {
|
||||
const upstream = new Response("<html></html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
const out = await buildBrowserResponse({ jar: new Map() }, upstream, "http://localhost:20128", "/fe/");
|
||||
expect(out.status).toBe(200);
|
||||
expect(out.headers.get("content-type")).toBe("text/html");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user