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.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user