Merge remote-tracking branch 'upstream/master'
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { createRequire } from "module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
ANTIGRAVITY_IDE_VERSION,
|
||||
applyAntigravityIdeVersionOverride,
|
||||
} = require("../../src/mitm/antigravityIdeVersion.js");
|
||||
|
||||
const CURRENT_VERSION = "2.11.0";
|
||||
|
||||
function makeRequest(metadata = { ideName: "antigravity", ideVersion: CURRENT_VERSION }) {
|
||||
const bodyBuffer = Buffer.from(JSON.stringify({ metadata, request: { contents: [] } }));
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(bodyBuffer.length),
|
||||
"user-agent": `antigravity/${CURRENT_VERSION}`,
|
||||
};
|
||||
return { bodyBuffer, headers };
|
||||
}
|
||||
|
||||
describe("Antigravity IDE version override", () => {
|
||||
it("preserves catalog request identity and bytes", () => {
|
||||
const { bodyBuffer, headers } = makeRequest();
|
||||
|
||||
const result = applyAntigravityIdeVersionOverride(
|
||||
bodyBuffer,
|
||||
headers,
|
||||
"/v1internal:fetchAvailableModels"
|
||||
);
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.bodyBuffer).toBe(bodyBuffer);
|
||||
expect(result.headers).toBe(headers);
|
||||
expect(result.headers["user-agent"]).toBe(`antigravity/${CURRENT_VERSION}`);
|
||||
expect(JSON.parse(result.bodyBuffer.toString()).metadata.ideVersion).toBe(CURRENT_VERSION);
|
||||
expect(result.headers["content-length"]).toBe(String(bodyBuffer.length));
|
||||
});
|
||||
|
||||
it.each([":generateContent", ":streamGenerateContent"])(
|
||||
"rewrites identity for %s requests",
|
||||
(endpoint) => {
|
||||
const { bodyBuffer, headers } = makeRequest();
|
||||
|
||||
const result = applyAntigravityIdeVersionOverride(
|
||||
bodyBuffer,
|
||||
headers,
|
||||
`/v1internal/models/gemini-3.7-flash-tiered${endpoint}`
|
||||
);
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(result.bodyBuffer).not.toBe(bodyBuffer);
|
||||
expect(result.headers["user-agent"]).toBe(`antigravity/${ANTIGRAVITY_IDE_VERSION}`);
|
||||
expect(JSON.parse(result.bodyBuffer.toString()).metadata.ideVersion).toBe(ANTIGRAVITY_IDE_VERSION);
|
||||
}
|
||||
);
|
||||
|
||||
it("preserves malformed non-generation request content byte-for-byte", () => {
|
||||
const bodyBuffer = Buffer.from([0xff, 0x00, 0x7b, 0x6e, 0x6f, 0x74, 0x2d, 0x6a, 0x73, 0x6f, 0x6e]);
|
||||
const headers = {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-length": String(bodyBuffer.length),
|
||||
"user-agent": `antigravity/${CURRENT_VERSION}`,
|
||||
};
|
||||
|
||||
const result = applyAntigravityIdeVersionOverride(bodyBuffer, headers, "/v1internal:loadCodeAssist");
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.bodyBuffer).toBe(bodyBuffer);
|
||||
expect(result.headers).toBe(headers);
|
||||
});
|
||||
|
||||
it("does not synthesize missing Antigravity identity", () => {
|
||||
const bodyBuffer = Buffer.from(JSON.stringify({ metadata: {}, request: { contents: [] } }));
|
||||
const headers = { "content-type": "application/json", "content-length": String(bodyBuffer.length) };
|
||||
|
||||
const result = applyAntigravityIdeVersionOverride(
|
||||
bodyBuffer,
|
||||
headers,
|
||||
"/v1internal/models/gemini-3.7-flash-tiered:generateContent"
|
||||
);
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.bodyBuffer).toBe(bodyBuffer);
|
||||
expect(result.headers).toEqual(headers);
|
||||
expect(result.headers).not.toHaveProperty("user-agent");
|
||||
expect(JSON.parse(result.bodyBuffer.toString()).metadata).not.toHaveProperty("ideVersion");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const proxyAwareFetch = vi.fn(async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => url.includes(":loadCodeAssist")
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
|
||||
: {
|
||||
models: {
|
||||
"gemini-3.8-flash-high": {
|
||||
displayName: "Gemini 3.8 Flash (High)",
|
||||
quotaInfo: { remainingFraction: 0.85, resetTime: "2026-08-25T12:00:00Z" },
|
||||
},
|
||||
"gemini-3.8-flash-medium": {
|
||||
displayName: "Gemini 3.8 Flash (Medium)",
|
||||
quotaInfo: { remainingFraction: 0.6, resetTime: "2026-08-25T12:00:00Z" },
|
||||
},
|
||||
"gemini-3.8-flash-low": {
|
||||
displayName: "Gemini 3.8 Flash (Low)",
|
||||
quotaInfo: { remainingFraction: 0.35, resetTime: "2026-08-25T12:00:00Z" },
|
||||
},
|
||||
"internal-model": {
|
||||
displayName: "Internal",
|
||||
isInternal: true,
|
||||
quotaInfo: { remainingFraction: 0.5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
text: async () => "{}",
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch,
|
||||
}));
|
||||
|
||||
describe("Antigravity quota tracker: Gemini 3.8 Flash usage bars", () => {
|
||||
beforeEach(() => proxyAwareFetch.mockClear());
|
||||
|
||||
it("returns Gemini 3.8 Flash tier quotas so the dashboard can render usage bars", async () => {
|
||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
||||
|
||||
const usage = await getAntigravityUsage("access-token", {});
|
||||
|
||||
expect(usage.quotas["gemini-3.8-flash-high"]).toMatchObject({
|
||||
used: 150,
|
||||
total: 1000,
|
||||
remainingPercentage: 85,
|
||||
displayName: "Gemini 3.8 Flash (High)",
|
||||
});
|
||||
expect(usage.quotas["gemini-3.8-flash-medium"]).toMatchObject({
|
||||
used: 400,
|
||||
total: 1000,
|
||||
remainingPercentage: 60,
|
||||
});
|
||||
expect(usage.quotas["gemini-3.8-flash-low"]).toMatchObject({
|
||||
used: 650,
|
||||
total: 1000,
|
||||
remainingPercentage: 35,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ vi.mock("open-sse/services/usage/google.js", () => ({
|
||||
}));
|
||||
vi.mock("@/sse/utils/logger.js", () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn() }));
|
||||
|
||||
const { getAntigravityQuotaCache, handleAntigravityQuotaError, refreshAntigravityQuota } = await import("@/sse/services/antigravityQuota.js");
|
||||
const { getAntigravityQuotaCache, handleAntigravityQuotaError, refreshAntigravityQuota, clearAntigravityStrikes } = await import("@/sse/services/antigravityQuota.js");
|
||||
const { getProviderCredentials } = await import("@/sse/services/auth.js");
|
||||
|
||||
const MODEL = "claude-opus-4-6-thinking";
|
||||
@@ -169,4 +169,143 @@ describe("Antigravity quota-aware routing", () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("strike-breaks after 3 optimistic 429s within 60s and cache-blocks 15 minutes", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
|
||||
// Quota API lies: reports 90% remaining while generation keeps 429ing.
|
||||
mocks.getAntigravityUsage.mockResolvedValue({ quotas: {
|
||||
[MODEL]: { remainingPercentage: 90, resetAt: FUTURE_RESET },
|
||||
} });
|
||||
|
||||
try {
|
||||
const first = await handleAntigravityQuotaError("ag-strike", 429, MODEL, "token", {});
|
||||
expect(first).toBeNull();
|
||||
const second = await handleAntigravityQuotaError("ag-strike", 429, MODEL, "token", {});
|
||||
expect(second).toBeNull();
|
||||
|
||||
const third = await handleAntigravityQuotaError("ag-strike", 429, MODEL, "token", {});
|
||||
expect(third).toBe(Date.parse("2026-08-26T00:15:00.000Z"));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("resets the strike counter when strikes fall outside the 60s window", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
|
||||
mocks.getAntigravityUsage.mockResolvedValue({ quotas: {
|
||||
[MODEL]: { remainingPercentage: 90, resetAt: FUTURE_RESET },
|
||||
} });
|
||||
|
||||
try {
|
||||
await handleAntigravityQuotaError("ag-window", 429, MODEL, "token", {});
|
||||
await handleAntigravityQuotaError("ag-window", 429, MODEL, "token", {});
|
||||
await vi.advanceTimersByTimeAsync(61_000);
|
||||
const result = await handleAntigravityQuotaError("ag-window", 429, MODEL, "token", {});
|
||||
expect(result).toBeNull(); // window lapsed — counter restarted at 1
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("strike-breaks when the quota API is unavailable (null reading) too", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
|
||||
// Quota endpoint failing/forbidden => quota unknown. Strikes must still count.
|
||||
mocks.getAntigravityUsage.mockResolvedValue({ message: "forbidden", quotas: {} });
|
||||
|
||||
try {
|
||||
await handleAntigravityQuotaError("ag-null", 429, MODEL, "token", {});
|
||||
await handleAntigravityQuotaError("ag-null", 429, MODEL, "token", {});
|
||||
const third = await handleAntigravityQuotaError("ag-null", 429, MODEL, "token", {});
|
||||
expect(third).toBe(Date.parse("2026-08-26T00:15:00.000Z"));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists the block into the shared cache so the next request skips the pair", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
|
||||
mocks.getAntigravityUsage.mockResolvedValue({ quotas: {
|
||||
[MODEL]: { remainingPercentage: 90, resetAt: FUTURE_RESET },
|
||||
} });
|
||||
|
||||
try {
|
||||
await handleAntigravityQuotaError("ag-persist", 429, MODEL, "token", {});
|
||||
await handleAntigravityQuotaError("ag-persist", 429, MODEL, "token", {});
|
||||
await handleAntigravityQuotaError("ag-persist", 429, MODEL, "token", {});
|
||||
|
||||
// The synthesized entry must be visible to the auth pre-filter reading
|
||||
// the shared cache — and must survive an optimistic upstream refresh.
|
||||
const cached = getAntigravityQuotaCache().get("ag-persist")?.[MODEL];
|
||||
expect(cached).toMatchObject({ remainingPercentage: 0 });
|
||||
expect(Date.parse(cached.resetAt)).toBe(Date.parse("2026-08-26T00:15:00.000Z"));
|
||||
|
||||
await refreshAntigravityQuota("ag-persist", "token", {});
|
||||
expect(getAntigravityQuotaCache().get("ag-persist")?.[MODEL]).toMatchObject({
|
||||
remainingPercentage: 0,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears strike state and the synthesized block after a successful request", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
|
||||
mocks.getAntigravityUsage.mockResolvedValue({ quotas: {
|
||||
[MODEL]: { remainingPercentage: 90, resetAt: FUTURE_RESET },
|
||||
} });
|
||||
|
||||
try {
|
||||
await handleAntigravityQuotaError("ag-clear", 429, MODEL, "token", {});
|
||||
await handleAntigravityQuotaError("ag-clear", 429, MODEL, "token", {});
|
||||
await handleAntigravityQuotaError("ag-clear", 429, MODEL, "token", {});
|
||||
expect(getAntigravityQuotaCache().get("ag-clear")?.[MODEL]?.remainingPercentage).toBe(0);
|
||||
|
||||
clearAntigravityStrikes("ag-clear", MODEL);
|
||||
// Synthesized entry gone — pair selectable again immediately.
|
||||
expect(getAntigravityQuotaCache().get("ag-clear")?.[MODEL]).toBeUndefined();
|
||||
|
||||
// Two more 429s do NOT inherit earlier strikes: no block on the third-in-episode.
|
||||
await handleAntigravityQuotaError("ag-clear", 429, MODEL, "token", {});
|
||||
await expect(handleAntigravityQuotaError("ag-clear", 429, MODEL, "token", {})).resolves.toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("anchors the window at the first strike: 3 strikes spread over 90s do not trip", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
|
||||
mocks.getAntigravityUsage.mockResolvedValue({ quotas: {
|
||||
[MODEL]: { remainingPercentage: 90, resetAt: FUTURE_RESET },
|
||||
} });
|
||||
|
||||
try {
|
||||
await handleAntigravityQuotaError("ag-anchor", 429, MODEL, "token", {}); // t=0
|
||||
await vi.advanceTimersByTimeAsync(45_000);
|
||||
await handleAntigravityQuotaError("ag-anchor", 429, MODEL, "token", {}); // t=45s
|
||||
await vi.advanceTimersByTimeAsync(45_000);
|
||||
// t=90s: within 60s of strike #2 but outside 60s of strike #1 => new window
|
||||
const result = await handleAntigravityQuotaError("ag-anchor", 429, MODEL, "token", {});
|
||||
expect(result).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the optimistic path null without touching the quota cache", async () => {
|
||||
mocks.getAntigravityUsage.mockResolvedValue({ quotas: {
|
||||
[MODEL]: { remainingPercentage: 90, resetAt: FUTURE_RESET },
|
||||
} });
|
||||
|
||||
await expect(handleAntigravityQuotaError("ag-optimistic", 429, MODEL, "token", {}))
|
||||
.resolves.toBeNull();
|
||||
// Optimistic reading must NOT poison the shared cache (auth pre-filter
|
||||
// treats cached 0% as exhausted).
|
||||
expect(getAntigravityQuotaCache().get("ag-optimistic")?.[MODEL]?.remainingPercentage).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,13 +69,13 @@ describe("antigravity computeRetryDelay hook (D3)", () => {
|
||||
|
||||
it("registry uses the daily IDE cloudcode host and user agent", () => {
|
||||
expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]);
|
||||
expect(antigravity.transport.headers["User-Agent"]).toBe("antigravity/ide/2.1.1 darwin/arm64");
|
||||
expect(antigravity.transport.headers["User-Agent"]).toBe("antigravity/ide/2.11.0 darwin/arm64");
|
||||
});
|
||||
|
||||
it("buildHeaders matches official IDE stream headers", () => {
|
||||
ag._lastSessionId = "sess-123";
|
||||
const h = ag.buildHeaders({ accessToken: "tok" }, true);
|
||||
expect(h["User-Agent"]).toBe("antigravity/ide/2.1.1 darwin/arm64");
|
||||
expect(h["User-Agent"]).toBe("antigravity/ide/2.11.0 darwin/arm64");
|
||||
expect(h["Content-Type"]).toBe("application/json");
|
||||
expect(h["Authorization"]).toBe("Bearer tok");
|
||||
expect(h).not.toHaveProperty("X-Machine-Session-Id");
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("Antigravity usage headers", () => {
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(2);
|
||||
for (const [, options] of proxyAwareFetch.mock.calls) {
|
||||
expect(options.headers["User-Agent"]).toBe("antigravity/ide/2.1.1 darwin/arm64");
|
||||
expect(options.headers["User-Agent"]).toBe("antigravity/ide/2.11.0 darwin/arm64");
|
||||
expect(options.headers).not.toHaveProperty("x-request-source");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,6 +32,13 @@ describe("getCapabilitiesForModel", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reports Claude Fable 5.1 as a permanent adaptive-thinking model", () => {
|
||||
expect(getCapabilitiesForModel("claude", "claude-fable-5-1")).toMatchObject({
|
||||
...claudeSonnet5Expected,
|
||||
thinkingCanDisable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports Kiro Claude Opus 4.8 as a 1M context model", () => {
|
||||
expect(getCapabilitiesForModel("kiro", "claude-opus-4.8").contextWindow).toBe(1000000);
|
||||
expect(getCapabilitiesForModel("kiro", "anthropic/claude-opus-4.8").contextWindow).toBe(1000000);
|
||||
|
||||
@@ -7,9 +7,14 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cloakClaudeTools, decloakStreamChunk } from "../../open-sse/utils/claudeCloaking.js";
|
||||
import { applyCloaking, cloakClaudeTools, decloakStreamChunk } from "../../open-sse/utils/claudeCloaking.js";
|
||||
import { CLAUDE_TOOL_SUFFIX } from "../../open-sse/config/appConstants.js";
|
||||
|
||||
it("advertises a Claude Code version accepted by Fable 5.1", () => {
|
||||
const body = applyCloaking({ messages: [] }, "sk-ant-oat-test", "session-id");
|
||||
expect(body.system[0].text).toMatch(/^x-anthropic-billing-header: cc_version=2.1.258\./);
|
||||
});
|
||||
|
||||
describe("cloakClaudeTools", () => {
|
||||
const baseBody = {
|
||||
tools: [{ name: "todo_write", description: "write todos", input_schema: { type: "object", properties: {} } }],
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// A combo that mixes providers leaks foreign block shapes into the Claude history.
|
||||
// Anthropic validates server_tool_use ids against ^srvtoolu_[a-zA-Z0-9_]+$ and 400s
|
||||
// the whole request when a provider (e.g. z.ai/glm) emits OpenAI-style call_ ids.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeClaudePassthrough } from "../../open-sse/translator/formats/claude.js";
|
||||
|
||||
const glmServerToolUse = () => ({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "searching" },
|
||||
{ type: "server_tool_use", id: "call_50b82aba1b754d82a4408a53", name: "analyze_image", input: {} },
|
||||
],
|
||||
});
|
||||
|
||||
describe("normalizeClaudePassthrough — foreign server_tool_use ids", () => {
|
||||
it("drops a server_tool_use block whose id is not an srvtoolu_ id", () => {
|
||||
const out = normalizeClaudePassthrough({ messages: [glmServerToolUse()] });
|
||||
expect(out.messages[0].content).toEqual([{ type: "text", text: "searching" }]);
|
||||
});
|
||||
|
||||
it("drops the paired tool_result so no orphan reference is left behind", () => {
|
||||
const out = normalizeClaudePassthrough({
|
||||
messages: [
|
||||
glmServerToolUse(),
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "call_50b82aba1b754d82a4408a53", content: "boom" },
|
||||
{ type: "text", text: "keep me" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out.messages[1].content).toEqual([{ type: "text", text: "keep me" }]);
|
||||
});
|
||||
|
||||
it("keeps a well-formed Anthropic server_tool_use block", () => {
|
||||
const block = { type: "server_tool_use", id: "srvtoolu_01EUi6RNgHntbStfCjgLyLzz", name: "web_search", input: {} };
|
||||
const out = normalizeClaudePassthrough({ messages: [{ role: "assistant", content: [block] }] });
|
||||
expect(out.messages[0].content).toEqual([block]);
|
||||
});
|
||||
|
||||
it("keeps regular tool_use blocks, whatever their id looks like", () => {
|
||||
const block = { type: "tool_use", id: "call_942248714fef4a9abb8e8eff", name: "Bash", input: { command: "ls" } };
|
||||
const out = normalizeClaudePassthrough({ messages: [{ role: "assistant", content: [block] }] });
|
||||
expect(out.messages[0].content).toEqual([block]);
|
||||
});
|
||||
|
||||
it("drops a message whose blocks were all stripped instead of padding it with empty text", () => {
|
||||
const out = normalizeClaudePassthrough({
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
{ role: "assistant", content: [{ type: "server_tool_use", id: "call_x", name: "analyze_image", input: {} }] },
|
||||
{ role: "user", content: [{ type: "text", text: "bye" }] },
|
||||
],
|
||||
});
|
||||
expect(out.messages).toHaveLength(2);
|
||||
expect(out.messages.map(m => m.role)).toEqual(["user", "user"]);
|
||||
});
|
||||
|
||||
it("strips empty text blocks a client put in the history (Anthropic 400s them)", () => {
|
||||
const out = normalizeClaudePassthrough({
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text: "real" }, { type: "text", text: "" }] }],
|
||||
});
|
||||
expect(out.messages[0].content).toEqual([{ type: "text", text: "real" }]);
|
||||
});
|
||||
|
||||
it("drops a message whose content is a single empty text block", () => {
|
||||
const out = normalizeClaudePassthrough({
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "" }] },
|
||||
],
|
||||
});
|
||||
expect(out.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops a message whose string content is empty", () => {
|
||||
const out = normalizeClaudePassthrough({
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "" },
|
||||
],
|
||||
});
|
||||
expect(out.messages).toHaveLength(1);
|
||||
expect(out.messages[0].content).toBe("hello");
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => {
|
||||
headers["Anthropic-Version"] === "2023-06-01" ||
|
||||
headers["anthropic-version"] === "2023-06-01";
|
||||
expect(hasVersion).toBe(true);
|
||||
expect(headers["User-Agent"]).toBe("claude-cli/2.1.258 (external, sdk-cli)");
|
||||
});
|
||||
|
||||
it("includes heavy-agent beta flags for claude-opus-5", () => {
|
||||
|
||||
@@ -125,6 +125,25 @@ describe("dashboard guard public LLM API access", () => {
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("rejects remote /responses rewrite without API key", async () => {
|
||||
const response = await proxy(request("/responses", { host: "router.example.com" }));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("allows remote /responses rewrite with a valid API key", async () => {
|
||||
mocks.validateApiKey.mockResolvedValue(true);
|
||||
|
||||
const response = await proxy(request("/responses", {
|
||||
host: "router.example.com",
|
||||
authorization: "Bearer sk-valid",
|
||||
}));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid");
|
||||
});
|
||||
|
||||
it("allows remote codex rewrite with valid API key", async () => {
|
||||
mocks.validateApiKey.mockResolvedValue(true);
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Regression: Anthropic rejects a tool that carries BOTH `defer_loading: true`
|
||||
* and `cache_control`:
|
||||
*
|
||||
* [400] Tool 'mcp__x__y' cannot both defer_loading=true cache_control set.
|
||||
* Tools defer_loading cannot use prompt caching.
|
||||
*
|
||||
* 9router anchors the 1h cache breakpoint on the LAST tool of the array with
|
||||
* no guard. Clients that speak MCP (Claude Code) put deferred tools at the
|
||||
* tail, so the anchor lands exactly on a tool that cannot be cached and the
|
||||
* request 400s before combo fallback can try the next hop.
|
||||
*
|
||||
* The fix anchors on the last tool that is NOT deferred, so prompt caching is
|
||||
* kept for the tools that can use it instead of being dropped wholesale.
|
||||
*
|
||||
* See: #3567.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { anchorClaudeCache } from "../../open-sse/translator/formats/claude.js";
|
||||
import { prepareClaudeRequest } from "../../open-sse/translator/formats/claude.js";
|
||||
|
||||
const tool = (name, extra = {}) => ({
|
||||
name,
|
||||
description: "t",
|
||||
input_schema: { type: "object", properties: {} },
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("defer_loading tools never carry cache_control (#3567)", () => {
|
||||
it("anchorClaudeCache: anchor moves to the last non-deferred tool", () => {
|
||||
const body = anchorClaudeCache({
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [tool("a"), tool("b"), tool("mcp__x__y", { defer_loading: true })],
|
||||
});
|
||||
|
||||
expect(body.tools[2].cache_control).toBeUndefined();
|
||||
expect(body.tools[1].cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
|
||||
expect(body.tools[0].cache_control).toBeUndefined();
|
||||
});
|
||||
|
||||
it("anchorClaudeCache: no tool is cached when every tool is deferred", () => {
|
||||
const body = anchorClaudeCache({
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [tool("mcp__a", { defer_loading: true }), tool("mcp__b", { defer_loading: true })],
|
||||
});
|
||||
|
||||
expect(body.tools.every(t => t.cache_control === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("anchorClaudeCache: strips a cache_control the client put on a deferred tool", () => {
|
||||
const body = anchorClaudeCache({
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [tool("mcp__a", { defer_loading: true, cache_control: { type: "ephemeral" } })],
|
||||
});
|
||||
|
||||
expect(body.tools[0].cache_control).toBeUndefined();
|
||||
});
|
||||
|
||||
it("anchorClaudeCache: unchanged behaviour when no tool is deferred", () => {
|
||||
const body = anchorClaudeCache({
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [tool("a"), tool("b")],
|
||||
});
|
||||
|
||||
expect(body.tools[1].cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
|
||||
expect(body.tools[0].cache_control).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prepareClaudeRequest: deferred tail tool does not get the anchor", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-sonnet-4.5",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [tool("a"), tool("mcp__x__y", { defer_loading: true })],
|
||||
}, "claude");
|
||||
|
||||
expect(out.tools).toHaveLength(2);
|
||||
expect(out.tools[1].cache_control).toBeUndefined();
|
||||
expect(out.tools[1].defer_loading).toBe(true);
|
||||
expect(out.tools[0].cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
|
||||
});
|
||||
});
|
||||
@@ -55,12 +55,17 @@ describe("OpenCode Free endpoint routing", () => {
|
||||
expect(opencode.transport.format).toBeUndefined();
|
||||
const muse = opencode.models.find((m) => m.id === MUSE);
|
||||
expect(muse?.targetFormat).toBe("openai-responses");
|
||||
const muse13 = opencode.models.find((m) => m.id === "muse-spark-1.3-contributor-free");
|
||||
expect(muse13?.targetFormat).toBe("openai-responses");
|
||||
});
|
||||
|
||||
it("routes Muse Spark to /responses and every other model to /chat/completions", () => {
|
||||
const executor = new OpenCodeExecutor();
|
||||
expect(executor.buildUrl(MUSE)).toBe("https://opencode.ai/zen/v1/responses");
|
||||
expect(executor.buildUrl(`${MUSE}(xhigh)`)).toBe("https://opencode.ai/zen/v1/responses");
|
||||
expect(executor.buildUrl("muse-spark-1.3-contributor-free")).toBe("https://opencode.ai/zen/v1/responses");
|
||||
expect(executor.buildUrl("muse-spark-1.4-contributor-free")).toBe("https://opencode.ai/zen/v1/responses");
|
||||
expect(executor.buildUrl("muse-spark-2.0-contributor-free(xhigh)")).toBe("https://opencode.ai/zen/v1/responses");
|
||||
expect(executor.buildUrl("big-pickle")).toBe("https://opencode.ai/zen/v1/chat/completions");
|
||||
expect(executor.buildUrl("hy3-free")).toBe("https://opencode.ai/zen/v1/chat/completions");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// sever the DB import chain (usageDb -> @/lib/db/*) — not under test
|
||||
vi.mock("@/lib/usageDb.js", () => ({
|
||||
saveRequestUsage: vi.fn(),
|
||||
appendRequestLog: vi.fn(),
|
||||
saveRequestDetail: vi.fn(),
|
||||
}));
|
||||
// and the stream/console-coloring utils that drag in the translator graph
|
||||
vi.mock("../../open-sse/utils/stream.js", () => ({
|
||||
COLORS: {},
|
||||
formatSSE: vi.fn(),
|
||||
}));
|
||||
|
||||
import { extractUsageFromResponse } from "../../open-sse/handlers/chatCore/requestDetail.js";
|
||||
import { canonicalizeUsage } from "../../open-sse/utils/usageTracking.js";
|
||||
|
||||
// The three real-world usage shapes and how extractUsageFromResponse() must
|
||||
// surface their cache-read count so canonicalizeUsage() produces a correct
|
||||
// cached_tokens. Regression for non-streaming codex/Responses traffic, where
|
||||
// cache reads were silently dropped and usage recorded cached_tokens: 0.
|
||||
describe("extractUsageFromResponse cache surfaces", () => {
|
||||
it("surfaces OpenAI Responses input_tokens_details.cached_tokens", () => {
|
||||
// codex / /v1/responses shape: prompt is cache-INCLUSIVE
|
||||
const out = extractUsageFromResponse({
|
||||
usage: { input_tokens: 25421, output_tokens: 5, total_tokens: 25426,
|
||||
input_tokens_details: { cached_tokens: 24320 } },
|
||||
});
|
||||
expect(out.cached_tokens).toBe(24320);
|
||||
expect(out.prompt_tokens).toBe(25421);
|
||||
expect(out.cache_read_input_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("canonicalizes Responses usage without double-counting the prompt", () => {
|
||||
const extracted = extractUsageFromResponse({
|
||||
usage: { input_tokens: 25421, output_tokens: 5,
|
||||
input_tokens_details: { cached_tokens: 24320 } },
|
||||
});
|
||||
const out = canonicalizeUsage(extracted);
|
||||
// inclusive prompt passes through unchanged; cache reported as subset
|
||||
expect(out.prompt_tokens).toBe(25421);
|
||||
expect(out.cached_tokens).toBe(24320);
|
||||
expect(out.total_tokens).toBe(25426);
|
||||
expect(out.cache_creation_input_tokens).toBe(0);
|
||||
});
|
||||
|
||||
it("still folds genuine Claude exclusive cache (regression)", () => {
|
||||
const extracted = extractUsageFromResponse({
|
||||
usage: { input_tokens: 100, output_tokens: 50,
|
||||
cache_read_input_tokens: 200, cache_creation_input_tokens: 30 },
|
||||
});
|
||||
expect(extracted.cached_tokens).toBeUndefined();
|
||||
const out = canonicalizeUsage(extracted);
|
||||
expect(out.prompt_tokens).toBe(330); // 100 + 200 + 30
|
||||
expect(out.cached_tokens).toBe(200);
|
||||
expect(out.cache_creation_input_tokens).toBe(30);
|
||||
});
|
||||
|
||||
it("surfaces flat cached_tokens on the OpenAI branch (SSE-to-JSON shape)", () => {
|
||||
const out = extractUsageFromResponse({
|
||||
usage: { prompt_tokens: 300, completion_tokens: 10, cached_tokens: 240 },
|
||||
});
|
||||
expect(out.cached_tokens).toBe(240);
|
||||
});
|
||||
|
||||
it("keeps nested prompt_tokens_details.cached_tokens working (regression)", () => {
|
||||
const out = extractUsageFromResponse({
|
||||
usage: { prompt_tokens: 300, completion_tokens: 10,
|
||||
prompt_tokens_details: { cached_tokens: 240 } },
|
||||
});
|
||||
expect(out.cached_tokens).toBe(240);
|
||||
expect(canonicalizeUsage(out).cached_tokens).toBe(240);
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ vi.mock("@/sse/utils/logger.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/utils/ssrfGuard.js", () => ({
|
||||
assertPublicUrl: vi.fn(),
|
||||
assertPublicUrlResolved: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
import { handleFetch } from "@/sse/handlers/fetch.js";
|
||||
@@ -85,7 +85,40 @@ describe("web fetch account state", () => {
|
||||
expect(mocks.clearAccountError).toHaveBeenCalledWith(
|
||||
"jina-connection",
|
||||
expect.objectContaining({ connectionName: "Jina Test" }),
|
||||
"webfetch:jina-reader",
|
||||
);
|
||||
expect(mocks.getProviderCredentials).toHaveBeenCalledWith(
|
||||
"jina-reader",
|
||||
expect.any(Set),
|
||||
"webfetch:jina-reader",
|
||||
);
|
||||
expect(mocks.markAccountUnavailable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes provider failures to web fetch", async () => {
|
||||
mocks.handleFetchCore.mockResolvedValue({
|
||||
success: false,
|
||||
status: 429,
|
||||
error: "quota exceeded",
|
||||
});
|
||||
mocks.markAccountUnavailable.mockResolvedValue({ shouldFallback: false });
|
||||
|
||||
const response = await handleFetch(new Request("http://localhost/v1/web/fetch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "jina-reader",
|
||||
url: "https://example.com/article",
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(mocks.markAccountUnavailable).toHaveBeenCalledWith(
|
||||
"jina-connection",
|
||||
429,
|
||||
"quota exceeded",
|
||||
"jina-reader",
|
||||
"webfetch:jina-reader",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
import antigravityRegistry from "../../open-sse/providers/registry/antigravity.js";
|
||||
import geminiRegistry from "../../open-sse/providers/registry/gemini.js";
|
||||
import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
|
||||
|
||||
describe("Gemini 3.8 Flash Support & Config", () => {
|
||||
it("registers gemini-3.8-flash tiered models in antigravity provider registry", () => {
|
||||
const agIds = antigravityRegistry.models.map(m => m.id);
|
||||
expect(agIds).toContain("gemini-3.8-flash-high");
|
||||
expect(agIds).toContain("gemini-3.8-flash-medium");
|
||||
expect(agIds).toContain("gemini-3.8-flash-low");
|
||||
expect(agIds).toContain("gemini-3.8-flash");
|
||||
});
|
||||
|
||||
it("registers gemini-3.8-flash in gemini provider registry", () => {
|
||||
const geminiIds = geminiRegistry.models.map(m => m.id);
|
||||
expect(geminiIds).toContain("gemini-3.8-flash");
|
||||
});
|
||||
|
||||
it("resolves capabilities correctly for gemini-3.8 models with official limits", () => {
|
||||
const caps = getCapabilitiesForModel("antigravity", "gemini-3.8-flash-high");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("gemini-level");
|
||||
expect(caps.contextWindow).toBe(1048576);
|
||||
expect(caps.maxOutput).toBe(65536);
|
||||
});
|
||||
|
||||
it("defines pricing matching gemini-3.7-flash baseline", () => {
|
||||
expect(MODEL_PRICING["gemini-3.8-flash"]).toEqual(MODEL_PRICING["gemini-3.7-flash"]);
|
||||
expect(MODEL_PRICING["gemini-3.8-flash-high"]).toEqual(MODEL_PRICING["gemini-3.7-flash-high"]);
|
||||
expect(MODEL_PRICING["gemini-3.8-flash-medium"]).toEqual(MODEL_PRICING["gemini-3.7-flash-medium"]);
|
||||
expect(MODEL_PRICING["gemini-3.8-flash-low"]).toEqual(MODEL_PRICING["gemini-3.7-flash-low"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { getModelUpstreamId } from "../../open-sse/config/providerModels.js";
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
|
||||
import { applyThinking, stripThinkingSuffix } from "../../open-sse/translator/concerns/thinkingUnified.js";
|
||||
import gemini from "../../open-sse/providers/registry/gemini.js";
|
||||
import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
|
||||
import { MITM_TOOLS } from "../../src/shared/constants/cliTools.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const mitmConfig = require("../../src/mitm/config.js");
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Gemini 3.8 Antigravity tiers", () => {
|
||||
it.each(["high", "medium", "low"])(
|
||||
"maps the %s tier to the shared upstream model with matching thinking level",
|
||||
(tier) => {
|
||||
const publicModel = `gemini-3.8-flash-${tier}`;
|
||||
const upstreamModel = getModelUpstreamId("ag", publicModel);
|
||||
const body = {
|
||||
model: stripThinkingSuffix(upstreamModel),
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "hello" }] }],
|
||||
generationConfig: {},
|
||||
},
|
||||
};
|
||||
|
||||
applyThinking("antigravity", upstreamModel, body, "antigravity");
|
||||
const finalBody = new AntigravityExecutor().transformRequest(
|
||||
publicModel,
|
||||
body,
|
||||
true,
|
||||
{ projectId: "project", connectionId: "connection" }
|
||||
);
|
||||
|
||||
expect(upstreamModel).toBe(`gemini-3.8-flash-${tier}(${tier})`);
|
||||
expect(finalBody.model).toBe(`gemini-3.8-flash-${tier}`);
|
||||
expect(finalBody.request.generationConfig.thinkingConfig).toEqual({
|
||||
thinkingLevel: tier,
|
||||
includeThoughts: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("Gemini 3.8 MITM model extraction", () => {
|
||||
it.each(["high", "medium", "low"])("extracts the %s thinking tier for gemini-3.8-flash-tiered", (tier) => {
|
||||
const body = Buffer.from(JSON.stringify({
|
||||
request: { generationConfig: { thinkingConfig: { thinkingLevel: tier } } },
|
||||
}));
|
||||
|
||||
expect(mitmConfig.extractModel(
|
||||
"/v1internal/models/gemini-3.8-flash-tiered:streamGenerateContent",
|
||||
body
|
||||
)).toBe(`gemini-3.8-flash-${tier}`);
|
||||
});
|
||||
|
||||
it("defaults invalid or missing thinking levels to medium", () => {
|
||||
const body = Buffer.from(JSON.stringify({
|
||||
request: { generationConfig: { thinkingConfig: { thinkingLevel: "unknown" } } },
|
||||
}));
|
||||
|
||||
expect(mitmConfig.extractModel(
|
||||
"/v1internal/models/gemini-3.8-flash-tiered:streamGenerateContent",
|
||||
body
|
||||
)).toBe("gemini-3.8-flash-medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemini 3.8 MITM tools and catalog", () => {
|
||||
it("includes gemini-3.8-flash tiers in MITM_TOOLS defaultModels", () => {
|
||||
const defaultModelIds = MITM_TOOLS.antigravity.defaultModels.map((m) => m.id);
|
||||
expect(defaultModelIds).toContain("gemini-3.8-flash-high");
|
||||
expect(defaultModelIds).toContain("gemini-3.8-flash-medium");
|
||||
expect(defaultModelIds).toContain("gemini-3.8-flash-low");
|
||||
});
|
||||
|
||||
it("exposes the direct Gemini 3.8 API models and pricing", () => {
|
||||
const ids = gemini.models.map((model) => model.id);
|
||||
expect(ids).toContain("gemini-3.8-flash");
|
||||
expect(MODEL_PRICING["gemini-3.8-flash"]).toMatchObject({ input: 1.5, output: 7.5 });
|
||||
});
|
||||
|
||||
it("keeps the standalone CLI Antigravity catalog synchronized", () => {
|
||||
const source = readFileSync(join(here, "../../cli/src/cli/menus/providers.js"), "utf8");
|
||||
const agCatalog = source.match(/\n ag: \[([\s\S]*?)\n \],/)?.[1] || "";
|
||||
|
||||
expect(agCatalog).toContain("gemini-3.8-flash-high");
|
||||
expect(agCatalog).toContain("gemini-3.8-flash-medium");
|
||||
expect(agCatalog).toContain("gemini-3.8-flash-low");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
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 MODELS_URL = "https://api.groq.com/openai/v1/models";
|
||||
|
||||
function response(body, { status = 200, headers = {} } = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
const RATE_LIMIT_HEADERS = {
|
||||
"x-ratelimit-limit-requests": "14400",
|
||||
"x-ratelimit-remaining-requests": "14370",
|
||||
"x-ratelimit-reset-requests": "2m59.56s",
|
||||
"x-ratelimit-limit-tokens": "18000",
|
||||
"x-ratelimit-remaining-tokens": "17997",
|
||||
"x-ratelimit-reset-tokens": "7.66s",
|
||||
};
|
||||
|
||||
describe("groq registry usage flags", () => {
|
||||
it("is listed for apikey quota dashboard", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("groq");
|
||||
expect(USAGE_APIKEY_PROVIDERS).toContain("groq");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsageForProvider(groq)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("GETs the models endpoint with Bearer apiKey", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
response({ data: [] }, { headers: RATE_LIMIT_HEADERS }),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "groq",
|
||||
apiKey: "gsk_test",
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("Groq");
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = proxyAwareFetch.mock.calls[0];
|
||||
expect(url).toBe(MODELS_URL);
|
||||
expect(opts.method).toBe("GET");
|
||||
expect(opts.headers.Authorization).toBe("Bearer gsk_test");
|
||||
});
|
||||
|
||||
it("parses request + token rate-limit headers into quotas", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
response({ data: [] }, { headers: RATE_LIMIT_HEADERS }),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "groq",
|
||||
apiKey: "gsk_test",
|
||||
});
|
||||
|
||||
expect(usage.quotas["Requests"]).toMatchObject({
|
||||
used: 30,
|
||||
total: 14400,
|
||||
unlimited: false,
|
||||
});
|
||||
expect(usage.quotas["Tokens"]).toMatchObject({
|
||||
used: 3,
|
||||
total: 18000,
|
||||
unlimited: false,
|
||||
});
|
||||
// Duration-string reset headers resolve to a real future ISO timestamp.
|
||||
expect(new Date(usage.quotas["Requests"].resetAt).getTime()).toBeGreaterThan(Date.now());
|
||||
expect(new Date(usage.quotas["Tokens"].resetAt).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("returns a soft message (not an error) when no rate-limit headers are present", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(response({ data: [] }));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "groq",
|
||||
apiKey: "gsk_test",
|
||||
});
|
||||
|
||||
expect(usage.error).toBeUndefined();
|
||||
expect(usage.message).toMatch(/no rate-limit data/i);
|
||||
expect(usage.quotas).toEqual({});
|
||||
});
|
||||
|
||||
it("returns message on missing key / 401", async () => {
|
||||
const missing = await getUsageForProvider({ provider: "groq" });
|
||||
expect(missing.message).toMatch(/api key/i);
|
||||
expect(proxyAwareFetch).not.toHaveBeenCalled();
|
||||
|
||||
proxyAwareFetch.mockResolvedValueOnce(response({ error: "invalid_api_key" }, { status: 401 }));
|
||||
const auth = await getUsageForProvider({ provider: "groq", apiKey: "bad" });
|
||||
expect(auth.message).toMatch(/auth|key/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuotaData(groq)", () => {
|
||||
it("forwards used/total/resetAt for the dashboard table", () => {
|
||||
const rows = parseQuotaData("groq", {
|
||||
plan: "Groq",
|
||||
quotas: {
|
||||
Requests: { used: 30, total: 14400, resetAt: "2026-01-01T00:03:00.000Z" },
|
||||
Tokens: { used: 3, total: 18000, resetAt: "2026-01-01T00:00:08.000Z" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({ name: "Requests", used: 30, total: 14400 });
|
||||
expect(rows[1]).toMatchObject({ name: "Tokens", used: 3, total: 18000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { intercept } = require("../../src/mitm/handlers/kiro.js");
|
||||
|
||||
const MODEL = "offline-test-model";
|
||||
|
||||
function makeResponseCollector() {
|
||||
const chunks = [];
|
||||
const response = {
|
||||
headersSent: false,
|
||||
statusCode: undefined,
|
||||
ended: false,
|
||||
writeHead(statusCode) {
|
||||
this.statusCode = statusCode;
|
||||
this.headersSent = true;
|
||||
return this;
|
||||
},
|
||||
write(chunk) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
return true;
|
||||
},
|
||||
end(chunk) {
|
||||
if (chunk !== undefined) chunks.push(Buffer.from(chunk));
|
||||
this.ended = true;
|
||||
this.headersSent = true;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
|
||||
return { response, chunks };
|
||||
}
|
||||
|
||||
async function captureOpenAIRequest(request) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const { response, chunks } = makeResponseCollector();
|
||||
let captured;
|
||||
|
||||
const fetchMock = vi.fn(async (url, init) => {
|
||||
captured = { url: String(url), init };
|
||||
return new Response("data: [DONE]\n\n", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
});
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
try {
|
||||
await intercept(
|
||||
{ headers: { "x-test": "kiro-image-forwarding" } },
|
||||
response,
|
||||
Buffer.from(JSON.stringify(request)),
|
||||
MODEL,
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(captured.url.endsWith("/v1/chat/completions")).toBe(true);
|
||||
expect(captured.init.method).toBe("POST");
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.ended).toBe(true);
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
|
||||
return JSON.parse(captured.init.body);
|
||||
} finally {
|
||||
if (originalFetch === undefined) delete globalThis.fetch;
|
||||
else globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
function image(format, bytes) {
|
||||
return { format, source: { bytes } };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Kiro MITM inline image forwarding", () => {
|
||||
it("forwards text and inline images as OpenAI image_url content parts", async () => {
|
||||
const outboundBody = await captureOpenAIRequest({
|
||||
conversationState: {
|
||||
history: [],
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
content: " Describe this ",
|
||||
images: [image("png", "aGVsbG8=")],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(outboundBody).toMatchObject({
|
||||
model: MODEL,
|
||||
stream: true,
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Describe this" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,aGVsbG8=" } },
|
||||
],
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it("emits an image-only user turn even when tool results are present", async () => {
|
||||
const outboundBody = await captureOpenAIRequest({
|
||||
conversationState: {
|
||||
history: [],
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
content: " ",
|
||||
images: [image("jpg", "LzlqLzQ=")],
|
||||
userInputMessageContext: {
|
||||
toolResults: [
|
||||
{ toolUseId: "tool-a", content: [{ text: "first" }, { text: "result" }] },
|
||||
{ toolUseId: "tool-b", content: [{ text: "second" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(outboundBody.messages).toEqual([
|
||||
{ role: "tool", tool_call_id: "tool-a", content: "first\nresult" },
|
||||
{ role: "tool", tool_call_id: "tool-b", content: "second" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image_url", image_url: { url: "data:image/jpeg;base64,LzlqLzQ=" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps historical images on their original user turn", async () => {
|
||||
const outboundBody = await captureOpenAIRequest({
|
||||
conversationState: {
|
||||
history: [
|
||||
{
|
||||
userInputMessage: {
|
||||
content: " historical evidence ",
|
||||
images: [image("jpeg", "anBlZw=="), image("webp", "d2VicA==")],
|
||||
},
|
||||
},
|
||||
{ assistantResponseMessage: { content: "assistant reply" } },
|
||||
],
|
||||
currentMessage: { userInputMessage: { content: "current question" } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(outboundBody.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "historical evidence" },
|
||||
{ type: "image_url", image_url: { url: "data:image/jpeg;base64,anBlZw==" } },
|
||||
{ type: "image_url", image_url: { url: "data:image/webp;base64,d2VicA==" } },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: "assistant reply" },
|
||||
{ role: "user", content: "current question" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores malformed and unsupported image entries without changing text-only behavior", async () => {
|
||||
const outboundBody = await captureOpenAIRequest({
|
||||
conversationState: {
|
||||
history: [],
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
content: " keep this text ",
|
||||
images: [
|
||||
image("svg", "ignored"),
|
||||
image("PNG", "ignored"),
|
||||
image("jpeg", ""),
|
||||
{ format: "gif", source: { bytes: 42 } },
|
||||
null,
|
||||
[],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(outboundBody.messages).toEqual([
|
||||
{ role: "user", content: "keep this text" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Regression: Claude Code appends `[1m]` to the model name when the
|
||||
* 1M-context beta is on, so `/v1/messages` arrives with
|
||||
* `model: "claude-opus-5[1m]"`. Nothing in 9router knows about the marker:
|
||||
* it matches no combo, no alias and no `provider/model` pair, so the request
|
||||
* is rejected at model resolution and the client reports
|
||||
*
|
||||
* There's an issue with the selected model (claude-opus-5[1m]).
|
||||
* It may not exist or you may not have access to it.
|
||||
*
|
||||
* The capability itself rides in the `anthropic-beta` header, which is
|
||||
* forwarded untouched, so stripping the marker is all that is needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { stripModelContextMarker } from "../../open-sse/utils/modelMarkers.js";
|
||||
|
||||
describe("model context marker", () => {
|
||||
it("strips the [1m] marker and reports it", () => {
|
||||
expect(stripModelContextMarker("claude-opus-5[1m]")).toEqual({
|
||||
model: "claude-opus-5",
|
||||
contextMarker: "1m",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips it from a provider-prefixed model too", () => {
|
||||
expect(stripModelContextMarker("cc/claude-sonnet-4.5[1m]")).toEqual({
|
||||
model: "cc/claude-sonnet-4.5",
|
||||
contextMarker: "1m",
|
||||
});
|
||||
});
|
||||
|
||||
it("is case insensitive", () => {
|
||||
expect(stripModelContextMarker("claude-opus-5[1M]").model).toBe("claude-opus-5");
|
||||
});
|
||||
|
||||
it("leaves a plain model untouched", () => {
|
||||
expect(stripModelContextMarker("claude-opus-5")).toEqual({
|
||||
model: "claude-opus-5",
|
||||
contextMarker: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("only strips a trailing marker, never one inside the name", () => {
|
||||
expect(stripModelContextMarker("weird[1m]name")).toEqual({
|
||||
model: "weird[1m]name",
|
||||
contextMarker: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("tolerates a non-string model", () => {
|
||||
expect(stripModelContextMarker(undefined)).toEqual({ model: undefined, contextMarker: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import REGISTRY from "../../open-sse/providers/registry/index.js";
|
||||
import { handleFetchCore } from "../../open-sse/handlers/fetch/index.js";
|
||||
import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers.js";
|
||||
|
||||
const CONFIG = {
|
||||
baseUrl: "https://ollama.com/api/web_fetch",
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Ollama Cloud web fetch provider", () => {
|
||||
it("registers web fetch on the existing Ollama Cloud connection", () => {
|
||||
const entry = REGISTRY.find((candidate) => candidate.id === "ollama");
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
category: "freeTier",
|
||||
serviceKinds: ["llm", "webFetch"],
|
||||
fetchConfig: {
|
||||
baseUrl: "https://ollama.com/api/web_fetch",
|
||||
method: "POST",
|
||||
authHeader: "bearer",
|
||||
formats: ["markdown"],
|
||||
},
|
||||
});
|
||||
expect(AI_PROVIDERS.ollama?.fetchConfig).toEqual(entry.fetchConfig);
|
||||
expect(getProvidersByKind("webFetch").map((provider) => provider.id)).toContain("ollama");
|
||||
});
|
||||
|
||||
it("calls Ollama with bearer auth and normalizes the response", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
||||
title: "Example Domain",
|
||||
content: "Hello from Ollama",
|
||||
links: ["https://www.iana.org/domains/example"],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})));
|
||||
|
||||
const result = await handleFetchCore({
|
||||
url: "https://example.com",
|
||||
format: "markdown",
|
||||
maxCharacters: 5,
|
||||
provider: "ollama",
|
||||
providerConfig: CONFIG,
|
||||
credentials: { apiKey: "ollama-test-key" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [requestUrl, init] = global.fetch.mock.calls[0];
|
||||
expect(requestUrl).toBe("https://ollama.com/api/web_fetch");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toEqual({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer ollama-test-key",
|
||||
});
|
||||
expect(JSON.parse(init.body)).toEqual({ url: "https://example.com" });
|
||||
expect(result.data).toMatchObject({
|
||||
provider: "ollama",
|
||||
url: "https://example.com",
|
||||
title: "Example Domain",
|
||||
content: { format: "markdown", text: "Hello", length: 5 },
|
||||
links: ["https://www.iana.org/domains/example"],
|
||||
usage: { fetch_cost_usd: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the upstream status and error message", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(
|
||||
JSON.stringify({ error: "invalid API key" }),
|
||||
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||
)));
|
||||
|
||||
const result = await handleFetchCore({
|
||||
url: "https://example.com",
|
||||
provider: "ollama",
|
||||
providerConfig: CONFIG,
|
||||
credentials: { apiKey: "bad-key" },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
status: 401,
|
||||
error: "invalid API key",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an empty successful response as an upstream error", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})));
|
||||
|
||||
const result = await handleFetchCore({
|
||||
url: "https://example.com",
|
||||
provider: "ollama",
|
||||
providerConfig: CONFIG,
|
||||
credentials: { apiKey: "ollama-test-key" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
status: 502,
|
||||
error: "Ollama returned an empty or invalid web fetch response",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js";
|
||||
import { PROVIDER_MODELS, getModelTargetFormat } from "../../open-sse/config/providerModels.js";
|
||||
import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js";
|
||||
@@ -19,25 +19,31 @@ const input = [{
|
||||
describe("OpenCode Free Muse Spark thinking", () => {
|
||||
it("advertises reasoning and the requested model limits", () => {
|
||||
expect(PROVIDER_MODELS.oc?.some((model) => model.id === MODEL)).toBe(true);
|
||||
expect(getCapabilitiesForModel(PROVIDER, MODEL)).toMatchObject({
|
||||
reasoning: true,
|
||||
thinkingFormat: "openai",
|
||||
contextWindow: 1048576,
|
||||
maxOutput: 131072,
|
||||
});
|
||||
expect(getCapabilitiesForModel(PROVIDER, `oc/${MODEL}`)).toMatchObject({
|
||||
reasoning: true,
|
||||
contextWindow: 1048576,
|
||||
maxOutput: 131072,
|
||||
});
|
||||
expect(getThinkingLevels(PROVIDER, MODEL)).toEqual([
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
]);
|
||||
expect(PROVIDER_MODELS.oc?.some((model) => model.id === "muse-spark-1.3-contributor-free")).toBe(true);
|
||||
for (const m of [MODEL, "muse-spark-1.3-contributor-free", "muse-spark-1.4-contributor-free", "muse-spark-2.0-contributor-free"]) {
|
||||
expect(getCapabilitiesForModel(PROVIDER, m)).toMatchObject({
|
||||
reasoning: true,
|
||||
thinkingFormat: "openai",
|
||||
contextWindow: 1048576,
|
||||
maxOutput: 131072,
|
||||
});
|
||||
expect(getCapabilitiesForModel(PROVIDER, `oc/${m}`)).toMatchObject({
|
||||
reasoning: true,
|
||||
contextWindow: 1048576,
|
||||
maxOutput: 131072,
|
||||
});
|
||||
expect(getThinkingLevels(PROVIDER, m)).toEqual([
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
]);
|
||||
expect(getModelTargetFormat("oc", m)).toBe(FORMATS.OPENAI_RESPONSES);
|
||||
expect(getModelTargetFormat("opencode", m)).toBe(FORMATS.OPENAI_RESPONSES);
|
||||
expect(getModelTargetFormat("openrouter", m)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps max to xhigh and emits the Responses reasoning shape", () => {
|
||||
@@ -91,4 +97,39 @@ describe("OpenCode Free Muse Spark thinking", () => {
|
||||
expect(out.max_output_tokens).toBe(131072);
|
||||
expect(out.max_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes muse-spark-1.3-contributor-free and future Muse Spark models to Responses API", () => {
|
||||
const executor = new OpenCodeExecutor();
|
||||
const futureModel = "muse-spark-1.4-contributor-free";
|
||||
|
||||
for (const m of ["muse-spark-1.3-contributor-free", futureModel]) {
|
||||
expect(executor.buildUrl(m)).toBe("https://opencode.ai/zen/v1/responses");
|
||||
expect(executor.buildUrl(`${m}(high)`)).toBe("https://opencode.ai/zen/v1/responses");
|
||||
expect(getModelTargetFormat("oc", m)).toBe("openai-responses");
|
||||
|
||||
const body = {
|
||||
model: `oc/${m}`,
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
reasoning_effort: "high",
|
||||
max_tokens: 2048,
|
||||
};
|
||||
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
m,
|
||||
body,
|
||||
true,
|
||||
{},
|
||||
PROVIDER,
|
||||
);
|
||||
const out = executor.transformRequest(m, translated, true, {
|
||||
connectionId: "opencode-muse-spark-13-test",
|
||||
});
|
||||
|
||||
expect(out.reasoning).toEqual({ effort: "high", summary: "auto" });
|
||||
expect(out.max_output_tokens).toBe(2048);
|
||||
expect(out.max_tokens).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
STATUS_FILTER_OPTIONS,
|
||||
getConnectionStatus,
|
||||
matchesStatusFilter,
|
||||
} from "@/app/(dashboard)/dashboard/providers/utils.js";
|
||||
|
||||
describe("providers status filter", () => {
|
||||
it("exposes all/active/inactive/none options", () => {
|
||||
expect(STATUS_FILTER_OPTIONS.map((o) => o.value)).toEqual([
|
||||
"all",
|
||||
"active",
|
||||
"inactive",
|
||||
"none",
|
||||
]);
|
||||
});
|
||||
|
||||
it("classifies a provider with no connections as none", () => {
|
||||
expect(getConnectionStatus({ total: 0, allDisabled: false })).toBe("none");
|
||||
});
|
||||
|
||||
it("classifies a provider whose only connections are disabled as inactive", () => {
|
||||
expect(getConnectionStatus({ total: 2, allDisabled: true })).toBe(
|
||||
"inactive",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies a provider with at least one enabled connection as active", () => {
|
||||
expect(getConnectionStatus({ total: 1, allDisabled: false })).toBe(
|
||||
"active",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats noAuth providers as active even with no stored connection", () => {
|
||||
expect(getConnectionStatus({ total: 0, allDisabled: false }, true)).toBe(
|
||||
"active",
|
||||
);
|
||||
});
|
||||
|
||||
it("matchesStatusFilter always passes for 'all'", () => {
|
||||
expect(matchesStatusFilter("all", { total: 0, allDisabled: false })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("matchesStatusFilter compares against the derived status", () => {
|
||||
const disabledStats = { total: 3, allDisabled: true };
|
||||
expect(matchesStatusFilter("inactive", disabledStats)).toBe(true);
|
||||
expect(matchesStatusFilter("active", disabledStats)).toBe(false);
|
||||
expect(matchesStatusFilter("none", disabledStats)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Regression coverage for #3714: the SSRF guard's literal-hostname/IP checks
|
||||
// matched specific textual representations rather than the underlying address,
|
||||
// so a different (but equivalent) representation slipped through. Each case
|
||||
// below is a bypass the issue reported, or one found while fixing it.
|
||||
|
||||
const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn() }));
|
||||
vi.mock("node:dns", () => ({
|
||||
default: { promises: { lookup: lookupMock } },
|
||||
promises: { lookup: lookupMock },
|
||||
}));
|
||||
|
||||
const { assertPublicUrl, assertPublicUrlResolved, fetchPublic } = await import("../../src/shared/utils/ssrfGuard.js");
|
||||
|
||||
describe("assertPublicUrl: literal hostname/IP bypasses from #3714", () => {
|
||||
it("blocks a trailing-dot FQDN the same as the bare hostname", () => {
|
||||
expect(() => assertPublicUrl("http://localhost/")).toThrow();
|
||||
expect(() => assertPublicUrl("http://localhost./")).toThrow();
|
||||
expect(() => assertPublicUrl("http://LOCALHOST./")).toThrow();
|
||||
});
|
||||
|
||||
it("blocks IPv4-mapped IPv6 loopback regardless of which textual form the URL parser picks", () => {
|
||||
// WHATWG URL parsing normalizes dotted-decimal IPv4-in-IPv6 to hex form —
|
||||
// the original regex only matched the dotted form.
|
||||
expect(() => assertPublicUrl("http://[::ffff:127.0.0.1]/")).toThrow();
|
||||
expect(() => assertPublicUrl("http://[::ffff:7f00:1]/")).toThrow(); // hex form directly
|
||||
expect(() => assertPublicUrl("http://[0000::ffff:127.0.0.1]/")).toThrow();
|
||||
});
|
||||
|
||||
it("blocks IPv4-mapped IPv6 cloud metadata address (169.254.169.254)", () => {
|
||||
expect(() => assertPublicUrl("http://[::ffff:169.254.169.254]/")).toThrow();
|
||||
expect(() => assertPublicUrl("http://[::ffff:a9fe:a9fe]/")).toThrow(); // hex form
|
||||
});
|
||||
|
||||
it("blocks other loopback/private/link-local/ULA IPv6 forms", () => {
|
||||
for (const url of [
|
||||
"http://[::1]/",
|
||||
"http://[::127.0.0.1]/",
|
||||
"http://[fe80::1]/",
|
||||
"http://[fc00::1]/",
|
||||
"http://[fd12:3456::1]/",
|
||||
"http://[64:ff9b::127.0.0.1]/", // NAT64 well-known prefix embedding a private IPv4
|
||||
]) {
|
||||
expect(() => assertPublicUrl(url), url).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks alternate IPv4 literal encodings (already normalized by the URL parser)", () => {
|
||||
for (const url of ["http://127.1/", "http://0177.0.0.1/", "http://2130706433/", "http://0x7f.0.0.1/"]) {
|
||||
expect(() => assertPublicUrl(url), url).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("still allows public hosts, including public IPv6", () => {
|
||||
expect(() => assertPublicUrl("https://api.openai.com/v1/models")).not.toThrow();
|
||||
expect(() => assertPublicUrl("http://8.8.8.8/")).not.toThrow();
|
||||
expect(() => assertPublicUrl("https://[2001:4860:4860::8888]/")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertPublicUrlResolved: DNS-resolving hostname bypass from #3714", () => {
|
||||
beforeEach(() => lookupMock.mockReset());
|
||||
|
||||
it("blocks a hostname that resolves to a loopback address (nip.io-style wildcard DNS)", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]);
|
||||
await expect(assertPublicUrlResolved("http://127.0.0.1.nip.io/")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("blocks a hostname that resolves to a private range even if one of several addresses is public", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "203.0.113.5", family: 4 }, { address: "10.0.0.5", family: 4 }]);
|
||||
await expect(assertPublicUrlResolved("http://multi-a-record.example.test/")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("blocks a hostname that resolves to a blocked IPv6 address", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "::1", family: 6 }]);
|
||||
await expect(assertPublicUrlResolved("http://evil.example.test/")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("allows a hostname that resolves only to public addresses", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
await expect(assertPublicUrlResolved("https://example.com/")).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
// Note: "fails open when the DNS lookup itself rejects" is deliberately not
|
||||
// covered here as a vitest case — a mocked node:dns rejection in this file
|
||||
// trips what looks like a vitest 4 / rolldown-transform source-map bug
|
||||
// (the same rejection pattern passes in an isolated single-function probe
|
||||
// module; only reproduces once mocked against this larger file). Verified
|
||||
// instead with a standalone Node script exercising the real try/catch
|
||||
// directly: dns.promises.lookup rejecting resolves assertPublicUrlResolved
|
||||
// with undefined rather than propagating, exactly as the source shows.
|
||||
|
||||
it("skips DNS lookup entirely for literal IP hosts (already covered by the sync check)", async () => {
|
||||
await expect(assertPublicUrlResolved("http://127.0.0.1/")).rejects.toThrow();
|
||||
expect(lookupMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchPublic: redirect-target re-validation from #3714", () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
beforeEach(() => {
|
||||
lookupMock.mockReset();
|
||||
// These tests exercise redirect-chasing, not DNS behavior — give every
|
||||
// synthetic *.example.test hostname a default public resolution so it
|
||||
// doesn't get blocked (or throw on an unmocked undefined return) before
|
||||
// reaching the redirect logic under test.
|
||||
lookupMock.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
|
||||
});
|
||||
|
||||
it("blocks a redirect from a validated public URL to an internal target", async () => {
|
||||
global.fetch = vi.fn(async () => new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: "http://127.0.0.1:9999/admin" },
|
||||
}));
|
||||
|
||||
await expect(fetchPublic("https://public.example.test/redirect")).rejects.toThrow();
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1); // never followed the redirect
|
||||
});
|
||||
|
||||
it("follows a redirect chain of public URLs, re-validating each hop", async () => {
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { Location: "https://hop2.example.test/" } }))
|
||||
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
||||
|
||||
const res = await fetchPublic("https://hop1.example.test/");
|
||||
expect(await res.text()).toBe("ok");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(global.fetch.mock.calls[1][0]).toBe("https://hop2.example.test/");
|
||||
});
|
||||
|
||||
it("bounds the redirect chain instead of looping forever", async () => {
|
||||
global.fetch = vi.fn(async (url) => new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: url === "https://loop.example.test/a" ? "https://loop.example.test/b" : "https://loop.example.test/a" },
|
||||
}));
|
||||
|
||||
await expect(fetchPublic("https://loop.example.test/a", {}, { maxRedirects: 3 })).rejects.toThrow(/too many redirects/i);
|
||||
});
|
||||
|
||||
it("rejects the initial URL before ever calling fetch", async () => {
|
||||
global.fetch = vi.fn();
|
||||
await expect(fetchPublic("http://127.0.0.1/steal")).rejects.toThrow();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildModelsList: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/app/api/v1/models/route.js", () => ({
|
||||
buildModelsList: mocks.buildModelsList,
|
||||
}));
|
||||
|
||||
const { GET } = await import("../../src/app/api/v1/models/[...model]/route.js");
|
||||
|
||||
const chatModel = {
|
||||
id: "cc/claude-sonnet-5",
|
||||
object: "model",
|
||||
owned_by: "cc",
|
||||
context_length: 1_000_000,
|
||||
};
|
||||
|
||||
function params(model) {
|
||||
return { params: Promise.resolve({ model }) };
|
||||
}
|
||||
|
||||
describe("GET /v1/models/{id}", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("retrieves a provider-prefixed model ID split across URL path segments", async () => {
|
||||
mocks.buildModelsList.mockResolvedValue([chatModel]);
|
||||
|
||||
const response = await GET(new Request("https://router.test/v1/models/cc/claude-sonnet-5"), params(["cc", "claude-sonnet-5"]));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual(chatModel);
|
||||
expect(mocks.buildModelsList).toHaveBeenCalledWith(["llm"]);
|
||||
});
|
||||
|
||||
it("also handles a decoded slash in a single catch-all segment", async () => {
|
||||
mocks.buildModelsList.mockResolvedValue([chatModel]);
|
||||
|
||||
const response = await GET(new Request("https://router.test/v1/models/cc%2Fclaude-sonnet-5"), params(["cc/claude-sonnet-5"]));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual(chatModel);
|
||||
});
|
||||
|
||||
it("keeps capability-list routes unchanged", async () => {
|
||||
const imageModel = { id: "image/gpt-image-1", object: "model", owned_by: "image" };
|
||||
mocks.buildModelsList.mockResolvedValue([imageModel]);
|
||||
|
||||
const response = await GET(new Request("https://router.test/v1/models/image"), params(["image"]));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ object: "list", data: [imageModel] });
|
||||
expect(mocks.buildModelsList).toHaveBeenCalledWith(["image"]);
|
||||
});
|
||||
|
||||
it("returns an OpenAI-style model_not_found response for an unknown model", async () => {
|
||||
mocks.buildModelsList.mockResolvedValue([chatModel]);
|
||||
|
||||
const response = await GET(new Request("https://router.test/v1/models/cc/missing-model"), params(["cc", "missing-model"]));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toMatchObject({
|
||||
type: "invalid_request_error",
|
||||
code: "model_not_found",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user