From 29ccb84fafb88025089e943f424d46ec2527febf Mon Sep 17 00:00:00 2001 From: luulam Date: Fri, 25 Sep 2026 16:09:09 +0700 Subject: [PATCH] feat(commandcode): align quota usage with the official CLI /usage flow Rewrites services/usage/commandcode.js to mirror the command-code CLI: whoami resolves the org id, credits+subscriptions report the 5-hour/weekly windows and plan, then usage/summary is queried with since=currentPeriodStart. Endpoints are read from the provider registry usage block instead of hardcoded constants. Tests updated to cover the new request order and response shapes. --- open-sse/services/usage/commandcode.js | 293 ++++++++----- tests/unit/commandcode-usage.test.js | 282 ++++++++----- tests/unit/openai-to-commandcode.test.js | 503 ++++++++++++++--------- 3 files changed, 677 insertions(+), 401 deletions(-) diff --git a/open-sse/services/usage/commandcode.js b/open-sse/services/usage/commandcode.js index 7c62480c..1b86f635 100644 --- a/open-sse/services/usage/commandcode.js +++ b/open-sse/services/usage/commandcode.js @@ -1,134 +1,207 @@ /** - * Command Code usage — billing credits + 5h/weekly rate windows. - * Mirrors ~/cc-usage.mjs: whoami → credits + subscriptions. + * CommandCode usage handler + * + * Mirrors the official command-code CLI /usage command: it calls the alpha API + * to surface the 5-hour + weekly usage windows, the subscription plan, and the + * credits consumed in the current billing period. + * + * GET /alpha/whoami → org.id (org-scoped billing; null for personal) + * GET /alpha/billing/credits → { credits: { monthlyCredits, purchasedCredits, + * freeCredits }, windowLimits: { fiveHour, weekly } } + * GET /alpha/billing/subscriptions → { data: { planId, currentPeriodStart, ... } } + * GET /alpha/usage/summary?since= → period token/cost totals + * + * The CLI fetches whoami first (for orgId), then credits + subscription in + * parallel, then the summary with since = currentPeriodStart. We keep the same + * order/dependencies: window limits live on credits, and the plan period start + * determines the summary window. */ import { proxyAwareFetch } from "../../utils/proxyFetch.js"; -import { parseResetTime, toFiniteNumber } from "./shared.js"; +import { U, parseResetTime } from "./shared.js"; -const BASE = (process.env.COMMAND_CODE_API_BASE_URL || "https://api.commandcode.ai").replace(/\/$/, ""); +const USAGE = U("commandcode"); +const BASE = USAGE.baseUrl || "https://api.commandcode.ai"; +const WHOAMI_URL = BASE + (USAGE.whoamiUrl || "/alpha/whoami"); +const CREDITS_URL = BASE + (USAGE.creditsUrl || "/alpha/billing/credits"); +const SUBSCRIPTIONS_URL = + BASE + (USAGE.subscriptionsUrl || "/alpha/billing/subscriptions"); +const SUMMARY_URL = BASE + (USAGE.summaryUrl || "/alpha/usage/summary"); -const PLAN_NAMES = { - "individual-go": "Go", - "individual-goat": "GOAT", - "individual-pro": "Pro", - "individual-pro-v1": "Pro", - "individual-provider": "Provider", - "individual-max": "Max", - "individual-ultra": "Ultra", - "teams-pro": "Teams Pro", -}; - -const PLAN_CAPS = { - "individual-go": 10, - "individual-goat": 70, - "individual-pro": 30, - "individual-pro-v1": 80, - "individual-provider": 15, - "individual-max": 150, - "individual-ultra": 300, - "teams-pro": 40, -}; - -function qs(route, params) { - const s = new URLSearchParams( - Object.entries(params || {}).filter(([, v]) => v != null), - ).toString(); - return s ? `${route}?${s}` : route; +function buildHeaders(token) { + return { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }; } -function windowQuota(win) { - if (!win || typeof win !== "object") return null; - const used = toFiniteNumber(win.used, 0); - const total = toFiniteNumber(win.cap, 0); - if (total <= 0 && used <= 0) return null; - return { - used, - total, - remaining: Math.max(0, total - used), - unlimited: false, - resetAt: parseResetTime(win.resetAt), - }; +/** Build a normalized quota row. `unit` is "$" — the API reports currency credits. */ +function makeQuota({ used, total, resetAt, unlimited = false, unit = "$" }) { + const safeTotal = Math.max(0, Number(total) || 0); + const safeUsed = Math.max(0, Number(used) || 0); + if (unlimited || safeTotal === 0) { + return { + used: safeUsed, + total: 0, + remainingPercentage: unlimited ? 100 : 0, + resetAt: resetAt || null, + unit, + unlimited: true, + }; + } + const remaining = Math.max(0, safeTotal - safeUsed); + const remainingPercentage = (remaining / safeTotal) * 100; + return { + used: safeUsed, + total: safeTotal, + remainingPercentage, + resetAt: resetAt || null, + unit, + unlimited: false, + }; } /** - * @param {string|null|undefined} apiKey + * @param {string} apiKey - commandcode API key (user_...) * @param {object|null} proxyOptions */ export async function getCommandCodeUsage(apiKey, proxyOptions = null) { - if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { - return { message: "Command Code API key not available. Add a key to view usage." }; - } + if (!apiKey) { + return { message: "CommandCode credential not available." }; + } - const headers = { - Authorization: `Bearer ${apiKey.trim()}`, - Accept: "application/json", - }; + const headers = buildHeaders(apiKey); - const get = async (route) => { - const response = await proxyAwareFetch( - BASE + route, - { method: "GET", headers }, - proxyOptions, - ); - return response; - }; + try { + // whoami resolves the org id (billing is org-scoped; null for personal). + const whoamiRes = await proxyAwareFetch( + WHOAMI_URL, + { method: "GET", headers }, + proxyOptions, + ); + if (whoamiRes.status === 401 || whoamiRes.status === 403) { + return { message: "CommandCode credential invalid or expired." }; + } + if (!whoamiRes.ok) { + return { message: `CommandCode whoami API error (${whoamiRes.status}).` }; + } + const whoami = await whoamiRes.json().catch(() => null); + const orgId = whoami?.org?.id ?? null; - try { - const whoamiRes = await get(qs("/alpha/whoami", { limits: "1" })); - if (whoamiRes.status === 401 || whoamiRes.status === 403) { - return { plan: "Command Code", message: "Command Code authentication failed. Check the API key." }; - } - if (!whoamiRes.ok) { - return { plan: "Command Code", message: `Command Code usage API error (${whoamiRes.status})` }; - } - const whoami = await whoamiRes.json().catch(() => ({})); - const orgId = whoami?.org?.id ?? null; + const orgQuery = orgId ? `?orgId=${encodeURIComponent(orgId)}` : ""; - const [creditsRes, subsRes] = await Promise.all([ - get(qs("/alpha/billing/credits", { orgId })), - get(qs("/alpha/billing/subscriptions", { orgId })), - ]); + const [creditsRes, subsRes] = await Promise.all([ + proxyAwareFetch( + CREDITS_URL + orgQuery, + { method: "GET", headers }, + proxyOptions, + ), + proxyAwareFetch( + SUBSCRIPTIONS_URL + orgQuery, + { method: "GET", headers }, + proxyOptions, + ), + ]); - if (creditsRes.status === 401 || creditsRes.status === 403 || subsRes.status === 401 || subsRes.status === 403) { - return { plan: "Command Code", message: "Command Code authentication failed. Check the API key." }; - } - if (!creditsRes.ok) { - return { plan: "Command Code", message: `Command Code credits API error (${creditsRes.status})` }; - } - if (!subsRes.ok) { - return { plan: "Command Code", message: `Command Code subscriptions API error (${subsRes.status})` }; - } + if ( + creditsRes.status === 401 || + creditsRes.status === 403 || + subsRes.status === 401 || + subsRes.status === 403 + ) { + return { message: "CommandCode credential invalid or expired." }; + } + if (!creditsRes.ok) { + return { + message: `CommandCode credits API error (${creditsRes.status}).`, + }; + } - const creditsBody = await creditsRes.json().catch(() => ({})); - const subsBody = await subsRes.json().catch(() => ({})); - const planId = subsBody?.data?.planId ?? null; - const plan = (planId && PLAN_NAMES[planId]) || planId || "Command Code"; - const cap = planId ? (PLAN_CAPS[planId] || 0) : 0; - const c = creditsBody?.credits || {}; - const remaining = - toFiniteNumber(c.monthlyCredits, 0) + - toFiniteNumber(c.purchasedCredits, 0) + - toFiniteNumber(c.freeCredits, 0); - const used = cap > 0 ? Math.max(0, cap - remaining) : 0; - const total = cap > 0 ? cap : remaining; + const credits = await creditsRes.json().catch(() => null); + const subs = await subsRes.json().catch(() => null); - const quotas = {}; - quotas.Credits = { - used, - total, - remaining, - unlimited: cap <= 0, - resetAt: parseResetTime(subsBody?.data?.currentPeriodEnd), - }; + const subData = subs?.data; + const planId = subData?.planId ?? null; + const periodStart = subData?.currentPeriodStart ?? null; - const fiveHour = windowQuota(creditsBody?.windowLimits?.fiveHour); - if (fiveHour) quotas["Session (5h)"] = fiveHour; - const weekly = windowQuota(creditsBody?.windowLimits?.weekly); - if (weekly) quotas.Weekly = weekly; + // Summary needs `since`; the CLI falls back to first-of-month when the + // subscription period start is unavailable. + const since = periodStart || firstOfMonth(); + const summaryRes = await proxyAwareFetch( + `${SUMMARY_URL}?since=${encodeURIComponent(since)}`, + { method: "GET", headers }, + proxyOptions, + ); + const summary = summaryRes.ok + ? await summaryRes.json().catch(() => null) + : null; - return { plan, quotas }; - } catch (error) { - return { message: `Command Code error: ${error.message}` }; - } + const quotas = {}; + const windowLimits = credits?.windowLimits || {}; + + const fiveHour = windowLimits.fiveHour; + if (fiveHour && Number(fiveHour.cap) > 0) { + quotas["5-hour window"] = makeQuota({ + used: fiveHour.used, + total: fiveHour.cap, + resetAt: parseResetTime(fiveHour.resetAt), + }); + } + + const weekly = windowLimits.weekly; + if (weekly && Number(weekly.cap) > 0) { + quotas["Weekly window"] = makeQuota({ + used: weekly.used, + total: weekly.cap, + resetAt: parseResetTime(weekly.resetAt), + }); + } + + // The credits API reports remaining balances (monthly/purchased/free), + // not a total. The official CLI renders the monthly line as + // `used = summary.totalCost`, `total = totalCost + remaining` — i.e. + // the plan ceiling is the sum of what was consumed and what is left. + const monthlyUsed = + typeof summary?.totalCredits === "number" + ? summary.totalCredits + : typeof summary?.totalCost === "number" + ? summary.totalCost + : 0; + + const creditsObj = credits?.credits || {}; + const remaining = + Math.max(0, Number(creditsObj.monthlyCredits) || 0) + + Math.max(0, Number(creditsObj.purchasedCredits) || 0) + + Math.max(0, Number(creditsObj.freeCredits) || 0); + const monthlyTotal = monthlyUsed + remaining; + + if (monthlyTotal > 0 || monthlyUsed > 0) { + quotas["Monthly credits"] = makeQuota({ + used: monthlyUsed, + total: monthlyTotal, + resetAt: periodStart ? undefined : null, + }); + } + + if (Object.keys(quotas).length === 0) { + return { + plan: planId || "CommandCode", + message: "CommandCode connected, but no quota was reported.", + quotas: {}, + }; + } + + return { + plan: planId || "CommandCode", + quotas, + periodBasis: summary?.periodBasis || "billing-period", + }; + } catch (error) { + return { message: `CommandCode usage error: ${error.message}` }; + } +} + +function firstOfMonth() { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1).toISOString(); } diff --git a/tests/unit/commandcode-usage.test.js b/tests/unit/commandcode-usage.test.js index 5f38dc47..22ade81b 100644 --- a/tests/unit/commandcode-usage.test.js +++ b/tests/unit/commandcode-usage.test.js @@ -1,135 +1,217 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ - proxyAwareFetch: vi.fn(), + 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, + USAGE_SUPPORTED_PROVIDERS, + USAGE_APIKEY_PROVIDERS, } from "../../src/shared/constants/providers.js"; import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; const BASE = "https://api.commandcode.ai"; +const WHOAMI_URL = `${BASE}/alpha/whoami`; +const CREDITS_URL = `${BASE}/alpha/billing/credits`; +const SUBS_URL = `${BASE}/alpha/billing/subscriptions`; +const SUMMARY_URL = `${BASE}/alpha/usage/summary`; function jsonResponse(body, status = 200) { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); } -const WHOAMI = { - user: { name: "Hieu", email: "hieu@example.com" }, - org: { id: "org_1", name: "personal" }, -}; +const WHOAMI = { success: true, user: { id: "u1" }, org: null }; const CREDITS = { - credits: { monthlyCredits: 12.5, purchasedCredits: 1, freeCredits: 0.5 }, - windowLimits: { - fiveHour: { used: 2, cap: 10, resetAt: Date.now() + 3_600_000, exceeded: false }, - weekly: { used: 20, cap: 70, resetAt: Date.now() + 86_400_000, exceeded: false }, - }, + credits: { + belowThreshold: false, + creditThreshold: 0, + monthlyCredits: 9.9, + purchasedCredits: 0, + freeCredits: 0, + }, + windowLimits: { + limited: true, + exceeded: null, + fiveHour: { used: 0.05, cap: 3, exceeded: false, resetAt: 1785812386064 }, + weekly: { used: 0.1, cap: 6, exceeded: false, resetAt: 1786379982640 }, + }, }; const SUBS = { - data: { - planId: "individual-goat", - currentPeriodStart: "2026-09-01T00:00:00.000Z", - currentPeriodEnd: "2026-10-01T00:00:00.000Z", - }, + success: true, + data: { + id: "sub_1", + status: "active", + orgId: null, + planId: "individual-go", + currentPeriodStart: "2026-08-03T16:38:16.000Z", + currentPeriodEnd: "2026-09-03T16:38:16.000Z", + }, +}; +const SUMMARY = { + totalCount: 61, + totalCost: 0.1, + totalCredits: 0.1, + totalMonthlyCredits: 0.1, + periodBasis: "billing-period", }; -function mockHappyPath() { - proxyAwareFetch.mockImplementation(async (url) => { - const u = String(url); - if (u.includes("/alpha/whoami")) return jsonResponse(WHOAMI); - if (u.includes("/alpha/billing/credits")) return jsonResponse(CREDITS); - if (u.includes("/alpha/billing/subscriptions")) return jsonResponse(SUBS); - return jsonResponse({ error: "unexpected " + u }, 404); - }); -} - describe("commandcode registry usage flags", () => { - it("is listed for apikey quota dashboard", () => { - expect(USAGE_SUPPORTED_PROVIDERS).toContain("commandcode"); - expect(USAGE_APIKEY_PROVIDERS).toContain("commandcode"); - }); + it("is listed for apikey quota dashboard", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("commandcode"); + expect(USAGE_APIKEY_PROVIDERS).toContain("commandcode"); + }); }); describe("getUsageForProvider(commandcode)", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - it("returns a message when apiKey is missing", async () => { - const usage = await getUsageForProvider({ provider: "commandcode" }); - expect(usage.message).toMatch(/api key/i); - expect(proxyAwareFetch).not.toHaveBeenCalled(); - }); + it("fetches whoami → credits+subs → summary and maps windows + credits", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(WHOAMI)) + .mockResolvedValueOnce(jsonResponse(CREDITS)) + .mockResolvedValueOnce(jsonResponse(SUBS)) + .mockResolvedValueOnce(jsonResponse(SUMMARY)); - it("GETs whoami, credits, and subscriptions with Bearer apiKey", async () => { - mockHappyPath(); - const usage = await getUsageForProvider({ - provider: "commandcode", - apiKey: "user_test", - }); + const usage = await getUsageForProvider({ + provider: "commandcode", + apiKey: "user_cc_test", + }); - expect(usage.message).toBeUndefined(); - expect(usage.plan).toBe("GOAT"); - const urls = proxyAwareFetch.mock.calls.map(([url]) => String(url)); - expect(urls.some((u) => u.startsWith(`${BASE}/alpha/whoami`))).toBe(true); - expect(urls.some((u) => u.includes("/alpha/billing/credits") && u.includes("orgId=org_1"))).toBe(true); - expect(urls.some((u) => u.includes("/alpha/billing/subscriptions") && u.includes("orgId=org_1"))).toBe(true); - expect(proxyAwareFetch.mock.calls[0][1].headers.Authorization).toBe("Bearer user_test"); - }); + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("individual-go"); + expect(usage.periodBasis).toBe("billing-period"); - it("maps remaining credits vs plan cap and rate windows", async () => { - mockHappyPath(); - const usage = await getUsageForProvider({ - provider: "commandcode", - apiKey: "user_test", - }); + expect(proxyAwareFetch).toHaveBeenCalledTimes(4); + const [whoamiUrl, whoamiOpts] = proxyAwareFetch.mock.calls[0]; + expect(whoamiUrl).toBe(WHOAMI_URL); + expect(whoamiOpts.headers.Authorization).toBe("Bearer user_cc_test"); - // remaining = 12.5 + 1 + 0.5 = 14; cap GOAT = 70; used = 56 - expect(usage.quotas.Credits).toMatchObject({ - used: 56, - total: 70, - unlimited: false, - }); - expect(usage.quotas["Session (5h)"]).toMatchObject({ - used: 2, - total: 10, - unlimited: false, - }); - expect(usage.quotas.Weekly).toMatchObject({ - used: 20, - total: 70, - }); - expect(new Date(usage.quotas.Credits.resetAt).toISOString()).toBe("2026-10-01T00:00:00.000Z"); - }); + // No org → credits/subscriptions called without orgId query + const creditsCall = proxyAwareFetch.mock.calls[1][0]; + expect(creditsCall).toBe(CREDITS_URL); - it("returns an auth message on 401", async () => { - proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401)); - const usage = await getUsageForProvider({ - provider: "commandcode", - apiKey: "bad", - }); - expect(usage.message).toMatch(/auth|key|login/i); - }); + // Summary uses currentPeriodStart as `since` + const summaryCall = proxyAwareFetch.mock.calls[3][0]; + expect(summaryCall).toBe( + `${SUMMARY_URL}?since=${encodeURIComponent("2026-08-03T16:38:16.000Z")}`, + ); + + expect(usage.quotas["5-hour window"]).toMatchObject({ + used: 0.05, + total: 3, + resetAt: new Date(1785812386064).toISOString(), + }); + expect(usage.quotas["Weekly window"]).toMatchObject({ + used: 0.1, + total: 6, + resetAt: new Date(1786379982640).toISOString(), + }); + // monthlyCredits/purchasedCredits/freeCredits are remaining balances; + // total = consumed (summary.totalCredits) + remaining, matching the CLI. + expect(usage.quotas["Monthly credits"]).toMatchObject({ + used: 0.1, + total: 10, + }); + }); + + it("adds orgId query when whoami returns an org", async () => { + proxyAwareFetch + .mockResolvedValueOnce( + jsonResponse({ success: true, org: { id: "org_1" } }), + ) + .mockResolvedValueOnce(jsonResponse(CREDITS)) + .mockResolvedValueOnce(jsonResponse(SUBS)) + .mockResolvedValueOnce(jsonResponse(SUMMARY)); + + await getUsageForProvider({ + provider: "commandcode", + apiKey: "user_cc_test", + }); + + expect(proxyAwareFetch.mock.calls[1][0]).toBe(`${CREDITS_URL}?orgId=org_1`); + expect(proxyAwareFetch.mock.calls[2][0]).toBe(`${SUBS_URL}?orgId=org_1`); + }); + + it("falls back to first-of-month since when subscription has no period start", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(WHOAMI)) + .mockResolvedValueOnce(jsonResponse(CREDITS)) + .mockResolvedValueOnce( + jsonResponse({ success: true, data: { planId: "individual-go" } }), + ) + .mockResolvedValueOnce(jsonResponse(SUMMARY)); + + await getUsageForProvider({ + provider: "commandcode", + apiKey: "user_cc_test", + }); + + const since = new URL(proxyAwareFetch.mock.calls[3][0]).searchParams.get( + "since", + ); + // firstOfMonth() is local-time based; assert the local date is the 1st. + const localDate = new Date(since); + expect(localDate.getDate()).toBe(1); + }); + + it("returns message on missing key / 401 / non-ok whoami", async () => { + const missing = await getUsageForProvider({ provider: "commandcode" }); + expect(missing.message).toMatch(/credential/i); + expect(proxyAwareFetch).not.toHaveBeenCalled(); + + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "no" }, 401)); + const auth = await getUsageForProvider({ + provider: "commandcode", + apiKey: "bad", + }); + expect(auth.message).toMatch(/invalid|expired/i); + + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "x" }, 500)); + const err = await getUsageForProvider({ + provider: "commandcode", + apiKey: "bad", + }); + expect(err.message).toMatch(/whoami/i); + }); }); describe("parseQuotaData(commandcode)", () => { - it("forwards used/total/resetAt for the dashboard table", () => { - const rows = parseQuotaData("commandcode", { - plan: "GOAT", - quotas: { - Credits: { used: 56, total: 70, resetAt: "2026-10-01T00:00:00.000Z" }, - "Session (5h)": { used: 2, total: 10, resetAt: "2026-09-16T10:00:00.000Z" }, - }, - }); - expect(rows).toHaveLength(2); - expect(rows[0]).toMatchObject({ name: "Credits", used: 56, total: 70 }); - expect(rows[1]).toMatchObject({ name: "Session (5h)", used: 2, total: 10 }); - }); + it("forwards remainingPercentage + unit for window/credit rows", () => { + const rows = parseQuotaData("commandcode", { + plan: "individual-go", + quotas: { + "5-hour window": { + used: 0.05, + total: 3, + remainingPercentage: 98.33, + resetAt: "2026-08-03T22:59:46.064Z", + unit: "$", + }, + "Monthly credits": { + used: 0.1, + total: 10, + remainingPercentage: 99, + resetAt: null, + unit: "$", + }, + }, + }); + expect(rows[0]).toMatchObject({ + name: "5-hour window", + used: 0.05, + total: 3, + }); + expect(rows[1]).toMatchObject({ + name: "Monthly credits", + used: 0.1, + total: 10, + }); + }); }); diff --git a/tests/unit/openai-to-commandcode.test.js b/tests/unit/openai-to-commandcode.test.js index 0a441dd7..cde895cd 100644 --- a/tests/unit/openai-to-commandcode.test.js +++ b/tests/unit/openai-to-commandcode.test.js @@ -14,222 +14,343 @@ import { openaiToCommandCodeRequest } from "../../open-sse/translator/request/op const MODEL = "moonshotai/Kimi-K2.6"; describe("openaiToCommandCodeRequest — basic envelope", () => { - it("returns the expected top-level envelope shape", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hi" }], - }, true); + it("returns the expected top-level envelope shape", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hi" }], + }, + true, + ); - expect(out).toHaveProperty("threadId"); - expect(out).toHaveProperty("memory"); - expect(out).toHaveProperty("config"); - expect(out).toHaveProperty("params"); - expect(out.params.model).toBe(MODEL); - expect(out.params.stream).toBe(true); - }); + expect(out).toHaveProperty("threadId"); + expect(out).toHaveProperty("memory"); + expect(out).toHaveProperty("config"); + expect(out).toHaveProperty("params"); + expect(out.params.model).toBe(MODEL); + expect(out.params.stream).toBe(true); + }); }); describe("openaiToCommandCodeRequest — system handling", () => { - it("hoists system messages to params.system (string), not messages[]", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [ - { role: "system", content: "You are concise." }, - { role: "user", content: "hi" }, - ], - }, true); + it("hoists system messages to params.system (string), not messages[]", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "hi" }, + ], + }, + true, + ); - expect(typeof out.params.system).toBe("string"); - expect(out.params.system).toBe("You are concise."); - const roles = out.params.messages.map((m) => m.role); - expect(roles).not.toContain("system"); - }); + expect(typeof out.params.system).toBe("string"); + expect(out.params.system).toBe("You are concise."); + const roles = out.params.messages.map((m) => m.role); + expect(roles).not.toContain("system"); + }); - it("joins multiple system messages with blank line", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [ - { role: "system", content: "A" }, - { role: "system", content: "B" }, - { role: "user", content: "hi" }, - ], - }, true); + it("joins multiple system messages with blank line", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { role: "system", content: "A" }, + { role: "system", content: "B" }, + { role: "user", content: "hi" }, + ], + }, + true, + ); - expect(out.params.system).toBe("A\n\nB"); - }); + expect(out.params.system).toBe("A\n\nB"); + }); - it("omits params.system when no system messages", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hi" }], - }, true); - expect(out.params.system).toBeUndefined(); - }); + it("omits params.system when no system messages", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hi" }], + }, + true, + ); + expect(out.params.system).toBeUndefined(); + }); +}); + +describe("openaiToCommandCodeRequest — vision / image blocks", () => { + const PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + it('maps OpenAI image_url (data URI) → {type:"image", image:"data:..."}', () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What color?" }, + { + type: "image_url", + image_url: { url: `data:image/png;base64,${PNG}` }, + }, + ], + }, + ], + }, + true, + ); + + const blocks = out.params.messages[0].content; + expect(blocks[0]).toEqual({ type: "text", text: "What color?" }); + expect(blocks[1]).toEqual({ + type: "image", + image: `data:image/png;base64,${PNG}`, + mimeType: "image/png", + }); + }); + + it("maps Claude-style image block (source.base64) → data URI with media_type", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { + type: "base64", + media_type: "image/jpeg", + data: "AAAA", + }, + }, + ], + }, + ], + }, + true, + ); + + expect(out.params.messages[0].content[0]).toEqual({ + type: "image", + image: "data:image/jpeg;base64,AAAA", + mimeType: "image/jpeg", + }); + }); + + it("passes raw URL through when image_url is a remote http(s) URL", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { url: "https://example.com/a.png" }, + }, + ], + }, + ], + }, + true, + ); + + expect(out.params.messages[0].content[0]).toEqual({ + type: "image", + image: "https://example.com/a.png", + }); + }); + + it("skips image block when no usable image source", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "" } }], + }, + ], + }, + true, + ); + + const blocks = out.params.messages[0].content; + expect(blocks.every((b) => b.type !== "image")).toBe(true); + }); }); describe("openaiToCommandCodeRequest — content shape", () => { - it("MUST always emit content as Array (never string) for user", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hello" }], - }, true); + it("MUST always emit content as Array (never string) for user", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hello" }], + }, + true, + ); - const u = out.params.messages[0]; - expect(Array.isArray(u.content)).toBe(true); - expect(u.content[0]).toEqual({ type: "text", text: "hello" }); - }); + const u = out.params.messages[0]; + expect(Array.isArray(u.content)).toBe(true); + expect(u.content[0]).toEqual({ type: "text", text: "hello" }); + }); - it("MUST always emit content as Array for assistant", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [ - { role: "user", content: "a" }, - { role: "assistant", content: "b" }, - ], - }, true); - const a = out.params.messages[1]; - expect(Array.isArray(a.content)).toBe(true); - expect(a.content[0]).toEqual({ type: "text", text: "b" }); - }); + it("MUST always emit content as Array for assistant", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + ], + }, + true, + ); + const a = out.params.messages[1]; + expect(Array.isArray(a.content)).toBe(true); + expect(a.content[0]).toEqual({ type: "text", text: "b" }); + }); }); describe("openaiToCommandCodeRequest — tool role / tool-result (AI SDK)", () => { - it("converts role:\"tool\" to role:\"tool\" with tool-result block; output is {type:\"text\",value}", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [ - { role: "user", content: "run X" }, - { - role: "assistant", - content: null, - tool_calls: [ - { id: "call_1", type: "function", function: { name: "do_x", arguments: "{\"a\":1}" } }, - ], - }, - { role: "tool", tool_call_id: "call_1", name: "do_x", content: "RESULT_OK" }, - ], - }, true); + it('converts role:"tool" to role:"tool" with tool-result block; output is {type:"text",value}', () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { role: "user", content: "run X" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "do_x", arguments: '{"a":1}' }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_1", + name: "do_x", + content: "RESULT_OK", + }, + ], + }, + true, + ); - const toolMsg = out.params.messages[out.params.messages.length - 1]; - expect(toolMsg.role).toBe("tool"); - const block = toolMsg.content[0]; - expect(block.type).toBe("tool-result"); - expect(block.toolCallId).toBe("call_1"); - expect(block.toolName).toBe("do_x"); - expect(block.output).toEqual({ type: "text", value: "RESULT_OK" }); - }); + const toolMsg = out.params.messages[out.params.messages.length - 1]; + expect(toolMsg.role).toBe("tool"); + const block = toolMsg.content[0]; + expect(block.type).toBe("tool-result"); + expect(block.toolCallId).toBe("call_1"); + expect(block.toolName).toBe("do_x"); + expect(block.output).toEqual({ type: "text", value: "RESULT_OK" }); + }); }); describe("openaiToCommandCodeRequest — assistant tool_calls / tool-call", () => { - it("converts assistant.tool_calls[] into content blocks of type tool-call", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [ - { role: "user", content: "go" }, - { - role: "assistant", - content: null, - tool_calls: [ - { id: "call_42", type: "function", function: { name: "search", arguments: "{\"q\":\"hi\"}" } }, - ], - }, - ], - }, true); + it("converts assistant.tool_calls[] into content blocks of type tool-call", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { role: "user", content: "go" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_42", + type: "function", + function: { name: "search", arguments: '{"q":"hi"}' }, + }, + ], + }, + ], + }, + true, + ); - const asst = out.params.messages[1]; - expect(asst.role).toBe("assistant"); - const tc = asst.content.find((b) => b.type === "tool-call"); - expect(tc).toBeDefined(); - expect(tc.toolCallId).toBe("call_42"); - expect(tc.toolName).toBe("search"); - expect(tc.input).toEqual({ q: "hi" }); - }); + const asst = out.params.messages[1]; + expect(asst.role).toBe("assistant"); + const tc = asst.content.find((b) => b.type === "tool-call"); + expect(tc).toBeDefined(); + expect(tc.toolCallId).toBe("call_42"); + expect(tc.toolName).toBe("search"); + expect(tc.input).toEqual({ q: "hi" }); + }); }); describe("openaiToCommandCodeRequest — tools schema conversion", () => { - it("converts OpenAI {type:\"function\", function:{...}} to Anthropic plain {name, input_schema}", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hi" }], - tools: [ - { - type: "function", - function: { - name: "weather", - description: "Get weather", - parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, - }, - }, - ], - }, true); + it('converts OpenAI {type:"function", function:{...}} to Anthropic plain {name, input_schema}', () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hi" }], + tools: [ + { + type: "function", + function: { + name: "weather", + description: "Get weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, + ], + }, + true, + ); - const t = out.params.tools[0]; - expect(t.name).toBe("weather"); - expect(t.input_schema).toBeDefined(); - expect(t.input_schema.type).toBe("object"); - expect(t.function).toBeUndefined(); - expect(t.parameters).toBeUndefined(); - }); + const t = out.params.tools[0]; + expect(t.name).toBe("weather"); + expect(t.input_schema).toBeDefined(); + expect(t.input_schema.type).toBe("object"); + expect(t.function).toBeUndefined(); + expect(t.parameters).toBeUndefined(); + }); - it("preserves description on converted tool", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hi" }], - tools: [ - { type: "function", function: { name: "ping", description: "Ping the server", parameters: { type: "object" } } }, - ], - }, true); - expect(out.params.tools[0].description).toBe("Ping the server"); - }); + it("preserves description on converted tool", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hi" }], + tools: [ + { + type: "function", + function: { + name: "ping", + description: "Ping the server", + parameters: { type: "object" }, + }, + }, + ], + }, + true, + ); + expect(out.params.tools[0].description).toBe("Ping the server"); + }); - it("does not include tools field when input has none", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hi" }], - }, true); - expect(out.params.tools).toBeUndefined(); - }); -}); - -describe("openaiToCommandCodeRequest — native image blocks", () => { - const PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; - const DATA_URI = `data:image/png;base64,${PNG_B64}`; - - it("maps OpenAI image_url data URI to CommandCode {type:image,image,mimeType}", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ - role: "user", - content: [ - { type: "text", text: "what color?" }, - { type: "image_url", image_url: { url: DATA_URI } }, - ], - }], - }, true); - - expect(out.params.messages[0].content).toEqual([ - { type: "text", text: "what color?" }, - { type: "image", image: DATA_URI, mimeType: "image/png" }, - ]); - }); - - it("maps Claude/OpenAI base64 image source to a data-URI image block", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ - role: "user", - content: [ - { type: "image", source: { type: "base64", media_type: "image/png", data: PNG_B64 } }, - ], - }], - }, true); - - expect(out.params.messages[0].content).toEqual([ - { type: "image", image: DATA_URI, mimeType: "image/png" }, - ]); - }); - - it("does not stub dropped images as [image omitted]", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ - role: "user", - content: [ - { type: "text", text: "see this" }, - { type: "image_url", image_url: { url: DATA_URI } }, - ], - }], - }, true); - - const texts = out.params.messages[0].content - .filter((b) => b.type === "text") - .map((b) => b.text); - expect(texts).not.toContain("[image omitted]"); - }); + it("does not include tools field when input has none", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hi" }], + }, + true, + ); + expect(out.params.tools).toBeUndefined(); + }); });