Something went wrong. Try again.
Something went wrong. Try again.
7.6 kB · 190 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191/** * Provider usage capability. * * A provider-agnostic view of "how much of my budget have I burned", * surfaced to clients via `GET /v1/usage?model=<id>`. Each provider that * has an account/rate-limit concept implements a fetcher; the endpoint * resolves a model to its backing provider and delegates. * * Only providers listed in USAGE_PROVIDERS are implemented. Others resolve * but report unsupported, so clients can degrade gracefully. */
import type { BridgeConfig, CustomProvider } from "./config"import type { ProviderAccount } from "./accounts"import { accountCredentialPath } from "./accounts"import { readCodexCredential } from "./codex-auth"import { readOAuthCredential } from "./anthropic-oauth"import { readAntigravityCredential } from "./antigravity-auth"import { readOpenCodeCredential } from "./opencode-auth"import { fetchAnthropicUsage } from "./anthropic"import { fetchCodexUsage, getCachedCodexUsage } from "./codex-usage"import { getCodexAccessToken, resolveCodexAuthPath } from "./codex"
export interface ProviderUsage { percent: number display: string stats: Record<string, unknown> /** Explicit fields for dashboards; null means upstream did not provide one. */ used: number | null limit: number | null resetAt: string | null}
/** Providers with a usage fetcher implemented today. */const USAGE_PROVIDERS: Record<string, true> = { "anthropic-oauth": true, // pull: GET /api/oauth/usage codex: true, // pull: /wham/usage, with response-header cache fallback}
export type AccountUsage = | { available: true; usage: ProviderUsage; observedAt: number; stale: boolean } | { available: false; reason: "not-connected" | "not-yet-observed" | "fetch-failed" }
type UsageCacheEntry = { promise?: Promise<AccountUsage>; value: AccountUsage; cachedAt: number }const accountUsageCache = new Map<string, UsageCacheEntry>()const ACCOUNT_USAGE_TTL = 60_000const ACCOUNT_USAGE_FAILURE_TTL = 15_000const ACCOUNT_USAGE_STALE_AFTER = 5 * 60_000/** * Resolve which provider backs a given model id. OpenCode's bare catalog ids * resolve from the registry before the legacy name-prefix inference. */export function resolveProviderName(config: BridgeConfig, modelId: string): string | null { const model = config.models[modelId] if (!model?.protocol && model?.provider === "opencode-go") return model.provider const lower = modelId.toLowerCase() if (lower.startsWith("gemini-")) return "antigravity" if (lower.startsWith("gpt-") || lower.startsWith("o3-") || lower.startsWith("o4-") || lower.startsWith("codex-")) { return "codex" } return config.models[modelId]?.provider ?? null}
/** Whether a resolved provider has a usage fetcher implemented. */export function hasUsageSupport(providerName: string): boolean { return USAGE_PROVIDERS[providerName] === true}
/** * Fetch normalized usage for the provider backing `modelId`. * Returns null when the provider is unconfigured, unsupported, has no cached * data yet (codex), or the fetch itself failed — the endpoint distinguishes. */export async function getUsageForModel( config: BridgeConfig, providerName: string, modelId: string,): Promise<ProviderUsage | null> { switch (providerName) { case "anthropic-oauth": { // Anthropic models are labeled "anthropic-oauth" regardless of the // provider's config key, so derive the credential from the per-model // authPath (falling back to any provider carrying an auth_path). const model = config.models[modelId] const provider: CustomProvider | undefined = model?.authPath ? { auth_path: model.authPath, base_url: model.baseUrl } : Object.values(config.providers).find((p) => p.auth_path) return provider ? fetchAnthropicUsage(provider) : null } case "codex": { try { const authPath = resolveCodexAuthPath(config.providers.codex) return (await fetchCodexUsage(await getCodexAccessToken(authPath))) ?? getCachedCodexUsage(providerName) } catch { return getCachedCodexUsage(providerName) } } default: return null }}
export async function getUsageForCredential( config: BridgeConfig, providerName: string, authPath: string, cacheKey = providerName,): Promise<ProviderUsage | null> { if (providerName === "anthropic-oauth") { return fetchAnthropicUsage({ auth_path: authPath }) } if (providerName === "codex") { try { return (await fetchCodexUsage(await getCodexAccessToken(authPath))) ?? getCachedCodexUsage(cacheKey) } catch { return getCachedCodexUsage(cacheKey) } } const provider = config.providers[providerName] return provider ? getUsageForModel( config, providerName, Object.keys(config.models).find((id) => config.models[id]?.provider === providerName) ?? "", ) : null}
export async function getAccountUsage( config: BridgeConfig, stateDirectory: string, account: ProviderAccount, options: { force?: boolean } = {},): Promise<AccountUsage> { const authPath = accountCredentialPath(stateDirectory, account.id, account.provider) if (!authPath) return { available: false, reason: "not-connected" } try { const connected = account.provider === "anthropic" ? Boolean(readOAuthCredential(authPath)) : account.provider === "codex" ? Boolean(readCodexCredential(authPath)) : account.provider === "opencode-go" ? Boolean(readOpenCodeCredential(authPath, account.provider)) : Boolean(readAntigravityCredential(authPath)) if (!connected) return { available: false, reason: "not-connected" } } catch { return { available: false, reason: "not-connected" } } const cacheKey = `${account.provider}:${account.id}` const existing = accountUsageCache.get(cacheKey) const now = Date.now() if (existing?.promise) return existing.promise if (!options.force && existing) { const ttl = existing.value.available ? ACCOUNT_USAGE_TTL : ACCOUNT_USAGE_FAILURE_TTL if (now - existing.cachedAt < ttl) { if (existing.value.available) return { ...existing.value, stale: now - existing.value.observedAt > ACCOUNT_USAGE_STALE_AFTER } return existing.value } } const promise = (async (): Promise<AccountUsage> => { if (account.provider === "antigravity" || account.provider === "opencode-go") return { available: false, reason: "not-yet-observed" } if (account.provider === "codex") { const usage = await getUsageForCredential(config, "codex", authPath, `codex:${account.id}`) if (!usage) return { available: false, reason: "not-yet-observed" } const observedAt = typeof usage.stats.fetched_at === "number" ? usage.stats.fetched_at : now return { available: true, usage, observedAt, stale: now - observedAt > ACCOUNT_USAGE_STALE_AFTER } } const usage = await getUsageForCredential(config, "anthropic-oauth", authPath) if (!usage) { if (existing?.value.available) return { ...existing.value, stale: true } return { available: false, reason: "fetch-failed" } } return { available: true, usage, observedAt: Date.now(), stale: false } })() accountUsageCache.set(cacheKey, { promise, value: existing?.value ?? { available: false, reason: "fetch-failed" }, cachedAt: now, }) const value = await promise accountUsageCache.set(cacheKey, { value, cachedAt: Date.now() }) return value}
export function clearAccountUsageCache(): void { accountUsageCache.clear()}