mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
refactor(usage): extract 5 provider usage families into leaves (#5782)
Split open-sse/services/usage.ts (1723 -> 901 LOC) by moving the Cursor, Kimi, Codex, Claude and Kiro usage-fetcher families into cohesive leaves under open-sse/services/usage/ (mirroring the existing glm/minimax/antigravity/quota/ scalars leaves): - usage/cursor.ts getCursorUsage (+ CURSOR_USAGE_CONFIG, decodeCursorJwtSub) - usage/kimi.ts getKimiUsage (+ KIMI_CONFIG, getKimiPlanName) - usage/codex.ts getCodexUsage (+ CODEX_CONFIG) - usage/claude.ts getClaudeUsage / getClaudePlanLabel (+ CLAUDE_CONFIG, legacy) - usage/kiro.ts getKiroUsage / buildKiroUsageResult / discoverKiroProfileArn (+ helpers) The host keeps the getUsageForProvider dispatcher and imports the fetchers back; the public export set is unchanged — buildKiroUsageResult + discoverKiroProfileArn are re-exported from the kiro leaf (the kiro-* tests import them from services/usage) and __testing stays wired to the moved claude/kiro internals. Bodies are verbatim: the code-line multiset of host + leaves equals the original. Adds tests/unit/usage-families-split.test.ts pinning the leaf surface, the kiro re-export identity, the __testing wiring, and getClaudePlanLabel's pure logic.
This commit is contained in:
committed by
GitHub
parent
e2d2c2759b
commit
d372a9af69
@@ -2,18 +2,13 @@
|
||||
* Usage Fetcher - Get usage data from provider APIs
|
||||
*/
|
||||
|
||||
import { buildCodexUsageQuotas } from "./codexUsageQuotas.ts";
|
||||
import { getGlmQuotaUrl } from "../config/glmProvider.ts";
|
||||
import { getGitHubCopilotInternalUserHeaders } from "../config/providerHeaderProfiles.ts";
|
||||
import { safePercentage } from "@/shared/utils/formatting";
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts";
|
||||
import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts";
|
||||
import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts";
|
||||
import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts";
|
||||
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts";
|
||||
import { CLAUDE_CODE_VERSION, fetchClaudeBootstrap } from "../executors/claudeIdentity.ts";
|
||||
import { isClaudeOauthUsageCoolingDown, markClaudeOauthUsage429 } from "./claudeUsageCooldown.ts";
|
||||
import {
|
||||
extractCodeAssistOnboardTierId,
|
||||
extractCodeAssistSubscriptionTier,
|
||||
@@ -55,48 +50,21 @@ import {
|
||||
mapCodeAssistTierIdToLabel,
|
||||
mapSubscriptionTierStringToPlanLabel,
|
||||
} from "./usage/antigravity.ts";
|
||||
import { getCursorUsage } from "./usage/cursor.ts";
|
||||
import { getKimiUsage } from "./usage/kimi.ts";
|
||||
import { getCodexUsage } from "./usage/codex.ts";
|
||||
import { getClaudeUsage, getClaudePlanLabel } from "./usage/claude.ts";
|
||||
import { getKiroUsage, buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts";
|
||||
// Re-exported para os testes kiro-* (importam de services/usage).
|
||||
export { buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts";
|
||||
|
||||
// Quota / usage upstream URLs (overridable for testing or relays).
|
||||
const CROF_USAGE_URL = process.env.OMNIROUTE_CROF_USAGE_URL ?? "https://crof.ai/usage_api/";
|
||||
const CODEWHISPERER_BASE_URL =
|
||||
process.env.OMNIROUTE_CODEWHISPERER_BASE_URL ?? "https://codewhisperer.us-east-1.amazonaws.com";
|
||||
|
||||
// Codex (OpenAI) API config
|
||||
const CODEX_CONFIG = {
|
||||
usageUrl: "https://chatgpt.com/backend-api/wham/usage",
|
||||
};
|
||||
|
||||
// Claude API config
|
||||
const CLAUDE_CONFIG = {
|
||||
oauthUsageUrl: "https://api.anthropic.com/api/oauth/usage",
|
||||
usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
|
||||
settingsUrl: "https://api.anthropic.com/v1/settings",
|
||||
apiVersion: "2023-06-01",
|
||||
};
|
||||
|
||||
// Kimi Coding API config
|
||||
const KIMI_CONFIG = {
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
usageUrl: "https://api.kimi.com/coding/v1/usages",
|
||||
apiVersion: "2023-06-01",
|
||||
};
|
||||
|
||||
const NANOGPT_CONFIG = {
|
||||
usageUrl: "https://nano-gpt.com/api/subscription/v1/usage",
|
||||
};
|
||||
|
||||
// Cursor dashboard usage API config
|
||||
// The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS
|
||||
// session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and
|
||||
// rejects requests without a matching Origin/Referer (Invalid origin for state-changing request).
|
||||
const CURSOR_USAGE_CONFIG = {
|
||||
usageUrl: "https://cursor.com/api/dashboard/get-current-period-usage",
|
||||
origin: "https://cursor.com",
|
||||
referer: "https://cursor.com/dashboard/spending",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type UsageProviderConnection = JsonRecord & {
|
||||
id?: string;
|
||||
@@ -114,57 +82,6 @@ function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota
|
||||
return quota.total > 0 || quota.remainingPercentage !== undefined;
|
||||
}
|
||||
|
||||
function isKiroOverageEnabled(data: JsonRecord): boolean {
|
||||
const overageConfiguration = toRecord(data.overageConfiguration);
|
||||
const overageStatus = String(overageConfiguration.overageStatus || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
overageStatus === "ENABLED" ||
|
||||
data.overageEnabled === true ||
|
||||
overageConfiguration.overageEnabled === true
|
||||
);
|
||||
}
|
||||
|
||||
function buildKiroQuota(
|
||||
used: number,
|
||||
total: number,
|
||||
resetAt: string | null,
|
||||
overageEnabled: boolean
|
||||
): UsageQuota {
|
||||
const remaining = total - used;
|
||||
|
||||
if (!overageEnabled) {
|
||||
return { used, total, remaining, resetAt, unlimited: false };
|
||||
}
|
||||
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage: 100,
|
||||
resetAt,
|
||||
unlimited: true,
|
||||
};
|
||||
}
|
||||
|
||||
function getClaudePlanLabel(...candidates: Array<string | null | undefined>): string | null {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.toLowerCase() === "claude code" ||
|
||||
trimmed.toLowerCase() === "unknown"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// CrofAI surfaces a tiny endpoint with two signals:
|
||||
// GET https://crof.ai/usage_api/ → { usable_requests: number|null, credits: number }
|
||||
// `usable_requests` is the daily request bucket on a subscription plan; `null`
|
||||
@@ -545,143 +462,6 @@ async function getNanoGptUsage(apiKey: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the `sub` claim of a Cursor JWT (the WorkOS user id).
|
||||
* Returns null if the token is not a parseable JWT.
|
||||
*/
|
||||
function decodeCursorJwtSub(token: string): string | null {
|
||||
if (!token || typeof token !== "string") return null;
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (payload.length % 4 !== 0) payload += "=";
|
||||
const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||
const sub = decoded?.sub;
|
||||
return typeof sub === "string" && sub.length > 0 ? sub : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor Pro Plan Usage
|
||||
* Fetches current-billing-cycle spend from the cursor.com dashboard API and exposes three
|
||||
* windows that mirror the cursor.com/dashboard/spending UI: Total / Auto + Composer / API.
|
||||
*/
|
||||
async function getCursorUsage(accessToken: string, providerSpecificData?: unknown) {
|
||||
if (!accessToken) {
|
||||
return { message: "Cursor access token missing. Re-import the connection from Cursor IDE." };
|
||||
}
|
||||
|
||||
const storedUserId = (() => {
|
||||
const raw = toRecord(providerSpecificData).userId;
|
||||
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
||||
})();
|
||||
const userId = storedUserId || decodeCursorJwtSub(accessToken);
|
||||
|
||||
if (!userId) {
|
||||
return {
|
||||
message: "Cursor token missing user id. Re-import the connection from Cursor IDE.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(CURSOR_USAGE_CONFIG.usageUrl, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`,
|
||||
Origin: CURSOR_USAGE_CONFIG.origin,
|
||||
Referer: CURSOR_USAGE_CONFIG.referer,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": CURSOR_USAGE_CONFIG.userAgent,
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
// 3xx redirect to WorkOS authkit means the session cookie was rejected.
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
return {
|
||||
plan: "Cursor",
|
||||
message: "Cursor session expired. Re-import the token from Cursor IDE.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = (await response.text()).slice(0, 200);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
plan: "Cursor",
|
||||
message: "Cursor session unauthorized. Re-import the token from Cursor IDE.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
plan: "Cursor",
|
||||
message: `Cursor usage endpoint error (${response.status}): ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = toRecord(await response.json());
|
||||
const planUsage = toRecord(data.planUsage);
|
||||
|
||||
if (Object.keys(planUsage).length === 0) {
|
||||
return {
|
||||
plan: "Cursor",
|
||||
message: "Cursor connected. No active plan usage returned.",
|
||||
};
|
||||
}
|
||||
|
||||
const limitCents = Math.max(0, toNumber(planUsage.limit, 0));
|
||||
const totalSpendCents = Math.max(0, toNumber(planUsage.totalSpend, 0));
|
||||
const autoPercentUsed = clampPercentage(toNumber(planUsage.autoPercentUsed, 0));
|
||||
const apiPercentUsed = clampPercentage(toNumber(planUsage.apiPercentUsed, 0));
|
||||
const totalPercentUsed = clampPercentage(toNumber(planUsage.totalPercentUsed, 0));
|
||||
|
||||
// billingCycleEnd is a numeric-string in ms; coerce so parseResetTime sees a number.
|
||||
const billingCycleEndMs = toNumber(data.billingCycleEnd, 0);
|
||||
const resetAt = billingCycleEndMs > 0 ? parseResetTime(billingCycleEndMs) : null;
|
||||
|
||||
// Convert cents → dollars rounded to 2 decimal places.
|
||||
const toDollars = (cents: number) => Math.round(cents) / 100;
|
||||
|
||||
const limitDollars = toDollars(limitCents);
|
||||
const buildWindow = (percentUsed: number, usedCentsOverride?: number): UsageQuota => {
|
||||
const usedCents =
|
||||
typeof usedCentsOverride === "number"
|
||||
? usedCentsOverride
|
||||
: Math.round((limitCents * percentUsed) / 100);
|
||||
const used = toDollars(Math.min(usedCents, limitCents));
|
||||
const remaining = toDollars(Math.max(limitCents - Math.min(usedCents, limitCents), 0));
|
||||
return {
|
||||
used,
|
||||
total: limitDollars,
|
||||
remaining,
|
||||
remainingPercentage: clampPercentage(100 - percentUsed),
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
};
|
||||
};
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {
|
||||
Total: buildWindow(totalPercentUsed, totalSpendCents),
|
||||
"Auto + Composer": buildWindow(autoPercentUsed),
|
||||
API: buildWindow(apiPercentUsed),
|
||||
};
|
||||
|
||||
return {
|
||||
plan: "Cursor Pro",
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
plan: "Cursor",
|
||||
message: `Cursor connected. Unable to fetch usage: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for which providers have a `getUsageForProvider`
|
||||
* implementation. Consumers like `genericQuotaFetcher.ts` reference this so
|
||||
@@ -1011,421 +791,6 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null):
|
||||
return "GitHub Copilot";
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Usage - Try to fetch from Anthropic API
|
||||
*/
|
||||
async function getClaudeUsage(accessToken?: string) {
|
||||
if (!accessToken) {
|
||||
return { message: "Claude connected. Access token not available.", bootstrap: null };
|
||||
}
|
||||
|
||||
// Refresh bootstrap in parallel; best-effort, failure non-fatal.
|
||||
const bootstrapPromise = fetchClaudeBootstrap(accessToken).catch(() => null);
|
||||
// Skip OAuth usage call while this token is cooling down from a recent 429
|
||||
// (chat with the same token still works — only the quota endpoint is throttled).
|
||||
if (isClaudeOauthUsageCoolingDown(accessToken)) {
|
||||
const legacy = await getClaudeUsageLegacy(accessToken);
|
||||
return { ...legacy, bootstrap: await bootstrapPromise };
|
||||
}
|
||||
try {
|
||||
// Real CLI uses axios here, not Stainless — UA is `claude-code/<version>`
|
||||
// (not `claude-cli/...`) and the shape is simpler than /v1/messages.
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 10_000);
|
||||
let oauthResponse;
|
||||
try {
|
||||
oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
"Accept-Encoding": "gzip, compress, deflate, br",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `claude-code/${CLAUDE_CODE_VERSION}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (oauthResponse.ok) {
|
||||
const data = toRecord(await oauthResponse.json());
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
// utilization = percentage USED (e.g., 90 means 90% used, 10% remaining)
|
||||
// Confirmed via user report #299: Claude.ai shows 87% used = OmniRoute must show 13% remaining.
|
||||
const hasUtilization = (window: JsonRecord) =>
|
||||
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
|
||||
|
||||
const createQuotaObject = (window: JsonRecord) => {
|
||||
const used = safePercentage(window.utilization) as number; // utilization = % used
|
||||
const remaining = Math.max(0, 100 - used);
|
||||
return {
|
||||
used,
|
||||
total: 100,
|
||||
remaining,
|
||||
resetAt: parseResetTime(window.resets_at),
|
||||
remainingPercentage: remaining,
|
||||
unlimited: false,
|
||||
};
|
||||
};
|
||||
|
||||
const fiveHour = toRecord(data.five_hour);
|
||||
if (hasUtilization(fiveHour)) {
|
||||
quotas["session (5h)"] = createQuotaObject(fiveHour);
|
||||
}
|
||||
|
||||
const sevenDay = toRecord(data.seven_day);
|
||||
if (hasUtilization(sevenDay)) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(sevenDay);
|
||||
}
|
||||
|
||||
// Map Anthropic's internal codenames (e.g., omelette → Designer) for display.
|
||||
const MODEL_DISPLAY_NAMES: Record<string, string> = {
|
||||
omelette: "designer",
|
||||
};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const valueRecord = toRecord(value);
|
||||
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) {
|
||||
const codename = key.replace("seven_day_", "");
|
||||
const modelName = MODEL_DISPLAY_NAMES[codename] || codename;
|
||||
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord);
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrap = await bootstrapPromise;
|
||||
const plan =
|
||||
getClaudePlanLabel(
|
||||
typeof data.tier === "string" ? data.tier : null,
|
||||
typeof data.plan === "string" ? data.plan : null,
|
||||
typeof data.subscription_type === "string" ? data.subscription_type : null,
|
||||
bootstrap?.organization_rate_limit_tier
|
||||
) ?? undefined;
|
||||
|
||||
return {
|
||||
...(plan ? { plan } : {}),
|
||||
quotas,
|
||||
extraUsage: data.extra_usage ?? null,
|
||||
bootstrap,
|
||||
};
|
||||
}
|
||||
|
||||
// Cool down OAuth usage polling after a 429 (quota endpoint only — chat is unaffected).
|
||||
if (oauthResponse.status === 429) {
|
||||
markClaudeOauthUsage429(accessToken);
|
||||
}
|
||||
|
||||
// Fallback: OAuth endpoint returned non-OK, try legacy settings/org endpoint
|
||||
console.warn(
|
||||
`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`
|
||||
);
|
||||
const legacy = await getClaudeUsageLegacy(accessToken);
|
||||
return { ...legacy, bootstrap: await bootstrapPromise };
|
||||
} catch (error) {
|
||||
return {
|
||||
message: `Claude connected. Unable to fetch usage: ${(error as Error).message}`,
|
||||
bootstrap: await bootstrapPromise,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Claude usage fetcher for API key / org admin users.
|
||||
* Uses /v1/settings + /v1/organizations/{org_id}/usage endpoints.
|
||||
*/
|
||||
async function getClaudeUsageLegacy(accessToken?: string) {
|
||||
try {
|
||||
const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
});
|
||||
|
||||
if (settingsResponse.ok) {
|
||||
const settings = toRecord(await settingsResponse.json());
|
||||
|
||||
const organizationId =
|
||||
typeof settings.organization_id === "string" ? settings.organization_id : "";
|
||||
if (organizationId) {
|
||||
const usageResponse = await fetch(
|
||||
CLAUDE_CONFIG.usageUrl.replace("{org_id}", organizationId),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (usageResponse.ok) {
|
||||
const usage = await usageResponse.json();
|
||||
return {
|
||||
plan: settings.plan || "Unknown",
|
||||
organization: settings.organization_name,
|
||||
quotas: usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plan: settings.plan || "Unknown",
|
||||
organization: settings.organization_name,
|
||||
message: "Claude connected. Usage details require admin access.",
|
||||
};
|
||||
}
|
||||
|
||||
return { message: "Claude connected. Usage API requires admin permissions." };
|
||||
} catch (error) {
|
||||
return { message: `Claude connected. Unable to fetch usage: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex (OpenAI) Usage - Fetch from ChatGPT backend API
|
||||
* IMPORTANT: Uses persisted workspaceId from OAuth to ensure correct workspace binding.
|
||||
* No fallback to other workspaces - strict binding to user's selected workspace.
|
||||
*/
|
||||
async function getCodexUsage(
|
||||
accessToken?: string,
|
||||
providerSpecificData: Record<string, unknown> = {}
|
||||
) {
|
||||
try {
|
||||
// Use persisted workspace ID from OAuth - NO FALLBACK
|
||||
const accountId =
|
||||
typeof providerSpecificData.workspaceId === "string"
|
||||
? providerSpecificData.workspaceId
|
||||
: null;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (accountId) {
|
||||
headers["chatgpt-account-id"] = accountId;
|
||||
}
|
||||
|
||||
const response = await fetch(CODEX_CONFIG.usageUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: `Codex token expired or access denied. Please re-authenticate the connection.`,
|
||||
};
|
||||
}
|
||||
throw new Error(`Codex API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const { rateLimit, quotas } = buildCodexUsageQuotas(data);
|
||||
|
||||
return {
|
||||
plan: String(getFieldValue(data, "plan_type", "planType") || "unknown"),
|
||||
limitReached: Boolean(getFieldValue(rateLimit, "limit_reached", "limitReached")),
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `Failed to fetch Codex usage: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Kiro usage result from a GetUsageLimits response. When the account returns no
|
||||
* usage breakdown (some AWS IAM / Builder ID accounts don't expose per-resource quota via
|
||||
* GetUsageLimits), return an informative message instead of empty `quotas:{}` — otherwise the
|
||||
* dashboard renders a blank quota card with no explanation (#3506). Exported for testing.
|
||||
*/
|
||||
export function buildKiroUsageResult(
|
||||
data: JsonRecord
|
||||
): { plan: string; quotas: Record<string, UsageQuota> } | { message: string } {
|
||||
const usageList = Array.isArray(data.usageBreakdownList) ? data.usageBreakdownList : [];
|
||||
const quotaInfo: Record<string, UsageQuota> = {};
|
||||
const resetAt = parseResetTime(data.nextDateReset || data.resetDate);
|
||||
const overageEnabled = isKiroOverageEnabled(data);
|
||||
|
||||
usageList.forEach((breakdownValue: unknown) => {
|
||||
const breakdown = toRecord(breakdownValue);
|
||||
const resourceType =
|
||||
typeof breakdown.resourceType === "string" ? breakdown.resourceType.toLowerCase() : "unknown";
|
||||
const used = toNumber(breakdown.currentUsageWithPrecision, 0);
|
||||
const total = toNumber(breakdown.usageLimitWithPrecision, 0);
|
||||
|
||||
quotaInfo[resourceType] = buildKiroQuota(used, total, resetAt, overageEnabled);
|
||||
|
||||
const freeTrialInfo = toRecord(breakdown.freeTrialInfo);
|
||||
if (Object.keys(freeTrialInfo).length > 0) {
|
||||
const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0);
|
||||
const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0);
|
||||
quotaInfo[`${resourceType}_freetrial`] = buildKiroQuota(
|
||||
freeUsed,
|
||||
freeTotal,
|
||||
resetAt,
|
||||
overageEnabled
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(quotaInfo).length === 0) {
|
||||
return {
|
||||
message:
|
||||
"Kiro connected, but the account returned no usage breakdown. Some AWS IAM / Builder ID accounts don't expose per-resource quota via GetUsageLimits.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plan: String(toRecord(data.subscriptionInfo).subscriptionTitle || "").trim() || "Kiro",
|
||||
quotas: quotaInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover a Kiro/CodeWhisperer profile ARN for an account that didn't persist one (common for
|
||||
* AWS IAM Identity Center logins and kiro-cli imports). Calls ListAvailableProfiles on the
|
||||
* region-matched endpoint and prefers a profile whose ARN is in the same region. Returns
|
||||
* undefined when no profile is available (e.g. the org/token has no Kiro entitlement).
|
||||
* Exported for testing.
|
||||
*/
|
||||
export async function discoverKiroProfileArn(
|
||||
accessToken: string,
|
||||
usageBaseUrl: string,
|
||||
region: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(usageBaseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/x-amz-json-1.0",
|
||||
"x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ maxResults: 10 }),
|
||||
// Don't let a hung profile lookup block the usage/quota refresh indefinitely.
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const data = toRecord(await response.json());
|
||||
const profiles = Array.isArray(data.profiles) ? data.profiles : [];
|
||||
const normalizedRegion = region.toLowerCase();
|
||||
const matched =
|
||||
profiles.find((profile: unknown) => {
|
||||
const arn = toRecord(profile).arn;
|
||||
return typeof arn === "string" && arn.toLowerCase().includes(`:${normalizedRegion}:`);
|
||||
}) || profiles[0];
|
||||
const arn = toRecord(matched).arn;
|
||||
return typeof arn === "string" && arn.length > 0 ? arn : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiro (AWS CodeWhisperer) Usage
|
||||
*/
|
||||
async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
|
||||
try {
|
||||
let profileArn =
|
||||
typeof providerSpecificData?.profileArn === "string"
|
||||
? providerSpecificData.profileArn
|
||||
: undefined;
|
||||
|
||||
// Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and
|
||||
// endpoint must all match the region. Derive the region from the stored region (preferred)
|
||||
// or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the
|
||||
// legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions).
|
||||
const regionFromArn = profileArn
|
||||
? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1]
|
||||
: undefined;
|
||||
const region =
|
||||
(typeof providerSpecificData?.region === "string" &&
|
||||
providerSpecificData.region.trim().toLowerCase()) ||
|
||||
regionFromArn ||
|
||||
"us-east-1";
|
||||
const usageBaseUrl =
|
||||
region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`;
|
||||
|
||||
// IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which
|
||||
// previously caused the quota card to show nothing ("0 used"). Discover it on demand from
|
||||
// ListAvailableProfiles (region-matched) so usage still resolves for those accounts.
|
||||
if (!profileArn && accessToken) {
|
||||
profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region);
|
||||
}
|
||||
|
||||
if (!profileArn) {
|
||||
return { message: "Kiro connected. Profile ARN not available for quota tracking." };
|
||||
}
|
||||
|
||||
// Kiro uses AWS CodeWhisperer GetUsageLimits API
|
||||
const payload = {
|
||||
origin: "AI_EDITOR",
|
||||
profileArn: profileArn,
|
||||
resourceType: "AGENTIC_REQUEST",
|
||||
};
|
||||
|
||||
const response = await fetch(usageBaseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/x-amz-json-1.0",
|
||||
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Social-auth Kiro accounts (added via /api/oauth/kiro/social-exchange with provider
|
||||
// Google or GitHub) use a different token format that AWS CodeWhisperer's GetUsageLimits
|
||||
// routinely rejects with 401/403, even when /messages still works. Surface a clear
|
||||
// "auth expired, chat may still work" message instead of a generic upstream-error blob
|
||||
// so the quota card matches what users with legacy social-auth accounts already see.
|
||||
// Inspired by https://github.com/decolua/9router/pull/620.
|
||||
if (
|
||||
(response.status === 401 || response.status === 403) &&
|
||||
isSocialAuthKiroAccount(providerSpecificData)
|
||||
) {
|
||||
return {
|
||||
message: "Kiro quota API authentication expired. Chat may still work.",
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Kiro API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = toRecord(await response.json());
|
||||
return buildKiroUsageResult(data);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch Kiro usage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Was this Kiro connection added via the Google/GitHub social-auth device flow
|
||||
* (POST /api/oauth/kiro/social-exchange)? That route persists
|
||||
* `{ authMethod: "imported", provider: "Google" | "Github" }` on the connection.
|
||||
* Builder-ID / IDC / kiro-cli imports use different markers and should keep the
|
||||
* existing throw-on-failure behavior.
|
||||
*/
|
||||
function isSocialAuthKiroAccount(providerSpecificData?: JsonRecord): boolean {
|
||||
if (!providerSpecificData || providerSpecificData.authMethod !== "imported") return false;
|
||||
const provider =
|
||||
typeof providerSpecificData.provider === "string"
|
||||
? providerSpecificData.provider.toLowerCase()
|
||||
: "";
|
||||
return provider === "google" || provider === "github";
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertex AI — SELF-TRACKED spend.
|
||||
*
|
||||
@@ -1473,193 +838,6 @@ async function getVertexUsage(connectionId: string, provider: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Kimi membership level to display name
|
||||
* LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto,
|
||||
* LEVEL_ADVANCED = Allegro, LEVEL_STANDARD = Vivace
|
||||
*/
|
||||
function getKimiPlanName(level: unknown): string {
|
||||
if (!level) return "";
|
||||
const normalizedLevel = String(level);
|
||||
|
||||
const levelMap = {
|
||||
LEVEL_BASIC: "Moderato",
|
||||
LEVEL_INTERMEDIATE: "Allegretto",
|
||||
LEVEL_ADVANCED: "Allegro",
|
||||
LEVEL_STANDARD: "Vivace",
|
||||
};
|
||||
|
||||
return (
|
||||
levelMap[normalizedLevel as keyof typeof levelMap] ||
|
||||
normalizedLevel.replace("LEVEL_", "").toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kimi Coding Usage - Fetch quota from Kimi API
|
||||
* Uses the official /v1/usages endpoint with custom X-Msh-* headers
|
||||
*/
|
||||
async function getKimiUsage(accessToken?: string, apiKey?: string) {
|
||||
// Generate device info for headers (same as OAuth flow)
|
||||
const deviceId = "kimi-usage-" + Date.now();
|
||||
const platform = "omniroute";
|
||||
const version = "2.1.2";
|
||||
const deviceModel =
|
||||
typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown";
|
||||
|
||||
// API key auth takes precedence — Kimi's /usages endpoint accepts the same
|
||||
// API key used for /messages (verified live: responds with
|
||||
// authentication.method = METHOD_API_KEY). OAuth flow falls through to the
|
||||
// Bearer + device-headers shape used by Kimi Coding OAuth.
|
||||
const useApiKey = typeof apiKey === "string" && apiKey.length > 0;
|
||||
|
||||
const authHeaders: Record<string, string> = useApiKey
|
||||
? { "x-api-key": apiKey as string }
|
||||
: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"X-Msh-Platform": platform,
|
||||
"X-Msh-Version": version,
|
||||
"X-Msh-Device-Model": deviceModel,
|
||||
"X-Msh-Device-Id": deviceId,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(KIMI_CONFIG.usageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...authHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
plan: "Kimi Coding",
|
||||
message: `Kimi Coding connected. API Error ${response.status}: ${responseText.slice(0, 100)}`,
|
||||
};
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch {
|
||||
return {
|
||||
plan: "Kimi Coding",
|
||||
message: "Kimi Coding connected. Invalid JSON response from API.",
|
||||
};
|
||||
}
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
const dataObj = toRecord(data);
|
||||
|
||||
// Parse Kimi usage response format
|
||||
// Format: { user: {...}, usage: { limit: "100", used: "92", remaining: "8", resetTime: "..." }, limits: [...] }
|
||||
const usageObj = toRecord(dataObj.usage);
|
||||
|
||||
// Check for Kimi's actual usage fields (strings, not numbers)
|
||||
const usageLimit = toNumber(usageObj.limit || usageObj.Limit, 0);
|
||||
const usageUsed = toNumber(usageObj.used || usageObj.Used, 0);
|
||||
const usageRemaining = toNumber(usageObj.remaining || usageObj.Remaining, 0);
|
||||
const usageResetTime =
|
||||
usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt;
|
||||
|
||||
if (usageLimit > 0) {
|
||||
const percentRemaining = usageLimit > 0 ? (usageRemaining / usageLimit) * 100 : 0;
|
||||
|
||||
quotas["Weekly"] = {
|
||||
used: usageUsed,
|
||||
total: usageLimit,
|
||||
remaining: usageRemaining,
|
||||
remainingPercentage: percentRemaining,
|
||||
resetAt: parseResetTime(usageResetTime),
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Also parse limits array for rate limits
|
||||
const limitsArray = Array.isArray(dataObj.limits) ? dataObj.limits : [];
|
||||
for (let i = 0; i < limitsArray.length; i++) {
|
||||
const limitItem = toRecord(limitsArray[i]);
|
||||
const window = toRecord(limitItem.window);
|
||||
const detail = toRecord(limitItem.detail);
|
||||
|
||||
const limit = toNumber(detail.limit || detail.Limit, 0);
|
||||
const remaining = toNumber(detail.remaining || detail.Remaining, 0);
|
||||
const resetTime = detail.resetTime || detail.reset_at || detail.resetAt;
|
||||
|
||||
if (limit > 0) {
|
||||
quotas["Ratelimit"] = {
|
||||
used: limit - remaining,
|
||||
total: limit,
|
||||
remaining,
|
||||
remainingPercentage: limit > 0 ? (remaining / limit) * 100 : 0,
|
||||
resetAt: parseResetTime(resetTime),
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for quota windows (Claude-like format with utilization) as fallback
|
||||
const hasUtilization = (window: JsonRecord) =>
|
||||
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
|
||||
|
||||
const createQuotaObject = (window: JsonRecord) => {
|
||||
const remaining = safePercentage(window.utilization) as number;
|
||||
const used = 100 - remaining;
|
||||
return {
|
||||
used,
|
||||
total: 100,
|
||||
remaining,
|
||||
resetAt: parseResetTime(window.resets_at),
|
||||
remainingPercentage: remaining,
|
||||
unlimited: false,
|
||||
};
|
||||
};
|
||||
|
||||
if (hasUtilization(toRecord(dataObj.five_hour))) {
|
||||
quotas["session (5h)"] = createQuotaObject(toRecord(dataObj.five_hour));
|
||||
}
|
||||
|
||||
if (hasUtilization(toRecord(dataObj.seven_day))) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(toRecord(dataObj.seven_day));
|
||||
}
|
||||
|
||||
// Check for model-specific quotas
|
||||
for (const [key, value] of Object.entries(dataObj)) {
|
||||
const valueRecord = toRecord(value);
|
||||
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) {
|
||||
const modelName = key.replace("seven_day_", "");
|
||||
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length > 0) {
|
||||
const userRecord = toRecord(dataObj.user);
|
||||
const membershipLevel = toRecord(userRecord.membership).level;
|
||||
const planName = getKimiPlanName(membershipLevel);
|
||||
return {
|
||||
plan: planName || "Kimi Coding",
|
||||
quotas,
|
||||
};
|
||||
}
|
||||
|
||||
// No quota data in response
|
||||
const userRecord = toRecord(dataObj.user);
|
||||
const membershipLevel = toRecord(userRecord.membership).level;
|
||||
const planName = getKimiPlanName(membershipLevel);
|
||||
return {
|
||||
plan: planName || "Kimi Coding",
|
||||
message: "Kimi Coding connected. Usage tracked per request.",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
message: `Kimi Coding connected. Unable to fetch usage: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen Usage
|
||||
*/
|
||||
|
||||
216
open-sse/services/usage/claude.ts
Normal file
216
open-sse/services/usage/claude.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* usage/claude.ts — Claude (Anthropic OAuth + legacy org) usage fetcher + plan-label helper.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the Claude family — the API
|
||||
* config, the plan-label picker, the OAuth usage fetcher (getClaudeUsage) with its legacy
|
||||
* settings/org fallback (getClaudeUsageLegacy). Depends only on the sibling scalar/quota
|
||||
* leaves + Claude identity/cooldown helpers + safePercentage — no host coupling — so it
|
||||
* lives as a co-located provider leaf. usage.ts imports getClaudeUsage (dispatcher) +
|
||||
* getClaudePlanLabel (__testing). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { safePercentage } from "@/shared/utils/formatting";
|
||||
import { CLAUDE_CODE_VERSION, fetchClaudeBootstrap } from "../../executors/claudeIdentity.ts";
|
||||
import { isClaudeOauthUsageCoolingDown, markClaudeOauthUsage429 } from "../claudeUsageCooldown.ts";
|
||||
import { toRecord } from "./scalars.ts";
|
||||
import { type UsageQuota, parseResetTime } from "./quota.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
// Claude API config
|
||||
const CLAUDE_CONFIG = {
|
||||
oauthUsageUrl: "https://api.anthropic.com/api/oauth/usage",
|
||||
usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
|
||||
settingsUrl: "https://api.anthropic.com/v1/settings",
|
||||
apiVersion: "2023-06-01",
|
||||
};
|
||||
|
||||
export function getClaudePlanLabel(...candidates: Array<string | null | undefined>): string | null {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.toLowerCase() === "claude code" ||
|
||||
trimmed.toLowerCase() === "unknown"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Usage - Try to fetch from Anthropic API
|
||||
*/
|
||||
export async function getClaudeUsage(accessToken?: string) {
|
||||
if (!accessToken) {
|
||||
return { message: "Claude connected. Access token not available.", bootstrap: null };
|
||||
}
|
||||
|
||||
// Refresh bootstrap in parallel; best-effort, failure non-fatal.
|
||||
const bootstrapPromise = fetchClaudeBootstrap(accessToken).catch(() => null);
|
||||
// Skip OAuth usage call while this token is cooling down from a recent 429
|
||||
// (chat with the same token still works — only the quota endpoint is throttled).
|
||||
if (isClaudeOauthUsageCoolingDown(accessToken)) {
|
||||
const legacy = await getClaudeUsageLegacy(accessToken);
|
||||
return { ...legacy, bootstrap: await bootstrapPromise };
|
||||
}
|
||||
try {
|
||||
// Real CLI uses axios here, not Stainless — UA is `claude-code/<version>`
|
||||
// (not `claude-cli/...`) and the shape is simpler than /v1/messages.
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 10_000);
|
||||
let oauthResponse;
|
||||
try {
|
||||
oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
"Accept-Encoding": "gzip, compress, deflate, br",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `claude-code/${CLAUDE_CODE_VERSION}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (oauthResponse.ok) {
|
||||
const data = toRecord(await oauthResponse.json());
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
// utilization = percentage USED (e.g., 90 means 90% used, 10% remaining)
|
||||
// Confirmed via user report #299: Claude.ai shows 87% used = OmniRoute must show 13% remaining.
|
||||
const hasUtilization = (window: JsonRecord) =>
|
||||
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
|
||||
|
||||
const createQuotaObject = (window: JsonRecord) => {
|
||||
const used = safePercentage(window.utilization) as number; // utilization = % used
|
||||
const remaining = Math.max(0, 100 - used);
|
||||
return {
|
||||
used,
|
||||
total: 100,
|
||||
remaining,
|
||||
resetAt: parseResetTime(window.resets_at),
|
||||
remainingPercentage: remaining,
|
||||
unlimited: false,
|
||||
};
|
||||
};
|
||||
|
||||
const fiveHour = toRecord(data.five_hour);
|
||||
if (hasUtilization(fiveHour)) {
|
||||
quotas["session (5h)"] = createQuotaObject(fiveHour);
|
||||
}
|
||||
|
||||
const sevenDay = toRecord(data.seven_day);
|
||||
if (hasUtilization(sevenDay)) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(sevenDay);
|
||||
}
|
||||
|
||||
// Map Anthropic's internal codenames (e.g., omelette → Designer) for display.
|
||||
const MODEL_DISPLAY_NAMES: Record<string, string> = {
|
||||
omelette: "designer",
|
||||
};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const valueRecord = toRecord(value);
|
||||
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) {
|
||||
const codename = key.replace("seven_day_", "");
|
||||
const modelName = MODEL_DISPLAY_NAMES[codename] || codename;
|
||||
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord);
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrap = await bootstrapPromise;
|
||||
const plan =
|
||||
getClaudePlanLabel(
|
||||
typeof data.tier === "string" ? data.tier : null,
|
||||
typeof data.plan === "string" ? data.plan : null,
|
||||
typeof data.subscription_type === "string" ? data.subscription_type : null,
|
||||
bootstrap?.organization_rate_limit_tier
|
||||
) ?? undefined;
|
||||
|
||||
return {
|
||||
...(plan ? { plan } : {}),
|
||||
quotas,
|
||||
extraUsage: data.extra_usage ?? null,
|
||||
bootstrap,
|
||||
};
|
||||
}
|
||||
|
||||
// Cool down OAuth usage polling after a 429 (quota endpoint only — chat is unaffected).
|
||||
if (oauthResponse.status === 429) {
|
||||
markClaudeOauthUsage429(accessToken);
|
||||
}
|
||||
|
||||
// Fallback: OAuth endpoint returned non-OK, try legacy settings/org endpoint
|
||||
console.warn(
|
||||
`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`
|
||||
);
|
||||
const legacy = await getClaudeUsageLegacy(accessToken);
|
||||
return { ...legacy, bootstrap: await bootstrapPromise };
|
||||
} catch (error) {
|
||||
return {
|
||||
message: `Claude connected. Unable to fetch usage: ${(error as Error).message}`,
|
||||
bootstrap: await bootstrapPromise,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Claude usage fetcher for API key / org admin users.
|
||||
* Uses /v1/settings + /v1/organizations/{org_id}/usage endpoints.
|
||||
*/
|
||||
async function getClaudeUsageLegacy(accessToken?: string) {
|
||||
try {
|
||||
const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
});
|
||||
|
||||
if (settingsResponse.ok) {
|
||||
const settings = toRecord(await settingsResponse.json());
|
||||
|
||||
const organizationId =
|
||||
typeof settings.organization_id === "string" ? settings.organization_id : "";
|
||||
if (organizationId) {
|
||||
const usageResponse = await fetch(
|
||||
CLAUDE_CONFIG.usageUrl.replace("{org_id}", organizationId),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (usageResponse.ok) {
|
||||
const usage = await usageResponse.json();
|
||||
return {
|
||||
plan: settings.plan || "Unknown",
|
||||
organization: settings.organization_name,
|
||||
quotas: usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plan: settings.plan || "Unknown",
|
||||
organization: settings.organization_name,
|
||||
message: "Claude connected. Usage details require admin access.",
|
||||
};
|
||||
}
|
||||
|
||||
return { message: "Claude connected. Usage API requires admin permissions." };
|
||||
} catch (error) {
|
||||
return { message: `Claude connected. Unable to fetch usage: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
70
open-sse/services/usage/codex.ts
Normal file
70
open-sse/services/usage/codex.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* usage/codex.ts — Codex (OpenAI / ChatGPT backend) usage fetcher.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the Codex family — the ChatGPT
|
||||
* backend usage-API config and the getCodexUsage fetcher that reads the persisted workspace
|
||||
* binding and shapes quotas via buildCodexUsageQuotas. Depends only on the scalar leaf +
|
||||
* codexUsageQuotas — no host coupling — so it lives as a co-located provider leaf. usage.ts
|
||||
* imports getCodexUsage (dispatcher). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { buildCodexUsageQuotas } from "../codexUsageQuotas.ts";
|
||||
import { getFieldValue } from "./scalars.ts";
|
||||
|
||||
// Codex (OpenAI) API config
|
||||
const CODEX_CONFIG = {
|
||||
usageUrl: "https://chatgpt.com/backend-api/wham/usage",
|
||||
};
|
||||
|
||||
/**
|
||||
* Codex (OpenAI) Usage - Fetch from ChatGPT backend API
|
||||
* IMPORTANT: Uses persisted workspaceId from OAuth to ensure correct workspace binding.
|
||||
* No fallback to other workspaces - strict binding to user's selected workspace.
|
||||
*/
|
||||
export async function getCodexUsage(
|
||||
accessToken?: string,
|
||||
providerSpecificData: Record<string, unknown> = {}
|
||||
) {
|
||||
try {
|
||||
// Use persisted workspace ID from OAuth - NO FALLBACK
|
||||
const accountId =
|
||||
typeof providerSpecificData.workspaceId === "string"
|
||||
? providerSpecificData.workspaceId
|
||||
: null;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (accountId) {
|
||||
headers["chatgpt-account-id"] = accountId;
|
||||
}
|
||||
|
||||
const response = await fetch(CODEX_CONFIG.usageUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: `Codex token expired or access denied. Please re-authenticate the connection.`,
|
||||
};
|
||||
}
|
||||
throw new Error(`Codex API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const { rateLimit, quotas } = buildCodexUsageQuotas(data);
|
||||
|
||||
return {
|
||||
plan: String(getFieldValue(data, "plan_type", "planType") || "unknown"),
|
||||
limitReached: Boolean(getFieldValue(rateLimit, "limit_reached", "limitReached")),
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `Failed to fetch Codex usage: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
161
open-sse/services/usage/cursor.ts
Normal file
161
open-sse/services/usage/cursor.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* usage/cursor.ts — Cursor (Pro) usage fetcher + JWT/config helpers.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the Cursor family — the
|
||||
* dashboard usage-API config, the WorkOS JWT `sub` decoder, and the getCursorUsage fetcher
|
||||
* that probes the cursor.com/dashboard/spending endpoint. Depends only on the sibling
|
||||
* scalar/quota leaves — no host coupling — so it lives as a co-located provider leaf.
|
||||
* usage.ts imports getCursorUsage (dispatcher). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { toRecord, toNumber, clampPercentage } from "./scalars.ts";
|
||||
import { type UsageQuota, parseResetTime } from "./quota.ts";
|
||||
|
||||
// Cursor dashboard usage API config
|
||||
// The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS
|
||||
// session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and
|
||||
// rejects requests without a matching Origin/Referer (Invalid origin for state-changing request).
|
||||
const CURSOR_USAGE_CONFIG = {
|
||||
usageUrl: "https://cursor.com/api/dashboard/get-current-period-usage",
|
||||
origin: "https://cursor.com",
|
||||
referer: "https://cursor.com/dashboard/spending",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode the `sub` claim of a Cursor JWT (the WorkOS user id).
|
||||
* Returns null if the token is not a parseable JWT.
|
||||
*/
|
||||
function decodeCursorJwtSub(token: string): string | null {
|
||||
if (!token || typeof token !== "string") return null;
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (payload.length % 4 !== 0) payload += "=";
|
||||
const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||
const sub = decoded?.sub;
|
||||
return typeof sub === "string" && sub.length > 0 ? sub : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor Pro Plan Usage
|
||||
* Fetches current-billing-cycle spend from the cursor.com dashboard API and exposes three
|
||||
* windows that mirror the cursor.com/dashboard/spending UI: Total / Auto + Composer / API.
|
||||
*/
|
||||
export async function getCursorUsage(accessToken: string, providerSpecificData?: unknown) {
|
||||
if (!accessToken) {
|
||||
return { message: "Cursor access token missing. Re-import the connection from Cursor IDE." };
|
||||
}
|
||||
|
||||
const storedUserId = (() => {
|
||||
const raw = toRecord(providerSpecificData).userId;
|
||||
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
||||
})();
|
||||
const userId = storedUserId || decodeCursorJwtSub(accessToken);
|
||||
|
||||
if (!userId) {
|
||||
return {
|
||||
message: "Cursor token missing user id. Re-import the connection from Cursor IDE.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(CURSOR_USAGE_CONFIG.usageUrl, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
Cookie: `WorkosCursorSessionToken=${userId}::${accessToken}`,
|
||||
Origin: CURSOR_USAGE_CONFIG.origin,
|
||||
Referer: CURSOR_USAGE_CONFIG.referer,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": CURSOR_USAGE_CONFIG.userAgent,
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
// 3xx redirect to WorkOS authkit means the session cookie was rejected.
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
return {
|
||||
plan: "Cursor",
|
||||
message: "Cursor session expired. Re-import the token from Cursor IDE.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = (await response.text()).slice(0, 200);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
plan: "Cursor",
|
||||
message: "Cursor session unauthorized. Re-import the token from Cursor IDE.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
plan: "Cursor",
|
||||
message: `Cursor usage endpoint error (${response.status}): ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = toRecord(await response.json());
|
||||
const planUsage = toRecord(data.planUsage);
|
||||
|
||||
if (Object.keys(planUsage).length === 0) {
|
||||
return {
|
||||
plan: "Cursor",
|
||||
message: "Cursor connected. No active plan usage returned.",
|
||||
};
|
||||
}
|
||||
|
||||
const limitCents = Math.max(0, toNumber(planUsage.limit, 0));
|
||||
const totalSpendCents = Math.max(0, toNumber(planUsage.totalSpend, 0));
|
||||
const autoPercentUsed = clampPercentage(toNumber(planUsage.autoPercentUsed, 0));
|
||||
const apiPercentUsed = clampPercentage(toNumber(planUsage.apiPercentUsed, 0));
|
||||
const totalPercentUsed = clampPercentage(toNumber(planUsage.totalPercentUsed, 0));
|
||||
|
||||
// billingCycleEnd is a numeric-string in ms; coerce so parseResetTime sees a number.
|
||||
const billingCycleEndMs = toNumber(data.billingCycleEnd, 0);
|
||||
const resetAt = billingCycleEndMs > 0 ? parseResetTime(billingCycleEndMs) : null;
|
||||
|
||||
// Convert cents → dollars rounded to 2 decimal places.
|
||||
const toDollars = (cents: number) => Math.round(cents) / 100;
|
||||
|
||||
const limitDollars = toDollars(limitCents);
|
||||
const buildWindow = (percentUsed: number, usedCentsOverride?: number): UsageQuota => {
|
||||
const usedCents =
|
||||
typeof usedCentsOverride === "number"
|
||||
? usedCentsOverride
|
||||
: Math.round((limitCents * percentUsed) / 100);
|
||||
const used = toDollars(Math.min(usedCents, limitCents));
|
||||
const remaining = toDollars(Math.max(limitCents - Math.min(usedCents, limitCents), 0));
|
||||
return {
|
||||
used,
|
||||
total: limitDollars,
|
||||
remaining,
|
||||
remainingPercentage: clampPercentage(100 - percentUsed),
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
};
|
||||
};
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {
|
||||
Total: buildWindow(totalPercentUsed, totalSpendCents),
|
||||
"Auto + Composer": buildWindow(autoPercentUsed),
|
||||
API: buildWindow(apiPercentUsed),
|
||||
};
|
||||
|
||||
return {
|
||||
plan: "Cursor Pro",
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
plan: "Cursor",
|
||||
message: `Cursor connected. Unable to fetch usage: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
209
open-sse/services/usage/kimi.ts
Normal file
209
open-sse/services/usage/kimi.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* usage/kimi.ts — Kimi Coding (kimi-coding / kimi-coding-apikey) usage fetcher + helpers.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the Kimi family — the coding
|
||||
* API config, membership-level → display-name mapping, and the getKimiUsage fetcher that
|
||||
* probes the official /v1/usages endpoint. Depends only on the sibling scalar/quota leaves
|
||||
* plus safePercentage — no host coupling — so it lives as a co-located provider leaf.
|
||||
* usage.ts imports getKimiUsage (dispatcher). Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { safePercentage } from "@/shared/utils/formatting";
|
||||
import { toRecord, toNumber } from "./scalars.ts";
|
||||
import { type UsageQuota, parseResetTime } from "./quota.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
// Kimi Coding API config
|
||||
const KIMI_CONFIG = {
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
usageUrl: "https://api.kimi.com/coding/v1/usages",
|
||||
apiVersion: "2023-06-01",
|
||||
};
|
||||
|
||||
/**
|
||||
* Map Kimi membership level to display name
|
||||
* LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto,
|
||||
* LEVEL_ADVANCED = Allegro, LEVEL_STANDARD = Vivace
|
||||
*/
|
||||
function getKimiPlanName(level: unknown): string {
|
||||
if (!level) return "";
|
||||
const normalizedLevel = String(level);
|
||||
|
||||
const levelMap = {
|
||||
LEVEL_BASIC: "Moderato",
|
||||
LEVEL_INTERMEDIATE: "Allegretto",
|
||||
LEVEL_ADVANCED: "Allegro",
|
||||
LEVEL_STANDARD: "Vivace",
|
||||
};
|
||||
|
||||
return (
|
||||
levelMap[normalizedLevel as keyof typeof levelMap] ||
|
||||
normalizedLevel.replace("LEVEL_", "").toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kimi Coding Usage - Fetch quota from Kimi API
|
||||
* Uses the official /v1/usages endpoint with custom X-Msh-* headers
|
||||
*/
|
||||
export async function getKimiUsage(accessToken?: string, apiKey?: string) {
|
||||
// Generate device info for headers (same as OAuth flow)
|
||||
const deviceId = "kimi-usage-" + Date.now();
|
||||
const platform = "omniroute";
|
||||
const version = "2.1.2";
|
||||
const deviceModel =
|
||||
typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown";
|
||||
|
||||
// API key auth takes precedence — Kimi's /usages endpoint accepts the same
|
||||
// API key used for /messages (verified live: responds with
|
||||
// authentication.method = METHOD_API_KEY). OAuth flow falls through to the
|
||||
// Bearer + device-headers shape used by Kimi Coding OAuth.
|
||||
const useApiKey = typeof apiKey === "string" && apiKey.length > 0;
|
||||
|
||||
const authHeaders: Record<string, string> = useApiKey
|
||||
? { "x-api-key": apiKey as string }
|
||||
: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"X-Msh-Platform": platform,
|
||||
"X-Msh-Version": version,
|
||||
"X-Msh-Device-Model": deviceModel,
|
||||
"X-Msh-Device-Id": deviceId,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(KIMI_CONFIG.usageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...authHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
plan: "Kimi Coding",
|
||||
message: `Kimi Coding connected. API Error ${response.status}: ${responseText.slice(0, 100)}`,
|
||||
};
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch {
|
||||
return {
|
||||
plan: "Kimi Coding",
|
||||
message: "Kimi Coding connected. Invalid JSON response from API.",
|
||||
};
|
||||
}
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
const dataObj = toRecord(data);
|
||||
|
||||
// Parse Kimi usage response format
|
||||
// Format: { user: {...}, usage: { limit: "100", used: "92", remaining: "8", resetTime: "..." }, limits: [...] }
|
||||
const usageObj = toRecord(dataObj.usage);
|
||||
|
||||
// Check for Kimi's actual usage fields (strings, not numbers)
|
||||
const usageLimit = toNumber(usageObj.limit || usageObj.Limit, 0);
|
||||
const usageUsed = toNumber(usageObj.used || usageObj.Used, 0);
|
||||
const usageRemaining = toNumber(usageObj.remaining || usageObj.Remaining, 0);
|
||||
const usageResetTime =
|
||||
usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt;
|
||||
|
||||
if (usageLimit > 0) {
|
||||
const percentRemaining = usageLimit > 0 ? (usageRemaining / usageLimit) * 100 : 0;
|
||||
|
||||
quotas["Weekly"] = {
|
||||
used: usageUsed,
|
||||
total: usageLimit,
|
||||
remaining: usageRemaining,
|
||||
remainingPercentage: percentRemaining,
|
||||
resetAt: parseResetTime(usageResetTime),
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Also parse limits array for rate limits
|
||||
const limitsArray = Array.isArray(dataObj.limits) ? dataObj.limits : [];
|
||||
for (let i = 0; i < limitsArray.length; i++) {
|
||||
const limitItem = toRecord(limitsArray[i]);
|
||||
const window = toRecord(limitItem.window);
|
||||
const detail = toRecord(limitItem.detail);
|
||||
|
||||
const limit = toNumber(detail.limit || detail.Limit, 0);
|
||||
const remaining = toNumber(detail.remaining || detail.Remaining, 0);
|
||||
const resetTime = detail.resetTime || detail.reset_at || detail.resetAt;
|
||||
|
||||
if (limit > 0) {
|
||||
quotas["Ratelimit"] = {
|
||||
used: limit - remaining,
|
||||
total: limit,
|
||||
remaining,
|
||||
remainingPercentage: limit > 0 ? (remaining / limit) * 100 : 0,
|
||||
resetAt: parseResetTime(resetTime),
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for quota windows (Claude-like format with utilization) as fallback
|
||||
const hasUtilization = (window: JsonRecord) =>
|
||||
window && typeof window === "object" && safePercentage(window.utilization) !== undefined;
|
||||
|
||||
const createQuotaObject = (window: JsonRecord) => {
|
||||
const remaining = safePercentage(window.utilization) as number;
|
||||
const used = 100 - remaining;
|
||||
return {
|
||||
used,
|
||||
total: 100,
|
||||
remaining,
|
||||
resetAt: parseResetTime(window.resets_at),
|
||||
remainingPercentage: remaining,
|
||||
unlimited: false,
|
||||
};
|
||||
};
|
||||
|
||||
if (hasUtilization(toRecord(dataObj.five_hour))) {
|
||||
quotas["session (5h)"] = createQuotaObject(toRecord(dataObj.five_hour));
|
||||
}
|
||||
|
||||
if (hasUtilization(toRecord(dataObj.seven_day))) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(toRecord(dataObj.seven_day));
|
||||
}
|
||||
|
||||
// Check for model-specific quotas
|
||||
for (const [key, value] of Object.entries(dataObj)) {
|
||||
const valueRecord = toRecord(value);
|
||||
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) {
|
||||
const modelName = key.replace("seven_day_", "");
|
||||
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length > 0) {
|
||||
const userRecord = toRecord(dataObj.user);
|
||||
const membershipLevel = toRecord(userRecord.membership).level;
|
||||
const planName = getKimiPlanName(membershipLevel);
|
||||
return {
|
||||
plan: planName || "Kimi Coding",
|
||||
quotas,
|
||||
};
|
||||
}
|
||||
|
||||
// No quota data in response
|
||||
const userRecord = toRecord(dataObj.user);
|
||||
const membershipLevel = toRecord(userRecord.membership).level;
|
||||
const planName = getKimiPlanName(membershipLevel);
|
||||
return {
|
||||
plan: planName || "Kimi Coding",
|
||||
message: "Kimi Coding connected. Usage tracked per request.",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
message: `Kimi Coding connected. Unable to fetch usage: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
243
open-sse/services/usage/kiro.ts
Normal file
243
open-sse/services/usage/kiro.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* usage/kiro.ts — Kiro / Amazon Q (AWS CodeWhisperer) usage fetcher + quota helpers.
|
||||
*
|
||||
* Extracted from services/usage.ts (god-file decomposition): the Kiro family — overage
|
||||
* detection, per-resource quota assembly (buildKiroQuota / buildKiroUsageResult), region-aware
|
||||
* profile-ARN discovery, the social-auth account marker, and the getKiroUsage fetcher that
|
||||
* calls GetUsageLimits on the region-matched CodeWhisperer endpoint. Depends only on the
|
||||
* sibling scalar/quota leaves — no host coupling — so it lives as a co-located provider leaf.
|
||||
* usage.ts imports getKiroUsage (dispatcher) + re-exports buildKiroUsageResult /
|
||||
* discoverKiroProfileArn (external kiro tests import them from services/usage) and pulls
|
||||
* getKiroUsage into __testing. Behavior-preserving move.
|
||||
*/
|
||||
|
||||
import { toRecord, toNumber } from "./scalars.ts";
|
||||
import { type UsageQuota, parseResetTime } from "./quota.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const CODEWHISPERER_BASE_URL =
|
||||
process.env.OMNIROUTE_CODEWHISPERER_BASE_URL ?? "https://codewhisperer.us-east-1.amazonaws.com";
|
||||
|
||||
function isKiroOverageEnabled(data: JsonRecord): boolean {
|
||||
const overageConfiguration = toRecord(data.overageConfiguration);
|
||||
const overageStatus = String(overageConfiguration.overageStatus || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
overageStatus === "ENABLED" ||
|
||||
data.overageEnabled === true ||
|
||||
overageConfiguration.overageEnabled === true
|
||||
);
|
||||
}
|
||||
|
||||
function buildKiroQuota(
|
||||
used: number,
|
||||
total: number,
|
||||
resetAt: string | null,
|
||||
overageEnabled: boolean
|
||||
): UsageQuota {
|
||||
const remaining = total - used;
|
||||
|
||||
if (!overageEnabled) {
|
||||
return { used, total, remaining, resetAt, unlimited: false };
|
||||
}
|
||||
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage: 100,
|
||||
resetAt,
|
||||
unlimited: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Kiro usage result from a GetUsageLimits response. When the account returns no
|
||||
* usage breakdown (some AWS IAM / Builder ID accounts don't expose per-resource quota via
|
||||
* GetUsageLimits), return an informative message instead of empty `quotas:{}` — otherwise the
|
||||
* dashboard renders a blank quota card with no explanation (#3506). Exported for testing.
|
||||
*/
|
||||
export function buildKiroUsageResult(
|
||||
data: JsonRecord
|
||||
): { plan: string; quotas: Record<string, UsageQuota> } | { message: string } {
|
||||
const usageList = Array.isArray(data.usageBreakdownList) ? data.usageBreakdownList : [];
|
||||
const quotaInfo: Record<string, UsageQuota> = {};
|
||||
const resetAt = parseResetTime(data.nextDateReset || data.resetDate);
|
||||
const overageEnabled = isKiroOverageEnabled(data);
|
||||
|
||||
usageList.forEach((breakdownValue: unknown) => {
|
||||
const breakdown = toRecord(breakdownValue);
|
||||
const resourceType =
|
||||
typeof breakdown.resourceType === "string" ? breakdown.resourceType.toLowerCase() : "unknown";
|
||||
const used = toNumber(breakdown.currentUsageWithPrecision, 0);
|
||||
const total = toNumber(breakdown.usageLimitWithPrecision, 0);
|
||||
|
||||
quotaInfo[resourceType] = buildKiroQuota(used, total, resetAt, overageEnabled);
|
||||
|
||||
const freeTrialInfo = toRecord(breakdown.freeTrialInfo);
|
||||
if (Object.keys(freeTrialInfo).length > 0) {
|
||||
const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0);
|
||||
const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0);
|
||||
quotaInfo[`${resourceType}_freetrial`] = buildKiroQuota(
|
||||
freeUsed,
|
||||
freeTotal,
|
||||
resetAt,
|
||||
overageEnabled
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(quotaInfo).length === 0) {
|
||||
return {
|
||||
message:
|
||||
"Kiro connected, but the account returned no usage breakdown. Some AWS IAM / Builder ID accounts don't expose per-resource quota via GetUsageLimits.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plan: String(toRecord(data.subscriptionInfo).subscriptionTitle || "").trim() || "Kiro",
|
||||
quotas: quotaInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover a Kiro/CodeWhisperer profile ARN for an account that didn't persist one (common for
|
||||
* AWS IAM Identity Center logins and kiro-cli imports). Calls ListAvailableProfiles on the
|
||||
* region-matched endpoint and prefers a profile whose ARN is in the same region. Returns
|
||||
* undefined when no profile is available (e.g. the org/token has no Kiro entitlement).
|
||||
* Exported for testing.
|
||||
*/
|
||||
export async function discoverKiroProfileArn(
|
||||
accessToken: string,
|
||||
usageBaseUrl: string,
|
||||
region: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(usageBaseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/x-amz-json-1.0",
|
||||
"x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ maxResults: 10 }),
|
||||
// Don't let a hung profile lookup block the usage/quota refresh indefinitely.
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const data = toRecord(await response.json());
|
||||
const profiles = Array.isArray(data.profiles) ? data.profiles : [];
|
||||
const normalizedRegion = region.toLowerCase();
|
||||
const matched =
|
||||
profiles.find((profile: unknown) => {
|
||||
const arn = toRecord(profile).arn;
|
||||
return typeof arn === "string" && arn.toLowerCase().includes(`:${normalizedRegion}:`);
|
||||
}) || profiles[0];
|
||||
const arn = toRecord(matched).arn;
|
||||
return typeof arn === "string" && arn.length > 0 ? arn : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiro (AWS CodeWhisperer) Usage
|
||||
*/
|
||||
export async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
|
||||
try {
|
||||
let profileArn =
|
||||
typeof providerSpecificData?.profileArn === "string"
|
||||
? providerSpecificData.profileArn
|
||||
: undefined;
|
||||
|
||||
// Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and
|
||||
// endpoint must all match the region. Derive the region from the stored region (preferred)
|
||||
// or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the
|
||||
// legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions).
|
||||
const regionFromArn = profileArn
|
||||
? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1]
|
||||
: undefined;
|
||||
const region =
|
||||
(typeof providerSpecificData?.region === "string" &&
|
||||
providerSpecificData.region.trim().toLowerCase()) ||
|
||||
regionFromArn ||
|
||||
"us-east-1";
|
||||
const usageBaseUrl =
|
||||
region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`;
|
||||
|
||||
// IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which
|
||||
// previously caused the quota card to show nothing ("0 used"). Discover it on demand from
|
||||
// ListAvailableProfiles (region-matched) so usage still resolves for those accounts.
|
||||
if (!profileArn && accessToken) {
|
||||
profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region);
|
||||
}
|
||||
|
||||
if (!profileArn) {
|
||||
return { message: "Kiro connected. Profile ARN not available for quota tracking." };
|
||||
}
|
||||
|
||||
// Kiro uses AWS CodeWhisperer GetUsageLimits API
|
||||
const payload = {
|
||||
origin: "AI_EDITOR",
|
||||
profileArn: profileArn,
|
||||
resourceType: "AGENTIC_REQUEST",
|
||||
};
|
||||
|
||||
const response = await fetch(usageBaseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/x-amz-json-1.0",
|
||||
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Social-auth Kiro accounts (added via /api/oauth/kiro/social-exchange with provider
|
||||
// Google or GitHub) use a different token format that AWS CodeWhisperer's GetUsageLimits
|
||||
// routinely rejects with 401/403, even when /messages still works. Surface a clear
|
||||
// "auth expired, chat may still work" message instead of a generic upstream-error blob
|
||||
// so the quota card matches what users with legacy social-auth accounts already see.
|
||||
// Inspired by https://github.com/decolua/9router/pull/620.
|
||||
if (
|
||||
(response.status === 401 || response.status === 403) &&
|
||||
isSocialAuthKiroAccount(providerSpecificData)
|
||||
) {
|
||||
return {
|
||||
message: "Kiro quota API authentication expired. Chat may still work.",
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Kiro API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = toRecord(await response.json());
|
||||
return buildKiroUsageResult(data);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch Kiro usage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Was this Kiro connection added via the Google/GitHub social-auth device flow
|
||||
* (POST /api/oauth/kiro/social-exchange)? That route persists
|
||||
* `{ authMethod: "imported", provider: "Google" | "Github" }` on the connection.
|
||||
* Builder-ID / IDC / kiro-cli imports use different markers and should keep the
|
||||
* existing throw-on-failure behavior.
|
||||
*/
|
||||
function isSocialAuthKiroAccount(providerSpecificData?: JsonRecord): boolean {
|
||||
if (!providerSpecificData || providerSpecificData.authMethod !== "imported") return false;
|
||||
const provider =
|
||||
typeof providerSpecificData.provider === "string"
|
||||
? providerSpecificData.provider.toLowerCase()
|
||||
: "";
|
||||
return provider === "google" || provider === "github";
|
||||
}
|
||||
62
tests/unit/usage-families-split.test.ts
Normal file
62
tests/unit/usage-families-split.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Characterization of the services/usage.ts family split (god-file decomposition): the Cursor,
|
||||
// Kimi, Codex, Claude, and Kiro usage families moved out of services/usage.ts into
|
||||
// services/usage/<family>.ts leaves (mirroring the earlier glm/minimax/antigravity leaves).
|
||||
// Behavior-preserving move; the locks pin each leaf's exported surface, the Claude plan-label
|
||||
// helper's pure logic, that usage.ts re-exports buildKiroUsageResult + discoverKiroProfileArn
|
||||
// (the kiro-* tests import them from services/usage), and that __testing stays wired to the leaves.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const CURSOR = await import("../../open-sse/services/usage/cursor.ts");
|
||||
const KIMI = await import("../../open-sse/services/usage/kimi.ts");
|
||||
const CODEX = await import("../../open-sse/services/usage/codex.ts");
|
||||
const CLAUDE = await import("../../open-sse/services/usage/claude.ts");
|
||||
const KIRO = await import("../../open-sse/services/usage/kiro.ts");
|
||||
const HOST = await import("../../open-sse/services/usage.ts");
|
||||
|
||||
const kind = (m: unknown, k: string) => typeof (m as Record<string, unknown>)[k];
|
||||
|
||||
test("each family leaf exposes its usage fetcher(s)", () => {
|
||||
assert.equal(kind(CURSOR, "getCursorUsage"), "function");
|
||||
assert.equal(kind(KIMI, "getKimiUsage"), "function");
|
||||
assert.equal(kind(CODEX, "getCodexUsage"), "function");
|
||||
assert.equal(kind(CLAUDE, "getClaudeUsage"), "function");
|
||||
assert.equal(kind(CLAUDE, "getClaudePlanLabel"), "function");
|
||||
assert.equal(kind(KIRO, "getKiroUsage"), "function");
|
||||
assert.equal(kind(KIRO, "buildKiroUsageResult"), "function");
|
||||
assert.equal(kind(KIRO, "discoverKiroProfileArn"), "function");
|
||||
});
|
||||
|
||||
test("host re-exports the kiro symbols the kiro-* tests import, with the same identity", () => {
|
||||
assert.equal(
|
||||
(HOST as Record<string, unknown>).buildKiroUsageResult,
|
||||
(KIRO as Record<string, unknown>).buildKiroUsageResult
|
||||
);
|
||||
assert.equal(
|
||||
(HOST as Record<string, unknown>).discoverKiroProfileArn,
|
||||
(KIRO as Record<string, unknown>).discoverKiroProfileArn
|
||||
);
|
||||
});
|
||||
|
||||
test("host __testing stays wired to the moved claude/kiro internals", () => {
|
||||
const testing = (HOST as Record<string, Record<string, unknown>>).__testing;
|
||||
assert.equal(testing.getClaudePlanLabel, (CLAUDE as Record<string, unknown>).getClaudePlanLabel);
|
||||
assert.equal(testing.getKiroUsage, (KIRO as Record<string, unknown>).getKiroUsage);
|
||||
});
|
||||
|
||||
test("claude getClaudePlanLabel picks the first meaningful candidate, skipping placeholders", () => {
|
||||
assert.equal(CLAUDE.getClaudePlanLabel("Pro"), "Pro");
|
||||
assert.equal(CLAUDE.getClaudePlanLabel(" Max "), "Max");
|
||||
assert.equal(CLAUDE.getClaudePlanLabel("claude code", "Team"), "Team");
|
||||
assert.equal(CLAUDE.getClaudePlanLabel("unknown", null, "Enterprise"), "Enterprise");
|
||||
assert.equal(CLAUDE.getClaudePlanLabel(null, undefined, ""), null);
|
||||
assert.equal(CLAUDE.getClaudePlanLabel(), null);
|
||||
});
|
||||
|
||||
test("host dispatcher + USAGE_FETCHER_PROVIDERS still cover the moved families", () => {
|
||||
assert.equal(kind(HOST, "getUsageForProvider"), "function");
|
||||
const providers = (HOST as Record<string, unknown>).USAGE_FETCHER_PROVIDERS as readonly string[];
|
||||
for (const p of ["cursor", "codex", "claude", "kiro", "kimi-coding"]) {
|
||||
assert.ok(providers.includes(p), `${p} must remain a usage-fetcher provider`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user