merge: sync upstream v0.5.81 into MIBP fork
# Conflicts: # .gitignore # Dockerfile # open-sse/handlers/chatCore.js # open-sse/providers/registry/cline.js # open-sse/providers/registry/index.js # open-sse/services/usage.js # open-sse/utils/streamHandler.js # package.json # src/app/(dashboard)/dashboard/profile/page.js # src/app/(dashboard)/dashboard/providers/[id]/page.js
This commit is contained in:
commit
0ac771bad8
208 files changed
+13062
-977
No files matched your search
@@ -0,0 +1,38 @@
|
||||
// Regression: an unmatched 4xx (a request-scoped failure) used to hit the
|
||||
// transient-cooldown default, which locked the account for 30s and — with a
|
||||
// single connection — answered every other request in that window with a copy of
|
||||
// the first error. A 400 "maximum context length" from one session therefore
|
||||
// looked like the same failure in unrelated sessions.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkFallbackError } from "../../open-sse/services/accountFallback.js";
|
||||
|
||||
describe("checkFallbackError — request-scoped vs account-scoped failures", () => {
|
||||
it("does not cool the account down for a 400 caused by the request", () => {
|
||||
const result = checkFallbackError(400, JSON.stringify({
|
||||
error: {
|
||||
message: "This model's maximum context length is 1048576 tokens. However, you requested 1186139 tokens",
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result).toEqual({ shouldFallback: false, cooldownMs: 0 });
|
||||
});
|
||||
|
||||
it("still falls back for account-scoped statuses", () => {
|
||||
for (const status of [401, 402, 403, 404, 429]) {
|
||||
expect(checkFallbackError(status, "nope").shouldFallback).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("still honours rate-limit / quota wording on any 4xx", () => {
|
||||
expect(checkFallbackError(400, "rate limit reached").shouldFallback).toBe(true);
|
||||
expect(checkFallbackError(422, "quota exceeded").shouldFallback).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the transient cooldown for unmatched server errors", () => {
|
||||
const result = checkFallbackError(503, "upstream exploded");
|
||||
|
||||
expect(result.shouldFallback).toBe(true);
|
||||
expect(result.cooldownMs).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
|
||||
import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.js";
|
||||
|
||||
const HEADER = "x-anthropic-billing-header: cc_version=2.1.275.f15; cc_entrypoint=cli;";
|
||||
|
||||
function systemTextSentToAntigravity(systemContent) {
|
||||
// OpenAI-format client (e.g. a proxy converting Claude Code to /v1/chat/completions).
|
||||
const body = openaiToAntigravityRequest("gemini-3.8-flash-tiered", {
|
||||
messages: [
|
||||
{ role: "system", content: systemContent },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
}, true);
|
||||
const finalBody = new AntigravityExecutor().transformRequest("gemini-3.8-flash-tiered", body, true, {});
|
||||
return finalBody.request.systemInstruction.parts.map((p) => p.text).join("\n");
|
||||
}
|
||||
|
||||
describe("Antigravity strips the Claude Code billing header from system prompts", () => {
|
||||
it("removes the header line prepended by Claude Code", () => {
|
||||
const text = systemTextSentToAntigravity(`${HEADER}\n\nYou are Claude Code, Anthropic's official CLI for Claude.`);
|
||||
expect(text).not.toContain("x-anthropic-billing-header");
|
||||
expect(text).toContain("You are Claude Code, Anthropic's official CLI for Claude.");
|
||||
});
|
||||
|
||||
it("removes the header when it is not the first line", () => {
|
||||
const text = systemTextSentToAntigravity(`Some preamble\n${HEADER}\nRest of prompt`);
|
||||
expect(text).not.toContain("x-anthropic-billing-header");
|
||||
expect(text).toContain("Some preamble");
|
||||
expect(text).toContain("Rest of prompt");
|
||||
});
|
||||
|
||||
it("leaves prompts without the header untouched", () => {
|
||||
const text = systemTextSentToAntigravity("You are a helpful assistant.");
|
||||
expect(text).toContain("You are a helpful assistant.");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ const proxyAwareFetch = vi.fn(async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => url.includes(":loadCodeAssist")
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }
|
||||
: {
|
||||
models: {
|
||||
"gemini-3.6-flash-high": {
|
||||
|
||||
@@ -4,7 +4,7 @@ const proxyAwareFetch = vi.fn(async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => url.includes(":loadCodeAssist")
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }
|
||||
: {
|
||||
models: {
|
||||
"gemini-3.7-flash-high": {
|
||||
|
||||
@@ -4,7 +4,7 @@ const proxyAwareFetch = vi.fn(async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => url.includes(":loadCodeAssist")
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }
|
||||
: {
|
||||
models: {
|
||||
"gemini-3.8-flash-high": {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Keep the signature store in RAM only; the SQLite kv layer is not under test here.
|
||||
vi.mock("@/lib/db/helpers/kvStore.js", () => ({
|
||||
makeKv: () => ({
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
remove: async () => {},
|
||||
getAll: async () => ({}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
storeGeminiThoughtSignature,
|
||||
getGeminiThoughtSignatureSync,
|
||||
signatureFamily,
|
||||
} = await import("../../open-sse/services/thoughtSignatureStore.js");
|
||||
const { openaiToAntigravityRequest } = await import("../../open-sse/translator/request/openai-to-gemini.js");
|
||||
const { geminiToOpenAIResponse } = await import("../../open-sse/translator/response/gemini-to-openai.js");
|
||||
const { DEFAULT_THINKING_GEMINI_CLI_SIGNATURE } = await import("../../open-sse/config/defaultThinkingSignature.js");
|
||||
|
||||
let n = 0;
|
||||
const uid = (p) => `${p}_${Date.now()}_${n++}`;
|
||||
|
||||
function toolHistory(callId) {
|
||||
return {
|
||||
messages: [
|
||||
{ role: "user", content: "list files" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: callId, type: "function", function: { name: "ls", arguments: "{}" } }] },
|
||||
{ role: "tool", tool_call_id: callId, content: "a.txt" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function functionCallSignatures(req) {
|
||||
return req.request.contents.flatMap((c) => c.parts || []).filter((p) => p.functionCall).map((p) => p.thoughtSignature);
|
||||
}
|
||||
|
||||
describe("antigravity thought signatures are scoped to the model family", () => {
|
||||
beforeEach(() => { n++; });
|
||||
|
||||
it("classifies model families", () => {
|
||||
expect(signatureFamily("claude-opus-4-6-thinking")).toBe("claude");
|
||||
expect(signatureFamily("gemini-3.8-flash-tiered")).toBe("gemini");
|
||||
expect(signatureFamily("gpt-oss-120b-medium")).toBe("gpt-oss-120b-medium");
|
||||
expect(signatureFamily(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("does not return a Claude signature for a Gemini target (and vice versa)", () => {
|
||||
const claudeCall = uid("toolu");
|
||||
const geminiCall = uid("call");
|
||||
storeGeminiThoughtSignature(claudeCall, "CLAUDE_SIG", "sess", "claude-opus-4-6-thinking");
|
||||
storeGeminiThoughtSignature(geminiCall, "GEMINI_SIG", "sess", "gemini-3.8-flash-tiered");
|
||||
|
||||
expect(getGeminiThoughtSignatureSync(claudeCall, "sess", "gemini-3.8-flash")).toBe(null);
|
||||
expect(getGeminiThoughtSignatureSync(claudeCall, "sess", "claude-opus-4-6-thinking")).toBe("CLAUDE_SIG");
|
||||
expect(getGeminiThoughtSignatureSync(geminiCall, "sess", "gemini-3.7-flash")).toBe("GEMINI_SIG");
|
||||
expect(getGeminiThoughtSignatureSync(geminiCall, null, "claude-sonnet-4-6")).toBe(null);
|
||||
});
|
||||
|
||||
it("keeps old behaviour for untagged entries and untargeted lookups", () => {
|
||||
const call = uid("call");
|
||||
storeGeminiThoughtSignature(call, "LEGACY_SIG", "sess");
|
||||
expect(getGeminiThoughtSignatureSync(call, "sess", "gemini-3.8-flash")).toBe("LEGACY_SIG");
|
||||
|
||||
const tagged = uid("toolu");
|
||||
storeGeminiThoughtSignature(tagged, "CLAUDE_SIG", "sess", "claude-opus-4-6-thinking");
|
||||
expect(getGeminiThoughtSignatureSync(tagged, "sess")).toBe("CLAUDE_SIG");
|
||||
});
|
||||
|
||||
it("records the producing model from the Gemini response stream", () => {
|
||||
const call = uid("toolu_vrtx");
|
||||
const state = { model: "claude-opus-4-6-thinking", sessionId: null, toolNameMap: null };
|
||||
geminiToOpenAIResponse({
|
||||
response: {
|
||||
responseId: "r1",
|
||||
candidates: [{ content: { role: "model", parts: [{ functionCall: { id: call, name: "ls", args: {} }, thoughtSignature: "CLAUDE_SIG" }] } }],
|
||||
},
|
||||
}, state);
|
||||
expect(getGeminiThoughtSignatureSync(call, null, "gemini-3.8-flash-tiered")).toBe(null);
|
||||
expect(getGeminiThoughtSignatureSync(call, null, "claude-opus-4-6-thinking")).toBe("CLAUDE_SIG");
|
||||
});
|
||||
|
||||
it("switching Claude -> Gemini mid-conversation sends the default signature, not Claude's", () => {
|
||||
const call = uid("toolu_vrtx");
|
||||
storeGeminiThoughtSignature(call, "CLAUDE_SIG", null, "claude-opus-4-6-thinking");
|
||||
const req = openaiToAntigravityRequest("gemini-3.8-flash-tiered", toolHistory(call), true);
|
||||
expect(functionCallSignatures(req)).toEqual([DEFAULT_THINKING_GEMINI_CLI_SIGNATURE]);
|
||||
});
|
||||
|
||||
it("switching Gemini -> Claude mid-conversation does not replay Gemini's signature", () => {
|
||||
const call = uid("call");
|
||||
storeGeminiThoughtSignature(call, "GEMINI_SIG", null, "gemini-3.8-flash-tiered");
|
||||
const req = openaiToAntigravityRequest("claude-opus-4-6-thinking", toolHistory(call), true);
|
||||
expect(functionCallSignatures(req)).not.toContain("GEMINI_SIG");
|
||||
});
|
||||
|
||||
it("same model family still reuses the cached signature", () => {
|
||||
const call = uid("call");
|
||||
storeGeminiThoughtSignature(call, "GEMINI_SIG", null, "gemini-3.8-flash-tiered");
|
||||
const req = openaiToAntigravityRequest("gemini-3.8-flash-tiered", toolHistory(call), true);
|
||||
expect(functionCallSignatures(req)).toEqual(["GEMINI_SIG"]);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@ const proxyAwareFetch = vi.fn(async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => url.includes(":loadCodeAssist")
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
|
||||
: { models: {} },
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }
|
||||
: url.includes(":retrieveUserQuotaSummary")
|
||||
? { groups: [] }
|
||||
: { models: {} },
|
||||
text: async () => "{}",
|
||||
}));
|
||||
|
||||
@@ -21,7 +23,8 @@ describe("Antigravity usage headers", () => {
|
||||
|
||||
await getAntigravityUsage("access-token", {});
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(2);
|
||||
// loadCodeAssist + fetchAvailableModels + retrieveUserQuotaSummary
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(3);
|
||||
for (const [, options] of proxyAwareFetch.mock.calls) {
|
||||
expect(options.headers["User-Agent"]).toBe("antigravity/ide/2.11.0 darwin/arm64");
|
||||
expect(options.headers).not.toHaveProperty("x-request-source");
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
describe("Antigravity dashboard normalization with weekly quotas", () => {
|
||||
const data = {
|
||||
quotas: {
|
||||
"gemini-pro-agent": {
|
||||
displayName: "Gemini 3.1 Pro (High)",
|
||||
used: 200,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-08T00:00:00Z",
|
||||
remainingPercentage: 80,
|
||||
},
|
||||
"claude-opus-4-6-thinking": {
|
||||
displayName: "Claude Opus 4.6 (Thinking)",
|
||||
used: 100,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-08T00:00:00Z",
|
||||
remainingPercentage: 90,
|
||||
},
|
||||
gemini_weekly: {
|
||||
displayName: "Gemini (Weekly)",
|
||||
used: 250,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-15T00:00:00Z",
|
||||
remainingPercentage: 75,
|
||||
},
|
||||
claude_gpt_weekly: {
|
||||
displayName: "Claude & GPT (Weekly)",
|
||||
used: 500,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-14T00:00:00Z",
|
||||
remainingPercentage: 50,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("includes weekly rows with correct display names", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const names = quotas.map((q) => q.name);
|
||||
|
||||
expect(names).toContain("Gemini (Flash / Pro)");
|
||||
expect(names).toContain("Claude (Sonnet / Opus)");
|
||||
expect(names).toContain("Gemini (Weekly)");
|
||||
expect(names).toContain("Claude & GPT (Weekly)");
|
||||
});
|
||||
|
||||
it("uses stable modelKey for weekly rows", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const keys = quotas.map((q) => q.modelKey);
|
||||
|
||||
expect(keys).toContain("gemini_weekly");
|
||||
expect(keys).toContain("claude_gpt_weekly");
|
||||
});
|
||||
|
||||
it("weekly rows carry correct quota values", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const geminiWeekly = quotas.find((q) => q.modelKey === "gemini_weekly");
|
||||
const claudeWeekly = quotas.find((q) => q.modelKey === "claude_gpt_weekly");
|
||||
|
||||
expect(geminiWeekly).toMatchObject({
|
||||
used: 250,
|
||||
total: 1000,
|
||||
remainingPercentage: 75,
|
||||
resetAt: "2026-09-15T00:00:00Z",
|
||||
});
|
||||
expect(claudeWeekly).toMatchObject({
|
||||
used: 500,
|
||||
total: 1000,
|
||||
remainingPercentage: 50,
|
||||
resetAt: "2026-09-14T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("weekly rows do NOT appear as otherModels", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const weeklyRows = quotas.filter((q) =>
|
||||
q.modelKey === "gemini_weekly" || q.modelKey === "claude_gpt_weekly"
|
||||
);
|
||||
expect(weeklyRows).toHaveLength(2);
|
||||
expect(weeklyRows[0].name).toMatch(/Weekly/);
|
||||
expect(weeklyRows[1].name).toMatch(/Weekly/);
|
||||
});
|
||||
|
||||
it("order: gemini family, claude family, weekly, then other", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const keys = quotas.map((q) => q.modelKey);
|
||||
|
||||
const geminiIdx = keys.indexOf("gemini");
|
||||
const claudeIdx = keys.indexOf("claude");
|
||||
const geminiWeeklyIdx = keys.indexOf("gemini_weekly");
|
||||
const claudeWeeklyIdx = keys.indexOf("claude_gpt_weekly");
|
||||
|
||||
expect(geminiIdx).toBeLessThan(geminiWeeklyIdx);
|
||||
expect(claudeIdx).toBeLessThan(claudeWeeklyIdx);
|
||||
});
|
||||
|
||||
it("works with no weekly keys present (backward compat)", () => {
|
||||
const noWeekly = {
|
||||
quotas: {
|
||||
"gemini-pro-agent": {
|
||||
displayName: "Gemini 3.1 Pro (High)",
|
||||
used: 200,
|
||||
total: 1000,
|
||||
remainingPercentage: 80,
|
||||
},
|
||||
},
|
||||
};
|
||||
const quotas = parseQuotaData("antigravity", noWeekly);
|
||||
expect(quotas).toHaveLength(1);
|
||||
expect(quotas[0].name).toBe("Gemini (Flash / Pro)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock proxyAwareFetch before any imports that use it
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import {
|
||||
parseWeeklyQuotaSummary,
|
||||
fetchAntigravityWeeklyQuota,
|
||||
_clearWeeklyCache,
|
||||
} from "../../open-sse/services/usage/antigravity-weekly.js";
|
||||
|
||||
// — Fixtures ——————————————————————————————————————————————
|
||||
const GEMINI_GROUP = {
|
||||
displayName: "Gemini Models",
|
||||
buckets: [
|
||||
{
|
||||
bucketId: "gemini-weekly-bucket",
|
||||
displayName: "Weekly Limit",
|
||||
remainingFraction: 0.75,
|
||||
resetTime: "2026-09-15T00:00:00Z",
|
||||
},
|
||||
{
|
||||
bucketId: "gemini-daily-bucket",
|
||||
displayName: "Daily Limit",
|
||||
remainingFraction: 0.9,
|
||||
resetTime: "2026-09-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CLAUDE_GPT_GROUP = {
|
||||
displayName: "Claude and GPT models",
|
||||
buckets: [
|
||||
{
|
||||
bucketId: "claude-gpt-weekly",
|
||||
displayName: "Weekly Quota",
|
||||
remainingFraction: 0.5,
|
||||
resetTime: "2026-09-14T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const FULL_RESPONSE = { groups: [GEMINI_GROUP, CLAUDE_GPT_GROUP] };
|
||||
|
||||
const NESTED_RESPONSE = {
|
||||
quotaSummary: {
|
||||
groups: [GEMINI_GROUP, CLAUDE_GPT_GROUP],
|
||||
},
|
||||
};
|
||||
|
||||
// — parseWeeklyQuotaSummary ———————————————————————————————
|
||||
describe("parseWeeklyQuotaSummary", () => {
|
||||
it("extracts Gemini weekly quota from top-level groups", () => {
|
||||
const result = parseWeeklyQuotaSummary(FULL_RESPONSE);
|
||||
expect(result.gemini_weekly).toMatchObject({
|
||||
used: 250,
|
||||
total: 1000,
|
||||
remainingPercentage: 75,
|
||||
displayName: "Gemini (Weekly)",
|
||||
unlimited: false,
|
||||
});
|
||||
expect(result.gemini_weekly.resetAt).toBe("2026-09-15T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("extracts Claude & GPT weekly quota", () => {
|
||||
const result = parseWeeklyQuotaSummary(FULL_RESPONSE);
|
||||
expect(result.claude_gpt_weekly).toMatchObject({
|
||||
used: 500,
|
||||
total: 1000,
|
||||
remainingPercentage: 50,
|
||||
displayName: "Claude & GPT (Weekly)",
|
||||
unlimited: false,
|
||||
});
|
||||
expect(result.claude_gpt_weekly.resetAt).toBe("2026-09-14T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("handles alternate nested quotaSummary.groups shape", () => {
|
||||
const result = parseWeeklyQuotaSummary(NESTED_RESPONSE);
|
||||
expect(result.gemini_weekly).toBeDefined();
|
||||
expect(result.claude_gpt_weekly).toBeDefined();
|
||||
expect(result.gemini_weekly.remainingPercentage).toBe(75);
|
||||
expect(result.claude_gpt_weekly.remainingPercentage).toBe(50);
|
||||
});
|
||||
|
||||
it("skips non-weekly buckets", () => {
|
||||
const data = {
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [
|
||||
{
|
||||
bucketId: "gemini-daily-bucket",
|
||||
displayName: "Daily Limit",
|
||||
remainingFraction: 0.9,
|
||||
resetTime: "2026-09-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}],
|
||||
};
|
||||
const result = parseWeeklyQuotaSummary(data);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("skips disabled weekly buckets", () => {
|
||||
const data = {
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [{
|
||||
bucketId: "gemini-weekly-bucket",
|
||||
displayName: "Weekly Limit",
|
||||
remainingFraction: 0.75,
|
||||
resetTime: "2026-09-15T00:00:00Z",
|
||||
disabled: true,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
const result = parseWeeklyQuotaSummary(data);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for null/undefined input", () => {
|
||||
expect(parseWeeklyQuotaSummary(null)).toEqual({});
|
||||
expect(parseWeeklyQuotaSummary(undefined)).toEqual({});
|
||||
expect(parseWeeklyQuotaSummary("string")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for response with no groups", () => {
|
||||
expect(parseWeeklyQuotaSummary({})).toEqual({});
|
||||
expect(parseWeeklyQuotaSummary({ groups: "not-array" })).toEqual({});
|
||||
expect(parseWeeklyQuotaSummary({ quotaSummary: {} })).toEqual({});
|
||||
});
|
||||
|
||||
it("handles groups with no buckets gracefully", () => {
|
||||
const data = {
|
||||
groups: [{ displayName: "Gemini Models" }],
|
||||
};
|
||||
expect(parseWeeklyQuotaSummary(data)).toEqual({});
|
||||
});
|
||||
|
||||
it("handles bucket with non-finite remainingFraction", () => {
|
||||
const data = {
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [{
|
||||
bucketId: "weekly-bucket",
|
||||
displayName: "Weekly",
|
||||
remainingFraction: "not-a-number",
|
||||
}],
|
||||
}],
|
||||
};
|
||||
expect(parseWeeklyQuotaSummary(data)).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores groups that don't match known families", () => {
|
||||
const data = {
|
||||
groups: [{
|
||||
displayName: "Unknown AI Provider",
|
||||
buckets: [{
|
||||
bucketId: "weekly-bucket",
|
||||
displayName: "Weekly",
|
||||
remainingFraction: 0.5,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
expect(parseWeeklyQuotaSummary(data)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// — fetchAntigravityWeeklyQuota ———————————————————————————
|
||||
describe("fetchAntigravityWeeklyQuota", () => {
|
||||
beforeEach(() => {
|
||||
proxyAwareFetch.mockReset();
|
||||
_clearWeeklyCache();
|
||||
});
|
||||
|
||||
it("fetches and returns parsed weekly quota on success", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => FULL_RESPONSE,
|
||||
});
|
||||
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result.gemini_weekly).toBeDefined();
|
||||
expect(result.claude_gpt_weekly).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns {} on HTTP 401", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({ ok: false, status: 401 });
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on HTTP 403", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({ ok: false, status: 403 });
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on HTTP 404", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({ ok: false, status: 404 });
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on HTTP 429", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({ ok: false, status: 429 });
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on network error", async () => {
|
||||
proxyAwareFetch.mockRejectedValue(new Error("network timeout"));
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on malformed JSON response", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => { throw new SyntaxError("Unexpected token"); },
|
||||
});
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("deduplicates concurrent requests for the same account", async () => {
|
||||
let resolveResponse;
|
||||
proxyAwareFetch.mockReturnValue(new Promise(resolve => {
|
||||
resolveResponse = resolve;
|
||||
}));
|
||||
|
||||
const p1 = fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
const p2 = fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
|
||||
resolveResponse({ ok: true, json: async () => FULL_RESPONSE });
|
||||
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
expect(r1).toEqual(r2);
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serves cached result within TTL", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => FULL_RESPONSE,
|
||||
});
|
||||
|
||||
await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
expect(result.gemini_weekly).toBeDefined();
|
||||
});
|
||||
|
||||
it("sends correct headers and body", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ groups: [] }),
|
||||
});
|
||||
|
||||
await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledWith(
|
||||
"https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Authorization": "Bearer token",
|
||||
"User-Agent": "antigravity/ide/2.11.0 darwin/arm64",
|
||||
"Content-Type": "application/json",
|
||||
"X-Client-Name": "antigravity",
|
||||
}),
|
||||
body: JSON.stringify({ project: "project-1" }),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// — Integration: weekly failure does not affect existing quotas —————
|
||||
describe("weekly quota isolation from existing quota", () => {
|
||||
beforeEach(() => {
|
||||
proxyAwareFetch.mockReset();
|
||||
_clearWeeklyCache();
|
||||
});
|
||||
|
||||
it("existing getAntigravityUsage succeeds even when weekly RPC fails", async () => {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
if (url.includes(":loadCodeAssist")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ cloudaicompanionProject: "p1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }),
|
||||
};
|
||||
}
|
||||
if (url.includes(":fetchAvailableModels")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.8-flash-high": {
|
||||
displayName: "Gemini 3.8 Flash (High)",
|
||||
quotaInfo: { remainingFraction: 0.85, resetTime: "2026-09-15T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes(":retrieveUserQuotaSummary")) {
|
||||
throw new Error("weekly endpoint unavailable");
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
});
|
||||
|
||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
||||
const result = await getAntigravityUsage("token", {});
|
||||
|
||||
expect(result.quotas["gemini-3.8-flash-high"]).toMatchObject({
|
||||
used: 150,
|
||||
total: 1000,
|
||||
remainingPercentage: 85,
|
||||
});
|
||||
expect(result.quotas.gemini_weekly).toBeUndefined();
|
||||
expect(result.quotas.claude_gpt_weekly).toBeUndefined();
|
||||
expect(result.message).toBeUndefined();
|
||||
});
|
||||
|
||||
it("free-tier accounts only show weekly quotas, not per-model short-window quotas", async () => {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
if (url.includes(":loadCodeAssist")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ cloudaicompanionProject: "p1", currentTier: { name: "Starter" }, paidTier: { id: "free-tier", name: "Antigravity Starter Quota" } }),
|
||||
};
|
||||
}
|
||||
if (url.includes(":fetchAvailableModels")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.8-flash-high": {
|
||||
displayName: "Gemini 3.8 Flash (High)",
|
||||
quotaInfo: { remainingFraction: 1, resetTime: "2026-09-15T00:00:00Z" },
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
displayName: "Claude Sonnet 4.6",
|
||||
// Missing remainingFraction — free tier exhausted
|
||||
quotaInfo: { resetTime: "2026-09-13T12:00:00Z" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes(":retrieveUserQuotaSummary")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [{
|
||||
bucketId: "gemini-weekly",
|
||||
displayName: "Weekly Limit Remaining",
|
||||
remainingFraction: 1,
|
||||
resetTime: "2026-09-15T00:00:00Z",
|
||||
}],
|
||||
}, {
|
||||
displayName: "Claude and GPT models",
|
||||
buckets: [{
|
||||
bucketId: "3p-weekly",
|
||||
displayName: "Weekly Limit Remaining",
|
||||
remainingFraction: 0,
|
||||
resetTime: "2026-09-13T12:00:00Z",
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
});
|
||||
|
||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
||||
const result = await getAntigravityUsage("token", {});
|
||||
|
||||
// Per-model quotas should be absent (free-tier accounts skip model parsing)
|
||||
expect(result.quotas["gemini-3.8-flash-high"]).toBeUndefined();
|
||||
expect(result.quotas["claude-sonnet-4-6"]).toBeUndefined();
|
||||
|
||||
// Only weekly quotas should appear
|
||||
expect(result.quotas.gemini_weekly).toMatchObject({
|
||||
used: 0,
|
||||
total: 1000,
|
||||
remainingPercentage: 100,
|
||||
});
|
||||
expect(result.quotas.claude_gpt_weekly).toMatchObject({
|
||||
used: 1000,
|
||||
total: 1000,
|
||||
remainingPercentage: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reconciles weekly quota to 0% when all paid-tier family models are exhausted", async () => {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
if (url.includes(":loadCodeAssist")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ cloudaicompanionProject: "p1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }),
|
||||
};
|
||||
}
|
||||
if (url.includes(":fetchAvailableModels")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.8-flash-high": {
|
||||
displayName: "Gemini 3.8 Flash (High)",
|
||||
// Exhausted model: no remainingFraction, future resetTime
|
||||
quotaInfo: { resetTime: "2026-09-13T12:00:00Z" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes(":retrieveUserQuotaSummary")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [{
|
||||
bucketId: "gemini-weekly",
|
||||
displayName: "Weekly Limit Remaining",
|
||||
remainingFraction: 1,
|
||||
resetTime: "2026-09-15T00:00:00Z",
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
});
|
||||
|
||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
||||
const result = await getAntigravityUsage("token", {});
|
||||
|
||||
// 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({
|
||||
used: 1000,
|
||||
total: 1000,
|
||||
remainingPercentage: 0,
|
||||
resetAt: "2026-09-13T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import airforce from "../../open-sse/providers/registry/api-airforce.js";
|
||||
import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
|
||||
describe("api-airforce free models", () => {
|
||||
const ids = airforce.models.map((m) => m.id);
|
||||
|
||||
it("registers the three live free models", () => {
|
||||
expect(ids).toContain("gpt-oss-120b");
|
||||
expect(ids).toContain("gpt-oss-20b");
|
||||
expect(ids).toContain("kimi-k2.7-code");
|
||||
});
|
||||
|
||||
it("drops the dead catalog ids", () => {
|
||||
expect(ids).not.toContain("anthropic/claude-3.7-sonnet");
|
||||
expect(ids).not.toContain("moonshot/kimi-k2.6");
|
||||
expect(ids).not.toContain("google/gemini-2.5-flash");
|
||||
});
|
||||
|
||||
it("is passthrough so any live id resolves", () => {
|
||||
expect(airforce.passthroughModels).toBe(true);
|
||||
expect(PROVIDERS["api-airforce"].forceStream).toBe(true);
|
||||
});
|
||||
|
||||
it("PROVIDER_MODELS['af'] exposes the new ids", () => {
|
||||
expect(PROVIDER_MODELS.af.map((m) => m.id)).toEqual(expect.arrayContaining([
|
||||
"gpt-oss-120b", "gpt-oss-20b", "kimi-k2.7-code",
|
||||
]));
|
||||
});
|
||||
|
||||
it("caps resolve for the free ids", () => {
|
||||
expect(getCapabilitiesForModel("api-airforce", "kimi-k2.7-code").reasoning).toBe(true);
|
||||
expect(getCapabilitiesForModel("api-airforce", "gpt-oss-120b").reasoning).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
|
||||
describe("getCapabilitiesForModel", () => {
|
||||
|
||||
it("reports DeepSeek V4.1-Flash ids as vision-capable without dropping their thinking/context", () => {
|
||||
const v41 = { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 };
|
||||
expect(getCapabilitiesForModel(undefined, "deepseek-v4.1-flash")).toMatchObject(v41);
|
||||
expect(getCapabilitiesForModel("opencode-go", "deepseek-v4.1-flash")).toMatchObject(v41);
|
||||
expect(getCapabilitiesForModel("openrouter", "deepseek/deepseek-v4.1-flash")).toMatchObject(v41);
|
||||
// "deepseek-flash" is the GA id for V4.1-Flash on the DeepSeek API; the pattern it
|
||||
// used to fall through to gives it 128K/64K, which the exact entry keeps.
|
||||
expect(getCapabilitiesForModel("opencode-go", "deepseek-flash")).toMatchObject({
|
||||
vision: true,
|
||||
reasoning: true,
|
||||
thinkingFormat: "deepseek",
|
||||
contextWindow: 128000,
|
||||
maxOutput: 64000,
|
||||
});
|
||||
// the superseded text-only Flash id stays text-only
|
||||
expect(getCapabilitiesForModel("opencode-go", "deepseek-v4-flash").vision).toBe(false);
|
||||
});
|
||||
const claudeSonnet5Expected = {
|
||||
contextWindow: 1000000,
|
||||
maxOutput: 128000,
|
||||
@@ -62,4 +80,37 @@ describe("getCapabilitiesForModel", () => {
|
||||
expect(getCapabilitiesForModel("kiro", "gpt-5.6-luna-agentic")).toMatchObject(kiroGpt56Expected);
|
||||
expect(getCapabilitiesForModel("kiro", "gpt-5.6-sol-thinking-agentic")).toMatchObject(kiroGpt56Expected);
|
||||
});
|
||||
|
||||
it("reports Codex GPT 6.0 Astra as a vision and thinking capable model", () => {
|
||||
expect(getCapabilitiesForModel("codex", "gpt-6-astra")).toMatchObject({
|
||||
vision: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
thinkingFormat: "openai",
|
||||
contextWindow: 272000,
|
||||
maxOutput: 128000,
|
||||
});
|
||||
});
|
||||
|
||||
it("CommandCode v4.1-flash is vision + effort capable", () => {
|
||||
expect(getCapabilitiesForModel("commandcode", "deepseek/deepseek-v4.1-flash")).toMatchObject({
|
||||
vision: true,
|
||||
reasoning: true,
|
||||
thinkingFormat: "commandcode",
|
||||
thinkingEffortSupported: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("CommandCode MiniMax-M3 is vision capable", () => {
|
||||
expect(getCapabilitiesForModel("commandcode", "MiniMaxAI/MiniMax-M3").vision).toBe(true);
|
||||
});
|
||||
|
||||
it("CommandCode text-only DeepSeek V4 Flash stays non-vision", () => {
|
||||
expect(getCapabilitiesForModel("commandcode", "deepseek/deepseek-v4-flash").vision).toBe(false);
|
||||
expect(getCapabilitiesForModel("commandcode", "deepseek/deepseek-v4-flash")).toMatchObject({
|
||||
reasoning: true,
|
||||
thinkingFormat: "commandcode",
|
||||
thinkingEffortSupported: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
// #3795 — a proxy must not dispatch more cache_control blocks than Anthropic
|
||||
// accepts, and must not lose turns that use single-object content (#3567 interplay).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
anchorClaudeCache,
|
||||
normalizeClaudePassthrough,
|
||||
prepareClaudeRequest,
|
||||
} from "../../open-sse/translator/formats/claude.js";
|
||||
import { claudeToOpenAIRequest } from "../../open-sse/translator/request/claude-to-openai.js";
|
||||
|
||||
const CC = { type: "ephemeral" };
|
||||
const text = (t, extra = {}) => ({ type: "text", text: t, ...extra });
|
||||
const tool = (name, extra = {}) => ({ name, description: "d", input_schema: {}, ...extra });
|
||||
|
||||
// counts markers incl. single-object content — mirrors the upstream contract
|
||||
function countMarkers(body) {
|
||||
let n = 0;
|
||||
if (Array.isArray(body.system)) for (const b of body.system) if (b?.cache_control) n++;
|
||||
if (Array.isArray(body.tools)) for (const t of body.tools) if (t?.cache_control) n++;
|
||||
if (Array.isArray(body.messages)) for (const m of body.messages) {
|
||||
if (Array.isArray(m?.content)) {
|
||||
for (const b of m.content) if (b?.cache_control) n++;
|
||||
} else if (m?.content && typeof m.content === "object" && m.content.cache_control) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
describe("cache marker budget and single-block content", () => {
|
||||
it("never emits more than four markers when the client already spent its budget", () => {
|
||||
const out = anchorClaudeCache({
|
||||
system: [text("s1"), text("s2", { cache_control: CC })],
|
||||
tools: [tool("t1"), tool("t2", { cache_control: CC })],
|
||||
messages: [
|
||||
{ role: "user", content: text("u1", { cache_control: CC }) },
|
||||
{ role: "assistant", content: text("a1", { cache_control: CC }) },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
});
|
||||
expect(countMarkers(out)).toBeLessThanOrEqual(4); // base: 5
|
||||
});
|
||||
|
||||
it("normalizes a single-object turn in passthrough and anchors it", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1")] },
|
||||
{ role: "assistant", content: text("a1") }, // single object, no marker
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
};
|
||||
normalizeClaudePassthrough(body);
|
||||
const assistant = body.messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(Array.isArray(assistant.content)).toBe(true); // base: still bare object
|
||||
expect(assistant.content).toHaveLength(1);
|
||||
const out = anchorClaudeCache(body);
|
||||
expect(countMarkers(out)).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps a turn whose content is a single object and strips its marker", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-sonnet-5", max_tokens: 100,
|
||||
system: [text("s1")],
|
||||
messages: [
|
||||
{ role: "user", content: text("u1", { cache_control: CC }) },
|
||||
{ role: "assistant", content: [text("a1")] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
}, "claude");
|
||||
const kept = out.messages.filter(m => JSON.stringify(m.content).includes("u1"));
|
||||
expect(kept.length).toBe(1); // base: 0 (dropped)
|
||||
expect(kept[0].content).toHaveLength(1); // normalized to array
|
||||
expect(kept[0].content[0].cache_control).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops no conversation turn when content is a single text object", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-sonnet-5", max_tokens: 100,
|
||||
messages: [
|
||||
{ role: "user", content: text("u1") },
|
||||
{ role: "assistant", content: text("a1") },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
}, "claude");
|
||||
expect(out.messages.length).toBe(3); // base: 1
|
||||
expect(Array.isArray(out.messages[0].content)).toBe(true);
|
||||
});
|
||||
|
||||
it("re-anchors the last assistant turn even when it uses single-object content", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-sonnet-5", max_tokens: 100,
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1")] },
|
||||
{ role: "assistant", content: text("a1") },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
}, "claude");
|
||||
expect(countMarkers(out)).toBe(1); // base: 0
|
||||
});
|
||||
|
||||
it("keeps a marked single-object turn when the marker budget is spent", () => {
|
||||
const body = {
|
||||
system: [text("s1", { cache_control: CC })],
|
||||
tools: [tool("t1", { cache_control: CC })],
|
||||
messages: [
|
||||
{ role: "user", content: [text("c1"), text("c2")] },
|
||||
{ role: "assistant", content: [text("a1", { cache_control: CC })] },
|
||||
{ role: "user", content: text("u1", { cache_control: CC }) },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
};
|
||||
normalizeClaudePassthrough(body);
|
||||
const out = anchorClaudeCache(body);
|
||||
const kept = out.messages.filter(m => JSON.stringify(m.content).includes("u1"));
|
||||
expect(kept.length).toBe(1);
|
||||
expect(Array.isArray(kept[0].content)).toBe(true); // base: bare object survives
|
||||
expect(kept[0].content).toHaveLength(1);
|
||||
expect(kept[0].content[0].cache_control).toBeUndefined();
|
||||
const ctx = out.messages.find(m => JSON.stringify(m.content).includes("c1"));
|
||||
expect(ctx.content).toEqual([text("c1"), text("c2")]);
|
||||
expect(countMarkers(out)).toBeLessThanOrEqual(4); // fixed: 3
|
||||
});
|
||||
|
||||
it("keeps single-object turns on the claude-to-openai leg", () => {
|
||||
const out = claudeToOpenAIRequest("m", {
|
||||
messages: [
|
||||
{ role: "user", content: text("u1") },
|
||||
{ role: "assistant", content: { type: "image", source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" } } },
|
||||
],
|
||||
}, false);
|
||||
expect(out.messages.some(m => JSON.stringify(m.content).includes("u1"))).toBe(true); // base: dropped
|
||||
const img = out.messages.find(m => m.role === "assistant");
|
||||
expect(JSON.stringify(img.content)).toContain("image_url"); // base: dropped
|
||||
});
|
||||
|
||||
it("keeps a bare-object user turn folded with a mid-conversation system message", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "user", content: text("u1") },
|
||||
{ role: "system", content: [text("reminder")] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
};
|
||||
normalizeClaudePassthrough(body);
|
||||
const first = body.messages[0];
|
||||
expect(Array.isArray(first.content)).toBe(true);
|
||||
expect(JSON.stringify(first.content).includes("u1")).toBe(true); // pre-hoist: fold zeroes bare-object content
|
||||
expect(JSON.stringify(first.content).includes("reminder")).toBe(true);
|
||||
});
|
||||
it("prunes a client body that already carries five markers down to four", () => {
|
||||
const out = anchorClaudeCache({
|
||||
system: [text("s1"), text("s2", { cache_control: CC })],
|
||||
tools: [tool("t1", { cache_control: CC }), tool("t2", { cache_control: CC })],
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1", { cache_control: CC })] },
|
||||
{ role: "assistant", content: [text("a1", { cache_control: CC })] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
});
|
||||
expect(countMarkers(out)).toBe(4); // pre-fix: 5 forwarded unchanged
|
||||
expect(out.system[0].cache_control).toBeUndefined(); // earliest marker pruned
|
||||
});
|
||||
|
||||
// A spent budget must not cost the head anchors their 1h TTL: system/tools are
|
||||
// the whole point of re-anchoring, and a 5m fallback silently halves the cache
|
||||
// lifetime on exactly the requests that already cached aggressively.
|
||||
it("keeps the 1h head anchors when the client spent the whole budget", () => {
|
||||
const out = anchorClaudeCache({
|
||||
system: [text("s1"), text("s2", { cache_control: CC })],
|
||||
tools: [tool("t1", { cache_control: CC }), tool("t2")],
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1", { cache_control: CC })] },
|
||||
{ role: "assistant", content: [text("a1", { cache_control: CC })] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
});
|
||||
expect(countMarkers(out)).toBeLessThanOrEqual(4);
|
||||
expect(out.system.at(-1).cache_control?.ttl).toBe("1h"); // pre-fix: fell back to 5m
|
||||
expect(out.tools.at(-1).cache_control?.ttl).toBe("1h"); // pre-fix: fell back to 5m
|
||||
});
|
||||
|
||||
it("keeps the 1h head anchors on an over-budget body", () => {
|
||||
const out = anchorClaudeCache({
|
||||
system: [text("s1", { cache_control: CC })],
|
||||
tools: [tool("t1", { cache_control: CC }), tool("t2")],
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1", { cache_control: CC })] },
|
||||
{ role: "assistant", content: [text("a1", { cache_control: CC })] },
|
||||
{ role: "user", content: [text("u2", { cache_control: CC })] },
|
||||
{ role: "assistant", content: [text("a2", { cache_control: CC })] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
});
|
||||
expect(countMarkers(out)).toBe(4);
|
||||
expect(out.system.at(-1).cache_control?.ttl).toBe("1h");
|
||||
expect(out.tools.at(-1).cache_control?.ttl).toBe("1h");
|
||||
});
|
||||
|
||||
it("strips a marker from a deferred tool even when the budget is spent", () => {
|
||||
const out = anchorClaudeCache({
|
||||
system: [text("s1", { cache_control: CC })],
|
||||
tools: [tool("t1", { cache_control: CC, defer_loading: true })],
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1", { cache_control: CC })] },
|
||||
{ role: "assistant", content: [text("a1", { cache_control: CC })] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
});
|
||||
expect(countMarkers(out)).toBeLessThanOrEqual(4);
|
||||
const deferred = out.tools.find(t => t.defer_loading);
|
||||
expect(deferred?.cache_control).toBeUndefined(); // pre-fix: invalid marker forwarded
|
||||
});
|
||||
|
||||
it("keeps a bare-object system reminder on the claude-to-openai leg", () => {
|
||||
const out = claudeToOpenAIRequest("m", {
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "system", content: text("be brief") },
|
||||
],
|
||||
}, false);
|
||||
expect(JSON.stringify(out.messages)).toContain("be brief"); // pre-fix: turn dropped
|
||||
});
|
||||
});
|
||||
@@ -187,6 +187,46 @@ describe("DefaultExecutor.buildHeaders() — anthropic-compatible stripping", ()
|
||||
headers["Anthropic-Version"] || headers["anthropic-version"];
|
||||
expect(hasVersion).toBeDefined();
|
||||
});
|
||||
|
||||
// A node fronting Anthropic (rotating multi-account proxy, corporate gateway)
|
||||
// needs the same beta flags the `claude` provider sends. Without
|
||||
// context-management-2025-06-27 upstream answers HTTP 400
|
||||
// "context_management: Extra inputs are not permitted" and the combo falls
|
||||
// through to the next model without anyone noticing.
|
||||
it("sends context-management beta for a Claude model on a custom host", () => {
|
||||
const executor = new DefaultExecutor("anthropic-compatible-custom");
|
||||
const headers = executor.buildHeaders(
|
||||
{
|
||||
apiKey: "key",
|
||||
providerSpecificData: { baseUrl: "https://myproxy.example.com/v1" },
|
||||
},
|
||||
true,
|
||||
undefined,
|
||||
"claude-opus-5"
|
||||
);
|
||||
|
||||
const betaFlags = (headers["Anthropic-Beta"] || headers["anthropic-beta"] || "")
|
||||
.split(",").map(s => s.trim());
|
||||
expect(betaFlags).toContain("context-management-2025-06-27");
|
||||
// The first-party identity flag is still stripped for a non-Anthropic host.
|
||||
expect(betaFlags).not.toContain("claude-code-20250219");
|
||||
});
|
||||
|
||||
it("gates the beta flags on the model id, not the provider prefix", () => {
|
||||
const executor = new DefaultExecutor("anthropic-compatible-custom");
|
||||
const headers = executor.buildHeaders(
|
||||
{
|
||||
apiKey: "key",
|
||||
providerSpecificData: { baseUrl: "https://myproxy.example.com/v1" },
|
||||
},
|
||||
true,
|
||||
undefined,
|
||||
"kimi-k3"
|
||||
);
|
||||
|
||||
const betaVal = headers["Anthropic-Beta"] || headers["anthropic-beta"] || "";
|
||||
expect(betaVal).not.toContain("context-management-2025-06-27");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── proxyFetch anthropicFetch routing ────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
getClineAccessToken,
|
||||
getClineAuthorizationHeader,
|
||||
} from "../../open-sse/shared/clineAuth.js";
|
||||
|
||||
test("getClineAccessToken keeps an existing workos: prefix", () => {
|
||||
const token = "workos:eyJhbGciOiJSUzI1NiJ9.eyJwYXAiJ9";
|
||||
assert.equal(getClineAccessToken(token), token);
|
||||
assert.equal(getClineAccessToken(` ${token} `), token);
|
||||
});
|
||||
|
||||
test("getClineAccessToken prefixes a bare WorkOS JWT with workos:", () => {
|
||||
const jwt = "eyJhbGciOiJSUzI1NiJ9.eyJwYXAiJ9";
|
||||
assert.equal(getClineAccessToken(jwt), `workos:${jwt}`);
|
||||
});
|
||||
|
||||
test("getClineAccessToken does NOT prefix ClinePass API keys", () => {
|
||||
// ClinePass API keys are opaque strings (e.g. clp_…). Sending them as
|
||||
// `workos:clp_…` makes api.cline.bot respond 401.
|
||||
assert.equal(getClineAccessToken("clp_1234567890abcdef"), "clp_1234567890abcdef");
|
||||
assert.equal(getClineAccessToken("sk-9r-abcdef"), "sk-9r-abcdef");
|
||||
assert.equal(getClineAccessToken(""), "");
|
||||
assert.equal(getClineAccessToken(" "), "");
|
||||
assert.equal(getClineAccessToken(undefined), "");
|
||||
assert.equal(getClineAccessToken(null), "");
|
||||
});
|
||||
|
||||
test("getClineAuthorizationHeader builds a Bearer header without double prefixing", () => {
|
||||
assert.equal(getClineAuthorizationHeader("clp_abc"), "Bearer clp_abc");
|
||||
assert.equal(
|
||||
getClineAuthorizationHeader("eyJpeg.eyJbG"),
|
||||
"Bearer workos:eyJpeg.eyJbG"
|
||||
);
|
||||
assert.equal(
|
||||
getClineAuthorizationHeader("workos:eyJpeg.eyJbG"),
|
||||
"Bearer workos:eyJpeg.eyJbG"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
// Cline free models (z-ai/glm-5.3-flash, deepseek-v4-flash) wrap non-stream
|
||||
// chat completions in {"success":true,"data":{...choices...}} on
|
||||
// https://api.cline.bot/api/v1/chat/completions. Both the UI model-test ping
|
||||
// (src/app/api/models/test/ping.js) and the proxy non-stream path
|
||||
// (open-sse/handlers/chatCore/nonStreamingHandler.js) read `choices` at the
|
||||
// top level, so enveloped choices are invisible ("Provider returned no
|
||||
// completion choices for this model"). These tests pin the unwrap behavior.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock the heavy Next.js-dependent imports BEFORE importing ping.js
|
||||
// (same pattern as tests/unit/ping-reasoning-models-3010.test.js).
|
||||
vi.mock("@/lib/localDb", () => ({ getApiKeys: vi.fn(async () => [{ key: "test-key", isActive: true }]) }));
|
||||
vi.mock("@/shared/constants/config", () => ({ UPDATER_CONFIG: { appPort: 20127 } }));
|
||||
vi.mock("@/shared/utils/machineId", () => ({ getConsistentMachineId: vi.fn(async () => "cli-token") }));
|
||||
// requestDetail.js imports from @/lib/usageDb.js too, so one mock covers both
|
||||
// the handler and its usage/detail helpers.
|
||||
vi.mock("@/lib/usageDb.js", () => ({
|
||||
appendRequestLog: vi.fn(async () => {}),
|
||||
saveRequestDetail: vi.fn(async () => {}),
|
||||
saveRequestUsage: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const { pingModelByKind } = await import("../../src/app/api/models/test/ping.js");
|
||||
const { handleNonStreamingResponse } = await import("../../open-sse/handlers/chatCore/nonStreamingHandler.js");
|
||||
|
||||
// The proxy adds a 2000-token headroom buffer to usage before returning it
|
||||
// to the client (addBufferToUsage), so response-body usage is input + 2000.
|
||||
// The usage recorded via saveRequestUsage is the unbuffered extraction —
|
||||
// asserting on it proves the unwrap ran before usage extraction.
|
||||
const { saveRequestUsage } = await import("@/lib/usageDb.js");
|
||||
|
||||
describe("cline free-models {success,data} envelope", () => {
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function jsonResponse(obj) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify(obj),
|
||||
json: async () => obj,
|
||||
};
|
||||
}
|
||||
|
||||
it("ping: enveloped success unwraps to ok:true (regression for reported error)", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: { choices: [{ message: { content: "OK" } }] } })
|
||||
);
|
||||
const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127");
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("ping: enveloped reasoning-only response still soft-passes with note", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
finish_reason: "length",
|
||||
message: { content: "", reasoning: "The user said hi" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.note).toMatch(/reasoning-only/);
|
||||
});
|
||||
|
||||
it("ping: error envelope passes through without unwrap", async () => {
|
||||
const body = { error: "empty response content", success: false };
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
});
|
||||
const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/empty response content/);
|
||||
});
|
||||
|
||||
it("ping: bare (un-enveloped) body still passes", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ choices: [{ message: { content: "Hello!" } }] }));
|
||||
const result = await pingModelByKind("openai/gpt-4o", "llm", "http://127.0.0.1:20127");
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("ping: does not unwrap for a provider that did not opt in", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: { choices: [{ message: { content: "OK" } }] } })
|
||||
);
|
||||
const result = await pingModelByKind("openai/gpt-4o", "llm", "http://127.0.0.1:20127");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/no completion choices/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cline free-models envelope in nonStreamingHandler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function stubLogger() {
|
||||
return { logProviderResponse() {}, logConvertedResponse() {} };
|
||||
}
|
||||
|
||||
function callHandler(providerResponse, provider = "cline") {
|
||||
return handleNonStreamingResponse({
|
||||
providerResponse,
|
||||
provider,
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
sourceFormat: "openai",
|
||||
targetFormat: "openai",
|
||||
body: { stream: false },
|
||||
stream: false,
|
||||
translatedBody: null,
|
||||
finalBody: null,
|
||||
requestStartTime: Date.now(),
|
||||
connectionId: "c1",
|
||||
apiKey: "k",
|
||||
clientRawRequest: null,
|
||||
onRequestSuccess: () => {},
|
||||
reqLogger: stubLogger(),
|
||||
toolNameMap: null,
|
||||
customToolNames: null,
|
||||
trackDone: () => {},
|
||||
appendLog: () => {},
|
||||
pxpipe: null,
|
||||
reqTag: "t",
|
||||
log: null,
|
||||
});
|
||||
}
|
||||
|
||||
it("unwraps the {success,data} envelope before usage extraction and translation", async () => {
|
||||
const providerResponse = new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
choices: [{ message: { content: "Hi" } }],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 2 },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
const result = await callHandler(providerResponse);
|
||||
expect(result.success).toBe(true);
|
||||
const body = await result.response.json();
|
||||
expect(body.choices).toBeDefined();
|
||||
expect(body.choices[0].message.content).toBe("Hi");
|
||||
expect(body.success).toBeUndefined();
|
||||
expect(saveRequestUsage).toHaveBeenCalledTimes(1);
|
||||
expect(saveRequestUsage.mock.calls[0][0].tokens).toMatchObject({
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
});
|
||||
expect(body.usage.prompt_tokens).toBe(2005);
|
||||
});
|
||||
|
||||
it("passes a bare (non-enveloped) body through unchanged", async () => {
|
||||
const providerResponse = new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: "Hi" } }],
|
||||
usage: { prompt_tokens: 3, completion_tokens: 1 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
const result = await callHandler(providerResponse);
|
||||
expect(result.success).toBe(true);
|
||||
const body = await result.response.json();
|
||||
expect(body.choices[0].message.content).toBe("Hi");
|
||||
expect(saveRequestUsage).toHaveBeenCalledTimes(1);
|
||||
expect(saveRequestUsage.mock.calls[0][0].tokens).toMatchObject({
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 1,
|
||||
});
|
||||
expect(body.usage.prompt_tokens).toBe(2003);
|
||||
});
|
||||
|
||||
// The unwrap is opt-in via transport.quirks.clineEnvelope so it can never
|
||||
// rewrite another provider's body — including one that happens to return
|
||||
// {"success":true,"data":...} for its own reasons.
|
||||
it("leaves an enveloped body untouched for a provider that did not opt in", async () => {
|
||||
const providerResponse = new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: { choices: [{ message: { content: "Hi" } }] },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
const result = await callHandler(providerResponse, "openai");
|
||||
const body = await result.response.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.choices[0].message.content).toBe("Hi");
|
||||
expect(body.choices).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cline /api/v1/models aggregation (resolveClineModels vs resolveClinepassModels)", () => {
|
||||
const API_MODELS_URL = "https://api.cline.bot/api/v1/models";
|
||||
|
||||
const API_RESPONSE = [
|
||||
{ id: "cline-pass/deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
||||
{ id: "cline-pass/glm-5.2", name: "GLM-5.2" },
|
||||
{ id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash" },
|
||||
{ id: "z-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash (Free)" },
|
||||
];
|
||||
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("resolveClineModels returns all models (including free-tier)", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => API_RESPONSE,
|
||||
});
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.models).toHaveLength(4);
|
||||
const ids = result.models.map((m) => m.id);
|
||||
expect(ids).toContain("cline-pass/deepseek-v4-flash");
|
||||
expect(ids).toContain("z-ai/glm-5.3-flash");
|
||||
expect(ids).toContain("z-ai/deepseek-v4-flash");
|
||||
});
|
||||
|
||||
it("resolveClinepassModels returns only cline-pass/ models", async () => {
|
||||
const { resolveClinepassModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => API_RESPONSE,
|
||||
});
|
||||
const result = await resolveClinepassModels({ accessToken: "test-token" });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.models).toHaveLength(2);
|
||||
const ids = result.models.map((m) => m.id);
|
||||
expect(ids).toContain("cline-pass/deepseek-v4-flash");
|
||||
expect(ids).toContain("cline-pass/glm-5.2");
|
||||
expect(ids).not.toContain("z-ai/glm-5.3-flash");
|
||||
});
|
||||
|
||||
it("resolveClineModels unwraps {success,data} envelope", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: API_RESPONSE }),
|
||||
});
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.models).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("resolveClineModels returns null when no token", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
const result = await resolveClineModels({});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("resolveClineModels returns null on fetch error", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
fetchMock.mockRejectedValue(new Error("network error"));
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("resolveClineModels returns {id,name} shape", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => API_RESPONSE,
|
||||
});
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
expect(result.models[0]).toHaveProperty("id");
|
||||
expect(result.models[0]).toHaveProperty("name");
|
||||
expect(typeof result.models[0].id).toBe("string");
|
||||
expect(typeof result.models[0].name).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getDefaultModel,
|
||||
getModelQuotaFamily,
|
||||
getModelUpstreamId,
|
||||
getProviderModels,
|
||||
} from "../../open-sse/config/providerModels.js";
|
||||
import { getModelInfoCore } from "../../open-sse/services/model.js";
|
||||
|
||||
// Codex CLI's auto-review sends the bare model id "codex-auto-review". Before #1398 it fell
|
||||
// through prefix inference to the "openai" default and failed with
|
||||
// "No active credentials for provider: openai".
|
||||
describe("codex auto-review routing (#1398)", () => {
|
||||
it("routes the bare Codex auto-review model to the OAuth Codex provider", async () => {
|
||||
await expect(getModelInfoCore("codex-auto-review", {})).resolves.toEqual({
|
||||
provider: "codex",
|
||||
model: "codex-auto-review",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes Codex auto-review as a review-quota Codex model", () => {
|
||||
const autoReview = getProviderModels("cx").find(
|
||||
(model) => model.id === "codex-auto-review",
|
||||
);
|
||||
|
||||
expect(autoReview).toBeTruthy();
|
||||
expect(autoReview.name).toBe("Codex Auto Review");
|
||||
expect(getModelQuotaFamily("cx", "codex-auto-review")).toBe("review");
|
||||
});
|
||||
|
||||
// getModelUpstreamId strips CODEX_REVIEW_SUFFIX from unregistered "cx" ids, which would send
|
||||
// "codex-auto" upstream. This model is not a derived review variant, so it must go out verbatim.
|
||||
it("forwards the id upstream without stripping the -review suffix", () => {
|
||||
expect(getModelUpstreamId("cx", "codex-auto-review")).toBe(
|
||||
"codex-auto-review",
|
||||
);
|
||||
});
|
||||
|
||||
// Registering it must not push it to the front of the cx list — getDefaultModel takes models[0].
|
||||
it("does not become the default Codex model", () => {
|
||||
expect(getDefaultModel("cx")).not.toBe("codex-auto-review");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getModelsByProviderId, getModelType, isValidModel } from "../../open-sse/config/providerModels.js";
|
||||
import { getModelInfoCore } from "../../open-sse/services/model.js";
|
||||
import { handleImageGenerationCore } from "../../open-sse/handlers/imageGenerationCore.js";
|
||||
|
||||
const models = ["gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra"];
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("Codex GPT-5.6 image models", () => {
|
||||
it.each(models)("exposes %s-image as an image model while retaining its chat entry", (model) => {
|
||||
const catalog = getModelsByProviderId("codex");
|
||||
expect(catalog.filter((entry) => entry.id === `${model}-image`)).toHaveLength(1);
|
||||
expect(catalog.find((entry) => entry.id === `${model}-image`)).toMatchObject({
|
||||
kind: "image",
|
||||
capabilities: ["text2img", "edit"],
|
||||
params: ["size", "quality", "background", "image_detail", "output_format"],
|
||||
});
|
||||
expect(isValidModel("cx", `${model}-image`)).toBe(true);
|
||||
expect(getModelType("cx", `${model}-image`)).toBe("image");
|
||||
expect(catalog.find((entry) => entry.id === model)).toBeDefined();
|
||||
expect(getModelType("cx", model)).not.toBe("image");
|
||||
});
|
||||
|
||||
it.each(models)("routes %s-image edits and streams image events", async (model) => {
|
||||
const events = [
|
||||
["response.image_generation_call.partial_image", { partial_image_b64: "cGFydGlhbA==", partial_image_index: 0 }],
|
||||
["response.output_item.done", { item: { type: "image_generation_call", result: "ZmluYWw=" } }],
|
||||
];
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(
|
||||
events.map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`).join(""),
|
||||
{ headers: { "Content-Type": "text/event-stream" } },
|
||||
));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const onRequestSuccess = vi.fn();
|
||||
const modelInfo = await getModelInfoCore(`cx/${model}-image`);
|
||||
expect(modelInfo).toEqual({ provider: "codex", model: `${model}-image` });
|
||||
expect(await getModelInfoCore(`codex/${model}-image`)).toEqual(modelInfo);
|
||||
|
||||
const result = await handleImageGenerationCore({
|
||||
modelInfo,
|
||||
body: {
|
||||
prompt: "Make the square blue",
|
||||
image: "data:image/png;base64,cmVmZXJlbmNl",
|
||||
image_detail: "low",
|
||||
size: "1024x1024",
|
||||
quality: "high",
|
||||
background: "transparent",
|
||||
output_format: "WEBP",
|
||||
},
|
||||
credentials: { accessToken: "test-token" },
|
||||
streamToClient: true,
|
||||
onRequestSuccess,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(result.response.headers.get("content-type")).toBe("text/event-stream");
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://chatgpt.com/backend-api/codex/responses");
|
||||
const upstreamBody = JSON.parse(options.body);
|
||||
expect(upstreamBody.model).toBe(model);
|
||||
expect(upstreamBody.tools).toEqual([{
|
||||
type: "image_generation", output_format: "webp", size: "1024x1024",
|
||||
quality: "high", background: "transparent",
|
||||
}]);
|
||||
expect(upstreamBody.input[0].content).toContainEqual({
|
||||
type: "input_image", image_url: "data:image/png;base64,cmVmZXJlbmNl", detail: "low",
|
||||
});
|
||||
const stream = await result.response.text();
|
||||
expect(stream).toContain('event: partial_image\ndata: {"b64_json":"cGFydGlhbA==","index":0}');
|
||||
expect(stream).toContain("event: done\n");
|
||||
expect(stream).toContain('"data":[{"b64_json":"ZmluYWw="}]');
|
||||
expect(onRequestSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,17 @@ describe("Codex reset credits", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces structured upstream errors as readable messages", async () => {
|
||||
mocks.proxyAwareFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ error: { message: "Reset credits are unavailable for this account" } }),
|
||||
});
|
||||
|
||||
const { getCodexRateLimitResetCredits } = await import("../../open-sse/services/usage/codex.js");
|
||||
await expect(getCodexRateLimitResetCredits("token")).rejects.toThrow("Reset credits are unavailable for this account");
|
||||
});
|
||||
|
||||
it("GET refreshes OAuth credentials before returning reset credit details", async () => {
|
||||
const connection = {
|
||||
id: "conn_1",
|
||||
|
||||
@@ -118,6 +118,69 @@ describe("CodexExecutor tool normalization", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips only Unicode-property patterns rejected by Codex", () => {
|
||||
const unicodePattern = "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}]{1,200}$";
|
||||
const validPattern = "^[a-z][a-z0-9_-]{0,31}$";
|
||||
const sourceParameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
artifact: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", pattern: unicodePattern },
|
||||
slug: { type: "string", pattern: validPattern },
|
||||
},
|
||||
},
|
||||
// A property named "pattern" is data, not the schema keyword.
|
||||
pattern: { type: "string", pattern: validPattern },
|
||||
},
|
||||
allOf: [{ properties: { title: { type: "string", pattern: unicodePattern } } }],
|
||||
};
|
||||
const tools = normalizeTools([{
|
||||
type: "function",
|
||||
name: "Artifact",
|
||||
parameters: sourceParameters,
|
||||
}]);
|
||||
|
||||
expect(tools[0].parameters.properties.artifact.properties.name.pattern).toBeUndefined();
|
||||
expect(tools[0].parameters.properties.artifact.properties.slug.pattern).toBe(validPattern);
|
||||
expect(tools[0].parameters.properties.pattern.pattern).toBe(validPattern);
|
||||
expect(tools[0].parameters.allOf[0].properties.title.pattern).toBeUndefined();
|
||||
// Copy-on-write: the caller's schema remains available for another provider.
|
||||
expect(sourceParameters.properties.artifact.properties.name.pattern).toBe(unicodePattern);
|
||||
});
|
||||
|
||||
it("keeps escaped literal property text and schema identity when no strip is needed", () => {
|
||||
const parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
literal: { type: "string", pattern: "^\\\\p{Cc}$" },
|
||||
simple: { type: "string", pattern: "^[A-Z]+$" },
|
||||
},
|
||||
};
|
||||
const tools = normalizeTools([{ type: "function", name: "probe", parameters }]);
|
||||
|
||||
expect(tools[0].parameters).toBe(parameters);
|
||||
expect(tools[0].parameters.properties.literal.pattern).toBe("^\\\\p{Cc}$");
|
||||
});
|
||||
|
||||
it("sanitizes nested namespace function schemas", () => {
|
||||
const tools = normalizeTools([{
|
||||
type: "namespace",
|
||||
name: "agent",
|
||||
tools: [{
|
||||
type: "function",
|
||||
name: "Artifact",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string", pattern: "^\\p{Cc}+$" } },
|
||||
},
|
||||
}],
|
||||
}]);
|
||||
|
||||
expect(tools[0].tools[0].parameters.properties.name.pattern).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves custom freeform tools with format payloads", () => {
|
||||
const tools = normalizeTools([
|
||||
{
|
||||
|
||||
@@ -132,6 +132,46 @@ describe("inspectAndWrapCommandCodeResponse", () => {
|
||||
expect(text).toContain("Hello from Laguna");
|
||||
expect(text).toContain("data: [DONE]");
|
||||
});
|
||||
|
||||
it("retries when initial stream yields an error and succeeds on second attempt", async () => {
|
||||
let callCount = 0;
|
||||
const executor = new CommandCodeExecutor();
|
||||
|
||||
// Override execute on instance to test retry behavior
|
||||
executor.execute = async (opts) => {
|
||||
const maxRetries = 2;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
callCount++;
|
||||
let rawResponse;
|
||||
if (callCount === 1) {
|
||||
rawResponse = new Response(createNdjsonStream([
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { type: "server_error", message: "Network connection lost." }
|
||||
}) + "\n"
|
||||
]), { status: 200, headers: { "Content-Type": "text/event-stream" } });
|
||||
} else {
|
||||
rawResponse = new Response(createNdjsonStream([
|
||||
JSON.stringify({ type: "start" }) + "\n",
|
||||
JSON.stringify({ type: "text-delta", text: "Recovered from lost connection" }) + "\n",
|
||||
JSON.stringify({ type: "finish" }) + "\n"
|
||||
]), { status: 200, headers: { "Content-Type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
const wrappedResponse = await inspectAndWrapCommandCodeResponse(rawResponse, opts.model);
|
||||
if (!wrappedResponse.ok && attempt < maxRetries) {
|
||||
continue;
|
||||
}
|
||||
return { response: wrappedResponse };
|
||||
}
|
||||
};
|
||||
|
||||
const res = await executor.execute({ model: "deepseek/deepseek-v4.1-flash" });
|
||||
expect(res.response.ok).toBe(true);
|
||||
expect(callCount).toBe(2);
|
||||
const text = await res.response.text();
|
||||
expect(text).toContain("Recovered from lost connection");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandCode in Combo Fallback", () => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.js";
|
||||
import {
|
||||
USAGE_SUPPORTED_PROVIDERS,
|
||||
USAGE_APIKEY_PROVIDERS,
|
||||
} from "../../src/shared/constants/providers.js";
|
||||
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
const BASE = "https://api.commandcode.ai";
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const WHOAMI = {
|
||||
user: { name: "Hieu", email: "hieu@example.com" },
|
||||
org: { id: "org_1", name: "personal" },
|
||||
};
|
||||
const CREDITS = {
|
||||
credits: { monthlyCredits: 12.5, purchasedCredits: 1, freeCredits: 0.5 },
|
||||
windowLimits: {
|
||||
fiveHour: { used: 2, cap: 10, resetAt: Date.now() + 3_600_000, exceeded: false },
|
||||
weekly: { used: 20, cap: 70, resetAt: Date.now() + 86_400_000, exceeded: false },
|
||||
},
|
||||
};
|
||||
const SUBS = {
|
||||
data: {
|
||||
planId: "individual-goat",
|
||||
currentPeriodStart: "2026-09-01T00:00:00.000Z",
|
||||
currentPeriodEnd: "2026-10-01T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
function mockHappyPath() {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/alpha/whoami")) return jsonResponse(WHOAMI);
|
||||
if (u.includes("/alpha/billing/credits")) return jsonResponse(CREDITS);
|
||||
if (u.includes("/alpha/billing/subscriptions")) return jsonResponse(SUBS);
|
||||
return jsonResponse({ error: "unexpected " + u }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
describe("commandcode registry usage flags", () => {
|
||||
it("is listed for apikey quota dashboard", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("commandcode");
|
||||
expect(USAGE_APIKEY_PROVIDERS).toContain("commandcode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsageForProvider(commandcode)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns a message when apiKey is missing", async () => {
|
||||
const usage = await getUsageForProvider({ provider: "commandcode" });
|
||||
expect(usage.message).toMatch(/api key/i);
|
||||
expect(proxyAwareFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("GETs whoami, credits, and subscriptions with Bearer apiKey", async () => {
|
||||
mockHappyPath();
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "commandcode",
|
||||
apiKey: "user_test",
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("GOAT");
|
||||
const urls = proxyAwareFetch.mock.calls.map(([url]) => String(url));
|
||||
expect(urls.some((u) => u.startsWith(`${BASE}/alpha/whoami`))).toBe(true);
|
||||
expect(urls.some((u) => u.includes("/alpha/billing/credits") && u.includes("orgId=org_1"))).toBe(true);
|
||||
expect(urls.some((u) => u.includes("/alpha/billing/subscriptions") && u.includes("orgId=org_1"))).toBe(true);
|
||||
expect(proxyAwareFetch.mock.calls[0][1].headers.Authorization).toBe("Bearer user_test");
|
||||
});
|
||||
|
||||
it("maps remaining credits vs plan cap and rate windows", async () => {
|
||||
mockHappyPath();
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "commandcode",
|
||||
apiKey: "user_test",
|
||||
});
|
||||
|
||||
// remaining = 12.5 + 1 + 0.5 = 14; cap GOAT = 70; used = 56
|
||||
expect(usage.quotas.Credits).toMatchObject({
|
||||
used: 56,
|
||||
total: 70,
|
||||
unlimited: false,
|
||||
});
|
||||
expect(usage.quotas["Session (5h)"]).toMatchObject({
|
||||
used: 2,
|
||||
total: 10,
|
||||
unlimited: false,
|
||||
});
|
||||
expect(usage.quotas.Weekly).toMatchObject({
|
||||
used: 20,
|
||||
total: 70,
|
||||
});
|
||||
expect(new Date(usage.quotas.Credits.resetAt).toISOString()).toBe("2026-10-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns an auth message on 401", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401));
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "commandcode",
|
||||
apiKey: "bad",
|
||||
});
|
||||
expect(usage.message).toMatch(/auth|key|login/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuotaData(commandcode)", () => {
|
||||
it("forwards used/total/resetAt for the dashboard table", () => {
|
||||
const rows = parseQuotaData("commandcode", {
|
||||
plan: "GOAT",
|
||||
quotas: {
|
||||
Credits: { used: 56, total: 70, resetAt: "2026-10-01T00:00:00.000Z" },
|
||||
"Session (5h)": { used: 2, total: 10, resetAt: "2026-09-16T10:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({ name: "Credits", used: 56, total: 70 });
|
||||
expect(rows[1]).toMatchObject({ name: "Session (5h)", used: 2, total: 10 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* SSRF guard on POST /api/cli-tools/cowork-mcp-tools (#3782).
|
||||
*
|
||||
* Remote callers must not be able to force server-side fetches to
|
||||
* internal URLs; local-host use (self-hosted MCP servers) keeps working.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json: (body, init) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: init?.status ?? 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { POST } = await import(
|
||||
"../../src/app/api/cli-tools/cowork-mcp-tools/route.js"
|
||||
);
|
||||
|
||||
function remoteRequest(url) {
|
||||
return new Request("http://gateway.example.com/api/cli-tools/cowork-mcp-tools", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("cowork-mcp-tools SSRF guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rejects loopback URLs from remote callers without fetching", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const res = await POST(remoteRequest("http://127.0.0.1:18731/internal-admin"));
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toEqual({ error: "URL not allowed" });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects private-network URLs from remote callers", async () => {
|
||||
for (const url of ["http://10.0.0.5/mcp", "http://192.168.1.1/mcp", "http://localhost:3000/mcp"]) {
|
||||
const res = await POST(remoteRequest(url));
|
||||
expect(res.status, `should reject ${url}`).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("still requires a url", async () => {
|
||||
const res = await POST(
|
||||
new Request("http://gateway.example.com/api/cli-tools/cowork-mcp-tools", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,104 @@ describe("DB SQLite layer — public API parity", () => {
|
||||
expect(back.providerSpecificData).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("providerConnections: successful validation clears stale routing locks", async () => {
|
||||
const c = await sqliteDb.createProviderConnection({
|
||||
provider: "health-reset-update",
|
||||
authType: "oauth",
|
||||
email: "update@example.com",
|
||||
accessToken: "old-token",
|
||||
});
|
||||
await sqliteDb.updateProviderConnection(c.id, {
|
||||
testStatus: "unavailable",
|
||||
lastError: "Access denied",
|
||||
lastErrorAt: "2026-09-05T00:00:00.000Z",
|
||||
errorCode: 403,
|
||||
backoffLevel: 3,
|
||||
rateLimitedUntil: "2099-01-01T00:00:00.000Z",
|
||||
modelLock_modelA: "2099-01-01T00:00:00.000Z",
|
||||
modelLock_modelB: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await sqliteDb.updateProviderConnection(c.id, { testStatus: "active" });
|
||||
|
||||
const back = await sqliteDb.getProviderConnectionById(c.id);
|
||||
expect(back).toMatchObject({
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
errorCode: null,
|
||||
backoffLevel: 0,
|
||||
rateLimitedUntil: null,
|
||||
modelLock_modelA: null,
|
||||
modelLock_modelB: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("providerConnections: re-saving valid OAuth credentials clears stale routing locks", async () => {
|
||||
const existing = await sqliteDb.createProviderConnection({
|
||||
provider: "health-reset-resave",
|
||||
authType: "oauth",
|
||||
email: "resave@example.com",
|
||||
accessToken: "old-token",
|
||||
});
|
||||
await sqliteDb.updateProviderConnection(existing.id, {
|
||||
testStatus: "unavailable",
|
||||
lastError: "Access denied",
|
||||
errorCode: 403,
|
||||
backoffLevel: 2,
|
||||
modelLock_modelA: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const resaved = await sqliteDb.createProviderConnection({
|
||||
provider: "health-reset-resave",
|
||||
authType: "oauth",
|
||||
email: "resave@example.com",
|
||||
accessToken: "new-token",
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
expect(resaved.id).toBe(existing.id);
|
||||
const back = await sqliteDb.getProviderConnectionById(existing.id);
|
||||
expect(back).toMatchObject({
|
||||
accessToken: "new-token",
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
errorCode: null,
|
||||
backoffLevel: 0,
|
||||
modelLock_modelA: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("providerConnections: active soft warnings survive the health reset", async () => {
|
||||
const c = await sqliteDb.createProviderConnection({
|
||||
provider: "health-reset-warning",
|
||||
authType: "oauth",
|
||||
email: "warning@example.com",
|
||||
});
|
||||
await sqliteDb.updateProviderConnection(c.id, {
|
||||
testStatus: "unavailable",
|
||||
lastError: "Old failure",
|
||||
modelLock_modelA: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const warningAt = "2026-09-06T00:00:00.000Z";
|
||||
await sqliteDb.updateProviderConnection(c.id, {
|
||||
testStatus: "active",
|
||||
lastError: "Connected, but credits are exhausted",
|
||||
lastErrorAt: warningAt,
|
||||
});
|
||||
|
||||
const back = await sqliteDb.getProviderConnectionById(c.id);
|
||||
expect(back).toMatchObject({
|
||||
testStatus: "active",
|
||||
lastError: "Connected, but credits are exhausted",
|
||||
lastErrorAt: warningAt,
|
||||
errorCode: null,
|
||||
backoffLevel: 0,
|
||||
modelLock_modelA: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("providerConnections: GitHub OAuth uses account identity as fallback name", async () => {
|
||||
const c = await sqliteDb.createProviderConnection({
|
||||
provider: "github",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Regression test: prepareClaudeRequest() must strip client-defined `custom`
|
||||
* tools when forwarding to a provider whose Anthropic-compatible endpoint
|
||||
* does not accept them (DeepSeek — accepts only web_search_*).
|
||||
*
|
||||
* Background:
|
||||
* When Claude Code talks to a DeepSeek route via /v1/messages, 9router
|
||||
* forwards the request body as Claude-format to
|
||||
* https://api.deepseek.com/anthropic/v1/messages. MCP / function tools
|
||||
* arrive with `type: "custom"`. DeepSeek rejects them with HTTP 400
|
||||
* "tools[0]: unknown variant `custom`, expected `web_search_20250305`
|
||||
* or `web_search_20260209`". The previous generic filter dropped them
|
||||
* but also stripped the web_search_* tools that DeepSeek actually
|
||||
* accepts. DeepSeek now exposes a `quirks.claudeSupportedToolTypes`
|
||||
* whitelist and prepareClaudeRequest honours it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { prepareClaudeRequest } from "../../open-sse/translator/formats/claude.js";
|
||||
import { PROVIDERS } from "../../open-sse/providers/index.js";
|
||||
|
||||
function makeBody(tools) {
|
||||
return {
|
||||
model: "deepseek-v4-pro",
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools,
|
||||
};
|
||||
}
|
||||
|
||||
describe("prepareClaudeRequest — provider: deepseek", () => {
|
||||
it("declares the supportedTypes quirk on the provider transport", () => {
|
||||
expect(PROVIDERS.deepseek).toBeDefined();
|
||||
expect(PROVIDERS.deepseek.quirks).toBeDefined();
|
||||
expect(PROVIDERS.deepseek.quirks.claudeSupportedToolTypes).toEqual([
|
||||
"web_search_20250305",
|
||||
"web_search_20260209",
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips MCP / custom tools (the regression) before forwarding", () => {
|
||||
const body = makeBody([
|
||||
{ type: "custom", name: "Bash", input_schema: { type: "object" } },
|
||||
{ type: "custom", name: "Read", input_schema: { type: "object" } },
|
||||
{ type: "custom", name: "Glob", input_schema: { type: "object" } },
|
||||
]);
|
||||
|
||||
const out = prepareClaudeRequest(body, "deepseek");
|
||||
|
||||
expect(out.tools).toBeUndefined();
|
||||
expect(out.tool_choice).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps web_search_20250305 and web_search_20260209", () => {
|
||||
const out = prepareClaudeRequest(
|
||||
makeBody([
|
||||
{ type: "web_search_20250305", name: "web_search" },
|
||||
{ type: "web_search_20260209", name: "web_search" },
|
||||
]),
|
||||
"deepseek"
|
||||
);
|
||||
|
||||
expect(Array.isArray(out.tools)).toBe(true);
|
||||
expect(out.tools).toHaveLength(2);
|
||||
const types = out.tools.map(t => t.type).sort();
|
||||
expect(types).toEqual(["web_search_20250305", "web_search_20260209"]);
|
||||
});
|
||||
|
||||
it("preserves the `type` field on web_search_* tools (DeepSeek requires it)", () => {
|
||||
// The .map below the filter must NOT strip `type` when the provider
|
||||
// declared a whitelist — DeepSeek would reject a tool object missing
|
||||
// its discriminator field with the same unknown-variant error.
|
||||
const out = prepareClaudeRequest(
|
||||
makeBody([{ type: "web_search_20250305", name: "web_search" }]),
|
||||
"deepseek"
|
||||
);
|
||||
|
||||
expect(out.tools[0].type).toBe("web_search_20250305");
|
||||
expect(out.tools[0].name).toBe("web_search");
|
||||
});
|
||||
|
||||
it("drops `custom` but keeps `web_search_*` when both are present", () => {
|
||||
const out = prepareClaudeRequest(
|
||||
makeBody([
|
||||
{ type: "custom", name: "Bash", input_schema: { type: "object" } },
|
||||
{ type: "web_search_20250305", name: "web_search" },
|
||||
]),
|
||||
"deepseek"
|
||||
);
|
||||
|
||||
expect(Array.isArray(out.tools)).toBe(true);
|
||||
expect(out.tools).toHaveLength(1);
|
||||
expect(out.tools[0].type).toBe("web_search_20250305");
|
||||
expect(out.tools[0].name).toBe("web_search");
|
||||
});
|
||||
|
||||
it("survives when body has no tools", () => {
|
||||
const out = prepareClaudeRequest(makeBody(undefined), "deepseek");
|
||||
expect(out.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects future / unknown tool types instead of forwarding them", () => {
|
||||
const out = prepareClaudeRequest(
|
||||
makeBody([{ type: "future_tool_2099", name: "x" }]),
|
||||
"deepseek"
|
||||
);
|
||||
expect(out.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareClaudeRequest — backward compat: providers without the quirk", () => {
|
||||
// Pick any non-Claude provider that has a Claude-format transport and has
|
||||
// NOT been migrated to the new quirk. This protects GLM / Kimi / future
|
||||
// Anthropic-compatible providers from unintended changes.
|
||||
it("keeps prior behaviour (drop custom + web_search_*, normalize no-type tools)", () => {
|
||||
const candidate = Object.entries(PROVIDERS).find(
|
||||
([id, p]) =>
|
||||
id !== "claude" &&
|
||||
p?.transports?.some(t => t.format === "claude") &&
|
||||
!p?.quirks?.claudeSupportedToolTypes
|
||||
);
|
||||
|
||||
if (!candidate) {
|
||||
// Every Claude-format provider has been migrated — nothing to verify.
|
||||
return;
|
||||
}
|
||||
|
||||
const [providerId] = candidate;
|
||||
|
||||
const out = prepareClaudeRequest(
|
||||
makeBody([
|
||||
{ type: "custom", name: "Bash", input_schema: { type: "object" } },
|
||||
{ type: "web_search_20250305", name: "web_search" },
|
||||
{ name: "no_type_tool", input_schema: { type: "object" } },
|
||||
]),
|
||||
providerId
|
||||
);
|
||||
|
||||
if (out.tools !== undefined) {
|
||||
for (const t of out.tools) {
|
||||
expect(t.type).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,8 @@ describe("getUsageForProvider(deepseek)", () => {
|
||||
used: 0,
|
||||
total: 12.5,
|
||||
remainingPercentage: 100,
|
||||
isCreditBalance: true,
|
||||
currency: "USD",
|
||||
});
|
||||
expect(usage.quotas["Balance (USD)"].remaining).toBeUndefined();
|
||||
// Zero CNY still listed so user sees currency row
|
||||
|
||||
@@ -316,7 +316,7 @@ describe("handleImageGenerationCore", () => {
|
||||
expect(responseBody.data[0].b64_json).toBeTruthy();
|
||||
});
|
||||
|
||||
it("generates image with Codex gpt-5.5-image using current Codex version header", async () => {
|
||||
it.each(["gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"])("generates image with Codex %s-image using current Codex version header", async (model) => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
[
|
||||
@@ -335,7 +335,7 @@ describe("handleImageGenerationCore", () => {
|
||||
size: "1024x1024",
|
||||
output_format: "png",
|
||||
},
|
||||
modelInfo: { provider: "codex", model: "gpt-5.5-image" },
|
||||
modelInfo: { provider: "codex", model: `${model}-image` },
|
||||
credentials: {
|
||||
accessToken: "codex-token",
|
||||
providerSpecificData: { chatgptAccountId: "account-123" },
|
||||
@@ -351,14 +351,14 @@ describe("handleImageGenerationCore", () => {
|
||||
headers: expect.objectContaining({
|
||||
authorization: "Bearer codex-token",
|
||||
"chatgpt-account-id": "account-123",
|
||||
version: "0.136.0",
|
||||
version: "0.154.0",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const fetchCall = global.fetch.mock.calls[0];
|
||||
const requestBody = JSON.parse(fetchCall[1].body);
|
||||
expect(requestBody.model).toBe("gpt-5.5");
|
||||
expect(requestBody.model).toBe(model);
|
||||
expect(requestBody.tools).toEqual([
|
||||
{ type: "image_generation", output_format: "png", size: "1024x1024" },
|
||||
]);
|
||||
@@ -367,6 +367,47 @@ describe("handleImageGenerationCore", () => {
|
||||
expect(responseBody.data[0].b64_json).toBe("base64codeximage");
|
||||
});
|
||||
|
||||
it("generates image with Codex gpt-image-2.5 tool model", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
[
|
||||
"event: response.output_item.done",
|
||||
'data: {"item":{"type":"image_generation_call","result":"base64codeximage"}}',
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
||||
)
|
||||
);
|
||||
|
||||
const result = await handleImageGenerationCore({
|
||||
body: {
|
||||
prompt: "A futuristic city",
|
||||
size: "1024x1024",
|
||||
output_format: "png",
|
||||
},
|
||||
modelInfo: { provider: "codex", model: "gpt-image-2.5" },
|
||||
credentials: {
|
||||
accessToken: "codex-token",
|
||||
providerSpecificData: { chatgptAccountId: "account-123" },
|
||||
},
|
||||
log: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const fetchCall = global.fetch.mock.calls[0];
|
||||
const requestBody = JSON.parse(fetchCall[1].body);
|
||||
expect(requestBody.model).toBe("gpt-5.5");
|
||||
expect(requestBody.tools).toEqual([
|
||||
{ type: "image_generation", output_format: "png", size: "1024x1024", action: "generate", model: "gpt-image-2.5" },
|
||||
]);
|
||||
expect(requestBody.tool_choice).toEqual({ type: "image_generation" });
|
||||
expect(requestBody.reasoning).toEqual({ effort: "medium", summary: "auto" });
|
||||
|
||||
const responseBody = await result.response.json();
|
||||
expect(responseBody.data[0].b64_json).toBe("base64codeximage");
|
||||
});
|
||||
|
||||
it("generates image with Cloudflare Workers AI JSON response", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
|
||||
@@ -20,26 +20,26 @@ describe("Kiro auth-aware endpoint routing", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps Builder ID OAuth on the Kiro runtime surface", () => {
|
||||
it("routes Builder ID OAuth through Amazon Q first (runtime path deprecated)", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("builder-id"))).toEqual([
|
||||
RUNTIME,
|
||||
CODEWHISPERER,
|
||||
Q,
|
||||
CODEWHISPERER,
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps external IdP on CodeWhisperer before Amazon Q", () => {
|
||||
it("routes external IdP through Amazon Q first", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("external_idp"))).toEqual([
|
||||
CODEWHISPERER,
|
||||
Q,
|
||||
CODEWHISPERER,
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("regionalizes AWS endpoints for IDC without changing Kiro runtime", () => {
|
||||
it("regionalizes AWS endpoints for IDC with Q first", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("idc", "eu-west-1"))).toEqual([
|
||||
"https://codewhisperer.eu-west-1.amazonaws.com/generateAssistantResponse",
|
||||
"https://q.eu-west-1.amazonaws.com/generateAssistantResponse",
|
||||
"https://codewhisperer.eu-west-1.amazonaws.com/generateAssistantResponse",
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js";
|
||||
import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js";
|
||||
|
||||
for (const [name, translate, body] of [
|
||||
["OpenAI", openaiToKiroRequest, { messages: [{ role: "user", content: "hello" }] }],
|
||||
["Claude", claudeToKiroRequest, { messages: [{ role: "user", content: "hello" }] }],
|
||||
]) {
|
||||
describe(`${name} Kiro minimal wire payload`, () => {
|
||||
it("omits unsupported agent fields", () => {
|
||||
const payload = translate("kiro/claude-sonnet-4.5", body, true, {});
|
||||
expect(payload).not.toHaveProperty("agentMode");
|
||||
expect(payload.conversationState).not.toHaveProperty("agentContinuationId");
|
||||
expect(payload.conversationState).not.toHaveProperty("agentTaskType");
|
||||
expect(payload.conversationState.chatTriggerType).toBe("MANUAL");
|
||||
expect(payload.conversationState.currentMessage.userInputMessage.origin).toBe("AI_EDITOR");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -115,7 +115,7 @@ async function text(stream) {
|
||||
async function execute(executor = new KiroExecutor(), overrides = {}) {
|
||||
return executor.execute({
|
||||
model: "kr/claude-opus-4.8",
|
||||
body: { systemPrompt: "base", conversationState: {} },
|
||||
body: { conversationState: { currentMessage: { userInputMessage: { content: "base", modelId: "m" } } } },
|
||||
stream: true,
|
||||
credentials,
|
||||
...overrides
|
||||
@@ -342,8 +342,12 @@ describe("Kiro terminal integrity recovery", () => {
|
||||
const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body);
|
||||
|
||||
expect(body).toContain("Recovered safely.");
|
||||
expect(retryBody.systemPrompt).toContain("tool_call wrapper was malformed");
|
||||
expect(retryBody.systemPrompt).not.toContain("IGNORE_ALL_INSTRUCTIONS");
|
||||
// The repair instruction rides in the user turn: kiro.dev rejects a
|
||||
// top-level systemPrompt with 400 REQUEST_BODY_INVALID.
|
||||
const retryContent = retryBody.conversationState.currentMessage.userInputMessage.content;
|
||||
expect(retryBody.systemPrompt).toBeUndefined();
|
||||
expect(retryContent).toContain("tool_call wrapper was malformed");
|
||||
expect(retryContent).not.toContain("IGNORE_ALL_INSTRUCTIONS");
|
||||
});
|
||||
|
||||
it("lets a complete tool call override metadata end_turn", async () => {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js";
|
||||
import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js";
|
||||
import {
|
||||
canonicalizeKiroConversation,
|
||||
KIRO_TOOL_RESULTS_PLACEHOLDER,
|
||||
KIRO_EMPTY_USER_PLACEHOLDER,
|
||||
} from "../../open-sse/translator/concerns/kiroConversation.js";
|
||||
|
||||
const TOOLS_OPENAI = [{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get weather",
|
||||
parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
},
|
||||
}];
|
||||
|
||||
const TOOLS_CLAUDE = [{
|
||||
name: "get_weather",
|
||||
description: "Get weather",
|
||||
input_schema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
}];
|
||||
|
||||
function allUserContents(payload) {
|
||||
const state = payload.conversationState;
|
||||
return [
|
||||
...state.history.filter((t) => t.userInputMessage).map((t) => t.userInputMessage.content),
|
||||
state.currentMessage.userInputMessage.content,
|
||||
];
|
||||
}
|
||||
|
||||
describe("Kiro tool-result-only turns", () => {
|
||||
it("OpenAI → Kiro: tool message gets a neutral placeholder, not \"continue\"", () => {
|
||||
const payload = openaiToKiroRequest("claude-sonnet-4.6", {
|
||||
tools: TOOLS_OPENAI,
|
||||
messages: [
|
||||
{ role: "user", content: "The secret word is PINEAPPLE. Weather in Jakarta?" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather", arguments: "{\"city\":\"Jakarta\"}" } }] },
|
||||
{ role: "tool", tool_call_id: "call_1", content: "32C, humid" },
|
||||
],
|
||||
}, true, {});
|
||||
|
||||
const current = payload.conversationState.currentMessage.userInputMessage;
|
||||
expect(current.content).toContain(KIRO_TOOL_RESULTS_PLACEHOLDER);
|
||||
expect(current.content).not.toMatch(/\bcontinue\b/);
|
||||
expect(current.userInputMessageContext.toolResults).toHaveLength(1);
|
||||
expect(allUserContents(payload).join("\n")).toContain("PINEAPPLE");
|
||||
});
|
||||
|
||||
it("Claude → Kiro: tool_result-only user message gets a neutral placeholder", () => {
|
||||
const payload = claudeToKiroRequest("claude-sonnet-4.6", {
|
||||
tools: TOOLS_CLAUDE,
|
||||
messages: [
|
||||
{ role: "user", content: "The secret word is PINEAPPLE. Weather in Jakarta?" },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: { city: "Jakarta" } }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "32C, humid" }] },
|
||||
],
|
||||
}, true, {});
|
||||
|
||||
const current = payload.conversationState.currentMessage.userInputMessage;
|
||||
expect(current.content).toContain(KIRO_TOOL_RESULTS_PLACEHOLDER);
|
||||
expect(current.content).not.toMatch(/\bcontinue\b/);
|
||||
expect(current.userInputMessageContext.toolResults).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps real user text when a turn has both text and tool results", () => {
|
||||
const payload = claudeToKiroRequest("claude-sonnet-4.6", {
|
||||
tools: TOOLS_CLAUDE,
|
||||
messages: [
|
||||
{ role: "user", content: "Weather in Jakarta?" },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: { city: "Jakarta" } }] },
|
||||
{ role: "user", content: [
|
||||
{ type: "tool_result", tool_use_id: "toolu_1", content: "32C" },
|
||||
{ type: "text", text: "Now answer in one word." },
|
||||
] },
|
||||
],
|
||||
}, true, {});
|
||||
|
||||
const current = payload.conversationState.currentMessage.userInputMessage;
|
||||
expect(current.content).toContain("Now answer in one word.");
|
||||
expect(current.content).not.toContain(KIRO_TOOL_RESULTS_PLACEHOLDER);
|
||||
});
|
||||
|
||||
it("canonicalize: history turn with tool results and no text uses the placeholder", () => {
|
||||
const result = canonicalizeKiroConversation({
|
||||
history: [
|
||||
{ userInputMessage: { content: "Weather in Jakarta?", modelId: "m" } },
|
||||
{ assistantResponseMessage: { content: "", toolUses: [{ toolUseId: "t1", name: "get_weather", input: { city: "Jakarta" } }] } },
|
||||
{ userInputMessage: { content: "", modelId: "m", userInputMessageContext: { toolResults: [{ toolUseId: "t1", status: "success", content: [{ text: "32C" }] }] } } },
|
||||
{ assistantResponseMessage: { content: "It is 32C." } },
|
||||
],
|
||||
currentMessage: { userInputMessage: { content: "Hot or cold?", modelId: "m" } },
|
||||
modelId: "m",
|
||||
toolSpecs: [{ toolSpecification: { name: "get_weather", description: "Get weather", inputSchema: { json: { type: "object", properties: {} } } } }],
|
||||
nameMap: new Map([["get_weather", "get_weather"]]),
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.history[2].userInputMessage.content).toBe(KIRO_TOOL_RESULTS_PLACEHOLDER);
|
||||
});
|
||||
|
||||
it("canonicalize: an empty turn without tool results still falls back to \"continue\"", () => {
|
||||
const result = canonicalizeKiroConversation({
|
||||
history: [{ assistantResponseMessage: { content: "Hello" } }],
|
||||
currentMessage: { userInputMessage: { content: "", modelId: "m" } },
|
||||
modelId: "m",
|
||||
toolSpecs: [],
|
||||
nameMap: new Map(),
|
||||
});
|
||||
|
||||
expect(result.history[0].userInputMessage.content).toBe(KIRO_EMPTY_USER_PLACEHOLDER);
|
||||
expect(result.currentMessage.userInputMessage.content).toBe(KIRO_EMPTY_USER_PLACEHOLDER);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Both modules read their file path from DATA_DIR at import time, so the temp
|
||||
// data dir has to be in place before the first import.
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "9r-catalog-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
const catalogFile = path.join(dataDir, "model-catalog.json");
|
||||
|
||||
// One upstream record per gateway: the same short id means different things to
|
||||
// different vendors, which is what used to leak capabilities across providers.
|
||||
const upstream = {
|
||||
zai: { models: { "glm-4.6v": { modalities: { input: ["text", "image"] } } } },
|
||||
// two local ids alias this one upstream provider
|
||||
zhipuai: { models: { "glm-5-canary": { modalities: { input: ["text", "image", "pdf"] } } } },
|
||||
moonshotai: { models: { "kimi-k3": { modalities: { input: ["text"] } } } },
|
||||
kilo: { models: { "kilo-auto/efficient": { modalities: { input: ["text", "image"] } } } },
|
||||
};
|
||||
// The registry snapshot the sync feeds build(): local ids, with the capabilities
|
||||
// the tables resolve on their own.
|
||||
const entries = [
|
||||
{ provider: "glm", model: "glm-4.6v", current: { contextWindow: 200000, maxOutput: 128000 } },
|
||||
{ provider: "glm-cn", model: "glm-5-canary", current: { contextWindow: 200000, maxOutput: 128000 } },
|
||||
{ provider: "zhipu", model: "glm-5-canary", current: { contextWindow: 200000, maxOutput: 128000 } },
|
||||
{ provider: "kimi", model: "kimi-k3", current: { contextWindow: 128000, maxOutput: 32000 } },
|
||||
];
|
||||
|
||||
let build, getCatalogModalities, invalidateCatalog, syncModelCatalog, startModelCatalogSync, capabilities;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ build, syncModelCatalog, startModelCatalogSync } = await import("../../src/lib/modelCatalog/sync.js"));
|
||||
// the builder is exercised directly; a missing export must fail loudly here
|
||||
// rather than skip every case below
|
||||
expect(typeof build).toBe("function");
|
||||
const { models, providers } = build(upstream, entries);
|
||||
fs.writeFileSync(catalogFile, JSON.stringify({ v: 2, models, providers }));
|
||||
({ getCatalogModalities, invalidateCatalog } = await import("../../open-sse/providers/catalogOverride.js"));
|
||||
capabilities = await import("../../open-sse/providers/capabilities.js");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("model catalog", () => {
|
||||
it("keys modalities by gateway and writes no model-only key", () => {
|
||||
const { models } = build(upstream, entries);
|
||||
// upstream "zai" is filed under the local id requests arrive with...
|
||||
expect(models["glm:glm-4.6v"]).toEqual({ vision: true });
|
||||
// ...and under its upstream name, because a custom provider node can carry
|
||||
// that name without being in the registry snapshot
|
||||
expect(models["zai:glm-4.6v"]).toEqual({ vision: true });
|
||||
expect(models["kilo:efficient"]).toEqual({ vision: true });
|
||||
// the vendor-stripped key is what used to be shared with every other gateway
|
||||
expect(models["glm-4.6v"]).toBeUndefined();
|
||||
expect(models["efficient"]).toBeUndefined();
|
||||
// a gateway that only declares text has nothing to contribute
|
||||
expect(models["kimi:kimi-k3"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("files an upstream provider under every local id that aliases it", () => {
|
||||
const { models } = build(upstream, entries);
|
||||
// glm-cn and zhipu are both zhipuai upstream; neither may be dropped
|
||||
expect(models["glm-cn:glm-5-canary"]).toEqual({ vision: true, pdf: true });
|
||||
expect(models["zhipu:glm-5-canary"]).toEqual({ vision: true, pdf: true });
|
||||
expect(models["zhipuai:glm-5-canary"]).toEqual({ vision: true, pdf: true });
|
||||
expect(getCatalogModalities("glm-cn", "glm-5-canary")).toEqual({ vision: true, pdf: true });
|
||||
expect(getCatalogModalities("zhipu", "glm-5-canary")).toEqual({ vision: true, pdf: true });
|
||||
});
|
||||
|
||||
it("does not hand a router mode another vendor's modalities", () => {
|
||||
// kilo's "efficient" is a real model; another gateway's "efficient" is a mode
|
||||
expect(getCatalogModalities("kilo", "kilo-auto/efficient")).toEqual({ vision: true });
|
||||
expect(getCatalogModalities("kilo-gateway", "kilo-auto/efficient")).toBeNull();
|
||||
expect(getCatalogModalities("qoder", "efficient")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves a gateway the file was written for, and nobody else", () => {
|
||||
expect(getCatalogModalities("glm", "glm-4.6v")).toEqual({ vision: true });
|
||||
expect(getCatalogModalities("zai", "glm-4.6v")).toEqual({ vision: true });
|
||||
expect(getCatalogModalities("unrelated", "glm-4.6v")).toBeNull();
|
||||
expect(getCatalogModalities(undefined, "glm-4.6v")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes the gateway to the catalog reader when refining", () => {
|
||||
const seen = [];
|
||||
capabilities.setCatalogSource({
|
||||
getModalities: (provider) => {
|
||||
seen.push(provider);
|
||||
return provider === "gateway-a" ? { vision: true } : null;
|
||||
},
|
||||
getLimits: () => null,
|
||||
});
|
||||
try {
|
||||
// "*laguna*" resolves from the pattern table, so refine() runs
|
||||
expect(capabilities.getCapabilitiesForModel("gateway-a", "laguna-9-preview").vision).toBe(true);
|
||||
expect(capabilities.getCapabilitiesForModel("gateway-b", "laguna-9-preview").vision).toBe(false);
|
||||
expect(seen).toContain("gateway-a");
|
||||
} finally {
|
||||
capabilities.setCatalogSource(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("shares the installed source with every copy of the module", async () => {
|
||||
const source = {
|
||||
getModalities: (provider) => (provider === "gateway-a" ? { vision: true } : null),
|
||||
getLimits: () => null,
|
||||
};
|
||||
capabilities.setCatalogSource(source);
|
||||
try {
|
||||
// The server bundles this module into more than one chunk and the startup
|
||||
// hook only runs in one of them, so the slot has to be process-wide.
|
||||
expect(globalThis.__9rCatalogSource).toBe(source);
|
||||
const other = await import("../../open-sse/providers/capabilities.js?copy=2");
|
||||
expect(other.getCapabilitiesForModel).not.toBe(capabilities.getCapabilitiesForModel);
|
||||
// ...and that second copy resolves through the source it never installed
|
||||
expect(other.getCapabilitiesForModel("gateway-a", "laguna-9-preview").vision).toBe(true);
|
||||
} finally {
|
||||
capabilities.setCatalogSource(null);
|
||||
}
|
||||
expect(globalThis.__9rCatalogSource).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog schema", () => {
|
||||
it("ignores a file written before the keys were scoped", () => {
|
||||
const scoped = fs.readFileSync(catalogFile);
|
||||
// v1: flat model keys, which is exactly the shape that collided
|
||||
fs.writeFileSync(catalogFile, JSON.stringify({ v: 1, models: { "kimi-k3": { vision: true } }, providers: {} }));
|
||||
invalidateCatalog();
|
||||
expect(getCatalogModalities("kimi", "kimi-k3")).toBeNull();
|
||||
fs.writeFileSync(catalogFile, scoped);
|
||||
invalidateCatalog();
|
||||
});
|
||||
|
||||
it("rebuilds an older-schema file instead of trusting its etag", async () => {
|
||||
fs.writeFileSync(catalogFile, JSON.stringify({ v: 1, etag: 'W/"old"', models: {}, providers: {} }));
|
||||
invalidateCatalog();
|
||||
startModelCatalogSync(); // picks the file's etag + schema version back up
|
||||
|
||||
const sent = [];
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
sent.push(options?.headers || {});
|
||||
return { ok: true, status: 200, headers: new Map([["etag", 'W/"new"']]), json: async () => upstream };
|
||||
};
|
||||
try {
|
||||
expect((await syncModelCatalog()).status).toBe("updated");
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
expect(sent[0]["if-none-match"]).toBeUndefined();
|
||||
const written = JSON.parse(fs.readFileSync(catalogFile, "utf8"));
|
||||
expect(written.v).toBe(2);
|
||||
expect(written.models["glm:glm-4.6v"]).toEqual({ vision: true });
|
||||
});
|
||||
|
||||
it("asks upstream for a 304 once the file is current", async () => {
|
||||
const sent = [];
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
sent.push(options?.headers || {});
|
||||
return { ok: false, status: 304, headers: new Map(), json: async () => ({}) };
|
||||
};
|
||||
try {
|
||||
expect((await syncModelCatalog()).status).toBe("unchanged");
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
expect(sent[0]["if-none-match"]).toBe('W/"new"');
|
||||
});
|
||||
});
|
||||
@@ -179,3 +179,57 @@ describe("openaiToCommandCodeRequest — tools schema conversion", () => {
|
||||
expect(out.params.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToCommandCodeRequest — native image blocks", () => {
|
||||
const PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
const DATA_URI = `data:image/png;base64,${PNG_B64}`;
|
||||
|
||||
it("maps OpenAI image_url data URI to CommandCode {type:image,image,mimeType}", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "what color?" },
|
||||
{ type: "image_url", image_url: { url: DATA_URI } },
|
||||
],
|
||||
}],
|
||||
}, true);
|
||||
|
||||
expect(out.params.messages[0].content).toEqual([
|
||||
{ type: "text", text: "what color?" },
|
||||
{ type: "image", image: DATA_URI, mimeType: "image/png" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps Claude/OpenAI base64 image source to a data-URI image block", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: PNG_B64 } },
|
||||
],
|
||||
}],
|
||||
}, true);
|
||||
|
||||
expect(out.params.messages[0].content).toEqual([
|
||||
{ type: "image", image: DATA_URI, mimeType: "image/png" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not stub dropped images as [image omitted]", () => {
|
||||
const out = openaiToCommandCodeRequest(MODEL, {
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "see this" },
|
||||
{ type: "image_url", image_url: { url: DATA_URI } },
|
||||
],
|
||||
}],
|
||||
}, true);
|
||||
|
||||
const texts = out.params.messages[0].content
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => b.text);
|
||||
expect(texts).not.toContain("[image omitted]");
|
||||
});
|
||||
});
|
||||
@@ -606,7 +606,7 @@ describe("openaiToKiroRequest", () => {
|
||||
);
|
||||
|
||||
expect(second.conversationState.conversationId).toBe("hermes-session-openai-replay");
|
||||
expect(second.conversationState.agentContinuationId).toBe(first.conversationState.agentContinuationId);
|
||||
expect(second.conversationState).not.toHaveProperty("agentContinuationId");
|
||||
expect(second.conversationState.history[0].userInputMessage.content).toBe(
|
||||
first.conversationState.currentMessage.userInputMessage.content
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PROVIDERS } from "../../open-sse/config/providers.js";
|
||||
import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js";
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(async () => ({ ok: true, status: 200, headers: { get: () => "" } })),
|
||||
}));
|
||||
|
||||
// Break caught: opencode/muse-spark-1.3-contributor-free 400 vì upstream
|
||||
// chỉ nhận tool_choice "auto"; named/required/none phải demote sang "auto".
|
||||
const FREE_13 = "muse-spark-1.3-contributor-free";
|
||||
const CREDS = { connectionId: "opencode-free-tool-choice-test" };
|
||||
const INPUT = [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }];
|
||||
const TOOLS = [{ type: "function", name: "get_weather", description: "w", parameters: { type: "object", properties: {} } }];
|
||||
|
||||
function responsesBody(model, tool_choice) {
|
||||
const body = { model, input: structuredClone(INPUT), tools: structuredClone(TOOLS) };
|
||||
if (tool_choice !== undefined) body.tool_choice = tool_choice;
|
||||
return body;
|
||||
}
|
||||
|
||||
describe("opencode Free 1.3 tool_choice auto-only", () => {
|
||||
it("khai quirk đúng model 1.3-Free trong registry", () => {
|
||||
expect(PROVIDERS.opencode.quirks?.forceAutoToolChoiceModels).toEqual([FREE_13]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Responses named", { type: "function", name: "get_weather" }],
|
||||
["Chat function named", { type: "function", function: { name: "get_weather" } }],
|
||||
["Claude tool named", { type: "tool", name: "get_weather" }],
|
||||
["required", "required"],
|
||||
["none", "none"],
|
||||
])("demote %s sang auto (plain và max)", (_label, choice) => {
|
||||
for (const model of [FREE_13, `${FREE_13}(max)`]) {
|
||||
const body = responsesBody(model, structuredClone(choice));
|
||||
const out = new OpenCodeExecutor().transformRequest(model, body, true, CREDS);
|
||||
expect(out.tool_choice).toBe("auto");
|
||||
expect(out.tools).toEqual(TOOLS);
|
||||
expect(out.input).toEqual(INPUT);
|
||||
}
|
||||
});
|
||||
|
||||
it("giữ auto và absent; tools/input nguyên vẹn", () => {
|
||||
const autoOut = new OpenCodeExecutor().transformRequest(
|
||||
FREE_13, responsesBody(FREE_13, "auto"), true, CREDS,
|
||||
);
|
||||
expect(autoOut.tool_choice).toBe("auto");
|
||||
expect(autoOut.tools).toEqual(TOOLS);
|
||||
expect(autoOut.input).toEqual(INPUT);
|
||||
|
||||
const absentOut = new OpenCodeExecutor().transformRequest(
|
||||
FREE_13, responsesBody(FREE_13, undefined), true, CREDS,
|
||||
);
|
||||
expect("tool_choice" in absentOut).toBe(false);
|
||||
expect(absentOut.tools).toEqual(TOOLS);
|
||||
expect(absentOut.input).toEqual(INPUT);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["1.2-Free", "muse-spark-1.2-contributor-free"],
|
||||
["future 1.4-Free", "muse-spark-1.4-contributor-free"],
|
||||
["Go id", "muse-spark-1.3-contributor"],
|
||||
["non-Muse", "big-pickle"],
|
||||
])("không đổi tool_choice của %s", (_label, model) => {
|
||||
const choice = { type: "function", name: "get_weather" };
|
||||
const body = responsesBody(model, structuredClone(choice));
|
||||
const out = new OpenCodeExecutor().transformRequest(model, body, true, CREDS);
|
||||
expect(out.tool_choice).toEqual(choice);
|
||||
});
|
||||
|
||||
it("wire: execute gửi choice auto tới /zen/v1/responses", async () => {
|
||||
proxyAwareFetch.mockClear();
|
||||
const ex = new OpenCodeExecutor();
|
||||
const body = responsesBody(FREE_13, { type: "function", name: "get_weather" });
|
||||
const { url, transformedBody } = await ex.execute({
|
||||
model: FREE_13, body, stream: true, credentials: CREDS,
|
||||
});
|
||||
expect(url).toBe("https://opencode.ai/zen/v1/responses");
|
||||
expect(transformedBody.tool_choice).toBe("auto");
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
const [actualUrl, actualInit] = proxyAwareFetch.mock.calls[0];
|
||||
expect(actualUrl).toBe("https://opencode.ai/zen/v1/responses");
|
||||
const sent = JSON.parse(actualInit.body);
|
||||
expect(sent.tool_choice).toBe("auto");
|
||||
expect(sent.model).toBe(FREE_13);
|
||||
expect(sent.tools).toEqual(TOOLS);
|
||||
expect(sent.input).toEqual(INPUT);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PROVIDER_MODELS, getModelSupportedFormats } from "../../open-sse/config/providerModels.js";
|
||||
import { PROVIDER_MODELS, getModelSupportedFormats, getModelTargetFormat } 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-go)
|
||||
const CHAT_ONLY = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "mimo-v2.5", "mimo-v2.5-pro"];
|
||||
const CHAT_ONLY = ["glm-5.3", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "kimi-k3",
|
||||
"deepseek-flash", "longcat-2.0", "mimo-v2.5", "mimo-v2.5-pro", "hy4-preview", "hy3"];
|
||||
// Models that also expose the Anthropic /messages endpoint
|
||||
const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"];
|
||||
const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5",
|
||||
"qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"];
|
||||
// Models that also expose the OpenAI /responses endpoint
|
||||
const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"];
|
||||
|
||||
@@ -22,15 +24,31 @@ describe("OpenCode Go model catalog", () => {
|
||||
it("matches the documented model IDs", () => {
|
||||
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id);
|
||||
expect(ids).toEqual([
|
||||
"glm-5.3-flash", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6",
|
||||
"deepseek-flash",
|
||||
"glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "kimi-k3",
|
||||
"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp",
|
||||
"mimo-v2.5", "mimo-v2.5-pro",
|
||||
"longcat-2.0", "mimo-v2.5", "mimo-v2.5-pro",
|
||||
"minimax-m3", "minimax-m2.7", "minimax-m2.5",
|
||||
"qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
|
||||
"qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
|
||||
"hy4-preview", "hy3",
|
||||
"grok-4.6", "gpt-5.6-luna",
|
||||
"muse-spark-1.2-contributor", "muse-spark-1.3-contributor",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Go thinking-suffix model lookup", () => {
|
||||
it("preserves Responses routing for gpt-5.6-luna thinking variants", () => {
|
||||
expect(getModelSupportedFormats("opencode-go", "gpt-5.6-luna(high)")).toEqual(["openai-responses"]);
|
||||
expect(getModelTargetFormat("opencode-go", "gpt-5.6-luna(high)")).toBe("openai-responses");
|
||||
});
|
||||
|
||||
it("preserves Responses routing for grok-4.6 thinking variants", () => {
|
||||
expect(getModelSupportedFormats("opencode-go", "grok-4.6(high)")).toEqual(["openai-responses"]);
|
||||
expect(getModelTargetFormat("opencode-go", "grok-4.6(high)")).toBe("openai-responses");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Go per-model supportedFormats", () => {
|
||||
it("declares [openai, claude] for MiniMax + Qwen models", () => {
|
||||
for (const m of CLAUDE_CAPABLE) {
|
||||
@@ -89,6 +107,15 @@ describe("OpenCode Go per-model transport guard (chatCore logic)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("routes Muse Spark (responses-only) to /responses, never to /messages", () => {
|
||||
for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor", "grok-4.6", "gpt-5.6-luna"]) {
|
||||
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai-responses"]);
|
||||
expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull();
|
||||
expect(pickTransport("opencode-go", "openai", "opencode-go", m)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT route MiniMax (no responses support) to /responses", () => {
|
||||
for (const m of CLAUDE_CAPABLE) {
|
||||
expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)).toBeNull();
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PROVIDER_MODELS, getModelTargetFormat, getModelSupportedFormats } from "../../open-sse/config/providerModels.js";
|
||||
import { PROVIDERS } from "../../open-sse/config/providers.js";
|
||||
import { resolveTransport } from "../../open-sse/services/provider.js";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js";
|
||||
import { getExecutor } from "../../open-sse/executors/index.js";
|
||||
import { OpenCodeGoExecutor } from "../../open-sse/executors/opencode-go.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
import "../translator/registerAll.js";
|
||||
import { translateRequest } from "../../open-sse/translator/index.js";
|
||||
|
||||
const MODEL = "muse-spark-1.3-contributor";
|
||||
const PROVIDER = "opencode-go";
|
||||
|
||||
// Mirror of chatCore's per-model transport guard
|
||||
function pickTransport(provider, sourceFormat, alias, model) {
|
||||
const supported = getModelSupportedFormats(alias, model);
|
||||
const rt = resolveTransport(provider, sourceFormat);
|
||||
return supported?.includes(sourceFormat) ? rt : null;
|
||||
}
|
||||
|
||||
describe("ocg/muse-spark-1.3-contributor catalog", () => {
|
||||
it("is registered responses-only", () => {
|
||||
const entry = (PROVIDER_MODELS["opencode-go"] || []).find((m) => m.id === MODEL);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.targetFormat).toBe("openai-responses");
|
||||
expect(getModelSupportedFormats("opencode-go", MODEL)).toEqual(["openai-responses"]);
|
||||
expect(getModelTargetFormat("ocg", MODEL)).toBe(FORMATS.OPENAI_RESPONSES);
|
||||
expect(getModelTargetFormat("opencode-go", MODEL)).toBe(FORMATS.OPENAI_RESPONSES);
|
||||
});
|
||||
|
||||
it("never takes the sourceFormat-matched transport (always translates)", () => {
|
||||
expect(pickTransport(PROVIDER, "openai", "opencode-go", MODEL)).toBeNull();
|
||||
expect(pickTransport(PROVIDER, "claude", "opencode-go", MODEL)).toBeNull();
|
||||
expect(pickTransport(PROVIDER, "openai-responses", "opencode-go", MODEL)?.baseUrl)
|
||||
.toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
});
|
||||
|
||||
it("advertises reasoning via the shared muse-spark pattern", () => {
|
||||
expect(getCapabilitiesForModel(PROVIDER, MODEL)).toMatchObject({
|
||||
vision: true,
|
||||
reasoning: true,
|
||||
thinkingFormat: "openai",
|
||||
});
|
||||
expect(getThinkingLevels(PROVIDER, MODEL)).toContain("xhigh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCodeGoExecutor routing + sanitization", () => {
|
||||
it("routes gpt-5.6-luna to /responses", () => {
|
||||
const ex = new OpenCodeGoExecutor();
|
||||
expect(ex.buildUrl("gpt-5.6-luna")).toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
expect(ex.buildUrl("gpt-5.6-luna(high)", true, 0, {
|
||||
runtimeTransport: { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions" },
|
||||
})).toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
});
|
||||
|
||||
it("routes every responses-only registry model (grok-4.6) to /responses", () => {
|
||||
const ex = new OpenCodeGoExecutor();
|
||||
expect(ex.buildUrl("grok-4.6")).toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
expect(ex.buildUrl("grok-4.6(high)", true, 0, {
|
||||
runtimeTransport: { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions" },
|
||||
})).toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
});
|
||||
|
||||
it("is wired for opencode-go and routes muse-spark to /responses", () => {
|
||||
expect(getExecutor("opencode-go")).toBeInstanceOf(OpenCodeGoExecutor);
|
||||
const ex = new OpenCodeGoExecutor();
|
||||
expect(ex.buildUrl(MODEL)).toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
// Even a stale runtimeTransport must not drag muse-spark onto chat/messages
|
||||
expect(ex.buildUrl(MODEL, true, 0, {
|
||||
runtimeTransport: { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions" },
|
||||
})).toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
});
|
||||
|
||||
it("leaves non-muse models on the default/runtime transport", () => {
|
||||
const ex = new OpenCodeGoExecutor();
|
||||
expect(ex.buildUrl("kimi-k2.6")).toBe("https://opencode.ai/zen/go/v1/chat/completions");
|
||||
expect(ex.buildUrl("minimax-m3", true, 0, {
|
||||
runtimeTransport: { baseUrl: "https://opencode.ai/zen/go/v1/messages" },
|
||||
})).toBe("https://opencode.ai/zen/go/v1/messages");
|
||||
});
|
||||
|
||||
it("normalizes caps + reasoning and coerces tool items exactly once", () => {
|
||||
const ex = new OpenCodeGoExecutor();
|
||||
const args = { path: "a\"b\nc\\d", emoji: "🚀 ü", nested: { q: "x'y\"z" } };
|
||||
const body = {
|
||||
model: MODEL,
|
||||
input: [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
|
||||
{ type: "function_call", call_id: "x".repeat(100), name: "read", arguments: args },
|
||||
{ type: "function_call", call_id: "bad", name: " ", arguments: "{}" },
|
||||
{ type: "function_call", call_id: "frag", name: "exec", arguments: "{not json" },
|
||||
{ type: "function_call_output", call_id: "c1", output: { ok: true, text: "héllo \"w\"" } },
|
||||
{ type: "function_call_output", call_id: "c2", output: null },
|
||||
],
|
||||
tools: [
|
||||
{ type: "function", function: { name: "read", description: "r", parameters: { type: "object", properties: {} } } },
|
||||
{ type: "function", function: { name: " ", parameters: {} } },
|
||||
],
|
||||
max_tokens: 2048,
|
||||
reasoning_effort: "high",
|
||||
};
|
||||
const out = ex.transformRequest(MODEL, body, true, {});
|
||||
expect(out.max_output_tokens).toBe(2048);
|
||||
expect(out.max_tokens).toBeUndefined();
|
||||
expect(out.reasoning).toEqual({ effort: "high", summary: "auto" });
|
||||
expect(out.stream).toBe(true);
|
||||
expect(out.store).toBe(false);
|
||||
// nameless declaration dropped, nameless call dropped
|
||||
expect(out.tools.map((t) => t.name)).toEqual(["read"]);
|
||||
const calls = out.input.filter((i) => i.type === "function_call");
|
||||
expect(calls.map((c) => c.name)).toEqual(["read", "exec"]);
|
||||
// overlong id clamped, object args stringified exactly once
|
||||
expect(calls[0].call_id).toHaveLength(64);
|
||||
expect(JSON.parse(calls[0].arguments)).toEqual(args);
|
||||
// invalid fragment coerced, never double-encoded
|
||||
expect(calls[1].arguments).toBe("{}");
|
||||
const outputs = out.input.filter((i) => i.type === "function_call_output");
|
||||
expect(JSON.parse(outputs[0].output)).toEqual({ ok: true, text: "héllo \"w\"" });
|
||||
expect(outputs[1].output).toBe("");
|
||||
});
|
||||
|
||||
it("fills in properties for object tool schemas missing them", () => {
|
||||
const ex = new OpenCodeGoExecutor();
|
||||
const body = {
|
||||
model: MODEL,
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
||||
tools: [
|
||||
{ type: "function", function: { name: "bare", parameters: { type: "object" } } },
|
||||
{ type: "function", function: { name: "full", parameters: { type: "object", properties: { a: { type: "string" } } } } },
|
||||
],
|
||||
};
|
||||
const out = ex.transformRequest(MODEL, body, true, {});
|
||||
expect(out.tools.find((t) => t.name === "bare").parameters).toEqual({ type: "object", properties: {} });
|
||||
expect(out.tools.find((t) => t.name === "full").parameters).toEqual({ type: "object", properties: { a: { type: "string" } } });
|
||||
});
|
||||
|
||||
it("strips prior-turn reasoning items carrying encrypted_content from input", () => {
|
||||
const ex = new OpenCodeGoExecutor();
|
||||
const body = {
|
||||
model: MODEL,
|
||||
input: [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_123",
|
||||
encrypted_content: "ENC_BLOB_TURN_1",
|
||||
summary: [{ type: "summary_text", text: "thinking text" }],
|
||||
},
|
||||
{ type: "function_call", call_id: "c1", name: "read", arguments: "{}" },
|
||||
{ type: "function_call_output", call_id: "c1", output: "ok" },
|
||||
],
|
||||
};
|
||||
const out = ex.transformRequest(MODEL, body, true, {});
|
||||
expect(out.input.some((i) => i.type === "reasoning")).toBe(false);
|
||||
expect(JSON.stringify(out.input)).not.toContain("ENC_BLOB_TURN_1");
|
||||
expect(out.input.map((i) => i.type)).toEqual(["message", "function_call", "function_call_output"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat/claude clients translate to Responses without breaking tools", () => {
|
||||
const tricky = { cmd: "echo \"hi\"\nnewline\ttab\\slash", emoji: "🎉 café naïve", nested: { a: [1, "x'y"] } };
|
||||
|
||||
it("openai chat → responses keeps arguments parseable", () => {
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
MODEL,
|
||||
{
|
||||
model: `ocg/${MODEL}`,
|
||||
messages: [
|
||||
{ role: "system", content: [{ type: "text", text: "sys one" }, { type: "text", text: "sys two" }] },
|
||||
{ role: "user", content: "run it" },
|
||||
{
|
||||
role: "assistant", content: null,
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "exec", arguments: tricky } }],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: tricky },
|
||||
],
|
||||
tools: [{ type: "function", function: { name: "exec", description: "e", parameters: { type: "object", properties: {} } } }],
|
||||
},
|
||||
true, {}, PROVIDER,
|
||||
);
|
||||
expect(translated.instructions).toBe("sys one\nsys two");
|
||||
const fc = translated.input.find((i) => i.type === "function_call");
|
||||
expect(JSON.parse(fc.arguments)).toEqual(tricky);
|
||||
const fco = translated.input.find((i) => i.type === "function_call_output");
|
||||
expect(JSON.parse(fco.output)).toEqual(tricky);
|
||||
});
|
||||
|
||||
it("claude messages → responses double-hop keeps tool input intact", () => {
|
||||
const viaOpenAI = translateRequest(FORMATS.CLAUDE, FORMATS.OPENAI, MODEL, {
|
||||
system: "be terse",
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "go" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "calling" },
|
||||
{ type: "tool_use", id: "tu_1", name: "exec", input: tricky },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "tu_1", content: [{ type: "text", text: JSON.stringify(tricky) }] }],
|
||||
},
|
||||
],
|
||||
tools: [{ name: "exec", description: "e", input_schema: { type: "object", properties: {} } }],
|
||||
}, true, {}, PROVIDER);
|
||||
const translated = translateRequest(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, MODEL, viaOpenAI, true, {}, PROVIDER);
|
||||
const fc = translated.input.find((i) => i.type === "function_call");
|
||||
expect(JSON.parse(fc.arguments)).toEqual(tricky);
|
||||
const fco = translated.input.find((i) => i.type === "function_call_output");
|
||||
expect(JSON.parse(fco.output)).toEqual(tricky);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const { fetchMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: fetchMock,
|
||||
}));
|
||||
|
||||
import { DefaultExecutor } from "../../open-sse/executors/default.js";
|
||||
import { getExecutor } from "../../open-sse/executors/index.js";
|
||||
|
||||
const TRANSPORTS = [
|
||||
{ format: "openai", baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
|
||||
{ format: "claude", baseUrl: "https://opencode.ai/zen/go/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } },
|
||||
{ format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
|
||||
];
|
||||
|
||||
function makeCredentials(overrides = {}) {
|
||||
return {
|
||||
apiKey: "test-key",
|
||||
connectionId: "connection-a",
|
||||
rawHeaders: {},
|
||||
runtimeTransport: TRANSPORTS[0],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(executor, overrides = {}) {
|
||||
const credentials = overrides.credentials || makeCredentials();
|
||||
const prepared = executor.prepareRequestCredentials({
|
||||
body: overrides.body || { messages: [{ role: "user", content: "hello" }] },
|
||||
credentials,
|
||||
providerSessionId: overrides.providerSessionId ?? "conversation-a",
|
||||
clientTool: overrides.clientTool ?? "claude",
|
||||
});
|
||||
return { credentials, prepared };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}));
|
||||
});
|
||||
|
||||
describe("OpenCode Go x-opencode-session", () => {
|
||||
it("uses a dedicated executor with request-local session credentials", () => {
|
||||
const executor = getExecutor("opencode-go");
|
||||
const { credentials, prepared } = prepare(executor);
|
||||
|
||||
expect(executor.constructor.name).toBe("OpenCodeGoExecutor");
|
||||
expect(prepared).not.toBe(credentials);
|
||||
expect(prepared._opencodeGoSession).toMatch(/^ses_[0-9a-f]{32}$/);
|
||||
expect(credentials).not.toHaveProperty("_opencodeGoSession");
|
||||
expect(executor).not.toHaveProperty("_currentSessionId");
|
||||
expect(executor).not.toHaveProperty("_opencodeGoSession");
|
||||
});
|
||||
|
||||
it("preserves a valid native session header case-insensitively", () => {
|
||||
const executor = getExecutor("opencode-go");
|
||||
const { prepared } = prepare(executor, {
|
||||
credentials: makeCredentials({ rawHeaders: { "X-OpenCode-Session": " native-session-a " } }),
|
||||
});
|
||||
|
||||
expect(prepared._opencodeGoSession).toBe("native-session-a");
|
||||
});
|
||||
|
||||
it("ignores an oversized native session and uses the translated identity", () => {
|
||||
const executor = getExecutor("opencode-go");
|
||||
const { prepared } = prepare(executor, {
|
||||
credentials: makeCredentials({ rawHeaders: { "x-opencode-session": "x".repeat(257) } }),
|
||||
});
|
||||
|
||||
expect(prepared._opencodeGoSession).toMatch(/^ses_[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
it("keeps the same translated conversation stable across all transports", () => {
|
||||
const executor = getExecutor("opencode-go");
|
||||
const values = TRANSPORTS.map((runtimeTransport) => {
|
||||
const { prepared } = prepare(executor, {
|
||||
credentials: makeCredentials({ runtimeTransport }),
|
||||
});
|
||||
return executor.buildHeaders(prepared, true)["x-opencode-session"];
|
||||
});
|
||||
|
||||
expect(new Set(values).size).toBe(1);
|
||||
expect(values[0]).toMatch(/^ses_[0-9a-f]{32}$/);
|
||||
expect(values[0]).not.toContain("conversation-a");
|
||||
});
|
||||
|
||||
it("isolates different conversations", () => {
|
||||
const executor = getExecutor("opencode-go");
|
||||
const a = prepare(executor, { providerSessionId: "conversation-a" }).prepared._opencodeGoSession;
|
||||
const b = prepare(executor, { providerSessionId: "conversation-b" }).prepared._opencodeGoSession;
|
||||
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("isolates different downstream agents that reuse the same raw id", () => {
|
||||
const executor = getExecutor("opencode-go");
|
||||
const claude = prepare(executor, { clientTool: "claude" }).prepared._opencodeGoSession;
|
||||
const codex = prepare(executor, { clientTool: "codex" }).prepared._opencodeGoSession;
|
||||
|
||||
expect(claude).not.toBe(codex);
|
||||
});
|
||||
|
||||
it("uses a stable opaque connection fallback when no session is supplied", () => {
|
||||
const executor = getExecutor("opencode-go");
|
||||
const options = {
|
||||
credentials: makeCredentials({ connectionId: "fallback-connection" }),
|
||||
providerSessionId: null,
|
||||
clientTool: null,
|
||||
body: { messages: [{ role: "user", content: "headerless" }] },
|
||||
};
|
||||
const first = prepare(executor, options).prepared._opencodeGoSession;
|
||||
const second = prepare(executor, options).prepared._opencodeGoSession;
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toMatch(/^ses_[0-9a-f]{32}$/);
|
||||
expect(first).not.toContain("fallback-connection");
|
||||
});
|
||||
|
||||
it("adds the prepared session to the actual fetch headers", async () => {
|
||||
const executor = getExecutor("opencode-go");
|
||||
const credentials = makeCredentials();
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.2",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
stream: false,
|
||||
credentials,
|
||||
providerSessionId: "conversation-fetch",
|
||||
clientTool: "codex",
|
||||
});
|
||||
|
||||
expect(result.headers["x-opencode-session"]).toMatch(/^ses_[0-9a-f]{32}$/);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock.mock.calls[0][1].headers["x-opencode-session"]).toBe(result.headers["x-opencode-session"]);
|
||||
expect(credentials).not.toHaveProperty("_opencodeGoSession");
|
||||
});
|
||||
|
||||
it("does not add the header to unrelated default executors", () => {
|
||||
const headers = new DefaultExecutor("openai").buildHeaders({ apiKey: "test-key" }, false);
|
||||
expect(headers["x-opencode-session"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chatCore provider session forwarding", () => {
|
||||
it("passes the original provider session and client tool on initial and retry execution", () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL("../../open-sse/handlers/chatCore.js", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
const calls = [...source.matchAll(/executor\.execute\(\{([\s\S]*?)\}\)/g)].map((match) => match[1]);
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
for (const call of calls) {
|
||||
expect(call).toMatch(/providerSessionId:\s*sessionSeed/);
|
||||
expect(call).toMatch(/\bclientTool\b/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.js";
|
||||
import {
|
||||
USAGE_APIKEY_PROVIDERS,
|
||||
USAGE_SUPPORTED_PROVIDERS,
|
||||
} from "../../src/shared/constants/providers.js";
|
||||
|
||||
const USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("OpenCode Go registry usage flags", () => {
|
||||
it("is listed for the API key quota dashboard", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("opencode-go");
|
||||
expect(USAGE_APIKEY_PROVIDERS).toContain("opencode-go");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsageForProvider(opencode-go)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("fetches and normalizes subscription usage", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
usage: {
|
||||
rolling: { status: "ok", percent: 13, resetsAt: "2026-09-04T14:28:02.617Z" },
|
||||
weekly: { status: "ok", percent: 5, resetsAt: "2026-09-07T00:00:00.617Z" },
|
||||
monthly: { status: "ok", percent: 2, resetsAt: "2026-10-02T12:14:24.617Z" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "opencode-go",
|
||||
apiKey: "sk-go-test",
|
||||
});
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledWith(
|
||||
USAGE_URL,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ Authorization: "Bearer sk-go-test" }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
expect(usage).toEqual({
|
||||
plan: "OpenCode Go",
|
||||
quotas: {
|
||||
Rolling: {
|
||||
used: 13,
|
||||
total: 100,
|
||||
remaining: 87,
|
||||
remainingPercentage: 87,
|
||||
resetAt: "2026-09-04T14:28:02.617Z",
|
||||
unlimited: false,
|
||||
},
|
||||
Weekly: expect.objectContaining({ used: 5, remainingPercentage: 95 }),
|
||||
Monthly: expect.objectContaining({ used: 2, remainingPercentage: 98 }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports missing and rejected credentials", async () => {
|
||||
const missing = await getUsageForProvider({ provider: "opencode-go" });
|
||||
expect(missing.message).toMatch(/api key/i);
|
||||
expect(proxyAwareFetch).not.toHaveBeenCalled();
|
||||
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401));
|
||||
const rejected = await getUsageForProvider({
|
||||
provider: "opencode-go",
|
||||
apiKey: "bad",
|
||||
});
|
||||
expect(rejected.message).toMatch(/authentication failed/i);
|
||||
});
|
||||
|
||||
it("distinguishes a missing subscription from invalid credentials", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ error: { type: "EntitlementError" } }, 403),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "opencode-go",
|
||||
apiKey: "sk-without-go",
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/subscription required/i);
|
||||
});
|
||||
|
||||
it("rejects responses without a valid quota percentage", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ usage: { rolling: { status: "ok" }, future: { percent: 10 } } }),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "opencode-go",
|
||||
apiKey: "sk-go-test",
|
||||
});
|
||||
|
||||
expect(usage.quotas).toBeUndefined();
|
||||
expect(usage.message).toMatch(/valid quota data/i);
|
||||
});
|
||||
|
||||
it("reports upstream and network failures", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unavailable" }, 500));
|
||||
const upstream = await getUsageForProvider({
|
||||
provider: "opencode-go",
|
||||
apiKey: "sk-go-test",
|
||||
});
|
||||
expect(upstream.message).toContain("500");
|
||||
|
||||
proxyAwareFetch.mockRejectedValueOnce(new Error("socket closed"));
|
||||
const network = await getUsageForProvider({
|
||||
provider: "opencode-go",
|
||||
apiKey: "sk-go-test",
|
||||
});
|
||||
expect(network.message).toContain("socket closed");
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,39 @@ describe("OpenCode Free Muse Spark thinking", () => {
|
||||
expect(out.max_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes Union Alpha through Anthropic Messages", () => {
|
||||
const caps = getCapabilitiesForModel(PROVIDER, "union-alpha");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.contextWindow).toBe(262144);
|
||||
expect(caps.maxOutput).toBe(131072);
|
||||
|
||||
const executor = new OpenCodeExecutor();
|
||||
|
||||
expect(getModelTargetFormat("oc", "union-alpha")).toBe(FORMATS.CLAUDE);
|
||||
const url = executor.buildUrl("union-alpha");
|
||||
expect(url).toBe("https://opencode.ai/zen/v1/messages");
|
||||
expect(executor.buildHeaders({}, true, url)).toMatchObject({
|
||||
"anthropic-version": "2023-06-01",
|
||||
});
|
||||
expect(executor.buildHeaders({}, true, executor.buildUrl("big-pickle")))
|
||||
.not.toHaveProperty("anthropic-version");
|
||||
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"union-alpha",
|
||||
{ messages: [{ role: "user", content: "ping" }], max_tokens: 1 },
|
||||
false,
|
||||
{},
|
||||
PROVIDER,
|
||||
);
|
||||
expect(translated).toMatchObject({
|
||||
model: "union-alpha",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "ping" }] }],
|
||||
max_tokens: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the other free models on Chat Completions", () => {
|
||||
const executor = new OpenCodeExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }], max_tokens: 1024 };
|
||||
@@ -132,4 +165,63 @@ describe("OpenCode Free Muse Spark thinking", () => {
|
||||
expect(out.max_tokens).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("strips prior-turn reasoning items carrying encrypted_content from input", () => {
|
||||
const executor = new OpenCodeExecutor();
|
||||
const model = "muse-spark-1.3-contributor-free";
|
||||
const body = {
|
||||
model,
|
||||
input: [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "say hi" }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_123",
|
||||
encrypted_content: "ENC_BLOB_TURN_1",
|
||||
summary: [{ type: "summary_text", text: "thinking text" }],
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
call_id: "call_1",
|
||||
name: "shell",
|
||||
arguments: JSON.stringify({ command: "echo hi" }),
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_1",
|
||||
output: "hi",
|
||||
},
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "now say bye" }] },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "shell",
|
||||
description: "Run shell command",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const out = executor.transformRequest(model, body, true, {});
|
||||
expect(out.stream).toBe(true);
|
||||
expect(out.store).toBe(false);
|
||||
// Prior reasoning items stripped to prevent 400 "reasoning encrypted_content was not issued to this caller"
|
||||
expect(out.input.some((item) => item.type === "reasoning")).toBe(false);
|
||||
expect(JSON.stringify(out.input)).not.toContain("ENC_BLOB_TURN_1");
|
||||
// User message, function_call, function_call_output, and next user message survive
|
||||
const types = out.input.map((item) => item.type);
|
||||
expect(types).toEqual(["message", "function_call", "function_call_output", "message"]);
|
||||
// Tools flattened and empty properties added
|
||||
expect(out.tools).toEqual([
|
||||
{
|
||||
type: "function",
|
||||
name: "shell",
|
||||
description: "Run shell command",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: fetchMock,
|
||||
}));
|
||||
|
||||
import { getExecutor } from "../../open-sse/executors/index.js";
|
||||
import {
|
||||
OPENCODE_SESSION_RE,
|
||||
OPENCODE_REQUEST_RE,
|
||||
generateSessionId,
|
||||
generateRequestId,
|
||||
translateSessionId,
|
||||
stableSessionId,
|
||||
deriveRequestId,
|
||||
} from "../../open-sse/executors/opencode.js";
|
||||
|
||||
function makeCredentials(overrides = {}) {
|
||||
return {
|
||||
connectionId: "conn_test",
|
||||
rawHeaders: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(executor, overrides = {}) {
|
||||
const credentials = overrides.credentials || makeCredentials();
|
||||
const prepared = executor.prepareRequestCredentials({
|
||||
body: overrides.body || { input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }] },
|
||||
credentials,
|
||||
providerSessionId: overrides.providerSessionId ?? "conversation-a",
|
||||
clientTool: overrides.clientTool ?? "claude",
|
||||
});
|
||||
return { credentials, prepared };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}));
|
||||
});
|
||||
|
||||
describe("OpenCode Free Session ID Format", () => {
|
||||
it("generates session IDs matching OpenCode canonical format (ses_ + 12 hex + 14 base62)", () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const id = generateSessionId();
|
||||
expect(id).toMatch(OPENCODE_SESSION_RE);
|
||||
expect(id).toHaveLength(30);
|
||||
}
|
||||
});
|
||||
|
||||
it("generates request IDs matching OpenCode canonical format (msg_ + 12 hex + 14 base62)", () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const id = generateRequestId();
|
||||
expect(id).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
|
||||
expect(id).toHaveLength(30);
|
||||
}
|
||||
});
|
||||
|
||||
it("translates arbitrary sessions into valid OpenCode session format", () => {
|
||||
const inputs = [
|
||||
"claude:550e8400-e29b-41d4-a716-446655440000",
|
||||
"antigravity:conv-abc-123",
|
||||
"session-from-codex",
|
||||
"12345",
|
||||
"",
|
||||
];
|
||||
for (const raw of inputs) {
|
||||
const translated = translateSessionId(raw, "claude");
|
||||
expect(translated).toMatch(OPENCODE_SESSION_RE);
|
||||
expect(translated).toHaveLength(30);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves already-valid OpenCode sessions without re-hashing", () => {
|
||||
const valid = "ses_f534dfae8ffeCy4Ee4tLWNygDc";
|
||||
expect(translateSessionId(valid)).toBe(valid);
|
||||
expect(translateSessionId(` ${valid} `)).toBe(valid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Free Executor Session Resolution", () => {
|
||||
it("uses request-local session credentials without mutating source credentials", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const { credentials, prepared } = prepare(executor);
|
||||
|
||||
expect(executor.constructor.name).toBe("OpenCodeExecutor");
|
||||
expect(prepared).not.toBe(credentials);
|
||||
expect(prepared._opencodeSession).toMatch(OPENCODE_SESSION_RE);
|
||||
expect(credentials).not.toHaveProperty("_opencodeSession");
|
||||
expect(executor).not.toHaveProperty("_currentSessionId");
|
||||
});
|
||||
|
||||
it("preserves valid native x-opencode-session header case-insensitively", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const valid = "ses_f534dfae8ffeCy4Ee4tLWNygDc";
|
||||
const { prepared } = prepare(executor, {
|
||||
credentials: makeCredentials({ rawHeaders: { "X-OpenCode-Session": ` ${valid} ` } }),
|
||||
});
|
||||
|
||||
expect(prepared._opencodeSession).toBe(valid);
|
||||
});
|
||||
|
||||
it("translates invalid native x-opencode-session header into a valid session", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const { prepared } = prepare(executor, {
|
||||
credentials: makeCredentials({ rawHeaders: { "x-opencode-session": "invalid-session-uuid" } }),
|
||||
});
|
||||
|
||||
expect(prepared._opencodeSession).toMatch(OPENCODE_SESSION_RE);
|
||||
expect(prepared._opencodeSession).not.toBe("invalid-session-uuid");
|
||||
});
|
||||
|
||||
it("translates conversation session deterministically", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const first = prepare(executor, { providerSessionId: "conversation-a", clientTool: "claude" }).prepared._opencodeSession;
|
||||
const second = prepare(executor, { providerSessionId: "conversation-a", clientTool: "claude" }).prepared._opencodeSession;
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toMatch(OPENCODE_SESSION_RE);
|
||||
});
|
||||
|
||||
it("isolates different conversations and tools", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const convA = prepare(executor, { providerSessionId: "conversation-a" }).prepared._opencodeSession;
|
||||
const convB = prepare(executor, { providerSessionId: "conversation-b" }).prepared._opencodeSession;
|
||||
const toolClaude = prepare(executor, { providerSessionId: "same", clientTool: "claude" }).prepared._opencodeSession;
|
||||
const toolCodex = prepare(executor, { providerSessionId: "same", clientTool: "codex" }).prepared._opencodeSession;
|
||||
|
||||
expect(convA).not.toBe(convB);
|
||||
expect(toolClaude).not.toBe(toolCodex);
|
||||
});
|
||||
|
||||
it("adds the valid session header to fetch requests", async () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const credentials = makeCredentials();
|
||||
const result = await executor.execute({
|
||||
model: "muse-spark-1.3-contributor-free",
|
||||
body: { input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }] },
|
||||
stream: false,
|
||||
credentials,
|
||||
providerSessionId: "conversation-fetch-test",
|
||||
clientTool: "claude",
|
||||
});
|
||||
|
||||
expect(result.headers["x-opencode-session"]).toMatch(OPENCODE_SESSION_RE);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock.mock.calls[0][1].headers["x-opencode-session"]).toBe(result.headers["x-opencode-session"]);
|
||||
expect(fetchMock.mock.calls[0][1].headers["Authorization"]).toBe("Bearer public");
|
||||
expect(credentials).not.toHaveProperty("_opencodeSession");
|
||||
});
|
||||
|
||||
it("falls back to a valid generated session in buildHeaders when called standalone", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const headers = executor.buildHeaders({});
|
||||
|
||||
expect(headers["x-opencode-session"]).toMatch(OPENCODE_SESSION_RE);
|
||||
expect(headers["Authorization"]).toBe("Bearer public");
|
||||
});
|
||||
it("handles null or undefined body gracefully in transformRequest", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
expect(() => executor.transformRequest("muse-spark-1.3-contributor-free", null, false, {})).not.toThrow();
|
||||
expect(() => executor.transformRequest("big-pickle", undefined, false, {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Free User-Agent Validation", () => {
|
||||
it("defaults User-Agent to opencode/1.18.31 for non-opencode downstream clients", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const headersNoUa = executor.buildHeaders({});
|
||||
expect(headersNoUa["User-Agent"]).toBe("opencode/1.18.31");
|
||||
|
||||
const headersClaude = executor.buildHeaders({ rawHeaders: { "user-agent": "Claude-Code/1.0" } });
|
||||
expect(headersClaude["User-Agent"]).toBe("opencode/1.18.31");
|
||||
});
|
||||
|
||||
it("replaces bare opencode with versioned opencode/1.18.31 to prevent 403 FreeTierError", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const headers = executor.buildHeaders({ rawHeaders: { "user-agent": "opencode" } });
|
||||
expect(headers["User-Agent"]).toBe("opencode/1.18.31");
|
||||
});
|
||||
|
||||
it("upgrades outdated opencode versions (< 1.17) to prevent 426 Upgrade Required", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const headers = executor.buildHeaders({ rawHeaders: { "user-agent": "opencode/1.15.0" } });
|
||||
expect(headers["User-Agent"]).toBe("opencode/1.18.31");
|
||||
});
|
||||
|
||||
it("preserves valid opencode versions (>= 1.17)", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const headers118 = executor.buildHeaders({
|
||||
rawHeaders: { "user-agent": "opencode/1.18.31 ai-sdk/provider-utils/4.0.40 runtime/bun/1.3.14" },
|
||||
});
|
||||
expect(headers118["User-Agent"]).toBe("opencode/1.18.31 ai-sdk/provider-utils/4.0.40 runtime/bun/1.3.14");
|
||||
|
||||
const headersFuture = executor.buildHeaders({ rawHeaders: { "user-agent": "opencode/1.19.0" } });
|
||||
expect(headersFuture["User-Agent"]).toBe("opencode/1.19.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Stable Session Reuse (429 follow-up)", () => {
|
||||
function anonymousCredentials(auth) {
|
||||
return makeCredentials({ connectionId: undefined, rawHeaders: { authorization: `Bearer ${auth}` } });
|
||||
}
|
||||
|
||||
it("reuses one stable upstream session instead of minting a new one per request", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
const first = executor.prepareRequestCredentials({
|
||||
body,
|
||||
credentials: anonymousCredentials("stable-key-1"),
|
||||
providerSessionId: null,
|
||||
clientTool: "claude",
|
||||
});
|
||||
const second = executor.prepareRequestCredentials({
|
||||
body,
|
||||
credentials: anonymousCredentials("stable-key-1"),
|
||||
providerSessionId: null,
|
||||
clientTool: "claude",
|
||||
});
|
||||
|
||||
expect(first._opencodeSession).toMatch(OPENCODE_SESSION_RE);
|
||||
expect(second._opencodeSession).toBe(first._opencodeSession);
|
||||
});
|
||||
|
||||
it("isolates stable sessions by downstream identity", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
const forKey = (auth) => executor.prepareRequestCredentials({
|
||||
body,
|
||||
credentials: anonymousCredentials(auth),
|
||||
providerSessionId: null,
|
||||
clientTool: "claude",
|
||||
})._opencodeSession;
|
||||
|
||||
expect(forKey("user-A")).not.toBe(forKey("user-B"));
|
||||
expect(forKey("user-A")).toMatch(OPENCODE_SESSION_RE);
|
||||
});
|
||||
|
||||
it("exposes the stable session helper directly", () => {
|
||||
const first = stableSessionId({ connectionId: "direct-conn" });
|
||||
expect(stableSessionId({ connectionId: "direct-conn" })).toBe(first);
|
||||
expect(first).toMatch(OPENCODE_SESSION_RE);
|
||||
});
|
||||
|
||||
it("derives deterministic, canonical request ids per message", () => {
|
||||
const session = stableSessionId({ connectionId: "req-conn" });
|
||||
const body = { messages: [{ role: "user", content: "ping" }] };
|
||||
const first = deriveRequestId(session, body);
|
||||
expect(first).toMatch(OPENCODE_REQUEST_RE);
|
||||
expect(deriveRequestId(session, body)).toBe(first);
|
||||
expect(
|
||||
deriveRequestId(session, { messages: [{ role: "user", content: "a different question" }] }),
|
||||
).not.toBe(first);
|
||||
});
|
||||
|
||||
it("preserves a valid downstream x-opencode-request header", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const validReq = "msg_0ae8d9cd3001swxaFbM248jcIF";
|
||||
const { prepared } = prepare(executor, {
|
||||
credentials: makeCredentials({ rawHeaders: { "x-opencode-request": validReq } }),
|
||||
});
|
||||
expect(prepared._opencodeRequest).toBe(validReq);
|
||||
});
|
||||
|
||||
it("keeps the standalone buildHeaders session stable across calls", () => {
|
||||
const executor = getExecutor("opencode");
|
||||
const first = executor.buildHeaders({})["x-opencode-session"];
|
||||
const second = executor.buildHeaders({})["x-opencode-session"];
|
||||
expect(first).toMatch(OPENCODE_SESSION_RE);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("cloaks free-tier requests with bash and read decoy 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" } }],
|
||||
tool_choice: "auto",
|
||||
});
|
||||
expect(chatWithTools.tool_choice).toBe("auto");
|
||||
const names = chatWithTools.tools.map((t) => t.function?.name);
|
||||
expect(names).toContain("Bash");
|
||||
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");
|
||||
});
|
||||
|
||||
it("declares forceStream on the opencode transport so chatCore serves SSE upstream", async () => {
|
||||
const { PROVIDERS } = await import("../../open-sse/config/providers.js");
|
||||
expect(PROVIDERS.opencode?.forceStream).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -55,4 +55,21 @@ describe("prefetchRemoteImages", () => {
|
||||
expect(n).toBe(1);
|
||||
expect(body.messages[0].content[0].source.type).toBe("base64");
|
||||
});
|
||||
|
||||
it("openai source -> commandcode target: converts remote URL to base64", async () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://x/a.png" } }] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.OPENAI, FORMATS.COMMANDCODE);
|
||||
expect(n).toBe(1);
|
||||
expect(body.messages[0].content[0].image_url.url.startsWith("data:image/png;base64,")).toBe(true);
|
||||
expect(fetchImageAsBase64).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("claude source -> commandcode target: source.url -> base64", async () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "image", source: { type: "url", url: "https://x/a.png" } },
|
||||
] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.CLAUDE, FORMATS.COMMANDCODE);
|
||||
expect(n).toBe(1);
|
||||
expect(body.messages[0].content[0].source.type).toBe("base64");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
filterQuotasByVisibility,
|
||||
getHiddenQuotaRows,
|
||||
parseQuotaData,
|
||||
trimHiddenQuotaKeys,
|
||||
} from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
describe("provider quota visibility", () => {
|
||||
@@ -13,22 +14,26 @@ describe("provider quota visibility", () => {
|
||||
used: 200,
|
||||
total: 1000,
|
||||
resetAt: "2026-07-04T00:00:00Z",
|
||||
remainingPercentage: 80,
|
||||
},
|
||||
"claude-opus-4-6-thinking": {
|
||||
displayName: "Claude Opus 4.6 (Thinking)",
|
||||
used: 100,
|
||||
total: 1000,
|
||||
resetAt: "2026-07-04T00:00:00Z",
|
||||
remainingPercentage: 90,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("keeps Antigravity modelKey so hidden settings use stable quota ids", () => {
|
||||
it("groups Antigravity model quotas into Gemini and Claude families", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
expect(quotas.map((q) => q.modelKey)).toEqual([
|
||||
"gemini-pro-agent",
|
||||
"claude-opus-4-6-thinking",
|
||||
"gemini",
|
||||
"claude",
|
||||
]);
|
||||
expect(quotas[0].name).toBe("Gemini (Flash / Pro)");
|
||||
expect(quotas[1].name).toBe("Claude (Sonnet / Opus)");
|
||||
});
|
||||
|
||||
it("shows all quotas by default and hides configured provider rows", () => {
|
||||
@@ -36,19 +41,34 @@ describe("provider quota visibility", () => {
|
||||
expect(filterQuotasByVisibility("antigravity", quotas, {})).toHaveLength(2);
|
||||
|
||||
const visibility = {
|
||||
antigravity: { hidden: ["claude-opus-4-6-thinking"] },
|
||||
antigravity: { hidden: ["claude"] },
|
||||
};
|
||||
const visible = filterQuotasByVisibility("antigravity", quotas, visibility);
|
||||
const hidden = getHiddenQuotaRows("antigravity", quotas, visibility);
|
||||
|
||||
expect(visible.map((q) => q.modelKey)).toEqual(["gemini-pro-agent"]);
|
||||
expect(hidden.map((q) => q.modelKey)).toEqual(["claude-opus-4-6-thinking"]);
|
||||
expect(visible.map((q) => q.modelKey)).toEqual(["gemini"]);
|
||||
expect(hidden.map((q) => q.modelKey)).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
it("trims stale or obsolete model keys", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const trimmed = trimHiddenQuotaKeys(["claude", "stale-model-xyz", "gemini-3.8-flash-low"], quotas);
|
||||
expect(trimmed).toEqual(["claude"]);
|
||||
|
||||
const visibility = {
|
||||
antigravity: { hidden: ["claude", "stale-model-xyz"] },
|
||||
};
|
||||
const visible = filterQuotasByVisibility("antigravity", quotas, visibility);
|
||||
const hidden = getHiddenQuotaRows("antigravity", quotas, visibility);
|
||||
|
||||
expect(visible.map((q) => q.modelKey)).toEqual(["gemini"]);
|
||||
expect(hidden.map((q) => q.modelKey)).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
it("does not apply one provider hidden list to another provider", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const visibility = {
|
||||
codex: { hidden: ["gemini-pro-agent"] },
|
||||
codex: { hidden: ["gemini"] },
|
||||
};
|
||||
expect(filterQuotasByVisibility("antigravity", quotas, visibility)).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Qoder context-window tiers + routable model listing.
|
||||
*
|
||||
* The Qoder IDE lets a user pick 200K / 400K / 1M for a model; qodercli-style
|
||||
* requests (what 9router sends) only carry the default max_input_tokens. These
|
||||
* tests pin the escalation policy and the payload fields the IDE writes.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
parseTierTokenCount,
|
||||
getQoderContextTiers,
|
||||
estimateQoderPromptTokens,
|
||||
resolveQoderContextTier,
|
||||
applyQoderContextTier,
|
||||
} from "../../open-sse/shared/qoder/contextTier.js";
|
||||
import { routableQoderModels } from "../../open-sse/services/qoderModels.js";
|
||||
|
||||
// Shape mirrors the live /algo/api/v2/model/list entry for qmodel_38max.
|
||||
const MODEL_CONFIG = {
|
||||
key: "qmodel_38max",
|
||||
display_name: "Qwen3.8-Max",
|
||||
is_reasoning: true,
|
||||
max_input_tokens: 180_000,
|
||||
max_output_tokens: 32_768,
|
||||
context_config: [
|
||||
{ name: "200K", tokenCount: 200_000, isDefault: true },
|
||||
{ name: "400K", tokenCount: 400_000, isDefault: false },
|
||||
{ name: "1M", tokenCount: 1_000_000, isDefault: false },
|
||||
],
|
||||
};
|
||||
|
||||
function promptOfTokens(n) {
|
||||
// ~4 ASCII chars per token
|
||||
return { system: "", messages: [{ role: "user", content: "abcd".repeat(n) }], tools: [] };
|
||||
}
|
||||
|
||||
describe("parseTierTokenCount", () => {
|
||||
it("accepts numbers and K/M suffixed strings", () => {
|
||||
expect(parseTierTokenCount(204800)).toBe(204800);
|
||||
expect(parseTierTokenCount("200K")).toBe(200_000);
|
||||
expect(parseTierTokenCount("1M")).toBe(1_000_000);
|
||||
expect(parseTierTokenCount("1.5m")).toBe(1_500_000);
|
||||
expect(parseTierTokenCount("131072")).toBe(131072);
|
||||
});
|
||||
|
||||
it("returns 0 for garbage", () => {
|
||||
expect(parseTierTokenCount(null)).toBe(0);
|
||||
expect(parseTierTokenCount("big")).toBe(0);
|
||||
expect(parseTierTokenCount(-5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQoderContextTiers", () => {
|
||||
it("sorts tiers ascending and keeps the default flag", () => {
|
||||
const tiers = getQoderContextTiers({
|
||||
context_config: [
|
||||
{ name: "1M", tokenCount: 1_000_000 },
|
||||
{ name: "200K", tokenCount: 200_000, isDefault: true },
|
||||
],
|
||||
});
|
||||
expect(tiers.map((t) => t.tokenCount)).toEqual([200_000, 1_000_000]);
|
||||
expect(tiers[0].isDefault).toBe(true);
|
||||
expect(tiers[1].isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it("understands camelCase / snake_case variants and derives names", () => {
|
||||
const tiers = getQoderContextTiers({
|
||||
contextConfig: [{ token_count: "400K", is_default: true }, { max_input_tokens: 1_000_000 }],
|
||||
});
|
||||
expect(tiers).toEqual([
|
||||
{ name: "400K", tokenCount: 400_000, isDefault: true },
|
||||
{ name: "1M", tokenCount: 1_000_000, isDefault: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] when the model has no tiers", () => {
|
||||
expect(getQoderContextTiers({ max_input_tokens: 131072 })).toEqual([]);
|
||||
expect(getQoderContextTiers(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateQoderPromptTokens", () => {
|
||||
it("counts CJK characters as ~1 token each instead of chars/4", () => {
|
||||
const ascii = estimateQoderPromptTokens({ messages: [{ role: "user", content: "a".repeat(4000) }] });
|
||||
const cjk = estimateQoderPromptTokens({ messages: [{ role: "user", content: "中".repeat(4000) }] });
|
||||
expect(ascii).toBeLessThan(1_200);
|
||||
expect(cjk).toBeGreaterThan(4_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveQoderContextTier (auto)", () => {
|
||||
it("leaves the payload untouched while the prompt fits the current max_input_tokens", () => {
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(50_000))).toBeNull();
|
||||
});
|
||||
|
||||
it("escalates to the smallest tier that fits once the prompt outgrows the default", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(250_000));
|
||||
expect(choice).not.toBeNull();
|
||||
expect(choice.tier.name).toBe("400K");
|
||||
expect(choice.reason).toBe("auto:fits");
|
||||
expect(choice.estimatedTokens).toBeGreaterThan(240_000);
|
||||
});
|
||||
|
||||
it("falls back to the largest tier when nothing fits (upstream decides)", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(1_200_000));
|
||||
expect(choice.tier.name).toBe("1M");
|
||||
expect(choice.reason).toBe("auto:largest");
|
||||
});
|
||||
|
||||
it("applies headroom so a prompt just under the limit still escalates", () => {
|
||||
// 170K estimated * 1.15 = 195.5K > 180K current → smallest tier above the current limit (200K)
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(170_000))?.tier.name).toBe("200K");
|
||||
// 190K * 1.15 = 218.5K → 200K no longer fits → 400K
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(190_000))?.tier.name).toBe("400K");
|
||||
});
|
||||
|
||||
it("returns null for models without context_config", () => {
|
||||
expect(resolveQoderContextTier({ max_input_tokens: 131072 }, promptOfTokens(500_000))).toBeNull();
|
||||
});
|
||||
|
||||
it("never escalates when the current limit is already the largest tier", () => {
|
||||
const cfg = { ...MODEL_CONFIG, max_input_tokens: 1_000_000 };
|
||||
expect(resolveQoderContextTier(cfg, promptOfTokens(1_500_000))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveQoderContextTier (forced via QODER_CONTEXT_TIER)", () => {
|
||||
it("max picks the largest tier regardless of prompt size", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "max" });
|
||||
expect(choice.tier.name).toBe("1M");
|
||||
expect(choice.reason).toBe("forced:max");
|
||||
});
|
||||
|
||||
it("default picks the isDefault tier", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "default" });
|
||||
expect(choice.tier.name).toBe("200K");
|
||||
});
|
||||
|
||||
it("a tier name or token count selects that tier", () => {
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "400k" }).tier.tokenCount).toBe(400_000);
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "1000000" }).tier.name).toBe("1M");
|
||||
});
|
||||
|
||||
it("an unknown tier name falls back to auto", () => {
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "9M" })).toBeNull();
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(250_000), { preference: "9M" }).tier.name).toBe("400K");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyQoderContextTier", () => {
|
||||
it("mirrors the tier into the three places the IDE writes", () => {
|
||||
const payload = {
|
||||
parameters: { max_tokens: 32_768 },
|
||||
chat_context: { extra: { context: [], modelConfig: { key: "qmodel_38max" } } },
|
||||
model_config: { ...MODEL_CONFIG },
|
||||
};
|
||||
applyQoderContextTier(payload, { name: "1M", tokenCount: 1_000_000 });
|
||||
expect(payload.parameters).toEqual({ max_tokens: 32_768, context_length: 1_000_000 });
|
||||
expect(payload.chat_context.extra.ideModelConfigOverride).toEqual({ max_input_tokens: 1_000_000 });
|
||||
expect(payload.chat_context.extra.modelConfig).toEqual({ key: "qmodel_38max" });
|
||||
expect(payload.model_config.max_input_tokens).toBe(1_000_000);
|
||||
expect(payload.model_config.context_config).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("is a no-op without a tier", () => {
|
||||
const payload = { parameters: { max_tokens: 1 } };
|
||||
expect(applyQoderContextTier(payload, null)).toBe(payload);
|
||||
expect(payload).toEqual({ parameters: { max_tokens: 1 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("routableQoderModels", () => {
|
||||
it("lists visible models first, then hidden (enable:false) catalog keys", () => {
|
||||
const catalog = {
|
||||
models: [{ id: "qmodel_38max", name: "Qwen3.8-Max" }],
|
||||
rawConfigs: new Map([
|
||||
["qmodel_38max", { key: "qmodel_38max", enable: true }],
|
||||
["qfmodel", { key: "qfmodel", enable: false, display_name: "Qwen Fast" }],
|
||||
["dmodel", { key: "dmodel", enable: false }],
|
||||
]),
|
||||
};
|
||||
expect(routableQoderModels(catalog)).toEqual([
|
||||
{ id: "qmodel_38max", name: "Qwen3.8-Max", hidden: false },
|
||||
{ id: "qfmodel", name: "Qwen Fast", hidden: true },
|
||||
{ id: "dmodel", name: "dmodel", hidden: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] for a failed catalog fetch", () => {
|
||||
expect(routableQoderModels(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
+273
-1
@@ -9,7 +9,7 @@
|
||||
* - device flow URL construction
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import crypto from "crypto";
|
||||
|
||||
import { qoderEncodeBody } from "../../src/lib/qoder/encoding.js";
|
||||
@@ -22,6 +22,13 @@ import {
|
||||
} from "../../src/lib/qoder/constants.js";
|
||||
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js";
|
||||
import { __test__ as qoderExecutorInternals } from "../../open-sse/executors/qoder.js";
|
||||
import { canonicalizeQoderUsage } from "../../open-sse/shared/qoder/sse.js";
|
||||
import {
|
||||
rewriteQoderMessageAttachments,
|
||||
clearQoderUploadCache,
|
||||
buildMultipartFile,
|
||||
} from "../../open-sse/shared/qoder/attachments.js";
|
||||
import { qoderInferenceBase } from "../../open-sse/shared/qoder/constants.js";
|
||||
|
||||
// Convenience aliases — tests were originally written against module-level
|
||||
// helpers; the QoderService class wraps them so each test creates its own
|
||||
@@ -367,6 +374,85 @@ describe("normalizeMessages", () => {
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.systemText).toBe("");
|
||||
});
|
||||
|
||||
it("preserves image_url blocks (http URL) instead of dropping them", () => {
|
||||
const result = normalizeMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "describe" },
|
||||
{ type: "image_url", image_url: { url: "https://example.com/a.png" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const content = result.messages[0].content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content).toContainEqual({ type: "text", text: "describe" });
|
||||
expect(content).toContainEqual({ type: "image_url", image_url: { url: "https://example.com/a.png" } });
|
||||
});
|
||||
|
||||
it("preserves base64 data: URI images (no OSS upload needed)", () => {
|
||||
const dataUri = "data:image/png;base64,iVBORw0KGgo=";
|
||||
const result = normalizeMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image_url", image_url: { url: dataUri } },
|
||||
{ type: "text", text: "what color?" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const content = result.messages[0].content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content[0]).toEqual({ type: "image_url", image_url: { url: dataUri } });
|
||||
expect(content.some((b) => b.type === "text" && b.text === "what color?")).toBe(true);
|
||||
});
|
||||
|
||||
it("converts claude-style base64 image blocks to image_url data URIs", () => {
|
||||
const result = normalizeMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "see this" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "AAAA" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const content = result.messages[0].content;
|
||||
expect(content).toContainEqual({
|
||||
type: "image_url",
|
||||
image_url: { url: "data:image/jpeg;base64,AAAA" },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops image blocks with no usable url but keeps the text", () => {
|
||||
const result = normalizeMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "image_url", image_url: {} },
|
||||
{ type: "image", source: { type: "base64" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(result.messages[0].content).toBe("hi");
|
||||
});
|
||||
|
||||
it("turns leftover file/document blocks into short stubs instead of dropping them", () => {
|
||||
const result = normalizeMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "see" },
|
||||
{ type: "file", file: { filename: "big.pdf", file_data: "data:application/pdf;base64,AAA" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(result.messages[0].content).toContain("see");
|
||||
expect(result.messages[0].content).toContain("big.pdf");
|
||||
expect(result.messages[0].content).not.toContain("AAA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapQoderSSE", () => {
|
||||
@@ -466,4 +552,190 @@ describe("wrapQoderSSE", () => {
|
||||
const wrapped = await wrapQoderSSE(r, "qoder/auto");
|
||||
expect(wrapped).toBe(r);
|
||||
});
|
||||
|
||||
function envelope(body) {
|
||||
return `data: ${JSON.stringify({ statusCodeValue: 200, body })}\n\n`;
|
||||
}
|
||||
|
||||
function parseForwardedChunks(out) {
|
||||
return out
|
||||
.split("\n\n")
|
||||
.map((block) => block.trim())
|
||||
.filter((block) => block.startsWith("data:") && !block.includes("[DONE]"))
|
||||
.map((block) => JSON.parse(block.slice("data:".length).trim()));
|
||||
}
|
||||
|
||||
it("coalesces empty finish-in-delta + usage-only into one OpenAI usage chunk", async () => {
|
||||
const content = JSON.stringify({
|
||||
id: "chatcmpl-qoder-1",
|
||||
created: 1700000000,
|
||||
model: "auto",
|
||||
choices: [{ index: 0, delta: { content: "hi" } }],
|
||||
});
|
||||
const finish = JSON.stringify({
|
||||
id: "chatcmpl-qoder-1",
|
||||
choices: [{ index: 0, delta: { content: "", finish_reason: "stop" } }],
|
||||
});
|
||||
const usage = JSON.stringify({
|
||||
id: "chatcmpl-qoder-1",
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
prompt_tokens_details: { cached_tokens: 40 },
|
||||
},
|
||||
});
|
||||
const wrapped = await wrapQoderSSE(
|
||||
makeResponse([envelope(content) + envelope(finish) + envelope(usage) + envelope("[DONE]")]),
|
||||
"qoder/auto",
|
||||
);
|
||||
const out = await drain(wrapped);
|
||||
expect(out).toContain(`data: ${content}\n\n`);
|
||||
const chunks = parseForwardedChunks(out);
|
||||
const usageChunk = chunks.find((c) => c.usage);
|
||||
expect(usageChunk).toBeDefined();
|
||||
expect(usageChunk.choices[0].finish_reason).toBe("stop");
|
||||
expect(usageChunk.usage.prompt_tokens).toBe(100);
|
||||
expect(usageChunk.usage.completion_tokens).toBe(20);
|
||||
expect(usageChunk.usage.prompt_tokens_details.cached_tokens).toBe(40);
|
||||
expect(chunks.some((c) => Array.isArray(c.choices) && c.choices.length === 0)).toBe(false);
|
||||
expect((out.match(/data: \[DONE\]/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("maps Qoder input_tokens aliases onto prompt_tokens in the coalesced usage chunk", async () => {
|
||||
const finish = JSON.stringify({
|
||||
choices: [{ index: 0, delta: { finish_reason: "stop" } }],
|
||||
});
|
||||
const usage = JSON.stringify({
|
||||
choices: [],
|
||||
usage: {
|
||||
input_tokens: 80,
|
||||
output_tokens: 10,
|
||||
cache_read_input_tokens: 25,
|
||||
},
|
||||
});
|
||||
const wrapped = await wrapQoderSSE(
|
||||
makeResponse([envelope(finish) + envelope(usage)]),
|
||||
"qoder/lite",
|
||||
);
|
||||
const chunks = parseForwardedChunks(await drain(wrapped));
|
||||
const usageChunk = chunks.find((c) => c.usage);
|
||||
expect(usageChunk.usage.prompt_tokens).toBe(80);
|
||||
expect(usageChunk.usage.completion_tokens).toBe(10);
|
||||
expect(usageChunk.usage.prompt_tokens_details.cached_tokens).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalizeQoderUsage", () => {
|
||||
it("returns null for missing or empty usage", () => {
|
||||
expect(canonicalizeQoderUsage(null)).toBeNull();
|
||||
expect(canonicalizeQoderUsage({})).toBeNull();
|
||||
});
|
||||
|
||||
it("copies prompt_tokens_details.cached_tokens through", () => {
|
||||
const out = canonicalizeQoderUsage({
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 5,
|
||||
prompt_tokens_details: { cached_tokens: 12 },
|
||||
});
|
||||
expect(out.prompt_tokens).toBe(50);
|
||||
expect(out.cached_tokens).toBe(12);
|
||||
expect(out.prompt_tokens_details.cached_tokens).toBe(12);
|
||||
expect(out.total_tokens).toBe(55);
|
||||
});
|
||||
});
|
||||
|
||||
describe("qoderInferenceBase", () => {
|
||||
it("sends job tokens to api2 and device tokens to api3", () => {
|
||||
expect(qoderInferenceBase({ accessToken: "jt-abc" })).toContain("api2.qoder.sh");
|
||||
expect(qoderInferenceBase({ accessToken: "dt-abc" })).toContain("api3.qoder.sh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteQoderMessageAttachments", () => {
|
||||
beforeEach(() => clearQoderUploadCache());
|
||||
|
||||
it("uploads data-URI images and keeps only the OSS URL in the message", async () => {
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "see this" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
|
||||
],
|
||||
}];
|
||||
const stats = await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async ({ buffer, mediaType }) => {
|
||||
expect(Buffer.isBuffer(buffer)).toBe(true);
|
||||
expect(mediaType).toBe("image/png");
|
||||
return "https://cdn.qoder.example/img.png";
|
||||
},
|
||||
});
|
||||
expect(messages[0].content).toEqual([
|
||||
{ type: "text", text: "see this" },
|
||||
{ type: "image_url", image_url: { url: "https://cdn.qoder.example/img.png" } },
|
||||
]);
|
||||
expect(JSON.stringify(messages)).not.toContain("AAAA");
|
||||
expect(stats.imageUrls).toEqual(["https://cdn.qoder.example/img.png"]);
|
||||
});
|
||||
|
||||
it("does not re-upload already-hosted http(s) image URLs", async () => {
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }],
|
||||
}];
|
||||
await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async () => {
|
||||
throw new Error("should not upload remote URLs");
|
||||
},
|
||||
});
|
||||
expect(messages[0].content[0].image_url.url).toBe("https://example.com/a.png");
|
||||
});
|
||||
|
||||
it("stubs non-image file blocks instead of inlining bytes", async () => {
|
||||
const pdfB64 = "A".repeat(200);
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "read this" },
|
||||
{ type: "file", file: { filename: "big.pdf", file_data: `data:application/pdf;base64,${pdfB64}` } },
|
||||
],
|
||||
}];
|
||||
await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async () => {
|
||||
throw new Error("should not upload PDFs as images");
|
||||
},
|
||||
});
|
||||
const wire = JSON.stringify(messages);
|
||||
expect(wire).not.toContain(pdfB64);
|
||||
expect(wire).toContain("[file omitted: big.pdf");
|
||||
});
|
||||
|
||||
it("stubs oversized images when OSS upload fails instead of keeping a huge data URI", async () => {
|
||||
const big = "A".repeat(700_000);
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: { url: `data:image/png;base64,${big}` } }],
|
||||
}];
|
||||
await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async () => {
|
||||
throw new Error("upstream 413");
|
||||
},
|
||||
});
|
||||
const wire = JSON.stringify(messages);
|
||||
expect(wire).not.toContain(big);
|
||||
expect(wire).toContain("[file omitted:");
|
||||
expect(Buffer.byteLength(wire, "utf8")).toBeLessThan(4096);
|
||||
});
|
||||
|
||||
it("buildMultipartFile uses the file field name qodercli sends", () => {
|
||||
const { boundary, body } = buildMultipartFile(Buffer.from("hi"), {
|
||||
fileName: "image.png",
|
||||
mediaType: "image/png",
|
||||
});
|
||||
const text = body.toString("latin1");
|
||||
expect(text).toContain(`name="file"`);
|
||||
expect(text).toContain("filename=\"image.png\"");
|
||||
expect(text).toContain(`--${boundary}`);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createDisconnectAwareStream } from "../../open-sse/utils/streamHandler.js";
|
||||
import { createDisconnectAwareStream, pipeWithDisconnect, createStreamController } from "../../open-sse/utils/streamHandler.js";
|
||||
import { buildAbortedResponsesTerminalBytes } from "../../open-sse/utils/responsesStreamHelpers.js";
|
||||
import { buildStreamErrorBytes } from "../../open-sse/utils/streamHelpers.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
// Minimal stream controller stub
|
||||
function makeController() {
|
||||
@@ -70,3 +72,71 @@ describe("Responses abort terminal synthesis", () => {
|
||||
expect(text).not.toContain("[DONE]");
|
||||
});
|
||||
});
|
||||
|
||||
// A stream that aborts after HTTP 200 cannot change status, so the failure must
|
||||
// travel in-band: structured error frame first, then [DONE]. openai-python raises
|
||||
// APIError on any `data:` payload carrying an `error` key (checked before [DONE]);
|
||||
// Anthropic clients need `event: error`. Never a fabricated finish_reason.
|
||||
describe("buildStreamErrorBytes", () => {
|
||||
const jsonOf = (sse) => JSON.parse(sse.match(/\{.*\}/s)[0]);
|
||||
const textOf = (bytes) => new TextDecoder().decode(bytes);
|
||||
|
||||
// onAbortTerminal callbacks are enqueued verbatim, so a string here is a
|
||||
// silent no-op at runtime (createDisconnectAwareStream swallows the throw).
|
||||
it("returns bytes, not a string", () => {
|
||||
expect(buildStreamErrorBytes(504, "x", FORMATS.OPENAI)).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("emits error frame then [DONE] for OpenAI clients", () => {
|
||||
const out = textOf(buildStreamErrorBytes(504, "stream stall timeout", FORMATS.OPENAI));
|
||||
|
||||
expect(out).toContain('data: {"error"');
|
||||
expect(out.indexOf("data: [DONE]")).toBeGreaterThan(out.indexOf('data: {"error"'));
|
||||
|
||||
expect(jsonOf(out).error).toEqual({
|
||||
message: "stream stall timeout",
|
||||
type: "server_error",
|
||||
code: "gateway_timeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits event: error (no [DONE]) for Claude clients", () => {
|
||||
const out = textOf(buildStreamErrorBytes(504, "stream stall timeout", FORMATS.CLAUDE));
|
||||
|
||||
expect(out).toContain("event: error\n");
|
||||
expect(out).not.toContain("[DONE]");
|
||||
expect(jsonOf(out)).toMatchObject({ type: "error", error: { message: "stream stall timeout" } });
|
||||
});
|
||||
});
|
||||
|
||||
// The wiring, not just the frame builder: the watchdog must hand its reason to
|
||||
// onAbortTerminal and the bytes must reach a real consumer.
|
||||
describe("stall abort through pipeWithDisconnect", () => {
|
||||
it("delivers the error frame and closes the stream", async () => {
|
||||
// Real controller: the stub above never fires its signal, and the abort
|
||||
// must reach the upstream body for the pipe to end.
|
||||
const ctrl = createStreamController({ provider: "ollama", model: "test" });
|
||||
|
||||
// Emits one chunk then goes silent; errors on abort like a real fetch body.
|
||||
const upstream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("data: hi\n\n"));
|
||||
ctrl.signal.addEventListener("abort", () => controller.error(new Error("aborted")), { once: true });
|
||||
},
|
||||
});
|
||||
|
||||
let seen = null;
|
||||
const out = pipeWithDisconnect(
|
||||
{ body: upstream },
|
||||
new TransformStream(),
|
||||
ctrl,
|
||||
(message) => { seen = message; return buildStreamErrorBytes(504, message, FORMATS.OPENAI); },
|
||||
50
|
||||
);
|
||||
|
||||
const text = await readAll(out);
|
||||
expect(seen).toBe("stream stall timeout");
|
||||
expect(text).toContain('"stream stall timeout"');
|
||||
expect(text).toContain("data: [DONE]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// Parallel function_calls from a Responses upstream must stay on separate
|
||||
// chat tool_calls indices. Regression: response/openai-responses.js attributed
|
||||
// every arguments delta to the positional toolCallIndex (advanced only on
|
||||
// output_item.done), so all-added-then-deltas ordering concatenated N JSON
|
||||
// payloads into index 0 and clients failed with InputValidationError.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "../translator/registerAll.js";
|
||||
import { openaiResponsesToOpenAIResponse } from "../../open-sse/translator/response/openai-responses.js";
|
||||
import { clampResponsesCallId, coerceResponsesOutput, MAX_RESPONSES_CALL_ID_LEN } from "../../open-sse/translator/formats/responsesApi.js";
|
||||
import { initState, translateResponse } from "../../open-sse/translator/index.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
const added = (id, call_id, name, type = "function_call") => ({
|
||||
type: "response.output_item.added",
|
||||
item: { id, type, call_id, name, arguments: "" },
|
||||
});
|
||||
const delta = (item_id, text) => ({
|
||||
type: "response.function_call_arguments.delta",
|
||||
item_id,
|
||||
delta: text,
|
||||
});
|
||||
const done = (id, call_id, name) => ({
|
||||
type: "response.output_item.done",
|
||||
item: { id, type: "function_call", call_id, name },
|
||||
});
|
||||
|
||||
// Reassemble translated chunks the way an OpenAI client accumulator does.
|
||||
function accumulate(calls, chunks) {
|
||||
for (const chunk of chunks) {
|
||||
if (!chunk) continue;
|
||||
for (const tc of chunk.choices?.[0]?.delta?.tool_calls || []) {
|
||||
const slot = (calls[tc.index] ??= { id: null, name: "", args: "" });
|
||||
if (tc.id) slot.id = tc.id;
|
||||
if (tc.function?.name) slot.name = tc.function.name;
|
||||
if (tc.function?.arguments) slot.args += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
function runStream(events) {
|
||||
const state = {};
|
||||
const chunks = [];
|
||||
for (const ev of events) {
|
||||
const out = openaiResponsesToOpenAIResponse(ev, state);
|
||||
if (out) chunks.push(out);
|
||||
}
|
||||
const flush = openaiResponsesToOpenAIResponse(null, state);
|
||||
if (flush) chunks.push(flush);
|
||||
return { state, chunks };
|
||||
}
|
||||
|
||||
const PAYLOADS = [
|
||||
'{"file_path":"/docs/PRODUCT.md"}',
|
||||
'{"file_path":"/docs/ROADMAP.md"}',
|
||||
'{"file_path":"/docs/openapi.custom.yaml"}',
|
||||
'{"file_path":"/docs/.gitignore"}',
|
||||
];
|
||||
|
||||
function hostileOrdering() {
|
||||
const events = PAYLOADS.map((_, i) => added(`fc_${i}`, `call_${i}`, "read_file"));
|
||||
// Interleaved deltas AFTER all addeds — the ordering that used to merge all
|
||||
// four payloads into index 0.
|
||||
PAYLOADS.forEach((p, i) => events.push(delta(`fc_${i}`, p.slice(0, 20)), delta(`fc_${i}`, p.slice(20))));
|
||||
PAYLOADS.forEach((_, i) => events.push(done(`fc_${i}`, `call_${i}`, "read_file")));
|
||||
return events;
|
||||
}
|
||||
|
||||
describe("responses parallel tool calls keep their own index", () => {
|
||||
it("all-added-then-deltas ordering yields 4 separately parseable calls", () => {
|
||||
const { chunks } = runStream(hostileOrdering());
|
||||
const calls = accumulate({}, chunks);
|
||||
expect(Object.keys(calls)).toHaveLength(4);
|
||||
PAYLOADS.forEach((p, i) => {
|
||||
expect(calls[i].id).toBe(`call_${i}`);
|
||||
expect(calls[i].name).toBe("read_file");
|
||||
expect(JSON.parse(calls[i].args)).toEqual(JSON.parse(p));
|
||||
});
|
||||
});
|
||||
|
||||
it("sequential ordering still yields indices 0,1 in order", () => {
|
||||
const events = [
|
||||
added("fc_0", "call_0", "read_file"),
|
||||
delta("fc_0", PAYLOADS[0]),
|
||||
done("fc_0", "call_0", "read_file"),
|
||||
added("fc_1", "call_1", "read_file"),
|
||||
delta("fc_1", PAYLOADS[1]),
|
||||
done("fc_1", "call_1", "read_file"),
|
||||
];
|
||||
const { chunks } = runStream(events);
|
||||
const calls = accumulate({}, chunks);
|
||||
expect(Object.keys(calls)).toEqual(["0", "1"]);
|
||||
expect(JSON.parse(calls[0].args)).toEqual(JSON.parse(PAYLOADS[0]));
|
||||
expect(JSON.parse(calls[1].args)).toEqual(JSON.parse(PAYLOADS[1]));
|
||||
});
|
||||
|
||||
it("done carrying full arguments (no deltas) emits them once", () => {
|
||||
const state = {};
|
||||
const out1 = openaiResponsesToOpenAIResponse(added("fc_9", "call_9", "read_file"), state);
|
||||
const out2 = openaiResponsesToOpenAIResponse({
|
||||
type: "response.output_item.done",
|
||||
item: { id: "fc_9", type: "function_call", call_id: "call_9", name: "read_file", arguments: PAYLOADS[0] },
|
||||
}, state);
|
||||
const calls = accumulate({}, [out1, out2]);
|
||||
expect(JSON.parse(calls[0].args)).toEqual(JSON.parse(PAYLOADS[0]));
|
||||
});
|
||||
|
||||
it("deltas without item_id fall back to the most recent call (legacy behavior)", () => {
|
||||
const events = [
|
||||
added("fc_0", "call_0", "read_file"),
|
||||
{ type: "response.function_call_arguments.delta", delta: PAYLOADS[0] },
|
||||
done("fc_0", "call_0", "read_file"),
|
||||
];
|
||||
const { chunks } = runStream(events);
|
||||
const calls = accumulate({}, chunks);
|
||||
expect(JSON.parse(calls[0].args)).toEqual(JSON.parse(PAYLOADS[0]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("responses → claude end-to-end keeps parallel tool_use blocks separate", () => {
|
||||
it("four read_file calls arrive as four parseable tool_use blocks", () => {
|
||||
const state = initState(FORMATS.CLAUDE);
|
||||
const out = [];
|
||||
for (const ev of hostileOrdering()) {
|
||||
for (const r of translateResponse(FORMATS.OPENAI_RESPONSES, FORMATS.CLAUDE, ev, state)) out.push(r);
|
||||
}
|
||||
for (const r of translateResponse(FORMATS.OPENAI_RESPONSES, FORMATS.CLAUDE, null, state)) out.push(r);
|
||||
|
||||
const starts = out.filter((r) => r?.type === "content_block_start" && r?.content_block?.type === "tool_use");
|
||||
expect(starts).toHaveLength(4);
|
||||
const partials = out.filter((r) => r?.delta?.type === "input_json_delta");
|
||||
expect(partials).toHaveLength(4);
|
||||
const bodies = partials.map((r) => JSON.parse(r.delta.partial_json).file_path).sort();
|
||||
expect(bodies).toEqual([
|
||||
"/docs/.gitignore",
|
||||
"/docs/PRODUCT.md",
|
||||
"/docs/ROADMAP.md",
|
||||
"/docs/openapi.custom.yaml",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback call_ids stay unique within a batch", () => {
|
||||
it("same-millisecond fallbacks never collide", () => {
|
||||
const ids = new Set(Array.from({ length: 50 }, () => clampResponsesCallId(undefined)));
|
||||
expect(ids.size).toBe(50);
|
||||
for (const id of ids) {
|
||||
expect(id.startsWith("call_")).toBe(true);
|
||||
expect(id.length).toBeLessThanOrEqual(MAX_RESPONSES_CALL_ID_LEN);
|
||||
}
|
||||
expect(new Set([clampResponsesCallId(""), clampResponsesCallId(null)]).size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("output coercion stays fail-soft on unstringifiable values", () => {
|
||||
it("never throws on BigInt/circular array elements", () => {
|
||||
const circular = {};
|
||||
circular.self = circular;
|
||||
const input = [1n, circular, { text: "ok" }];
|
||||
expect(() => coerceResponsesOutput(input)).not.toThrow();
|
||||
const out = coerceResponsesOutput(input);
|
||||
expect(typeof out).toBe("string");
|
||||
expect(out).toContain("ok");
|
||||
});
|
||||
});
|
||||
@@ -261,138 +261,121 @@ describe("system-inject gemini", () => {
|
||||
});
|
||||
|
||||
describe("system-inject kiro", () => {
|
||||
it("updates systemPrompt and mirrored prefix of first history user preserving tail", () => {
|
||||
const oldPrompt = "OLD_SYS";
|
||||
// The kiro.dev gateway rejects any body carrying a top-level `systemPrompt`
|
||||
// with 400 REQUEST_BODY_INVALID, so the prompt goes into the user turn only.
|
||||
it("appends to first history user, leaves systemPrompt untouched", () => {
|
||||
const timeCtx = "[Context: Current time is 2026-01-01T00:00:00.000Z]";
|
||||
const tail = "user tail content";
|
||||
const historyUserContent = `${oldPrompt}${SEP}${timeCtx}${SEP}${tail}`;
|
||||
const historyUserContent = `${timeCtx}${SEP}${tail}`;
|
||||
const body = {
|
||||
systemPrompt: oldPrompt,
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: historyUserContent, modelId: "m" } }, { assistantResponseMessage: { content: "..." } }],
|
||||
currentMessage: { userInputMessage: { content: "current " + tail, modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
const next = `${oldPrompt}${SEP}${P1}`;
|
||||
expect(body.systemPrompt).toBe(next);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`${next}${SEP}${timeCtx}${SEP}${tail}`);
|
||||
expect(body.systemPrompt).toBeUndefined();
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`${historyUserContent}${SEP}${P1}`);
|
||||
// currentMessage must stay untouched
|
||||
expect(body.conversationState.currentMessage.userInputMessage.content).toBe("current " + tail);
|
||||
});
|
||||
|
||||
it("when no history user, updates currentMessage instead", () => {
|
||||
const oldPrompt = "OLD";
|
||||
const body = {
|
||||
systemPrompt: oldPrompt,
|
||||
conversationState: {
|
||||
history: [],
|
||||
currentMessage: { userInputMessage: { content: `${oldPrompt}${SEP}tail`, modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}`);
|
||||
expect(body.conversationState.currentMessage.userInputMessage.content).toBe(`${oldPrompt}${SEP}${P1}${SEP}tail`);
|
||||
});
|
||||
|
||||
it("empty old prompt prepends to chosen user content", () => {
|
||||
const body = {
|
||||
systemPrompt: "",
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: "tail hello", modelId: "m" } }],
|
||||
currentMessage: { userInputMessage: { content: "cur", modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(P1);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`${P1}${SEP}tail hello`);
|
||||
});
|
||||
|
||||
it("if old prompt not mirrored at head, do not alter user content", () => {
|
||||
it("never writes a top-level systemPrompt, even if one is already present", () => {
|
||||
const body = {
|
||||
systemPrompt: "OLD",
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: "different head content", modelId: "m" } }],
|
||||
history: [{ userInputMessage: { content: "tail", modelId: "m" } }],
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe("OLD");
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`tail${SEP}${P1}`);
|
||||
});
|
||||
|
||||
it("when no history user, updates currentMessage instead", () => {
|
||||
const body = {
|
||||
conversationState: {
|
||||
history: [],
|
||||
currentMessage: { userInputMessage: { content: "tail", modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBeUndefined();
|
||||
expect(body.conversationState.currentMessage.userInputMessage.content).toBe(`tail${SEP}${P1}`);
|
||||
});
|
||||
|
||||
it("empty user content becomes the prompt itself", () => {
|
||||
const body = {
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: "", modelId: "m" } }],
|
||||
currentMessage: { userInputMessage: { content: "cur", modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(`OLD${SEP}${P1}`);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe("different head content");
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(P1);
|
||||
expect(body.conversationState.currentMessage.userInputMessage.content).toBe("cur");
|
||||
});
|
||||
|
||||
it("exact retry idempotency for kiro", () => {
|
||||
const oldPrompt = "OLD";
|
||||
const body = {
|
||||
systemPrompt: oldPrompt,
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: `${oldPrompt}${SEP}tail`, modelId: "m" } }],
|
||||
history: [{ userInputMessage: { content: "tail", modelId: "m" } }],
|
||||
currentMessage: { userInputMessage: { content: "cur", modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
const after1 = JSON.parse(JSON.stringify(body));
|
||||
const after1 = body.conversationState.history[0].userInputMessage.content;
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(after1.systemPrompt);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(after1.conversationState.history[0].userInputMessage.content);
|
||||
// different prompt both apply
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(after1);
|
||||
// different prompt both apply, in injection order
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P2);
|
||||
expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}${SEP}${P2}`);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`tail${SEP}${P1}${SEP}${P2}`);
|
||||
});
|
||||
|
||||
it("preserves non-enumerable _kiroUpstreamModel", () => {
|
||||
const body = {
|
||||
systemPrompt: "OLD",
|
||||
conversationState: { history: [{ userInputMessage: { content: "OLD" + SEP + "tail", modelId: "m" } }], currentMessage: { userInputMessage: { content: "OLD" + SEP + "tail2", modelId: "m" } } },
|
||||
conversationState: { history: [{ userInputMessage: { content: "tail", modelId: "m" } }], currentMessage: { userInputMessage: { content: "tail2", modelId: "m" } } },
|
||||
};
|
||||
Object.defineProperty(body, "_kiroUpstreamModel", { value: "m", enumerable: false });
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body._kiroUpstreamModel).toBe("m");
|
||||
expect(Object.getOwnPropertyDescriptor(body, "_kiroUpstreamModel").enumerable).toBe(false);
|
||||
});
|
||||
|
||||
it("frozen user message fails open without throwing or half-writing", () => {
|
||||
const body = {
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: Object.freeze({ content: "tail", modelId: "m" }) }],
|
||||
},
|
||||
};
|
||||
expect(() => injectSystemPrompt(body, FORMATS.KIRO, P1)).not.toThrow();
|
||||
expect(body.systemPrompt).toBeUndefined();
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe("tail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("system-inject regression fixes", () => {
|
||||
it("kiro partial mutation converges on retry after transient content write failure", () => {
|
||||
const oldPrompt = "OLD";
|
||||
let failNextWrite = true;
|
||||
const um = { content: `${oldPrompt}${SEP}tail`, modelId: "m" };
|
||||
const um = { content: "tail", modelId: "m" };
|
||||
const proxiedUm = new Proxy(um, {
|
||||
set(t, p, v) {
|
||||
if (p === "content" && failNextWrite) { failNextWrite = false; throw new Error("transient"); }
|
||||
t[p] = v; return true;
|
||||
},
|
||||
});
|
||||
const body = {
|
||||
systemPrompt: oldPrompt,
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: proxiedUm }],
|
||||
},
|
||||
};
|
||||
const body = { conversationState: { history: [{ userInputMessage: proxiedUm }] } };
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
// first pass rolled back atomically — nothing half-applied
|
||||
expect(body.systemPrompt).toBe(oldPrompt);
|
||||
expect(um.content).toBe(`${oldPrompt}${SEP}tail`);
|
||||
// nothing half-applied
|
||||
expect(um.content).toBe("tail");
|
||||
expect(body.systemPrompt).toBeUndefined();
|
||||
// retry converges
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}`);
|
||||
expect(um.content).toBe(`${oldPrompt}${SEP}${P1}${SEP}tail`);
|
||||
});
|
||||
|
||||
it("kiro rolls back systemPrompt when user content write fails (atomicity)", () => {
|
||||
const oldPrompt = "OLD";
|
||||
const body = {
|
||||
systemPrompt: oldPrompt,
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: Object.freeze({ content: `${oldPrompt}${SEP}tail`, modelId: "m" }) }],
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(oldPrompt);
|
||||
expect(um.content).toBe(`tail${SEP}${P1}`);
|
||||
});
|
||||
|
||||
it("kiro shape gate: stray conversationState without history/currentMessage does not hijack chat body", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }], systemPrompt: "", conversationState: {} };
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }], conversationState: {} };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.messages[0].content).toBe(`hello${SEP}${P1}`);
|
||||
});
|
||||
@@ -409,15 +392,14 @@ describe("system-inject regression fixes", () => {
|
||||
expect(body.instructions).toBe(`You are RULE follower${SEP}RULE`);
|
||||
});
|
||||
|
||||
it("kiro empty-old prepend fires when prompt appears mid-tail only", () => {
|
||||
it("substring occurrence does not suppress kiro injection", () => {
|
||||
const body = {
|
||||
systemPrompt: "",
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: `some ${P1} here`, modelId: "m" } }],
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`${P1}${SEP}some ${P1} here`);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`some ${P1} here${SEP}${P1}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const SUPPORTED = [
|
||||
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
|
||||
"qoder", "iflow", "ollama", "glm", "glm-cn",
|
||||
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
|
||||
"deepseek", "zed",
|
||||
"deepseek", "opencode-go", "zed", "commandcode",
|
||||
];
|
||||
|
||||
describe("usage dispatch", () => {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Unit tests for the OpenRouter + Vertex (Veo) video adapters.
|
||||
*
|
||||
* Covers:
|
||||
* - registry wiring (videoConfig, video serviceKind, video-kind models)
|
||||
* - OpenRouter: POST to the collection root, GET poll, verbatim passthrough
|
||||
* - Vertex: predictLongRunning body translation, fetchPredictOperation polling,
|
||||
* operation-name round-trip through the job id, response mapping
|
||||
* - xAI default path is unchanged by the adapter hook
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("open-sse/services/tokenRefresh.js", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return { ...actual, refreshTokenByProvider: vi.fn(), refreshVertexToken: vi.fn() };
|
||||
});
|
||||
|
||||
import { handleVideoProxyCore, getVideoConfig } from "open-sse/handlers/videoCore.js";
|
||||
import { refreshVertexToken } from "open-sse/services/tokenRefresh.js";
|
||||
import { PROVIDER_MEDIA, PROVIDER_MODELS } from "open-sse/providers/index.js";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const jsonResponse = (body, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
|
||||
// Vertex operation names are resource paths; the adapter base64url-encodes them.
|
||||
const OPERATION_NAME =
|
||||
"projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/op-abc";
|
||||
const JOB_ID = Buffer.from(OPERATION_NAME, "utf8").toString("base64url");
|
||||
|
||||
describe("registry wiring", () => {
|
||||
it("exposes videoConfig + video serviceKind for openrouter and vertex", () => {
|
||||
expect(getVideoConfig("openrouter").baseUrl).toBe("https://openrouter.ai/api/v1/videos");
|
||||
expect(getVideoConfig("vertex").baseUrl).toBe("https://aiplatform.googleapis.com");
|
||||
expect(PROVIDER_MEDIA.openrouter.serviceKinds).toContain("video");
|
||||
expect(PROVIDER_MEDIA.vertex.serviceKinds).toContain("video");
|
||||
});
|
||||
|
||||
it("registers video-kind models on both providers", () => {
|
||||
const or = PROVIDER_MODELS.openrouter.find((m) => m.id === "google/veo-3.1");
|
||||
const vx = PROVIDER_MODELS.vertex.find((m) => m.id === "veo-3.1-generate-preview");
|
||||
expect(or?.kind).toBe("video");
|
||||
expect(vx?.kind).toBe("video");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openrouter video adapter", () => {
|
||||
beforeEach(() => { global.fetch = vi.fn(); });
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it("POSTs creation to the collection root (no /generations suffix)", async () => {
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse({ id: "job-1", status: "pending" }));
|
||||
|
||||
const raw = '{"model":"google/veo-3.1","prompt":"a paper boat"}';
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "openrouter",
|
||||
action: "generations",
|
||||
rawBody: raw,
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "sk-or-key" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe("https://openrouter.ai/api/v1/videos");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBe(raw); // verbatim
|
||||
expect(init.headers.Authorization).toBe("Bearer sk-or-key");
|
||||
expect(init.headers["HTTP-Referer"]).toBe("https://endpoint-proxy.local");
|
||||
expect(await result.response.json()).toEqual({ id: "job-1", status: "pending" });
|
||||
});
|
||||
|
||||
it("polls GET /videos/{id} and passes the payload through verbatim", async () => {
|
||||
const payload = { id: "job-1", status: "completed", unsigned_urls: ["https://cdn/v.mp4"] };
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse(payload));
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "openrouter",
|
||||
requestId: "job-1",
|
||||
credentials: { apiKey: "sk-or-key" },
|
||||
});
|
||||
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe("https://openrouter.ai/api/v1/videos/job-1");
|
||||
expect(init.method).toBe("GET");
|
||||
expect(await result.response.json()).toEqual(payload);
|
||||
});
|
||||
|
||||
it("rejects unsupported actions before any upstream call (no billable job)", async () => {
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "openrouter",
|
||||
action: "extensions",
|
||||
rawBody: "{}",
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "sk-or-key" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("vertex (veo) video adapter", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
refreshVertexToken.mockReset();
|
||||
});
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
const saJson = JSON.stringify({
|
||||
type: "service_account",
|
||||
client_email: "sa@proj-1.iam.gserviceaccount.com",
|
||||
private_key: "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----\n",
|
||||
project_id: "proj-1",
|
||||
});
|
||||
|
||||
it("translates the create body to predictLongRunning and returns a poll-able job id", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse({ name: OPERATION_NAME }));
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({
|
||||
model: "veo-3.1-generate-preview",
|
||||
prompt: "a neon city",
|
||||
duration: 8,
|
||||
aspect_ratio: "16:9",
|
||||
resolution: "720p",
|
||||
n: 1,
|
||||
}),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview:predictLongRunning"
|
||||
);
|
||||
expect(init.headers.Authorization).toBe("Bearer vertex-tok");
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
instances: [{ prompt: "a neon city" }],
|
||||
parameters: { sampleCount: 1, durationSeconds: 8, aspectRatio: "16:9", resolution: "720p" },
|
||||
});
|
||||
|
||||
// Response is mapped onto the async-job shape clients already poll.
|
||||
expect(await result.response.json()).toEqual({
|
||||
id: JOB_ID,
|
||||
request_id: JOB_ID,
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a data-URL image onto the Vertex image instance", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse({ name: OPERATION_NAME }));
|
||||
|
||||
await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({
|
||||
model: "veo-3.1-generate-preview",
|
||||
prompt: "animate this",
|
||||
image: "data:image/png;base64,AAAB",
|
||||
}),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
expect(JSON.parse(global.fetch.mock.calls[0][1].body).instances[0].image).toEqual({
|
||||
bytesBase64Encoded: "AAAB",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
it("polls via fetchPredictOperation and maps a completed operation", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
name: OPERATION_NAME,
|
||||
done: true,
|
||||
response: { videos: [{ gcsUri: "gs://bucket/v.mp4", mimeType: "video/mp4" }] },
|
||||
})
|
||||
);
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
requestId: JOB_ID,
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview:fetchPredictOperation"
|
||||
);
|
||||
expect(init.method).toBe("POST"); // Vertex polls with POST, not GET
|
||||
expect(JSON.parse(init.body)).toEqual({ operationName: OPERATION_NAME });
|
||||
|
||||
expect(await result.response.json()).toEqual({
|
||||
id: JOB_ID,
|
||||
request_id: JOB_ID,
|
||||
status: "completed",
|
||||
video: { url: "gs://bucket/v.mp4", b64_json: null, mime_type: "video/mp4" },
|
||||
videos: [{ url: "gs://bucket/v.mp4", b64_json: null, mime_type: "video/mp4" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a failed operation to status failed", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
jsonResponse({ name: OPERATION_NAME, done: true, error: { code: 3, message: "bad prompt" } })
|
||||
);
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
requestId: JOB_ID,
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
const body = await result.response.json();
|
||||
expect(body.status).toBe("failed");
|
||||
expect(body.error.message).toBe("bad prompt");
|
||||
});
|
||||
|
||||
it("rejects missing project id and raw API keys before any upstream call", async () => {
|
||||
const noProject = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({ model: "veo-3.1-generate-preview", prompt: "x" }),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "AIzaRawKey" },
|
||||
});
|
||||
expect(noProject.success).toBe(false);
|
||||
expect(noProject.status).toBe(400);
|
||||
|
||||
const noToken = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({ model: "veo-3.1-generate-preview", prompt: "x" }),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "AIzaRawKey", providerSpecificData: { projectId: "proj-1" } },
|
||||
});
|
||||
expect(noToken.success).toBe(false);
|
||||
expect(noToken.status).toBe(400);
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an invalid job id without calling upstream", async () => {
|
||||
refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" });
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
requestId: Buffer.from("not-an-operation", "utf8").toString("base64url"),
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A base64url id decodes to arbitrary bytes, so a crafted one used to splice a
|
||||
// path traversal into the fetch URL while the Authorization header stayed on.
|
||||
it("rejects job ids that decode outside the projects/…/operations/ shape", async () => {
|
||||
refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" });
|
||||
const jid = (s) => Buffer.from(s, "utf8").toString("base64url");
|
||||
|
||||
for (const id of [
|
||||
jid("../../evil"),
|
||||
jid("projects/p/locations/l/publishers/google/models/m/operations/../../x"),
|
||||
jid("../../evil/operations/op"),
|
||||
"!!!not-base64!!!",
|
||||
`${JOB_ID}=`,
|
||||
`${JOB_ID}\n`,
|
||||
]) {
|
||||
const result = await handleVideoProxyCore({ provider: "vertex", requestId: id, credentials: { apiKey: saJson } });
|
||||
expect(result.status).toBe(400);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a model id carrying path separators", async () => {
|
||||
refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" });
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({ model: "../../evil", prompt: "x" }),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
expect(result.status).toBe(400);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ vi.mock("@/sse/services/auth.js", () => authMocks);
|
||||
vi.mock("@/sse/services/tokenRefresh.js", () => tokenMocks);
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getSettings: vi.fn(async () => ({ requireApiKey: false })),
|
||||
getProviderConnectionById: vi.fn(async () => ({ id: "conn-5", provider: "xai" })),
|
||||
getComboByName: vi.fn(async () => null),
|
||||
getModelAliases: vi.fn(async () => ({})),
|
||||
getProviderNodes: vi.fn(async () => []),
|
||||
|
||||
Loaded 50 of 208 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user