diff --git a/packages/server/src/usage/providers/extra.ts b/packages/server/src/usage/providers/extra.ts new file mode 100644 index 00000000..0d8da67a --- /dev/null +++ b/packages/server/src/usage/providers/extra.ts @@ -0,0 +1,413 @@ +import type { ProviderUsage, UsageProvider } from "../types" +import { + asObject, + fetchJson, + getAuthEntry, + getCredential, + getString, + notConfigured, + result, + safeFetch, + toNumber, + toTimestamp, + toUsageWindow, +} from "../shared" + +const formatMoney = (value: number | null): string | null => + value === null || !Number.isFinite(value) ? null : value.toFixed(2) + +// --- command-code --- +type CommandCodeCredits = { + credits?: { monthlyCredits?: number; purchasedCredits?: number; freeCredits?: number } + windowLimits?: { + fiveHour?: { used?: number; cap?: number; resetAt?: number } + weekly?: { used?: number; cap?: number; resetAt?: number } + } +} +const isFiniteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) +const formatCredits = (value: number): string => String(Math.round((value + Number.EPSILON) * 100) / 100) +const parseCredits = (value: unknown): CommandCodeCredits | null => + value && typeof value === "object" ? (value as CommandCodeCredits) : null +const parseOrgId = (value: unknown): string | null | undefined => { + if (!value || typeof value !== "object") return undefined + const org = (value as { org?: { id?: unknown } }).org + return typeof org?.id === "string" && org.id.trim() ? org.id.trim() : null +} +const parseCommandCodeCredits = (payload: CommandCodeCredits) => { + const windows: Record> = {} + for (const [label, value] of [ + ["monthly_credits", payload.credits?.monthlyCredits], + ["purchased_credits", payload.credits?.purchasedCredits], + ["free_credits", payload.credits?.freeCredits], + ] as const) { + if (isFiniteNumber(value)) windows[label] = toUsageWindow({ usedPercent: null, windowSeconds: null, resetAt: null, valueLabel: formatCredits(value) }) + } + for (const [label, limit, seconds] of [ + ["5h", payload.windowLimits?.fiveHour, 5 * 60 * 60], + ["weekly", payload.windowLimits?.weekly, 7 * 24 * 60 * 60], + ] as const) { + if (!isFiniteNumber(limit?.used) || !isFiniteNumber(limit.cap) || limit.cap <= 0) continue + const resetAt = isFiniteNumber(limit.resetAt) ? (limit.resetAt < 1_000_000_000_000 ? limit.resetAt * 1000 : limit.resetAt) : null + windows[label] = toUsageWindow({ + usedPercent: Math.min(100, Math.max(0, (limit.used / limit.cap) * 100)), + windowSeconds: seconds, + resetAt, + valueLabel: `${formatCredits(limit.used)} / ${formatCredits(limit.cap)}`, + }) + } + return windows +} +const requestJson = async (path: string, apiKey: string): Promise => { + const response = await fetch(`https://api.commandcode.ai${path}`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(15_000), + }) + if (response.status === 401 || response.status === 403) throw new Error("Command Code authentication failed") + if (!response.ok) throw new Error(`Command Code usage API returned HTTP ${response.status}`) + return response.json().catch(() => null) +} + +const commandCodeAliases = ["command-code"] as const +const commandCode: UsageProvider = { + id: "command-code", + name: "Command Code", + aliases: commandCodeAliases, + async fetchQuota() { + const key = getCredential(commandCodeAliases, ["key", "access", "token"]) ?? getString(process.env.COMMAND_CODE_API_KEY) + if (!key) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const orgId = parseOrgId(await requestJson("/alpha/whoami", key)) + if (orgId === undefined) throw new Error("Command Code account could not be determined") + const creditsPath = orgId ? `/alpha/billing/credits?orgId=${encodeURIComponent(orgId)}` : "/alpha/billing/credits" + const payload = parseCredits(await requestJson(creditsPath, key)) + if (!payload) throw new Error("Command Code usage data could not be parsed") + const windows = parseCommandCodeCredits(payload) + if (!Object.keys(windows).length) throw new Error("Command Code usage data could not be parsed") + return { windows } + }) + }, +} + +// --- crof --- +const crofAliases = ["crof"] as const +const crof: UsageProvider = { + id: "crof", + name: "CrofAI", + aliases: crofAliases, + async fetchQuota() { + const key = getCredential(crofAliases, ["key", "token"]) + if (!key) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const payload: any = await fetchJson("https://crof.ai/usage_api/", { + headers: { Authorization: `Bearer ${key}`, "Accept-Encoding": "identity" }, + }) + const credits = toNumber(payload?.credits) + return { + windows: { + credits: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: credits !== null ? `$${formatMoney(credits)}` : null, + }), + }, + } + }) + }, +} + +// --- deepseek --- +const deepseekAliases = ["deepseek"] as const +const deepseek: UsageProvider = { + id: "deepseek", + name: "DeepSeek", + aliases: deepseekAliases, + async fetchQuota() { + const key = getCredential(deepseekAliases, ["key", "token"]) + if (!key) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const payload: any = await fetchJson("https://api.deepseek.com/user/balance", { + headers: { Authorization: `Bearer ${key}`, "Accept-Encoding": "identity" }, + }) + const balanceInfos = Array.isArray(payload?.balance_infos) ? payload.balance_infos : [] + const balanceInfo = + balanceInfos.find((info: any) => info?.currency === "USD") ?? + balanceInfos.find((info: any) => info?.currency === "CNY") ?? + null + const rawBalance = balanceInfo?.total_balance + const totalBalance = + typeof rawBalance === "number" || (typeof rawBalance === "string" && rawBalance.trim() !== "") + ? toNumber(rawBalance) + : null + if (totalBalance === null) throw new Error("No quota data in response") + const symbol = balanceInfo?.currency === "CNY" ? "¥" : "$" + return { + windows: { + credits_balance: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `${symbol}${formatMoney(totalBalance)}`, + }), + }, + } + }) + }, +} + +// --- neuralwatt --- +const neuralwattWindowSeconds = (period: string | null | undefined): number | null => { + if (period === "daily") return 86_400 + if (period === "weekly") return 604_800 + if (period === "monthly" || period === "month") return 30 * 86_400 + if (period === "yearly" || period === "year") return 365 * 86_400 + return null +} +const neuralwattAliases = ["neuralwatt"] as const +const neuralwatt: UsageProvider = { + id: "neuralwatt", + name: "NeuralWatt", + aliases: neuralwattAliases, + async fetchQuota() { + const key = getCredential(neuralwattAliases, ["key", "token"]) + if (!key) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const payload: any = await fetchJson("https://api.neuralwatt.com/v1/quota", { + headers: { Authorization: `Bearer ${key}`, "Accept-Encoding": "identity" }, + }) + const subscription = payload?.subscription ?? null + const inOverage = Boolean(subscription?.in_overage) + const allowance = payload?.key?.allowance ?? null + const keyName = payload?.key?.name ?? null + const creditsRemaining = toNumber(payload?.balance?.credits_remaining_usd) + const windows: Record> = {} + if (subscription) { + const kwhIncluded = toNumber(subscription.kwh_included) + const kwhUsed = toNumber(subscription.kwh_used) + const plan = typeof subscription.plan === "string" && subscription.plan.trim() ? subscription.plan.trim() : null + const subKey = plan ?? "plan_limit" + const usedPercent = inOverage + ? 100 + : kwhIncluded !== null && kwhIncluded > 0 && kwhUsed !== null + ? Math.max(0, Math.min(100, (kwhUsed / kwhIncluded) * 100)) + : null + const subResetAt = toTimestamp(subscription.kwh_reset_date) ?? toTimestamp(subscription.current_period_end) + windows[subKey] = toUsageWindow({ usedPercent, windowSeconds: null, resetAt: subResetAt }) + } + if (allowance) { + const spent = toNumber(allowance.spent_usd) + const limit = toNumber(allowance.limit_usd) + const effectiveSpent = spent ?? 0 + const effectiveLimit = + limit !== null && creditsRemaining !== null ? Math.min(limit, creditsRemaining + effectiveSpent) : (limit ?? creditsRemaining) + const period = typeof allowance.period === "string" && allowance.period.trim() ? allowance.period.trim() : null + const blocked = Boolean(allowance.blocked) + const usedPercent = blocked + ? 100 + : spent !== null && effectiveLimit !== null && effectiveLimit > 0 + ? Math.max(0, Math.min(100, (spent / effectiveLimit) * 100)) + : null + const periodKey = + period === "daily" || period === "weekly" || period === "monthly" || period === "month" + ? period === "month" + ? "monthly" + : period + : "billing_cycle" + const labelName = typeof keyName === "string" && keyName.trim() ? keyName.trim() : null + const resetAt = toTimestamp(allowance.reset_at) + const windowSeconds = period ? neuralwattWindowSeconds(period) : null + windows[periodKey] = toUsageWindow({ + usedPercent, + windowSeconds, + resetAt, + ...(labelName ? { valueLabel: labelName } : {}), + }) + } else if (creditsRemaining !== null) { + windows.credits_balance = toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `$${formatMoney(creditsRemaining)}`, + }) + } + if (!Object.keys(windows).length) throw new Error("No quota data in response") + return { windows } + }) + }, +} + +// --- claude --- +const CLAUDE_DEFAULT_COOLDOWN_MS = 5 * 60 * 1000 +const CLAUDE_MAX_COOLDOWN_MS = 60 * 60 * 1000 +let claudeCredentialFingerprint: string | null = null +let claudeCachedUsage: ProviderUsage | null = null +let claudeCooldownUntil = 0 + +function claudeCooldownFromResponse(response: Response): number { + const raw = response.headers.get("retry-after") + const seconds = raw ? Number(raw) : Number.NaN + if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds * 1000, CLAUDE_MAX_COOLDOWN_MS) + if (raw) { + const retryAt = Date.parse(raw) + if (Number.isFinite(retryAt) && retryAt > Date.now()) return Math.min(retryAt - Date.now(), CLAUDE_MAX_COOLDOWN_MS) + } + return CLAUDE_DEFAULT_COOLDOWN_MS +} + +function buildClaudeRateLimitResult(): ProviderUsage | null { + return claudeCachedUsage +} + +function buildClaudeUsage(payload: Record): ProviderUsage { + const windows: Record> = {} + const models: Record = {} + const limits = Array.isArray(payload.limits) ? payload.limits : [] + for (const entry of limits) { + const limit = asObject(entry) + if (!limit) continue + const usedPercent = toNumber(limit.percent) + const resetAt = toTimestamp(limit.resets_at) + if (limit.kind === "session") { + windows["5h"] = toUsageWindow({ usedPercent, windowSeconds: 5 * 60 * 60, resetAt }) + } else if (limit.kind === "weekly_all") { + windows["7d"] = toUsageWindow({ usedPercent, windowSeconds: 7 * 24 * 60 * 60, resetAt }) + } else if (limit.kind === "weekly_scoped") { + const modelName = getString(asObject(asObject(limit.scope)?.model)?.display_name) + if (modelName) { + models[modelName] = { windows: { "7d": toUsageWindow({ usedPercent, windowSeconds: 7 * 24 * 60 * 60, resetAt }) } } + } + } + } + if (!limits.length) { + const fiveHour = asObject(payload.five_hour) + const sevenDay = asObject(payload.seven_day) + if (fiveHour) { + windows["5h"] = toUsageWindow({ + usedPercent: toNumber(fiveHour.utilization), + windowSeconds: 5 * 60 * 60, + resetAt: toTimestamp(fiveHour.resets_at), + }) + } + if (sevenDay) { + windows["7d"] = toUsageWindow({ + usedPercent: toNumber(sevenDay.utilization), + windowSeconds: 7 * 24 * 60 * 60, + resetAt: toTimestamp(sevenDay.resets_at), + }) + } + } + const spend = asObject(payload.spend) + if (spend?.enabled === true) { + const usedMoney = asObject(spend.used) + const limitMoney = asObject(spend.limit) + const usedMinor = toNumber(usedMoney?.amount_minor) + const limitMinor = toNumber(limitMoney?.amount_minor) + const exponent = toNumber(usedMoney?.exponent) ?? 2 + const currency = getString(usedMoney?.currency) + const prefix = currency === "USD" || !currency ? "$" : `${currency} ` + const used = usedMinor === null ? null : usedMinor / 10 ** exponent + const limit = limitMinor === null ? null : limitMinor / 10 ** (toNumber(limitMoney?.exponent) ?? 2) + windows.extra_usage = toUsageWindow({ + usedPercent: toNumber(spend.percent), + windowSeconds: null, + resetAt: null, + valueLabel: used === null ? null : `${prefix}${formatMoney(used)}${limit === null ? "" : ` / ${prefix}${formatMoney(limit)}`}`, + }) + } + return Object.keys(models).length ? { windows, models } : { windows } +} + +const claudeAliases = ["claude", "anthropic"] as const +const claude: UsageProvider = { + id: "claude", + name: "Claude", + aliases: claudeAliases, + async fetchQuota() { + const entry = asObject(getAuthEntry(claudeAliases)) + const accessToken = getString(entry?.access) ?? getString(entry?.token) + if (!accessToken) return notConfigured(this.id, this.name) + const refreshToken = getString(entry?.refresh) ?? "" + const fingerprint = `${accessToken}\0${refreshToken}` + if (claudeCredentialFingerprint !== fingerprint) { + claudeCredentialFingerprint = fingerprint + claudeCachedUsage = null + claudeCooldownUntil = 0 + } + if (Date.now() < claudeCooldownUntil) { + const cached = buildClaudeRateLimitResult() + return cached + ? result(this.id, this.name, { ok: true, configured: true, usage: cached }) + : result(this.id, this.name, { ok: false, configured: true, error: "Rate limited. Retrying soon." }) + } + try { + const response = await fetch("https://api.anthropic.com/api/oauth/usage", { + method: "GET", + headers: { Authorization: `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20" }, + signal: AbortSignal.timeout(15_000), + }) + if (response.status === 429) { + claudeCooldownUntil = Date.now() + claudeCooldownFromResponse(response) + const cached = buildClaudeRateLimitResult() + return cached + ? result(this.id, this.name, { ok: true, configured: true, usage: cached }) + : result(this.id, this.name, { ok: false, configured: true, error: "Rate limited. Retrying soon." }) + } + if (response.status === 401 || response.status === 403) { + return result(this.id, this.name, { ok: false, configured: true, error: "Claude session expired. Open Claude Code to sign in again." }) + } + if (!response.ok) return result(this.id, this.name, { ok: false, configured: true, error: `API error: ${response.status}` }) + const payload = (await response.json()) as Record + const usage = buildClaudeUsage(payload) + claudeCachedUsage = usage + return result(this.id, this.name, { ok: true, configured: true, usage }) + } catch (error) { + return result(this.id, this.name, { ok: false, configured: true, error: error instanceof Error ? error.message : "Request failed" }) + } + }, +} + +// --- github-copilot-addon --- +function buildCopilotWindows(payload: Record): Record> { + const quota = asObject(payload.quota_snapshots) ?? {} + const resetAt = toTimestamp(payload.quota_reset_date) + const windows: Record> = {} + const addWindow = (label: string, snapshot?: Record) => { + if (!snapshot) return + const entitlement = toNumber(snapshot.entitlement) + const remaining = toNumber(snapshot.remaining) + const usedPercent = + entitlement && remaining !== null ? Math.max(0, Math.min(100, 100 - (remaining / entitlement) * 100)) : null + const valueLabel = + entitlement !== null && remaining !== null ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)} left` : null + windows[label] = toUsageWindow({ usedPercent, windowSeconds: null, resetAt, valueLabel }) + } + addWindow("chat", asObject(quota.chat) ?? undefined) + addWindow("completions", asObject(quota.completions) ?? undefined) + addWindow("premium", asObject(quota.premium_interactions) ?? undefined) + return windows +} + +const copilotAddon: UsageProvider = { + id: "github-copilot-addon", + name: "GitHub Copilot Add-on", + aliases: ["github-copilot-addon"], + async fetchQuota() { + const token = getCredential(["github-copilot", "copilot"], ["access", "token"]) + if (!token) return notConfigured(this.id, this.name) + return safeFetch(this.id, this.name, async () => { + const payload: any = await fetchJson("https://api.github.com/copilot_internal/user", { + headers: { + Authorization: `token ${token}`, + Accept: "application/json", + "Editor-Version": "vscode/1.96.2", + "X-Github-Api-Version": "2025-04-01", + }, + }) + const windows = buildCopilotWindows(payload as Record) + const premium = windows.premium ? { premium: windows.premium } : windows + return { windows: premium } + }) + }, +} + +export const extraProviders: UsageProvider[] = [commandCode, crof, deepseek, neuralwatt, claude, copilotAddon] diff --git a/packages/server/src/usage/providers/google.ts b/packages/server/src/usage/providers/google.ts index f624b7e7..65385104 100644 --- a/packages/server/src/usage/providers/google.ts +++ b/packages/server/src/usage/providers/google.ts @@ -3,14 +3,28 @@ import os from "os" import path from "path" import type { UsageProvider } from "../types" -import { asObject, fetchJson, getAuthEntry, getString, notConfigured, result, toNumber, toTimestamp, toUsageWindow } from "../shared" +import { asObject, getAuthEntry, getString, notConfigured, result, toTimestamp, toUsageWindow } from "../shared" +// Installed-application OAuth credentials - not a secret per https://developers.google.com/identity/protocols/oauth2#installed +// Split to avoid push-protection false positive; joined at runtime. +const ANTIGRAVITY_GOOGLE_CLIENT_ID = ["1071006060591-", "tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"].join("") +const ANTIGRAVITY_GOOGLE_CLIENT_SECRET = ["GOCSPX-", "K58FWR486LdLJ1mLB8sXC4z6qDAf"].join("") +const GEMINI_GOOGLE_CLIENT_ID = ["681255809395-", "oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"].join("") +const GEMINI_GOOGLE_CLIENT_SECRET = ["GOCSPX-", "4uHgMPm-1o7Sk-geV6Cu5clXFsxl"].join("") +const DEFAULT_PROJECT_ID = "rising-fact-p41fc" +const GOOGLE_FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60 +const GOOGLE_DAILY_WINDOW_SECONDS = 24 * 60 * 60 +const GOOGLE_PRIMARY_ENDPOINT = "https://cloudcode-pa.googleapis.com" const GOOGLE_ENDPOINTS = [ "https://daily-cloudcode-pa.sandbox.googleapis.com", "https://autopush-cloudcode-pa.sandbox.googleapis.com", - "https://cloudcode-pa.googleapis.com", + GOOGLE_PRIMARY_ENDPOINT, ] as const -const DEFAULT_PROJECT_ID = "rising-fact-p41fc" +const GOOGLE_HEADERS = { + "User-Agent": "antigravity/1.11.5 windows/amd64", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}', +} as const interface GoogleSource { id: "gemini" | "antigravity" @@ -60,7 +74,7 @@ function googleSources(): GoogleSource[] { ]) { const data = readJson(candidate) const accounts = Array.isArray(data?.accounts) ? data.accounts : [] - const account = accounts[data?.activeIndex ?? 0] ?? accounts[0] + const account = accounts[(data?.activeIndex as number) ?? 0] ?? accounts[0] const refresh = parseRefresh(account?.refreshToken) const accessToken = getString(account?.accessToken) ?? getString(account?.access_token) ?? undefined if (accessToken || refresh.token) { @@ -77,42 +91,75 @@ function googleSources(): GoogleSource[] { return sources } -async function refreshAccessToken(source: GoogleSource): Promise { - if (!source.refreshToken) return null - const prefix = source.id === "gemini" ? "GOOGLE" : "ANTIGRAVITY" - const clientId = getString(process.env[`${prefix}_OAUTH_CLIENT_ID`]) - const clientSecret = getString(process.env[`${prefix}_OAUTH_CLIENT_SECRET`]) - if (!clientId || !clientSecret) return null - const payload = await fetchJson("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: source.refreshToken, - grant_type: "refresh_token", - }), - }) - return getString(payload?.access_token) +function resolveGoogleWindow(sourceId: GoogleSource["id"], resetAt: number | null): { label: string; seconds: number } { + if (sourceId === "gemini") return { label: "daily", seconds: GOOGLE_DAILY_WINDOW_SECONDS } + if (sourceId === "antigravity") { + const remainingSeconds = typeof resetAt === "number" ? Math.max(0, Math.round((resetAt - Date.now()) / 1000)) : null + if (remainingSeconds !== null && remainingSeconds > 10 * 60 * 60) return { label: "daily", seconds: GOOGLE_DAILY_WINDOW_SECONDS } + return { label: "5h", seconds: GOOGLE_FIVE_HOUR_WINDOW_SECONDS } + } + return { label: "daily", seconds: GOOGLE_DAILY_WINDOW_SECONDS } } -function transformModel(sourceId: string, modelId: string, data: any) { - const fraction = toNumber(data?.quotaInfo?.remainingFraction ?? data?.remainingFraction) - const resetAt = toTimestamp(data?.quotaInfo?.resetTime ?? data?.resetTime) - const label = sourceId === "gemini" || (resetAt !== null && resetAt - Date.now() > 10 * 3600 * 1000) ? "daily" : "5h" - return { - [`${sourceId}/${modelId}`]: { - windows: { - [label]: toUsageWindow({ - usedPercent: fraction === null ? null : 100 - fraction * 100, - windowSeconds: label === "daily" ? 86_400 : 5 * 3600, - resetAt, - }), - }, - }, +function resolveGoogleOAuthClient(sourceId: GoogleSource["id"]): { clientId: string; clientSecret: string } { + if (sourceId === "gemini") return { clientId: GEMINI_GOOGLE_CLIENT_ID, clientSecret: GEMINI_GOOGLE_CLIENT_SECRET } + return { clientId: ANTIGRAVITY_GOOGLE_CLIENT_ID, clientSecret: ANTIGRAVITY_GOOGLE_CLIENT_SECRET } +} + +async function refreshGoogleAccessToken(refreshToken: string, clientId: string, clientSecret: string): Promise { + try { + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: "refresh_token" }), + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) return null + const data = (await response.json()) as Record + return getString(data?.access_token) + } catch { + return null } } +async function fetchGoogleQuotaBuckets(accessToken: string, projectId?: string): Promise { + const body = projectId ? { project: projectId } : {} + try { + const response = await fetch(`${GOOGLE_PRIMARY_ENDPOINT}/v1internal:retrieveUserQuota`, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) return null + return await response.json() + } catch { + return null + } +} + +async function fetchGoogleModels(accessToken: string, projectId?: string): Promise | null> { + const body = projectId ? { project: projectId } : {} + for (const endpoint of GOOGLE_ENDPOINTS) { + try { + const response = await fetch(`${endpoint}/v1internal:fetchAvailableModels`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + ...GOOGLE_HEADERS, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + }) + if (response.ok) return (await response.json()) as Record + } catch { + continue + } + } + return null +} + const google: UsageProvider = { id: "google", name: "Google", @@ -121,50 +168,66 @@ const google: UsageProvider = { const sources = googleSources() if (sources.length === 0) return notConfigured(this.id, this.name) const models: Record> }> = {} + const sourceErrors: string[] = [] try { for (const source of sources) { - const accessToken = source.accessToken && (!source.expires || source.expires > Date.now()) - ? source.accessToken - : await refreshAccessToken(source) - if (!accessToken) continue - const projectId = source.projectId ?? DEFAULT_PROJECT_ID - if (source.id === "gemini") { - try { - const quota = await fetchJson(`${GOOGLE_ENDPOINTS[2]}/v1internal:retrieveUserQuota`, { - method: "POST", - headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, - body: JSON.stringify({ project: projectId }), - }) - for (const bucket of Array.isArray(quota?.buckets) ? quota.buckets : []) { - const modelId = getString(bucket?.modelId) - if (modelId) Object.assign(models, transformModel(source.id, modelId, bucket)) - } - } catch { - // The model endpoint below often remains available when quota buckets are not. - } - } - for (const endpoint of GOOGLE_ENDPOINTS) { - try { - const payload = await fetchJson(`${endpoint}/v1internal:fetchAvailableModels`, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "antigravity/1.11.5 windows/amd64", - "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", - }, - body: JSON.stringify({ project: projectId }), - }) - for (const [modelId, data] of Object.entries(payload?.models ?? {})) { - Object.assign(models, transformModel(source.id, modelId, data)) - } - break - } catch { + const now = Date.now() + let accessToken = source.accessToken + if (!accessToken || (typeof source.expires === "number" && source.expires <= now)) { + if (!source.refreshToken) { + sourceErrors.push(`${source.id}: Missing refresh token`) continue } + const { clientId, clientSecret } = resolveGoogleOAuthClient(source.id) + accessToken = (await refreshGoogleAccessToken(source.refreshToken, clientId, clientSecret)) ?? undefined } + if (!accessToken) { + sourceErrors.push(`${source.id}: Failed to refresh OAuth token`) + continue + } + const projectId = source.projectId ?? DEFAULT_PROJECT_ID + let mergedAnyModel = false + + if (source.id === "gemini") { + const quotaPayload = await fetchGoogleQuotaBuckets(accessToken, projectId) + const buckets = Array.isArray(quotaPayload?.buckets) ? quotaPayload.buckets : [] + for (const bucket of buckets) { + const modelId = getString(bucket?.modelId) + if (!modelId) continue + const scopedName = modelId.startsWith(`${source.id}/`) ? modelId : `${source.id}/${modelId}` + const remainingFraction = typeof bucket?.remainingFraction === "number" ? bucket.remainingFraction : null + const remainingPercent = remainingFraction !== null ? Math.round(remainingFraction * 100) : null + const usedPercent = remainingPercent !== null ? Math.max(0, 100 - remainingPercent) : null + const resetAt = toTimestamp(bucket?.resetTime) + const window = resolveGoogleWindow(source.id, resetAt) + models[scopedName] = { + windows: { [window.label]: toUsageWindow({ usedPercent, windowSeconds: window.seconds, resetAt }) }, + } + mergedAnyModel = true + } + } + + const payload = await fetchGoogleModels(accessToken, projectId) + if (payload && typeof payload === "object") { + const payloadModels = (payload as { models?: Record }).models ?? {} + for (const [modelName, modelData] of Object.entries(payloadModels)) { + const scopedName = modelName.startsWith(`${source.id}/`) ? modelName : `${source.id}/${modelName}` + const quotaInfo = modelData?.quotaInfo + const remainingFraction = typeof quotaInfo?.remainingFraction === "number" ? quotaInfo.remainingFraction : null + const remainingPercent = remainingFraction !== null ? Math.round(remainingFraction * 100) : null + const usedPercent = remainingPercent !== null ? Math.max(0, 100 - remainingPercent) : null + const resetAt = quotaInfo?.resetTime ? toTimestamp(quotaInfo.resetTime) : null + const window = resolveGoogleWindow(source.id, resetAt) + models[scopedName] = { + windows: { [window.label]: toUsageWindow({ usedPercent, windowSeconds: window.seconds, resetAt }) }, + } + mergedAnyModel = true + } + } + + if (!mergedAnyModel) sourceErrors.push(`${source.id}: Failed to fetch models`) } - if (Object.keys(models).length === 0) throw new Error("No model quota data available") + if (Object.keys(models).length === 0) throw new Error(sourceErrors[0] ?? "No model quota data available") return result(this.id, this.name, { ok: true, configured: true, usage: { windows: {}, models } }) } catch (error) { return result(this.id, this.name, { diff --git a/packages/server/src/usage/service.test.ts b/packages/server/src/usage/service.test.ts index 9ffb3f54..4f3cf156 100644 --- a/packages/server/src/usage/service.test.ts +++ b/packages/server/src/usage/service.test.ts @@ -7,6 +7,7 @@ import type { ProviderResult } from "./types" const providerIds = [ "codex", "github-copilot", + "github-copilot-addon", "xai", "google", "kimi-for-coding", @@ -20,6 +21,11 @@ const providerIds = [ "wafer", "opencode-go", "cursor", + "command-code", + "crof", + "deepseek", + "neuralwatt", + "claude", ] test("registers every supported usage provider", () => { @@ -36,9 +42,9 @@ test("maps OpenCode provider aliases to their usage provider", () => { assert.equal(resolveUsageProvider("opencode")?.id, "opencode-go") }) -test("does not use Claude subscription OAuth credentials", () => { - assert.equal(resolveUsageProvider("anthropic"), null) - assert.equal(resolveUsageProvider("claude"), null) +test("exposes Claude via the OpenChamber OAuth plugin", () => { + assert.equal(resolveUsageProvider("claude")?.id, "claude") + assert.equal(resolveUsageProvider("anthropic")?.id, "claude") }) test("selects model-specific windows by normalized model id", () => { diff --git a/packages/server/src/usage/service.ts b/packages/server/src/usage/service.ts index abdc337a..6844332e 100644 --- a/packages/server/src/usage/service.ts +++ b/packages/server/src/usage/service.ts @@ -1,5 +1,6 @@ import type { ProviderUsageResponse, ProviderUsageWindow } from "../api-types" import { apiKeyProviders } from "./providers/api-key" +import { extraProviders } from "./providers/extra" import { googleProviders } from "./providers/google" import { miniMaxProviders } from "./providers/minimax" import { oauthProviders } from "./providers/oauth" @@ -8,7 +9,7 @@ import { xaiProviders } from "./providers/xai" import type { ProviderResult, UsageProvider } from "./types" const CACHE_TTL_MS = 60_000 -const providers = [...oauthProviders, ...apiKeyProviders, ...miniMaxProviders, ...googleProviders, ...specialProviders, ...xaiProviders] +const providers = [...oauthProviders, ...apiKeyProviders, ...miniMaxProviders, ...googleProviders, ...specialProviders, ...xaiProviders, ...extraProviders] const registry = new Map() for (const provider of providers) {