Merge origin/master (v0.5.69) into gitea/new_feature
This commit is contained in:
89
tests/unit/antigravity-ide-version.test.js
Normal file
89
tests/unit/antigravity-ide-version.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
61
tests/unit/antigravity-quota-gemini-3.8.test.js
Normal file
61
tests/unit/antigravity-quota-gemini-3.8.test.js
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
311
tests/unit/antigravity-quota-routing.test.js
Normal file
311
tests/unit/antigravity-quota-routing.test.js
Normal file
@@ -0,0 +1,311 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getProviderConnections: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
resolveConnectionProxyConfig: vi.fn(),
|
||||
getAntigravityUsage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getProviderConnections: mocks.getProviderConnections,
|
||||
getSettings: mocks.getSettings,
|
||||
getProxyPools: vi.fn(),
|
||||
validateApiKey: vi.fn(),
|
||||
updateProviderConnection: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/network/connectionProxy", () => ({
|
||||
resolveConnectionProxyConfig: mocks.resolveConnectionProxyConfig,
|
||||
pickProxyPoolId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/shared/constants/providers.js", () => ({
|
||||
FREE_PROVIDERS: {},
|
||||
resolveProviderId: (provider) => provider,
|
||||
}));
|
||||
vi.mock("open-sse/services/usage/google.js", () => ({
|
||||
getAntigravityUsage: mocks.getAntigravityUsage,
|
||||
}));
|
||||
vi.mock("@/sse/utils/logger.js", () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn() }));
|
||||
|
||||
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";
|
||||
const FUTURE_RESET = "2026-09-01T00:00:00.000Z";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getAntigravityQuotaCache().clear();
|
||||
mocks.resolveConnectionProxyConfig.mockResolvedValue({});
|
||||
mocks.getSettings.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("Antigravity quota-aware routing", () => {
|
||||
it("records exhausted upstream quota after 429 and returns its exact reset time", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
|
||||
mocks.getAntigravityUsage.mockResolvedValue({ quotas: {
|
||||
[MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET },
|
||||
} });
|
||||
|
||||
try {
|
||||
await expect(handleAntigravityQuotaError("ag-a", 429, MODEL, "token", {}))
|
||||
.resolves.toBe(Date.parse(FUTURE_RESET));
|
||||
expect(getAntigravityQuotaCache().get("ag-a")[MODEL]).toEqual({
|
||||
remainingPercentage: 0,
|
||||
resetAt: FUTURE_RESET,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips exhausted account/model and selects the next account", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
|
||||
mocks.getProviderConnections.mockResolvedValue([
|
||||
{ id: "ag-a", email: "a@example.com", isActive: true },
|
||||
{ id: "ag-b", email: "b@example.com", isActive: true },
|
||||
]);
|
||||
getAntigravityQuotaCache().set("ag-a", {
|
||||
[MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET },
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(getProviderCredentials("antigravity", null, MODEL)).resolves.toMatchObject({
|
||||
connectionId: "ag-b",
|
||||
connectionName: "b@example.com",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports retry time when every account is cache-blocked", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
|
||||
mocks.getProviderConnections.mockResolvedValue([{ id: "ag-a", email: "a@example.com", isActive: true }]);
|
||||
getAntigravityQuotaCache().set("ag-a", {
|
||||
[MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET },
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(getProviderCredentials("antigravity", null, MODEL)).resolves.toMatchObject({
|
||||
allRateLimited: true,
|
||||
retryAfter: FUTURE_RESET,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets account back into rotation once reset time has passed", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-09-01T00:00:01.000Z"));
|
||||
mocks.getProviderConnections.mockResolvedValue([{ id: "ag-a", email: "a@example.com", isActive: true }]);
|
||||
getAntigravityQuotaCache().set("ag-a", {
|
||||
[MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET },
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(getProviderCredentials("antigravity", null, MODEL)).resolves.toMatchObject({
|
||||
connectionId: "ag-a",
|
||||
connectionName: "a@example.com",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("coalesces concurrent quota refreshes for one account", async () => {
|
||||
let resolveUsage;
|
||||
mocks.getAntigravityUsage.mockReturnValue(new Promise(resolve => { resolveUsage = resolve; }));
|
||||
|
||||
const first = refreshAntigravityQuota("ag-concurrent", "token", {});
|
||||
const second = refreshAntigravityQuota("ag-concurrent", "token", {});
|
||||
resolveUsage({ quotas: { [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET } } });
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET } },
|
||||
{ [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET } },
|
||||
]);
|
||||
expect(mocks.getAntigravityUsage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves strict proxy policy for usage refresh", async () => {
|
||||
mocks.resolveConnectionProxyConfig.mockResolvedValue({ strictProxy: true });
|
||||
mocks.getAntigravityUsage.mockResolvedValue({ quotas: {} });
|
||||
|
||||
await refreshAntigravityQuota("ag-strict-proxy", "token", {});
|
||||
|
||||
expect(mocks.getAntigravityUsage).toHaveBeenCalledWith("token", {}, expect.objectContaining({
|
||||
strictProxy: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps known cache when quota endpoint returns an error payload", async () => {
|
||||
const cached = { [MODEL]: { remainingPercentage: 0, resetAt: FUTURE_RESET } };
|
||||
getAntigravityQuotaCache().set("ag-error-response", cached);
|
||||
mocks.getAntigravityUsage.mockResolvedValue({ message: "Unauthorized", quotas: {} });
|
||||
|
||||
await expect(refreshAntigravityQuota("ag-error-response", "token", {})).resolves.toBeNull();
|
||||
expect(getAntigravityQuotaCache().get("ag-error-response")).toBe(cached);
|
||||
});
|
||||
|
||||
it("throttles failed refresh attempts for 30 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-26T00:00:00.000Z"));
|
||||
mocks.getAntigravityUsage.mockRejectedValue(new Error("usage unavailable"));
|
||||
|
||||
try {
|
||||
await refreshAntigravityQuota("ag-failed-refresh", "token", {});
|
||||
await refreshAntigravityQuota("ag-failed-refresh", "token", {});
|
||||
expect(mocks.getAntigravityUsage).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await refreshAntigravityQuota("ag-failed-refresh", "token", {});
|
||||
expect(mocks.getAntigravityUsage).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { canonicalizeUsage, extractUsage, mergeUsage } from "../../open-sse/utils/usageTracking.js";
|
||||
import { calculateCostFromTokens } from "../../open-sse/providers/pricing.js";
|
||||
import { toOpenAIUsage } from "../../open-sse/translator/concerns/usage.js";
|
||||
import { buildUsage, toOpenAIUsage } from "../../open-sse/translator/concerns/usage.js";
|
||||
|
||||
// Canonical convention (single source of truth for storage + cost):
|
||||
// prompt_tokens = total input INCLUDING cache read + cache creation
|
||||
@@ -49,6 +49,18 @@ describe("canonicalizeUsage", () => {
|
||||
expect(out.reasoning_tokens).toBe(40);
|
||||
});
|
||||
|
||||
it("reads cached_tokens from the nested buildUsage() shape", () => {
|
||||
// buildUsage() only emits cache reads under prompt_tokens_details. The
|
||||
// Responses translator overwrites state.usage with that shape on
|
||||
// response.completed, so a top-level-only read silently drops the cache
|
||||
// count for every Responses provider (codex, grok-cli, ...).
|
||||
const out = canonicalizeUsage(
|
||||
buildUsage({ promptTokens: 330, completionTokens: 50, totalTokens: 380, cachedTokens: 200 })
|
||||
);
|
||||
expect(out.prompt_tokens).toBe(330);
|
||||
expect(out.cached_tokens).toBe(200);
|
||||
});
|
||||
|
||||
it("handles no-cache usage", () => {
|
||||
const out = canonicalizeUsage({ prompt_tokens: 100, completion_tokens: 50 });
|
||||
expect(out.prompt_tokens).toBe(100);
|
||||
|
||||
@@ -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);
|
||||
@@ -55,4 +62,15 @@ 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
*
|
||||
* Tests cover:
|
||||
* - cloakClaudeTools() - tool renaming and forced tool_choice suffixing
|
||||
* - decloakStreamChunk() - restoring tool names in streamed Claude SSE events
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cloakClaudeTools } 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: {} } }],
|
||||
@@ -74,3 +80,44 @@ describe("cloakClaudeTools", () => {
|
||||
expect(toolNameMap).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("decloakStreamChunk", () => {
|
||||
// Cloaked exactly as cloakClaudeTools() does on the request side
|
||||
const toolNameMap = new Map([["run_code" + CLAUDE_TOOL_SUFFIX, "run_code"]]);
|
||||
|
||||
const toolUseStart = (name) => ({
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: { type: "tool_use", id: "toolu_01abc", name, input: {} }
|
||||
});
|
||||
|
||||
it("restores the original name on a tool_use content_block_start", () => {
|
||||
const out = decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), toolNameMap);
|
||||
expect(out.content_block.name).toBe("run_code");
|
||||
});
|
||||
|
||||
it("does not mutate the input chunk", () => {
|
||||
const chunk = toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX);
|
||||
decloakStreamChunk(chunk, toolNameMap);
|
||||
expect(chunk.content_block.name).toBe("run_code" + CLAUDE_TOOL_SUFFIX);
|
||||
});
|
||||
|
||||
it("passes through names the map does not know (e.g. decoy tools)", () => {
|
||||
const chunk = toolUseStart("Bash");
|
||||
expect(decloakStreamChunk(chunk, toolNameMap)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("passes through non-tool_use events unchanged", () => {
|
||||
const textStart = { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } };
|
||||
expect(decloakStreamChunk(textStart, toolNameMap)).toBe(textStart);
|
||||
|
||||
const delta = { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: "{}" } };
|
||||
expect(decloakStreamChunk(delta, toolNameMap)).toBe(delta);
|
||||
});
|
||||
|
||||
it("tolerates null chunks and missing maps (stream flush path)", () => {
|
||||
expect(decloakStreamChunk(null, toolNameMap)).toBeNull();
|
||||
expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), null).content_block.name).toBe("run_code" + CLAUDE_TOOL_SUFFIX);
|
||||
expect(decloakStreamChunk(toolUseStart("run_code" + CLAUDE_TOOL_SUFFIX), new Map()).content_block.name).toBe("run_code" + CLAUDE_TOOL_SUFFIX);
|
||||
});
|
||||
});
|
||||
|
||||
88
tests/unit/claude-foreign-server-tool-use.test.js
Normal file
88
tests/unit/claude-foreign-server-tool-use.test.js
Normal file
@@ -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", () => {
|
||||
@@ -186,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 ────────────────────────────────────────
|
||||
|
||||
76
tests/unit/codex-image-models.test.js
Normal file
76
tests/unit/codex-image-models.test.js
Normal file
@@ -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",
|
||||
|
||||
37
tests/unit/codex-spark-quota-tracking.test.js
Normal file
37
tests/unit/codex-spark-quota-tracking.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
describe("Codex Spark Quota Tracking (#3431)", () => {
|
||||
it("correctly normalizes spark_session and spark_weekly quotas with display labels", () => {
|
||||
const mockCodexUsage = {
|
||||
plan: "team",
|
||||
quotas: {
|
||||
session: { used: 20, total: 100, remaining: 80, resetAt: "2026-08-22T05:00:00.000Z" },
|
||||
weekly: { used: 40, total: 100, remaining: 60, resetAt: "2026-08-28T05:00:00.000Z" },
|
||||
review_session: { used: 0, total: 100, remaining: 100, resetAt: "2026-08-22T05:00:00.000Z" },
|
||||
spark_session: { used: 12, total: 100, remaining: 88, resetAt: "2026-08-22T05:00:00.000Z" },
|
||||
spark_weekly: { used: 25, total: 100, remaining: 75, resetAt: "2026-08-28T05:00:00.000Z" },
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = parseQuotaData("codex", mockCodexUsage);
|
||||
|
||||
const sparkSession = parsed.find((q) => q.name === "Spark (5h)");
|
||||
const sparkWeekly = parsed.find((q) => q.name === "Spark (Weekly)");
|
||||
const session = parsed.find((q) => q.name === "5h");
|
||||
const weekly = parsed.find((q) => q.name === "Weekly");
|
||||
|
||||
expect(sparkSession).toBeDefined();
|
||||
expect(sparkSession.used).toBe(12);
|
||||
expect(sparkSession.remaining).toBe(88);
|
||||
|
||||
expect(sparkWeekly).toBeDefined();
|
||||
expect(sparkWeekly.used).toBe(25);
|
||||
expect(sparkWeekly.remaining).toBe(75);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session.used).toBe(20);
|
||||
expect(weekly).toBeDefined();
|
||||
expect(weekly.used).toBe(40);
|
||||
});
|
||||
});
|
||||
@@ -1,126 +1,192 @@
|
||||
/**
|
||||
* Unit tests for the CommandCode executor early-error peek.
|
||||
*
|
||||
* The upstream emits AI SDK v5 NDJSON over an HTTP 200 stream, so a terminal
|
||||
* `{"type":"error"}` event is invisible to the normal `response.ok` success
|
||||
* check. `peekForUpstreamError` reads the first events before committing the
|
||||
* response: an error event → non-ok Response (fallback can kick in); otherwise
|
||||
* the buffered bytes are re-emitted and streaming proceeds as before.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { peekForUpstreamError } from "../../open-sse/executors/commandcode.js";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
parseCommandCodeError,
|
||||
inspectAndWrapCommandCodeResponse,
|
||||
CommandCodeExecutor,
|
||||
} from "../../open-sse/executors/commandcode.js";
|
||||
import { handleComboChat } from "../../open-sse/services/combo.js";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function ndjsonResponse(lines) {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const line of lines) controller.enqueue(encoder.encode(line + "\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
function createNdjsonStream(lines) {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const line of lines) {
|
||||
controller.enqueue(encoder.encode(typeof line === "string" ? line : JSON.stringify(line) + "\n"));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("commandcode executor — early-error peek", () => {
|
||||
it("returns 502 when the first meaningful event is an error", async () => {
|
||||
const res = await peekForUpstreamError(
|
||||
ndjsonResponse([
|
||||
'{"type":"error","error":{"type":"server_error","message":"Network connection lost."}}',
|
||||
]),
|
||||
"model",
|
||||
);
|
||||
expect(res.status).toBe(502);
|
||||
const body = await res.json();
|
||||
expect(body.error.message).toBe("Network connection lost.");
|
||||
expect(body.error.type).toBe("server_error");
|
||||
});
|
||||
describe("parseCommandCodeError", () => {
|
||||
it("parses user exact error payload with statusCode 503 and isRetryable", () => {
|
||||
const event = {
|
||||
type: "error",
|
||||
error: {
|
||||
type: "server_error",
|
||||
message: "Service temporarily unavailable. Please try again shortly.",
|
||||
statusCode: 503,
|
||||
isRetryable: true,
|
||||
},
|
||||
};
|
||||
const parsed = parseCommandCodeError(event);
|
||||
expect(parsed.statusCode).toBe(503);
|
||||
expect(parsed.message).toBe("Service temporarily unavailable. Please try again shortly.");
|
||||
expect(parsed.type).toBe("server_error");
|
||||
});
|
||||
|
||||
it("detects an error event even when metadata events arrive first", async () => {
|
||||
const res = await peekForUpstreamError(
|
||||
ndjsonResponse([
|
||||
'{"type":"start"}',
|
||||
'{"type":"start-step"}',
|
||||
'{"type":"error","error":{"type":"server_error","message":"Network connection lost."}}',
|
||||
]),
|
||||
"model",
|
||||
);
|
||||
expect(res.status).toBe(502);
|
||||
const body = await res.json();
|
||||
expect(body.error.message).toContain("Network connection lost");
|
||||
});
|
||||
it("handles string error message", () => {
|
||||
const event = {
|
||||
type: "error",
|
||||
message: "Rate limit exceeded. Please wait 30s.",
|
||||
};
|
||||
const parsed = parseCommandCodeError(event);
|
||||
expect(parsed.statusCode).toBe(429);
|
||||
expect(parsed.message).toBe("Rate limit exceeded. Please wait 30s.");
|
||||
});
|
||||
|
||||
it("commits and streams normally when the first meaningful event is content", async () => {
|
||||
const res = await peekForUpstreamError(
|
||||
ndjsonResponse([
|
||||
'{"type":"start"}',
|
||||
'{"type":"text-delta","text":"hi there"}',
|
||||
'{"type":"finish"}',
|
||||
]),
|
||||
"model",
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('"content":"hi there"');
|
||||
expect(text).not.toContain("[CommandCode error:");
|
||||
});
|
||||
|
||||
it("commits when the stream ends without any event", async () => {
|
||||
const res = await peekForUpstreamError(ndjsonResponse([]), "model");
|
||||
expect(res.status).toBe(200);
|
||||
await res.body.cancel();
|
||||
});
|
||||
|
||||
it("commits (does not hang) when no event arrives before the peek timeout", async () => {
|
||||
const stalled = new Response(new ReadableStream({ start() {} }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
const res = await peekForUpstreamError(stalled, "model", { timeoutMs: 50 });
|
||||
expect(res.status).toBe(200);
|
||||
await res.body.cancel();
|
||||
});
|
||||
|
||||
it("does not hang when the request signal aborts during the peek", async () => {
|
||||
const controller = new AbortController();
|
||||
const stalled = new Response(new ReadableStream({ start() {} }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
setTimeout(() => controller.abort(new Error("client gone")), 10);
|
||||
const res = await peekForUpstreamError(stalled, "model", {
|
||||
signal: controller.signal,
|
||||
timeoutMs: 2000,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await res.body.cancel();
|
||||
});
|
||||
|
||||
it("re-emits raw bytes so a multi-byte char split across the peek boundary survives", async () => {
|
||||
// chunk1 ends mid-é (0xC3); the peek commits on the first complete line
|
||||
// (text-delta "hi") while the decoder still holds 0xC3. Re-emission must
|
||||
// use RAW bytes — re-encoding decoded text would replace 0xC3 with U+FFFD.
|
||||
const first = Buffer.from(
|
||||
'{"type":"text-delta","text":"hi"}\n{"type":"text-delta","text":"caf',
|
||||
);
|
||||
const chunk1 = new Uint8Array([...first, 0xc3]);
|
||||
const rest = new Uint8Array([0xa9, 0x22, 0x7d, 0x0a]); // é"}\n
|
||||
const body = new ReadableStream({
|
||||
start(c) {
|
||||
c.enqueue(chunk1);
|
||||
c.enqueue(rest);
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
const res = await peekForUpstreamError(
|
||||
new Response(body, { status: 200 }),
|
||||
"m",
|
||||
);
|
||||
const text = await res.text();
|
||||
expect(text).toContain("café");
|
||||
expect(text).not.toContain("\uFFFD");
|
||||
});
|
||||
it("handles plain error string in error property", () => {
|
||||
const event = {
|
||||
type: "error",
|
||||
error: "Unauthorized access",
|
||||
};
|
||||
const parsed = parseCommandCodeError(event);
|
||||
expect(parsed.statusCode).toBe(401);
|
||||
expect(parsed.message).toBe("Unauthorized access");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inspectAndWrapCommandCodeResponse", () => {
|
||||
it("converts initial upstream 200 with error event to 503 Response", async () => {
|
||||
const ndjsonBody = createNdjsonStream([
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: {
|
||||
type: "server_error",
|
||||
message: "Service temporarily unavailable. Please try again shortly.",
|
||||
statusCode: 503,
|
||||
isRetryable: true,
|
||||
},
|
||||
}) + "\n",
|
||||
]);
|
||||
|
||||
const fakeResponse = new Response(ndjsonBody, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
|
||||
const result = await inspectAndWrapCommandCodeResponse(fakeResponse, "poolside/laguna-s-2.1-free");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.status).toBe(503);
|
||||
|
||||
const body = await result.json();
|
||||
expect(body.error.message).toContain("Service temporarily unavailable");
|
||||
expect(body.error.code).toBe(503);
|
||||
});
|
||||
|
||||
it("converts initial upstream 200 with start/start-step followed by error to 503 Response", async () => {
|
||||
const ndjsonBody = createNdjsonStream([
|
||||
JSON.stringify({ type: "start" }) + "\n",
|
||||
JSON.stringify({ type: "start-step" }) + "\n",
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: {
|
||||
type: "server_error",
|
||||
message: "Service temporarily unavailable. Please try again shortly.",
|
||||
statusCode: 503,
|
||||
isRetryable: true,
|
||||
},
|
||||
}) + "\n",
|
||||
]);
|
||||
|
||||
const fakeResponse = new Response(ndjsonBody, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
|
||||
const result = await inspectAndWrapCommandCodeResponse(fakeResponse, "poolside/laguna-s-2.1-free");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.status).toBe(503);
|
||||
|
||||
const body = await result.json();
|
||||
expect(body.error.message).toContain("Service temporarily unavailable");
|
||||
});
|
||||
|
||||
it("streams successful responses when content is emitted", async () => {
|
||||
const ndjsonBody = createNdjsonStream([
|
||||
JSON.stringify({ type: "start" }) + "\n",
|
||||
JSON.stringify({ type: "text-delta", text: "Hello from Laguna" }) + "\n",
|
||||
JSON.stringify({ type: "finish" }) + "\n",
|
||||
]);
|
||||
|
||||
const fakeResponse = new Response(ndjsonBody, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
|
||||
const result = await inspectAndWrapCommandCodeResponse(fakeResponse, "poolside/laguna-s-2.1-free");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.status).toBe(200);
|
||||
|
||||
const text = await result.text();
|
||||
expect(text).toContain("Hello from Laguna");
|
||||
expect(text).toContain("data: [DONE]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandCode in Combo Fallback", () => {
|
||||
it("automatically falls back to next model when commandcode returns 503 error", async () => {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
const handleSingleModel = vi.fn(async (body, modelStr) => {
|
||||
if (modelStr === "commandcode/poolside/laguna-s-2.1-free") {
|
||||
// Simulated failed CommandCode response
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Service temporarily unavailable. Please try again shortly.",
|
||||
type: "server_error",
|
||||
code: 503,
|
||||
},
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (modelStr === "openai/gpt-4o-mini") {
|
||||
// Fallback model succeeds
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-test",
|
||||
choices: [{ message: { role: "assistant", content: "Fallback success!" } }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
|
||||
const comboResponse = await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "Hello" }] },
|
||||
models: ["commandcode/poolside/laguna-s-2.1-free", "openai/gpt-4o-mini"],
|
||||
handleSingleModel,
|
||||
log,
|
||||
comboName: "test-combo",
|
||||
comboStrategy: "fallback",
|
||||
});
|
||||
|
||||
expect(comboResponse.ok).toBe(true);
|
||||
expect(comboResponse.status).toBe(200);
|
||||
|
||||
const data = await comboResponse.json();
|
||||
expect(data.choices[0].message.content).toBe("Fallback success!");
|
||||
expect(handleSingleModel).toHaveBeenCalledTimes(2);
|
||||
expect(handleSingleModel).toHaveBeenNthCalledWith(1, expect.anything(), "commandcode/poolside/laguna-s-2.1-free");
|
||||
expect(handleSingleModel).toHaveBeenNthCalledWith(2, expect.anything(), "openai/gpt-4o-mini");
|
||||
});
|
||||
});
|
||||
|
||||
61
tests/unit/cowork-mcp-ssrf-guard.test.js
Normal file
61
tests/unit/cowork-mcp-ssrf-guard.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
82
tests/unit/defer-loading-cache-control.test.js
Normal file
82
tests/unit/defer-loading-cache-control.test.js
Normal file
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../open-sse/config/ru
|
||||
import mimoFree from "../../open-sse/providers/registry/mimo-free.js";
|
||||
import opencode from "../../open-sse/providers/registry/opencode.js";
|
||||
import antigravity from "../../open-sse/providers/registry/antigravity.js";
|
||||
import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js";
|
||||
|
||||
describe("compat base URLs / version", () => {
|
||||
it("OPENAI_COMPAT_BASE", () => {
|
||||
@@ -46,3 +47,41 @@ describe("antigravity retry (intentional change: 429=6, 503=3)", () => {
|
||||
expect(antigravity.transport.retry["503"].attempts).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Free endpoint routing", () => {
|
||||
const MUSE = "muse-spark-1.2-contributor-free";
|
||||
|
||||
it("declares the Responses format only on the Muse Spark model", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
it("normalizes Chat token/thinking fields only for the Responses model", () => {
|
||||
const executor = new OpenCodeExecutor();
|
||||
const muse = { max_tokens: 4096, reasoning_effort: "high" };
|
||||
executor.transformRequest(MUSE, muse, true, {});
|
||||
expect(muse.max_output_tokens).toBe(4096);
|
||||
expect(muse.max_tokens).toBeUndefined();
|
||||
expect(muse.reasoning).toEqual({ effort: "high", summary: "auto" });
|
||||
|
||||
const chat = { max_tokens: 4096, reasoning_effort: "high" };
|
||||
executor.transformRequest("big-pickle", chat, true, {});
|
||||
expect(chat.max_tokens).toBe(4096);
|
||||
expect(chat.max_output_tokens).toBeUndefined();
|
||||
expect(chat.reasoning_effort).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
74
tests/unit/extract-usage-cache-shapes.test.js
Normal file
74
tests/unit/extract-usage-cache-shapes.test.js
Normal file
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
36
tests/unit/gemini-3.8-antigravity.test.js
Normal file
36
tests/unit/gemini-3.8-antigravity.test.js
Normal file
@@ -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"]);
|
||||
});
|
||||
});
|
||||
100
tests/unit/gemini-37-integration.test.js
Normal file
100
tests/unit/gemini-37-integration.test.js
Normal file
@@ -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.7 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.7-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.7-flash-tiered(${tier})`);
|
||||
expect(finalBody.model).toBe("gemini-3.7-flash-tiered");
|
||||
expect(finalBody.request.generationConfig.thinkingConfig).toEqual({
|
||||
thinkingLevel: tier,
|
||||
includeThoughts: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("Gemini 3.7 MITM model extraction", () => {
|
||||
it.each(["high", "medium", "low"])("extracts the %s thinking tier for gemini-3.7-flash-tiered", (tier) => {
|
||||
const body = Buffer.from(JSON.stringify({
|
||||
request: { generationConfig: { thinkingConfig: { thinkingLevel: tier } } },
|
||||
}));
|
||||
|
||||
expect(mitmConfig.extractModel(
|
||||
"/v1internal/models/gemini-3.7-flash-tiered:streamGenerateContent",
|
||||
body
|
||||
)).toBe(`gemini-3.7-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.7-flash-tiered:streamGenerateContent",
|
||||
body
|
||||
)).toBe("gemini-3.7-flash-medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemini 3.7 MITM tools and catalog", () => {
|
||||
it("includes gemini-3.7-flash tiers in MITM_TOOLS defaultModels", () => {
|
||||
const defaultModelIds = MITM_TOOLS.antigravity.defaultModels.map((m) => m.id);
|
||||
expect(defaultModelIds).toContain("gemini-3.7-flash-high");
|
||||
expect(defaultModelIds).toContain("gemini-3.7-flash-medium");
|
||||
expect(defaultModelIds).toContain("gemini-3.7-flash-low");
|
||||
});
|
||||
|
||||
it("exposes the direct Gemini 3.7 API models and pricing", () => {
|
||||
const ids = gemini.models.map((model) => model.id);
|
||||
expect(ids).toContain("gemini-3.7-flash");
|
||||
expect(MODEL_PRICING["gemini-3.7-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.7-flash-high");
|
||||
expect(agCatalog).toContain("gemini-3.7-flash-medium");
|
||||
expect(agCatalog).toContain("gemini-3.7-flash-low");
|
||||
});
|
||||
});
|
||||
100
tests/unit/gemini-38-integration.test.js
Normal file
100
tests/unit/gemini-38-integration.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
192
tests/unit/glm-usage.test.js
Normal file
192
tests/unit/glm-usage.test.js
Normal file
@@ -0,0 +1,192 @@
|
||||
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 { getGlmUsage } from "../../open-sse/services/usage/glm.js";
|
||||
import {
|
||||
USAGE_SUPPORTED_PROVIDERS,
|
||||
USAGE_APIKEY_PROVIDERS,
|
||||
} from "../../src/shared/constants/providers.js";
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const SAMPLE_GLM_CREDIT_USAGE = {
|
||||
code: 200,
|
||||
msg: "Operation successful",
|
||||
data: {
|
||||
limits: [
|
||||
{
|
||||
type: "CREDIT_LIMIT",
|
||||
unit: 3,
|
||||
number: 5,
|
||||
usage: 2000,
|
||||
currentValue: 0,
|
||||
remaining: 1999,
|
||||
percentage: 25,
|
||||
nextResetTime: 1787905548392,
|
||||
},
|
||||
{
|
||||
type: "CREDIT_LIMIT",
|
||||
unit: 6,
|
||||
number: 1,
|
||||
usage: 10000,
|
||||
currentValue: 0,
|
||||
remaining: 9999,
|
||||
percentage: 10,
|
||||
nextResetTime: 1788492142997,
|
||||
},
|
||||
],
|
||||
level: "lite",
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
|
||||
const SAMPLE_GLM_TOKENS_USAGE = {
|
||||
code: 200,
|
||||
msg: "Operation successful",
|
||||
data: {
|
||||
limits: [
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
percentage: 40,
|
||||
nextResetTime: 1787905548392,
|
||||
},
|
||||
],
|
||||
level: "standard",
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
|
||||
describe("glm registry usage flags", () => {
|
||||
it("is listed for apikey quota dashboard", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("glm");
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("glm-cn");
|
||||
expect(USAGE_APIKEY_PROVIDERS).toContain("glm");
|
||||
expect(USAGE_APIKEY_PROVIDERS).toContain("glm-cn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGlmUsage and getUsageForProvider(glm)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("handles CREDIT_LIMIT with session 5h and weekly 7d quotas", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(SAMPLE_GLM_CREDIT_USAGE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "glm",
|
||||
apiKey: "glm-key-123",
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("Lite");
|
||||
expect(usage.quotas["Session (5h)"]).toEqual({
|
||||
used: 25,
|
||||
total: 100,
|
||||
remaining: 75,
|
||||
remainingPercentage: 75,
|
||||
resetAt: new Date(1787905548392).toISOString(),
|
||||
unlimited: false,
|
||||
});
|
||||
expect(usage.quotas["Weekly (7d)"]).toEqual({
|
||||
used: 100 ? 10 : 10,
|
||||
total: 100,
|
||||
remaining: 90,
|
||||
remainingPercentage: 90,
|
||||
resetAt: new Date(1788492142997).toISOString(),
|
||||
unlimited: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles TOKENS_LIMIT quotas", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(SAMPLE_GLM_TOKENS_USAGE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "glm-cn",
|
||||
apiKey: "glm-cn-key",
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("Standard");
|
||||
expect(usage.quotas["Tokens"]).toEqual({
|
||||
used: 40,
|
||||
total: 100,
|
||||
remaining: 60,
|
||||
remainingPercentage: 60,
|
||||
resetAt: new Date(1787905548392).toISOString(),
|
||||
unlimited: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles fallback key for custom limit units", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
code: 200,
|
||||
data: {
|
||||
limits: [
|
||||
{
|
||||
type: "CREDIT_LIMIT",
|
||||
unit: 99,
|
||||
number: 12,
|
||||
percentage: 5,
|
||||
nextResetTime: 0,
|
||||
},
|
||||
],
|
||||
level: "pro",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const usage = await getGlmUsage("glm-key", "glm");
|
||||
expect(usage.plan).toBe("Pro");
|
||||
expect(usage.quotas["Limit (12)"]).toEqual({
|
||||
used: 5,
|
||||
total: 100,
|
||||
remaining: 95,
|
||||
remainingPercentage: 95,
|
||||
resetAt: null,
|
||||
unlimited: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces invalid key message on 401", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "glm",
|
||||
apiKey: "invalid-key",
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/invalid or expired/i);
|
||||
});
|
||||
|
||||
it("handles non-200 error response", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "server error" }, 500));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "glm",
|
||||
apiKey: "valid-key",
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/GLM quota API error \(500\)/);
|
||||
});
|
||||
|
||||
it("returns message when apiKey is missing", async () => {
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "glm",
|
||||
apiKey: "",
|
||||
});
|
||||
|
||||
expect(usage.message).toBe("GLM API key not available.");
|
||||
});
|
||||
});
|
||||
127
tests/unit/groq-usage.test.js
Normal file
127
tests/unit/groq-usage.test.js
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -202,6 +202,101 @@ describe("compressWithHeadroom", () => {
|
||||
expect(stats).toBeNull();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("timeout normalization", () => {
|
||||
const mockResponse = JSON.stringify({
|
||||
messages: [{ role: "user", content: "short" }],
|
||||
tokens_before: 100,
|
||||
tokens_after: 20,
|
||||
tokens_saved: 80,
|
||||
});
|
||||
|
||||
function makeSuccessfulFetch() {
|
||||
global.fetch = vi.fn(async () =>
|
||||
new Response(mockResponse, { status: 200 })
|
||||
);
|
||||
}
|
||||
|
||||
function captureTimeoutCalls() {
|
||||
const calls = [];
|
||||
vi.spyOn(AbortSignal, "timeout").mockImplementation((ms) => {
|
||||
calls.push(ms);
|
||||
const controller = new AbortController();
|
||||
return controller.signal;
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
it("passes a valid positive timeout to AbortSignal.timeout", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: 5000 });
|
||||
|
||||
expect(calls).toContain(5000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is null", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: null });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is 0", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: 0 });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is negative", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: -100 });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is NaN", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: NaN });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is Infinity", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: Infinity });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
|
||||
it("falls back to the default timeout when timeoutMs is a string", async () => {
|
||||
makeSuccessfulFetch();
|
||||
const calls = captureTimeoutCalls();
|
||||
const body = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
await compressWithHeadroom(body, { enabled: true, url: "http://localhost:8787", timeoutMs: "5000" });
|
||||
|
||||
expect(calls).toContain(3000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHeadroomLog", () => {
|
||||
|
||||
@@ -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" },
|
||||
@@ -358,7 +358,7 @@ describe("handleImageGenerationCore", () => {
|
||||
|
||||
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" },
|
||||
]);
|
||||
|
||||
192
tests/unit/kiro-image-forwarding.test.js
Normal file
192
tests/unit/kiro-image-forwarding.test.js
Normal file
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
189
tests/unit/minimax-transport-target-format.test.js
Normal file
189
tests/unit/minimax-transport-target-format.test.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Multi-transport providers must keep the request body and selected endpoint on
|
||||
* the same wire format. MiniMax-M3 declares a Claude target for compatibility,
|
||||
* but an OpenAI client should use MiniMax's matching OpenAI transport without
|
||||
* an OpenAI -> Claude translation.
|
||||
* Regression: https://github.com/decolua/9router/issues/3418
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
executeMock,
|
||||
translateRequestMock,
|
||||
handleNonStreamingResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
executeMock: vi.fn(),
|
||||
translateRequestMock: vi.fn((sourceFormat, targetFormat, model, body) => ({
|
||||
...body,
|
||||
model,
|
||||
_translatedFrom: sourceFormat,
|
||||
_translatedTo: targetFormat,
|
||||
})),
|
||||
handleNonStreamingResponseMock: vi.fn(async () => ({ success: true })),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/executors/index.js", () => ({
|
||||
getExecutor: vi.fn(() => ({
|
||||
execute: executeMock,
|
||||
refreshCredentials: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/translator/index.js", () => ({
|
||||
translateRequest: translateRequestMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/handlers/chatCore/nonStreamingHandler.js", () => ({
|
||||
handleNonStreamingResponse: handleNonStreamingResponseMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/requestLogger.js", () => ({
|
||||
createRequestLogger: vi.fn(async () => ({
|
||||
logClientRawRequest: vi.fn(),
|
||||
logRawRequest: vi.fn(),
|
||||
logTargetRequest: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/clientDetector.js", () => ({
|
||||
detectClientTool: vi.fn(() => null),
|
||||
isNativePassthrough: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/bypassHandler.js", () => ({
|
||||
handleBypassRequest: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/streamHandler.js", () => ({
|
||||
createStreamController: vi.fn(() => ({
|
||||
signal: undefined,
|
||||
handleComplete: vi.fn(),
|
||||
handleError: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/services/tokenRefresh.js", () => ({
|
||||
refreshWithRetry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
default: vi.fn(),
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/translator/formats/claude.js", () => ({
|
||||
normalizeClaudePassthrough: vi.fn(),
|
||||
anchorClaudeCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/toolDeduper.js", () => ({
|
||||
dedupeTools: vi.fn((tools) => ({ tools, stripped: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/rtk/caveman.js", () => ({ injectCaveman: vi.fn() }));
|
||||
vi.mock("../../open-sse/rtk/ponytail.js", () => ({ injectPonytail: vi.fn() }));
|
||||
vi.mock("../../open-sse/rtk/index.js", () => ({
|
||||
compressMessages: vi.fn(() => null),
|
||||
formatRtkLog: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("../../open-sse/rtk/headroom.js", () => ({
|
||||
compressWithHeadroom: vi.fn(async () => null),
|
||||
formatHeadroomLog: vi.fn(() => ""),
|
||||
formatHeadroomSizeLog: vi.fn(() => ""),
|
||||
isHeadroomPhantomSavings: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("../../open-sse/rtk/pxpipe.js", () => ({
|
||||
compressWithPxpipe: vi.fn(async () => ({ body: null, summary: null })),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/translator/concerns/prefetch.js", () => ({
|
||||
prefetchRemoteImages: vi.fn(async () => 0),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/handlers/chatCore/requestDetail.js", () => ({
|
||||
buildRequestDetail: vi.fn((detail) => detail),
|
||||
extractRequestConfig: vi.fn((body, stream) => ({ body, stream })),
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/error.js", () => ({
|
||||
createErrorResult: vi.fn((status, message) => ({ success: false, status, error: message })),
|
||||
formatProviderError: vi.fn((error) => error.message),
|
||||
parseUpstreamError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/usageDb.js", () => ({
|
||||
trackPendingRequest: vi.fn(),
|
||||
appendRequestLog: vi.fn(() => Promise.resolve()),
|
||||
saveRequestDetail: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
function makeOptions(body) {
|
||||
return {
|
||||
body,
|
||||
modelInfo: { provider: "minimax-cn", model: "MiniMax-M3" },
|
||||
credentials: { apiKey: "test-api-key", providerSpecificData: {} },
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/chat/completions",
|
||||
body,
|
||||
headers: { accept: "application/json" },
|
||||
},
|
||||
connectionId: "test-connection",
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
describe("MiniMax-M3 multi-transport routing", () => {
|
||||
beforeEach(() => {
|
||||
executeMock.mockReset();
|
||||
translateRequestMock.mockClear();
|
||||
handleNonStreamingResponseMock.mockClear();
|
||||
executeMock.mockResolvedValue({
|
||||
response: new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
url: "https://api.minimaxi.com/v1/chat/completions",
|
||||
headers: {},
|
||||
transformedBody: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps OpenAI image blocks on the matching OpenAI transport", async () => {
|
||||
const imageBlock = {
|
||||
type: "image_url",
|
||||
image_url: { url: "data:image/png;base64,AAAB" },
|
||||
};
|
||||
const body = {
|
||||
model: "minimax-cn/MiniMax-M3",
|
||||
stream: false,
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Describe this image" }, imageBlock],
|
||||
}],
|
||||
};
|
||||
|
||||
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js");
|
||||
await handleChatCore(makeOptions(body));
|
||||
|
||||
expect(translateRequestMock).toHaveBeenCalledWith(
|
||||
"openai",
|
||||
"openai",
|
||||
"MiniMax-M3",
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
"minimax-cn",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
"test-connection",
|
||||
null,
|
||||
);
|
||||
expect(executeMock).toHaveBeenCalledTimes(1);
|
||||
const requestBody = executeMock.mock.calls[0][0].body;
|
||||
expect(requestBody.messages[0].content).toContainEqual(imageBlock);
|
||||
expect(requestBody._translatedTo).toBe("openai");
|
||||
expect(requestBody).not.toHaveProperty("system");
|
||||
expect(executeMock.mock.calls[0][0].credentials.runtimeTransport.format).toBe("openai");
|
||||
});
|
||||
});
|
||||
54
tests/unit/model-context-marker.test.js
Normal file
54
tests/unit/model-context-marker.test.js
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
96
tests/unit/ollama-stream-tail.test.js
Normal file
96
tests/unit/ollama-stream-tail.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
import { createSSETransformStreamWithLogger } from "../../open-sse/utils/stream.js";
|
||||
|
||||
// Ollama streams NDJSON — one raw JSON object per line, no "data: " prefix.
|
||||
// Whatever arrives without a closing newline stays in the line buffer and is
|
||||
// only parsed when the transform flushes.
|
||||
async function runOllamaStream(input) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(input));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const output = stream.pipeThrough(
|
||||
createSSETransformStreamWithLogger(FORMATS.OLLAMA, FORMATS.OPENAI, "ollama", null, null, "gpt-oss:120b"),
|
||||
);
|
||||
|
||||
const reader = output.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return text + decoder.decode();
|
||||
}
|
||||
|
||||
const chunk = (content, done = false) => JSON.stringify({
|
||||
model: "gpt-oss:120b",
|
||||
created_at: "2026-08-25T00:00:00Z",
|
||||
message: { role: "assistant", content },
|
||||
done,
|
||||
...(done ? { done_reason: "stop", prompt_eval_count: 11, eval_count: 7 } : {}),
|
||||
});
|
||||
|
||||
const deltas = (sse) => sse
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("data: ") && l !== "data: [DONE]")
|
||||
.map((l) => JSON.parse(l.slice(6)));
|
||||
|
||||
describe("Ollama NDJSON stream: the tail left in the line buffer", () => {
|
||||
it("delivers a content chunk that arrived without its newline", async () => {
|
||||
const out = await runOllamaStream([chunk("hello"), chunk(" world")].join("\n"));
|
||||
const content = deltas(out).map((c) => c.choices?.[0]?.delta?.content || "").join("");
|
||||
expect(content).toBe("hello world");
|
||||
});
|
||||
|
||||
it("delivers the final chunk — finish_reason and usage — when it arrives without its newline", async () => {
|
||||
const out = await runOllamaStream([chunk("hello"), chunk("", true)].join("\n"));
|
||||
const last = deltas(out).at(-1);
|
||||
expect(last.choices[0].finish_reason).toBe("stop");
|
||||
expect(last.usage).toEqual({ prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 });
|
||||
});
|
||||
|
||||
it("is unchanged when every line is newline-terminated", async () => {
|
||||
const out = await runOllamaStream(`${[chunk("hello"), chunk(" world"), chunk("", true)].join("\n")}\n`);
|
||||
const parsed = deltas(out);
|
||||
expect(parsed.map((c) => c.choices?.[0]?.delta?.content || "").join("")).toBe("hello world");
|
||||
expect(parsed.at(-1).choices[0].finish_reason).toBe("stop");
|
||||
expect(parsed.at(-1).usage.total_tokens).toBe(18);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE providers keep their sentinel handling", () => {
|
||||
it("does not translate a trailing data: [DONE]", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { content: "hi" } }] })}\ndata: [DONE]`,
|
||||
));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = stream.pipeThrough(
|
||||
createSSETransformStreamWithLogger(FORMATS.OPENAI, FORMATS.OPENAI, "openai", null, null, "gpt-4o"),
|
||||
);
|
||||
const reader = out.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
expect(text).toContain('"content":"hi"');
|
||||
// The sentinel is a framing marker, not a chunk — it must not be translated.
|
||||
expect(text).not.toContain('"done":true');
|
||||
});
|
||||
});
|
||||
112
tests/unit/ollama-web-fetch-provider.test.js
Normal file
112
tests/unit/ollama-web-fetch-provider.test.js
Normal file
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,11 +22,12 @@ 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.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6",
|
||||
"deepseek-v4-pro", "deepseek-v4-flash",
|
||||
"glm-5.3-flash", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6",
|
||||
"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp",
|
||||
"mimo-v2.5", "mimo-v2.5-pro",
|
||||
"minimax-m3", "minimax-m2.7", "minimax-m2.5",
|
||||
"qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
|
||||
"muse-spark-1.2-contributor", "muse-spark-1.3-contributor",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +90,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"]) {
|
||||
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();
|
||||
|
||||
180
tests/unit/opencode-go-muse-spark-responses.test.js
Normal file
180
tests/unit/opencode-go-muse-spark-responses.test.js
Normal file
@@ -0,0 +1,180 @@
|
||||
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("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" } } });
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
166
tests/unit/opencode-go-session.test.js
Normal file
166
tests/unit/opencode-go-session.test.js
Normal file
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
129
tests/unit/opencode-go-usage.test.js
Normal file
129
tests/unit/opencode-go-usage.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
135
tests/unit/opencode-muse-spark-thinking.test.js
Normal file
135
tests/unit/opencode-muse-spark-thinking.test.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.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";
|
||||
import "../translator/registerAll.js";
|
||||
import { translateRequest } from "../../open-sse/translator/index.js";
|
||||
|
||||
const MODEL = "muse-spark-1.2-contributor-free";
|
||||
const PROVIDER = "opencode";
|
||||
|
||||
const input = [{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "Think, then answer: 2 + 2?" }],
|
||||
}];
|
||||
|
||||
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(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", () => {
|
||||
const body = {
|
||||
input,
|
||||
reasoning: { effort: "max" },
|
||||
max_tokens: 131072,
|
||||
};
|
||||
|
||||
const out = new OpenCodeExecutor().transformRequest(MODEL, body, true, {
|
||||
connectionId: "opencode-muse-spark-test",
|
||||
});
|
||||
|
||||
expect(out.reasoning).toEqual({ effort: "xhigh", summary: "auto" });
|
||||
expect(out.reasoning_effort).toBeUndefined();
|
||||
expect(out.max_output_tokens).toBe(131072);
|
||||
expect(out.max_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the other free models on Chat Completions", () => {
|
||||
const executor = new OpenCodeExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }], max_tokens: 1024 };
|
||||
executor.transformRequest("big-pickle", body, true, {});
|
||||
expect(executor.buildUrl("big-pickle")).toBe("https://opencode.ai/zen/v1/chat/completions");
|
||||
expect(body.max_tokens).toBe(1024);
|
||||
expect(body.max_output_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("translates Chat Completions max thinking into a Responses request", () => {
|
||||
const body = {
|
||||
model: `oc/${MODEL}`,
|
||||
messages: [{ role: "user", content: "Think, then answer: 2 + 2?" }],
|
||||
reasoning_effort: "max",
|
||||
max_tokens: 131072,
|
||||
};
|
||||
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
MODEL,
|
||||
body,
|
||||
true,
|
||||
{},
|
||||
PROVIDER,
|
||||
);
|
||||
const out = new OpenCodeExecutor().transformRequest(MODEL, translated, true, {
|
||||
connectionId: "opencode-muse-spark-translation-test",
|
||||
});
|
||||
|
||||
expect(out.reasoning).toEqual({ effort: "xhigh", summary: "auto" });
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
52
tests/unit/providers-status-filter.test.js
Normal file
52
tests/unit/providers-status-filter.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -367,6 +367,70 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapQoderSSE", () => {
|
||||
|
||||
165
tests/unit/responses-parallel-tool-calls.test.js
Normal file
165
tests/unit/responses-parallel-tool-calls.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
147
tests/unit/ssrf-guard-hardening.test.js
Normal file
147
tests/unit/ssrf-guard-hardening.test.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
491
tests/unit/system-inject.test.js
Normal file
491
tests/unit/system-inject.test.js
Normal file
@@ -0,0 +1,491 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { injectSystemPrompt } from "../../open-sse/rtk/systemInject.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
import { OPENAI_BLOCK, CLAUDE_BLOCK, RESPONSES_ITEM } from "../../open-sse/translator/schema/blocks.js";
|
||||
import { ROLE } from "../../open-sse/translator/schema/roles.js";
|
||||
import { injectCaveman } from "../../open-sse/rtk/caveman.js";
|
||||
import { injectPonytail } from "../../open-sse/rtk/ponytail.js";
|
||||
import { CAVEMAN_PROMPTS } from "../../open-sse/rtk/cavemanPrompts.js";
|
||||
import { PONYTAIL_PROMPTS } from "../../open-sse/rtk/ponytailPrompt.js";
|
||||
|
||||
const SEP = "\n\n";
|
||||
const P1 = "CAVEMAN_TEST_PROMPT_AAA";
|
||||
const P2 = "PONYTAIL_TEST_PROMPT_BBB";
|
||||
|
||||
describe("system-inject chat messages", () => {
|
||||
it("appends TEXT block to existing system string with SEP", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }, { role: ROLE.USER, content: "hi" }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.messages[0].content).toBe(`hello${SEP}${P1}`);
|
||||
});
|
||||
|
||||
it("appends TEXT block to existing system array with OPENAI_BLOCK.TEXT never input_text", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: [{ type: OPENAI_BLOCK.TEXT, text: "hello" }] }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
const arr = body.messages[0].content;
|
||||
expect(arr[arr.length - 1]).toEqual({ type: OPENAI_BLOCK.TEXT, text: P1 });
|
||||
expect(arr.some(c => c.type === "input_text")).toBe(false);
|
||||
});
|
||||
|
||||
it("unshifts system message when no system/developer present", () => {
|
||||
const body = { messages: [{ role: ROLE.USER, content: "hi" }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.messages[0]).toEqual({ role: ROLE.SYSTEM, content: P1 });
|
||||
expect(body.messages[1].role).toBe(ROLE.USER);
|
||||
});
|
||||
|
||||
it("handles developer role as system", () => {
|
||||
const body = { messages: [{ role: ROLE.DEVELOPER, content: "dev" }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.messages[0].content).toBe(`dev${SEP}${P1}`);
|
||||
});
|
||||
|
||||
it("exact full-prompt idempotency for chat string", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.messages[0].content).toBe(`hello${SEP}${P1}`);
|
||||
// different prompt both apply
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P2);
|
||||
expect(body.messages[0].content).toBe(`hello${SEP}${P1}${SEP}${P2}`);
|
||||
});
|
||||
|
||||
it("exact full-prompt idempotency for chat array", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: [{ type: OPENAI_BLOCK.TEXT, text: "hello" }] }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
const texts = body.messages[0].content.filter(c => c.text === P1);
|
||||
expect(texts.length).toBe(1);
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P2);
|
||||
expect(body.messages[0].content.filter(c => c.text === P2).length).toBe(1);
|
||||
});
|
||||
|
||||
it("never uses first-100 fingerprint: long prompt exact idempotency", () => {
|
||||
const longA = "X".repeat(150) + "_A";
|
||||
const longB = "X".repeat(150) + "_B";
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "base" }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, longA);
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, longB);
|
||||
expect(body.messages[0].content).toContain(longA);
|
||||
expect(body.messages[0].content).toContain(longB);
|
||||
// retry same longA is idempotent
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, longA);
|
||||
const countA = body.messages[0].content.split(longA).length - 1;
|
||||
expect(countA).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system-inject responses input[]", () => {
|
||||
it("modifies only type: message system/developer and preserves non-message order", () => {
|
||||
const body = {
|
||||
input: [
|
||||
{ type: RESPONSES_ITEM.FUNCTION_CALL, call_id: "c1", name: "fn" },
|
||||
{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.SYSTEM, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "sys" }] },
|
||||
{ type: RESPONSES_ITEM.REASONING, summary: "x" },
|
||||
{ type: RESPONSES_ITEM.FUNCTION_CALL_OUTPUT, call_id: "c1", output: "ok" },
|
||||
],
|
||||
};
|
||||
const before = JSON.parse(JSON.stringify(body.input));
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
// length unchanged except injection inside message
|
||||
expect(body.input.length).toBe(before.length);
|
||||
expect(body.input[0]).toEqual(before[0]);
|
||||
expect(body.input[2]).toEqual(before[2]);
|
||||
expect(body.input[3]).toEqual(before[3]);
|
||||
// system message got INPUT_TEXT appended
|
||||
const sys = body.input[1];
|
||||
expect(sys.content[sys.content.length - 1]).toEqual({ type: RESPONSES_ITEM.INPUT_TEXT, text: P1 });
|
||||
});
|
||||
|
||||
it("appends INPUT_TEXT to array content", () => {
|
||||
const body = { input: [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "hi" }] }, { type: RESPONSES_ITEM.MESSAGE, role: ROLE.SYSTEM, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "base" }] }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
const sys = body.input.find(m => m.role === ROLE.SYSTEM);
|
||||
expect(sys.content[sys.content.length - 1].type).toBe(RESPONSES_ITEM.INPUT_TEXT);
|
||||
expect(sys.content[sys.content.length - 1].text).toBe(P1);
|
||||
});
|
||||
|
||||
it("creates typed message at index 0 if absent preserving order", () => {
|
||||
const body = { input: [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "hi" }] }, { type: RESPONSES_ITEM.FUNCTION_CALL, call_id: "1", name: "a" }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.input[0]).toEqual({ type: RESPONSES_ITEM.MESSAGE, role: ROLE.SYSTEM, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: P1 }] });
|
||||
expect(body.input[1].role).toBe(ROLE.USER);
|
||||
});
|
||||
|
||||
it("instructions string takes precedence over input[]", () => {
|
||||
const body = { instructions: "instr", input: [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "hi" }] }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.instructions).toBe(`instr${SEP}${P1}`);
|
||||
expect(body.input.length).toBe(1);
|
||||
expect(body.input[0].content[0].text).toBe("hi");
|
||||
});
|
||||
|
||||
it("does not coerce string input", () => {
|
||||
const body = { input: "hello string" };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.input).toBe("hello string");
|
||||
expect(body.instructions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exact idempotency for responses input", () => {
|
||||
const body = { input: [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.SYSTEM, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "base" }] }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
const sys = body.input[0];
|
||||
expect(sys.content.filter(c => c.text === P1).length).toBe(1);
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P2);
|
||||
expect(sys.content.filter(c => c.text === P2).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system-inject instructions", () => {
|
||||
it("appends to instructions string with idempotency", () => {
|
||||
const body = { instructions: "base" };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.instructions).toBe(`base${SEP}${P1}`);
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.instructions).toBe(`base${SEP}${P1}`);
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P2);
|
||||
expect(body.instructions).toBe(`base${SEP}${P1}${SEP}${P2}`);
|
||||
});
|
||||
|
||||
it("creates instructions when empty", () => {
|
||||
const body = { instructions: "" };
|
||||
// empty string still taken as string field, should become prompt
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.instructions).toBe(P1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system-inject dispatch by wire shape", () => {
|
||||
it("messages[] means Chat even when format is openai-responses label", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "hi" }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI_RESPONSES, P1);
|
||||
// should still treat as Chat because messages present
|
||||
expect(body.messages[0].content).toBe(`hi${SEP}${P1}`);
|
||||
});
|
||||
it("input[] means Responses even when format is openai", () => {
|
||||
const body = { input: [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "hi" }] }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.input[0].role).toBe(ROLE.SYSTEM);
|
||||
expect(body.input[0].content[0].type).toBe(RESPONSES_ITEM.INPUT_TEXT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system-inject claude", () => {
|
||||
it("string system appends with SEP and idempotent", () => {
|
||||
const body = { system: "base" };
|
||||
injectSystemPrompt(body, FORMATS.CLAUDE, P1);
|
||||
expect(body.system).toBe(`base${SEP}${P1}`);
|
||||
injectSystemPrompt(body, FORMATS.CLAUDE, P1);
|
||||
expect(body.system).toBe(`base${SEP}${P1}`);
|
||||
injectSystemPrompt(body, FORMATS.CLAUDE, P2);
|
||||
expect(body.system).toBe(`base${SEP}${P1}${SEP}${P2}`);
|
||||
});
|
||||
|
||||
it("array system uses CLAUDE_BLOCK.TEXT and inserts before last cache_control", () => {
|
||||
const body = { system: [{ type: CLAUDE_BLOCK.TEXT, text: "a" }, { type: CLAUDE_BLOCK.TEXT, text: "b", cache_control: { type: "ephemeral" } }, { type: CLAUDE_BLOCK.TEXT, text: "c", cache_control: { type: "ephemeral" } }] };
|
||||
injectSystemPrompt(body, FORMATS.CLAUDE, P1);
|
||||
// should be inserted before last cache_control (index 2)
|
||||
expect(body.system[2]).toEqual({ type: CLAUDE_BLOCK.TEXT, text: P1 });
|
||||
expect(body.system[3].text).toBe("c");
|
||||
expect(body.system[3].cache_control).toBeDefined();
|
||||
});
|
||||
|
||||
it("array without cache_control appends", () => {
|
||||
const body = { system: [{ type: CLAUDE_BLOCK.TEXT, text: "a" }] };
|
||||
injectSystemPrompt(body, FORMATS.CLAUDE, P1);
|
||||
expect(body.system[body.system.length - 1]).toEqual({ type: CLAUDE_BLOCK.TEXT, text: P1 });
|
||||
});
|
||||
|
||||
it("exact idempotency for claude array", () => {
|
||||
const body = { system: [{ type: CLAUDE_BLOCK.TEXT, text: "a" }] };
|
||||
injectSystemPrompt(body, FORMATS.CLAUDE, P1);
|
||||
injectSystemPrompt(body, FORMATS.CLAUDE, P1);
|
||||
expect(body.system.filter(b => b.text === P1).length).toBe(1);
|
||||
});
|
||||
|
||||
it("creates system when absent", () => {
|
||||
const body = {};
|
||||
injectSystemPrompt(body, FORMATS.CLAUDE, P1);
|
||||
expect(body.system).toBe(P1);
|
||||
});
|
||||
|
||||
it("real body with messages[] injects into system, never a system role turn", () => {
|
||||
const body = { system: "base", messages: [{ role: ROLE.USER, content: "hi" }] };
|
||||
injectSystemPrompt(body, FORMATS.CLAUDE, P1);
|
||||
expect(body.system).toBe(`base${SEP}${P1}`);
|
||||
expect(body.messages).toEqual([{ role: ROLE.USER, content: "hi" }]);
|
||||
});
|
||||
|
||||
it("absent system with messages[] creates system field, not a system message", () => {
|
||||
const body = { messages: [{ role: ROLE.USER, content: "hi" }] };
|
||||
injectSystemPrompt(body, FORMATS.CLAUDE, P1);
|
||||
expect(body.system).toBe(P1);
|
||||
expect(body.messages.some(m => m.role === ROLE.SYSTEM)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system-inject gemini", () => {
|
||||
it("preserves snake_case key", () => {
|
||||
const body = { system_instruction: { parts: [{ text: "base" }] } };
|
||||
injectSystemPrompt(body, FORMATS.GEMINI, P1);
|
||||
expect(body.system_instruction.parts.length).toBe(2);
|
||||
expect(body.system_instruction.parts[1].text).toBe(P1);
|
||||
expect(body.systemInstruction).toBeUndefined();
|
||||
});
|
||||
it("preserves camelCase key", () => {
|
||||
const body = { systemInstruction: { parts: [{ text: "base" }] } };
|
||||
injectSystemPrompt(body, FORMATS.GEMINI, P1);
|
||||
expect(body.systemInstruction.parts[1].text).toBe(P1);
|
||||
expect(body.system_instruction).toBeUndefined();
|
||||
});
|
||||
it("handles Antigravity wrapper request.systemInstruction", () => {
|
||||
const body = { request: { systemInstruction: { parts: [{ text: "base" }] } } };
|
||||
injectSystemPrompt(body, FORMATS.ANTIGRAVITY, P1);
|
||||
expect(body.request.systemInstruction.parts[1].text).toBe(P1);
|
||||
});
|
||||
it("exact idempotency for gemini", () => {
|
||||
const body = { systemInstruction: { parts: [{ text: "base" }] } };
|
||||
injectSystemPrompt(body, FORMATS.GEMINI, P1);
|
||||
injectSystemPrompt(body, FORMATS.GEMINI, P1);
|
||||
expect(body.systemInstruction.parts.filter(p => p.text === P1).length).toBe(1);
|
||||
injectSystemPrompt(body, FORMATS.GEMINI, P2);
|
||||
expect(body.systemInstruction.parts.filter(p => p.text === P2).length).toBe(1);
|
||||
});
|
||||
it("creates when absent", () => {
|
||||
const body = {};
|
||||
injectSystemPrompt(body, FORMATS.GEMINI, P1);
|
||||
expect(body.systemInstruction.parts[0].text).toBe(P1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system-inject kiro", () => {
|
||||
it("updates systemPrompt and mirrored prefix of first history user preserving tail", () => {
|
||||
const oldPrompt = "OLD_SYS";
|
||||
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 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}`);
|
||||
// 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", () => {
|
||||
const body = {
|
||||
systemPrompt: "OLD",
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: "different head 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");
|
||||
});
|
||||
|
||||
it("exact retry idempotency for kiro", () => {
|
||||
const oldPrompt = "OLD";
|
||||
const body = {
|
||||
systemPrompt: oldPrompt,
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: `${oldPrompt}${SEP}tail`, modelId: "m" } }],
|
||||
currentMessage: { userInputMessage: { content: "cur", modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
const after1 = JSON.parse(JSON.stringify(body));
|
||||
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
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P2);
|
||||
expect(body.systemPrompt).toBe(`${oldPrompt}${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" } } },
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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 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 }],
|
||||
},
|
||||
};
|
||||
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`);
|
||||
// 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);
|
||||
});
|
||||
|
||||
it("kiro shape gate: stray conversationState without history/currentMessage does not hijack chat body", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }], systemPrompt: "", conversationState: {} };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.messages[0].content).toBe(`hello${SEP}${P1}`);
|
||||
});
|
||||
|
||||
it("substring occurrence does not suppress injection (exact SEP-delimited idempotency)", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "You are RULE follower" }] };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, "RULE");
|
||||
expect(body.messages[0].content).toBe(`You are RULE follower${SEP}RULE`);
|
||||
});
|
||||
|
||||
it("instructions substring occurrence does not suppress injection", () => {
|
||||
const body = { instructions: "You are RULE follower" };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, "RULE");
|
||||
expect(body.instructions).toBe(`You are RULE follower${SEP}RULE`);
|
||||
});
|
||||
|
||||
it("kiro empty-old prepend fires when prompt appears mid-tail only", () => {
|
||||
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`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system-inject fail-open", () => {
|
||||
it("null/undefined bodies never throw", () => {
|
||||
expect(() => injectSystemPrompt(null, FORMATS.OPENAI, P1)).not.toThrow();
|
||||
expect(() => injectSystemPrompt(undefined, FORMATS.OPENAI, P1)).not.toThrow();
|
||||
expect(() => injectSystemPrompt({}, FORMATS.OPENAI, null)).not.toThrow();
|
||||
});
|
||||
|
||||
it("malformed messages array never throws", () => {
|
||||
expect(() => injectSystemPrompt({ messages: null }, FORMATS.OPENAI, P1)).not.toThrow();
|
||||
expect(() => injectSystemPrompt({ messages: "bad" }, FORMATS.OPENAI, P1)).not.toThrow();
|
||||
expect(() => injectSystemPrompt({ messages: [{ role: null, content: null }] }, FORMATS.OPENAI, P1)).not.toThrow();
|
||||
});
|
||||
|
||||
it("frozen body never throws and does not partially mutate", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }] };
|
||||
Object.freeze(body);
|
||||
Object.freeze(body.messages);
|
||||
Object.freeze(body.messages[0]);
|
||||
expect(() => injectSystemPrompt(body, FORMATS.OPENAI, P1)).not.toThrow();
|
||||
expect(body.messages[0].content).toBe("hello");
|
||||
});
|
||||
|
||||
it("Proxy throwing setter never throws", () => {
|
||||
const throwingMsg = new Proxy({ role: ROLE.SYSTEM, content: "hello" }, {
|
||||
set() { throw new Error("msg setter fail"); },
|
||||
});
|
||||
const arrProxy = new Proxy([throwingMsg], {
|
||||
get(t, p, r) { return Reflect.get(t, p, r); },
|
||||
set() { throw new Error("arr setter fail"); },
|
||||
});
|
||||
const proxy = new Proxy({}, {
|
||||
set(t, p, v) { if (p === "messages") throw new Error("setter fail"); return Reflect.set(t, p, v); },
|
||||
get(t, p) { if (p === "messages") return arrProxy; return t[p]; },
|
||||
});
|
||||
expect(() => injectSystemPrompt(proxy, FORMATS.OPENAI, P1)).not.toThrow();
|
||||
expect(() => injectSystemPrompt(proxy, FORMATS.OPENAI_RESPONSES, P1)).not.toThrow();
|
||||
});
|
||||
|
||||
it("frozen claude never throws", () => {
|
||||
const body = { system: [{ type: CLAUDE_BLOCK.TEXT, text: "a" }] };
|
||||
Object.freeze(body.system);
|
||||
expect(() => injectSystemPrompt(body, FORMATS.CLAUDE, P1)).not.toThrow();
|
||||
});
|
||||
|
||||
it("frozen gemini never throws", () => {
|
||||
const body = { systemInstruction: { parts: [{ text: "a" }] } };
|
||||
Object.freeze(body.systemInstruction.parts);
|
||||
expect(() => injectSystemPrompt(body, FORMATS.GEMINI, P1)).not.toThrow();
|
||||
});
|
||||
|
||||
it("injectCaveman and injectPonytail fail open on frozen", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "hi" }] };
|
||||
Object.freeze(body);
|
||||
Object.freeze(body.messages);
|
||||
expect(() => injectCaveman(body, FORMATS.OPENAI, "full")).not.toThrow();
|
||||
expect(() => injectPonytail(body, FORMATS.OPENAI, "full")).not.toThrow();
|
||||
});
|
||||
|
||||
it("different caveman and ponytail prompts both apply", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "base" }] };
|
||||
injectCaveman(body, FORMATS.OPENAI, "full");
|
||||
const afterCaveman = body.messages[0].content;
|
||||
expect(afterCaveman).toContain(CAVEMAN_PROMPTS.full.slice(0, 30));
|
||||
injectPonytail(body, FORMATS.OPENAI, "full");
|
||||
expect(body.messages[0].content).toContain(PONYTAIL_PROMPTS.full.slice(0, 30));
|
||||
expect(body.messages[0].content).toContain(afterCaveman);
|
||||
});
|
||||
});
|
||||
@@ -102,19 +102,38 @@ describe("refreshAccessToken — config-driven profiles", () => {
|
||||
expect(fm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshAccessToken — legacy generic path (no profile)", () => {
|
||||
describe("Cline refresh", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; });
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it("still works for an unprofiled provider via config.refreshUrl/clientId/clientSecret", async () => {
|
||||
const fm = mockFetchOnce({ access_token: "gen-acc", expires_in: 3600 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
it("uses the extension JSON refresh contract", async () => {
|
||||
const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
|
||||
const fm = mockFetchOnce({
|
||||
data: {
|
||||
accessToken: "cline-acc",
|
||||
refreshToken: "cline-rot",
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
const { refreshTokenByProvider } = await import(
|
||||
"open-sse/services/tokenRefresh.js"
|
||||
);
|
||||
|
||||
await refreshAccessToken("cline", "gen-old", {}, console);
|
||||
const out = await refreshTokenByProvider(
|
||||
"cline",
|
||||
{ refreshToken: "cline-old" },
|
||||
console
|
||||
);
|
||||
|
||||
const body = new URLSearchParams(fm.mock.calls[0][1].body);
|
||||
expect(body.get("grant_type")).toBe("refresh_token");
|
||||
expect(body.get("client_id")).toBeTruthy();
|
||||
const [, init] = fm.mock.calls[0];
|
||||
expect(init.headers["Content-Type"]).toBe("application/json");
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
refreshToken: "cline-old",
|
||||
grantType: "refresh_token",
|
||||
clientType: "extension",
|
||||
});
|
||||
expect(out.accessToken).toBe("cline-acc");
|
||||
expect(out.refreshToken).toBe("cline-rot");
|
||||
expect(out.expiresIn).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
"deepseek", "opencode-go", "zed",
|
||||
];
|
||||
|
||||
describe("usage dispatch", () => {
|
||||
|
||||
71
tests/unit/v1-model-lookup-3588.test.js
Normal file
71
tests/unit/v1-model-lookup-3588.test.js
Normal file
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
154
tests/unit/xquik-search-provider.test.js
Normal file
154
tests/unit/xquik-search-provider.test.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import REGISTRY from "../../open-sse/providers/registry/index.js";
|
||||
import { buildSearchRequest } from "../../open-sse/handlers/search/callers.js";
|
||||
import { handleSearchCore } from "../../open-sse/handlers/search/index.js";
|
||||
import { normalizeSearchResponse } from "../../open-sse/handlers/search/normalizers.js";
|
||||
import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers.js";
|
||||
|
||||
const CONFIG = {
|
||||
id: "xquik",
|
||||
baseUrl: "https://xquik.com/api/v1/x/tweets/search",
|
||||
method: "GET",
|
||||
authType: "apikey",
|
||||
searchTypes: ["x"],
|
||||
defaultMaxResults: 5,
|
||||
maxMaxResults: 100,
|
||||
creditsPerResult: 1,
|
||||
};
|
||||
|
||||
const PARAMS = {
|
||||
query: "from:github release notes",
|
||||
searchType: "x",
|
||||
maxResults: 10,
|
||||
token: "xq_test_key",
|
||||
language: "en",
|
||||
providerOptions: { queryType: "Latest", cursor: "next page" },
|
||||
};
|
||||
|
||||
const RESPONSE = {
|
||||
tweets: [
|
||||
{
|
||||
id: "1234567890",
|
||||
text: "Release notes are live.",
|
||||
createdAt: "2026-08-25T12:00:00Z",
|
||||
author: { username: "github", name: "GitHub" },
|
||||
media: [{ mediaUrl: "https://pbs.twimg.com/media/example.jpg", type: "photo" }],
|
||||
},
|
||||
],
|
||||
has_next_page: true,
|
||||
next_cursor: "cursor-2",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Xquik search provider", () => {
|
||||
it("registers a dedicated X search provider with no-charge key validation", () => {
|
||||
const entry = REGISTRY.find((candidate) => candidate.id === "xquik");
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
category: "apikey",
|
||||
serviceKinds: ["webSearch"],
|
||||
searchConfig: {
|
||||
authHeader: "x-api-key",
|
||||
validateUrl: "https://xquik.com/api/v1/credits",
|
||||
searchTypes: ["x"],
|
||||
creditsPerResult: 1,
|
||||
},
|
||||
});
|
||||
expect(AI_PROVIDERS.xquik?.searchConfig).toEqual(entry.searchConfig);
|
||||
expect(getProvidersByKind("webSearch").map((provider) => provider.id)).toContain("xquik");
|
||||
});
|
||||
|
||||
it("builds the documented GET request without putting the key in the URL", () => {
|
||||
const request = buildSearchRequest(CONFIG, PARAMS);
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(url.origin + url.pathname).toBe("https://xquik.com/api/v1/x/tweets/search");
|
||||
expect(Object.fromEntries(url.searchParams)).toEqual({
|
||||
q: "from:github release notes",
|
||||
limit: "10",
|
||||
cursor: "next page",
|
||||
queryType: "Latest",
|
||||
language: "en",
|
||||
});
|
||||
expect(url.search).not.toContain("xq_test_key");
|
||||
expect(request.init).toEqual({
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", "x-api-key": "xq_test_key" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported query types before contacting Xquik", () => {
|
||||
expect(() => buildSearchRequest(CONFIG, {
|
||||
...PARAMS,
|
||||
providerOptions: { queryType: "Popular" },
|
||||
})).toThrow("Xquik queryType must be Latest or Top");
|
||||
});
|
||||
|
||||
it("normalizes posts and preserves cursor pagination", () => {
|
||||
const normalized = normalizeSearchResponse("xquik", RESPONSE, PARAMS.query, "x");
|
||||
|
||||
expect(normalized.totalResults).toBeNull();
|
||||
expect(normalized.pagination).toEqual({ has_more: true, next_cursor: "cursor-2" });
|
||||
expect(normalized.results).toHaveLength(1);
|
||||
expect(normalized.results[0]).toMatchObject({
|
||||
title: "@github on X",
|
||||
url: "https://x.com/github/status/1234567890",
|
||||
display_url: "x.com/github/status/1234567890",
|
||||
snippet: "Release notes are live.",
|
||||
published_at: "2026-08-25T12:00:00Z",
|
||||
metadata: {
|
||||
author: "@github",
|
||||
source_type: "x_post",
|
||||
image_url: "https://pbs.twimg.com/media/example.jpg",
|
||||
},
|
||||
citation: { provider: "xquik", rank: 1 },
|
||||
});
|
||||
expect(normalized.results[0].content).toEqual({
|
||||
format: "text",
|
||||
text: "Release notes are live.",
|
||||
length: 23,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the stable status URL when author data is unavailable", () => {
|
||||
const normalized = normalizeSearchResponse("xquik", {
|
||||
tweets: [{ id: "9876543210", text: "Author data is unavailable." }],
|
||||
has_next_page: false,
|
||||
next_cursor: "",
|
||||
}, PARAMS.query, "x");
|
||||
|
||||
expect(normalized.results[0]).toMatchObject({
|
||||
title: "X post",
|
||||
url: "https://x.com/i/web/status/9876543210",
|
||||
metadata: { author: null, source_type: "x_post" },
|
||||
});
|
||||
expect(normalized.pagination).toEqual({ has_more: false, next_cursor: null });
|
||||
});
|
||||
|
||||
it("reports Xquik credits without claiming an unknown USD cost", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(RESPONSE), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})));
|
||||
|
||||
const result = await handleSearchCore({
|
||||
body: { query: PARAMS.query, max_results: 10, provider_options: PARAMS.providerOptions },
|
||||
provider: { id: "xquik" },
|
||||
providerConfig: CONFIG,
|
||||
credentials: { apiKey: "xq_test_key" },
|
||||
});
|
||||
const payload = await result.response.json();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(payload.usage).toEqual({
|
||||
queries_used: 1,
|
||||
search_cost_usd: null,
|
||||
provider_credits_used: 1,
|
||||
});
|
||||
expect(payload.pagination).toEqual({ has_more: true, next_cursor: "cursor-2" });
|
||||
});
|
||||
});
|
||||
189
tests/unit/zed-usage.test.js
Normal file
189
tests/unit/zed-usage.test.js
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/shared/zedAuth.js", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
fetchZedAuthenticatedUser: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { fetchZedAuthenticatedUser } from "../../open-sse/shared/zedAuth.js";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.js";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.js";
|
||||
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
import {
|
||||
formatZedPlanLabel,
|
||||
parseZedUsageLimit,
|
||||
parseZedAuthenticatedUserUsage,
|
||||
} from "../../open-sse/services/usage/zed.js";
|
||||
|
||||
describe("zed registry usage flags", () => {
|
||||
it("is listed in USAGE_SUPPORTED_PROVIDERS", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("zed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseZedUsageLimit", () => {
|
||||
it("parses unlimited string and object forms", () => {
|
||||
expect(parseZedUsageLimit("unlimited")).toEqual({ unlimited: true, total: 0 });
|
||||
expect(parseZedUsageLimit({ unlimited: true })).toEqual({ unlimited: true, total: 0 });
|
||||
});
|
||||
|
||||
it("parses numeric and limited object forms", () => {
|
||||
expect(parseZedUsageLimit(50)).toEqual({ unlimited: false, total: 50 });
|
||||
expect(parseZedUsageLimit("25")).toEqual({ unlimited: false, total: 25 });
|
||||
expect(parseZedUsageLimit({ limited: 40 })).toEqual({ unlimited: false, total: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatZedPlanLabel", () => {
|
||||
it("maps known plan ids", () => {
|
||||
expect(formatZedPlanLabel("zed_pro")).toBe("Zed Pro");
|
||||
expect(formatZedPlanLabel("zed_pro_trial")).toBe("Zed Pro Trial");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseZedAuthenticatedUserUsage", () => {
|
||||
it("maps edit_predictions and billing cycle reset", () => {
|
||||
const parsed = parseZedAuthenticatedUserUsage({
|
||||
plan: {
|
||||
plan_v3: "zed_pro",
|
||||
subscription_period: {
|
||||
started_at: "2026-07-01T00:00:00Z",
|
||||
ended_at: "2026-08-01T00:00:00Z",
|
||||
},
|
||||
usage: {
|
||||
edit_predictions: { used: 12, limit: 50 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.plan).toBe("Zed Pro");
|
||||
expect(parsed.quotas["Edit Predictions"]).toMatchObject({
|
||||
used: 12,
|
||||
total: 50,
|
||||
remainingPercentage: 76,
|
||||
resetAt: "2026-08-01T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks unlimited edit predictions at 100% remaining", () => {
|
||||
const parsed = parseZedAuthenticatedUserUsage({
|
||||
plan: {
|
||||
plan_v3: "zed_pro",
|
||||
usage: {
|
||||
edit_predictions: { used: 999, limit: "unlimited" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.quotas["Edit Predictions"]).toMatchObject({
|
||||
used: 999,
|
||||
total: 0,
|
||||
remainingPercentage: 100,
|
||||
unlimited: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips token-billed model_requests limit=0 and adds billing note", () => {
|
||||
const parsed = parseZedAuthenticatedUserUsage({
|
||||
plan: {
|
||||
plan_v3: "zed_student",
|
||||
usage: {
|
||||
model_requests: { used: 0, limit: { limited: 0 } },
|
||||
edit_predictions: { used: 0, limit: "unlimited" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.quotas["Hosted Model Requests"]).toBeUndefined();
|
||||
expect(parsed.quotas["Edit Predictions"]).toBeDefined();
|
||||
expect(parsed.message).toMatch(/token/i);
|
||||
expect(parsed.message).toMatch(/dashboard\.zed\.dev/);
|
||||
});
|
||||
|
||||
it("surfaces overdue invoice warning", () => {
|
||||
const parsed = parseZedAuthenticatedUserUsage({
|
||||
plan: {
|
||||
plan_v3: "zed_pro",
|
||||
has_overdue_invoices: true,
|
||||
usage: {
|
||||
edit_predictions: { used: 0, limit: "unlimited" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.hasOverdueInvoices).toBe(true);
|
||||
expect(parsed.message).toMatch(/overdue invoices/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsageForProvider(zed)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns quotas from /client/users/me", async () => {
|
||||
fetchZedAuthenticatedUser.mockResolvedValueOnce({
|
||||
plan: {
|
||||
plan_v3: "zed_student",
|
||||
usage: {
|
||||
edit_predictions: { used: 3, limit: 30 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "zed",
|
||||
accessToken: "plain-token",
|
||||
providerSpecificData: { userId: "user-42", systemId: "sys-1" },
|
||||
});
|
||||
|
||||
expect(usage.plan).toBe("Zed Student");
|
||||
expect(usage.quotas["Edit Predictions"]).toMatchObject({
|
||||
used: 3,
|
||||
total: 30,
|
||||
remainingPercentage: 90,
|
||||
});
|
||||
|
||||
expect(fetchZedAuthenticatedUser).toHaveBeenCalledWith(
|
||||
{
|
||||
accessToken: "plain-token",
|
||||
providerSpecificData: { userId: "user-42", systemId: "sys-1" },
|
||||
},
|
||||
{ proxyOptions: null },
|
||||
);
|
||||
});
|
||||
|
||||
it("requires user id on the connection", async () => {
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "zed",
|
||||
accessToken: "plain-token",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/missing user id/i);
|
||||
expect(fetchZedAuthenticatedUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuotaData(zed)", () => {
|
||||
it("normalizes zed quotas for QuotaTable", () => {
|
||||
const data = parseZedAuthenticatedUserUsage({
|
||||
plan: {
|
||||
plan_v3: "zed_pro",
|
||||
usage: { edit_predictions: { used: 10, limit: 20 } },
|
||||
},
|
||||
});
|
||||
|
||||
const rows = parseQuotaData("zed", data);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: "Edit Predictions",
|
||||
used: 10,
|
||||
total: 20,
|
||||
remainingPercentage: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user