diff --git a/.gitignore b/.gitignore index 8ccac2c7..7a15c62c 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,6 @@ graphify-out/* .codegraph/ .PR/ .next-analyze/* + +# CommandCode CLI local state (auth/taste/projects) +.commandcode/ diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 4c1708c1..b4acb649 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -136,10 +136,11 @@ export class BaseExecutor { const timeoutMs = await resolveProviderTimeoutMs(this.provider, this.config?.timeoutMs, FETCH_CONNECT_TIMEOUT_MS); const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs); const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal; + let fetchT0 = 0; try { const bodyStr = JSON.stringify(transformedBody); - const fetchT0 = Date.now(); + fetchT0 = Date.now(); dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms`); const response = await proxyAwareFetch(url, { method: "POST", @@ -165,6 +166,11 @@ export class BaseExecutor { clearTimeout(connectTimer); lastError = error; const isConnectTimeout = connectCtrl.signal.aborted && error.name === "AbortError"; + // Error diagnostic — only logs on actual upstream failure. Distinguishes + // undici connect timeout (UND_ERR_CONNECT_TIMEOUT), DNS (ENOTFOUND), + // refused (ECONNREFUSED) vs our own connectCtrl abort (AbortError). + const cause = error?.cause || {}; + console.log(`[FETCH-DIAG] ${this.provider} fetch error | name=${error.name} | code=${error.code ?? cause?.code ?? "none"} | msg=${String(error.message).slice(0, 120)} | connectTimeout=${timeoutMs}ms | elapsed=${Date.now() - fetchT0}ms`); dbg("FETCH", `${this.provider.toUpperCase()} ✖ ${error.name}: ${error.message}${isConnectTimeout ? " (connect timeout)" : ""}`); // Connect timeout is internal — convert to retryable network error, don't propagate AbortError if (error.name === "AbortError" && !isConnectTimeout) throw error; diff --git a/open-sse/providers/registry/commandcode.js b/open-sse/providers/registry/commandcode.js index 3b21fbbc..a2d2a257 100644 --- a/open-sse/providers/registry/commandcode.js +++ b/open-sse/providers/registry/commandcode.js @@ -23,9 +23,24 @@ export default { format: "commandcode", forceStream: true, headers: { - "x-command-code-version": "0.25.7", + "x-command-code-version": "1.10.0", "x-cli-environment": "cli", + "User-Agent": "cli", }, + // Quota/billing endpoints (same alpha API the official CLI /usage calls). + // whoami resolves orgId; credits+subscription+usage/summary then report the + // 5-hour/weekly windows, plan, and period credits. See services/usage/commandcode.js. + usage: { + baseUrl: "https://api.commandcode.ai", + whoamiUrl: "/alpha/whoami", + creditsUrl: "/alpha/billing/credits", + subscriptionsUrl: "/alpha/billing/subscriptions", + summaryUrl: "/alpha/usage/summary", + }, + }, + features: { + usage: true, + usageApikey: true, }, models: [ { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" }, diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 544d2584..b8cfe1b5 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -15,6 +15,7 @@ import { getXaiUsage } from "./usage/xai.js"; import { getGrokCliUsage } from "./usage/grok-cli.js"; import { getKimiUsage } from "./usage/kimi.js"; import { getDeepseekUsage } from "./usage/deepseek.js"; +import { getCommandCodeUsage } from "./usage/commandcode.js"; import { getQwenUsage, getIflowUsage, @@ -51,6 +52,7 @@ const USAGE_HANDLERS = { "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData), deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions), + commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null) { diff --git a/open-sse/services/usage/commandcode.js b/open-sse/services/usage/commandcode.js new file mode 100644 index 00000000..0f4e3065 --- /dev/null +++ b/open-sse/services/usage/commandcode.js @@ -0,0 +1,203 @@ +/** + * 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 { U, parseResetTime } from "./shared.js"; + +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"); + +function buildHeaders(token) { + return { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }; +} + +/** 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} apiKey - commandcode API key (user_...) + * @param {object|null} proxyOptions + */ +export async function getCommandCodeUsage(apiKey, proxyOptions = null) { + if (!apiKey) { + return { message: "CommandCode credential not available." }; + } + + const headers = buildHeaders(apiKey); + + 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; + + const orgQuery = orgId ? `?orgId=${encodeURIComponent(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 { message: "CommandCode credential invalid or expired." }; + } + if (!creditsRes.ok) { + return { + message: `CommandCode credits API error (${creditsRes.status}).`, + }; + } + + const credits = await creditsRes.json().catch(() => null); + const subs = await subsRes.json().catch(() => null); + + const subData = subs?.data; + const planId = subData?.planId ?? null; + const periodStart = subData?.currentPeriodStart ?? null; + + // 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; + + 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), + }); + } + + // Monthly credits consumed this billing period (from summary when present, + // else the credits object's monthlyCredits as a fallback). + const monthlyUsed = + typeof summary?.totalCredits === "number" + ? summary.totalCredits + : typeof credits?.credits?.monthlyCredits === "number" + ? credits.credits.monthlyCredits + : 0; + + const monthlyTotal = + typeof credits?.credits?.monthlyCredits === "number" + ? credits.credits.monthlyCredits + : 0; + + 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/open-sse/translator/request/openai-to-commandcode.js b/open-sse/translator/request/openai-to-commandcode.js index 3479a0c8..33d35815 100644 --- a/open-sse/translator/request/openai-to-commandcode.js +++ b/open-sse/translator/request/openai-to-commandcode.js @@ -191,13 +191,17 @@ export function openaiToCommandCodeRequest( const today = new Date().toISOString().slice(0, 10); + // environment format mirrors the official command-code CLI (getEnvironmentInfo): + // `${platform}-${arch}, Node.js ${version}` (e.g. "darwin-arm64, Node.js v24.16.0"). + const environment = `${process.platform}-${process.arch}, Node.js ${process.version}`; + return { threadId: randomUUID(), memory: "", config: { workingDir: process.cwd(), date: today, - environment: process.platform, + environment, structure: [], isGitRepo: false, currentBranch: "", diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index da62da9f..e7251247 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -288,9 +288,11 @@ export default function ProfilePage() { const handleGlobalTimeoutChange = async (e) => { const raw = e.target.value.replace(/[^0-9]/g, ""); const numTimeout = parseInt(raw, 10); + // Enforce a sane minimum (1s) so a stray "60" never becomes a 60ms + // connect timeout — same guard as the per-provider timeout input. const patchValue = raw !== "" && Number.isFinite(numTimeout) && numTimeout > 0 - ? numTimeout + ? Math.max(1000, numTimeout) : null; try { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 2ec75f1c..85b13537 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -438,7 +438,7 @@ export default function ProviderDetailPage() { const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {}; setThinkingMode(thinkingCfg.mode || "auto"); - // Load per-provider connect timeout + // Load per-provider connect timeout (ms) const timeoutCfg = (settingsData.providerTimeouts || {})[providerId] || {}; setProviderTimeout( @@ -577,15 +577,13 @@ export default function ProviderDetailPage() { const settingsData = settingsRes.ok ? await settingsRes.json() : {}; const current = settingsData.providerTimeouts || {}; const updated = { ...current }; - if (!ms || ms === "") { - delete updated[providerId]; + const timeoutMs = parseInt(ms, 10); + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + // Stored in ms; enforce a sane minimum (1s) so a stray "60" means + // 60 SECONDS-worth of protection — never a 60ms connect timeout. + updated[providerId] = { timeoutMs: Math.max(1000, timeoutMs) }; } else { - const timeoutMs = parseInt(ms, 10); - if (Number.isFinite(timeoutMs) && timeoutMs > 0) { - updated[providerId] = { timeoutMs }; - } else { - delete updated[providerId]; - } + delete updated[providerId]; } await fetch("/api/settings", { method: "PATCH", diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index eb486cc0..aff42359 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -147,7 +147,7 @@ export default function ProviderLimits() { const [proxyPools, setProxyPools] = useState([]); const [providerFilter, setProviderFilter] = useState("all"); const [providerOptions, setProviderOptions] = useState([]); - const [accountFilter, setAccountFilter] = useState("all"); + const [accountFilter, setAccountFilter] = useState("active"); const [quotaSortMode, setQuotaSortMode] = useState("default"); const [quotaVisibility, setQuotaVisibility] = useState({}); const [expiringFirst, setExpiringFirst] = useState(false); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 3334d9a4..06963c99 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -564,6 +564,24 @@ export function parseQuotaData(provider, data) { } break; + case "commandcode": + // CommandCode reports currency credits (5-hour/weekly windows + monthly + // credits) with used/total in dollars. Forward remainingPercentage (the + // UI would otherwise render "$0.05" balances as "0%") and unit "$". + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + unit: quota.unit || "$", + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); + }); + } + break; + default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/tests/unit/commandcode-usage.test.js b/tests/unit/commandcode-usage.test.js new file mode 100644 index 00000000..60aa6821 --- /dev/null +++ b/tests/unit/commandcode-usage.test.js @@ -0,0 +1,218 @@ +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 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" }, + }); +} + +const WHOAMI = { success: true, user: { id: "u1" }, org: null }; +const CREDITS = { + 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 = { + 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", +}; + +describe("commandcode registry usage flags", () => { + 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(); + }); + + it("fetches whoami → credits+subs → summary and maps windows + credits", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(WHOAMI)) + .mockResolvedValueOnce(jsonResponse(CREDITS)) + .mockResolvedValueOnce(jsonResponse(SUBS)) + .mockResolvedValueOnce(jsonResponse(SUMMARY)); + + const usage = await getUsageForProvider({ + provider: "commandcode", + apiKey: "user_cc_test", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("individual-go"); + expect(usage.periodBasis).toBe("billing-period"); + + 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"); + + // No org → credits/subscriptions called without orgId query + const creditsCall = proxyAwareFetch.mock.calls[1][0]; + expect(creditsCall).toBe(CREDITS_URL); + + // 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(), + }); + expect(usage.quotas["Monthly credits"]).toMatchObject({ + used: 0.1, + total: 9.9, + }); + }); + + 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 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: 9.9, + remainingPercentage: 98.99, + resetAt: null, + unit: "$", + }, + }, + }); + expect(rows[0]).toMatchObject({ + name: "5-hour window", + used: 0.05, + total: 3, + remainingPercentage: 98.33, + unit: "$", + }); + expect(rows[1]).toMatchObject({ + name: "Monthly credits", + used: 0.1, + total: 9.9, + remainingPercentage: 98.99, + }); + }); +});