mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
merge(master): sync master into dev
This commit is contained in:
@@ -67,8 +67,8 @@ describe("antigravity computeRetryDelay hook (D3)", () => {
|
||||
expect(out.request.tools[0].functionDeclarations.map(fn => fn.name)).toEqual(["read_file"]);
|
||||
});
|
||||
|
||||
it("registry uses the official IDE cloudcode host and user agent", () => {
|
||||
expect(antigravity.transport.baseUrls).toEqual(["https://cloudcode-pa.googleapis.com"]);
|
||||
it("registry uses the daily IDE cloudcode host and user agent", () => {
|
||||
expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]);
|
||||
expect(antigravity.transport.headers["User-Agent"]).toBe("antigravity/ide/2.1.1 darwin/arm64");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
|
||||
|
||||
const credentials = {
|
||||
projectId: "synthetic-project",
|
||||
connectionId: "synthetic-connection",
|
||||
};
|
||||
|
||||
function requestBody(stream) {
|
||||
return {
|
||||
stream,
|
||||
stream_options: { include_usage: true },
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "Reply only OK" }] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("AntigravityExecutor stream_options normalization", () => {
|
||||
it("removes stream_options from a non-streaming request", () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const output = executor.transformRequest(
|
||||
"gpt-oss-120b-medium",
|
||||
requestBody(false),
|
||||
false,
|
||||
credentials,
|
||||
);
|
||||
|
||||
expect(output.stream).toBe(false);
|
||||
expect(output.stream_options).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves stream_options for a streaming request", () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const output = executor.transformRequest(
|
||||
"gpt-oss-120b-medium",
|
||||
requestBody(true),
|
||||
true,
|
||||
credentials,
|
||||
);
|
||||
|
||||
expect(output.stream).toBe(true);
|
||||
expect(output.stream_options).toEqual({ include_usage: true });
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
|
||||
// Claude Opus 4.6+ ships a 1M-token context window (GA, standard pricing).
|
||||
// The registry exposes dashed ids (claude-opus-4-8, claude-opus-4-7), which
|
||||
// The registry exposes dashed ids (claude-opus-5, claude-opus-4-8, claude-opus-4-7), which
|
||||
// must resolve to the 1M context + adaptive thinking caps rather than falling
|
||||
// through to the generic *claude*opus* pattern (200k / budget thinking).
|
||||
describe("Claude Opus 1M context capabilities", () => {
|
||||
@@ -17,6 +17,10 @@ describe("Claude Opus 1M context capabilities", () => {
|
||||
};
|
||||
|
||||
for (const model of [
|
||||
"claude-opus-5",
|
||||
"claude-opus-5-thinking",
|
||||
"claude-opus-5-agentic",
|
||||
"claude-opus-5-thinking-agentic",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4.8",
|
||||
"claude-opus-4-7",
|
||||
|
||||
@@ -20,6 +20,18 @@ describe("getCapabilitiesForModel", () => {
|
||||
search: true,
|
||||
};
|
||||
|
||||
it("reports Kiro Claude Opus 5 variants as 1M adaptive-thinking models", () => {
|
||||
for (const model of [
|
||||
"claude-opus-5",
|
||||
"anthropic/claude-opus-5",
|
||||
"claude-opus-5-thinking",
|
||||
"claude-opus-5-agentic",
|
||||
"claude-opus-5-thinking-agentic",
|
||||
]) {
|
||||
expect(getCapabilitiesForModel("kiro", model)).toMatchObject(claudeSonnet5Expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports Kiro Claude Opus 4.8 as a 1M context model", () => {
|
||||
expect(getCapabilitiesForModel("kiro", "claude-opus-4.8").contextWindow).toBe(1000000);
|
||||
expect(getCapabilitiesForModel("kiro", "anthropic/claude-opus-4.8").contextWindow).toBe(1000000);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// Mock DNS so the SSRF guard treats example.com as public.
|
||||
vi.mock("node:dns/promises", () => ({ lookup: async () => ({ address: "93.184.216.34" }) }));
|
||||
vi.mock("node:dns/promises", () => ({ lookup: async () => [{ address: "93.184.216.34", family: 4 }] }));
|
||||
|
||||
import { CodexExecutor } from "../../open-sse/executors/codex.js";
|
||||
import * as proxyFetchModule from "../../open-sse/utils/proxyFetch.js";
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { CursorExecutor } from "../../open-sse/executors/cursor.js";
|
||||
import { encodeField, wrapConnectRPCFrame } from "../../open-sse/utils/cursorProtobuf.js";
|
||||
|
||||
const LEN = 2;
|
||||
|
||||
// agent.v1.AgentServerMessage.exec_request (field 2) carrying one ExecServerMessage variant.
|
||||
function execRequestFrame(execField) {
|
||||
const execServerMessage = Buffer.from(encodeField(execField, LEN, new Uint8Array()));
|
||||
return Buffer.from(wrapConnectRPCFrame(encodeField(2, LEN, execServerMessage)));
|
||||
}
|
||||
|
||||
// agent.v1.AgentServerMessage.interaction_update (field 1) → text delta.
|
||||
function textFrame(text) {
|
||||
const textPart = Buffer.from(encodeField(1, LEN, text));
|
||||
const update = Buffer.from(encodeField(1, LEN, textPart));
|
||||
return Buffer.from(wrapConnectRPCFrame(encodeField(1, LEN, update)));
|
||||
}
|
||||
|
||||
function stubAgentSession(executor, frames) {
|
||||
const written = [];
|
||||
const queue = [...frames];
|
||||
executor.openAgentHttp2Stream = () => ({
|
||||
responseHeaders: Promise.resolve({ ":status": 200 }),
|
||||
write: (frame) => written.push(Buffer.from(frame)),
|
||||
end() {},
|
||||
close() {},
|
||||
async read() {
|
||||
if (!queue.length) return { value: undefined, done: true };
|
||||
return { value: queue.shift(), done: false };
|
||||
},
|
||||
});
|
||||
return written;
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
accessToken: "test-token",
|
||||
providerSpecificData: { machineId: "a".repeat(64) },
|
||||
};
|
||||
|
||||
function parseSSE(text) {
|
||||
return text
|
||||
.split("\n\n")
|
||||
.filter((chunk) => chunk.startsWith("data: "))
|
||||
.map((chunk) => chunk.slice("data: ".length))
|
||||
.filter((data) => data !== "[DONE]")
|
||||
.map((data) => JSON.parse(data));
|
||||
}
|
||||
|
||||
async function runAgent({ frames, stream }) {
|
||||
const executor = new CursorExecutor();
|
||||
const written = stubAgentSession(executor, frames);
|
||||
const result = await executor.executeAgent({
|
||||
model: "gpt-5.2",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream,
|
||||
credentials,
|
||||
});
|
||||
return { result, written };
|
||||
}
|
||||
|
||||
describe("CursorExecutor AgentService exec_request handling", () => {
|
||||
it("acknowledges a request-context exec request without ending the turn", async () => {
|
||||
const { result, written } = await runAgent({
|
||||
frames: [execRequestFrame(10), textFrame("hello")],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(written.length).toBe(2); // run frame + request-context reply
|
||||
const events = parseSSE(await result.response.text());
|
||||
const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join("");
|
||||
expect(content).toBe("hello");
|
||||
});
|
||||
|
||||
it("does not render an unsupported exec request as assistant content", async () => {
|
||||
const { result } = await runAgent({
|
||||
frames: [textFrame("partial answer"), execRequestFrame(2)],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const body = await result.response.text();
|
||||
expect(body).not.toContain("unsupported IDE tool\\n");
|
||||
const events = parseSSE(body);
|
||||
const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join("");
|
||||
expect(content).toBe("partial answer");
|
||||
|
||||
const errorEvent = events.find((e) => e.error);
|
||||
expect(errorEvent?.error?.message).toContain("unsupported IDE tool");
|
||||
expect(events.some((e) => e.choices?.[0]?.finish_reason === "stop")).toBe(false);
|
||||
});
|
||||
|
||||
it("drops frames batched behind an unsupported exec request in the same read", async () => {
|
||||
const { result } = await runAgent({
|
||||
frames: [Buffer.concat([execRequestFrame(2), textFrame("late")])],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const body = await result.response.text();
|
||||
expect(body).toContain("unsupported IDE tool");
|
||||
expect(body).not.toContain("late");
|
||||
});
|
||||
|
||||
it("returns a non-200 error body for an unsupported exec request when not streaming", async () => {
|
||||
const { result } = await runAgent({
|
||||
frames: [execRequestFrame(11)],
|
||||
stream: false,
|
||||
});
|
||||
|
||||
expect(result.response.status).not.toBe(200);
|
||||
const payload = await result.response.json();
|
||||
expect(payload.error.message).toContain("unsupported IDE tool");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
let tempDir;
|
||||
let db;
|
||||
let adminOwnerId;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-concurrent-"));
|
||||
@@ -15,6 +16,8 @@ beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/db/index.js");
|
||||
await db.initDb();
|
||||
const admin = await db.createUser({ username: "concurrency-admin", password: "password", role: "admin" });
|
||||
adminOwnerId = admin.id;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -102,6 +105,7 @@ describe("DB Concurrency — atomic safety", () => {
|
||||
const conn = await db.createProviderConnection({
|
||||
provider: "oauth-test", authType: "oauth", email: "x@y.com",
|
||||
accessToken: "initial", refreshToken: "rt-initial",
|
||||
ownerId: adminOwnerId,
|
||||
});
|
||||
|
||||
// 20 parallel updates each with a unique field
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
let tempDir;
|
||||
let sqliteDb;
|
||||
let adminOwnerId;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-db-compare-"));
|
||||
@@ -15,6 +16,8 @@ beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
sqliteDb = await import("@/lib/db/index.js");
|
||||
await sqliteDb.initDb();
|
||||
const admin = await sqliteDb.createUser({ username: "db-parity-admin", password: "password", role: "admin" });
|
||||
adminOwnerId = admin.id;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -76,9 +79,9 @@ describe("DB SQLite layer — public API parity", () => {
|
||||
});
|
||||
|
||||
it("providerConnections: CRUD + reorder by priority", async () => {
|
||||
const c1 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "a", apiKey: "k1" });
|
||||
const c2 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "b", apiKey: "k2" });
|
||||
const c3 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "c", apiKey: "k3" });
|
||||
const c1 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "a", apiKey: "k1", ownerId: adminOwnerId });
|
||||
const c2 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "b", apiKey: "k2", ownerId: adminOwnerId });
|
||||
const c3 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "c", apiKey: "k3", ownerId: adminOwnerId });
|
||||
|
||||
const list = await sqliteDb.getProviderConnections({ provider: "test" });
|
||||
expect(list).toHaveLength(3);
|
||||
@@ -103,6 +106,7 @@ describe("DB SQLite layer — public API parity", () => {
|
||||
provider: "p2", authType: "oauth", email: "x@y.com",
|
||||
accessToken: "tok", refreshToken: "rtok", expiresAt: 12345,
|
||||
providerSpecificData: { foo: "bar" },
|
||||
ownerId: adminOwnerId,
|
||||
});
|
||||
const back = await sqliteDb.getProviderConnectionById(c.id);
|
||||
expect(back.accessToken).toBe("tok");
|
||||
@@ -112,8 +116,8 @@ describe("DB SQLite layer — public API parity", () => {
|
||||
});
|
||||
|
||||
it("providerConnections: scopes retrieval and lookup to connection owner", async () => {
|
||||
const ownerOne = await sqliteDb.createUser({ username: "connection-owner-one", password: "password", role: "user" });
|
||||
const ownerTwo = await sqliteDb.createUser({ username: "connection-owner-two", password: "password", role: "user" });
|
||||
const ownerOne = await sqliteDb.createUser({ username: "connection-owner-one", password: "password", role: "admin" });
|
||||
const ownerTwo = await sqliteDb.createUser({ username: "connection-owner-two", password: "password", role: "admin" });
|
||||
const firstConnection = await sqliteDb.createProviderConnection({
|
||||
provider: "owner-test-one",
|
||||
authType: "apikey",
|
||||
@@ -200,6 +204,7 @@ describe("DB SQLite layer — public API parity", () => {
|
||||
authType: "oauth",
|
||||
accessToken: "tok",
|
||||
providerSpecificData: { githubLogin: "octocat" },
|
||||
ownerId: adminOwnerId,
|
||||
});
|
||||
|
||||
expect(c.name).toBe("octocat");
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.js";
|
||||
import {
|
||||
USAGE_SUPPORTED_PROVIDERS,
|
||||
USAGE_APIKEY_PROVIDERS,
|
||||
} from "../../src/shared/constants/providers.js";
|
||||
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
const BALANCE_URL = "https://api.deepseek.com/user/balance";
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const ACTIVE_BALANCE = {
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "12.50",
|
||||
granted_balance: "2.50",
|
||||
topped_up_balance: "10.00",
|
||||
},
|
||||
{
|
||||
currency: "CNY",
|
||||
total_balance: "0.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "0.00",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("deepseek registry usage flags", () => {
|
||||
it("is listed for apikey quota dashboard", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("deepseek");
|
||||
expect(USAGE_APIKEY_PROVIDERS).toContain("deepseek");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsageForProvider(deepseek)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("GETs /user/balance with Bearer apiKey", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_BALANCE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "deepseek",
|
||||
apiKey: "sk-ds-test",
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("DeepSeek");
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = proxyAwareFetch.mock.calls[0];
|
||||
expect(url).toBe(BALANCE_URL);
|
||||
expect(opts.method).toBe("GET");
|
||||
expect(opts.headers.Authorization).toBe("Bearer sk-ds-test");
|
||||
});
|
||||
|
||||
it("maps balances without absolute remaining (UI treats remaining as %)", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_BALANCE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "deepseek",
|
||||
apiKey: "sk-ds-test",
|
||||
});
|
||||
|
||||
expect(usage.quotas["Balance (USD)"]).toMatchObject({
|
||||
used: 0,
|
||||
total: 12.5,
|
||||
remainingPercentage: 100,
|
||||
});
|
||||
expect(usage.quotas["Balance (USD)"].remaining).toBeUndefined();
|
||||
// Zero CNY still listed so user sees currency row
|
||||
expect(usage.quotas["Balance (CNY)"]).toMatchObject({
|
||||
used: 0,
|
||||
total: 0,
|
||||
remainingPercentage: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks plan unavailable when is_available false", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
is_available: false,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "0",
|
||||
granted_balance: "0",
|
||||
topped_up_balance: "0",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "deepseek",
|
||||
apiKey: "sk-ds-test",
|
||||
});
|
||||
|
||||
expect(usage.plan).toMatch(/insufficient|unavailable/i);
|
||||
expect(usage.quotas["Balance (USD)"].remainingPercentage).toBe(0);
|
||||
});
|
||||
|
||||
it("returns message on missing key / 401", async () => {
|
||||
const missing = await getUsageForProvider({ provider: "deepseek" });
|
||||
expect(missing.message).toMatch(/api key/i);
|
||||
expect(proxyAwareFetch).not.toHaveBeenCalled();
|
||||
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "no" }, 401));
|
||||
const auth = await getUsageForProvider({
|
||||
provider: "deepseek",
|
||||
apiKey: "bad",
|
||||
});
|
||||
expect(auth.message).toMatch(/auth|key|401/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuotaData(deepseek)", () => {
|
||||
it("forwards remainingPercentage for balance rows", () => {
|
||||
const rows = parseQuotaData("deepseek", {
|
||||
plan: "DeepSeek",
|
||||
quotas: {
|
||||
"Balance (USD)": {
|
||||
used: 0,
|
||||
total: 12.5,
|
||||
remainingPercentage: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: "Balance (USD)",
|
||||
total: 12.5,
|
||||
remainingPercentage: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import os from "node:os";
|
||||
|
||||
// `vi.hoisted` runs before the mocked module is evaluated, so the factory can
|
||||
// safely reference the mock fn.
|
||||
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: (...args) => spawnMock(...args),
|
||||
}));
|
||||
|
||||
const { default: DevinCliExecutor } = await import("open-sse/executors/devin-cli.js");
|
||||
|
||||
// Fake devin ACP subprocess. Mirrors the real CLI's session/new validation:
|
||||
// it requires `mcpServers` to be an array, otherwise returns -32602 — this is
|
||||
// the exact error the dashboard "test" button hit ("Invalid params").
|
||||
function makeFakeChild() {
|
||||
const child = new EventEmitter();
|
||||
child.writes = [];
|
||||
child.stdin = new EventEmitter();
|
||||
child.stdin.destroyed = false;
|
||||
child.stdin.write = (data) => {
|
||||
child.writes.push(String(data));
|
||||
try {
|
||||
const msg = JSON.parse(String(data).trim());
|
||||
handle(msg);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return true;
|
||||
};
|
||||
child.stdin.end = () => {
|
||||
child.stdin.destroyed = true;
|
||||
};
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.killed = false;
|
||||
child.kill = () => {
|
||||
child.killed = true;
|
||||
};
|
||||
|
||||
const send = (obj) =>
|
||||
child.stdout.emit("data", Buffer.from(JSON.stringify(obj) + "\n"));
|
||||
|
||||
function handle(msg) {
|
||||
if (msg.method === "initialize") {
|
||||
send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
|
||||
} else if (msg.method === "session/new") {
|
||||
// Mirror devin 3000.2.x: `mcpServers` is a required sequence.
|
||||
if (Array.isArray(msg.params && msg.params.mcpServers)) {
|
||||
send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "fake-session" } });
|
||||
} else if (!msg.params || msg.params.mcpServers === undefined) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32602, message: "Invalid params", data: { error: "missing field `mcpServers`" } },
|
||||
});
|
||||
} else {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32602, message: "Invalid params", data: { error: "invalid type: map, expected a sequence" } },
|
||||
});
|
||||
}
|
||||
} else if (msg.method === "session/prompt") {
|
||||
// devin 3000.2.x requires `prompt` (a sequence), not `content`.
|
||||
if (Array.isArray(msg.params && msg.params.prompt)) {
|
||||
// Agent requests permission to run a tool before replying.
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: 777,
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
sessionId: "fake-session",
|
||||
options: [
|
||||
{ optionId: "allow-once", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
},
|
||||
});
|
||||
// New ACP shape: streaming via session/update with params.update.sessionUpdate.
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: { sessionId: "fake-session", update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "(thinking)" } } },
|
||||
});
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: { sessionId: "fake-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hello world" } } },
|
||||
});
|
||||
// Stop signal: _cognition.ai/agent_stopped notification.
|
||||
send({ jsonrpc: "2.0", method: "_cognition.ai/agent_stopped", params: { cause: "complete" } });
|
||||
} else {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32602, message: "Invalid params", data: { error: "missing field `prompt`" } },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
async function runExecute(credentials = {}) {
|
||||
const child = makeFakeChild();
|
||||
spawnMock.mockImplementation((bin, args, opts) => {
|
||||
child.bin = bin;
|
||||
child.args = args;
|
||||
child.opts = opts;
|
||||
return child;
|
||||
});
|
||||
const exec = new DevinCliExecutor();
|
||||
const { response } = await exec.execute({
|
||||
model: "swe-1.6-fast",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
credentials,
|
||||
log: { info() {}, debug() {} },
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
let acc = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
acc += new TextDecoder().decode(value);
|
||||
}
|
||||
return { acc, child };
|
||||
}
|
||||
|
||||
describe("DevinCliExecutor ACP session/new", () => {
|
||||
it("sends session/new with mcpServers as an array", async () => {
|
||||
const { child } = await runExecute();
|
||||
const writes = child.writes.map((w) => JSON.parse(w.trim()));
|
||||
const newMsg = writes.find((m) => m.method === "session/new");
|
||||
expect(newMsg).toBeTruthy();
|
||||
expect(Array.isArray(newMsg.params.mcpServers)).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults session/new cwd to os.tmpdir when request has no workspace cwd", async () => {
|
||||
const { child } = await runExecute();
|
||||
const writes = child.writes.map((w) => JSON.parse(w.trim()));
|
||||
const newMsg = writes.find((m) => m.method === "session/new");
|
||||
expect(newMsg.params.cwd).toBe(os.tmpdir());
|
||||
});
|
||||
|
||||
it("uses client <cwd> env context for session/new and spawn", async () => {
|
||||
const child = makeFakeChild();
|
||||
spawnMock.mockImplementation((bin, args, opts) => {
|
||||
child.args = args;
|
||||
child.opts = opts;
|
||||
return child;
|
||||
});
|
||||
const workspace = os.tmpdir(); // known existing absolute dir
|
||||
const exec = new DevinCliExecutor();
|
||||
const { response } = await exec.execute({
|
||||
model: "swe-1.6-fast",
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `<environment_context>\n <cwd>${workspace}</cwd>\n</environment_context>\nhi`,
|
||||
},
|
||||
],
|
||||
},
|
||||
credentials: {},
|
||||
log: { info() {}, debug() {} },
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
expect(child.opts.cwd).toBe(workspace);
|
||||
const writes = child.writes.map((w) => JSON.parse(w.trim()));
|
||||
const newMsg = writes.find((m) => m.method === "session/new");
|
||||
expect(newMsg.params.cwd).toBe(workspace);
|
||||
});
|
||||
|
||||
it("sends session/prompt with prompt (not content) as an array", async () => {
|
||||
const { child } = await runExecute();
|
||||
const writes = child.writes.map((w) => JSON.parse(w.trim()));
|
||||
const promptMsg = writes.find((m) => m.method === "session/prompt");
|
||||
expect(promptMsg).toBeTruthy();
|
||||
expect(Array.isArray(promptMsg.params.prompt)).toBe(true);
|
||||
expect(promptMsg.params.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("completes the prompt without a -32602 Invalid params error", async () => {
|
||||
const { acc } = await runExecute();
|
||||
expect(acc).not.toContain("-32602");
|
||||
expect(acc).not.toContain("Invalid params");
|
||||
expect(acc.toLowerCase()).toContain("hello world");
|
||||
});
|
||||
|
||||
it("emits agent_message_chunk content and skips agent_thought_chunk", async () => {
|
||||
// devin 3000.2.x streams via params.update.sessionUpdate.
|
||||
const { acc } = await runExecute();
|
||||
// Reply text is delivered, finish chunk present, thinking is not surfaced.
|
||||
expect(acc.toLowerCase()).toContain("hello world");
|
||||
expect(acc).toContain("finish_reason");
|
||||
expect(acc.toLowerCase()).not.toContain("(thinking)");
|
||||
expect(acc).toContain("[DONE]");
|
||||
});
|
||||
|
||||
it("spawns the default agent (with built-in tools) by default", async () => {
|
||||
const { child } = await runExecute();
|
||||
expect(child.args).toEqual(["acp"]);
|
||||
});
|
||||
|
||||
it("seeds MCP with tool_result from prior client round-trip", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const child = makeFakeChild();
|
||||
let capturedCfg = null;
|
||||
let capturedPrompt = null;
|
||||
spawnMock.mockImplementation((bin, args, opts) => {
|
||||
child.args = args;
|
||||
child.opts = opts;
|
||||
// Capture config at spawn time (finish() cleans the temp dir).
|
||||
if (opts?.env?.XDG_CONFIG_HOME) {
|
||||
capturedCfg = JSON.parse(
|
||||
fs.readFileSync(opts.env.XDG_CONFIG_HOME + "/devin/config.json", "utf8")
|
||||
);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
const origWrite = child.stdin.write;
|
||||
child.stdin.write = (data) => {
|
||||
const s = String(data);
|
||||
try {
|
||||
const msg = JSON.parse(s.trim());
|
||||
if (msg.method === "session/prompt") {
|
||||
capturedPrompt = msg.params.prompt[0].text;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return origWrite.call(child.stdin, data);
|
||||
};
|
||||
const exec = new DevinCliExecutor();
|
||||
const { response } = await exec.execute({
|
||||
model: "swe-1.6-fast",
|
||||
body: {
|
||||
messages: [
|
||||
{ role: "user", content: "weather?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "get_weather", arguments: '{"city":"Paris"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: "28C sunny" },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
parameters: { type: "object", properties: { city: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
credentials: {},
|
||||
log: { info() {}, debug() {} },
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
expect(capturedCfg).toBeTruthy();
|
||||
const results = JSON.parse(capturedCfg.mcpServers.clientTools.env.DEVIN_MCP_RESULTS);
|
||||
expect(results.mcp_get_weather).toBe("28C sunny");
|
||||
expect(capturedPrompt).toContain("get_weather");
|
||||
expect(capturedPrompt).toContain("28C sunny");
|
||||
});
|
||||
|
||||
it("bridges a client-tool MCP call to an OpenAI tool_use", async () => {
|
||||
// Custom fake: on session/prompt, report devin calling our exposed MCP tool.
|
||||
const child = new EventEmitter();
|
||||
child.writes = [];
|
||||
child.stdin = new EventEmitter();
|
||||
child.stdin.destroyed = false;
|
||||
child.stdin.write = (data) => { child.writes.push(String(data)); handle(JSON.parse(String(data).trim())); return true; };
|
||||
child.stdin.end = () => { child.stdin.destroyed = true; };
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.killed = false;
|
||||
child.kill = () => { child.killed = true; };
|
||||
child.args = ["acp"];
|
||||
child.opts = { env: {} };
|
||||
spawnMock.mockReturnValue(child);
|
||||
const send = (o) => child.stdout.emit("data", Buffer.from(JSON.stringify(o) + "\n"));
|
||||
function handle(msg) {
|
||||
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
|
||||
else if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "s1" } });
|
||||
else if (msg.method === "session/prompt") {
|
||||
// Mirror real ACP: title on first event, rawInput on a later update.
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: { sessionId: "s1", update: { sessionUpdate: "tool_call", toolCallId: "call_abc", title: "Calling mcp_get_weather from clientTools" } },
|
||||
});
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: { sessionId: "s1", update: { sessionUpdate: "tool_call_update", toolCallId: "call_abc", rawInput: { city: "Paris" } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const exec = new DevinCliExecutor();
|
||||
const { response } = await exec.execute({
|
||||
model: "swe-1.6-fast",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "weather?" }],
|
||||
tools: [{ type: "function", function: { name: "get_weather", parameters: { type: "object" } } }],
|
||||
},
|
||||
credentials: {},
|
||||
log: { info() {}, debug() {} },
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
let acc = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
acc += new TextDecoder().decode(value);
|
||||
if (acc.includes("[DONE]")) break;
|
||||
}
|
||||
const tc = JSON.parse(acc.match(/"tool_calls":\[(\{.*?\})\]/)?.[1] ?? "{}");
|
||||
expect(tc.function.name).toBe("get_weather"); // mcp_ prefix stripped, MCP-real untouched
|
||||
expect(tc.id).toBe("call_abc");
|
||||
expect(JSON.parse(tc.function.arguments).city).toBe("Paris");
|
||||
expect(acc).toContain('"finish_reason":"tool_calls"');
|
||||
expect(acc).toContain("[DONE]");
|
||||
});
|
||||
|
||||
it("overrides the agent type via CLI_DEVIN_AGENT_TYPE", async () => {
|
||||
process.env.CLI_DEVIN_AGENT_TYPE = "summarizer";
|
||||
try {
|
||||
const { child } = await runExecute();
|
||||
expect(child.args).toEqual(["acp", "--agent-type", "summarizer"]);
|
||||
} finally {
|
||||
delete process.env.CLI_DEVIN_AGENT_TYPE;
|
||||
}
|
||||
});
|
||||
|
||||
it("sets DEVIN_PERMISSION_MODE=bypass so tool calls don't hang on permission prompts", async () => {
|
||||
const { child } = await runExecute();
|
||||
expect(child.opts.env.DEVIN_PERMISSION_MODE).toBe("bypass");
|
||||
});
|
||||
|
||||
it("does not inject WINDSURF_API_KEY — devin-cli uses stored CLI creds (devin auth login)", async () => {
|
||||
// Provider is noAuth; devin must fall back to ~/.local/share/devin/credentials.toml.
|
||||
// Injecting a bogus WINDSURF_API_KEY makes devin reject stored creds → -32000.
|
||||
const { child } = await runExecute({ accessToken: "bogus-token", apiKey: "bogus-key" });
|
||||
expect(child.opts.env.WINDSURF_API_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects an explicit DEVIN_PERMISSION_MODE override", async () => {
|
||||
process.env.DEVIN_PERMISSION_MODE = "accept-edits";
|
||||
try {
|
||||
const { child } = await runExecute();
|
||||
expect(child.opts.env.DEVIN_PERMISSION_MODE).toBe("accept-edits");
|
||||
} finally {
|
||||
delete process.env.DEVIN_PERMISSION_MODE;
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-approves session/request_permission with the first allow option", async () => {
|
||||
const { child } = await runExecute();
|
||||
const writes = child.writes.map((w) => JSON.parse(w.trim()));
|
||||
const resp = writes.find((m) => m.id === 777 && m.result);
|
||||
expect(resp).toBeTruthy();
|
||||
expect(resp.result.outcome.outcome).toBe("selected");
|
||||
expect(resp.result.outcome.optionId).toBe("allow-once");
|
||||
});
|
||||
|
||||
it("sets XDG_CONFIG_HOME when DEVIN_MCP_SERVERS is provided", async () => {
|
||||
process.env.DEVIN_MCP_SERVERS = JSON.stringify({
|
||||
echo: { command: "/usr/bin/node", args: ["/srv/echo.js"] },
|
||||
});
|
||||
try {
|
||||
const { child } = await runExecute();
|
||||
expect(child.opts.env.XDG_CONFIG_HOME).toBeTruthy();
|
||||
// devin reads $XDG_CONFIG_HOME/devin/config.json (E2E verifies content).
|
||||
} finally {
|
||||
delete process.env.DEVIN_MCP_SERVERS;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not set XDG_CONFIG_HOME when DEVIN_MCP_SERVERS is absent", async () => {
|
||||
const { child } = await runExecute();
|
||||
expect(child.opts.env.XDG_CONFIG_HOME).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes body.tools as an MCP server (sets XDG_CONFIG_HOME + writes script)", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
const child = makeFakeChild();
|
||||
spawnMock.mockImplementation((bin, args, opts) => {
|
||||
child.args = args;
|
||||
child.opts = opts;
|
||||
return child;
|
||||
});
|
||||
const exec = new DevinCliExecutor();
|
||||
const { response } = await exec.execute({
|
||||
model: "swe-1.6-fast",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "weather?" }],
|
||||
tools: [
|
||||
{ type: "function", function: { name: "get_weather", description: "Get weather", parameters: { type: "object", properties: { city: { type: "string" } } } } },
|
||||
],
|
||||
},
|
||||
credentials: {},
|
||||
log: { info() {}, debug() {} },
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
await reader.read();
|
||||
// XDG_CONFIG_HOME set so devin loads the generated config.
|
||||
expect(child.opts.env.XDG_CONFIG_HOME).toBeTruthy();
|
||||
// Static MCP bridge script written to disk.
|
||||
const scriptPath = path.join(os.tmpdir(), "9router-devin-client-tools.mjs");
|
||||
expect(fs.existsSync(scriptPath)).toBe(true);
|
||||
expect(fs.readFileSync(scriptPath, "utf8")).toContain("clientTools");
|
||||
expect(fs.readFileSync(scriptPath, "utf8")).toContain("DEVIN_MCP_TOOLS");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handleEmbeddingsCore: vi.fn(),
|
||||
saveRequestUsage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/sse/services/auth.js", () => ({
|
||||
getProviderCredentials: async () => ({
|
||||
apiKey: "provider-secret",
|
||||
connectionId: "connection-a",
|
||||
connectionName: "Provider A",
|
||||
}),
|
||||
markAccountUnavailable: vi.fn(),
|
||||
clearAccountError: vi.fn(),
|
||||
extractApiKey: () => "client-key",
|
||||
getApiKeyOwnerId: async () => "user-a",
|
||||
isValidApiKey: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/localDb", () => ({ getSettings: async () => ({ requireApiKey: false }) }));
|
||||
vi.mock("../../src/sse/services/model.js", () => ({
|
||||
getModelInfo: async () => ({ provider: "openai", model: "text-embedding-3-small" }),
|
||||
}));
|
||||
vi.mock("../../open-sse/handlers/embeddingsCore.js", () => ({
|
||||
handleEmbeddingsCore: mocks.handleEmbeddingsCore,
|
||||
}));
|
||||
vi.mock("../../open-sse/utils/error.js", () => ({
|
||||
errorResponse: (status, message) => Response.json({ error: message }, { status }),
|
||||
unavailableResponse: (status, message) => Response.json({ error: message }, { status }),
|
||||
}));
|
||||
vi.mock("../../src/sse/utils/logger.js", () => ({
|
||||
request: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn(), maskKey: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../src/sse/services/tokenRefresh.js", () => ({
|
||||
updateProviderCredentials: vi.fn(),
|
||||
checkAndRefreshToken: async (_provider, credentials) => credentials,
|
||||
}));
|
||||
vi.mock("@/lib/usageDb.js", () => ({ saveRequestUsage: mocks.saveRequestUsage }));
|
||||
|
||||
import { handleEmbeddings } from "../../src/sse/handlers/embeddings.js";
|
||||
|
||||
describe("embedding usage persistence", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.saveRequestUsage.mockResolvedValue(undefined);
|
||||
mocks.handleEmbeddingsCore.mockResolvedValue({
|
||||
success: true,
|
||||
usage: { prompt_tokens: 12, total_tokens: 12 },
|
||||
response: Response.json({ data: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
it("records exact provider usage for successful embedding requests", async () => {
|
||||
await handleEmbeddings(new Request("http://localhost/v1/embeddings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }),
|
||||
}));
|
||||
|
||||
expect(mocks.saveRequestUsage).toHaveBeenCalledWith(expect.objectContaining({
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
connectionId: "connection-a",
|
||||
apiKey: "client-key",
|
||||
endpoint: "/v1/embeddings",
|
||||
status: "success",
|
||||
tokens: { prompt_tokens: 12, completion_tokens: 0, total_tokens: 12 },
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
{ prompt_tokens: 0, total_tokens: 0 },
|
||||
{ prompt_tokens: "12", total_tokens: 12 },
|
||||
{ prompt_tokens: 12, total_tokens: 13 },
|
||||
{ prompt_tokens: 12, completion_tokens: 1, total_tokens: 12 },
|
||||
{ prompt_tokens: 12, total_tokens: 12, estimated: true },
|
||||
])("does not record inexact usage %#", async (usage) => {
|
||||
mocks.handleEmbeddingsCore.mockResolvedValue({
|
||||
success: true,
|
||||
usage,
|
||||
response: Response.json({ data: [] }),
|
||||
});
|
||||
|
||||
await handleEmbeddings(new Request("http://localhost/v1/embeddings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }),
|
||||
}));
|
||||
|
||||
expect(mocks.saveRequestUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getProviderCredentials: vi.fn(),
|
||||
markAccountUnavailable: vi.fn(),
|
||||
clearAccountError: vi.fn(),
|
||||
extractApiKey: vi.fn(() => null),
|
||||
isValidApiKey: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getCombos: vi.fn(),
|
||||
handleFetchCore: vi.fn(),
|
||||
checkAndRefreshToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/sse/services/auth.js", () => ({
|
||||
getProviderCredentials: mocks.getProviderCredentials,
|
||||
markAccountUnavailable: mocks.markAccountUnavailable,
|
||||
clearAccountError: mocks.clearAccountError,
|
||||
extractApiKey: mocks.extractApiKey,
|
||||
getApiKeyOwnerId: vi.fn(async () => null),
|
||||
isValidApiKey: mocks.isValidApiKey,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getSettings: mocks.getSettings,
|
||||
getCombos: mocks.getCombos,
|
||||
}));
|
||||
|
||||
vi.mock("open-sse/handlers/fetch/index.js", () => ({
|
||||
handleFetchCore: mocks.handleFetchCore,
|
||||
}));
|
||||
|
||||
vi.mock("@/sse/services/tokenRefresh.js", () => ({
|
||||
checkAndRefreshToken: mocks.checkAndRefreshToken,
|
||||
updateProviderCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/sse/utils/logger.js", () => ({
|
||||
request: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
maskKey: vi.fn(() => "masked"),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/utils/ssrfGuard.js", () => ({
|
||||
assertPublicUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
import { handleFetch } from "@/sse/handlers/fetch.js";
|
||||
|
||||
describe("web fetch account state", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSettings.mockResolvedValue({ requireApiKey: false });
|
||||
mocks.getCombos.mockResolvedValue([]);
|
||||
mocks.getProviderCredentials.mockResolvedValue({
|
||||
apiKey: "jina-test-key",
|
||||
connectionId: "jina-connection",
|
||||
connectionName: "Jina Test",
|
||||
_connection: {
|
||||
testStatus: "unavailable",
|
||||
lastError: "old error",
|
||||
modelLock___all: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
mocks.checkAndRefreshToken.mockImplementation(async (_provider, credentials) => credentials);
|
||||
mocks.handleFetchCore.mockResolvedValue({
|
||||
success: true,
|
||||
data: { provider: "jina-reader", content: { text: "ok" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a stale provider lock after a successful fetch", async () => {
|
||||
const response = await handleFetch(new Request("http://localhost/v1/web/fetch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "jina-reader",
|
||||
url: "https://example.com/article",
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.clearAccountError).toHaveBeenCalledWith(
|
||||
"jina-connection",
|
||||
expect.objectContaining({ connectionName: "Jina Test" }),
|
||||
);
|
||||
expect(mocks.markAccountUnavailable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { getModelUpstreamId } from "../../open-sse/config/providerModels.js";
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
|
||||
import { applyThinking, stripThinkingSuffix } from "../../open-sse/translator/concerns/thinkingUnified.js";
|
||||
import antigravity from "../../open-sse/providers/registry/antigravity.js";
|
||||
import geminiCli from "../../open-sse/providers/registry/gemini-cli.js";
|
||||
import gemini from "../../open-sse/providers/registry/gemini.js";
|
||||
import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
|
||||
import {
|
||||
getProjectIdForConnection,
|
||||
removeConnection,
|
||||
} from "../../open-sse/services/projectId.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const mitmConfig = require("../../src/mitm/config.js");
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function cloudCodeResponse(projectId) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ cloudaicompanionProject: { id: projectId } }),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Gemini Cloud Code endpoint isolation", () => {
|
||||
it("keeps Gemini CLI on the official cloudcode host", async () => {
|
||||
const connectionId = "gemini-cli-endpoint-test";
|
||||
const fetchMock = vi.fn(async () => cloudCodeResponse("gemini-project"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await getProjectIdForConnection(connectionId, "token", "gemini-cli");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expect(geminiCli.transport.baseUrl).toBe("https://cloudcode-pa.googleapis.com/v1internal");
|
||||
removeConnection(connectionId);
|
||||
});
|
||||
|
||||
it("uses the prod cloudcode host for Antigravity discovery but daily for chat", async () => {
|
||||
const connectionId = "antigravity-endpoint-test";
|
||||
const fetchMock = vi.fn(async () => cloudCodeResponse("antigravity-project"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await getProjectIdForConnection(connectionId, "token", "antigravity");
|
||||
|
||||
// Discovery (loadCodeAssist) on PROD — daily host rejects auth/onboarding calls.
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
// Chat transport still uses the daily host to bypass prod 429.
|
||||
expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]);
|
||||
removeConnection(connectionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemini 3.6 Antigravity tiers", () => {
|
||||
it.each(["high", "medium", "low"])(
|
||||
"maps the %s tier to the shared upstream model with matching thinking level",
|
||||
(tier) => {
|
||||
const publicModel = `gemini-3.6-flash-${tier}`;
|
||||
const upstreamModel = getModelUpstreamId("ag", publicModel);
|
||||
const body = {
|
||||
model: stripThinkingSuffix(upstreamModel),
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "hello" }] }],
|
||||
generationConfig: {},
|
||||
},
|
||||
};
|
||||
|
||||
applyThinking("antigravity", upstreamModel, body, "antigravity");
|
||||
const finalBody = new AntigravityExecutor().transformRequest(
|
||||
publicModel,
|
||||
body,
|
||||
true,
|
||||
{ projectId: "project", connectionId: "connection" }
|
||||
);
|
||||
|
||||
expect(upstreamModel).toBe(`gemini-3.6-flash-tiered(${tier})`);
|
||||
expect(finalBody.model).toBe("gemini-3.6-flash-tiered");
|
||||
expect(finalBody.request.generationConfig.thinkingConfig).toEqual({
|
||||
thinkingLevel: tier,
|
||||
includeThoughts: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("Gemini 3.6 MITM model extraction", () => {
|
||||
it("exports the model extractor from the side-effect-free MITM config module", () => {
|
||||
expect(mitmConfig.extractModel).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it.each(["high", "medium", "low"])("extracts the %s thinking tier", (tier) => {
|
||||
const body = Buffer.from(JSON.stringify({
|
||||
request: { generationConfig: { thinkingConfig: { thinkingLevel: tier } } },
|
||||
}));
|
||||
|
||||
expect(mitmConfig.extractModel(
|
||||
"/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent",
|
||||
body
|
||||
)).toBe(`gemini-3.6-flash-${tier}`);
|
||||
});
|
||||
|
||||
it("defaults invalid or missing thinking levels to medium", () => {
|
||||
const body = Buffer.from(JSON.stringify({
|
||||
request: { generationConfig: { thinkingConfig: { thinkingLevel: "unknown" } } },
|
||||
}));
|
||||
|
||||
expect(mitmConfig.extractModel(
|
||||
"/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent",
|
||||
body
|
||||
)).toBe("gemini-3.6-flash-medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gemini 3.6 catalogs and pricing", () => {
|
||||
it("exposes the direct Gemini API models and their pricing", () => {
|
||||
const ids = gemini.models.map((model) => model.id);
|
||||
expect(ids).toContain("gemini-3.6-flash");
|
||||
expect(ids).toContain("gemini-3.5-flash-lite");
|
||||
expect(MODEL_PRICING["gemini-3.6-flash"]).toMatchObject({ input: 1.5, output: 7.5 });
|
||||
expect(MODEL_PRICING["gemini-3.5-flash-lite"]).toMatchObject({ input: 0.3, output: 2.5 });
|
||||
});
|
||||
|
||||
it("keeps the standalone CLI Gemini catalog synchronized", () => {
|
||||
const source = readFileSync(join(here, "../../cli/src/cli/menus/providers.js"), "utf8");
|
||||
const geminiCatalog = source.match(/\n gemini: \[([\s\S]*?)\n \],/)?.[1] || "";
|
||||
|
||||
expect(geminiCatalog).toContain("gemini-3.6-flash");
|
||||
expect(geminiCatalog).toContain("gemini-3.5-flash-lite");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
decodeGrokCreditsFrame,
|
||||
probeFrameHeader,
|
||||
} from "../../open-sse/services/usage/grokCliQuotaFrame.js";
|
||||
|
||||
/**
|
||||
* Minimal protobuf encoder for fixtures — real GetGrokCreditsConfig wire shape
|
||||
* (nested field 1 / fixed32 ratio / Timestamp reset + optional trailer 0x80).
|
||||
*/
|
||||
|
||||
function encodeVarint(value) {
|
||||
const bytes = [];
|
||||
let v = BigInt(value);
|
||||
do {
|
||||
let byte = Number(v & 0x7fn);
|
||||
v >>= 7n;
|
||||
if (v !== 0n) byte |= 0x80;
|
||||
bytes.push(byte);
|
||||
} while (v !== 0n);
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function encodeTag(fieldNumber, wireType) {
|
||||
return encodeVarint((fieldNumber << 3) | wireType);
|
||||
}
|
||||
|
||||
function encodeFixed32Field(fieldNumber, value) {
|
||||
const body = Buffer.alloc(4);
|
||||
body.writeFloatLE(value, 0);
|
||||
return Buffer.concat([encodeTag(fieldNumber, 5), body]);
|
||||
}
|
||||
|
||||
function encodeLengthDelimited(fieldNumber, body) {
|
||||
return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]);
|
||||
}
|
||||
|
||||
function encodeVarintField(fieldNumber, value) {
|
||||
return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]);
|
||||
}
|
||||
|
||||
function encodeTimestampField(fieldNumber, seconds, nanos) {
|
||||
const parts = [];
|
||||
if (seconds !== 0) parts.push(encodeVarintField(1, seconds));
|
||||
if (nanos !== 0) parts.push(encodeVarintField(2, nanos));
|
||||
return encodeLengthDelimited(fieldNumber, Buffer.concat(parts));
|
||||
}
|
||||
|
||||
function encodeCreditsInfo(shape) {
|
||||
const parts = [];
|
||||
if (shape.usageRatio !== undefined) parts.push(encodeFixed32Field(1, shape.usageRatio));
|
||||
if (shape.asOfSeconds !== undefined) {
|
||||
parts.push(encodeTimestampField(4, shape.asOfSeconds, shape.asOfNanos ?? 0));
|
||||
}
|
||||
if (shape.resetSeconds !== undefined) {
|
||||
parts.push(encodeTimestampField(5, shape.resetSeconds, shape.resetNanos ?? 0));
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function encodeTopLevelMessage(creditsInfo) {
|
||||
return encodeLengthDelimited(1, creditsInfo);
|
||||
}
|
||||
|
||||
function frameData(payload) {
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0x00;
|
||||
header.writeUInt32BE(payload.length, 1);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function frameTrailer(statusText = "grpc-status:0\r\n") {
|
||||
const body = Buffer.from(statusText, "utf8");
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0x80;
|
||||
header.writeUInt32BE(body.length, 1);
|
||||
return Buffer.concat([header, body]);
|
||||
}
|
||||
|
||||
const REAL_USAGE_RATIO = 1.0;
|
||||
const REAL_ASOF_SECONDS = 1784221140;
|
||||
const REAL_ASOF_NANOS = 867850000;
|
||||
const REAL_RESET_SECONDS = 1784825940;
|
||||
const REAL_RESET_NANOS = 867850000;
|
||||
const PERCENT_TOLERANCE = 1e-4;
|
||||
|
||||
function isoFromEpoch(seconds, nanos) {
|
||||
return new Date(seconds * 1000 + Math.round(nanos / 1_000_000)).toISOString();
|
||||
}
|
||||
|
||||
describe("decodeGrokCreditsFrame", () => {
|
||||
it("decodes real GetGrokCreditsConfig shape (nested, fixed32, Timestamp, trailer)", () => {
|
||||
const creditsInfo = encodeCreditsInfo({
|
||||
usageRatio: REAL_USAGE_RATIO,
|
||||
asOfSeconds: REAL_ASOF_SECONDS,
|
||||
asOfNanos: REAL_ASOF_NANOS,
|
||||
resetSeconds: REAL_RESET_SECONDS,
|
||||
resetNanos: REAL_RESET_NANOS,
|
||||
});
|
||||
const buffer = Buffer.concat([frameData(encodeTopLevelMessage(creditsInfo)), frameTrailer()]);
|
||||
|
||||
const result = decodeGrokCreditsFrame(buffer);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.percentUsed).toBe(100);
|
||||
expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS));
|
||||
});
|
||||
|
||||
it("ignores trailing gRPC-web trailer frame (flag 0x80)", () => {
|
||||
const creditsInfo = encodeCreditsInfo({
|
||||
usageRatio: 0.5,
|
||||
resetSeconds: REAL_RESET_SECONDS,
|
||||
resetNanos: 0,
|
||||
});
|
||||
const topMessage = encodeTopLevelMessage(creditsInfo);
|
||||
const withoutTrailer = frameData(topMessage);
|
||||
const withTrailer = Buffer.concat([frameData(topMessage), frameTrailer()]);
|
||||
|
||||
const a = decodeGrokCreditsFrame(withoutTrailer);
|
||||
const b = decodeGrokCreditsFrame(withTrailer);
|
||||
expect(a).toBeTruthy();
|
||||
expect(b).toBeTruthy();
|
||||
expect(b.percentUsed).toBe(a.percentUsed);
|
||||
expect(b.resetAt).toBe(a.resetAt);
|
||||
expect(b.percentUsed).toBe(50);
|
||||
});
|
||||
|
||||
it("decodes raw unframed protobuf payload", () => {
|
||||
const creditsInfo = encodeCreditsInfo({
|
||||
usageRatio: 0.75,
|
||||
resetSeconds: REAL_RESET_SECONDS,
|
||||
resetNanos: REAL_RESET_NANOS,
|
||||
});
|
||||
const payload = encodeTopLevelMessage(creditsInfo);
|
||||
expect(probeFrameHeader(payload)).toBeNull();
|
||||
|
||||
const result = decodeGrokCreditsFrame(payload);
|
||||
expect(result).toBeTruthy();
|
||||
expect(Math.abs(result.percentUsed - 75)).toBeLessThan(PERCENT_TOLERANCE);
|
||||
expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS));
|
||||
});
|
||||
|
||||
it("treats omitted usage-ratio as 0% (proto3 default)", () => {
|
||||
const creditsInfo = encodeCreditsInfo({
|
||||
resetSeconds: REAL_RESET_SECONDS,
|
||||
resetNanos: REAL_RESET_NANOS,
|
||||
});
|
||||
const result = decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)));
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.percentUsed).toBe(0);
|
||||
expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS));
|
||||
});
|
||||
|
||||
it("clamps usage ratio above 1.0 to percentUsed 100", () => {
|
||||
const creditsInfo = encodeCreditsInfo({ usageRatio: 1.5 });
|
||||
const result = decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)));
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.percentUsed).toBe(100);
|
||||
});
|
||||
|
||||
it("returns null for negative usage ratio", () => {
|
||||
const creditsInfo = encodeCreditsInfo({ usageRatio: -0.1 });
|
||||
expect(decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when top-level field 1 is not length-delimited", () => {
|
||||
expect(decodeGrokCreditsFrame(frameData(encodeVarintField(1, 42)))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when nested usage-ratio has unexpected wire type", () => {
|
||||
const creditsInfo = encodeLengthDelimited(1, Buffer.from("not-a-float", "utf8"));
|
||||
expect(decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when top-level has no field 1", () => {
|
||||
expect(decodeGrokCreditsFrame(frameData(encodeVarintField(9, 1)))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for truncated buffer", () => {
|
||||
const creditsInfo = encodeCreditsInfo({
|
||||
usageRatio: 0.5,
|
||||
resetSeconds: REAL_RESET_SECONDS,
|
||||
resetNanos: REAL_RESET_NANOS,
|
||||
});
|
||||
const buffer = frameData(encodeTopLevelMessage(creditsInfo));
|
||||
expect(decodeGrokCreditsFrame(buffer.subarray(0, buffer.length - 3))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for trailer-only body", () => {
|
||||
expect(decodeGrokCreditsFrame(frameTrailer())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty buffer", () => {
|
||||
expect(decodeGrokCreditsFrame(Buffer.alloc(0))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeFrameHeader", () => {
|
||||
it("rejects declared length that exceeds body", () => {
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0x00;
|
||||
header.writeUInt32BE(9999, 1);
|
||||
expect(probeFrameHeader(Buffer.concat([header, Buffer.from([0x01, 0x02])]))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid compression flag", () => {
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0x07;
|
||||
expect(probeFrameHeader(header)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts trailer frame header (flag 0x80)", () => {
|
||||
const result = probeFrameHeader(frameTrailer());
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.flag).toBe(0x80);
|
||||
});
|
||||
|
||||
it("reads frame header at non-zero offset", () => {
|
||||
const creditsInfo = encodeCreditsInfo({ usageRatio: 0.5 });
|
||||
const buffer = Buffer.concat([
|
||||
frameData(encodeTopLevelMessage(creditsInfo)),
|
||||
frameTrailer(),
|
||||
]);
|
||||
const first = probeFrameHeader(buffer);
|
||||
expect(first).toBeTruthy();
|
||||
const second = probeFrameHeader(buffer, first.payloadStart + first.payloadLength);
|
||||
expect(second).toBeTruthy();
|
||||
expect(second.flag).toBe(0x80);
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,43 @@ describe("parseGrokCliBilling", () => {
|
||||
expect(parsed.exhausted).toBe(false);
|
||||
});
|
||||
|
||||
it("maps creditUsagePercent to a single Weekly SuperGrok bar (not productUsage)", () => {
|
||||
const parsed = parseGrokCliBilling(
|
||||
{
|
||||
config: {
|
||||
currentPeriod: {
|
||||
type: "USAGE_PERIOD_TYPE_WEEKLY",
|
||||
start: "2026-07-17T12:42:26.494595+00:00",
|
||||
end: "2026-07-24T12:42:26.494595+00:00",
|
||||
},
|
||||
creditUsagePercent: 99.0,
|
||||
onDemandCap: { val: 0 },
|
||||
onDemandUsed: { val: 0 },
|
||||
productUsage: [
|
||||
{ product: "GrokBuild", usagePercent: 97.0 },
|
||||
{ product: "GrokImagine", usagePercent: 2.0 },
|
||||
],
|
||||
isUnifiedBillingUser: true,
|
||||
prepaidBalance: { val: 0 },
|
||||
billingPeriodStart: "2026-07-17T12:42:26.494595+00:00",
|
||||
billingPeriodEnd: "2026-07-24T12:42:26.494595+00:00",
|
||||
},
|
||||
},
|
||||
{ subscriptionTier: "XPremiumPlus", hasGrokCodeAccess: true },
|
||||
);
|
||||
// Single shared-pool bar from creditUsagePercent
|
||||
expect(parsed.quotas["Weekly SuperGrok"]).toMatchObject({
|
||||
used: 99,
|
||||
total: 100,
|
||||
remainingPercentage: 1,
|
||||
resetAt: "2026-07-24T12:42:26.494Z",
|
||||
unlimited: false,
|
||||
});
|
||||
// productUsage must NOT become independent quota bars
|
||||
expect(Object.keys(parsed.quotas)).toEqual(["Weekly SuperGrok"]);
|
||||
expect(parsed.exhausted).toBe(false);
|
||||
});
|
||||
|
||||
it("maps current monthly fields and snake-case subscription tier", () => {
|
||||
const parsed = parseGrokCliBilling({
|
||||
monthlyLimit: { val: 1000 },
|
||||
@@ -132,6 +169,67 @@ describe("parseGrokCliBilling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function encodeVarint(value) {
|
||||
const bytes = [];
|
||||
let v = BigInt(value);
|
||||
do {
|
||||
let byte = Number(v & 0x7fn);
|
||||
v >>= 7n;
|
||||
if (v !== 0n) byte |= 0x80;
|
||||
bytes.push(byte);
|
||||
} while (v !== 0n);
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function encodeTag(fieldNumber, wireType) {
|
||||
return encodeVarint((fieldNumber << 3) | wireType);
|
||||
}
|
||||
|
||||
function encodeFixed32Field(fieldNumber, value) {
|
||||
const body = Buffer.alloc(4);
|
||||
body.writeFloatLE(value, 0);
|
||||
return Buffer.concat([encodeTag(fieldNumber, 5), body]);
|
||||
}
|
||||
|
||||
function encodeLengthDelimited(fieldNumber, body) {
|
||||
return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]);
|
||||
}
|
||||
|
||||
function encodeVarintField(fieldNumber, value) {
|
||||
return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]);
|
||||
}
|
||||
|
||||
function encodeTimestampField(fieldNumber, seconds, nanos) {
|
||||
const parts = [];
|
||||
if (seconds !== 0) parts.push(encodeVarintField(1, seconds));
|
||||
if (nanos !== 0) parts.push(encodeVarintField(2, nanos));
|
||||
return encodeLengthDelimited(fieldNumber, Buffer.concat(parts));
|
||||
}
|
||||
|
||||
/** Framed GetGrokCreditsConfig response for a usage ratio 0..1. */
|
||||
function buildCreditsResponseBuffer(usageRatio, resetSeconds = 1784825940, resetNanos = 867850000) {
|
||||
const creditsInfo = Buffer.concat([
|
||||
encodeFixed32Field(1, usageRatio),
|
||||
encodeTimestampField(5, resetSeconds, resetNanos),
|
||||
]);
|
||||
const topMessage = encodeLengthDelimited(1, creditsInfo);
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = 0x00;
|
||||
header.writeUInt32BE(topMessage.length, 1);
|
||||
return Buffer.concat([header, topMessage]);
|
||||
}
|
||||
|
||||
function binaryResponse(buffer, status = 200) {
|
||||
return new Response(buffer, {
|
||||
status,
|
||||
headers: { "content-type": "application/grpc-web+proto" },
|
||||
});
|
||||
}
|
||||
|
||||
const EMPTY_GRPC_WEB_FRAME = Buffer.from([0, 0, 0, 0, 0]);
|
||||
const GRPC_CREDITS_URL =
|
||||
"https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig";
|
||||
|
||||
describe("getUsageForProvider(grok-cli)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -174,6 +272,8 @@ describe("getUsageForProvider(grok-cli)", () => {
|
||||
expect(billingCall[1].headers["x-userid"]).toBe(
|
||||
"d84768dd-224d-4052-ba49-0d336fa9160c",
|
||||
);
|
||||
// REST already has numeric quotas — do not hit gRPC fallback
|
||||
expect(proxyAwareFetch.mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("surfaces auth-expired message on 401", async () => {
|
||||
@@ -187,6 +287,8 @@ describe("getUsageForProvider(grok-cli)", () => {
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/expired|re-authorize/i);
|
||||
// Auth failure must not attempt gRPC fallback
|
||||
expect(proxyAwareFetch.mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns depleted on-demand bar without blocking message when cap is zero", async () => {
|
||||
@@ -204,15 +306,62 @@ describe("getUsageForProvider(grok-cli)", () => {
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.quotas["On-demand"].remainingPercentage).toBe(0);
|
||||
expect(usage.quotas["On-demand"].total).toBe(1);
|
||||
// Exhausted free already has a quota bar — no gRPC fallback
|
||||
expect(proxyAwareFetch.mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reports active paid access when provider exposes no numeric quota", async () => {
|
||||
it("falls back to GetGrokCreditsConfig gRPC when paid sub has no REST numeric quota", async () => {
|
||||
const resetSeconds = 1784825940;
|
||||
const resetNanos = 867850000;
|
||||
const resetAt = new Date(
|
||||
resetSeconds * 1000 + Math.round(resetNanos / 1_000_000),
|
||||
).toISOString();
|
||||
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
...USER_PROFILE,
|
||||
subscriptionTier: "XPremiumPlus",
|
||||
}));
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
...USER_PROFILE,
|
||||
subscriptionTier: "XPremiumPlus",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(binaryResponse(buildCreditsResponseBuffer(0.35, resetSeconds, resetNanos)));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "grok-cli",
|
||||
accessToken: "test-token",
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("XPremiumPlus");
|
||||
expect(usage.quotas["Weekly SuperGrok"]).toMatchObject({
|
||||
used: 35,
|
||||
total: 100,
|
||||
remainingPercentage: 65,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
});
|
||||
|
||||
const grpcCall = proxyAwareFetch.mock.calls[2];
|
||||
expect(grpcCall[0]).toBe(GRPC_CREDITS_URL);
|
||||
expect(grpcCall[1].method).toBe("POST");
|
||||
expect(grpcCall[1].headers.Authorization).toBe("Bearer test-token");
|
||||
expect(grpcCall[1].headers["Content-Type"]).toBe("application/grpc-web+proto");
|
||||
expect(grpcCall[1].headers["X-Grpc-Web"]).toBe("1");
|
||||
// Empty gRPC-web request frame is required (flag 0 + length 0)
|
||||
expect(Buffer.from(grpcCall[1].body)).toEqual(EMPTY_GRPC_WEB_FRAME);
|
||||
});
|
||||
|
||||
it("keeps subscription message when REST empty and gRPC fails open", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
...USER_PROFILE,
|
||||
subscriptionTier: "XPremiumPlus",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(binaryResponse(Buffer.alloc(0), 500));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "grok-cli",
|
||||
@@ -223,6 +372,26 @@ describe("getUsageForProvider(grok-cli)", () => {
|
||||
expect(usage.message).toMatch(/active.*numeric included quota/i);
|
||||
expect(usage.quotas).toEqual({});
|
||||
});
|
||||
|
||||
it("does not throw when gRPC network fails after empty REST quotas", async () => {
|
||||
proxyAwareFetch
|
||||
.mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
...USER_PROFILE,
|
||||
subscriptionTier: "XPremiumPlus",
|
||||
}),
|
||||
)
|
||||
.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "grok-cli",
|
||||
accessToken: "test-token",
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/active.*numeric included quota/i);
|
||||
expect(usage.quotas).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuotaData(grok-cli)", () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleFetchCore } from "../../open-sse/handlers/fetch/index.js";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe("Jina Reader fetch", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("uses Jina's JSON POST API instead of embedding the URL in the path", async () => {
|
||||
global.fetch.mockResolvedValueOnce(new Response([
|
||||
"Title: Example page",
|
||||
"",
|
||||
"URL Source: https://example.com/article",
|
||||
"",
|
||||
"Markdown Content:",
|
||||
"Hello",
|
||||
].join("\n")));
|
||||
|
||||
const result = await handleFetchCore({
|
||||
url: "https://example.com/article",
|
||||
format: "markdown",
|
||||
provider: "jina-reader",
|
||||
providerConfig: { timeoutMs: 30000 },
|
||||
credentials: { apiKey: "jina-test-key" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.title).toBe("Example page");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [requestUrl, init] = global.fetch.mock.calls[0];
|
||||
expect(requestUrl).toBe("https://r.jina.ai/");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toEqual({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer jina-test-key",
|
||||
});
|
||||
expect(JSON.parse(init.body)).toEqual({ url: "https://example.com/article" });
|
||||
});
|
||||
|
||||
it("returns the upstream status and error body", async () => {
|
||||
global.fetch.mockResolvedValueOnce(new Response(
|
||||
JSON.stringify({ detail: "Payment required" }),
|
||||
{ status: 402, headers: { "Content-Type": "application/json" } },
|
||||
));
|
||||
|
||||
const result = await handleFetchCore({
|
||||
url: "https://example.com/article",
|
||||
provider: "jina-reader",
|
||||
providerConfig: { timeoutMs: 30000 },
|
||||
credentials: { apiKey: "jina-test-key" },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
status: 402,
|
||||
});
|
||||
expect(result.error).toContain("Payment required");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.js";
|
||||
import { USAGE_SUPPORTED_PROVIDERS, USAGE_APIKEY_PROVIDERS } from "../../src/shared/constants/providers.js";
|
||||
import { PROVIDERS } from "../../open-sse/providers/index.js";
|
||||
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
|
||||
|
||||
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const ACTIVE_USAGE = {
|
||||
user: {
|
||||
membership: { level: "LEVEL_ADVANCED" },
|
||||
},
|
||||
usage: {
|
||||
limit: "100",
|
||||
used: "35",
|
||||
remaining: "65",
|
||||
resetTime: "2026-08-01T00:00:00Z",
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
window: { type: "rate" },
|
||||
detail: {
|
||||
limit: "60",
|
||||
remaining: "40",
|
||||
resetTime: "2026-07-29T12:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("kimi registry usage flags", () => {
|
||||
it("exposes usage + usageApikey so OAuth and apikey cards appear on /quota", () => {
|
||||
expect(USAGE_SUPPORTED_PROVIDERS).toContain("kimi");
|
||||
expect(USAGE_APIKEY_PROVIDERS).toContain("kimi");
|
||||
});
|
||||
|
||||
it("registers transport.usage url when present (optional)", () => {
|
||||
// Provider may or may not put usage url on transport; handler has its own constant.
|
||||
expect(PROVIDERS.kimi).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsageForProvider(kimi) auth selection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("OAuth path: Bearer + X-Msh-* (not chat x-api-key)", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "tok-abc",
|
||||
providerSpecificData: { deviceId: "stable-device-1" },
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.plan).toBe("Allegro");
|
||||
expect(usage.quotas.Weekly).toMatchObject({
|
||||
used: 35,
|
||||
total: 100,
|
||||
remainingPercentage: 65,
|
||||
});
|
||||
|
||||
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = proxyAwareFetch.mock.calls[0];
|
||||
expect(url).toBe(KIMI_USAGE_URL);
|
||||
expect(opts.method).toBe("GET");
|
||||
expect(opts.headers.Authorization).toBe("Bearer tok-abc");
|
||||
expect(opts.headers["x-api-key"]).toBeUndefined();
|
||||
expect(opts.headers["X-Msh-Platform"]).toBe("9router");
|
||||
expect(opts.headers["X-Msh-Device-Id"]).toBe("stable-device-1");
|
||||
expect(opts.headers["X-Msh-Version"]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("apikey path: x-api-key only (no Bearer / X-Msh)", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
apiKey: "sk-test-123",
|
||||
});
|
||||
|
||||
expect(usage.message).toBeUndefined();
|
||||
expect(usage.quotas.Weekly.used).toBe(35);
|
||||
|
||||
const [, opts] = proxyAwareFetch.mock.calls[0];
|
||||
expect(opts.headers["x-api-key"]).toBe("sk-test-123");
|
||||
expect(opts.headers.Authorization).toBeUndefined();
|
||||
expect(opts.headers["X-Msh-Platform"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers apiKey over accessToken when both present", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE));
|
||||
|
||||
await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "tok-abc",
|
||||
apiKey: "sk-prefer-me",
|
||||
});
|
||||
|
||||
const [, opts] = proxyAwareFetch.mock.calls[0];
|
||||
expect(opts.headers["x-api-key"]).toBe("sk-prefer-me");
|
||||
expect(opts.headers.Authorization).toBeUndefined();
|
||||
expect(opts.headers["X-Msh-Platform"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps membership levels to plan display names", async () => {
|
||||
for (const [level, plan] of [
|
||||
["LEVEL_BASIC", "Moderato"],
|
||||
["LEVEL_INTERMEDIATE", "Allegretto"],
|
||||
["LEVEL_ADVANCED", "Allegro"],
|
||||
["LEVEL_STANDARD", "Vivace"],
|
||||
]) {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
user: { membership: { level } },
|
||||
usage: { limit: "10", used: "1", remaining: "9" },
|
||||
}),
|
||||
);
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "t",
|
||||
});
|
||||
expect(usage.plan).toBe(plan);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses Weekly + Ratelimit; does not put absolute remaining on quota rows", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE));
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "tok",
|
||||
});
|
||||
|
||||
// Absolute remaining would break getRemainingPercentage (treats it as 0-100 %)
|
||||
expect(usage.quotas.Weekly.remaining).toBeUndefined();
|
||||
expect(usage.quotas.Weekly.remainingPercentage).toBe(65);
|
||||
expect(usage.quotas.Ratelimit).toMatchObject({
|
||||
used: 20,
|
||||
total: 60,
|
||||
remainingPercentage: expect.closeTo(40 / 60 * 100, 5),
|
||||
});
|
||||
expect(usage.quotas.Ratelimit.remaining).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces re-authorize message only on 401 unauthenticated", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
code: "unauthenticated",
|
||||
details: [
|
||||
{
|
||||
debug: {
|
||||
reason: "REASON_INVALID_AUTH_TOKEN",
|
||||
localizedMessage: { message: "Invalid auth token" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
401,
|
||||
),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "expired",
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/expired|re-authorize/i);
|
||||
expect(usage.message).not.toMatch(/subscribe|permission/i);
|
||||
expect(usage.quotas).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps 403 REASON_FEATURE_NO_PERMISSION to subscribe message (not expired)", async () => {
|
||||
// Live capture: valid OAuth JWT still returns 403 permission_denied when
|
||||
// the account has no Kimi Code usage entitlement.
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
code: "permission_denied",
|
||||
details: [
|
||||
{
|
||||
type: "common.error.v1.ErrorDetail",
|
||||
debug: {
|
||||
reason: "REASON_FEATURE_NO_PERMISSION",
|
||||
localizedMessage: {
|
||||
locale: "en-US",
|
||||
message:
|
||||
"You do not have permission to use this feature. Please subscribe to access.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
403,
|
||||
),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "valid-but-no-sub",
|
||||
providerSpecificData: { deviceId: "stable-device-1" },
|
||||
});
|
||||
|
||||
expect(usage.message).toMatch(/permission|subscribe/i);
|
||||
expect(usage.message).not.toMatch(/expired|re-authorize/i);
|
||||
// Must not trip usage-route AUTH_EXPIRED_PATTERNS force-refresh loop
|
||||
expect(usage.message.toLowerCase()).not.toMatch(/expired|re-authorize|unauthorized|401/);
|
||||
});
|
||||
|
||||
it("formatKimiUsageError distinguishes 401 vs 403 feature gate", async () => {
|
||||
const { formatKimiUsageError } = await import(
|
||||
"../../open-sse/services/usage/kimi.js"
|
||||
);
|
||||
expect(formatKimiUsageError(401, '{"code":"unauthenticated"}')).toMatch(
|
||||
/expired|re-authorize/i,
|
||||
);
|
||||
expect(
|
||||
formatKimiUsageError(
|
||||
403,
|
||||
JSON.stringify({
|
||||
code: "permission_denied",
|
||||
details: [
|
||||
{
|
||||
debug: {
|
||||
reason: "REASON_FEATURE_NO_PERMISSION",
|
||||
localizedMessage: {
|
||||
message: "You do not have permission to use this feature.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toMatch(/permission|subscribe/i);
|
||||
});
|
||||
|
||||
it("returns tracked-per-request message when usage limit missing", async () => {
|
||||
proxyAwareFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
user: { membership: { level: "LEVEL_BASIC" } },
|
||||
usage: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const usage = await getUsageForProvider({
|
||||
provider: "kimi",
|
||||
accessToken: "tok",
|
||||
});
|
||||
|
||||
expect(usage.plan).toBe("Moderato");
|
||||
expect(usage.message).toMatch(/tracked per request/i);
|
||||
});
|
||||
|
||||
it("returns missing-credentials message when neither token nor key", async () => {
|
||||
const usage = await getUsageForProvider({ provider: "kimi" });
|
||||
expect(usage.message).toMatch(/token|key|credential/i);
|
||||
expect(proxyAwareFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQuotaData(kimi)", () => {
|
||||
it("forwards remainingPercentage for dashboard bars", () => {
|
||||
const rows = parseQuotaData("kimi", {
|
||||
plan: "Allegro",
|
||||
quotas: {
|
||||
Weekly: {
|
||||
used: 35,
|
||||
total: 100,
|
||||
remainingPercentage: 65,
|
||||
resetAt: "2026-08-01T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: "Weekly",
|
||||
used: 35,
|
||||
total: 100,
|
||||
remainingPercentage: 65,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { KiroExecutor } from "../../open-sse/executors/kiro.js";
|
||||
|
||||
const RUNTIME = "https://runtime.us-east-1.kiro.dev/generateAssistantResponse";
|
||||
const CODEWHISPERER = "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse";
|
||||
const Q = "https://q.us-east-1.amazonaws.com/generateAssistantResponse";
|
||||
|
||||
function credentials(authMethod, region = "us-east-1") {
|
||||
return { providerSpecificData: { authMethod, region } };
|
||||
}
|
||||
|
||||
describe("Kiro auth-aware endpoint routing", () => {
|
||||
const executor = new KiroExecutor();
|
||||
|
||||
it("routes API-key inference through Amazon Q before other surfaces", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("api_key"))).toEqual([
|
||||
Q,
|
||||
CODEWHISPERER,
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps Builder ID OAuth on the Kiro runtime surface", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("builder-id"))).toEqual([
|
||||
RUNTIME,
|
||||
CODEWHISPERER,
|
||||
Q,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps external IdP on CodeWhisperer before Amazon Q", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("external_idp"))).toEqual([
|
||||
CODEWHISPERER,
|
||||
Q,
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("regionalizes AWS endpoints for IDC without changing Kiro runtime", () => {
|
||||
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",
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries only endpoint/auth-surface failures, not payload-invalid 400s", () => {
|
||||
expect(executor.shouldRetry(400, 0)).toBe(false);
|
||||
expect(executor.shouldRetry(401, 1)).toBe(true);
|
||||
expect(executor.shouldRetry(403, 2)).toBe(false);
|
||||
expect(executor.shouldRetry(422, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("builds endpoint-specific headers", () => {
|
||||
const auth = { accessToken: "test-key", providerSpecificData: { authMethod: "api_key" } };
|
||||
const qHeaders = executor.buildHeaders(auth, true, Q);
|
||||
const codeWhispererHeaders = executor.buildHeaders(auth, true, CODEWHISPERER);
|
||||
const runtimeHeaders = executor.buildHeaders(auth, true, RUNTIME);
|
||||
|
||||
expect(qHeaders.TokenType).toBe("API_KEY");
|
||||
expect(qHeaders["X-Amz-Target"]).toBeUndefined();
|
||||
expect(codeWhispererHeaders["X-Amz-Target"]).toBe(
|
||||
"AmazonCodeWhispererStreamingService.GenerateAssistantResponse"
|
||||
);
|
||||
expect(runtimeHeaders["X-Amz-Target"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canonicalizeKiroConversation,
|
||||
normalizeKiroToolSpecs,
|
||||
validateKiroConversation,
|
||||
} from "../../open-sse/translator/concerns/kiroConversation.js";
|
||||
import { clearKiroSessionReplayStore } from "../../open-sse/utils/kiroSessionReplay.js";
|
||||
import { clearSessionStore } from "../../open-sse/utils/sessionManager.js";
|
||||
import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js";
|
||||
import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js";
|
||||
|
||||
const modelId = "claude-opus-5";
|
||||
|
||||
function tool(name, schema = { type: "object", properties: {} }) {
|
||||
return { name, description: `Tool ${name}`, input_schema: schema };
|
||||
}
|
||||
|
||||
function specState(names = ["first", "second"]) {
|
||||
const source = names.map((name) => tool(name));
|
||||
return normalizeKiroToolSpecs(source);
|
||||
}
|
||||
|
||||
function user(content, toolResults = []) {
|
||||
return {
|
||||
userInputMessage: {
|
||||
content,
|
||||
modelId,
|
||||
...(toolResults.length > 0 && { userInputMessageContext: { toolResults } }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistant(content, toolUses = []) {
|
||||
return {
|
||||
assistantResponseMessage: {
|
||||
content,
|
||||
...(toolUses.length > 0 && { toolUses }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function result(toolUseId, value, status = "success") {
|
||||
return { toolUseId, status, content: [{ text: value }] };
|
||||
}
|
||||
|
||||
describe("Kiro conversation canonicalizer", () => {
|
||||
beforeEach(() => {
|
||||
clearKiroSessionReplayStore();
|
||||
clearSessionStore();
|
||||
});
|
||||
|
||||
it("keeps complete parallel tool pairs structured", () => {
|
||||
const { specs, nameMap } = specState();
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "t1", name: "first", input: { n: 1 } },
|
||||
{ toolUseId: "t2", name: "second", input: { n: 2 } },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const calls = canonical.history[1].assistantResponseMessage.toolUses;
|
||||
const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults;
|
||||
expect(calls.map((call) => call.toolUseId)).toEqual(["t1", "t2"]);
|
||||
expect(results.map((item) => item.toolUseId)).toEqual(["t1", "t2"]);
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the answered parallel call and flattens only the missing one", () => {
|
||||
const { specs, nameMap } = specState();
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "t1", name: "first", input: {} },
|
||||
{ toolUseId: "t2", name: "second", input: {} },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const assistantMessage = canonical.history[1].assistantResponseMessage;
|
||||
expect(assistantMessage.toolUses).toHaveLength(1);
|
||||
expect(assistantMessage.toolUses[0].toolUseId).toBe("t1");
|
||||
expect(assistantMessage.content).toContain("[Tool call: second(");
|
||||
expect(canonical.repairs.missingResults).toBe(1);
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens non-adjacent and orphaned tool results", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
user("result missing here"),
|
||||
assistant("later"),
|
||||
],
|
||||
currentMessage: user("late result", [result("t1", "too late")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(canonical)).not.toContain('"toolUseId":"t1"');
|
||||
expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:");
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("too late");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("remaps duplicate tool IDs together with their adjacent results", () => {
|
||||
const { specs, nameMap } = specState();
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "duplicate", name: "first", input: {} },
|
||||
{ toolUseId: "duplicate", name: "second", input: {} },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [
|
||||
result("duplicate", "one"),
|
||||
result("duplicate", "two"),
|
||||
]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const calls = canonical.history[1].assistantResponseMessage.toolUses;
|
||||
const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults;
|
||||
expect(new Set(calls.map((call) => call.toolUseId)).size).toBe(2);
|
||||
expect(results.map((item) => item.toolUseId)).toEqual(calls.map((call) => call.toolUseId));
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("deduplicates extra results without losing their text", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one"), result("t1", "duplicate")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const current = canonical.currentMessage.userInputMessage;
|
||||
expect(current.userInputMessageContext.toolResults).toHaveLength(1);
|
||||
expect(current.content).toContain("duplicate");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens a trailing unanswered assistant tool call and creates a current user turn", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [user("start")],
|
||||
currentMessage: assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(canonical.currentMessage.userInputMessage.content).toBe("continue");
|
||||
expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined();
|
||||
expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens malformed input and tool uses missing from the current specs", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "t1", name: "first", input: "{bad json" },
|
||||
{ toolUseId: "t2", name: "removed_tool", input: {} },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined();
|
||||
expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toBeUndefined();
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("one");
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("two");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("repairs a 30-call parallel turn with one missing result", () => {
|
||||
const names = Array.from({ length: 30 }, (_, index) => `tool_${index}`);
|
||||
const { specs, nameMap } = specState(names);
|
||||
const calls = names.map((name, index) => ({
|
||||
toolUseId: `t${index}`,
|
||||
name,
|
||||
input: { index },
|
||||
}));
|
||||
const results = names.slice(0, -1).map((_, index) => result(`t${index}`, `r${index}`));
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [user("start"), assistant("run", calls)],
|
||||
currentMessage: user("continue", results),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(canonical.history[1].assistantResponseMessage.toolUses).toHaveLength(29);
|
||||
expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toHaveLength(29);
|
||||
expect(canonical.repairs.missingResults).toBe(1);
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens structured history when the client sent no tool specs", () => {
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one")]),
|
||||
modelId,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(canonical)).not.toContain("toolUses");
|
||||
expect(JSON.stringify(canonical)).not.toContain("toolResults");
|
||||
expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:");
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("[Tool result:");
|
||||
});
|
||||
|
||||
it("normalizes names and recursively removes unsupported schema fields", () => {
|
||||
const longDescription = "x".repeat(11000);
|
||||
const { specs, nameMap } = normalizeKiroToolSpecs([{
|
||||
name: "bad tool/name",
|
||||
description: longDescription,
|
||||
input_schema: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nested: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
}]);
|
||||
|
||||
const specification = specs[0].toolSpecification;
|
||||
expect(nameMap.get("bad tool/name")).toBe("bad_tool_name");
|
||||
expect(specification.name.length).toBeLessThanOrEqual(64);
|
||||
expect(specification.description.length).toBe(10237);
|
||||
expect(JSON.stringify(specification.inputSchema.json)).not.toContain("additionalProperties");
|
||||
expect(JSON.stringify(specification.inputSchema.json)).not.toContain('"required":[]');
|
||||
});
|
||||
|
||||
it("does not mutate the source conversation or tool definitions", () => {
|
||||
const sourceTools = [tool("first")];
|
||||
const sourceHistory = [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
];
|
||||
const sourceCurrent = user("continue", [result("t1", "one")]);
|
||||
const before = JSON.stringify({ sourceTools, sourceHistory, sourceCurrent });
|
||||
const { specs, nameMap } = normalizeKiroToolSpecs(sourceTools);
|
||||
|
||||
canonicalizeKiroConversation({
|
||||
history: sourceHistory,
|
||||
currentMessage: sourceCurrent,
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(JSON.stringify({ sourceTools, sourceHistory, sourceCurrent })).toBe(before);
|
||||
});
|
||||
|
||||
it("preserves Claude tool_result errors", () => {
|
||||
const output = claudeToKiroRequest(modelId, {
|
||||
tools: [tool("first")],
|
||||
messages: [
|
||||
{ role: "user", content: "start" },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", is_error: true, content: "failed" }] },
|
||||
],
|
||||
}, true, {});
|
||||
|
||||
const item = output.conversationState.currentMessage.userInputMessage
|
||||
.userInputMessageContext.toolResults[0];
|
||||
expect(item.status).toBe("error");
|
||||
});
|
||||
|
||||
it("repairs partial parallel results in both direct translators", () => {
|
||||
const claude = claudeToKiroRequest(modelId, {
|
||||
tools: [tool("first"), tool("second")],
|
||||
messages: [
|
||||
{ role: "user", content: "start" },
|
||||
{ role: "assistant", content: [
|
||||
{ type: "tool_use", id: "t1", name: "first", input: {} },
|
||||
{ type: "tool_use", id: "t2", name: "second", input: {} },
|
||||
] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "one" }] },
|
||||
],
|
||||
}, true, {});
|
||||
const openai = openaiToKiroRequest(modelId, {
|
||||
tools: [
|
||||
{ type: "function", function: { name: "first", parameters: { type: "object", properties: {} } } },
|
||||
{ type: "function", function: { name: "second", parameters: { type: "object", properties: {} } } },
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: "start" },
|
||||
{ role: "assistant", content: "", tool_calls: [
|
||||
{ id: "t1", type: "function", function: { name: "first", arguments: "{}" } },
|
||||
{ id: "t2", type: "function", function: { name: "second", arguments: "{}" } },
|
||||
] },
|
||||
{ role: "tool", tool_call_id: "t1", content: "one" },
|
||||
],
|
||||
}, true, {});
|
||||
|
||||
for (const payload of [claude, openai]) {
|
||||
const state = payload.conversationState;
|
||||
const validation = validateKiroConversation(
|
||||
state.history,
|
||||
state.currentMessage,
|
||||
state.currentMessage.userInputMessage.userInputMessageContext.tools
|
||||
);
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(state.history[1].assistantResponseMessage.toolUses).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let session replay replace a tool-result turn", () => {
|
||||
const credentials = {
|
||||
rawHeaders: { "x-session-id": "kiro-replay-tool-result-regression" },
|
||||
connectionId: "kiro-account",
|
||||
};
|
||||
claudeToKiroRequest(modelId, {
|
||||
messages: [{ role: "user", content: "frozen session start" }],
|
||||
}, true, credentials);
|
||||
|
||||
const output = claudeToKiroRequest(modelId, {
|
||||
tools: [tool("first")],
|
||||
messages: [
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "kept" }] },
|
||||
],
|
||||
}, true, credentials);
|
||||
const state = output.conversationState;
|
||||
const allText = JSON.stringify(state);
|
||||
|
||||
expect(allText).toContain("frozen session start");
|
||||
expect(allText).toContain("kept");
|
||||
expect(validateKiroConversation(
|
||||
state.history,
|
||||
state.currentMessage,
|
||||
state.currentMessage.userInputMessage.userInputMessageContext.tools
|
||||
).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,8 @@ import { KiroService } from "../../src/lib/oauth/services/kiro.js";
|
||||
/**
|
||||
* Regression tests for Kiro API-key auth.
|
||||
*
|
||||
* KiroService.validateApiKey resolves a profileArn with the key (via
|
||||
* CodeWhisperer ListAvailableProfiles) and returns a credential shaped for
|
||||
* persistence with authMethod="api_key". The response profile field name
|
||||
* varies (`arn` vs `profileArn`) — both are accepted by listAvailableProfiles.
|
||||
* KiroService.validateApiKey validates against the Amazon Q model catalog and
|
||||
* returns an account-bound credential without inventing a profileArn.
|
||||
*
|
||||
* Note: OAuth (Builder ID / IDC) profileArn resolution is handled upstream by
|
||||
* fetchKiroProfileArn in providers.js and is covered there — not here.
|
||||
@@ -16,11 +14,10 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("validates an API key and resolves a credential with profileArn", async () => {
|
||||
const expectedArn = "arn:aws:codewhisperer:us-east-1:444:profile/KEY";
|
||||
it("validates an API key against Amazon Q without inventing profileArn", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ profiles: [{ arn: expectedArn }] }),
|
||||
json: async () => ({ models: [{ modelId: "claude-opus-5" }] }),
|
||||
});
|
||||
|
||||
const svc = new KiroService();
|
||||
@@ -29,17 +26,18 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => {
|
||||
expect(cred).toEqual({
|
||||
accessToken: "my-secret-key",
|
||||
refreshToken: null,
|
||||
profileArn: expectedArn,
|
||||
profileArn: null,
|
||||
region: "us-east-1",
|
||||
authMethod: "api_key",
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://codewhisperer.us-east-1.amazonaws.com");
|
||||
expect(init.headers.Authorization).toBe("Bearer my-secret-key");
|
||||
expect(init.headers["x-amz-target"]).toBe(
|
||||
"AmazonCodeWhispererService.ListAvailableProfiles"
|
||||
expect(url).toBe(
|
||||
"https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR"
|
||||
);
|
||||
expect(init.method).toBe("GET");
|
||||
expect(init.headers.Authorization).toBe("Bearer my-secret-key");
|
||||
expect(init.headers.TokenType).toBe("API_KEY");
|
||||
});
|
||||
|
||||
it("rejects an empty API key without a network call", async () => {
|
||||
@@ -60,4 +58,15 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => {
|
||||
/API key validation failed/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a 200 response with an empty model catalog", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ models: [] }),
|
||||
});
|
||||
const svc = new KiroService();
|
||||
await expect(svc.validateApiKey("empty-key")).rejects.toThrow(
|
||||
/returned no available models/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -424,6 +424,31 @@ describe("openaiToKiroRequest", () => {
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude-sonnet-4.5-thinking-agentic(high)", "claude-sonnet-4.5"],
|
||||
["glm-5-thinking-agentic(medium)", "glm-5"],
|
||||
])("normalizes unsupported Kiro intensity suffix for %s", (model, upstream) => {
|
||||
const result = openaiToKiroRequest(model, {
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}, true, {});
|
||||
|
||||
expect(result.conversationState.currentMessage.userInputMessage.modelId).toBe(upstream);
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
expect(systemPromptOf(result)).toContain("CHUNKED WRITE PROTOCOL");
|
||||
});
|
||||
|
||||
it("maps a supported Kiro Claude intensity suffix to native effort fields", () => {
|
||||
const result = openaiToKiroRequest("claude-sonnet-5-thinking-agentic(high)", {
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}, true, {});
|
||||
|
||||
expect(result.conversationState.currentMessage.userInputMessage.modelId).toBe("claude-sonnet-5");
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for date-suffixed Claude 4 model ids", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
|
||||
@@ -11,6 +11,10 @@ vi.mock("@/lib/localDb", () => ({
|
||||
getApiKeys: mocks.getApiKeys,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/providers/connectionAccess", () => ({
|
||||
getProviderConnectionAccess: vi.fn(async () => ({ ownerId: "admin-a" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/utils/machineId", () => ({
|
||||
getConsistentMachineId: mocks.getConsistentMachineId,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js";
|
||||
|
||||
describe("getThinkingLevels for Kiro", () => {
|
||||
it("does not advertise native intensity for legacy Kiro models", () => {
|
||||
expect(getThinkingLevels("kiro", "claude-sonnet-4.5")).toBeNull();
|
||||
expect(getThinkingLevels("kiro", "glm-5")).toBeNull();
|
||||
});
|
||||
|
||||
it("advertises native levels for supported Kiro models", () => {
|
||||
expect(getThinkingLevels("kiro", "claude-sonnet-5")).toContain("high");
|
||||
expect(getThinkingLevels("kiro", "gpt-5.6-sol")).toContain("xhigh");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Generic OAuth2 token refresh — config-driven profiles.
|
||||
*
|
||||
* Verifies refreshAccessToken() handles the 5 foldable providers
|
||||
* (qwen, iflow, github, kimi, claude) via a REFRESH_PROFILES table,
|
||||
* while preserving the legacy generic path for unknown providers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
function mockFetchOnce(payload, { ok = true, status = 200 } = {}) {
|
||||
const fn = vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
json: () => Promise.resolve(payload),
|
||||
text: () => Promise.resolve(JSON.stringify(payload)),
|
||||
});
|
||||
global.fetch = fn;
|
||||
return fn;
|
||||
}
|
||||
|
||||
describe("refreshAccessToken — config-driven profiles", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; });
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it("qwen: form body + clientId, surfaces resource_url as providerSpecificData", async () => {
|
||||
const fm = mockFetchOnce({
|
||||
access_token: "qw-acc",
|
||||
refresh_token: "qw-refresh-rotated",
|
||||
expires_in: 7200,
|
||||
resource_url: "https://dashscope.aliyuncs.com",
|
||||
});
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
|
||||
const out = await refreshAccessToken("qwen", "qw-old-refresh", {}, console);
|
||||
|
||||
expect(out).toEqual({
|
||||
accessToken: "qw-acc",
|
||||
refreshToken: "qw-refresh-rotated",
|
||||
expiresIn: 7200,
|
||||
providerSpecificData: { resourceUrl: "https://dashscope.aliyuncs.com" },
|
||||
});
|
||||
const [url, init] = fm.mock.calls[0];
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
||||
const body = new URLSearchParams(init.body);
|
||||
expect(body.get("grant_type")).toBe("refresh_token");
|
||||
expect(body.get("refresh_token")).toBe("qw-old-refresh");
|
||||
expect(body.get("client_id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("iflow: Basic Auth header from clientId:clientSecret, form body keeps client_secret", async () => {
|
||||
const fm = mockFetchOnce({ access_token: "if-acc", refresh_token: "if-rot", expires_in: 3600 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
|
||||
await refreshAccessToken("iflow", "if-old", {}, console);
|
||||
|
||||
const [, init] = fm.mock.calls[0];
|
||||
expect(init.headers["Authorization"]).toMatch(/^Basic /);
|
||||
const body = new URLSearchParams(init.body);
|
||||
expect(body.get("client_id")).toBeTruthy();
|
||||
expect(body.get("client_secret")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("github: omits client_secret when config has none", async () => {
|
||||
const fm = mockFetchOnce({ access_token: "gh-acc", expires_in: 28800 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
|
||||
const out = await refreshAccessToken("github", "gh-old", {}, console);
|
||||
|
||||
const body = new URLSearchParams(fm.mock.calls[0][1].body);
|
||||
expect(body.get("client_secret")).toBeNull();
|
||||
expect(out.accessToken).toBe("gh-acc");
|
||||
expect(out.refreshToken).toBe("gh-old");
|
||||
});
|
||||
|
||||
it("kimi: merges X-Msh-* headers from credentials.providerSpecificData.deviceId", async () => {
|
||||
const fm = mockFetchOnce({ access_token: "km-acc", expires_in: 86400 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
|
||||
await refreshAccessToken("kimi", "km-old", {
|
||||
providerSpecificData: { deviceId: "dev-xyz" },
|
||||
}, console);
|
||||
|
||||
const headers = fm.mock.calls[0][1].headers;
|
||||
// Kimi's buildKimiHeaders must contribute at least one X-Msh- header
|
||||
const mshKeys = Object.keys(headers).filter((k) => k.toLowerCase().startsWith("x-msh-"));
|
||||
expect(mshKeys.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("claude: JSON body, client_id only (no client_secret)", async () => {
|
||||
const fm = mockFetchOnce({ access_token: "cl-acc", refresh_token: "cl-rot", expires_in: 3600 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
|
||||
await refreshAccessToken("claude", "cl-old", {}, console);
|
||||
|
||||
const [, init] = fm.mock.calls[0];
|
||||
expect(init.headers["Content-Type"]).toBe("application/json");
|
||||
const parsed = JSON.parse(init.body);
|
||||
expect(parsed.grant_type).toBe("refresh_token");
|
||||
expect(parsed.client_id).toBeTruthy();
|
||||
expect(parsed).not.toHaveProperty("client_secret");
|
||||
});
|
||||
|
||||
it("returns null on non-ok response", async () => {
|
||||
mockFetchOnce({ error: "invalid_grant" }, { ok: false, status: 400 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
const out = await refreshAccessToken("qwen", "dead", {}, console);
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when refreshToken missing", async () => {
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
const out = await refreshAccessToken("qwen", "", {}, console);
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it("dedupes concurrent calls with same refresh token (same dedupKey)", async () => {
|
||||
const fm = mockFetchOnce({ access_token: "dd-acc", expires_in: 3600 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
const creds = { providerSpecificData: { deviceId: "d" } };
|
||||
await Promise.all([
|
||||
refreshAccessToken("kimi", "dup-refresh", creds, console),
|
||||
refreshAccessToken("kimi", "dup-refresh", creds, console),
|
||||
]);
|
||||
expect(fm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshAccessToken — legacy generic path (no profile)", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; });
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it("still works for an unprofiled provider via config.refreshUrl/clientId/clientSecret", async () => {
|
||||
const fm = mockFetchOnce({ access_token: "gen-acc", expires_in: 3600 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
|
||||
await refreshAccessToken("cline", "gen-old", {}, console);
|
||||
|
||||
const body = new URLSearchParams(fm.mock.calls[0][1].body);
|
||||
expect(body.get("grant_type")).toBe("refresh_token");
|
||||
expect(body.get("client_id")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,8 @@ const load = () => import("../../open-sse/services/usage.js");
|
||||
const SUPPORTED = [
|
||||
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
|
||||
"qoder", "qwen", "iflow", "ollama", "glm", "glm-cn",
|
||||
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli",
|
||||
"orbit-provider",
|
||||
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
|
||||
"deepseek", "orbit-provider",
|
||||
];
|
||||
|
||||
describe("usage dispatch", () => {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveWsModelId,
|
||||
buildGetChatMessageRequest,
|
||||
grpcWebFrame,
|
||||
decodeCompletionChunk,
|
||||
default as WindsurfExecutor,
|
||||
} from "open-sse/executors/windsurf.js";
|
||||
import { PROVIDERS } from "open-sse/config/providers.js";
|
||||
|
||||
// ─── Protobuf helpers for building expected wire bytes in tests ──────────────
|
||||
|
||||
function encodeVarint(value) {
|
||||
const bytes = [];
|
||||
let v = value >>> 0;
|
||||
while (v > 0x7f) { bytes.push((v & 0x7f) | 0x80); v >>>= 7; }
|
||||
bytes.push(v & 0x7f);
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
function encodeLenField(fieldNum, payload) {
|
||||
const tag = encodeVarint((fieldNum << 3) | 2);
|
||||
const len = encodeVarint(payload.length);
|
||||
const out = new Uint8Array(tag.length + len.length + payload.length);
|
||||
out.set(tag, 0); out.set(len, tag.length); out.set(payload, tag.length + len.length);
|
||||
return out;
|
||||
}
|
||||
function encodeStringField(fieldNum, str) {
|
||||
return encodeLenField(fieldNum, new TextEncoder().encode(str));
|
||||
}
|
||||
|
||||
describe("windsurf MODEL_ALIAS_MAP", () => {
|
||||
it("maps SWE models to snake-case wire names", () => {
|
||||
expect(resolveWsModelId("swe-1.6-fast")).toBe("swe-1-6-fast");
|
||||
expect(resolveWsModelId("swe-1.5")).toBe("swe-1-5");
|
||||
});
|
||||
it("maps Claude 4.5 to MODEL_PRIVATE_* aliases", () => {
|
||||
expect(resolveWsModelId("claude-sonnet-4.5")).toBe("MODEL_PRIVATE_2");
|
||||
expect(resolveWsModelId("claude-opus-4.5")).toBe("MODEL_CLAUDE_4_5_OPUS");
|
||||
});
|
||||
it("applies default effort level for bare gpt-5.x ids", () => {
|
||||
expect(resolveWsModelId("gpt-5.5")).toBe("gpt-5-5-medium");
|
||||
expect(resolveWsModelId("gpt-5.4")).toBe("gpt-5-4-medium");
|
||||
});
|
||||
it("passes through unknown ids as-is", () => {
|
||||
expect(resolveWsModelId("custom-model")).toBe("custom-model");
|
||||
});
|
||||
});
|
||||
|
||||
describe("grpcWebFrame", () => {
|
||||
it("prepends a 5-byte header: 0x00 flag + big-endian length", () => {
|
||||
const payload = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const frame = grpcWebFrame(payload);
|
||||
expect(frame[0]).toBe(0x00);
|
||||
const view = new DataView(frame.buffer);
|
||||
expect(view.getUint32(1, false)).toBe(5); // big-endian length
|
||||
expect(Array.from(frame.slice(5))).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
it("encodes empty payload as a 5-byte frame", () => {
|
||||
const frame = grpcWebFrame(new Uint8Array(0));
|
||||
expect(frame.length).toBe(5);
|
||||
expect(frame[0]).toBe(0x00);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGetChatMessageRequest", () => {
|
||||
it("emits metadata (field 1), cascade_id (2), model (3), messages (4+)", () => {
|
||||
const payload = buildGetChatMessageRequest("sk-ws-test", "swe-1.6", [
|
||||
{ role: "user", content: "hello" },
|
||||
]);
|
||||
expect(payload.length).toBeGreaterThan(10);
|
||||
// First byte 0x0a = field 1, wire type 2 (length-delimited) → metadata present
|
||||
expect(payload[0]).toBe(0x0a);
|
||||
});
|
||||
|
||||
it("embeds the apiKey inside the metadata sub-message", () => {
|
||||
const payload = buildGetChatMessageRequest("sk-ws-secret", "gpt-5", []);
|
||||
// The metadata bytes are the first length-delimited field — should contain the key.
|
||||
const asString = new TextDecoder().decode(payload);
|
||||
expect(asString).toContain("sk-ws-secret");
|
||||
// And the IDE identification fields.
|
||||
expect(asString).toContain("windsurf");
|
||||
expect(asString).toContain("3.14.0");
|
||||
});
|
||||
|
||||
it("appends one field-4 message per chat message", () => {
|
||||
// Proper top-level protobuf field counter (byte 0x22 collides with content bytes).
|
||||
const countField = (buf, target) => {
|
||||
let offset = 0;
|
||||
let count = 0;
|
||||
while (offset < buf.length) {
|
||||
let result = 0, shift = 0;
|
||||
while (offset < buf.length) {
|
||||
const b = buf[offset++];
|
||||
result |= (b & 0x7f) << shift;
|
||||
if ((b & 0x80) === 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
const fieldNum = result >>> 3;
|
||||
const wireType = result & 0x07;
|
||||
if (wireType === 2) {
|
||||
let len = 0, ls = 0;
|
||||
while (offset < buf.length) {
|
||||
const b = buf[offset++];
|
||||
len |= (b & 0x7f) << ls;
|
||||
if ((b & 0x80) === 0) break;
|
||||
ls += 7;
|
||||
}
|
||||
if (fieldNum === target) count++;
|
||||
offset += len;
|
||||
} else if (wireType === 0) {
|
||||
while (offset < buf.length) {
|
||||
const b = buf[offset++];
|
||||
if ((b & 0x80) === 0) break;
|
||||
}
|
||||
} else if (wireType === 1) {
|
||||
offset += 8;
|
||||
} else if (wireType === 5) {
|
||||
offset += 4;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const one = buildGetChatMessageRequest("k", "m", [{ role: "user", content: "a" }]);
|
||||
const two = buildGetChatMessageRequest("k", "m", [
|
||||
{ role: "user", content: "a" },
|
||||
{ role: "assistant", content: "b" },
|
||||
]);
|
||||
expect(countField(one, 4)).toBe(1);
|
||||
expect(countField(two, 4)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeCompletionChunk", () => {
|
||||
it("decodes a ContentChunk (field 1 → text)", () => {
|
||||
const chunk = encodeLenField(1, encodeStringField(1, "hello world"));
|
||||
const decoded = decodeCompletionChunk(chunk);
|
||||
expect(decoded).toEqual({ kind: "content", text: "hello world" });
|
||||
});
|
||||
|
||||
it("decodes an ErrorChunk (field 4 → message)", () => {
|
||||
const chunk = encodeLenField(4, encodeStringField(1, "quota exhausted"));
|
||||
const decoded = decodeCompletionChunk(chunk);
|
||||
expect(decoded).toEqual({ kind: "error", message: "quota exhausted" });
|
||||
});
|
||||
|
||||
it("decodes a DoneChunk (field 3 → UsageStats with prompt/completion tokens)", () => {
|
||||
// UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint)
|
||||
const usage = new Uint8Array([...encodeVarint((1 << 3) | 0), ...encodeVarint(42), ...encodeVarint((2 << 3) | 0), ...encodeVarint(99)]);
|
||||
const doneChunk = encodeLenField(3, encodeLenField(1, usage));
|
||||
const decoded = decodeCompletionChunk(doneChunk);
|
||||
expect(decoded.kind).toBe("done");
|
||||
expect(decoded.promptTokens).toBe(42);
|
||||
expect(decoded.completionTokens).toBe(99);
|
||||
});
|
||||
|
||||
it("returns { kind: 'unknown' } for empty buffer", () => {
|
||||
expect(decodeCompletionChunk(new Uint8Array(0))).toEqual({ kind: "unknown" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("WindsurfExecutor class", () => {
|
||||
it("constructor wires the current Codeium chat endpoint", () => {
|
||||
const ex = new WindsurfExecutor();
|
||||
expect(ex.provider).toBe("windsurf");
|
||||
expect(ex.config).toBeDefined();
|
||||
expect(ex.config.baseUrl).toContain("server.codeium.com");
|
||||
expect(typeof ex.execute).toBe("function");
|
||||
});
|
||||
|
||||
it("buildHeaders emits grpc-web+proto + Bearer token", () => {
|
||||
const ex = new WindsurfExecutor();
|
||||
const h = ex.buildHeaders({ accessToken: "sk-ws-abc" });
|
||||
expect(h["Content-Type"]).toBe("application/grpc-web+proto");
|
||||
expect(h.Accept).toBe("application/grpc-web+proto");
|
||||
expect(h["X-Grpc-Web"]).toBe("1");
|
||||
expect(h.Authorization).toBe("Bearer sk-ws-abc");
|
||||
expect(h["User-Agent"]).toMatch(/^windsurf\//);
|
||||
});
|
||||
|
||||
it("buildHeaders omits Authorization when no token", () => {
|
||||
const ex = new WindsurfExecutor();
|
||||
const h = ex.buildHeaders({});
|
||||
expect(h.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("buildUrl returns the GetChatMessage endpoint", () => {
|
||||
const ex = new WindsurfExecutor();
|
||||
expect(ex.buildUrl()).toBe("https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage");
|
||||
});
|
||||
|
||||
it("uses a valid fallback while the provider remains hidden from the public registry", () => {
|
||||
expect(PROVIDERS.windsurf).toBeUndefined();
|
||||
expect(new WindsurfExecutor().config.baseUrl).toBe(
|
||||
"https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user