Merge origin/master (v0.5.75) into gitea/new_feature
Resolve conflicts: - package.json / cli/package.json: take 0.5.75 - .gitignore: union both sides (upstream 9router-*/temp files + local state dirs) - CHANGELOG.md: keep both blocks, v0.5.75 above v0.5.70 - nonStreamingHandler.js: merge imports (unwrapClineEnvelope + tokensForDetail/shouldPersistRequestDetail); drop dead appendRequestLog - providers/[id]/page.js: union useState blocks (compatible-model states + importingClineModels) Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
@@ -192,9 +192,10 @@
|
||||
"baseUrl": "https://chatgpt.com/backend-api/codex/responses",
|
||||
"format": "openai-responses",
|
||||
"forceStream": true,
|
||||
"cliVersion": "0.154.0",
|
||||
"headers": {
|
||||
"originator": "codex_cli_rs",
|
||||
"User-Agent": "codex_cli_rs/0.136.0"
|
||||
"User-Agent": "codex_cli_rs/0.154.0"
|
||||
},
|
||||
"usage": {
|
||||
"url": "https://chatgpt.com/backend-api/wham/usage",
|
||||
|
||||
33
tests/translator/bugs-3905-deepseek-tool-type.test.js
Normal file
33
tests/translator/bugs-3905-deepseek-tool-type.test.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// Regression for #3905: defaultClaudeToolType() (type:"custom") must only run for
|
||||
// gateways that declare the requireClaudeToolType quirk (MiniMax). Claude-format
|
||||
// endpoints that only accept the legacy typeless tool shape — e.g. DeepSeek's
|
||||
// Anthropic-compatible endpoint, which answers HTTP 400 "unknown variant `custom`" —
|
||||
// must never receive tools[].type = "custom".
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PROVIDERS } from "../../open-sse/providers/index.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
import { shouldDefaultClaudeToolType } from "../../open-sse/translator/concerns/toolCall.js";
|
||||
|
||||
const tools = [{ name: "get_weather", description: "weather", input_schema: { type: "object" } }];
|
||||
|
||||
describe("Claude tool `type` defaulting is provider-scoped (#3905)", () => {
|
||||
it("runs only for providers declaring requireClaudeToolType", () => {
|
||||
expect(shouldDefaultClaudeToolType("minimax", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(true);
|
||||
expect(shouldDefaultClaudeToolType("minimax-cn", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(true);
|
||||
// Endpoints accepting only the legacy typeless shape must NOT get type:"custom".
|
||||
expect(shouldDefaultClaudeToolType("deepseek", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(false);
|
||||
expect(shouldDefaultClaudeToolType("claude", FORMATS.CLAUDE, tools, PROVIDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it("never applies outside Claude-format requests or without tools", () => {
|
||||
expect(shouldDefaultClaudeToolType("minimax", FORMATS.OPENAI, tools, PROVIDERS)).toBe(false);
|
||||
expect(shouldDefaultClaudeToolType("minimax", FORMATS.CLAUDE, undefined, PROVIDERS)).toBe(false);
|
||||
expect(shouldDefaultClaudeToolType("minimax", FORMATS.CLAUDE, null, PROVIDERS)).toBe(false);
|
||||
});
|
||||
|
||||
it("declares the quirk only on the MiniMax providers (registry tripwire)", () => {
|
||||
expect(PROVIDERS.minimax?.quirks?.requireClaudeToolType).toBe(true);
|
||||
expect(PROVIDERS["minimax-cn"]?.quirks?.requireClaudeToolType).toBe(true);
|
||||
expect(PROVIDERS.deepseek?.quirks?.requireClaudeToolType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -26,9 +26,8 @@ describe("Claude → Kiro (direct route)", () => {
|
||||
|
||||
expect(first.conversationState.conversationId).toBe("hermes-session-123-claude-replay");
|
||||
expect(second.conversationState.conversationId).toBe("hermes-session-123-claude-replay");
|
||||
expect(first.conversationState.agentContinuationId).toBeTruthy();
|
||||
expect(second.conversationState.agentContinuationId).toBe(first.conversationState.agentContinuationId);
|
||||
expect(first.conversationState.agentTaskType).toBe("vibe");
|
||||
expect(first.conversationState).not.toHaveProperty("agentContinuationId");
|
||||
expect(second.conversationState).not.toHaveProperty("agentTaskType");
|
||||
expect(second.conversationState.history[0].userInputMessage.content).toBe(
|
||||
first.conversationState.currentMessage.userInputMessage.content
|
||||
);
|
||||
@@ -84,7 +83,7 @@ describe("Claude → Kiro (direct route)", () => {
|
||||
expect(out.systemPrompt).toContain(
|
||||
"<thinking_mode>enabled</thinking_mode>"
|
||||
);
|
||||
expect(out.agentMode).toBe("vibe");
|
||||
expect(out).not.toHaveProperty("agentMode");
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for Kiro models without effort support", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ const proxyAwareFetch = vi.fn(async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => url.includes(":loadCodeAssist")
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }
|
||||
: {
|
||||
models: {
|
||||
"gemini-3.6-flash-high": {
|
||||
|
||||
@@ -4,7 +4,7 @@ const proxyAwareFetch = vi.fn(async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => url.includes(":loadCodeAssist")
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }
|
||||
: {
|
||||
models: {
|
||||
"gemini-3.7-flash-high": {
|
||||
|
||||
@@ -4,7 +4,7 @@ const proxyAwareFetch = vi.fn(async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => url.includes(":loadCodeAssist")
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }
|
||||
: {
|
||||
models: {
|
||||
"gemini-3.8-flash-high": {
|
||||
|
||||
@@ -4,8 +4,10 @@ const proxyAwareFetch = vi.fn(async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => url.includes(":loadCodeAssist")
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
|
||||
: { models: {} },
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }
|
||||
: url.includes(":retrieveUserQuotaSummary")
|
||||
? { groups: [] }
|
||||
: { models: {} },
|
||||
text: async () => "{}",
|
||||
}));
|
||||
|
||||
@@ -21,7 +23,8 @@ describe("Antigravity usage headers", () => {
|
||||
|
||||
await getAntigravityUsage("access-token", {});
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(2);
|
||||
// loadCodeAssist + fetchAvailableModels + retrieveUserQuotaSummary
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(3);
|
||||
for (const [, options] of proxyAwareFetch.mock.calls) {
|
||||
expect(options.headers["User-Agent"]).toBe("antigravity/ide/2.11.0 darwin/arm64");
|
||||
expect(options.headers).not.toHaveProperty("x-request-source");
|
||||
|
||||
113
tests/unit/antigravity-weekly-dashboard.test.js
Normal file
113
tests/unit/antigravity-weekly-dashboard.test.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
describe("Antigravity dashboard normalization with weekly quotas", () => {
|
||||
const data = {
|
||||
quotas: {
|
||||
"gemini-pro-agent": {
|
||||
displayName: "Gemini 3.1 Pro (High)",
|
||||
used: 200,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-08T00:00:00Z",
|
||||
remainingPercentage: 80,
|
||||
},
|
||||
"claude-opus-4-6-thinking": {
|
||||
displayName: "Claude Opus 4.6 (Thinking)",
|
||||
used: 100,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-08T00:00:00Z",
|
||||
remainingPercentage: 90,
|
||||
},
|
||||
gemini_weekly: {
|
||||
displayName: "Gemini (Weekly)",
|
||||
used: 250,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-15T00:00:00Z",
|
||||
remainingPercentage: 75,
|
||||
},
|
||||
claude_gpt_weekly: {
|
||||
displayName: "Claude & GPT (Weekly)",
|
||||
used: 500,
|
||||
total: 1000,
|
||||
resetAt: "2026-09-14T00:00:00Z",
|
||||
remainingPercentage: 50,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("includes weekly rows with correct display names", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const names = quotas.map((q) => q.name);
|
||||
|
||||
expect(names).toContain("Gemini (Flash / Pro)");
|
||||
expect(names).toContain("Claude (Sonnet / Opus)");
|
||||
expect(names).toContain("Gemini (Weekly)");
|
||||
expect(names).toContain("Claude & GPT (Weekly)");
|
||||
});
|
||||
|
||||
it("uses stable modelKey for weekly rows", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const keys = quotas.map((q) => q.modelKey);
|
||||
|
||||
expect(keys).toContain("gemini_weekly");
|
||||
expect(keys).toContain("claude_gpt_weekly");
|
||||
});
|
||||
|
||||
it("weekly rows carry correct quota values", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const geminiWeekly = quotas.find((q) => q.modelKey === "gemini_weekly");
|
||||
const claudeWeekly = quotas.find((q) => q.modelKey === "claude_gpt_weekly");
|
||||
|
||||
expect(geminiWeekly).toMatchObject({
|
||||
used: 250,
|
||||
total: 1000,
|
||||
remainingPercentage: 75,
|
||||
resetAt: "2026-09-15T00:00:00Z",
|
||||
});
|
||||
expect(claudeWeekly).toMatchObject({
|
||||
used: 500,
|
||||
total: 1000,
|
||||
remainingPercentage: 50,
|
||||
resetAt: "2026-09-14T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("weekly rows do NOT appear as otherModels", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const weeklyRows = quotas.filter((q) =>
|
||||
q.modelKey === "gemini_weekly" || q.modelKey === "claude_gpt_weekly"
|
||||
);
|
||||
expect(weeklyRows).toHaveLength(2);
|
||||
expect(weeklyRows[0].name).toMatch(/Weekly/);
|
||||
expect(weeklyRows[1].name).toMatch(/Weekly/);
|
||||
});
|
||||
|
||||
it("order: gemini family, claude family, weekly, then other", () => {
|
||||
const quotas = parseQuotaData("antigravity", data);
|
||||
const keys = quotas.map((q) => q.modelKey);
|
||||
|
||||
const geminiIdx = keys.indexOf("gemini");
|
||||
const claudeIdx = keys.indexOf("claude");
|
||||
const geminiWeeklyIdx = keys.indexOf("gemini_weekly");
|
||||
const claudeWeeklyIdx = keys.indexOf("claude_gpt_weekly");
|
||||
|
||||
expect(geminiIdx).toBeLessThan(geminiWeeklyIdx);
|
||||
expect(claudeIdx).toBeLessThan(claudeWeeklyIdx);
|
||||
});
|
||||
|
||||
it("works with no weekly keys present (backward compat)", () => {
|
||||
const noWeekly = {
|
||||
quotas: {
|
||||
"gemini-pro-agent": {
|
||||
displayName: "Gemini 3.1 Pro (High)",
|
||||
used: 200,
|
||||
total: 1000,
|
||||
remainingPercentage: 80,
|
||||
},
|
||||
},
|
||||
};
|
||||
const quotas = parseQuotaData("antigravity", noWeekly);
|
||||
expect(quotas).toHaveLength(1);
|
||||
expect(quotas[0].name).toBe("Gemini (Flash / Pro)");
|
||||
});
|
||||
});
|
||||
464
tests/unit/antigravity-weekly-quota.test.js
Normal file
464
tests/unit/antigravity-weekly-quota.test.js
Normal file
@@ -0,0 +1,464 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock proxyAwareFetch before any imports that use it
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import {
|
||||
parseWeeklyQuotaSummary,
|
||||
fetchAntigravityWeeklyQuota,
|
||||
_clearWeeklyCache,
|
||||
} from "../../open-sse/services/usage/antigravity-weekly.js";
|
||||
|
||||
// — Fixtures ——————————————————————————————————————————————
|
||||
const GEMINI_GROUP = {
|
||||
displayName: "Gemini Models",
|
||||
buckets: [
|
||||
{
|
||||
bucketId: "gemini-weekly-bucket",
|
||||
displayName: "Weekly Limit",
|
||||
remainingFraction: 0.75,
|
||||
resetTime: "2026-09-15T00:00:00Z",
|
||||
},
|
||||
{
|
||||
bucketId: "gemini-daily-bucket",
|
||||
displayName: "Daily Limit",
|
||||
remainingFraction: 0.9,
|
||||
resetTime: "2026-09-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CLAUDE_GPT_GROUP = {
|
||||
displayName: "Claude and GPT models",
|
||||
buckets: [
|
||||
{
|
||||
bucketId: "claude-gpt-weekly",
|
||||
displayName: "Weekly Quota",
|
||||
remainingFraction: 0.5,
|
||||
resetTime: "2026-09-14T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const FULL_RESPONSE = { groups: [GEMINI_GROUP, CLAUDE_GPT_GROUP] };
|
||||
|
||||
const NESTED_RESPONSE = {
|
||||
quotaSummary: {
|
||||
groups: [GEMINI_GROUP, CLAUDE_GPT_GROUP],
|
||||
},
|
||||
};
|
||||
|
||||
// — parseWeeklyQuotaSummary ———————————————————————————————
|
||||
describe("parseWeeklyQuotaSummary", () => {
|
||||
it("extracts Gemini weekly quota from top-level groups", () => {
|
||||
const result = parseWeeklyQuotaSummary(FULL_RESPONSE);
|
||||
expect(result.gemini_weekly).toMatchObject({
|
||||
used: 250,
|
||||
total: 1000,
|
||||
remainingPercentage: 75,
|
||||
displayName: "Gemini (Weekly)",
|
||||
unlimited: false,
|
||||
});
|
||||
expect(result.gemini_weekly.resetAt).toBe("2026-09-15T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("extracts Claude & GPT weekly quota", () => {
|
||||
const result = parseWeeklyQuotaSummary(FULL_RESPONSE);
|
||||
expect(result.claude_gpt_weekly).toMatchObject({
|
||||
used: 500,
|
||||
total: 1000,
|
||||
remainingPercentage: 50,
|
||||
displayName: "Claude & GPT (Weekly)",
|
||||
unlimited: false,
|
||||
});
|
||||
expect(result.claude_gpt_weekly.resetAt).toBe("2026-09-14T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("handles alternate nested quotaSummary.groups shape", () => {
|
||||
const result = parseWeeklyQuotaSummary(NESTED_RESPONSE);
|
||||
expect(result.gemini_weekly).toBeDefined();
|
||||
expect(result.claude_gpt_weekly).toBeDefined();
|
||||
expect(result.gemini_weekly.remainingPercentage).toBe(75);
|
||||
expect(result.claude_gpt_weekly.remainingPercentage).toBe(50);
|
||||
});
|
||||
|
||||
it("skips non-weekly buckets", () => {
|
||||
const data = {
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [
|
||||
{
|
||||
bucketId: "gemini-daily-bucket",
|
||||
displayName: "Daily Limit",
|
||||
remainingFraction: 0.9,
|
||||
resetTime: "2026-09-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}],
|
||||
};
|
||||
const result = parseWeeklyQuotaSummary(data);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("skips disabled weekly buckets", () => {
|
||||
const data = {
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [{
|
||||
bucketId: "gemini-weekly-bucket",
|
||||
displayName: "Weekly Limit",
|
||||
remainingFraction: 0.75,
|
||||
resetTime: "2026-09-15T00:00:00Z",
|
||||
disabled: true,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
const result = parseWeeklyQuotaSummary(data);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for null/undefined input", () => {
|
||||
expect(parseWeeklyQuotaSummary(null)).toEqual({});
|
||||
expect(parseWeeklyQuotaSummary(undefined)).toEqual({});
|
||||
expect(parseWeeklyQuotaSummary("string")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for response with no groups", () => {
|
||||
expect(parseWeeklyQuotaSummary({})).toEqual({});
|
||||
expect(parseWeeklyQuotaSummary({ groups: "not-array" })).toEqual({});
|
||||
expect(parseWeeklyQuotaSummary({ quotaSummary: {} })).toEqual({});
|
||||
});
|
||||
|
||||
it("handles groups with no buckets gracefully", () => {
|
||||
const data = {
|
||||
groups: [{ displayName: "Gemini Models" }],
|
||||
};
|
||||
expect(parseWeeklyQuotaSummary(data)).toEqual({});
|
||||
});
|
||||
|
||||
it("handles bucket with non-finite remainingFraction", () => {
|
||||
const data = {
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [{
|
||||
bucketId: "weekly-bucket",
|
||||
displayName: "Weekly",
|
||||
remainingFraction: "not-a-number",
|
||||
}],
|
||||
}],
|
||||
};
|
||||
expect(parseWeeklyQuotaSummary(data)).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores groups that don't match known families", () => {
|
||||
const data = {
|
||||
groups: [{
|
||||
displayName: "Unknown AI Provider",
|
||||
buckets: [{
|
||||
bucketId: "weekly-bucket",
|
||||
displayName: "Weekly",
|
||||
remainingFraction: 0.5,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
expect(parseWeeklyQuotaSummary(data)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// — fetchAntigravityWeeklyQuota ———————————————————————————
|
||||
describe("fetchAntigravityWeeklyQuota", () => {
|
||||
beforeEach(() => {
|
||||
proxyAwareFetch.mockReset();
|
||||
_clearWeeklyCache();
|
||||
});
|
||||
|
||||
it("fetches and returns parsed weekly quota on success", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => FULL_RESPONSE,
|
||||
});
|
||||
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result.gemini_weekly).toBeDefined();
|
||||
expect(result.claude_gpt_weekly).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns {} on HTTP 401", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({ ok: false, status: 401 });
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on HTTP 403", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({ ok: false, status: 403 });
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on HTTP 404", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({ ok: false, status: 404 });
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on HTTP 429", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({ ok: false, status: 429 });
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on network error", async () => {
|
||||
proxyAwareFetch.mockRejectedValue(new Error("network timeout"));
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on malformed JSON response", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => { throw new SyntaxError("Unexpected token"); },
|
||||
});
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("deduplicates concurrent requests for the same account", async () => {
|
||||
let resolveResponse;
|
||||
proxyAwareFetch.mockReturnValue(new Promise(resolve => {
|
||||
resolveResponse = resolve;
|
||||
}));
|
||||
|
||||
const p1 = fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
const p2 = fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
|
||||
resolveResponse({ ok: true, json: async () => FULL_RESPONSE });
|
||||
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
expect(r1).toEqual(r2);
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serves cached result within TTL", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => FULL_RESPONSE,
|
||||
});
|
||||
|
||||
await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
const result = await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
expect(result.gemini_weekly).toBeDefined();
|
||||
});
|
||||
|
||||
it("sends correct headers and body", async () => {
|
||||
proxyAwareFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ groups: [] }),
|
||||
});
|
||||
|
||||
await fetchAntigravityWeeklyQuota("token", "project-1");
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledWith(
|
||||
"https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Authorization": "Bearer token",
|
||||
"User-Agent": "antigravity/ide/2.11.0 darwin/arm64",
|
||||
"Content-Type": "application/json",
|
||||
"X-Client-Name": "antigravity",
|
||||
}),
|
||||
body: JSON.stringify({ project: "project-1" }),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// — Integration: weekly failure does not affect existing quotas —————
|
||||
describe("weekly quota isolation from existing quota", () => {
|
||||
beforeEach(() => {
|
||||
proxyAwareFetch.mockReset();
|
||||
_clearWeeklyCache();
|
||||
});
|
||||
|
||||
it("existing getAntigravityUsage succeeds even when weekly RPC fails", async () => {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
if (url.includes(":loadCodeAssist")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ cloudaicompanionProject: "p1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }),
|
||||
};
|
||||
}
|
||||
if (url.includes(":fetchAvailableModels")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.8-flash-high": {
|
||||
displayName: "Gemini 3.8 Flash (High)",
|
||||
quotaInfo: { remainingFraction: 0.85, resetTime: "2026-09-15T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes(":retrieveUserQuotaSummary")) {
|
||||
throw new Error("weekly endpoint unavailable");
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
});
|
||||
|
||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
||||
const result = await getAntigravityUsage("token", {});
|
||||
|
||||
expect(result.quotas["gemini-3.8-flash-high"]).toMatchObject({
|
||||
used: 150,
|
||||
total: 1000,
|
||||
remainingPercentage: 85,
|
||||
});
|
||||
expect(result.quotas.gemini_weekly).toBeUndefined();
|
||||
expect(result.quotas.claude_gpt_weekly).toBeUndefined();
|
||||
expect(result.message).toBeUndefined();
|
||||
});
|
||||
|
||||
it("free-tier accounts only show weekly quotas, not per-model short-window quotas", async () => {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
if (url.includes(":loadCodeAssist")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ cloudaicompanionProject: "p1", currentTier: { name: "Starter" }, paidTier: { id: "free-tier", name: "Antigravity Starter Quota" } }),
|
||||
};
|
||||
}
|
||||
if (url.includes(":fetchAvailableModels")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.8-flash-high": {
|
||||
displayName: "Gemini 3.8 Flash (High)",
|
||||
quotaInfo: { remainingFraction: 1, resetTime: "2026-09-15T00:00:00Z" },
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
displayName: "Claude Sonnet 4.6",
|
||||
// Missing remainingFraction — free tier exhausted
|
||||
quotaInfo: { resetTime: "2026-09-13T12:00:00Z" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes(":retrieveUserQuotaSummary")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [{
|
||||
bucketId: "gemini-weekly",
|
||||
displayName: "Weekly Limit Remaining",
|
||||
remainingFraction: 1,
|
||||
resetTime: "2026-09-15T00:00:00Z",
|
||||
}],
|
||||
}, {
|
||||
displayName: "Claude and GPT models",
|
||||
buckets: [{
|
||||
bucketId: "3p-weekly",
|
||||
displayName: "Weekly Limit Remaining",
|
||||
remainingFraction: 0,
|
||||
resetTime: "2026-09-13T12:00:00Z",
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
});
|
||||
|
||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
||||
const result = await getAntigravityUsage("token", {});
|
||||
|
||||
// Per-model quotas should be absent (free-tier accounts skip model parsing)
|
||||
expect(result.quotas["gemini-3.8-flash-high"]).toBeUndefined();
|
||||
expect(result.quotas["claude-sonnet-4-6"]).toBeUndefined();
|
||||
|
||||
// Only weekly quotas should appear
|
||||
expect(result.quotas.gemini_weekly).toMatchObject({
|
||||
used: 0,
|
||||
total: 1000,
|
||||
remainingPercentage: 100,
|
||||
});
|
||||
expect(result.quotas.claude_gpt_weekly).toMatchObject({
|
||||
used: 1000,
|
||||
total: 1000,
|
||||
remainingPercentage: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reconciles weekly quota to 0% when all paid-tier family models are exhausted", async () => {
|
||||
proxyAwareFetch.mockImplementation(async (url) => {
|
||||
if (url.includes(":loadCodeAssist")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ cloudaicompanionProject: "p1", currentTier: { name: "Pro" }, paidTier: { id: "g1-pro-tier", name: "Google AI Pro" } }),
|
||||
};
|
||||
}
|
||||
if (url.includes(":fetchAvailableModels")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: {
|
||||
"gemini-3.8-flash-high": {
|
||||
displayName: "Gemini 3.8 Flash (High)",
|
||||
// Exhausted model: no remainingFraction, future resetTime
|
||||
quotaInfo: { resetTime: "2026-09-13T12:00:00Z" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes(":retrieveUserQuotaSummary")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
groups: [{
|
||||
displayName: "Gemini Models",
|
||||
buckets: [{
|
||||
bucketId: "gemini-weekly",
|
||||
displayName: "Weekly Limit Remaining",
|
||||
remainingFraction: 1,
|
||||
resetTime: "2026-09-15T00:00:00Z",
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
});
|
||||
|
||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
||||
const result = await getAntigravityUsage("token", {});
|
||||
|
||||
// Per-model quota should show exhausted
|
||||
expect(result.quotas["gemini-3.8-flash-high"].remainingPercentage).toBe(0);
|
||||
// Weekly quota should be reconciled to 0% with the family reset time
|
||||
expect(result.quotas.gemini_weekly).toMatchObject({
|
||||
used: 1000,
|
||||
total: 1000,
|
||||
remainingPercentage: 0,
|
||||
resetAt: "2026-09-13T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
36
tests/unit/api-airforce-free-models.test.js
Normal file
36
tests/unit/api-airforce-free-models.test.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import airforce from "../../open-sse/providers/registry/api-airforce.js";
|
||||
import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
|
||||
describe("api-airforce free models", () => {
|
||||
const ids = airforce.models.map((m) => m.id);
|
||||
|
||||
it("registers the three live free models", () => {
|
||||
expect(ids).toContain("gpt-oss-120b");
|
||||
expect(ids).toContain("gpt-oss-20b");
|
||||
expect(ids).toContain("kimi-k2.7-code");
|
||||
});
|
||||
|
||||
it("drops the dead catalog ids", () => {
|
||||
expect(ids).not.toContain("anthropic/claude-3.7-sonnet");
|
||||
expect(ids).not.toContain("moonshot/kimi-k2.6");
|
||||
expect(ids).not.toContain("google/gemini-2.5-flash");
|
||||
});
|
||||
|
||||
it("is passthrough so any live id resolves", () => {
|
||||
expect(airforce.passthroughModels).toBe(true);
|
||||
expect(PROVIDERS["api-airforce"].forceStream).toBe(true);
|
||||
});
|
||||
|
||||
it("PROVIDER_MODELS['af'] exposes the new ids", () => {
|
||||
expect(PROVIDER_MODELS.af.map((m) => m.id)).toEqual(expect.arrayContaining([
|
||||
"gpt-oss-120b", "gpt-oss-20b", "kimi-k2.7-code",
|
||||
]));
|
||||
});
|
||||
|
||||
it("caps resolve for the free ids", () => {
|
||||
expect(getCapabilitiesForModel("api-airforce", "kimi-k2.7-code").reasoning).toBe(true);
|
||||
expect(getCapabilitiesForModel("api-airforce", "gpt-oss-120b").reasoning).toBe(true);
|
||||
});
|
||||
});
|
||||
222
tests/unit/claude-cache-budget-single-object.test.js
Normal file
222
tests/unit/claude-cache-budget-single-object.test.js
Normal file
@@ -0,0 +1,222 @@
|
||||
// #3795 — a proxy must not dispatch more cache_control blocks than Anthropic
|
||||
// accepts, and must not lose turns that use single-object content (#3567 interplay).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
anchorClaudeCache,
|
||||
normalizeClaudePassthrough,
|
||||
prepareClaudeRequest,
|
||||
} from "../../open-sse/translator/formats/claude.js";
|
||||
import { claudeToOpenAIRequest } from "../../open-sse/translator/request/claude-to-openai.js";
|
||||
|
||||
const CC = { type: "ephemeral" };
|
||||
const text = (t, extra = {}) => ({ type: "text", text: t, ...extra });
|
||||
const tool = (name, extra = {}) => ({ name, description: "d", input_schema: {}, ...extra });
|
||||
|
||||
// counts markers incl. single-object content — mirrors the upstream contract
|
||||
function countMarkers(body) {
|
||||
let n = 0;
|
||||
if (Array.isArray(body.system)) for (const b of body.system) if (b?.cache_control) n++;
|
||||
if (Array.isArray(body.tools)) for (const t of body.tools) if (t?.cache_control) n++;
|
||||
if (Array.isArray(body.messages)) for (const m of body.messages) {
|
||||
if (Array.isArray(m?.content)) {
|
||||
for (const b of m.content) if (b?.cache_control) n++;
|
||||
} else if (m?.content && typeof m.content === "object" && m.content.cache_control) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
describe("cache marker budget and single-block content", () => {
|
||||
it("never emits more than four markers when the client already spent its budget", () => {
|
||||
const out = anchorClaudeCache({
|
||||
system: [text("s1"), text("s2", { cache_control: CC })],
|
||||
tools: [tool("t1"), tool("t2", { cache_control: CC })],
|
||||
messages: [
|
||||
{ role: "user", content: text("u1", { cache_control: CC }) },
|
||||
{ role: "assistant", content: text("a1", { cache_control: CC }) },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
});
|
||||
expect(countMarkers(out)).toBeLessThanOrEqual(4); // base: 5
|
||||
});
|
||||
|
||||
it("normalizes a single-object turn in passthrough and anchors it", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1")] },
|
||||
{ role: "assistant", content: text("a1") }, // single object, no marker
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
};
|
||||
normalizeClaudePassthrough(body);
|
||||
const assistant = body.messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(Array.isArray(assistant.content)).toBe(true); // base: still bare object
|
||||
expect(assistant.content).toHaveLength(1);
|
||||
const out = anchorClaudeCache(body);
|
||||
expect(countMarkers(out)).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps a turn whose content is a single object and strips its marker", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-sonnet-5", max_tokens: 100,
|
||||
system: [text("s1")],
|
||||
messages: [
|
||||
{ role: "user", content: text("u1", { cache_control: CC }) },
|
||||
{ role: "assistant", content: [text("a1")] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
}, "claude");
|
||||
const kept = out.messages.filter(m => JSON.stringify(m.content).includes("u1"));
|
||||
expect(kept.length).toBe(1); // base: 0 (dropped)
|
||||
expect(kept[0].content).toHaveLength(1); // normalized to array
|
||||
expect(kept[0].content[0].cache_control).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops no conversation turn when content is a single text object", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-sonnet-5", max_tokens: 100,
|
||||
messages: [
|
||||
{ role: "user", content: text("u1") },
|
||||
{ role: "assistant", content: text("a1") },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
}, "claude");
|
||||
expect(out.messages.length).toBe(3); // base: 1
|
||||
expect(Array.isArray(out.messages[0].content)).toBe(true);
|
||||
});
|
||||
|
||||
it("re-anchors the last assistant turn even when it uses single-object content", () => {
|
||||
const out = prepareClaudeRequest({
|
||||
model: "claude-sonnet-5", max_tokens: 100,
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1")] },
|
||||
{ role: "assistant", content: text("a1") },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
}, "claude");
|
||||
expect(countMarkers(out)).toBe(1); // base: 0
|
||||
});
|
||||
|
||||
it("keeps a marked single-object turn when the marker budget is spent", () => {
|
||||
const body = {
|
||||
system: [text("s1", { cache_control: CC })],
|
||||
tools: [tool("t1", { cache_control: CC })],
|
||||
messages: [
|
||||
{ role: "user", content: [text("c1"), text("c2")] },
|
||||
{ role: "assistant", content: [text("a1", { cache_control: CC })] },
|
||||
{ role: "user", content: text("u1", { cache_control: CC }) },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
};
|
||||
normalizeClaudePassthrough(body);
|
||||
const out = anchorClaudeCache(body);
|
||||
const kept = out.messages.filter(m => JSON.stringify(m.content).includes("u1"));
|
||||
expect(kept.length).toBe(1);
|
||||
expect(Array.isArray(kept[0].content)).toBe(true); // base: bare object survives
|
||||
expect(kept[0].content).toHaveLength(1);
|
||||
expect(kept[0].content[0].cache_control).toBeUndefined();
|
||||
const ctx = out.messages.find(m => JSON.stringify(m.content).includes("c1"));
|
||||
expect(ctx.content).toEqual([text("c1"), text("c2")]);
|
||||
expect(countMarkers(out)).toBeLessThanOrEqual(4); // fixed: 3
|
||||
});
|
||||
|
||||
it("keeps single-object turns on the claude-to-openai leg", () => {
|
||||
const out = claudeToOpenAIRequest("m", {
|
||||
messages: [
|
||||
{ role: "user", content: text("u1") },
|
||||
{ role: "assistant", content: { type: "image", source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" } } },
|
||||
],
|
||||
}, false);
|
||||
expect(out.messages.some(m => JSON.stringify(m.content).includes("u1"))).toBe(true); // base: dropped
|
||||
const img = out.messages.find(m => m.role === "assistant");
|
||||
expect(JSON.stringify(img.content)).toContain("image_url"); // base: dropped
|
||||
});
|
||||
|
||||
it("keeps a bare-object user turn folded with a mid-conversation system message", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "user", content: text("u1") },
|
||||
{ role: "system", content: [text("reminder")] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
};
|
||||
normalizeClaudePassthrough(body);
|
||||
const first = body.messages[0];
|
||||
expect(Array.isArray(first.content)).toBe(true);
|
||||
expect(JSON.stringify(first.content).includes("u1")).toBe(true); // pre-hoist: fold zeroes bare-object content
|
||||
expect(JSON.stringify(first.content).includes("reminder")).toBe(true);
|
||||
});
|
||||
it("prunes a client body that already carries five markers down to four", () => {
|
||||
const out = anchorClaudeCache({
|
||||
system: [text("s1"), text("s2", { cache_control: CC })],
|
||||
tools: [tool("t1", { cache_control: CC }), tool("t2", { cache_control: CC })],
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1", { cache_control: CC })] },
|
||||
{ role: "assistant", content: [text("a1", { cache_control: CC })] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
});
|
||||
expect(countMarkers(out)).toBe(4); // pre-fix: 5 forwarded unchanged
|
||||
expect(out.system[0].cache_control).toBeUndefined(); // earliest marker pruned
|
||||
});
|
||||
|
||||
// A spent budget must not cost the head anchors their 1h TTL: system/tools are
|
||||
// the whole point of re-anchoring, and a 5m fallback silently halves the cache
|
||||
// lifetime on exactly the requests that already cached aggressively.
|
||||
it("keeps the 1h head anchors when the client spent the whole budget", () => {
|
||||
const out = anchorClaudeCache({
|
||||
system: [text("s1"), text("s2", { cache_control: CC })],
|
||||
tools: [tool("t1", { cache_control: CC }), tool("t2")],
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1", { cache_control: CC })] },
|
||||
{ role: "assistant", content: [text("a1", { cache_control: CC })] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
});
|
||||
expect(countMarkers(out)).toBeLessThanOrEqual(4);
|
||||
expect(out.system.at(-1).cache_control?.ttl).toBe("1h"); // pre-fix: fell back to 5m
|
||||
expect(out.tools.at(-1).cache_control?.ttl).toBe("1h"); // pre-fix: fell back to 5m
|
||||
});
|
||||
|
||||
it("keeps the 1h head anchors on an over-budget body", () => {
|
||||
const out = anchorClaudeCache({
|
||||
system: [text("s1", { cache_control: CC })],
|
||||
tools: [tool("t1", { cache_control: CC }), tool("t2")],
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1", { cache_control: CC })] },
|
||||
{ role: "assistant", content: [text("a1", { cache_control: CC })] },
|
||||
{ role: "user", content: [text("u2", { cache_control: CC })] },
|
||||
{ role: "assistant", content: [text("a2", { cache_control: CC })] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
});
|
||||
expect(countMarkers(out)).toBe(4);
|
||||
expect(out.system.at(-1).cache_control?.ttl).toBe("1h");
|
||||
expect(out.tools.at(-1).cache_control?.ttl).toBe("1h");
|
||||
});
|
||||
|
||||
it("strips a marker from a deferred tool even when the budget is spent", () => {
|
||||
const out = anchorClaudeCache({
|
||||
system: [text("s1", { cache_control: CC })],
|
||||
tools: [tool("t1", { cache_control: CC, defer_loading: true })],
|
||||
messages: [
|
||||
{ role: "user", content: [text("u1", { cache_control: CC })] },
|
||||
{ role: "assistant", content: [text("a1", { cache_control: CC })] },
|
||||
{ role: "user", content: [text("q")] },
|
||||
],
|
||||
});
|
||||
expect(countMarkers(out)).toBeLessThanOrEqual(4);
|
||||
const deferred = out.tools.find(t => t.defer_loading);
|
||||
expect(deferred?.cache_control).toBeUndefined(); // pre-fix: invalid marker forwarded
|
||||
});
|
||||
|
||||
it("keeps a bare-object system reminder on the claude-to-openai leg", () => {
|
||||
const out = claudeToOpenAIRequest("m", {
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "system", content: text("be brief") },
|
||||
],
|
||||
}, false);
|
||||
expect(JSON.stringify(out.messages)).toContain("be brief"); // pre-fix: turn dropped
|
||||
});
|
||||
});
|
||||
40
tests/unit/cline-auth.test.js
Normal file
40
tests/unit/cline-auth.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
getClineAccessToken,
|
||||
getClineAuthorizationHeader,
|
||||
} from "../../open-sse/shared/clineAuth.js";
|
||||
|
||||
test("getClineAccessToken keeps an existing workos: prefix", () => {
|
||||
const token = "workos:eyJhbGciOiJSUzI1NiJ9.eyJwYXAiJ9";
|
||||
assert.equal(getClineAccessToken(token), token);
|
||||
assert.equal(getClineAccessToken(` ${token} `), token);
|
||||
});
|
||||
|
||||
test("getClineAccessToken prefixes a bare WorkOS JWT with workos:", () => {
|
||||
const jwt = "eyJhbGciOiJSUzI1NiJ9.eyJwYXAiJ9";
|
||||
assert.equal(getClineAccessToken(jwt), `workos:${jwt}`);
|
||||
});
|
||||
|
||||
test("getClineAccessToken does NOT prefix ClinePass API keys", () => {
|
||||
// ClinePass API keys are opaque strings (e.g. clp_…). Sending them as
|
||||
// `workos:clp_…` makes api.cline.bot respond 401.
|
||||
assert.equal(getClineAccessToken("clp_1234567890abcdef"), "clp_1234567890abcdef");
|
||||
assert.equal(getClineAccessToken("sk-9r-abcdef"), "sk-9r-abcdef");
|
||||
assert.equal(getClineAccessToken(""), "");
|
||||
assert.equal(getClineAccessToken(" "), "");
|
||||
assert.equal(getClineAccessToken(undefined), "");
|
||||
assert.equal(getClineAccessToken(null), "");
|
||||
});
|
||||
|
||||
test("getClineAuthorizationHeader builds a Bearer header without double prefixing", () => {
|
||||
assert.equal(getClineAuthorizationHeader("clp_abc"), "Bearer clp_abc");
|
||||
assert.equal(
|
||||
getClineAuthorizationHeader("eyJpeg.eyJbG"),
|
||||
"Bearer workos:eyJpeg.eyJbG"
|
||||
);
|
||||
assert.equal(
|
||||
getClineAuthorizationHeader("workos:eyJpeg.eyJbG"),
|
||||
"Bearer workos:eyJpeg.eyJbG"
|
||||
);
|
||||
});
|
||||
297
tests/unit/cline-free-models-envelope.test.js
Normal file
297
tests/unit/cline-free-models-envelope.test.js
Normal file
@@ -0,0 +1,297 @@
|
||||
// Cline free models (z-ai/glm-5.3-flash, deepseek-v4-flash) wrap non-stream
|
||||
// chat completions in {"success":true,"data":{...choices...}} on
|
||||
// https://api.cline.bot/api/v1/chat/completions. Both the UI model-test ping
|
||||
// (src/app/api/models/test/ping.js) and the proxy non-stream path
|
||||
// (open-sse/handlers/chatCore/nonStreamingHandler.js) read `choices` at the
|
||||
// top level, so enveloped choices are invisible ("Provider returned no
|
||||
// completion choices for this model"). These tests pin the unwrap behavior.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock the heavy Next.js-dependent imports BEFORE importing ping.js
|
||||
// (same pattern as tests/unit/ping-reasoning-models-3010.test.js).
|
||||
vi.mock("@/lib/localDb", () => ({ getApiKeys: vi.fn(async () => [{ key: "test-key", isActive: true }]) }));
|
||||
vi.mock("@/shared/constants/config", () => ({ UPDATER_CONFIG: { appPort: 20127 } }));
|
||||
vi.mock("@/shared/utils/machineId", () => ({ getConsistentMachineId: vi.fn(async () => "cli-token") }));
|
||||
// requestDetail.js imports from @/lib/usageDb.js too, so one mock covers both
|
||||
// the handler and its usage/detail helpers.
|
||||
vi.mock("@/lib/usageDb.js", () => ({
|
||||
appendRequestLog: vi.fn(async () => {}),
|
||||
saveRequestDetail: vi.fn(async () => {}),
|
||||
saveRequestUsage: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const { pingModelByKind } = await import("../../src/app/api/models/test/ping.js");
|
||||
const { handleNonStreamingResponse } = await import("../../open-sse/handlers/chatCore/nonStreamingHandler.js");
|
||||
|
||||
// The proxy adds a 2000-token headroom buffer to usage before returning it
|
||||
// to the client (addBufferToUsage), so response-body usage is input + 2000.
|
||||
// The usage recorded via saveRequestUsage is the unbuffered extraction —
|
||||
// asserting on it proves the unwrap ran before usage extraction.
|
||||
const { saveRequestUsage } = await import("@/lib/usageDb.js");
|
||||
|
||||
describe("cline free-models {success,data} envelope", () => {
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function jsonResponse(obj) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify(obj),
|
||||
json: async () => obj,
|
||||
};
|
||||
}
|
||||
|
||||
it("ping: enveloped success unwraps to ok:true (regression for reported error)", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: { choices: [{ message: { content: "OK" } }] } })
|
||||
);
|
||||
const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127");
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("ping: enveloped reasoning-only response still soft-passes with note", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
choices: [
|
||||
{
|
||||
finish_reason: "length",
|
||||
message: { content: "", reasoning: "The user said hi" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.note).toMatch(/reasoning-only/);
|
||||
});
|
||||
|
||||
it("ping: error envelope passes through without unwrap", async () => {
|
||||
const body = { error: "empty response content", success: false };
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
});
|
||||
const result = await pingModelByKind("cl/z-ai/glm-5.3-flash", "llm", "http://127.0.0.1:20127");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/empty response content/);
|
||||
});
|
||||
|
||||
it("ping: bare (un-enveloped) body still passes", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ choices: [{ message: { content: "Hello!" } }] }));
|
||||
const result = await pingModelByKind("openai/gpt-4o", "llm", "http://127.0.0.1:20127");
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("ping: does not unwrap for a provider that did not opt in", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: { choices: [{ message: { content: "OK" } }] } })
|
||||
);
|
||||
const result = await pingModelByKind("openai/gpt-4o", "llm", "http://127.0.0.1:20127");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/no completion choices/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cline free-models envelope in nonStreamingHandler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function stubLogger() {
|
||||
return { logProviderResponse() {}, logConvertedResponse() {} };
|
||||
}
|
||||
|
||||
function callHandler(providerResponse, provider = "cline") {
|
||||
return handleNonStreamingResponse({
|
||||
providerResponse,
|
||||
provider,
|
||||
model: "z-ai/glm-5.3-flash",
|
||||
sourceFormat: "openai",
|
||||
targetFormat: "openai",
|
||||
body: { stream: false },
|
||||
stream: false,
|
||||
translatedBody: null,
|
||||
finalBody: null,
|
||||
requestStartTime: Date.now(),
|
||||
connectionId: "c1",
|
||||
apiKey: "k",
|
||||
clientRawRequest: null,
|
||||
onRequestSuccess: () => {},
|
||||
reqLogger: stubLogger(),
|
||||
toolNameMap: null,
|
||||
customToolNames: null,
|
||||
trackDone: () => {},
|
||||
appendLog: () => {},
|
||||
pxpipe: null,
|
||||
reqTag: "t",
|
||||
log: null,
|
||||
});
|
||||
}
|
||||
|
||||
it("unwraps the {success,data} envelope before usage extraction and translation", async () => {
|
||||
const providerResponse = new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
choices: [{ message: { content: "Hi" } }],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 2 },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
const result = await callHandler(providerResponse);
|
||||
expect(result.success).toBe(true);
|
||||
const body = await result.response.json();
|
||||
expect(body.choices).toBeDefined();
|
||||
expect(body.choices[0].message.content).toBe("Hi");
|
||||
expect(body.success).toBeUndefined();
|
||||
expect(saveRequestUsage).toHaveBeenCalledTimes(1);
|
||||
expect(saveRequestUsage.mock.calls[0][0].tokens).toMatchObject({
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
});
|
||||
expect(body.usage.prompt_tokens).toBe(2005);
|
||||
});
|
||||
|
||||
it("passes a bare (non-enveloped) body through unchanged", async () => {
|
||||
const providerResponse = new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: "Hi" } }],
|
||||
usage: { prompt_tokens: 3, completion_tokens: 1 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
const result = await callHandler(providerResponse);
|
||||
expect(result.success).toBe(true);
|
||||
const body = await result.response.json();
|
||||
expect(body.choices[0].message.content).toBe("Hi");
|
||||
expect(saveRequestUsage).toHaveBeenCalledTimes(1);
|
||||
expect(saveRequestUsage.mock.calls[0][0].tokens).toMatchObject({
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 1,
|
||||
});
|
||||
expect(body.usage.prompt_tokens).toBe(2003);
|
||||
});
|
||||
|
||||
// The unwrap is opt-in via transport.quirks.clineEnvelope so it can never
|
||||
// rewrite another provider's body — including one that happens to return
|
||||
// {"success":true,"data":...} for its own reasons.
|
||||
it("leaves an enveloped body untouched for a provider that did not opt in", async () => {
|
||||
const providerResponse = new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: { choices: [{ message: { content: "Hi" } }] },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
const result = await callHandler(providerResponse, "openai");
|
||||
const body = await result.response.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.choices[0].message.content).toBe("Hi");
|
||||
expect(body.choices).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cline /api/v1/models aggregation (resolveClineModels vs resolveClinepassModels)", () => {
|
||||
const API_MODELS_URL = "https://api.cline.bot/api/v1/models";
|
||||
|
||||
const API_RESPONSE = [
|
||||
{ id: "cline-pass/deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
||||
{ id: "cline-pass/glm-5.2", name: "GLM-5.2" },
|
||||
{ id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash" },
|
||||
{ id: "z-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash (Free)" },
|
||||
];
|
||||
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("resolveClineModels returns all models (including free-tier)", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => API_RESPONSE,
|
||||
});
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.models).toHaveLength(4);
|
||||
const ids = result.models.map((m) => m.id);
|
||||
expect(ids).toContain("cline-pass/deepseek-v4-flash");
|
||||
expect(ids).toContain("z-ai/glm-5.3-flash");
|
||||
expect(ids).toContain("z-ai/deepseek-v4-flash");
|
||||
});
|
||||
|
||||
it("resolveClinepassModels returns only cline-pass/ models", async () => {
|
||||
const { resolveClinepassModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => API_RESPONSE,
|
||||
});
|
||||
const result = await resolveClinepassModels({ accessToken: "test-token" });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.models).toHaveLength(2);
|
||||
const ids = result.models.map((m) => m.id);
|
||||
expect(ids).toContain("cline-pass/deepseek-v4-flash");
|
||||
expect(ids).toContain("cline-pass/glm-5.2");
|
||||
expect(ids).not.toContain("z-ai/glm-5.3-flash");
|
||||
});
|
||||
|
||||
it("resolveClineModels unwraps {success,data} envelope", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: API_RESPONSE }),
|
||||
});
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.models).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("resolveClineModels returns null when no token", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
const result = await resolveClineModels({});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("resolveClineModels returns null on fetch error", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
fetchMock.mockRejectedValue(new Error("network error"));
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("resolveClineModels returns {id,name} shape", async () => {
|
||||
const { resolveClineModels } = await import("../../open-sse/services/clinepassModels.js");
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => API_RESPONSE,
|
||||
});
|
||||
const result = await resolveClineModels({ accessToken: "test-token" });
|
||||
expect(result.models[0]).toHaveProperty("id");
|
||||
expect(result.models[0]).toHaveProperty("name");
|
||||
expect(typeof result.models[0].id).toBe("string");
|
||||
expect(typeof result.models[0].name).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,69 @@ describe("CodexExecutor tool normalization", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips only Unicode-property patterns rejected by Codex", () => {
|
||||
const unicodePattern = "^(?!__.*__$)[^\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}]{1,200}$";
|
||||
const validPattern = "^[a-z][a-z0-9_-]{0,31}$";
|
||||
const sourceParameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
artifact: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", pattern: unicodePattern },
|
||||
slug: { type: "string", pattern: validPattern },
|
||||
},
|
||||
},
|
||||
// A property named "pattern" is data, not the schema keyword.
|
||||
pattern: { type: "string", pattern: validPattern },
|
||||
},
|
||||
allOf: [{ properties: { title: { type: "string", pattern: unicodePattern } } }],
|
||||
};
|
||||
const tools = normalizeTools([{
|
||||
type: "function",
|
||||
name: "Artifact",
|
||||
parameters: sourceParameters,
|
||||
}]);
|
||||
|
||||
expect(tools[0].parameters.properties.artifact.properties.name.pattern).toBeUndefined();
|
||||
expect(tools[0].parameters.properties.artifact.properties.slug.pattern).toBe(validPattern);
|
||||
expect(tools[0].parameters.properties.pattern.pattern).toBe(validPattern);
|
||||
expect(tools[0].parameters.allOf[0].properties.title.pattern).toBeUndefined();
|
||||
// Copy-on-write: the caller's schema remains available for another provider.
|
||||
expect(sourceParameters.properties.artifact.properties.name.pattern).toBe(unicodePattern);
|
||||
});
|
||||
|
||||
it("keeps escaped literal property text and schema identity when no strip is needed", () => {
|
||||
const parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
literal: { type: "string", pattern: "^\\\\p{Cc}$" },
|
||||
simple: { type: "string", pattern: "^[A-Z]+$" },
|
||||
},
|
||||
};
|
||||
const tools = normalizeTools([{ type: "function", name: "probe", parameters }]);
|
||||
|
||||
expect(tools[0].parameters).toBe(parameters);
|
||||
expect(tools[0].parameters.properties.literal.pattern).toBe("^\\\\p{Cc}$");
|
||||
});
|
||||
|
||||
it("sanitizes nested namespace function schemas", () => {
|
||||
const tools = normalizeTools([{
|
||||
type: "namespace",
|
||||
name: "agent",
|
||||
tools: [{
|
||||
type: "function",
|
||||
name: "Artifact",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string", pattern: "^\\p{Cc}+$" } },
|
||||
},
|
||||
}],
|
||||
}]);
|
||||
|
||||
expect(tools[0].tools[0].parameters.properties.name.pattern).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves custom freeform tools with format payloads", () => {
|
||||
const tools = normalizeTools([
|
||||
{
|
||||
|
||||
@@ -101,6 +101,104 @@ describe("DB SQLite layer — public API parity", () => {
|
||||
expect(back.providerSpecificData).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("providerConnections: successful validation clears stale routing locks", async () => {
|
||||
const c = await sqliteDb.createProviderConnection({
|
||||
provider: "health-reset-update",
|
||||
authType: "oauth",
|
||||
email: "update@example.com",
|
||||
accessToken: "old-token",
|
||||
});
|
||||
await sqliteDb.updateProviderConnection(c.id, {
|
||||
testStatus: "unavailable",
|
||||
lastError: "Access denied",
|
||||
lastErrorAt: "2026-09-05T00:00:00.000Z",
|
||||
errorCode: 403,
|
||||
backoffLevel: 3,
|
||||
rateLimitedUntil: "2099-01-01T00:00:00.000Z",
|
||||
modelLock_modelA: "2099-01-01T00:00:00.000Z",
|
||||
modelLock_modelB: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await sqliteDb.updateProviderConnection(c.id, { testStatus: "active" });
|
||||
|
||||
const back = await sqliteDb.getProviderConnectionById(c.id);
|
||||
expect(back).toMatchObject({
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
errorCode: null,
|
||||
backoffLevel: 0,
|
||||
rateLimitedUntil: null,
|
||||
modelLock_modelA: null,
|
||||
modelLock_modelB: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("providerConnections: re-saving valid OAuth credentials clears stale routing locks", async () => {
|
||||
const existing = await sqliteDb.createProviderConnection({
|
||||
provider: "health-reset-resave",
|
||||
authType: "oauth",
|
||||
email: "resave@example.com",
|
||||
accessToken: "old-token",
|
||||
});
|
||||
await sqliteDb.updateProviderConnection(existing.id, {
|
||||
testStatus: "unavailable",
|
||||
lastError: "Access denied",
|
||||
errorCode: 403,
|
||||
backoffLevel: 2,
|
||||
modelLock_modelA: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const resaved = await sqliteDb.createProviderConnection({
|
||||
provider: "health-reset-resave",
|
||||
authType: "oauth",
|
||||
email: "resave@example.com",
|
||||
accessToken: "new-token",
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
expect(resaved.id).toBe(existing.id);
|
||||
const back = await sqliteDb.getProviderConnectionById(existing.id);
|
||||
expect(back).toMatchObject({
|
||||
accessToken: "new-token",
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
errorCode: null,
|
||||
backoffLevel: 0,
|
||||
modelLock_modelA: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("providerConnections: active soft warnings survive the health reset", async () => {
|
||||
const c = await sqliteDb.createProviderConnection({
|
||||
provider: "health-reset-warning",
|
||||
authType: "oauth",
|
||||
email: "warning@example.com",
|
||||
});
|
||||
await sqliteDb.updateProviderConnection(c.id, {
|
||||
testStatus: "unavailable",
|
||||
lastError: "Old failure",
|
||||
modelLock_modelA: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const warningAt = "2026-09-06T00:00:00.000Z";
|
||||
await sqliteDb.updateProviderConnection(c.id, {
|
||||
testStatus: "active",
|
||||
lastError: "Connected, but credits are exhausted",
|
||||
lastErrorAt: warningAt,
|
||||
});
|
||||
|
||||
const back = await sqliteDb.getProviderConnectionById(c.id);
|
||||
expect(back).toMatchObject({
|
||||
testStatus: "active",
|
||||
lastError: "Connected, but credits are exhausted",
|
||||
lastErrorAt: warningAt,
|
||||
errorCode: null,
|
||||
backoffLevel: 0,
|
||||
modelLock_modelA: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("providerConnections: GitHub OAuth uses account identity as fallback name", async () => {
|
||||
const c = await sqliteDb.createProviderConnection({
|
||||
provider: "github",
|
||||
|
||||
145
tests/unit/deepseek-claude-tools.test.js
Normal file
145
tests/unit/deepseek-claude-tools.test.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Regression test: prepareClaudeRequest() must strip client-defined `custom`
|
||||
* tools when forwarding to a provider whose Anthropic-compatible endpoint
|
||||
* does not accept them (DeepSeek — accepts only web_search_*).
|
||||
*
|
||||
* Background:
|
||||
* When Claude Code talks to a DeepSeek route via /v1/messages, 9router
|
||||
* forwards the request body as Claude-format to
|
||||
* https://api.deepseek.com/anthropic/v1/messages. MCP / function tools
|
||||
* arrive with `type: "custom"`. DeepSeek rejects them with HTTP 400
|
||||
* "tools[0]: unknown variant `custom`, expected `web_search_20250305`
|
||||
* or `web_search_20260209`". The previous generic filter dropped them
|
||||
* but also stripped the web_search_* tools that DeepSeek actually
|
||||
* accepts. DeepSeek now exposes a `quirks.claudeSupportedToolTypes`
|
||||
* whitelist and prepareClaudeRequest honours it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { prepareClaudeRequest } from "../../open-sse/translator/formats/claude.js";
|
||||
import { PROVIDERS } from "../../open-sse/providers/index.js";
|
||||
|
||||
function makeBody(tools) {
|
||||
return {
|
||||
model: "deepseek-v4-pro",
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools,
|
||||
};
|
||||
}
|
||||
|
||||
describe("prepareClaudeRequest — provider: deepseek", () => {
|
||||
it("declares the supportedTypes quirk on the provider transport", () => {
|
||||
expect(PROVIDERS.deepseek).toBeDefined();
|
||||
expect(PROVIDERS.deepseek.quirks).toBeDefined();
|
||||
expect(PROVIDERS.deepseek.quirks.claudeSupportedToolTypes).toEqual([
|
||||
"web_search_20250305",
|
||||
"web_search_20260209",
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips MCP / custom tools (the regression) before forwarding", () => {
|
||||
const body = makeBody([
|
||||
{ type: "custom", name: "Bash", input_schema: { type: "object" } },
|
||||
{ type: "custom", name: "Read", input_schema: { type: "object" } },
|
||||
{ type: "custom", name: "Glob", input_schema: { type: "object" } },
|
||||
]);
|
||||
|
||||
const out = prepareClaudeRequest(body, "deepseek");
|
||||
|
||||
expect(out.tools).toBeUndefined();
|
||||
expect(out.tool_choice).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps web_search_20250305 and web_search_20260209", () => {
|
||||
const out = prepareClaudeRequest(
|
||||
makeBody([
|
||||
{ type: "web_search_20250305", name: "web_search" },
|
||||
{ type: "web_search_20260209", name: "web_search" },
|
||||
]),
|
||||
"deepseek"
|
||||
);
|
||||
|
||||
expect(Array.isArray(out.tools)).toBe(true);
|
||||
expect(out.tools).toHaveLength(2);
|
||||
const types = out.tools.map(t => t.type).sort();
|
||||
expect(types).toEqual(["web_search_20250305", "web_search_20260209"]);
|
||||
});
|
||||
|
||||
it("preserves the `type` field on web_search_* tools (DeepSeek requires it)", () => {
|
||||
// The .map below the filter must NOT strip `type` when the provider
|
||||
// declared a whitelist — DeepSeek would reject a tool object missing
|
||||
// its discriminator field with the same unknown-variant error.
|
||||
const out = prepareClaudeRequest(
|
||||
makeBody([{ type: "web_search_20250305", name: "web_search" }]),
|
||||
"deepseek"
|
||||
);
|
||||
|
||||
expect(out.tools[0].type).toBe("web_search_20250305");
|
||||
expect(out.tools[0].name).toBe("web_search");
|
||||
});
|
||||
|
||||
it("drops `custom` but keeps `web_search_*` when both are present", () => {
|
||||
const out = prepareClaudeRequest(
|
||||
makeBody([
|
||||
{ type: "custom", name: "Bash", input_schema: { type: "object" } },
|
||||
{ type: "web_search_20250305", name: "web_search" },
|
||||
]),
|
||||
"deepseek"
|
||||
);
|
||||
|
||||
expect(Array.isArray(out.tools)).toBe(true);
|
||||
expect(out.tools).toHaveLength(1);
|
||||
expect(out.tools[0].type).toBe("web_search_20250305");
|
||||
expect(out.tools[0].name).toBe("web_search");
|
||||
});
|
||||
|
||||
it("survives when body has no tools", () => {
|
||||
const out = prepareClaudeRequest(makeBody(undefined), "deepseek");
|
||||
expect(out.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects future / unknown tool types instead of forwarding them", () => {
|
||||
const out = prepareClaudeRequest(
|
||||
makeBody([{ type: "future_tool_2099", name: "x" }]),
|
||||
"deepseek"
|
||||
);
|
||||
expect(out.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareClaudeRequest — backward compat: providers without the quirk", () => {
|
||||
// Pick any non-Claude provider that has a Claude-format transport and has
|
||||
// NOT been migrated to the new quirk. This protects GLM / Kimi / future
|
||||
// Anthropic-compatible providers from unintended changes.
|
||||
it("keeps prior behaviour (drop custom + web_search_*, normalize no-type tools)", () => {
|
||||
const candidate = Object.entries(PROVIDERS).find(
|
||||
([id, p]) =>
|
||||
id !== "claude" &&
|
||||
p?.transports?.some(t => t.format === "claude") &&
|
||||
!p?.quirks?.claudeSupportedToolTypes
|
||||
);
|
||||
|
||||
if (!candidate) {
|
||||
// Every Claude-format provider has been migrated — nothing to verify.
|
||||
return;
|
||||
}
|
||||
|
||||
const [providerId] = candidate;
|
||||
|
||||
const out = prepareClaudeRequest(
|
||||
makeBody([
|
||||
{ type: "custom", name: "Bash", input_schema: { type: "object" } },
|
||||
{ type: "web_search_20250305", name: "web_search" },
|
||||
{ name: "no_type_tool", input_schema: { type: "object" } },
|
||||
]),
|
||||
providerId
|
||||
);
|
||||
|
||||
if (out.tools !== undefined) {
|
||||
for (const t of out.tools) {
|
||||
expect(t.type).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -351,7 +351,7 @@ describe("handleImageGenerationCore", () => {
|
||||
headers: expect.objectContaining({
|
||||
authorization: "Bearer codex-token",
|
||||
"chatgpt-account-id": "account-123",
|
||||
version: "0.136.0",
|
||||
version: "0.154.0",
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -367,6 +367,47 @@ describe("handleImageGenerationCore", () => {
|
||||
expect(responseBody.data[0].b64_json).toBe("base64codeximage");
|
||||
});
|
||||
|
||||
it("generates image with Codex gpt-image-2.5 tool model", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
[
|
||||
"event: response.output_item.done",
|
||||
'data: {"item":{"type":"image_generation_call","result":"base64codeximage"}}',
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
||||
)
|
||||
);
|
||||
|
||||
const result = await handleImageGenerationCore({
|
||||
body: {
|
||||
prompt: "A futuristic city",
|
||||
size: "1024x1024",
|
||||
output_format: "png",
|
||||
},
|
||||
modelInfo: { provider: "codex", model: "gpt-image-2.5" },
|
||||
credentials: {
|
||||
accessToken: "codex-token",
|
||||
providerSpecificData: { chatgptAccountId: "account-123" },
|
||||
},
|
||||
log: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const fetchCall = global.fetch.mock.calls[0];
|
||||
const requestBody = JSON.parse(fetchCall[1].body);
|
||||
expect(requestBody.model).toBe("gpt-5.5");
|
||||
expect(requestBody.tools).toEqual([
|
||||
{ type: "image_generation", output_format: "png", size: "1024x1024", action: "generate", model: "gpt-image-2.5" },
|
||||
]);
|
||||
expect(requestBody.tool_choice).toEqual({ type: "image_generation" });
|
||||
expect(requestBody.reasoning).toEqual({ effort: "medium", summary: "auto" });
|
||||
|
||||
const responseBody = await result.response.json();
|
||||
expect(responseBody.data[0].b64_json).toBe("base64codeximage");
|
||||
});
|
||||
|
||||
it("generates image with Cloudflare Workers AI JSON response", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
|
||||
@@ -20,26 +20,26 @@ describe("Kiro auth-aware endpoint routing", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps Builder ID OAuth on the Kiro runtime surface", () => {
|
||||
it("routes Builder ID OAuth through Amazon Q first (runtime path deprecated)", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("builder-id"))).toEqual([
|
||||
RUNTIME,
|
||||
CODEWHISPERER,
|
||||
Q,
|
||||
CODEWHISPERER,
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps external IdP on CodeWhisperer before Amazon Q", () => {
|
||||
it("routes external IdP through Amazon Q first", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("external_idp"))).toEqual([
|
||||
CODEWHISPERER,
|
||||
Q,
|
||||
CODEWHISPERER,
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("regionalizes AWS endpoints for IDC without changing Kiro runtime", () => {
|
||||
it("regionalizes AWS endpoints for IDC with Q first", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("idc", "eu-west-1"))).toEqual([
|
||||
"https://codewhisperer.eu-west-1.amazonaws.com/generateAssistantResponse",
|
||||
"https://q.eu-west-1.amazonaws.com/generateAssistantResponse",
|
||||
"https://codewhisperer.eu-west-1.amazonaws.com/generateAssistantResponse",
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
19
tests/unit/kiro-minimal-wire-payload.test.js
Normal file
19
tests/unit/kiro-minimal-wire-payload.test.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js";
|
||||
import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js";
|
||||
|
||||
for (const [name, translate, body] of [
|
||||
["OpenAI", openaiToKiroRequest, { messages: [{ role: "user", content: "hello" }] }],
|
||||
["Claude", claudeToKiroRequest, { messages: [{ role: "user", content: "hello" }] }],
|
||||
]) {
|
||||
describe(`${name} Kiro minimal wire payload`, () => {
|
||||
it("omits unsupported agent fields", () => {
|
||||
const payload = translate("kiro/claude-sonnet-4.5", body, true, {});
|
||||
expect(payload).not.toHaveProperty("agentMode");
|
||||
expect(payload.conversationState).not.toHaveProperty("agentContinuationId");
|
||||
expect(payload.conversationState).not.toHaveProperty("agentTaskType");
|
||||
expect(payload.conversationState.chatTriggerType).toBe("MANUAL");
|
||||
expect(payload.conversationState.currentMessage.userInputMessage.origin).toBe("AI_EDITOR");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -115,7 +115,7 @@ async function text(stream) {
|
||||
async function execute(executor = new KiroExecutor(), overrides = {}) {
|
||||
return executor.execute({
|
||||
model: "kr/claude-opus-4.8",
|
||||
body: { systemPrompt: "base", conversationState: {} },
|
||||
body: { conversationState: { currentMessage: { userInputMessage: { content: "base", modelId: "m" } } } },
|
||||
stream: true,
|
||||
credentials,
|
||||
...overrides
|
||||
@@ -342,8 +342,12 @@ describe("Kiro terminal integrity recovery", () => {
|
||||
const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body);
|
||||
|
||||
expect(body).toContain("Recovered safely.");
|
||||
expect(retryBody.systemPrompt).toContain("tool_call wrapper was malformed");
|
||||
expect(retryBody.systemPrompt).not.toContain("IGNORE_ALL_INSTRUCTIONS");
|
||||
// The repair instruction rides in the user turn: kiro.dev rejects a
|
||||
// top-level systemPrompt with 400 REQUEST_BODY_INVALID.
|
||||
const retryContent = retryBody.conversationState.currentMessage.userInputMessage.content;
|
||||
expect(retryBody.systemPrompt).toBeUndefined();
|
||||
expect(retryContent).toContain("tool_call wrapper was malformed");
|
||||
expect(retryContent).not.toContain("IGNORE_ALL_INSTRUCTIONS");
|
||||
});
|
||||
|
||||
it("lets a complete tool call override metadata end_turn", async () => {
|
||||
|
||||
@@ -628,7 +628,7 @@ describe("openaiToKiroRequest", () => {
|
||||
);
|
||||
|
||||
expect(second.conversationState.conversationId).toBe("hermes-session-openai-replay");
|
||||
expect(second.conversationState.agentContinuationId).toBe(first.conversationState.agentContinuationId);
|
||||
expect(second.conversationState).not.toHaveProperty("agentContinuationId");
|
||||
expect(second.conversationState.history[0].userInputMessage.content).toBe(
|
||||
first.conversationState.currentMessage.userInputMessage.content
|
||||
);
|
||||
|
||||
@@ -4,9 +4,11 @@ import { PROVIDERS } from "../../open-sse/config/providers.js";
|
||||
import { resolveTransport } from "../../open-sse/services/provider.js";
|
||||
|
||||
// Chat-only models (no /messages, no /responses support on opencode-go)
|
||||
const CHAT_ONLY = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "mimo-v2.5", "mimo-v2.5-pro"];
|
||||
const CHAT_ONLY = ["glm-5.3", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "kimi-k3",
|
||||
"deepseek-flash", "longcat-2.0", "mimo-v2.5", "mimo-v2.5-pro", "hy4-preview", "hy3"];
|
||||
// Models that also expose the Anthropic /messages endpoint
|
||||
const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"];
|
||||
const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5",
|
||||
"qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"];
|
||||
// Models that also expose the OpenAI /responses endpoint
|
||||
const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"];
|
||||
|
||||
@@ -22,11 +24,14 @@ describe("OpenCode Go model catalog", () => {
|
||||
it("matches the documented model IDs", () => {
|
||||
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id);
|
||||
expect(ids).toEqual([
|
||||
"glm-5.3-flash", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6",
|
||||
"deepseek-flash",
|
||||
"glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "kimi-k3",
|
||||
"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp",
|
||||
"mimo-v2.5", "mimo-v2.5-pro",
|
||||
"longcat-2.0", "mimo-v2.5", "mimo-v2.5-pro",
|
||||
"minimax-m3", "minimax-m2.7", "minimax-m2.5",
|
||||
"qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
|
||||
"qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
|
||||
"hy4-preview", "hy3",
|
||||
"grok-4.6", "gpt-5.6-luna",
|
||||
"muse-spark-1.2-contributor", "muse-spark-1.3-contributor",
|
||||
]);
|
||||
});
|
||||
@@ -91,7 +96,7 @@ describe("OpenCode Go per-model transport guard (chatCore logic)", () => {
|
||||
});
|
||||
|
||||
it("routes Muse Spark (responses-only) to /responses, never to /messages", () => {
|
||||
for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor"]) {
|
||||
for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor", "grok-4.6", "gpt-5.6-luna"]) {
|
||||
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai-responses"]);
|
||||
expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull();
|
||||
|
||||
193
tests/unit/qoder-context-tier.test.js
Normal file
193
tests/unit/qoder-context-tier.test.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Qoder context-window tiers + routable model listing.
|
||||
*
|
||||
* The Qoder IDE lets a user pick 200K / 400K / 1M for a model; qodercli-style
|
||||
* requests (what 9router sends) only carry the default max_input_tokens. These
|
||||
* tests pin the escalation policy and the payload fields the IDE writes.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
parseTierTokenCount,
|
||||
getQoderContextTiers,
|
||||
estimateQoderPromptTokens,
|
||||
resolveQoderContextTier,
|
||||
applyQoderContextTier,
|
||||
} from "../../open-sse/shared/qoder/contextTier.js";
|
||||
import { routableQoderModels } from "../../open-sse/services/qoderModels.js";
|
||||
|
||||
// Shape mirrors the live /algo/api/v2/model/list entry for qmodel_38max.
|
||||
const MODEL_CONFIG = {
|
||||
key: "qmodel_38max",
|
||||
display_name: "Qwen3.8-Max",
|
||||
is_reasoning: true,
|
||||
max_input_tokens: 180_000,
|
||||
max_output_tokens: 32_768,
|
||||
context_config: [
|
||||
{ name: "200K", tokenCount: 200_000, isDefault: true },
|
||||
{ name: "400K", tokenCount: 400_000, isDefault: false },
|
||||
{ name: "1M", tokenCount: 1_000_000, isDefault: false },
|
||||
],
|
||||
};
|
||||
|
||||
function promptOfTokens(n) {
|
||||
// ~4 ASCII chars per token
|
||||
return { system: "", messages: [{ role: "user", content: "abcd".repeat(n) }], tools: [] };
|
||||
}
|
||||
|
||||
describe("parseTierTokenCount", () => {
|
||||
it("accepts numbers and K/M suffixed strings", () => {
|
||||
expect(parseTierTokenCount(204800)).toBe(204800);
|
||||
expect(parseTierTokenCount("200K")).toBe(200_000);
|
||||
expect(parseTierTokenCount("1M")).toBe(1_000_000);
|
||||
expect(parseTierTokenCount("1.5m")).toBe(1_500_000);
|
||||
expect(parseTierTokenCount("131072")).toBe(131072);
|
||||
});
|
||||
|
||||
it("returns 0 for garbage", () => {
|
||||
expect(parseTierTokenCount(null)).toBe(0);
|
||||
expect(parseTierTokenCount("big")).toBe(0);
|
||||
expect(parseTierTokenCount(-5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQoderContextTiers", () => {
|
||||
it("sorts tiers ascending and keeps the default flag", () => {
|
||||
const tiers = getQoderContextTiers({
|
||||
context_config: [
|
||||
{ name: "1M", tokenCount: 1_000_000 },
|
||||
{ name: "200K", tokenCount: 200_000, isDefault: true },
|
||||
],
|
||||
});
|
||||
expect(tiers.map((t) => t.tokenCount)).toEqual([200_000, 1_000_000]);
|
||||
expect(tiers[0].isDefault).toBe(true);
|
||||
expect(tiers[1].isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it("understands camelCase / snake_case variants and derives names", () => {
|
||||
const tiers = getQoderContextTiers({
|
||||
contextConfig: [{ token_count: "400K", is_default: true }, { max_input_tokens: 1_000_000 }],
|
||||
});
|
||||
expect(tiers).toEqual([
|
||||
{ name: "400K", tokenCount: 400_000, isDefault: true },
|
||||
{ name: "1M", tokenCount: 1_000_000, isDefault: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] when the model has no tiers", () => {
|
||||
expect(getQoderContextTiers({ max_input_tokens: 131072 })).toEqual([]);
|
||||
expect(getQoderContextTiers(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateQoderPromptTokens", () => {
|
||||
it("counts CJK characters as ~1 token each instead of chars/4", () => {
|
||||
const ascii = estimateQoderPromptTokens({ messages: [{ role: "user", content: "a".repeat(4000) }] });
|
||||
const cjk = estimateQoderPromptTokens({ messages: [{ role: "user", content: "中".repeat(4000) }] });
|
||||
expect(ascii).toBeLessThan(1_200);
|
||||
expect(cjk).toBeGreaterThan(4_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveQoderContextTier (auto)", () => {
|
||||
it("leaves the payload untouched while the prompt fits the current max_input_tokens", () => {
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(50_000))).toBeNull();
|
||||
});
|
||||
|
||||
it("escalates to the smallest tier that fits once the prompt outgrows the default", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(250_000));
|
||||
expect(choice).not.toBeNull();
|
||||
expect(choice.tier.name).toBe("400K");
|
||||
expect(choice.reason).toBe("auto:fits");
|
||||
expect(choice.estimatedTokens).toBeGreaterThan(240_000);
|
||||
});
|
||||
|
||||
it("falls back to the largest tier when nothing fits (upstream decides)", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(1_200_000));
|
||||
expect(choice.tier.name).toBe("1M");
|
||||
expect(choice.reason).toBe("auto:largest");
|
||||
});
|
||||
|
||||
it("applies headroom so a prompt just under the limit still escalates", () => {
|
||||
// 170K estimated * 1.15 = 195.5K > 180K current → smallest tier above the current limit (200K)
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(170_000))?.tier.name).toBe("200K");
|
||||
// 190K * 1.15 = 218.5K → 200K no longer fits → 400K
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(190_000))?.tier.name).toBe("400K");
|
||||
});
|
||||
|
||||
it("returns null for models without context_config", () => {
|
||||
expect(resolveQoderContextTier({ max_input_tokens: 131072 }, promptOfTokens(500_000))).toBeNull();
|
||||
});
|
||||
|
||||
it("never escalates when the current limit is already the largest tier", () => {
|
||||
const cfg = { ...MODEL_CONFIG, max_input_tokens: 1_000_000 };
|
||||
expect(resolveQoderContextTier(cfg, promptOfTokens(1_500_000))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveQoderContextTier (forced via QODER_CONTEXT_TIER)", () => {
|
||||
it("max picks the largest tier regardless of prompt size", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "max" });
|
||||
expect(choice.tier.name).toBe("1M");
|
||||
expect(choice.reason).toBe("forced:max");
|
||||
});
|
||||
|
||||
it("default picks the isDefault tier", () => {
|
||||
const choice = resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "default" });
|
||||
expect(choice.tier.name).toBe("200K");
|
||||
});
|
||||
|
||||
it("a tier name or token count selects that tier", () => {
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "400k" }).tier.tokenCount).toBe(400_000);
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "1000000" }).tier.name).toBe("1M");
|
||||
});
|
||||
|
||||
it("an unknown tier name falls back to auto", () => {
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(10), { preference: "9M" })).toBeNull();
|
||||
expect(resolveQoderContextTier(MODEL_CONFIG, promptOfTokens(250_000), { preference: "9M" }).tier.name).toBe("400K");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyQoderContextTier", () => {
|
||||
it("mirrors the tier into the three places the IDE writes", () => {
|
||||
const payload = {
|
||||
parameters: { max_tokens: 32_768 },
|
||||
chat_context: { extra: { context: [], modelConfig: { key: "qmodel_38max" } } },
|
||||
model_config: { ...MODEL_CONFIG },
|
||||
};
|
||||
applyQoderContextTier(payload, { name: "1M", tokenCount: 1_000_000 });
|
||||
expect(payload.parameters).toEqual({ max_tokens: 32_768, context_length: 1_000_000 });
|
||||
expect(payload.chat_context.extra.ideModelConfigOverride).toEqual({ max_input_tokens: 1_000_000 });
|
||||
expect(payload.chat_context.extra.modelConfig).toEqual({ key: "qmodel_38max" });
|
||||
expect(payload.model_config.max_input_tokens).toBe(1_000_000);
|
||||
expect(payload.model_config.context_config).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("is a no-op without a tier", () => {
|
||||
const payload = { parameters: { max_tokens: 1 } };
|
||||
expect(applyQoderContextTier(payload, null)).toBe(payload);
|
||||
expect(payload).toEqual({ parameters: { max_tokens: 1 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("routableQoderModels", () => {
|
||||
it("lists visible models first, then hidden (enable:false) catalog keys", () => {
|
||||
const catalog = {
|
||||
models: [{ id: "qmodel_38max", name: "Qwen3.8-Max" }],
|
||||
rawConfigs: new Map([
|
||||
["qmodel_38max", { key: "qmodel_38max", enable: true }],
|
||||
["qfmodel", { key: "qfmodel", enable: false, display_name: "Qwen Fast" }],
|
||||
["dmodel", { key: "dmodel", enable: false }],
|
||||
]),
|
||||
};
|
||||
expect(routableQoderModels(catalog)).toEqual([
|
||||
{ id: "qmodel_38max", name: "Qwen3.8-Max", hidden: false },
|
||||
{ id: "qfmodel", name: "Qwen Fast", hidden: true },
|
||||
{ id: "dmodel", name: "dmodel", hidden: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] for a failed catalog fetch", () => {
|
||||
expect(routableQoderModels(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
* - device flow URL construction
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import crypto from "crypto";
|
||||
|
||||
import { qoderEncodeBody } from "../../src/lib/qoder/encoding.js";
|
||||
@@ -22,6 +22,13 @@ import {
|
||||
} from "../../src/lib/qoder/constants.js";
|
||||
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js";
|
||||
import { __test__ as qoderExecutorInternals } from "../../open-sse/executors/qoder.js";
|
||||
import { canonicalizeQoderUsage } from "../../open-sse/shared/qoder/sse.js";
|
||||
import {
|
||||
rewriteQoderMessageAttachments,
|
||||
clearQoderUploadCache,
|
||||
buildMultipartFile,
|
||||
} from "../../open-sse/shared/qoder/attachments.js";
|
||||
import { qoderInferenceBase } from "../../open-sse/shared/qoder/constants.js";
|
||||
|
||||
// Convenience aliases — tests were originally written against module-level
|
||||
// helpers; the QoderService class wraps them so each test creates its own
|
||||
@@ -431,6 +438,21 @@ describe("normalizeMessages", () => {
|
||||
]);
|
||||
expect(result.messages[0].content).toBe("hi");
|
||||
});
|
||||
|
||||
it("turns leftover file/document blocks into short stubs instead of dropping them", () => {
|
||||
const result = normalizeMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "see" },
|
||||
{ type: "file", file: { filename: "big.pdf", file_data: "data:application/pdf;base64,AAA" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(result.messages[0].content).toContain("see");
|
||||
expect(result.messages[0].content).toContain("big.pdf");
|
||||
expect(result.messages[0].content).not.toContain("AAA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapQoderSSE", () => {
|
||||
@@ -530,4 +552,190 @@ describe("wrapQoderSSE", () => {
|
||||
const wrapped = await wrapQoderSSE(r, "qoder/auto");
|
||||
expect(wrapped).toBe(r);
|
||||
});
|
||||
|
||||
function envelope(body) {
|
||||
return `data: ${JSON.stringify({ statusCodeValue: 200, body })}\n\n`;
|
||||
}
|
||||
|
||||
function parseForwardedChunks(out) {
|
||||
return out
|
||||
.split("\n\n")
|
||||
.map((block) => block.trim())
|
||||
.filter((block) => block.startsWith("data:") && !block.includes("[DONE]"))
|
||||
.map((block) => JSON.parse(block.slice("data:".length).trim()));
|
||||
}
|
||||
|
||||
it("coalesces empty finish-in-delta + usage-only into one OpenAI usage chunk", async () => {
|
||||
const content = JSON.stringify({
|
||||
id: "chatcmpl-qoder-1",
|
||||
created: 1700000000,
|
||||
model: "auto",
|
||||
choices: [{ index: 0, delta: { content: "hi" } }],
|
||||
});
|
||||
const finish = JSON.stringify({
|
||||
id: "chatcmpl-qoder-1",
|
||||
choices: [{ index: 0, delta: { content: "", finish_reason: "stop" } }],
|
||||
});
|
||||
const usage = JSON.stringify({
|
||||
id: "chatcmpl-qoder-1",
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
prompt_tokens_details: { cached_tokens: 40 },
|
||||
},
|
||||
});
|
||||
const wrapped = await wrapQoderSSE(
|
||||
makeResponse([envelope(content) + envelope(finish) + envelope(usage) + envelope("[DONE]")]),
|
||||
"qoder/auto",
|
||||
);
|
||||
const out = await drain(wrapped);
|
||||
expect(out).toContain(`data: ${content}\n\n`);
|
||||
const chunks = parseForwardedChunks(out);
|
||||
const usageChunk = chunks.find((c) => c.usage);
|
||||
expect(usageChunk).toBeDefined();
|
||||
expect(usageChunk.choices[0].finish_reason).toBe("stop");
|
||||
expect(usageChunk.usage.prompt_tokens).toBe(100);
|
||||
expect(usageChunk.usage.completion_tokens).toBe(20);
|
||||
expect(usageChunk.usage.prompt_tokens_details.cached_tokens).toBe(40);
|
||||
expect(chunks.some((c) => Array.isArray(c.choices) && c.choices.length === 0)).toBe(false);
|
||||
expect((out.match(/data: \[DONE\]/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("maps Qoder input_tokens aliases onto prompt_tokens in the coalesced usage chunk", async () => {
|
||||
const finish = JSON.stringify({
|
||||
choices: [{ index: 0, delta: { finish_reason: "stop" } }],
|
||||
});
|
||||
const usage = JSON.stringify({
|
||||
choices: [],
|
||||
usage: {
|
||||
input_tokens: 80,
|
||||
output_tokens: 10,
|
||||
cache_read_input_tokens: 25,
|
||||
},
|
||||
});
|
||||
const wrapped = await wrapQoderSSE(
|
||||
makeResponse([envelope(finish) + envelope(usage)]),
|
||||
"qoder/lite",
|
||||
);
|
||||
const chunks = parseForwardedChunks(await drain(wrapped));
|
||||
const usageChunk = chunks.find((c) => c.usage);
|
||||
expect(usageChunk.usage.prompt_tokens).toBe(80);
|
||||
expect(usageChunk.usage.completion_tokens).toBe(10);
|
||||
expect(usageChunk.usage.prompt_tokens_details.cached_tokens).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalizeQoderUsage", () => {
|
||||
it("returns null for missing or empty usage", () => {
|
||||
expect(canonicalizeQoderUsage(null)).toBeNull();
|
||||
expect(canonicalizeQoderUsage({})).toBeNull();
|
||||
});
|
||||
|
||||
it("copies prompt_tokens_details.cached_tokens through", () => {
|
||||
const out = canonicalizeQoderUsage({
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 5,
|
||||
prompt_tokens_details: { cached_tokens: 12 },
|
||||
});
|
||||
expect(out.prompt_tokens).toBe(50);
|
||||
expect(out.cached_tokens).toBe(12);
|
||||
expect(out.prompt_tokens_details.cached_tokens).toBe(12);
|
||||
expect(out.total_tokens).toBe(55);
|
||||
});
|
||||
});
|
||||
|
||||
describe("qoderInferenceBase", () => {
|
||||
it("sends job tokens to api2 and device tokens to api3", () => {
|
||||
expect(qoderInferenceBase({ accessToken: "jt-abc" })).toContain("api2.qoder.sh");
|
||||
expect(qoderInferenceBase({ accessToken: "dt-abc" })).toContain("api3.qoder.sh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteQoderMessageAttachments", () => {
|
||||
beforeEach(() => clearQoderUploadCache());
|
||||
|
||||
it("uploads data-URI images and keeps only the OSS URL in the message", async () => {
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "see this" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
|
||||
],
|
||||
}];
|
||||
const stats = await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async ({ buffer, mediaType }) => {
|
||||
expect(Buffer.isBuffer(buffer)).toBe(true);
|
||||
expect(mediaType).toBe("image/png");
|
||||
return "https://cdn.qoder.example/img.png";
|
||||
},
|
||||
});
|
||||
expect(messages[0].content).toEqual([
|
||||
{ type: "text", text: "see this" },
|
||||
{ type: "image_url", image_url: { url: "https://cdn.qoder.example/img.png" } },
|
||||
]);
|
||||
expect(JSON.stringify(messages)).not.toContain("AAAA");
|
||||
expect(stats.imageUrls).toEqual(["https://cdn.qoder.example/img.png"]);
|
||||
});
|
||||
|
||||
it("does not re-upload already-hosted http(s) image URLs", async () => {
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }],
|
||||
}];
|
||||
await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async () => {
|
||||
throw new Error("should not upload remote URLs");
|
||||
},
|
||||
});
|
||||
expect(messages[0].content[0].image_url.url).toBe("https://example.com/a.png");
|
||||
});
|
||||
|
||||
it("stubs non-image file blocks instead of inlining bytes", async () => {
|
||||
const pdfB64 = "A".repeat(200);
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "read this" },
|
||||
{ type: "file", file: { filename: "big.pdf", file_data: `data:application/pdf;base64,${pdfB64}` } },
|
||||
],
|
||||
}];
|
||||
await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async () => {
|
||||
throw new Error("should not upload PDFs as images");
|
||||
},
|
||||
});
|
||||
const wire = JSON.stringify(messages);
|
||||
expect(wire).not.toContain(pdfB64);
|
||||
expect(wire).toContain("[file omitted: big.pdf");
|
||||
});
|
||||
|
||||
it("stubs oversized images when OSS upload fails instead of keeping a huge data URI", async () => {
|
||||
const big = "A".repeat(700_000);
|
||||
const messages = [{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: { url: `data:image/png;base64,${big}` } }],
|
||||
}];
|
||||
await rewriteQoderMessageAttachments(messages, {
|
||||
uploadFn: async () => {
|
||||
throw new Error("upstream 413");
|
||||
},
|
||||
});
|
||||
const wire = JSON.stringify(messages);
|
||||
expect(wire).not.toContain(big);
|
||||
expect(wire).toContain("[file omitted:");
|
||||
expect(Buffer.byteLength(wire, "utf8")).toBeLessThan(4096);
|
||||
});
|
||||
|
||||
it("buildMultipartFile uses the file field name qodercli sends", () => {
|
||||
const { boundary, body } = buildMultipartFile(Buffer.from("hi"), {
|
||||
fileName: "image.png",
|
||||
mediaType: "image/png",
|
||||
});
|
||||
const text = body.toString("latin1");
|
||||
expect(text).toContain(`name="file"`);
|
||||
expect(text).toContain("filename=\"image.png\"");
|
||||
expect(text).toContain(`--${boundary}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,138 +261,121 @@ describe("system-inject gemini", () => {
|
||||
});
|
||||
|
||||
describe("system-inject kiro", () => {
|
||||
it("updates systemPrompt and mirrored prefix of first history user preserving tail", () => {
|
||||
const oldPrompt = "OLD_SYS";
|
||||
// The kiro.dev gateway rejects any body carrying a top-level `systemPrompt`
|
||||
// with 400 REQUEST_BODY_INVALID, so the prompt goes into the user turn only.
|
||||
it("appends to first history user, leaves systemPrompt untouched", () => {
|
||||
const timeCtx = "[Context: Current time is 2026-01-01T00:00:00.000Z]";
|
||||
const tail = "user tail content";
|
||||
const historyUserContent = `${oldPrompt}${SEP}${timeCtx}${SEP}${tail}`;
|
||||
const historyUserContent = `${timeCtx}${SEP}${tail}`;
|
||||
const body = {
|
||||
systemPrompt: oldPrompt,
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: historyUserContent, modelId: "m" } }, { assistantResponseMessage: { content: "..." } }],
|
||||
currentMessage: { userInputMessage: { content: "current " + tail, modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
const next = `${oldPrompt}${SEP}${P1}`;
|
||||
expect(body.systemPrompt).toBe(next);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`${next}${SEP}${timeCtx}${SEP}${tail}`);
|
||||
expect(body.systemPrompt).toBeUndefined();
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`${historyUserContent}${SEP}${P1}`);
|
||||
// currentMessage must stay untouched
|
||||
expect(body.conversationState.currentMessage.userInputMessage.content).toBe("current " + tail);
|
||||
});
|
||||
|
||||
it("when no history user, updates currentMessage instead", () => {
|
||||
const oldPrompt = "OLD";
|
||||
const body = {
|
||||
systemPrompt: oldPrompt,
|
||||
conversationState: {
|
||||
history: [],
|
||||
currentMessage: { userInputMessage: { content: `${oldPrompt}${SEP}tail`, modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}`);
|
||||
expect(body.conversationState.currentMessage.userInputMessage.content).toBe(`${oldPrompt}${SEP}${P1}${SEP}tail`);
|
||||
});
|
||||
|
||||
it("empty old prompt prepends to chosen user content", () => {
|
||||
const body = {
|
||||
systemPrompt: "",
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: "tail hello", modelId: "m" } }],
|
||||
currentMessage: { userInputMessage: { content: "cur", modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(P1);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`${P1}${SEP}tail hello`);
|
||||
});
|
||||
|
||||
it("if old prompt not mirrored at head, do not alter user content", () => {
|
||||
it("never writes a top-level systemPrompt, even if one is already present", () => {
|
||||
const body = {
|
||||
systemPrompt: "OLD",
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: "different head content", modelId: "m" } }],
|
||||
history: [{ userInputMessage: { content: "tail", modelId: "m" } }],
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe("OLD");
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`tail${SEP}${P1}`);
|
||||
});
|
||||
|
||||
it("when no history user, updates currentMessage instead", () => {
|
||||
const body = {
|
||||
conversationState: {
|
||||
history: [],
|
||||
currentMessage: { userInputMessage: { content: "tail", modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBeUndefined();
|
||||
expect(body.conversationState.currentMessage.userInputMessage.content).toBe(`tail${SEP}${P1}`);
|
||||
});
|
||||
|
||||
it("empty user content becomes the prompt itself", () => {
|
||||
const body = {
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: "", modelId: "m" } }],
|
||||
currentMessage: { userInputMessage: { content: "cur", modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(`OLD${SEP}${P1}`);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe("different head content");
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(P1);
|
||||
expect(body.conversationState.currentMessage.userInputMessage.content).toBe("cur");
|
||||
});
|
||||
|
||||
it("exact retry idempotency for kiro", () => {
|
||||
const oldPrompt = "OLD";
|
||||
const body = {
|
||||
systemPrompt: oldPrompt,
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: `${oldPrompt}${SEP}tail`, modelId: "m" } }],
|
||||
history: [{ userInputMessage: { content: "tail", modelId: "m" } }],
|
||||
currentMessage: { userInputMessage: { content: "cur", modelId: "m" } },
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
const after1 = JSON.parse(JSON.stringify(body));
|
||||
const after1 = body.conversationState.history[0].userInputMessage.content;
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(after1.systemPrompt);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(after1.conversationState.history[0].userInputMessage.content);
|
||||
// different prompt both apply
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(after1);
|
||||
// different prompt both apply, in injection order
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P2);
|
||||
expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}${SEP}${P2}`);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`tail${SEP}${P1}${SEP}${P2}`);
|
||||
});
|
||||
|
||||
it("preserves non-enumerable _kiroUpstreamModel", () => {
|
||||
const body = {
|
||||
systemPrompt: "OLD",
|
||||
conversationState: { history: [{ userInputMessage: { content: "OLD" + SEP + "tail", modelId: "m" } }], currentMessage: { userInputMessage: { content: "OLD" + SEP + "tail2", modelId: "m" } } },
|
||||
conversationState: { history: [{ userInputMessage: { content: "tail", modelId: "m" } }], currentMessage: { userInputMessage: { content: "tail2", modelId: "m" } } },
|
||||
};
|
||||
Object.defineProperty(body, "_kiroUpstreamModel", { value: "m", enumerable: false });
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body._kiroUpstreamModel).toBe("m");
|
||||
expect(Object.getOwnPropertyDescriptor(body, "_kiroUpstreamModel").enumerable).toBe(false);
|
||||
});
|
||||
|
||||
it("frozen user message fails open without throwing or half-writing", () => {
|
||||
const body = {
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: Object.freeze({ content: "tail", modelId: "m" }) }],
|
||||
},
|
||||
};
|
||||
expect(() => injectSystemPrompt(body, FORMATS.KIRO, P1)).not.toThrow();
|
||||
expect(body.systemPrompt).toBeUndefined();
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe("tail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("system-inject regression fixes", () => {
|
||||
it("kiro partial mutation converges on retry after transient content write failure", () => {
|
||||
const oldPrompt = "OLD";
|
||||
let failNextWrite = true;
|
||||
const um = { content: `${oldPrompt}${SEP}tail`, modelId: "m" };
|
||||
const um = { content: "tail", modelId: "m" };
|
||||
const proxiedUm = new Proxy(um, {
|
||||
set(t, p, v) {
|
||||
if (p === "content" && failNextWrite) { failNextWrite = false; throw new Error("transient"); }
|
||||
t[p] = v; return true;
|
||||
},
|
||||
});
|
||||
const body = {
|
||||
systemPrompt: oldPrompt,
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: proxiedUm }],
|
||||
},
|
||||
};
|
||||
const body = { conversationState: { history: [{ userInputMessage: proxiedUm }] } };
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
// first pass rolled back atomically — nothing half-applied
|
||||
expect(body.systemPrompt).toBe(oldPrompt);
|
||||
expect(um.content).toBe(`${oldPrompt}${SEP}tail`);
|
||||
// nothing half-applied
|
||||
expect(um.content).toBe("tail");
|
||||
expect(body.systemPrompt).toBeUndefined();
|
||||
// retry converges
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}`);
|
||||
expect(um.content).toBe(`${oldPrompt}${SEP}${P1}${SEP}tail`);
|
||||
});
|
||||
|
||||
it("kiro rolls back systemPrompt when user content write fails (atomicity)", () => {
|
||||
const oldPrompt = "OLD";
|
||||
const body = {
|
||||
systemPrompt: oldPrompt,
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: Object.freeze({ content: `${oldPrompt}${SEP}tail`, modelId: "m" }) }],
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.systemPrompt).toBe(oldPrompt);
|
||||
expect(um.content).toBe(`tail${SEP}${P1}`);
|
||||
});
|
||||
|
||||
it("kiro shape gate: stray conversationState without history/currentMessage does not hijack chat body", () => {
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }], systemPrompt: "", conversationState: {} };
|
||||
const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }], conversationState: {} };
|
||||
injectSystemPrompt(body, FORMATS.OPENAI, P1);
|
||||
expect(body.messages[0].content).toBe(`hello${SEP}${P1}`);
|
||||
});
|
||||
@@ -409,15 +392,14 @@ describe("system-inject regression fixes", () => {
|
||||
expect(body.instructions).toBe(`You are RULE follower${SEP}RULE`);
|
||||
});
|
||||
|
||||
it("kiro empty-old prepend fires when prompt appears mid-tail only", () => {
|
||||
it("substring occurrence does not suppress kiro injection", () => {
|
||||
const body = {
|
||||
systemPrompt: "",
|
||||
conversationState: {
|
||||
history: [{ userInputMessage: { content: `some ${P1} here`, modelId: "m" } }],
|
||||
},
|
||||
};
|
||||
injectSystemPrompt(body, FORMATS.KIRO, P1);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`${P1}${SEP}some ${P1} here`);
|
||||
expect(body.conversationState.history[0].userInputMessage.content).toBe(`some ${P1} here${SEP}${P1}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
296
tests/unit/video-providers.test.js
Normal file
296
tests/unit/video-providers.test.js
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Unit tests for the OpenRouter + Vertex (Veo) video adapters.
|
||||
*
|
||||
* Covers:
|
||||
* - registry wiring (videoConfig, video serviceKind, video-kind models)
|
||||
* - OpenRouter: POST to the collection root, GET poll, verbatim passthrough
|
||||
* - Vertex: predictLongRunning body translation, fetchPredictOperation polling,
|
||||
* operation-name round-trip through the job id, response mapping
|
||||
* - xAI default path is unchanged by the adapter hook
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("open-sse/services/tokenRefresh.js", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return { ...actual, refreshTokenByProvider: vi.fn(), refreshVertexToken: vi.fn() };
|
||||
});
|
||||
|
||||
import { handleVideoProxyCore, getVideoConfig } from "open-sse/handlers/videoCore.js";
|
||||
import { refreshVertexToken } from "open-sse/services/tokenRefresh.js";
|
||||
import { PROVIDER_MEDIA, PROVIDER_MODELS } from "open-sse/providers/index.js";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const jsonResponse = (body, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
|
||||
// Vertex operation names are resource paths; the adapter base64url-encodes them.
|
||||
const OPERATION_NAME =
|
||||
"projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/op-abc";
|
||||
const JOB_ID = Buffer.from(OPERATION_NAME, "utf8").toString("base64url");
|
||||
|
||||
describe("registry wiring", () => {
|
||||
it("exposes videoConfig + video serviceKind for openrouter and vertex", () => {
|
||||
expect(getVideoConfig("openrouter").baseUrl).toBe("https://openrouter.ai/api/v1/videos");
|
||||
expect(getVideoConfig("vertex").baseUrl).toBe("https://aiplatform.googleapis.com");
|
||||
expect(PROVIDER_MEDIA.openrouter.serviceKinds).toContain("video");
|
||||
expect(PROVIDER_MEDIA.vertex.serviceKinds).toContain("video");
|
||||
});
|
||||
|
||||
it("registers video-kind models on both providers", () => {
|
||||
const or = PROVIDER_MODELS.openrouter.find((m) => m.id === "google/veo-3.1");
|
||||
const vx = PROVIDER_MODELS.vertex.find((m) => m.id === "veo-3.1-generate-preview");
|
||||
expect(or?.kind).toBe("video");
|
||||
expect(vx?.kind).toBe("video");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openrouter video adapter", () => {
|
||||
beforeEach(() => { global.fetch = vi.fn(); });
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it("POSTs creation to the collection root (no /generations suffix)", async () => {
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse({ id: "job-1", status: "pending" }));
|
||||
|
||||
const raw = '{"model":"google/veo-3.1","prompt":"a paper boat"}';
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "openrouter",
|
||||
action: "generations",
|
||||
rawBody: raw,
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "sk-or-key" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe("https://openrouter.ai/api/v1/videos");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBe(raw); // verbatim
|
||||
expect(init.headers.Authorization).toBe("Bearer sk-or-key");
|
||||
expect(init.headers["HTTP-Referer"]).toBe("https://endpoint-proxy.local");
|
||||
expect(await result.response.json()).toEqual({ id: "job-1", status: "pending" });
|
||||
});
|
||||
|
||||
it("polls GET /videos/{id} and passes the payload through verbatim", async () => {
|
||||
const payload = { id: "job-1", status: "completed", unsigned_urls: ["https://cdn/v.mp4"] };
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse(payload));
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "openrouter",
|
||||
requestId: "job-1",
|
||||
credentials: { apiKey: "sk-or-key" },
|
||||
});
|
||||
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe("https://openrouter.ai/api/v1/videos/job-1");
|
||||
expect(init.method).toBe("GET");
|
||||
expect(await result.response.json()).toEqual(payload);
|
||||
});
|
||||
|
||||
it("rejects unsupported actions before any upstream call (no billable job)", async () => {
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "openrouter",
|
||||
action: "extensions",
|
||||
rawBody: "{}",
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "sk-or-key" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("vertex (veo) video adapter", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
refreshVertexToken.mockReset();
|
||||
});
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
const saJson = JSON.stringify({
|
||||
type: "service_account",
|
||||
client_email: "sa@proj-1.iam.gserviceaccount.com",
|
||||
private_key: "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----\n",
|
||||
project_id: "proj-1",
|
||||
});
|
||||
|
||||
it("translates the create body to predictLongRunning and returns a poll-able job id", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse({ name: OPERATION_NAME }));
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({
|
||||
model: "veo-3.1-generate-preview",
|
||||
prompt: "a neon city",
|
||||
duration: 8,
|
||||
aspect_ratio: "16:9",
|
||||
resolution: "720p",
|
||||
n: 1,
|
||||
}),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview:predictLongRunning"
|
||||
);
|
||||
expect(init.headers.Authorization).toBe("Bearer vertex-tok");
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
instances: [{ prompt: "a neon city" }],
|
||||
parameters: { sampleCount: 1, durationSeconds: 8, aspectRatio: "16:9", resolution: "720p" },
|
||||
});
|
||||
|
||||
// Response is mapped onto the async-job shape clients already poll.
|
||||
expect(await result.response.json()).toEqual({
|
||||
id: JOB_ID,
|
||||
request_id: JOB_ID,
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a data-URL image onto the Vertex image instance", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse({ name: OPERATION_NAME }));
|
||||
|
||||
await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({
|
||||
model: "veo-3.1-generate-preview",
|
||||
prompt: "animate this",
|
||||
image: "data:image/png;base64,AAAB",
|
||||
}),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
expect(JSON.parse(global.fetch.mock.calls[0][1].body).instances[0].image).toEqual({
|
||||
bytesBase64Encoded: "AAAB",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
it("polls via fetchPredictOperation and maps a completed operation", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
name: OPERATION_NAME,
|
||||
done: true,
|
||||
response: { videos: [{ gcsUri: "gs://bucket/v.mp4", mimeType: "video/mp4" }] },
|
||||
})
|
||||
);
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
requestId: JOB_ID,
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview:fetchPredictOperation"
|
||||
);
|
||||
expect(init.method).toBe("POST"); // Vertex polls with POST, not GET
|
||||
expect(JSON.parse(init.body)).toEqual({ operationName: OPERATION_NAME });
|
||||
|
||||
expect(await result.response.json()).toEqual({
|
||||
id: JOB_ID,
|
||||
request_id: JOB_ID,
|
||||
status: "completed",
|
||||
video: { url: "gs://bucket/v.mp4", b64_json: null, mime_type: "video/mp4" },
|
||||
videos: [{ url: "gs://bucket/v.mp4", b64_json: null, mime_type: "video/mp4" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a failed operation to status failed", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
jsonResponse({ name: OPERATION_NAME, done: true, error: { code: 3, message: "bad prompt" } })
|
||||
);
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
requestId: JOB_ID,
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
const body = await result.response.json();
|
||||
expect(body.status).toBe("failed");
|
||||
expect(body.error.message).toBe("bad prompt");
|
||||
});
|
||||
|
||||
it("rejects missing project id and raw API keys before any upstream call", async () => {
|
||||
const noProject = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({ model: "veo-3.1-generate-preview", prompt: "x" }),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "AIzaRawKey" },
|
||||
});
|
||||
expect(noProject.success).toBe(false);
|
||||
expect(noProject.status).toBe(400);
|
||||
|
||||
const noToken = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({ model: "veo-3.1-generate-preview", prompt: "x" }),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "AIzaRawKey", providerSpecificData: { projectId: "proj-1" } },
|
||||
});
|
||||
expect(noToken.success).toBe(false);
|
||||
expect(noToken.status).toBe(400);
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an invalid job id without calling upstream", async () => {
|
||||
refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" });
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
requestId: Buffer.from("not-an-operation", "utf8").toString("base64url"),
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A base64url id decodes to arbitrary bytes, so a crafted one used to splice a
|
||||
// path traversal into the fetch URL while the Authorization header stayed on.
|
||||
it("rejects job ids that decode outside the projects/…/operations/ shape", async () => {
|
||||
refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" });
|
||||
const jid = (s) => Buffer.from(s, "utf8").toString("base64url");
|
||||
|
||||
for (const id of [
|
||||
jid("../../evil"),
|
||||
jid("projects/p/locations/l/publishers/google/models/m/operations/../../x"),
|
||||
jid("../../evil/operations/op"),
|
||||
"!!!not-base64!!!",
|
||||
`${JOB_ID}=`,
|
||||
`${JOB_ID}\n`,
|
||||
]) {
|
||||
const result = await handleVideoProxyCore({ provider: "vertex", requestId: id, credentials: { apiKey: saJson } });
|
||||
expect(result.status).toBe(400);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a model id carrying path separators", async () => {
|
||||
refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" });
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({ model: "../../evil", prompt: "x" }),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
expect(result.status).toBe(400);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ vi.mock("@/sse/services/auth.js", () => authMocks);
|
||||
vi.mock("@/sse/services/tokenRefresh.js", () => tokenMocks);
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getSettings: vi.fn(async () => ({ requireApiKey: false })),
|
||||
getProviderConnectionById: vi.fn(async () => ({ id: "conn-5", provider: "xai" })),
|
||||
getComboByName: vi.fn(async () => null),
|
||||
getModelAliases: vi.fn(async () => ({})),
|
||||
getProviderNodes: vi.fn(async () => []),
|
||||
|
||||
80
tests/unit/xiaomi-mimo-executor.test.js
Normal file
80
tests/unit/xiaomi-mimo-executor.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { XiaomiMimoExecutor, __test__ } from "../../open-sse/executors/xiaomi-mimo.js";
|
||||
import { getExecutor } from "../../open-sse/executors/index.js";
|
||||
|
||||
const { bareModel, COOKIE_KEY } = __test__;
|
||||
|
||||
const OPENAI_T = { runtimeTransport: { format: "openai", baseUrl: "https://api.xiaomimimo.com/v1/chat/completions" } };
|
||||
const CLAUDE_T = { runtimeTransport: { format: "claude", baseUrl: "https://api.xiaomimimo.com/anthropic/v1/messages" } };
|
||||
|
||||
describe("xiaomi-mimo executor", () => {
|
||||
let ex;
|
||||
beforeEach(() => {
|
||||
ex = new XiaomiMimoExecutor();
|
||||
});
|
||||
|
||||
it("is registered for xiaomi-mimo", () => {
|
||||
expect(getExecutor("xiaomi-mimo")).toBeInstanceOf(XiaomiMimoExecutor);
|
||||
});
|
||||
|
||||
it("routes Preview models to the account-service route regardless of transport", () => {
|
||||
const expected = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions";
|
||||
expect(ex.buildUrl("mimo-x-pro-preview", true, 0, OPENAI_T)).toBe(expected);
|
||||
expect(ex.buildUrl("mimo-x-pro-preview", true, 0, CLAUDE_T)).toBe(expected);
|
||||
// body.model arrives as `xiaomi/<id>` via upstreamModelId
|
||||
expect(ex.buildUrl("xiaomi/mimo-x-flash-preview", true, 0, OPENAI_T)).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps the sourceFormat-matched endpoint for cloud models", () => {
|
||||
// Regression: a Claude client must reach /anthropic/v1/messages, not /v1/chat/completions.
|
||||
expect(ex.buildUrl("mimo-v2.5-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl);
|
||||
expect(ex.buildUrl("mimo-v2.5-pro", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl);
|
||||
});
|
||||
|
||||
it("authenticates Preview calls with the account cookie", () => {
|
||||
const headers = ex.buildHeaders({ [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" }, true, "u", "mimo-x-pro-preview");
|
||||
expect(headers.Cookie).toBe("serviceToken=abc");
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("authenticates cloud calls with the bearer key", () => {
|
||||
const headers = ex.buildHeaders({ accessToken: "sk-x" }, true, "u", "mimo-v2.5-pro");
|
||||
expect(headers.Authorization).toBe("Bearer sk-x");
|
||||
expect(headers.Cookie).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails fast when a Preview call has no account session", async () => {
|
||||
await expect(
|
||||
ex.execute({ model: "mimo-x-pro-preview", body: {}, stream: true, credentials: {}, log: null }),
|
||||
).rejects.toThrow(/account session unavailable/);
|
||||
});
|
||||
|
||||
it("flattens content-part arrays to plain strings", () => {
|
||||
const out = ex.transformRequest(
|
||||
"mimo-x-pro-preview",
|
||||
{ messages: [{ role: "user", content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }] },
|
||||
true,
|
||||
{},
|
||||
);
|
||||
expect(out.messages[0].content).toBe("ab");
|
||||
});
|
||||
|
||||
it("applies Preview defaults without overriding explicit values", () => {
|
||||
const body = { messages: [{ role: "user", content: "hi" }], temperature: 0.2 };
|
||||
const out = ex.transformRequest("mimo-x-pro-preview", body, true, {});
|
||||
expect(out.temperature).toBe(0.2); // caller's value kept
|
||||
expect(out.top_p).toBe(0.95); // default filled in
|
||||
expect(out.max_tokens).toBe(4096);
|
||||
});
|
||||
|
||||
it("leaves cloud bodies free of Preview defaults", () => {
|
||||
const out = ex.transformRequest("mimo-v2.5-pro", { messages: [{ role: "user", content: "hi" }] }, true, {});
|
||||
expect(out.thinking).toBeUndefined();
|
||||
expect(out.max_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips a provider/model prefix when testing preview ids", () => {
|
||||
expect(bareModel("xiaomi/mimo-x-pro-preview")).toBe("mimo-x-pro-preview");
|
||||
expect(bareModel("mimo-x-pro-preview")).toBe("mimo-x-pro-preview");
|
||||
});
|
||||
});
|
||||
54
tests/unit/xiaomi-mimo-oauth-proxy.test.js
Normal file
54
tests/unit/xiaomi-mimo-oauth-proxy.test.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Regression: the xiaomi-mimo OAuth session store must not retain sessions
|
||||
* once the callback listener is down.
|
||||
*
|
||||
* Each /authorize registers a session holding an X25519 private key, keyed by a
|
||||
* fresh state. Unlike trae/windsurf/zed (singleton session) this is a Map, so
|
||||
* without an explicit clear every login attempt would leak a private key for
|
||||
* the whole process lifetime.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
registerXiaomiMimoSession,
|
||||
getXiaomiMimoSessionStatus,
|
||||
clearXiaomiMimoSession,
|
||||
stopXiaomiMimoProxy,
|
||||
} from "../../src/lib/oauth/utils/server.js";
|
||||
|
||||
const KEY = Buffer.from("x25519-private-key-material");
|
||||
|
||||
describe("xiaomi-mimo OAuth session store", () => {
|
||||
it("drops pending sessions when the proxy stops", () => {
|
||||
registerXiaomiMimoSession({ state: "s1", privateKeyDer: KEY });
|
||||
expect(getXiaomiMimoSessionStatus("s1")).not.toBeNull();
|
||||
|
||||
stopXiaomiMimoProxy();
|
||||
|
||||
expect(getXiaomiMimoSessionStatus("s1")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops every session, not just the last one", () => {
|
||||
registerXiaomiMimoSession({ state: "a", privateKeyDer: KEY });
|
||||
registerXiaomiMimoSession({ state: "b", privateKeyDer: KEY });
|
||||
registerXiaomiMimoSession({ state: "c", privateKeyDer: KEY });
|
||||
|
||||
stopXiaomiMimoProxy();
|
||||
|
||||
for (const s of ["a", "b", "c"]) {
|
||||
expect(getXiaomiMimoSessionStatus(s)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores registrations with a missing state or key", () => {
|
||||
expect(registerXiaomiMimoSession({ state: "", privateKeyDer: KEY })).toBe(false);
|
||||
expect(registerXiaomiMimoSession({ state: "s", privateKeyDer: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("never exposes the private key to callers", () => {
|
||||
registerXiaomiMimoSession({ state: "s1", privateKeyDer: KEY });
|
||||
const view = getXiaomiMimoSessionStatus("s1");
|
||||
expect(view).toEqual({ status: "pending", result: null, error: null });
|
||||
expect(JSON.stringify(view)).not.toContain("privateKeyDer");
|
||||
clearXiaomiMimoSession("s1");
|
||||
});
|
||||
});
|
||||
152
tests/unit/xiaomi-mimo-oauth-session.test.js
Normal file
152
tests/unit/xiaomi-mimo-oauth-session.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Regression: the poll-status/exchange session lifecycle for xiaomi-mimo.
|
||||
*
|
||||
* The original PR cleared the session inside poll-status, so the client's
|
||||
* following POST /exchange always saw a missing session and returned 400 —
|
||||
* the whole browser-OAuth fallback was dead. These tests pin the contract:
|
||||
* - a finished session survives /poll-status until /exchange consumes it
|
||||
* - a failed session is cleaned up by /poll-status itself
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json: (body, init) => ({
|
||||
status: init?.status || 200,
|
||||
body,
|
||||
json: async () => body,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/oauth/providers", () => ({
|
||||
getProvider: vi.fn(),
|
||||
generateAuthData: vi.fn(),
|
||||
exchangeTokens: vi.fn(),
|
||||
requestDeviceCode: vi.fn(),
|
||||
pollForToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/models", () => ({
|
||||
createProviderConnection: vi.fn(async (d) => ({ id: "conn-1", ...d })),
|
||||
}));
|
||||
|
||||
vi.mock("open-sse/shared/mimoAccount.js", () => ({
|
||||
readDesktopPassToken: vi.fn(async () => ({ passToken: "pt-abc", userId: "u1", cUserId: "c1" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/oauth/utils/ideDetect", () => ({ detectIdeInstalled: vi.fn() }));
|
||||
|
||||
// Session store backing the mocked OAuth server helpers, so the test can assert
|
||||
// on real lifecycle transitions rather than on call counts alone.
|
||||
const sessions = new Map();
|
||||
const stopped = { count: 0 };
|
||||
|
||||
vi.mock("@/lib/oauth/utils/server", () => {
|
||||
const notUsed = () => { throw new Error("unexpected helper"); };
|
||||
const noop = () => {};
|
||||
return {
|
||||
startCodexProxy: notUsed, stopCodexProxy: noop, registerCodexSession: noop,
|
||||
getCodexSessionStatus: () => null, clearCodexSession: noop,
|
||||
startXaiProxy: notUsed, stopXaiProxy: noop, registerXaiSession: noop,
|
||||
getXaiSessionStatus: () => null, clearXaiSession: noop,
|
||||
startTraeProxy: notUsed, stopTraeProxy: noop, registerTraeSession: noop,
|
||||
getTraeSessionStatus: () => null, clearTraeSession: noop,
|
||||
startWindsurfProxy: notUsed, stopWindsurfProxy: noop, registerWindsurfSession: noop,
|
||||
getWindsurfSessionStatus: () => null, clearWindsurfSession: noop,
|
||||
startZedProxy: notUsed, stopZedProxy: noop, registerZedSession: noop,
|
||||
getZedSessionStatus: () => null, clearZedSession: noop,
|
||||
startXiaomiMimoProxy: notUsed,
|
||||
stopXiaomiMimoProxy: () => { stopped.count += 1; },
|
||||
registerXiaomiMimoSession: () => {},
|
||||
getXiaomiMimoSessionStatus: (state) => {
|
||||
const s = sessions.get(state);
|
||||
return s ? { status: s.status, result: s.result || null, error: s.error || null } : null;
|
||||
},
|
||||
clearXiaomiMimoSession: (state) => { sessions.delete(state); },
|
||||
};
|
||||
});
|
||||
|
||||
const { GET, POST } = await import("../../src/app/api/oauth/[provider]/[action]/route.js");
|
||||
|
||||
const get = (action, state) =>
|
||||
GET(new Request(`http://localhost/api/oauth/xiaomi-mimo/${action}?state=${state}`), {
|
||||
params: Promise.resolve({ provider: "xiaomi-mimo", action }),
|
||||
});
|
||||
|
||||
const exchange = (state) =>
|
||||
POST(
|
||||
new Request("http://localhost/api/oauth/xiaomi-mimo/exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ state }),
|
||||
}),
|
||||
{ params: Promise.resolve({ provider: "xiaomi-mimo", action: "exchange" }) },
|
||||
);
|
||||
|
||||
describe("xiaomi-mimo OAuth session lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
sessions.clear();
|
||||
stopped.count = 0;
|
||||
});
|
||||
|
||||
it("keeps a finished session alive so /exchange can consume it", async () => {
|
||||
sessions.set("st1", { status: "done", result: { uid: "u1", accessToken: "sk-x", baseUrl: "https://api.xiaomimimo.com/v1" } });
|
||||
|
||||
const poll = await get("poll-status", "st1");
|
||||
expect(poll.status).toBe(200);
|
||||
expect(await poll.json()).toMatchObject({ status: "done" });
|
||||
|
||||
// The bug: this used to be gone, making /exchange always 400.
|
||||
expect(sessions.has("st1")).toBe(true);
|
||||
|
||||
const res = await exchange("st1");
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).success).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the session once /exchange consumed it", async () => {
|
||||
sessions.set("st1", { status: "done", result: { uid: "u1", accessToken: "sk-x" } });
|
||||
await exchange("st1");
|
||||
expect(sessions.has("st1")).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up a failed session in poll-status and stops the proxy", async () => {
|
||||
sessions.set("st2", { status: "error", error: "Could not decrypt with any pending session key" });
|
||||
|
||||
const poll = await get("poll-status", "st2");
|
||||
expect(await poll.json()).toMatchObject({ status: "error" });
|
||||
|
||||
expect(sessions.has("st2")).toBe(false);
|
||||
expect(stopped.count).toBe(1);
|
||||
});
|
||||
|
||||
it("persists the Desktop passToken onto the connection (Preview models need it)", async () => {
|
||||
const { createProviderConnection } = await import("@/models");
|
||||
sessions.set("st3", { status: "done", result: { uid: "u1", accessToken: "sk-x" } });
|
||||
|
||||
await exchange("st3");
|
||||
|
||||
const arg = createProviderConnection.mock.calls.at(-1)[0];
|
||||
expect(arg.provider).toBe("xiaomi-mimo");
|
||||
expect(arg.providerSpecificData.mimoPassToken).toBe("pt-abc");
|
||||
expect(arg.providerSpecificData.mimoUserId).toBe("u1");
|
||||
});
|
||||
|
||||
it("still reports unknown for an unregistered state", async () => {
|
||||
const poll = await get("poll-status", "nope");
|
||||
expect(await poll.json()).toEqual({ status: "unknown" });
|
||||
});
|
||||
|
||||
it("rejects /exchange without a state", async () => {
|
||||
const res = await POST(
|
||||
new Request("http://localhost/api/oauth/xiaomi-mimo/exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
{ params: Promise.resolve({ provider: "xiaomi-mimo", action: "exchange" }) },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user