diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index b7bd3021609..6cc034a6564 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -74,6 +74,8 @@ import type { CreatePersonalAccessTokenRequest, CreatePersonalAccessTokenResponse, RuntimeUsage, + ProviderUsageResponse, + RuntimeProviderUsageListResponse, IssueUsageSummary, RuntimeHourlyActivity, RuntimeUsageByAgent, @@ -334,6 +336,10 @@ import { RuntimeUsageByAgentListSchema, RuntimeUsageByHourListSchema, RuntimeUsageListSchema, + ProviderUsageResponseSchema, + EMPTY_PROVIDER_USAGE_RESPONSE, + RuntimeProviderUsageListSchema, + EMPTY_RUNTIME_PROVIDER_USAGE_LIST, SearchIssuesResponseSchema, SearchProjectsResponseSchema, SearchIndexManifestSchema, @@ -2447,6 +2453,34 @@ export class ApiClient { ); } + async getRuntimeProviderUsage(runtimeId: string): Promise { + const raw = await this.fetch( + `/api/runtimes/${runtimeId}/provider-usage`, + ); + return parseWithFallback( + raw, + ProviderUsageResponseSchema, + EMPTY_PROVIDER_USAGE_RESPONSE, + { endpoint: "GET /api/runtimes/:id/provider-usage" }, + ); + } + + async listRuntimeProviderUsage( + runtimeIds: readonly string[], + ): Promise { + const search = new URLSearchParams(); + search.set("runtime_ids", runtimeIds.join(",")); + const raw = await this.fetch( + `/api/runtimes/provider-usage?${search}`, + ); + return parseWithFallback( + raw, + RuntimeProviderUsageListSchema, + EMPTY_RUNTIME_PROVIDER_USAGE_LIST, + { endpoint: "GET /api/runtimes/provider-usage" }, + ); + } + async getRuntimeUsage( runtimeId: string, params?: { days?: number; tz?: string }, diff --git a/packages/core/api/schemas.test.ts b/packages/core/api/schemas.test.ts index 2cfdd3536ce..5e589ba884a 100644 --- a/packages/core/api/schemas.test.ts +++ b/packages/core/api/schemas.test.ts @@ -54,6 +54,10 @@ import { RuntimeUsageByAgentListSchema, RuntimeUsageByHourListSchema, RuntimeUsageListSchema, + ProviderUsageResponseSchema, + EMPTY_PROVIDER_USAGE_RESPONSE, + RuntimeProviderUsageListSchema, + EMPTY_RUNTIME_PROVIDER_USAGE_LIST, SendChatMessageResponseSchema, SquadListSchema, SquadSchema, @@ -1184,6 +1188,64 @@ describe("dashboard + runtime usage schema drift", () => { expect(RuntimeUsageByHourListSchema.parse([{ hour: 9 }])[0]?.model).toBe(""); }); + it("falls back when a provider-usage payload is malformed and keeps a partial snapshot", () => { + expect( + parseWithFallback( + { providers: "nope" }, + ProviderUsageResponseSchema, + EMPTY_PROVIDER_USAGE_RESPONSE, + { endpoint: "GET /api/runtimes/:id/provider-usage" }, + ), + ).toEqual(EMPTY_PROVIDER_USAGE_RESPONSE); + expect( + parseWithFallback( + null, + ProviderUsageResponseSchema, + EMPTY_PROVIDER_USAGE_RESPONSE, + { endpoint: "GET /api/runtimes/:id/provider-usage" }, + ).providers, + ).toEqual([]); + + const partial = ProviderUsageResponseSchema.parse({ + providers: [{ provider: "claude" }, { plan_name: "Pro", windows: [{ percent_used: 12 }] }], + }); + expect(partial.providers[0]?.windows).toBeUndefined(); + expect(partial.providers[0]?.provider).toBe("claude"); + expect(partial.providers[1]?.provider).toBe(""); + expect(partial.providers[1]?.windows?.[0]?.id).toBe(""); + expect(partial.providers[1]?.windows?.[0]?.percent_used).toBe(12); + }); + + it("falls back when a runtime provider-usage list is malformed and keeps a partial row", () => { + expect( + parseWithFallback( + { runtimes: "nope" }, + RuntimeProviderUsageListSchema, + EMPTY_RUNTIME_PROVIDER_USAGE_LIST, + { endpoint: "GET /api/runtimes/provider-usage" }, + ), + ).toEqual(EMPTY_RUNTIME_PROVIDER_USAGE_LIST); + expect( + parseWithFallback( + null, + RuntimeProviderUsageListSchema, + EMPTY_RUNTIME_PROVIDER_USAGE_LIST, + { endpoint: "GET /api/runtimes/provider-usage" }, + ).runtimes, + ).toEqual([]); + + const partial = RuntimeProviderUsageListSchema.parse({ + runtimes: [ + { providers: [{ provider: "claude", windows: [{ percent_used: 38 }] }] }, + { runtime_id: "rt-2" }, + ], + }); + expect(partial.runtimes[0]?.runtime_id).toBe(""); + expect(partial.runtimes[0]?.providers[0]?.windows?.[0]?.percent_used).toBe(38); + expect(partial.runtimes[1]?.runtime_id).toBe("rt-2"); + expect(partial.runtimes[1]?.providers).toEqual([]); + }); + it("defaults a missing provider to \"\" so an older server's rows still price by bare model", () => { // provider was added for cross-provider model disambiguation; a server // predating it omits the field. The schema must fill "" (→ bare-model diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index f21c39d81f7..3adaf7ebb86 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -1840,6 +1840,38 @@ const RuntimeUsageByHourSchema = z.object({ export const RuntimeUsageByHourListSchema = z.array(RuntimeUsageByHourSchema); +// Plan-limit snapshots reported by the local daemon. Numbers and strings stay +// optional so an older or partial payload degrades instead of failing the +// whole response. Unknown fields pass through `.loose()`. +const ProviderUsageWindowSchema = z.object({ + id: z.string().default(""), + percent_used: z.number().optional(), + resets_at: z.string().optional(), +}).loose(); + +const ProviderUsageSnapshotSchema = z.object({ + provider: z.string().default(""), + plan_name: z.string().optional(), + collected_at: z.string().optional(), + reason_code: z.string().optional(), + windows: z.array(ProviderUsageWindowSchema).optional(), +}).loose(); + +export const ProviderUsageResponseSchema = z.object({ + providers: z.array(ProviderUsageSnapshotSchema).default([]), +}).loose(); + +export const EMPTY_PROVIDER_USAGE_RESPONSE = { providers: [] }; + +export const RuntimeProviderUsageListSchema = z.object({ + runtimes: z.array(z.object({ + runtime_id: z.string().default(""), + providers: z.array(ProviderUsageSnapshotSchema).default([]), + }).loose()).default([]), +}).loose(); + +export const EMPTY_RUNTIME_PROVIDER_USAGE_LIST = { runtimes: [] }; + // --------------------------------------------------------------------------- // Agent task responses. The base object stays loose so daemon/runtime fields // can drift while task-list consumers still validate the fields they render. diff --git a/packages/core/runtimes/queries.ts b/packages/core/runtimes/queries.ts index ec8f16d043b..1d6cb9dd0f4 100644 --- a/packages/core/runtimes/queries.ts +++ b/packages/core/runtimes/queries.ts @@ -12,6 +12,10 @@ export const runtimeKeys = { // by-hour now follows the viewer's tz, like the other reports. usageByHour: (rid: string, days: number, tz: string) => ["runtimes", "usage", "by-hour", rid, days, tz] as const, + providerUsage: (wsId: string, runtimeId: string) => + [...runtimeKeys.all(wsId), "provider-usage", runtimeId] as const, + providerUsageList: (wsId: string, runtimeIds: readonly string[]) => + [...runtimeKeys.all(wsId), "provider-usage-list", runtimeIds] as const, }; // `tz` is the viewer's IANA name — all reports follow the viewer's tz. @@ -39,6 +43,28 @@ export function runtimeUsageByAgentOptions( }); } +export function runtimeProviderUsageOptions(wsId: string, runtimeId: string) { + return queryOptions({ + queryKey: runtimeKeys.providerUsage(wsId, runtimeId), + queryFn: () => api.getRuntimeProviderUsage(runtimeId), + enabled: wsId.length > 0 && runtimeId.length > 0, + staleTime: 60 * 1000, + }); +} + +export function runtimeProviderUsageListOptions( + wsId: string, + runtimeIds: readonly string[], +) { + const ids = [...runtimeIds].sort(); + return queryOptions({ + queryKey: runtimeKeys.providerUsageList(wsId, ids), + queryFn: () => api.listRuntimeProviderUsage(ids), + enabled: wsId.length > 0 && ids.length > 0, + staleTime: 60 * 1000, + }); +} + export function runtimeUsageByHourOptions(runtimeId: string, days: number, tz: string) { return queryOptions({ queryKey: runtimeKeys.usageByHour(runtimeId, days, tz), diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index ff3a3b06854..8e4060d098a 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -975,6 +975,38 @@ export interface RuntimeHourlyActivity { count: number; } +// Derived plan-limit snapshot for one provider on a runtime. Older backends +// omit the endpoint entirely; fields stay optional so a partial payload still +// renders. This is not task token accounting. +export interface ProviderUsageWindow { + id: string; + percent_used?: number; + resets_at?: string; +} + +export interface ProviderUsageSnapshot { + provider: string; + plan_name?: string; + collected_at?: string; + reason_code?: string; + windows?: ProviderUsageWindow[]; +} + +export interface ProviderUsageResponse { + providers: ProviderUsageSnapshot[]; +} + +// One machine's plan-limit snapshots, keyed by the runtime that stored them. +// The snapshot objects are the same shape as ProviderUsageResponse.providers. +export interface RuntimeProviderUsageListItem { + runtime_id: string; + providers: ProviderUsageSnapshot[]; +} + +export interface RuntimeProviderUsageListResponse { + runtimes: RuntimeProviderUsageListItem[]; +} + // One (agent, provider, model) row of the "Cost by agent" tab on the runtime // detail page. provider + model stay on the wire because cost is computed // client-side from a per-model pricing table (provider disambiguates bare diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index 9e67ddad4f7..d1278e14bab 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -61,6 +61,11 @@ export type { RuntimeHourlyActivity, RuntimeUsageByAgent, RuntimeUsageByHour, + ProviderUsageWindow, + ProviderUsageSnapshot, + ProviderUsageResponse, + RuntimeProviderUsageListItem, + RuntimeProviderUsageListResponse, DashboardUsageDaily, DashboardUsageByAgent, DashboardAgentRunTime, diff --git a/packages/views/agents/components/agent-overview-pane.tsx b/packages/views/agents/components/agent-overview-pane.tsx index 5443c44e770..96d7fca2e7a 100644 --- a/packages/views/agents/components/agent-overview-pane.tsx +++ b/packages/views/agents/components/agent-overview-pane.tsx @@ -384,7 +384,11 @@ export function AgentOverviewPane({ className={cn(PAGE_RAIL, PAGE_GUTTER, "py-4 sm:py-6")} >
- + + {agent.runtime_id ? ( + + ) : null} {showPerformance && ( diff --git a/packages/views/locales/en/runtimes.json b/packages/views/locales/en/runtimes.json index a20edab70e7..7701affa687 100644 --- a/packages/views/locales/en/runtimes.json +++ b/packages/views/locales/en/runtimes.json @@ -302,6 +302,7 @@ "col_owner": "Owner", "col_agents": "Agents", "col_cost": "Cost · 7d", + "col_usage": "Usage", "col_cli": "CLI", "cost_delta_flat": "flat", "cli_managed_badge": "Desktop", @@ -464,6 +465,51 @@ "table_output": "Output", "table_cache_r": "Cache R", "table_cache_w": "Cache W", - "no_data": "No usage data yet" + "no_data": "No usage data yet", + "provider_limits": { + "title": "Plan usage", + "description": "Percent used on this runtime's signed-in plan.", + "empty": "No local session on this machine.", + "waiting": "No plan usage reported from this machine yet.", + "plan": "Plan", + "collected": "Collected {{time}}", + "resets": "Resets {{time}}", + "used": "{{pct}}% used", + "provider_claude": "Claude Code", + "provider_cursor": "Cursor", + "provider_codex": "Codex", + "provider_copilot": "GitHub Copilot", + "provider_antigravity": "Antigravity", + "provider_grok": "Grok", + "provider_kimi": "Kimi", + "provider_kiro": "Kiro", + "provider_opencode": "OpenCode", + "window_session": "Session", + "window_weekly_all": "Weekly", + "window_auto": "Auto", + "window_api": "API", + "window_primary": "Primary", + "window_secondary": "Secondary", + "window_premium_interactions": "Premium requests", + "window_chat": "Chat", + "window_completions": "Completions", + "window_rolling": "5-hour", + "window_weekly": "Weekly", + "window_monthly": "Monthly", + "window_credits": "Credits", + "window_bonus": "Bonus", + "window_gemini_hourly": "Gemini 5-hour", + "window_gemini_weekly": "Gemini weekly", + "window_third_party_hourly": "Claude and GPT 5-hour", + "window_third_party_weekly": "Claude and GPT weekly", + "reason_not_logged_in": "Not signed in on this machine.", + "reason_api_key_only": "This Codex login is an API key, so plan limits are not available.", + "reason_unauthorized": "The local session was rejected. Sign in again in the CLI or editor.", + "reason_credential_expired": "The local sign-in has expired. Open the app to renew it.", + "reason_cli_unavailable": "The CLI is not installed on this machine.", + "reason_session_unavailable": "No local editor session was found on this machine.", + "reason_unsupported": "Plan limits cannot be read on this machine.", + "reason_unknown": "Plan limits are unavailable." + } } } diff --git a/packages/views/locales/fr/runtimes.json b/packages/views/locales/fr/runtimes.json index 3d0194b374a..ba3414f582d 100644 --- a/packages/views/locales/fr/runtimes.json +++ b/packages/views/locales/fr/runtimes.json @@ -302,6 +302,7 @@ "col_owner": "Propriétaire", "col_agents": "Agents", "col_cost": "Coût · 7 j", + "col_usage": "Utilisation", "col_cli": "CLI", "cost_delta_flat": "stable", "cli_managed_badge": "Desktop", @@ -464,6 +465,51 @@ "table_output": "Sortie", "table_cache_r": "Cache L", "table_cache_w": "Cache É", - "no_data": "Aucune donnée de consommation pour l'instant" + "no_data": "Aucune donnée de consommation pour l'instant", + "provider_limits": { + "title": "Usage du forfait", + "description": "Pourcentage utilisé du forfait connecté sur ce runtime.", + "empty": "Aucune session locale sur cette machine.", + "waiting": "Aucun usage de forfait n'a encore été remonté depuis cette machine.", + "plan": "Forfait", + "collected": "Collecté {{time}}", + "resets": "Réinitialisation {{time}}", + "used": "{{pct}} % utilisé", + "provider_claude": "Claude Code", + "provider_cursor": "Cursor", + "provider_codex": "Codex", + "provider_copilot": "GitHub Copilot", + "provider_antigravity": "Antigravity", + "provider_grok": "Grok", + "provider_kimi": "Kimi", + "provider_kiro": "Kiro", + "provider_opencode": "OpenCode", + "window_session": "Session", + "window_weekly_all": "Hebdomadaire", + "window_auto": "Auto", + "window_api": "API", + "window_primary": "Principale", + "window_secondary": "Secondaire", + "window_premium_interactions": "Requêtes premium", + "window_chat": "Chat", + "window_completions": "Complétions", + "window_rolling": "5 heures", + "window_weekly": "Hebdomadaire", + "window_monthly": "Mensuel", + "window_credits": "Crédits", + "window_bonus": "Bonus", + "window_gemini_hourly": "Gemini · 5 heures", + "window_gemini_weekly": "Gemini · hebdomadaire", + "window_third_party_hourly": "Claude et GPT · 5 heures", + "window_third_party_weekly": "Claude et GPT · hebdomadaire", + "reason_not_logged_in": "Non connecté sur cette machine.", + "reason_api_key_only": "Cette connexion Codex est une clé API : les limites du forfait ne sont pas disponibles.", + "reason_unauthorized": "La session locale a été refusée. Reconnectez-vous dans le CLI ou l'éditeur.", + "reason_credential_expired": "La connexion locale a expiré. Ouvrez l'application pour la renouveler.", + "reason_cli_unavailable": "Le CLI n'est pas installé sur cette machine.", + "reason_session_unavailable": "Aucune session d'éditeur locale n'a été trouvée sur cette machine.", + "reason_unsupported": "Les limites du forfait ne peuvent pas être lues sur cette machine.", + "reason_unknown": "Les limites du forfait sont indisponibles." + } } } diff --git a/packages/views/locales/ja/runtimes.json b/packages/views/locales/ja/runtimes.json index aa8c2333268..fe09a12e692 100644 --- a/packages/views/locales/ja/runtimes.json +++ b/packages/views/locales/ja/runtimes.json @@ -289,6 +289,7 @@ "col_owner": "所有者", "col_agents": "エージェント", "col_cost": "コスト · 7 日", + "col_usage": "使用量", "col_cli": "CLI", "cost_delta_flat": "変化なし", "cli_managed_badge": "デスクトップ", @@ -448,6 +449,51 @@ "table_output": "出力", "table_cache_r": "キャッシュ R", "table_cache_w": "キャッシュ W", - "no_data": "まだ使用量データがありません" + "no_data": "まだ使用量データがありません", + "provider_limits": { + "title": "プラン使用量", + "description": "このランタイムでログイン済みのプランの使用率です。", + "empty": "このマシンにローカルセッションはありません。", + "waiting": "このマシンからプラン使用量はまだ報告されていません。", + "plan": "プラン", + "collected": "{{time}} に取得", + "resets": "{{time}} にリセット", + "used": "{{pct}}% 使用", + "provider_claude": "Claude Code", + "provider_cursor": "Cursor", + "provider_codex": "Codex", + "provider_copilot": "GitHub Copilot", + "provider_antigravity": "Antigravity", + "provider_grok": "Grok", + "provider_kimi": "Kimi", + "provider_kiro": "Kiro", + "provider_opencode": "OpenCode", + "window_session": "セッション", + "window_weekly_all": "週間", + "window_auto": "Auto", + "window_api": "API", + "window_primary": "プライマリ", + "window_secondary": "セカンダリ", + "window_premium_interactions": "プレミアムリクエスト", + "window_chat": "チャット", + "window_completions": "補完", + "window_rolling": "5時間", + "window_weekly": "週間", + "window_monthly": "月間", + "window_credits": "クレジット", + "window_bonus": "ボーナス", + "window_gemini_hourly": "Gemini · 5時間", + "window_gemini_weekly": "Gemini · 週間", + "window_third_party_hourly": "Claude と GPT · 5時間", + "window_third_party_weekly": "Claude と GPT · 週間", + "reason_not_logged_in": "このマシンではログインしていません。", + "reason_api_key_only": "この Codex ログインは API キーのため、プラン上限は表示できません。", + "reason_unauthorized": "ローカルセッションが拒否されました。CLI またはエディタで再ログインしてください。", + "reason_credential_expired": "ローカルのサインインの有効期限が切れています。アプリを開いて更新してください。", + "reason_cli_unavailable": "このマシンに CLI がインストールされていません。", + "reason_session_unavailable": "このマシンでローカルのエディタセッションが見つかりません。", + "reason_unsupported": "このマシンではプラン上限を読み取れません。", + "reason_unknown": "プラン上限は利用できません。" + } } } diff --git a/packages/views/locales/ko/runtimes.json b/packages/views/locales/ko/runtimes.json index f49ac0c787f..3a743379669 100644 --- a/packages/views/locales/ko/runtimes.json +++ b/packages/views/locales/ko/runtimes.json @@ -289,6 +289,7 @@ "col_owner": "소유자", "col_agents": "에이전트", "col_cost": "비용 · 7일", + "col_usage": "사용량", "col_cli": "CLI", "cost_delta_flat": "변화 없음", "cli_managed_badge": "데스크톱", @@ -448,6 +449,51 @@ "table_output": "출력", "table_cache_r": "캐시 R", "table_cache_w": "캐시 W", - "no_data": "아직 사용량 데이터가 없습니다" + "no_data": "아직 사용량 데이터가 없습니다", + "provider_limits": { + "title": "플랜 사용량", + "description": "이 런타임에 로그인된 플랜의 사용 비율입니다.", + "empty": "이 머신에 로컬 세션이 없습니다.", + "waiting": "이 머신에서 아직 플랜 사용량이 보고되지 않았습니다.", + "plan": "플랜", + "collected": "{{time}}에 수집", + "resets": "{{time}}에 초기화", + "used": "{{pct}}% 사용", + "provider_claude": "Claude Code", + "provider_cursor": "Cursor", + "provider_codex": "Codex", + "provider_copilot": "GitHub Copilot", + "provider_antigravity": "Antigravity", + "provider_grok": "Grok", + "provider_kimi": "Kimi", + "provider_kiro": "Kiro", + "provider_opencode": "OpenCode", + "window_session": "세션", + "window_weekly_all": "주간", + "window_auto": "Auto", + "window_api": "API", + "window_primary": "기본", + "window_secondary": "보조", + "window_premium_interactions": "프리미엄 요청", + "window_chat": "채팅", + "window_completions": "자동 완성", + "window_rolling": "5시간", + "window_weekly": "주간", + "window_monthly": "월간", + "window_credits": "크레딧", + "window_bonus": "보너스", + "window_gemini_hourly": "Gemini · 5시간", + "window_gemini_weekly": "Gemini · 주간", + "window_third_party_hourly": "Claude 및 GPT · 5시간", + "window_third_party_weekly": "Claude 및 GPT · 주간", + "reason_not_logged_in": "이 머신에 로그인되어 있지 않습니다.", + "reason_api_key_only": "이 Codex 로그인은 API 키라 플랜 한도를 볼 수 없습니다.", + "reason_unauthorized": "로컬 세션이 거부되었습니다. CLI 또는 편집기에서 다시 로그인하세요.", + "reason_credential_expired": "로컬 로그인이 만료되었습니다. 앱을 열어 갱신하세요.", + "reason_cli_unavailable": "이 머신에 CLI가 설치되어 있지 않습니다.", + "reason_session_unavailable": "이 머신에서 로컬 편집기 세션을 찾지 못했습니다.", + "reason_unsupported": "이 머신에서는 플랜 한도를 읽을 수 없습니다.", + "reason_unknown": "플랜 한도를 사용할 수 없습니다." + } } } diff --git a/packages/views/locales/zh-Hans/runtimes.json b/packages/views/locales/zh-Hans/runtimes.json index 1a7e162904e..537770dad53 100644 --- a/packages/views/locales/zh-Hans/runtimes.json +++ b/packages/views/locales/zh-Hans/runtimes.json @@ -289,6 +289,7 @@ "col_owner": "所有者", "col_agents": "智能体", "col_cost": "费用 · 7 天", + "col_usage": "用量", "col_cli": "CLI", "cost_delta_flat": "持平", "cli_managed_badge": "桌面端", @@ -448,6 +449,51 @@ "table_output": "输出", "table_cache_r": "缓存读", "table_cache_w": "缓存写", - "no_data": "还没有使用数据" + "no_data": "还没有使用数据", + "provider_limits": { + "title": "套餐用量", + "description": "此运行时已登录套餐的已用百分比。", + "empty": "这台机器上没有本地登录会话。", + "waiting": "这台机器还没有上报套餐用量。", + "plan": "套餐", + "collected": "采集于 {{time}}", + "resets": "{{time}} 重置", + "used": "已用 {{pct}}%", + "provider_claude": "Claude Code", + "provider_cursor": "Cursor", + "provider_codex": "Codex", + "provider_copilot": "GitHub Copilot", + "provider_antigravity": "Antigravity", + "provider_grok": "Grok", + "provider_kimi": "Kimi", + "provider_kiro": "Kiro", + "provider_opencode": "OpenCode", + "window_session": "会话", + "window_weekly_all": "每周", + "window_auto": "Auto", + "window_api": "API", + "window_primary": "主要窗口", + "window_secondary": "次要窗口", + "window_premium_interactions": "高级请求", + "window_chat": "对话", + "window_completions": "补全", + "window_rolling": "5 小时", + "window_weekly": "每周", + "window_monthly": "每月", + "window_credits": "额度", + "window_bonus": "奖励额度", + "window_gemini_hourly": "Gemini · 5 小时", + "window_gemini_weekly": "Gemini · 每周", + "window_third_party_hourly": "Claude 与 GPT · 5 小时", + "window_third_party_weekly": "Claude 与 GPT · 每周", + "reason_not_logged_in": "这台机器上尚未登录。", + "reason_api_key_only": "此 Codex 登录使用的是 API key,无法读取套餐额度。", + "reason_unauthorized": "本地会话已被拒绝。请在 CLI 或编辑器中重新登录。", + "reason_credential_expired": "本地登录已过期。请打开应用续期。", + "reason_cli_unavailable": "这台机器上没有安装对应的 CLI。", + "reason_session_unavailable": "这台机器上没有找到本地编辑器会话。", + "reason_unsupported": "这台机器上无法读取套餐额度。", + "reason_unknown": "套餐额度暂不可用。" + } } } diff --git a/packages/views/runtimes/components/provider-usage-block.test.tsx b/packages/views/runtimes/components/provider-usage-block.test.tsx new file mode 100644 index 00000000000..f76ce5f55a2 --- /dev/null +++ b/packages/views/runtimes/components/provider-usage-block.test.tsx @@ -0,0 +1,302 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { ProviderUsageResponse } from "@multica/core/types"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../locales/en/common.json"; +import enRuntimes from "../../locales/en/runtimes.json"; + +const TEST_RESOURCES = { en: { common: enCommon, runtimes: enRuntimes } }; + +const queryResult = vi.hoisted(() => ({ + current: { + data: { providers: [] } as ProviderUsageResponse, + isLoading: false, + }, +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual( + "@tanstack/react-query", + ); + return { + ...actual, + useQuery: () => queryResult.current, + }; +}); + +vi.mock("@multica/core/runtimes/queries", () => ({ + runtimeProviderUsageOptions: () => ({ kind: "provider-usage" }), +})); + +import { ProviderUsageBlock } from "./provider-usage-block"; + +function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function formatWhen(value: string): string { + return new Intl.DateTimeFormat("en", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +describe("ProviderUsageBlock", () => { + it("shows percent, reset, plan, and collected time", () => { + const collectedAt = "2026-09-21T07:00:00.000Z"; + const resetsAt = "2026-09-22T07:59:00.000Z"; + queryResult.current = { + isLoading: false, + data: { + providers: [ + { + provider: "claude", + plan_name: "Max", + collected_at: collectedAt, + windows: [ + { id: "session", percent_used: 38.2, resets_at: resetsAt }, + { id: "weekly_all", percent_used: 4, resets_at: resetsAt }, + ], + }, + ], + }, + }; + + render( + , + { wrapper: Wrapper }, + ); + + expect( + screen.getByText("Percent used on this runtime's signed-in plan."), + ).toBeInTheDocument(); + expect( + screen.queryByText(/Claude, Cursor, and Codex/), + ).not.toBeInTheDocument(); + expect(screen.getByText("Claude Code")).toBeInTheDocument(); + expect(screen.getByText("Plan: Max")).toBeInTheDocument(); + expect(screen.getByText("Session")).toBeInTheDocument(); + expect(screen.getByText("Weekly")).toBeInTheDocument(); + expect(screen.getByText("38% used")).toBeInTheDocument(); + expect(screen.getByText("4% used")).toBeInTheDocument(); + expect( + screen.getByText(`Collected ${formatWhen(collectedAt)}`), + ).toBeInTheDocument(); + expect(screen.getAllByText(`Resets ${formatWhen(resetsAt)}`)).toHaveLength( + 2, + ); + }); + + it("shows the empty-session reason when a provider has no windows", () => { + queryResult.current = { + isLoading: false, + data: { + providers: [ + { + provider: "codex", + reason_code: "api_key_only", + windows: [], + }, + ], + }, + }; + + render( + , + { wrapper: Wrapper }, + ); + + expect(screen.getByText("Codex")).toBeInTheDocument(); + expect( + screen.getByText( + "This Codex login is an API key, so plan limits are not available.", + ), + ).toBeInTheDocument(); + }); + + it("shows an expired local sign-in as an empty snapshot", () => { + queryResult.current = { + isLoading: false, + data: { + providers: [ + { + provider: "opencode", + reason_code: "credential_expired", + windows: [], + }, + ], + }, + }; + + render( + , + { wrapper: Wrapper }, + ); + + expect(screen.getByText("OpenCode")).toBeInTheDocument(); + expect( + screen.getByText("The local sign-in has expired. Open the app to renew it."), + ).toBeInTheDocument(); + }); + + it("shows the waiting state for an unknown runtime instead of other vendors", () => { + queryResult.current = { + isLoading: false, + data: { + providers: [ + { + provider: "claude", + windows: [{ id: "session", percent_used: 1 }], + }, + { + provider: "codex", + windows: [{ id: "primary", percent_used: 2 }], + }, + { + provider: "cursor", + windows: [{ id: "auto", percent_used: 3 }], + }, + { provider: "mystery", reason_code: "brand_new" }, + ], + }, + }; + + render( + , + { wrapper: Wrapper }, + ); + + expect( + screen.getByText("No plan usage reported from this machine yet."), + ).toBeInTheDocument(); + expect(screen.queryByText("Claude Code")).not.toBeInTheDocument(); + expect(screen.queryByText("Codex")).not.toBeInTheDocument(); + expect(screen.queryByText("Cursor")).not.toBeInTheDocument(); + expect(screen.queryByText("mystery")).not.toBeInTheDocument(); + }); + + it("shows only Grok credits on the Grok runtime", () => { + queryResult.current = { + isLoading: false, + data: { + providers: [ + { + provider: "claude", + plan_name: "Max", + windows: [{ id: "session", percent_used: 38 }], + }, + { + provider: "grok", + plan_name: "Grok Build", + windows: [{ id: "credits", percent_used: 8 }], + }, + ], + }, + }; + + render( + , + { wrapper: Wrapper }, + ); + + expect(screen.getByText("Grok")).toBeInTheDocument(); + expect(screen.getByText("Credits")).toBeInTheDocument(); + expect(screen.getByText("8% used")).toBeInTheDocument(); + expect(screen.getByText("Plan: Grok Build")).toBeInTheDocument(); + expect(screen.queryByText("Claude Code")).not.toBeInTheDocument(); + expect(screen.queryByText("38% used")).not.toBeInTheDocument(); + }); + + it("shows only Claude windows when the machine also reported Codex and Cursor", () => { + queryResult.current = { + isLoading: false, + data: { + providers: [ + { + provider: "claude", + plan_name: "Max", + windows: [ + { id: "session", percent_used: 0 }, + { id: "weekly_all", percent_used: 25 }, + ], + }, + { + provider: "codex", + plan_name: "free", + windows: [{ id: "primary", percent_used: 6 }], + }, + { + provider: "cursor", + plan_name: "pro", + windows: [ + { id: "api", percent_used: 9 }, + { id: "auto", percent_used: 10 }, + ], + }, + ], + }, + }; + + render( + , + { wrapper: Wrapper }, + ); + + expect(screen.getByText("Claude Code")).toBeInTheDocument(); + expect(screen.getByText("Session")).toBeInTheDocument(); + expect(screen.getByText("Weekly")).toBeInTheDocument(); + expect(screen.getByText("0% used")).toBeInTheDocument(); + expect(screen.getByText("25% used")).toBeInTheDocument(); + expect(screen.queryByText("Codex")).not.toBeInTheDocument(); + expect(screen.queryByText("Cursor")).not.toBeInTheDocument(); + expect(screen.queryByText("Primary")).not.toBeInTheDocument(); + expect(screen.queryByText("API")).not.toBeInTheDocument(); + expect(screen.queryByText("Auto")).not.toBeInTheDocument(); + expect(screen.queryByText("6% used")).not.toBeInTheDocument(); + expect(screen.queryByText("9% used")).not.toBeInTheDocument(); + expect(screen.queryByText("10% used")).not.toBeInTheDocument(); + expect(screen.queryByText("Plan: free")).not.toBeInTheDocument(); + expect(screen.queryByText("Plan: pro")).not.toBeInTheDocument(); + }); + + it("tolerates a snapshot that omits windows", () => { + queryResult.current = { + isLoading: false, + data: { + providers: [{ provider: "cursor", plan_name: "pro" }], + }, + }; + + render( + , + { wrapper: Wrapper }, + ); + + expect(screen.getByText("Cursor")).toBeInTheDocument(); + expect(screen.getByText("Plan: pro")).toBeInTheDocument(); + expect( + screen.getByText("No local session on this machine."), + ).toBeInTheDocument(); + }); + + it("shows the waiting copy when the machine has not reported yet", () => { + queryResult.current = { isLoading: false, data: { providers: [] } }; + + render( + , + { wrapper: Wrapper }, + ); + + expect( + screen.getByText("No plan usage reported from this machine yet."), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/views/runtimes/components/provider-usage-block.tsx b/packages/views/runtimes/components/provider-usage-block.tsx new file mode 100644 index 00000000000..10a9b59e2a3 --- /dev/null +++ b/packages/views/runtimes/components/provider-usage-block.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { ProviderUsageSnapshot, ProviderUsageWindow } from "@multica/core/types"; +import { runtimeProviderUsageOptions } from "@multica/core/runtimes/queries"; +import { Skeleton } from "@multica/ui/components/ui/skeleton"; +import { useT } from "../../i18n"; +import { planUsageSnapshotsForProvider } from "./runtime-plan-usage-cell"; + +interface ProviderUsageBlockProps { + wsId: string; + runtimeId: string; + /** Protocol family of the open runtime (`claude`, `codex`, `cursor`, …). */ + provider: string | undefined; +} + +export function ProviderUsageBlock({ + wsId, + runtimeId, + provider, +}: ProviderUsageBlockProps) { + const { t, i18n } = useT("runtimes"); + const { data, isLoading } = useQuery(runtimeProviderUsageOptions(wsId, runtimeId)); + const providers = planUsageSnapshotsForProvider(provider, data?.providers); + const locale = i18n.resolvedLanguage ?? i18n.language; + + return ( +
+
+

{t(($) => $.usage.provider_limits.title)}

+

+ {t(($) => $.usage.provider_limits.description)} +

+
+ {isLoading ? ( + + ) : providers.length === 0 ? ( +

+ {t(($) => $.usage.provider_limits.waiting)} +

+ ) : ( +
+ {providers.map((snapshot, index) => ( + + ))} +
+ )} +
+ ); +} + +function ProviderUsageCard({ + snapshot, + locale, +}: { + snapshot: ProviderUsageSnapshot; + locale: string; +}) { + const { t } = useT("runtimes"); + const windows = snapshot.windows ?? []; + const collected = formatWhen(snapshot.collected_at, locale); + const plan = snapshot.plan_name?.trim() ?? ""; + + return ( +
+
+

{providerLabel(snapshot.provider, t)}

+ {plan ? ( +

+ {t(($) => $.usage.provider_limits.plan)}: {plan} +

+ ) : null} +
+ {windows.length === 0 ? ( +

{reasonLabel(snapshot.reason_code, t)}

+ ) : ( +
+ {windows.map((window, index) => ( + + ))} +
+ )} + {collected ? ( +

+ {t(($) => $.usage.provider_limits.collected, { time: collected })} +

+ ) : null} +
+ ); +} + +function WindowRow({ window, locale }: { window: ProviderUsageWindow; locale: string }) { + const { t } = useT("runtimes"); + const percent = + typeof window.percent_used === "number" && Number.isFinite(window.percent_used) + ? Math.round(window.percent_used) + : null; + const resets = formatWhen(window.resets_at, locale); + + return ( +
+
+ {windowLabel(window.id, t)} + {percent !== null ? ( + + {t(($) => $.usage.provider_limits.used, { pct: percent })} + + ) : null} +
+ {resets ? ( +

+ {t(($) => $.usage.provider_limits.resets, { time: resets })} +

+ ) : null} +
+ ); +} + +function providerLabel( + provider: string | undefined, + t: ReturnType>["t"], +): string { + switch (provider) { + case "claude": + return t(($) => $.usage.provider_limits.provider_claude); + case "cursor": + return t(($) => $.usage.provider_limits.provider_cursor); + case "codex": + return t(($) => $.usage.provider_limits.provider_codex); + case "copilot": + return t(($) => $.usage.provider_limits.provider_copilot); + case "antigravity": + return t(($) => $.usage.provider_limits.provider_antigravity); + case "grok": + return t(($) => $.usage.provider_limits.provider_grok); + case "kimi": + return t(($) => $.usage.provider_limits.provider_kimi); + case "kiro": + return t(($) => $.usage.provider_limits.provider_kiro); + case "opencode": + return t(($) => $.usage.provider_limits.provider_opencode); + default: + return provider?.trim() || t(($) => $.usage.provider_limits.reason_unknown); + } +} + +function windowLabel( + id: string | undefined, + t: ReturnType>["t"], +): string { + switch (id) { + case "session": + return t(($) => $.usage.provider_limits.window_session); + case "weekly_all": + return t(($) => $.usage.provider_limits.window_weekly_all); + case "auto": + return t(($) => $.usage.provider_limits.window_auto); + case "api": + return t(($) => $.usage.provider_limits.window_api); + case "primary": + return t(($) => $.usage.provider_limits.window_primary); + case "secondary": + return t(($) => $.usage.provider_limits.window_secondary); + case "premium_interactions": + return t(($) => $.usage.provider_limits.window_premium_interactions); + case "chat": + return t(($) => $.usage.provider_limits.window_chat); + case "completions": + return t(($) => $.usage.provider_limits.window_completions); + case "rolling": + return t(($) => $.usage.provider_limits.window_rolling); + case "weekly": + return t(($) => $.usage.provider_limits.window_weekly); + case "monthly": + return t(($) => $.usage.provider_limits.window_monthly); + case "credits": + return t(($) => $.usage.provider_limits.window_credits); + case "bonus": + return t(($) => $.usage.provider_limits.window_bonus); + case "gemini_hourly": + return t(($) => $.usage.provider_limits.window_gemini_hourly); + case "gemini_weekly": + return t(($) => $.usage.provider_limits.window_gemini_weekly); + case "third_party_hourly": + return t(($) => $.usage.provider_limits.window_third_party_hourly); + case "third_party_weekly": + return t(($) => $.usage.provider_limits.window_third_party_weekly); + default: { + if (id?.startsWith("weekly_")) { + const name = id.slice("weekly_".length).replaceAll("_", " ").trim(); + if (name) return name; + } + return id?.trim() || t(($) => $.usage.provider_limits.reason_unknown); + } + } +} + +function reasonLabel( + reason: string | undefined, + t: ReturnType>["t"], +): string { + switch (reason) { + case "not_logged_in": + return t(($) => $.usage.provider_limits.reason_not_logged_in); + case "api_key_only": + return t(($) => $.usage.provider_limits.reason_api_key_only); + case "unauthorized": + return t(($) => $.usage.provider_limits.reason_unauthorized); + case "credential_expired": + return t(($) => $.usage.provider_limits.reason_credential_expired); + case "cli_unavailable": + return t(($) => $.usage.provider_limits.reason_cli_unavailable); + case "session_unavailable": + return t(($) => $.usage.provider_limits.reason_session_unavailable); + case "unsupported": + return t(($) => $.usage.provider_limits.reason_unsupported); + default: + return t(($) => $.usage.provider_limits.empty); + } +} + +function formatWhen(value: string | undefined, locale: string): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} diff --git a/packages/views/runtimes/components/runtime-list.tsx b/packages/views/runtimes/components/runtime-list.tsx index 8b7b37e225e..cb9bbe046dd 100644 --- a/packages/views/runtimes/components/runtime-list.tsx +++ b/packages/views/runtimes/components/runtime-list.tsx @@ -18,6 +18,7 @@ import type { AgentRuntime, AgentTask, MemberWithUser, + ProviderUsageSnapshot, RuntimeProfile, } from "@multica/core/types"; import { useAuthStore } from "@multica/core/auth"; @@ -31,6 +32,7 @@ import { deriveRuntimeHealth, isRuntimeUsableForUser, runtimeProfileListOptions, + runtimeProviderUsageListOptions, runtimeUsageOptions, } from "@multica/core/runtimes"; import { useWorkspacePaths } from "@multica/core/paths"; @@ -66,6 +68,10 @@ import { pctChange, } from "../utils"; import { runtimeRowLabel } from "./runtime-machines"; +import { + RuntimePlanUsageCell, + runtimeHasPlanUsage, +} from "./runtime-plan-usage-cell"; import { customRuntimeRegistrationFailure, isDisabledCustomRuntime, @@ -85,7 +91,7 @@ import { useT, useTimeAgo } from "../../i18n"; // operation) is deliberately not offered. const GRID_COLS = "grid-cols-[0.75rem_minmax(120px,1fr)_var(--rtc-health)_var(--rtc-kebab)_0.75rem] " + - "@2xl:grid-cols-[0.75rem_minmax(140px,1fr)_var(--rtc-health)_var(--rtc-owner)_var(--rtc-agents)_var(--rtc-cost)_var(--rtc-cli)_var(--rtc-kebab)_0.75rem]"; + "@2xl:grid-cols-[0.75rem_minmax(140px,1fr)_var(--rtc-health)_var(--rtc-owner)_var(--rtc-agents)_var(--rtc-cost)_var(--rtc-usage)_var(--rtc-cli)_var(--rtc-kebab)_0.75rem]"; const COLUMN_WIDTHS = { // Health folds the workload in as a suffix ("Healthy · 2 running") — @@ -94,13 +100,14 @@ const COLUMN_WIDTHS = { owner: 96, agents: 92, cost: 96, + usage: 112, cli: 112, } as const; -// Fixed tracks (edges 12+12, name min 140) plus the 8 gap-x-3 gaps -// between the wide template's 9 tracks (zero-width tracks still carry +// Fixed tracks (edges 12+12, name min 140) plus the 9 gap-x-3 gaps +// between the wide template's 10 tracks (zero-width tracks still carry // gaps). -const FIXED_TRACKS_WIDTH = 164 + 8 * 12; +const FIXED_TRACKS_WIDTH = 164 + 9 * 12; // The kebab track is conditional like the owner column: on a list where // no row carries a delete-permission, EVERY row's only action is hidden, @@ -116,6 +123,7 @@ function columnTrackVars( (showOwner ? COLUMN_WIDTHS.owner : 0) + COLUMN_WIDTHS.agents + COLUMN_WIDTHS.cost + + COLUMN_WIDTHS.usage + COLUMN_WIDTHS.cli + (showActions ? 28 : 0); return { @@ -123,6 +131,7 @@ function columnTrackVars( "--rtc-owner": showOwner ? `${COLUMN_WIDTHS.owner}px` : "0px", "--rtc-agents": `${COLUMN_WIDTHS.agents}px`, "--rtc-cost": `${COLUMN_WIDTHS.cost}px`, + "--rtc-usage": `${COLUMN_WIDTHS.usage}px`, "--rtc-cli": `${COLUMN_WIDTHS.cli}px`, "--rtc-kebab": showActions ? "1.75rem" : "0px", "--rtc-minw": `${minWidth}px`, @@ -748,6 +757,29 @@ export function RuntimeList({ // width when at least one row will actually show the menu. const showActions = rows.some((row) => row.canDelete); + const planUsageRuntimeIds = useMemo(() => { + const ids: string[] = []; + for (const runtime of runtimes) { + if (!runtimeHasPlanUsage(runtime.provider)) continue; + if (isPendingCustomRuntime(runtime)) continue; + if (!canReadRuntimeUsage(runtime, user?.id ?? null)) continue; + ids.push(runtime.id); + } + ids.sort(); + return ids; + }, [runtimes, user?.id]); + const { data: planUsage, isLoading: planUsageLoading } = useQuery( + runtimeProviderUsageListOptions(wsId, planUsageRuntimeIds), + ); + const planUsageByRuntimeId = useMemo(() => { + const map = new Map(); + for (const item of planUsage?.runtimes ?? []) { + if (!item.runtime_id) continue; + map.set(item.runtime_id, item.providers ?? []); + } + return map; + }, [planUsage]); + return (
{t(($) => $.list.col_cost)} + + {t(($) => $.list.col_usage)} + {t(($) => $.list.col_cli)} @@ -830,6 +865,17 @@ export function RuntimeList({ /> )} + + + diff --git a/packages/views/runtimes/components/runtime-plan-usage-cell.test.tsx b/packages/views/runtimes/components/runtime-plan-usage-cell.test.tsx new file mode 100644 index 00000000000..374dd634bc8 --- /dev/null +++ b/packages/views/runtimes/components/runtime-plan-usage-cell.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../locales/en/common.json"; +import enRuntimes from "../../locales/en/runtimes.json"; +import { RuntimePlanUsageCell } from "./runtime-plan-usage-cell"; + +const TEST_RESOURCES = { en: { common: enCommon, runtimes: enRuntimes } }; + +function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +const NOW = Date.parse("2026-09-22T12:00:00.000Z"); + +describe("RuntimePlanUsageCell", () => { + it("shows the headline percent and a short reset for a snapshot", () => { + const resetsAt = "2026-09-22T14:00:00.000Z"; + render( + , + { wrapper: Wrapper }, + ); + + expect(screen.getByText("38%")).toBeInTheDocument(); + expect(screen.queryByText("4%")).not.toBeInTheDocument(); + expect( + screen.getByText( + new Intl.RelativeTimeFormat("en", { numeric: "always", style: "narrow" }).format( + 2, + "hour", + ), + ), + ).toBeInTheDocument(); + expect(screen.queryByText("Max")).not.toBeInTheDocument(); + }); + + it("shows the plan name when the snapshot has no short reset", () => { + render( + , + { wrapper: Wrapper }, + ); + + expect(screen.getByText("12%")).toBeInTheDocument(); + expect(screen.getByText("Pro")).toBeInTheDocument(); + expect(screen.queryByText("80%")).not.toBeInTheDocument(); + }); + + it("renders an empty cell when the provider is not logged in", () => { + render( + , + { wrapper: Wrapper }, + ); + + expect(screen.getByText("—")).toBeInTheDocument(); + expect(screen.queryByText(/%/)).not.toBeInTheDocument(); + }); + + it("shows the Grok credits headline and ignores other vendors", () => { + render( + , + { wrapper: Wrapper }, + ); + + expect(screen.getByText("8%")).toBeInTheDocument(); + expect(screen.getByText("Grok Build")).toBeInTheDocument(); + expect(screen.queryByText("38%")).not.toBeInTheDocument(); + }); + + it("renders an empty cell for a provider with no plan limits", () => { + render( + , + { wrapper: Wrapper }, + ); + + expect(screen.getByText("—")).toBeInTheDocument(); + expect(screen.queryByText("38%")).not.toBeInTheDocument(); + expect(screen.queryByText("90%")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/views/runtimes/components/runtime-plan-usage-cell.tsx b/packages/views/runtimes/components/runtime-plan-usage-cell.tsx new file mode 100644 index 00000000000..a394bc3968c --- /dev/null +++ b/packages/views/runtimes/components/runtime-plan-usage-cell.tsx @@ -0,0 +1,142 @@ +"use client"; + +import type { ProviderUsageSnapshot } from "@multica/core/types"; +import { Skeleton } from "@multica/ui/components/ui/skeleton"; +import { useT } from "../../i18n"; + +const PLAN_LIMIT_PROVIDERS = new Set([ + "claude", + "cursor", + "codex", + "copilot", + "antigravity", + "grok", + "kimi", + "kiro", + "opencode", +]); + +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; + +export function runtimeHasPlanUsage(provider: string | undefined): boolean { + return PLAN_LIMIT_PROVIDERS.has(normalizeProvider(provider)); +} + +/** Snapshots for this runtime's protocol family. Other vendors on the same + * machine, and families with no plan window, stay out of the detail view. */ +export function planUsageSnapshotsForProvider( + provider: string | undefined, + providers: readonly ProviderUsageSnapshot[] | undefined, +): ProviderUsageSnapshot[] { + const key = normalizeProvider(provider); + if (!runtimeHasPlanUsage(key)) return []; + return (providers ?? []).filter( + (item) => normalizeProvider(item.provider) === key, + ); +} + +interface RuntimePlanUsageCellProps { + provider: string | undefined; + providers: readonly ProviderUsageSnapshot[] | undefined; + loading?: boolean; + now?: number; +} + +export function RuntimePlanUsageCell({ + provider, + providers, + loading = false, + now, +}: RuntimePlanUsageCellProps) { + const { t, i18n } = useT("runtimes"); + const locale = i18n.resolvedLanguage ?? i18n.language; + if (loading && runtimeHasPlanUsage(provider)) { + return ; + } + const headline = planUsageHeadline(provider, providers, locale, now ?? Date.now()); + if (!headline) { + return —; + } + const used = t(($) => $.usage.provider_limits.used, { pct: headline.percent }); + const label = headline.detail ? `${used}, ${headline.detail}` : used; + return ( +
+ {headline.percent}% + {headline.detail ? ( + {headline.detail} + ) : null} + {label} +
+ ); +} + +function planUsageHeadline( + provider: string | undefined, + providers: readonly ProviderUsageSnapshot[] | undefined, + locale: string, + now: number, +): { percent: number; detail: string } | null { + const key = normalizeProvider(provider); + const snapshot = planUsageSnapshotsForProvider(key, providers)[0]; + const windows = snapshot?.windows ?? []; + const preferred = headlineWindowId(key); + const headline = + windows.find((window) => window.id === preferred && usablePercent(window.percent_used)) ?? + windows.find((window) => usablePercent(window.percent_used)); + if (!headline || !usablePercent(headline.percent_used)) return null; + const reset = shortResetLabel(headline.resets_at, locale, now); + const plan = snapshot?.plan_name?.trim() ?? ""; + return { + percent: Math.round(headline.percent_used), + detail: reset || plan, + }; +} + +function headlineWindowId(provider: string): string { + switch (provider) { + case "claude": + return "session"; + case "cursor": + return "auto"; + case "codex": + return "primary"; + case "copilot": + return "premium_interactions"; + case "kimi": + case "opencode": + return "rolling"; + case "grok": + case "kiro": + return "credits"; + case "antigravity": + return "gemini_hourly"; + default: + return ""; + } +} + +function usablePercent(value: number | undefined): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1000; +} + +function shortResetLabel(value: string | undefined, locale: string, now: number): string { + if (!value) return ""; + const time = new Date(value).getTime(); + if (!Number.isFinite(time)) return ""; + const delta = time - now; + if (delta <= 0 || delta > 14 * DAY_MS) return ""; + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "always", style: "narrow" }); + if (delta < HOUR_MS) { + return rtf.format(Math.max(1, Math.round(delta / MINUTE_MS)), "minute"); + } + if (delta < 2 * DAY_MS) { + return rtf.format(Math.round(delta / HOUR_MS), "hour"); + } + return rtf.format(Math.round(delta / DAY_MS), "day"); +} + +function normalizeProvider(provider: string | undefined): string { + return provider?.trim().toLowerCase() ?? ""; +} diff --git a/packages/views/runtimes/components/usage-section.test.tsx b/packages/views/runtimes/components/usage-section.test.tsx index 7f903acfe82..04871f230d1 100644 --- a/packages/views/runtimes/components/usage-section.test.tsx +++ b/packages/views/runtimes/components/usage-section.test.tsx @@ -21,6 +21,9 @@ const runtimeUsageOptions = vi.hoisted(() => const runtimeUsageByAgentOptions = vi.hoisted(() => vi.fn((..._args: unknown[]) => ({ kind: "by-agent" as const })), ); +const runtimeProviderUsageOptions = vi.hoisted(() => + vi.fn((..._args: unknown[]) => ({ kind: "provider-usage" as const })), +); vi.mock("../../common/use-viewing-timezone", () => ({ useViewingTimezone: () => VIEWER_TZ, @@ -29,6 +32,7 @@ vi.mock("../../common/use-viewing-timezone", () => ({ vi.mock("@multica/core/runtimes/queries", () => ({ runtimeUsageOptions, runtimeUsageByAgentOptions, + runtimeProviderUsageOptions, })); vi.mock("@multica/core/workspace/queries", () => ({ @@ -102,7 +106,12 @@ vi.mock("@tanstack/react-query", async () => { return { ...actual, useQuery: (opts: { kind?: string }) => ({ - data: opts?.kind === "usage" ? (usageOverride.rows ?? usageRows) : [], + data: + opts?.kind === "usage" + ? (usageOverride.rows ?? usageRows) + : opts?.kind === "provider-usage" + ? { providers: [] } + : [], isLoading: false, }), }; diff --git a/packages/views/runtimes/components/usage-section.tsx b/packages/views/runtimes/components/usage-section.tsx index 48dac7afc8e..c006fa4709e 100644 --- a/packages/views/runtimes/components/usage-section.tsx +++ b/packages/views/runtimes/components/usage-section.tsx @@ -44,6 +44,7 @@ import { ActivityHeatmap, } from "./charts"; import { CustomPricingDialog } from "./custom-pricing-dialog"; +import { ProviderUsageBlock } from "./provider-usage-block"; import { useT } from "../../i18n"; // Single source of truth for the period selector. KPIs, the When-chart, the @@ -131,6 +132,7 @@ function Segmented({ export function UsageSection({ runtime }: { runtime: AgentRuntime }) { const { t, i18n } = useT("runtimes"); + const wsId = useWorkspaceId(); const runtimeId = runtime.id; // Reports render in the viewer's timezone — the backend slices the UTC // hourly rollup on the same `tz` we pass here, so every frontend window @@ -147,8 +149,30 @@ export function UsageSection({ runtime }: { runtime: AgentRuntime }) { // subscribe on their own and pass pricings as a memo dep there. useCustomPricingStore((s) => s.pricings); - if (loading) return ; - if (usage.length === 0) return ; + const planLimits = ( + + ); + + if (loading) { + return ( +
+ {planLimits} + +
+ ); + } + if (usage.length === 0) { + return ( +
+ {planLimits} + +
+ ); + } // Slice the cached 180-day window into the user's selected sub-window AND // the immediately prior window of equal length. The KPI delta ("+18% vs @@ -183,6 +207,7 @@ export function UsageSection({ runtime }: { runtime: AgentRuntime }) { return (
+ {planLimits} {/* Page-wide period selector. Lives at the top because it controls basically everything below: the KPI numbers and labels, the daily / weekly chart window, and the cost-by aggregations. The diff --git a/server/cmd/migrate/main.go b/server/cmd/migrate/main.go index 088c1f31dba..ffd6f151d76 100644 --- a/server/cmd/migrate/main.go +++ b/server/cmd/migrate/main.go @@ -348,6 +348,8 @@ var concurrentIndexCleanups = map[string]string{ "482_agent_task_queue_telemetry_started_index": "idx_agent_task_queue_telemetry_started", "484_issue_triage_state_index": "idx_issue_triage_state", "537_issue_duplicate_of_index": "idx_issue_duplicate_of", + "565_runtime_provider_usage_snapshot_key": "idx_runtime_provider_usage_snapshot_key", + "566_runtime_provider_usage_snapshot_workspace": "idx_runtime_provider_usage_snapshot_workspace", } // concurrentDownIndexCleanups covers every migration whose down direction diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index adca0985ddf..959e798430f 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -1594,6 +1594,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus r.Post("/tasks/{taskId}/complete", h.CompleteTask) r.Post("/tasks/{taskId}/fail", h.FailTask) r.Post("/tasks/{taskId}/usage", h.ReportTaskUsage) + r.Post("/runtimes/{runtimeId}/provider-usage", h.ReportProviderUsage) r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages) r.Get("/tasks/{taskId}/messages", h.ListTaskMessages) r.Post("/tasks/{taskId}/cancel-ack", h.AckTaskCancelled) @@ -2323,9 +2324,13 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus // Runtimes r.Route("/api/runtimes", func(r chi.Router) { r.Get("/", h.ListAgentRuntimes) + // Static path before /{runtimeId} so "provider-usage" is not + // captured as a runtime id. One read for the machine table. + r.Get("/provider-usage", h.ListRuntimesProviderUsage) r.Route("/{runtimeId}", func(r chi.Router) { r.Patch("/", h.UpdateAgentRuntime) r.Get("/usage", h.GetRuntimeUsage) + r.Get("/provider-usage", h.GetRuntimeProviderUsage) r.Get("/usage/by-agent", h.GetRuntimeUsageByAgent) r.Get("/usage/by-hour", h.GetRuntimeUsageByHour) r.Get("/activity", h.GetRuntimeTaskActivity) diff --git a/server/go.mod b/server/go.mod index cd664420bcf..d25a0383baa 100644 --- a/server/go.mod +++ b/server/go.mod @@ -55,27 +55,36 @@ require ( github.com/aws/smithy-go v1.28.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/prometheus/common v0.71.0 // indirect github.com/prometheus/procfs v0.22.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect go.uber.org/atomic v1.11.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.41.0 // indirect golang.org/x/net v0.59.0 // indirect golang.org/x/telemetry v0.0.0-20260910141331-15ceca2b0a1f // indirect golang.org/x/text v0.42.0 // indirect golang.org/x/tools v0.50.0 // indirect golang.org/x/vuln v1.8.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.39.1 // indirect ) tool golang.org/x/vuln/cmd/govulncheck diff --git a/server/go.sum b/server/go.sum index 269da211a22..2e3e1dc4263 100644 --- a/server/go.sum +++ b/server/go.sum @@ -47,6 +47,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= @@ -93,10 +95,14 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lmittmann/tint v1.2.0 h1:AogHRHy8HUJUnNJBHJlYa+fR4YY8mko2cnCp67xn9JY= github.com/lmittmann/tint v1.2.0/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-shellwords v1.0.14 h1:yUKzIgsCnosndOASY6/enly1EAuaXeFSQ7cdyA3OuYg= github.com/mattn/go-shellwords v1.0.14/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= @@ -121,6 +127,8 @@ github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0o github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/resend/resend-go/v2 v2.28.0 h1:ttM1/VZR4fApBv3xI1TneSKi1pbfFsVrq7fXFlHKtj4= github.com/resend/resend-go/v2 v2.28.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -168,12 +176,15 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20260910141331-15ceca2b0a1f h1:5LLXkapW8RSq5f6l0ztG82Xt1OYp5uRIyMHuZVmkm4k= @@ -202,3 +213,11 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index 66c83217021..92fb76ca371 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -654,6 +654,27 @@ func (c *Client) ReportTaskUsage(ctx context.Context, taskID string, usage []Tas }, nil) } +// ProviderUsageWindowReport is one derived limit window. It never carries a +// token, cookie, or auth.json field. +type ProviderUsageWindowReport struct { + ID string `json:"id"` + PercentUsed float64 `json:"percent_used"` + ResetsAt *time.Time `json:"resets_at,omitempty"` +} + +// ProviderUsageReport is the daemon upload for one provider's plan limits. +type ProviderUsageReport struct { + Provider string `json:"provider"` + PlanName string `json:"plan_name,omitempty"` + CollectedAt time.Time `json:"collected_at"` + ReasonCode string `json:"reason_code,omitempty"` + Windows []ProviderUsageWindowReport `json:"windows,omitempty"` +} + +func (c *Client) ReportProviderUsage(ctx context.Context, runtimeID string, report ProviderUsageReport) error { + return c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/provider-usage", runtimeID), report, nil) +} + func (c *Client) FailTask(ctx context.Context, taskID, errMsg, sessionID, workDir, branchName, failureReason string, sessionRolloutMissing bool, retiredSessionID, durableWorkDir string) error { return c.failTaskWithRetrySchedule(ctx, taskID, errMsg, sessionID, workDir, branchName, failureReason, sessionRolloutMissing, retiredSessionID, durableWorkDir, defaultTerminalRetrySchedule) } diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 85745dab5cd..f41919e4778 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -2168,6 +2168,7 @@ func (d *Daemon) Run(ctx context.Context) error { taskWakeups := make(chan taskWakeup, 256) go d.taskWakeupLoop(ctx, taskWakeups) go d.heartbeatLoop(ctx) + go d.providerUsageLoop(ctx) go d.gcLoop(ctx) go d.autoUpdateLoop(ctx) go d.tokenRenewalLoop(ctx) diff --git a/server/internal/daemon/provider_usage_loop.go b/server/internal/daemon/provider_usage_loop.go new file mode 100644 index 00000000000..be2eac8d83c --- /dev/null +++ b/server/internal/daemon/provider_usage_loop.go @@ -0,0 +1,102 @@ +package daemon + +import ( + "context" + "time" + + "github.com/multica-ai/multica/server/internal/daemon/providerusage" +) + +const ( + providerUsageInitialDelay = time.Minute + providerUsageInterval = 5 * time.Minute +) + +// providerUsageLoop samples local CLI and editor plan limits and uploads +// derived snapshots. A collection failure is logged and skipped; it does not +// affect task execution. +func (d *Daemon) providerUsageLoop(ctx context.Context) { + timer := time.NewTimer(providerUsageInitialDelay) + defer timer.Stop() + backoffUntil := map[string]time.Time{} + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + d.collectProviderUsage(ctx, backoffUntil) + timer.Reset(providerUsageInterval) + } + } +} + +func (d *Daemon) collectProviderUsage(ctx context.Context, backoffUntil map[string]time.Time) { + if d.client == nil || len(d.allRuntimeIDs()) == 0 { + return + } + now := time.Now() + // Left uncollected after CodeNotch 1.22.0 (v1.21.0...v1.22.0): + // Qoder — qoder and qoderclicn are Multica runtimes, but the credit + // ring is a WebKit sign-in on qoder.com or qoder.com.cn + // (GET /api/v2/me/usages/big_model_credits). That is a website + // cookie, not a local CLI session. + // Command Code — not a Multica runtime. Extra ~/.commandcode- + // homes each hold an apiKey; they do not add a runtime to attach. + // DeepSeek — platform.deepseek.com wallet. Picking the funded wallet + // still reports prepaid spend, not a signed-in plan window. The dsh + // runtime does not expose that wallet locally. + // Qianwen, Gemini CLI, Amp, Devin, GLM, MiniMax, Ollama, LM Studio, and + // Perplexity stay out for the same reasons: not a Multica runtime, or + // only a browser cookie or a local token ledger. + collectors := []struct { + provider string + collect func(context.Context) providerusage.Result + }{ + {providerusage.ProviderClaude, providerusage.ClaudeCollector{}.Collect}, + {providerusage.ProviderCursor, providerusage.CursorCollector{}.Collect}, + {providerusage.ProviderCodex, providerusage.CodexCollector{}.Collect}, + {providerusage.ProviderCopilot, providerusage.CopilotCollector{}.Collect}, + {providerusage.ProviderAntigravity, providerusage.AntigravityCollector{}.Collect}, + {providerusage.ProviderGrok, providerusage.GrokCollector{}.Collect}, + {providerusage.ProviderKimi, providerusage.KimiCollector{}.Collect}, + {providerusage.ProviderKiro, providerusage.KiroCollector{}.Collect}, + {providerusage.ProviderOpenCode, providerusage.OpenCodeCollector{}.Collect}, + } + for _, item := range collectors { + if until, ok := backoffUntil[item.provider]; ok && now.Before(until) { + continue + } + delete(backoffUntil, item.provider) + result := item.collect(ctx) + if result.Backoff > 0 { + backoffUntil[item.provider] = now.Add(result.Backoff) + } + if !result.Upload { + d.logger.Debug("provider usage collection kept the last snapshot", "provider", item.provider) + continue + } + report := providerUsageReportFrom(result.Snapshot) + for _, runtimeID := range d.allRuntimeIDs() { + if err := d.client.ReportProviderUsage(ctx, runtimeID, report); err != nil { + d.logger.Warn("provider usage upload failed", "provider", item.provider, "runtime_id", runtimeID, "error", err) + } + } + } +} + +func providerUsageReportFrom(snapshot providerusage.Snapshot) ProviderUsageReport { + report := ProviderUsageReport{ + Provider: snapshot.Provider, + PlanName: snapshot.PlanName, + CollectedAt: snapshot.CollectedAt, + ReasonCode: snapshot.ReasonCode, + } + for _, window := range snapshot.Windows { + report.Windows = append(report.Windows, ProviderUsageWindowReport{ + ID: window.ID, + PercentUsed: window.PercentUsed, + ResetsAt: window.ResetsAt, + }) + } + return report +} diff --git a/server/internal/daemon/providerusage/antigravity.go b/server/internal/daemon/providerusage/antigravity.go new file mode 100644 index 00000000000..467afdfaaee --- /dev/null +++ b/server/internal/daemon/providerusage/antigravity.go @@ -0,0 +1,293 @@ +package providerusage + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const antigravityQuotaURL = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary" + +var antigravityWindowOrder = []string{ + "gemini_hourly", + "gemini_weekly", + "third_party_hourly", + "third_party_weekly", +} + +// AntigravityCollector reads Cloud Code quota with the OAuth file Gemini / +// Antigravity already wrote. It does not prompt the keychain or scan for a +// local language-server port. An expired token is left for the CLI to refresh. +// +// CodeNotch 1.21.0 starts Antigravity's language_server when no IDE or CLI +// process is listening. This collector does not. That child reads the login +// keychain, may refresh the session, and is kept for later polls: it binds +// quickly, then spends several seconds authenticating. The binary CodeNotch +// launches is the macOS app bundle; that release does not spawn one on +// Windows. ~/.gemini/oauth_creds.json remains after the IDE exits, so a +// closed IDE is already covered here. A vendor 403 uploads an empty +// unauthorized snapshot. +// +// CodeNotch 1.22.0 (#428) parses agy 1.2.16 quota text on Windows. Under +// ConPTY the CLI prints each reset as local wall-clock time plus a zone +// abbreviation ("2026-10-10 22:31 AEDT") instead of RFC 3339. This +// collector does not run agy. Cloud Code JSON resetTime is still an +// RFC 3339 string or a unix timestamp, which parseResetValue already +// accepts. The CLI's local form is not a resetTime value here, so it is +// left unparsed rather than guessed from the host zone. +type AntigravityCollector struct { + AuthPath string + Do HTTPDoer + Now func() time.Time +} + +func (c AntigravityCollector) Collect(ctx context.Context) Result { + now := time.Now() + if c.Now != nil { + now = c.Now() + } + path := c.AuthPath + if path == "" { + path = antigravityAuthPath() + } + token, project, expired, found := loadAntigravityToken(path, now) + if !found { + return emptyResult(ProviderAntigravity, ReasonSessionUnavailable, now) + } + if expired { + return Result{Upload: false} + } + payload := map[string]string{} + if project != "" { + payload["project"] = project + } + raw, err := json.Marshal(payload) + if err != nil { + return Result{Upload: false} + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, antigravityQuotaURL, bytes.NewReader(raw)) + if err != nil { + return Result{Upload: false} + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Client-Metadata", "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI") + body, status, retryAfter, err := performVendor(c.Do, req) + if err != nil { + return Result{Upload: false} + } + reason, backoff, transient := classifyVendorStatus(status, retryAfter) + if transient { + return Result{Upload: false, Backoff: backoff} + } + if reason != "" { + return emptyResult(ProviderAntigravity, reason, now) + } + windows, plan, ok := ParseAntigravityQuota(body, now) + if !ok { + return Result{Upload: false} + } + return Result{ + Upload: true, + Snapshot: Snapshot{ + Provider: ProviderAntigravity, + PlanName: plan, + CollectedAt: now, + Windows: windows, + }, + } +} + +func antigravityAuthPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".gemini", "oauth_creds.json") +} + +func loadAntigravityToken(path string, now time.Time) (token, project string, expired, found bool) { + body, err := readRegularFile(path) + if err != nil { + return "", "", false, false + } + root, ok := decodeObject(body) + if !ok { + return "", "", false, false + } + token = strings.TrimSpace(stringField(root, "access_token")) + if token == "" { + return "", "", false, false + } + project = stringField(root, "projectId") + if project == "" { + project = stringField(root, "project_id") + } + if expiry, hasExpiry := asFloat(root["expiry_date"]); hasExpiry && expiry > 0 { + when := time.UnixMilli(int64(expiry)) + if !when.After(now) { + return "", project, true, true + } + } + return token, project, false, true +} + +// ParseAntigravityQuota reduces grouped or per-model quota buckets to the +// four windows the runtime page can show. Remaining fraction is converted +// to percent used. +func ParseAntigravityQuota(body []byte, now time.Time) (windows []Window, plan string, ok bool) { + root, parsed := decodeObject(body) + if !parsed { + return nil, "", false + } + plan = antigravityPlan(root) + best := map[string]Window{} + groups := antigravityGroups(root) + if len(groups) > 0 { + for _, item := range groups { + group := asMap(item) + family := antigravityFamily(stringField(group, "displayName")) + for _, bucketItem := range asSlice(group["buckets"]) { + bucket := asMap(bucketItem) + bucketFamily := family + if bucketFamily == "" { + bucketFamily = antigravityFamily(strings.Join([]string{ + stringField(bucket, "modelId"), + stringField(bucket, "name"), + stringField(bucket, "bucketId"), + }, " ")) + } + antigravityConsider(best, bucket, bucketFamily, now) + } + } + } else { + for _, bucketItem := range asSlice(root["buckets"]) { + bucket := asMap(bucketItem) + family := antigravityFamily(strings.Join([]string{ + stringField(bucket, "modelId"), + stringField(bucket, "name"), + stringField(bucket, "bucketId"), + }, " ")) + antigravityConsider(best, bucket, family, now) + } + } + for _, id := range antigravityWindowOrder { + if window, exists := best[id]; exists { + windows = append(windows, window) + } + } + return windows, plan, len(windows) > 0 +} + +func antigravityGroups(root map[string]any) []any { + for _, key := range []string{"groups", "quotaGroups"} { + if items := asSlice(root[key]); len(items) > 0 { + return items + } + } + for _, key := range []string{"response", "summary"} { + if items := asSlice(asMap(root[key])["groups"]); len(items) > 0 { + return items + } + } + return nil +} + +func antigravityPlan(root map[string]any) string { + if name := stringField(asMap(root["currentTier"]), "name"); name != "" { + return trimPlan(name) + } + return "" +} + +func antigravityConsider(best map[string]Window, bucket map[string]any, family string, now time.Time) { + if bucket == nil || family == "" { + return + } + if disabled, isBool := bucket["disabled"].(bool); isBool && disabled { + return + } + model := strings.ToLower(stringField(bucket, "modelId")) + if strings.HasPrefix(model, "chat_") { + return + } + percent, hasPercent := antigravityPercent(bucket) + if !hasPercent { + return + } + cadence := antigravityCadence(bucket, now) + id := family + "_" + cadence + window := Window{ID: id, PercentUsed: percent, ResetsAt: parseResetValue(bucket["resetTime"])} + current, exists := best[id] + if !exists || window.PercentUsed > current.PercentUsed { + best[id] = window + } +} + +func antigravityPercent(bucket map[string]any) (float64, bool) { + if fraction, ok := antigravityFraction(bucket); ok { + return usablePercent((1 - fraction) * 100) + } + limit, hasLimit := asFloat(bucket["limit"]) + used, hasUsed := asFloat(bucket["used"]) + if !hasLimit || !hasUsed || limit <= 0 || used < 0 || used > limit*1.5 { + return 0, false + } + return usablePercent(used / limit * 100) +} + +func antigravityFraction(bucket map[string]any) (float64, bool) { + if fraction, ok := asFloat(bucket["remainingFraction"]); ok && fraction >= 0 && fraction <= 1 { + return fraction, true + } + remaining := asMap(bucket["remaining"]) + if remaining == nil { + return 0, false + } + if fraction, ok := asFloat(remaining["remainingFraction"]); ok && fraction >= 0 && fraction <= 1 { + return fraction, true + } + if stringField(remaining, "case") == "remainingFraction" { + if fraction, ok := asFloat(remaining["value"]); ok && fraction >= 0 && fraction <= 1 { + return fraction, true + } + } + return 0, false +} + +func antigravityFamily(text string) string { + lower := strings.ToLower(text) + switch { + case strings.Contains(lower, "gemini"): + return "gemini" + case strings.Contains(lower, "claude") || strings.Contains(lower, "gpt") || strings.Contains(lower, "openai"): + return "third_party" + default: + return "" + } +} + +func antigravityCadence(bucket map[string]any, now time.Time) string { + for _, key := range []string{"window", "bucketId", "displayName", "name"} { + lower := strings.ToLower(stringField(bucket, key)) + if lower == "" { + continue + } + if strings.Contains(lower, "week") { + return "weekly" + } + if strings.Contains(lower, "hour") || strings.Contains(lower, "5h") || strings.Contains(lower, "session") { + return "hourly" + } + } + if reset := parseResetValue(bucket["resetTime"]); reset != nil && reset.Sub(now) > 24*time.Hour { + return "weekly" + } + return "hourly" +} diff --git a/server/internal/daemon/providerusage/antigravity_test.go b/server/internal/daemon/providerusage/antigravity_test.go new file mode 100644 index 00000000000..61a56266652 --- /dev/null +++ b/server/internal/daemon/providerusage/antigravity_test.go @@ -0,0 +1,115 @@ +package providerusage + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestParseAntigravityGroupedQuota(t *testing.T) { + body := []byte(`{ + "currentTier": {"name": "Pro"}, + "groups": [ + {"displayName": "Gemini Models", "buckets": [ + {"displayName": "5-hour Limit", "remainingFraction": 0.8, "resetTime": "2026-09-23T18:00:00Z"}, + {"displayName": "Weekly Limit", "remainingFraction": 0.5, "resetTime": "2026-09-28T00:00:00Z"} + ]}, + {"displayName": "Claude and GPT models", "buckets": [ + {"bucketId": "session", "remainingFraction": 0.9, "resetTime": "2026-09-23T18:00:00Z"}, + {"displayName": "Weekly Limit", "remaining": {"case": "remainingFraction", "value": 0.25}} + ]} + ] + }`) + now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) + windows, plan, ok := ParseAntigravityQuota(body, now) + if !ok || plan != "Pro" || len(windows) != 4 { + t.Fatalf("windows=%+v plan=%q ok=%v", windows, plan, ok) + } + want := []struct { + id string + percent float64 + }{ + {"gemini_hourly", 20}, + {"gemini_weekly", 50}, + {"third_party_hourly", 10}, + {"third_party_weekly", 75}, + } + for i, item := range want { + if windows[i].ID != item.id || windows[i].PercentUsed != item.percent { + t.Fatalf("window %d = %+v", i, windows[i]) + } + } +} + +func TestAntigravityMissingSessionDoesNotCallVendor(t *testing.T) { + c := AntigravityCollector{ + AuthPath: filepath.Join(t.TempDir(), "oauth_creds.json"), + Do: func(*http.Request) (*http.Response, error) { + t.Fatal("missing antigravity session must not call Google") + return nil, nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonSessionUnavailable { + t.Fatalf("result = %+v", got) + } +} + +func TestAntigravityExpiredTokenSkipsVendor(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "oauth_creds.json") + raw := []byte(`{"access_token":"ya29.expired","expiry_date":1000}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + c := AntigravityCollector{ + AuthPath: path, + Now: func() time.Time { return time.UnixMilli(5000) }, + Do: func(*http.Request) (*http.Response, error) { + t.Fatal("expired antigravity token must not call Google") + return nil, nil + }, + } + got := c.Collect(t.Context()) + if got.Upload { + t.Fatalf("expired token uploaded %+v", got) + } +} + +func TestAntigravityCollectorUploadsPercentsNotToken(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "oauth_creds.json") + const token = "ya29.local-session" + raw := []byte(`{"access_token":"` + token + `","expiry_date":5000,"projectId":"proj-1"}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + c := AntigravityCollector{ + AuthPath: path, + Now: func() time.Time { return time.UnixMilli(1000) }, + Do: func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost { + t.Fatalf("method = %s", req.Method) + } + if req.Header.Get("Authorization") != "Bearer "+token { + t.Fatalf("auth = %q", req.Header.Get("Authorization")) + } + payload, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(payload), `"project":"proj-1"`) || strings.Contains(string(payload), token) { + t.Fatalf("body = %s", payload) + } + return jsonResponse(http.StatusOK, `{"groups":[{"displayName":"Gemini Models","buckets":[{"displayName":"5-hour Limit","remainingFraction":0.6}]}]}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || len(got.Snapshot.Windows) != 1 || got.Snapshot.Windows[0].ID != "gemini_hourly" || got.Snapshot.Windows[0].PercentUsed != 40 { + t.Fatalf("result = %+v", got) + } +} diff --git a/server/internal/daemon/providerusage/claude.go b/server/internal/daemon/providerusage/claude.go new file mode 100644 index 00000000000..b38a3a1a516 --- /dev/null +++ b/server/internal/daemon/providerusage/claude.go @@ -0,0 +1,369 @@ +package providerusage + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + _ "time/tzdata" +) + +// claudeUsageArgs is the print-mode invocation that reads /usage without +// writing a session transcript or starting the user's MCP servers. It does +// not refresh OAuth. +var claudeUsageArgs = []string{"--print", "--no-session-persistence", "--strict-mcp-config", "/usage"} + +var claudeUsageLine = regexp.MustCompile(`(?i)^Current (?:(session)|week \(([^)]+)\)):\s*(\d+(?:\.\d+)?)%\s*used(?:\s*[·|\-]\s*resets\s*(.+?))?\s*$`) + +var claudeResetLine = regexp.MustCompile(`(?i)^([A-Za-z]{3})\s+(\d{1,2})\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s*\(([^)]+)\))?`) + +var claudePlanPhrases = []string{"Max 20x", "Max 5x", "extra usage", "Max", "Pro", "Team"} + +// ClaudeCommand runs claude and returns stdout, a short stderr prefix, and +// the exit code. Tests inject this so the real CLI is never executed. +type ClaudeCommand func(ctx context.Context, path string, args []string, dir string) (stdout, stderr string, exitCode int, err error) + +// ClaudeCollector reads plan limits from `claude /usage` stdout. +type ClaudeCollector struct { + // Locate returns the claude binary. Nil uses the production search. + Locate func() (string, error) + // Run executes the binary. Nil uses exec.CommandContext. + Run ClaudeCommand + Now func() time.Time +} + +func (c ClaudeCollector) Collect(ctx context.Context) Result { + now := time.Now() + if c.Now != nil { + now = c.Now() + } + locate := c.Locate + if locate == nil { + locate = locateClaude + } + path, err := locate() + if err != nil || path == "" { + return emptyResult(ProviderClaude, ReasonCLIUnavailable, now) + } + run := c.Run + if run == nil { + run = runClaude + } + dir, _ := claudeScratchDir() + stdout, stderr, _, runErr := run(ctx, path, claudeUsageArgs, dir) + if runErr != nil && stdout == "" { + return Result{Upload: false} + } + windows, plan, ok := ParseClaudeUsage(stdout, now) + if ok { + return Result{ + Upload: true, + Snapshot: Snapshot{ + Provider: ProviderClaude, + PlanName: plan, + CollectedAt: now, + Windows: windows, + }, + } + } + if claudeLooksLoggedOut(stdout) || claudeLooksLoggedOut(stderr) { + return emptyResult(ProviderClaude, ReasonNotLoggedIn, now) + } + return Result{Upload: false} +} + +func emptyResult(provider, reason string, now time.Time) Result { + return Result{ + Upload: true, + Snapshot: Snapshot{ + Provider: provider, + PlanName: "", + CollectedAt: now, + ReasonCode: reason, + }, + } +} + +// ParseClaudeUsage extracts session and weekly windows from /usage stdout. +// ok is false when the text has no limit line. A window whose reset text +// does not parse is kept with a nil reset. +func ParseClaudeUsage(text string, now time.Time) (windows []Window, plan string, ok bool) { + plan = claudePlan(text) + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + m := claudeUsageLine.FindStringSubmatch(line) + if m == nil { + continue + } + percent, err := parsePercent(m[3]) + if err != nil { + continue + } + id := "session" + if m[1] == "" { + id = weeklyWindowID(m[2]) + } + var resets *time.Time + if strings.TrimSpace(m[4]) != "" { + if t, parsed := parseClaudeReset(m[4], now); parsed { + resets = &t + } + } + windows = append(windows, Window{ID: id, PercentUsed: percent, ResetsAt: resets}) + } + return windows, plan, len(windows) > 0 +} + +func claudePlan(text string) string { + lines := strings.Split(text, "\n") + if len(lines) > 4 { + lines = lines[:4] + } + head := strings.ToLower(strings.Join(lines, "\n")) + for _, phrase := range claudePlanPhrases { + if strings.Contains(head, strings.ToLower(phrase)) { + return phrase + } + } + return "" +} + +func weeklyWindowID(name string) string { + n := strings.ToLower(strings.TrimSpace(name)) + if n == "all models" { + return "weekly_all" + } + var b strings.Builder + prevUnderscore := false + for _, r := range n { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + prevUnderscore = false + case r == ' ' || r == '-' || r == '_': + if !prevUnderscore && b.Len() > 0 { + b.WriteByte('_') + prevUnderscore = true + } + } + } + id := strings.Trim(b.String(), "_") + if id == "" { + return "weekly" + } + return "weekly_" + id +} + +func parsePercent(s string) (float64, error) { + var v float64 + _, err := fmt.Sscanf(s, "%f", &v) + if err != nil { + return 0, err + } + return v, nil +} + +func parseClaudeReset(fragment string, now time.Time) (time.Time, bool) { + m := claudeResetLine.FindStringSubmatch(strings.TrimSpace(fragment)) + if m == nil { + return time.Time{}, false + } + month, ok := claudeMonth(m[1]) + if !ok { + return time.Time{}, false + } + day := atoi(m[2]) + hour := to24(atoi(m[3]), strings.ToLower(m[5])) + minute := 0 + if m[4] != "" { + minute = atoi(m[4]) + } + if hour < 0 || day < 1 || day > 31 || minute < 0 || minute > 59 { + return time.Time{}, false + } + loc := time.UTC + if zone := strings.TrimSpace(m[6]); zone != "" { + loaded, err := time.LoadLocation(zone) + if err != nil { + return time.Time{}, false + } + loc = loaded + } + candidate := time.Date(now.Year(), month, day, hour, minute, 0, 0, loc) + const halfYear = 180 * 24 * time.Hour + if now.Sub(candidate) > halfYear { + candidate = candidate.AddDate(1, 0, 0) + } else if candidate.Sub(now) > halfYear { + candidate = candidate.AddDate(-1, 0, 0) + } + return candidate, true +} + +func claudeMonth(s string) (time.Month, bool) { + switch strings.ToLower(s) { + case "jan": + return time.January, true + case "feb": + return time.February, true + case "mar": + return time.March, true + case "apr": + return time.April, true + case "may": + return time.May, true + case "jun": + return time.June, true + case "jul": + return time.July, true + case "aug": + return time.August, true + case "sep": + return time.September, true + case "oct": + return time.October, true + case "nov": + return time.November, true + case "dec": + return time.December, true + default: + return 0, false + } +} + +func atoi(s string) int { + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + return -1 + } + n = n*10 + int(r-'0') + } + return n +} + +func to24(hour int, ampm string) int { + if hour < 1 || hour > 12 { + return -1 + } + if ampm == "am" { + if hour == 12 { + return 0 + } + return hour + } + if ampm == "pm" { + if hour == 12 { + return 12 + } + return hour + 12 + } + return -1 +} + +func claudeLooksLoggedOut(text string) bool { + lower := strings.ToLower(text) + for _, phrase := range []string{"/login", "not logged", "sign in", "signin", "log in", "authenticate", "unauthorized"} { + if strings.Contains(lower, phrase) { + return true + } + } + return false +} + +func locateClaude() (string, error) { + if path, err := exec.LookPath("claude"); err == nil { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + for _, rel := range []string{ + ".local/bin/claude", + ".claude/local/claude", + ".npm-global/bin/claude", + ".bun/bin/claude", + } { + path := filepath.Join(home, rel) + if isExecutable(path) { + return path, nil + } + } + for _, path := range []string{"/usr/local/bin/claude", "/usr/bin/claude"} { + if isExecutable(path) { + return path, nil + } + } + return "", fmt.Errorf("claude cli not found") +} + +func isExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + return info.Mode()&0o111 != 0 +} + +func claudeScratchDir() (string, error) { + base, err := os.UserCacheDir() + if err != nil { + base = os.TempDir() + } + dir := filepath.Join(base, "multica", "claude-usage-scratch") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return dir, nil +} + +func runClaude(ctx context.Context, path string, args []string, dir string) (string, string, int, error) { + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, path, args...) + if dir != "" { + cmd.Dir = dir + } + cmd.Stdin = bytes.NewReader(nil) + var stdout, stderr bytes.Buffer + cmd.Stdout = &limitedWriter{w: &stdout, n: 256 * 1024} + cmd.Stderr = &limitedWriter{w: &stderr, n: 4 * 1024} + err := cmd.Run() + exitCode := 0 + if err != nil { + if ctx.Err() != nil { + return stdout.String(), stderr.String(), -1, ctx.Err() + } + if ee, ok := err.(*exec.ExitError); ok { + exitCode = ee.ExitCode() + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +type limitedWriter struct { + w io.Writer + n int +} + +func (l *limitedWriter) Write(p []byte) (int, error) { + if l.n <= 0 { + return len(p), nil + } + if len(p) > l.n { + p = p[:l.n] + } + n, err := l.w.Write(p) + l.n -= n + if err != nil { + return n, err + } + return len(p), nil +} diff --git a/server/internal/daemon/providerusage/claude_test.go b/server/internal/daemon/providerusage/claude_test.go new file mode 100644 index 00000000000..92e304b62c8 --- /dev/null +++ b/server/internal/daemon/providerusage/claude_test.go @@ -0,0 +1,157 @@ +package providerusage + +import ( + "context" + "strings" + "testing" + "time" +) + +const claudeLive = `You are currently using your subscription to power your Claude Code usage + +Current session: 38% used · resets Sep 7 at 2:59pm (Asia/Jakarta) +Current week (all models): 4% used · resets Sep 14 at 5:59am (Asia/Jakarta) + +What's contributing to your limits usage? +Approximate, based on local sessions on this machine — does not include other devices. + +Last 24h · 268 requests · 3 sessions + 37% of your usage was at >150k context +` + +func TestParseClaudeUsageLiveFixture(t *testing.T) { + now := time.Date(2026, 9, 7, 6, 0, 0, 0, time.UTC) + windows, plan, ok := ParseClaudeUsage(claudeLive, now) + if !ok { + t.Fatal("expected windows") + } + if plan != "" { + t.Fatalf("plan = %q, want empty", plan) + } + if len(windows) != 2 { + t.Fatalf("windows = %d, want 2 (prose percentages must be ignored)", len(windows)) + } + if windows[0].ID != "session" || windows[0].PercentUsed != 38 { + t.Fatalf("session = %+v", windows[0]) + } + if windows[1].ID != "weekly_all" || windows[1].PercentUsed != 4 { + t.Fatalf("weekly = %+v", windows[1]) + } + if windows[0].ResetsAt == nil || !windows[0].ResetsAt.Equal(time.Date(2026, 9, 7, 7, 59, 0, 0, time.UTC)) { + t.Fatalf("session reset = %v", windows[0].ResetsAt) + } +} + +func TestParseClaudeUsagePlanAndOnTheHour(t *testing.T) { + text := "You are currently using Max 5x\n\nCurrent session: 72% used · resets Sep 7 at 3pm (Asia/Jakarta)\n" + now := time.Date(2026, 9, 7, 6, 0, 0, 0, time.UTC) + windows, plan, ok := ParseClaudeUsage(text, now) + if !ok || plan != "Max 5x" { + t.Fatalf("plan = %q ok=%v", plan, ok) + } + if windows[0].ResetsAt == nil || !windows[0].ResetsAt.Equal(time.Date(2026, 9, 7, 8, 0, 0, 0, time.UTC)) { + t.Fatalf("reset = %v", windows[0].ResetsAt) + } +} + +func TestParseClaudeUsageExtraUsagePhrase(t *testing.T) { + text := "You are currently using your extra usage to power your Claude Code usage\n\nCurrent session: 10% used · resets Sep 7 at 2:59pm (UTC)\n" + _, plan, ok := ParseClaudeUsage(text, time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)) + if !ok || plan != "extra usage" { + t.Fatalf("plan = %q ok=%v", plan, ok) + } +} + +func TestParseClaudeUsageKeepsPercentWhenResetIsGarbage(t *testing.T) { + windows, _, ok := ParseClaudeUsage("Current session: 38% used · resets whenever it feels like it\n", time.Now()) + if !ok || len(windows) != 1 || windows[0].PercentUsed != 38 || windows[0].ResetsAt != nil { + t.Fatalf("windows = %+v ok=%v", windows, ok) + } +} + +func TestParseClaudeUsagePerModelWeekly(t *testing.T) { + text := "Current session: 10% used · resets Sep 7 at 2:59pm (UTC)\nCurrent week (Opus): 12% used · resets Sep 14 at 5:59am (UTC)\n" + windows, _, ok := ParseClaudeUsage(text, time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)) + if !ok || len(windows) != 2 || windows[1].ID != "weekly_opus" { + t.Fatalf("windows = %+v", windows) + } +} + +func TestParseClaudeUsageYearBoundary(t *testing.T) { + dec := time.Date(2026, 12, 31, 12, 0, 0, 0, time.UTC) + got, ok := parseClaudeReset("Jan 2 at 3:00am (UTC)", dec) + want := time.Date(2027, 1, 2, 3, 0, 0, 0, time.UTC) + if !ok || !got.Equal(want) { + t.Fatalf("jan reset = %v ok=%v", got, ok) + } + jan := time.Date(2027, 1, 2, 12, 0, 0, 0, time.UTC) + got, ok = parseClaudeReset("Dec 31 at 3:00am (UTC)", jan) + want = time.Date(2026, 12, 31, 3, 0, 0, 0, time.UTC) + if !ok || !got.Equal(want) { + t.Fatalf("dec reset = %v ok=%v", got, ok) + } +} + +func TestParseClaudeUsageMalformed(t *testing.T) { + if _, _, ok := ParseClaudeUsage("Please run /login first", time.Now()); ok { + t.Fatal("login text parsed as a limit") + } + if _, _, ok := ParseClaudeUsage("", time.Now()); ok { + t.Fatal("empty text parsed") + } + if _, _, ok := ParseClaudeUsage("{not json and not usage}", time.Now()); ok { + t.Fatal("garbage parsed") + } +} + +func TestClaudeCollectorDoesNotRunWhenCLIMissing(t *testing.T) { + c := ClaudeCollector{ + Locate: func() (string, error) { return "", osErr("missing") }, + Run: func(context.Context, string, []string, string) (string, string, int, error) { + t.Fatal("command ran") + return "", "", 0, nil + }, + Now: func() time.Time { return time.Unix(0, 0).UTC() }, + } + got := c.Collect(context.Background()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonCLIUnavailable { + t.Fatalf("result = %+v", got) + } +} + +func TestClaudeCollectorLoginTextIsEmptyNotFailure(t *testing.T) { + c := ClaudeCollector{ + Locate: func() (string, error) { return "/tmp/fake-claude", nil }, + Run: func(context.Context, string, []string, string) (string, string, int, error) { + return "Please run /login first\n", "", 1, nil + }, + } + got := c.Collect(context.Background()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonNotLoggedIn || len(got.Snapshot.Windows) != 0 { + t.Fatalf("result = %+v", got) + } +} + +func TestClaudeCollectorKeepsLastGoodOnGarbage(t *testing.T) { + c := ClaudeCollector{ + Locate: func() (string, error) { return "/tmp/fake-claude", nil }, + Run: func(context.Context, string, []string, string) (string, string, int, error) { + return "usage output changed shape", "", 0, nil + }, + } + got := c.Collect(context.Background()) + if got.Upload { + t.Fatal("unparsed stdout must not replace the last snapshot") + } +} + +type osErr string + +func (e osErr) Error() string { return string(e) } + +func TestClaudeUsageArgsDoNotRenewLogin(t *testing.T) { + joined := strings.Join(claudeUsageArgs, " ") + if strings.Contains(joined, "-p ") || strings.Contains(joined, "login") { + t.Fatalf("args must not refresh oauth: %s", joined) + } +} diff --git a/server/internal/daemon/providerusage/codex.go b/server/internal/daemon/providerusage/codex.go new file mode 100644 index 00000000000..f7eb94bf074 --- /dev/null +++ b/server/internal/daemon/providerusage/codex.go @@ -0,0 +1,174 @@ +package providerusage + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "time" +) + +const codexUsageURL = "https://chatgpt.com/backend-api/wham/usage" + +// CodexAuthPath resolves the Codex auth file the daemon already uses for +// task homes: $CODEX_HOME/auth.json, otherwise ~/.codex/auth.json. This +// matches execenv.resolveSharedCodexHome without exporting it. +func CodexAuthPath() string { + if v := os.Getenv("CODEX_HOME"); v != "" { + if abs, err := filepath.Abs(v); err == nil { + return filepath.Join(abs, "auth.json") + } + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".codex", "auth.json") +} + +// CodexCollector reads ChatGPT plan limits with the local Codex login. +// An API-key-only auth.json is an empty snapshot, not an error. +type CodexCollector struct { + AuthPath string + Do HTTPDoer + Now func() time.Time +} + +func (c CodexCollector) Collect(ctx context.Context) Result { + now := time.Now() + if c.Now != nil { + now = c.Now() + } + path := c.AuthPath + if path == "" { + path = CodexAuthPath() + } + token, accountID, reason, err := loadCodexChatGPTAuth(path) + if err != nil { + if reason == "" { + reason = ReasonNotLoggedIn + } + return emptyResult(ProviderCodex, reason, now) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, codexUsageURL, nil) + if err != nil { + return Result{Upload: false} + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Cache-Control", "no-cache, no-store") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("ChatGPT-Account-Id", accountID) + do := c.Do + if do == nil { + client := &http.Client{Timeout: vendorTimeout} + do = client.Do + } + resp, err := do(req) + if err != nil { + return Result{Upload: false} + } + body, err := readVendorBody(resp) + if err != nil { + return Result{Upload: false} + } + emptyReason, backoff, transient := classifyVendorStatus(resp.StatusCode, resp.Header.Get("Retry-After")) + if transient { + return Result{Upload: false, Backoff: backoff} + } + if emptyReason != "" { + return emptyResult(ProviderCodex, emptyReason, now) + } + windows, plan, ok := ParseCodexUsage(body, now) + if !ok { + return Result{Upload: false} + } + return Result{ + Upload: true, + Snapshot: Snapshot{ + Provider: ProviderCodex, + PlanName: plan, + CollectedAt: now, + Windows: windows, + }, + } +} + +func loadCodexChatGPTAuth(path string) (token, accountID, reason string, err error) { + if path == "" { + return "", "", ReasonNotLoggedIn, fmt.Errorf("codex auth path missing") + } + body, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", "", ReasonNotLoggedIn, err + } + return "", "", "", err + } + var doc struct { + Tokens *struct { + AccessToken string `json:"access_token"` + AccountID string `json:"account_id"` + } `json:"tokens"` + APIKey string `json:"OPENAI_API_KEY"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return "", "", ReasonNotLoggedIn, err + } + if doc.Tokens == nil || doc.Tokens.AccessToken == "" || doc.Tokens.AccountID == "" { + if doc.APIKey != "" || doc.Tokens == nil { + return "", "", ReasonAPIKeyOnly, fmt.Errorf("codex auth has no chatgpt tokens") + } + return "", "", ReasonAPIKeyOnly, fmt.Errorf("codex auth has no chatgpt tokens") + } + return doc.Tokens.AccessToken, doc.Tokens.AccountID, "", nil +} + +type codexWindow struct { + UsedPercent *float64 `json:"used_percent"` + ResetAt *int64 `json:"reset_at"` + ResetAfterSeconds *int64 `json:"reset_after_seconds"` +} + +type codexUsageBody struct { + PlanType string `json:"plan_type"` + RateLimit struct { + Primary *codexWindow `json:"primary_window"` + Secondary *codexWindow `json:"secondary_window"` + } `json:"rate_limit"` +} + +// ParseCodexUsage maps wham usage JSON onto primary and secondary windows. +func ParseCodexUsage(body []byte, now time.Time) (windows []Window, plan string, ok bool) { + var parsed codexUsageBody + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, "", false + } + if parsed.RateLimit.Primary == nil && parsed.RateLimit.Secondary == nil && parsed.PlanType == "" { + return nil, "", false + } + if w, ok := codexWindowOf("primary", parsed.RateLimit.Primary, now); ok { + windows = append(windows, w) + } + if w, ok := codexWindowOf("secondary", parsed.RateLimit.Secondary, now); ok { + windows = append(windows, w) + } + return windows, parsed.PlanType, true +} + +func codexWindowOf(id string, raw *codexWindow, now time.Time) (Window, bool) { + if raw == nil || raw.UsedPercent == nil { + return Window{}, false + } + w := Window{ID: id, PercentUsed: *raw.UsedPercent} + switch { + case raw.ResetAt != nil && *raw.ResetAt > 0: + t := time.Unix(*raw.ResetAt, 0).UTC() + w.ResetsAt = &t + case raw.ResetAfterSeconds != nil && *raw.ResetAfterSeconds >= 0: + t := now.Add(time.Duration(*raw.ResetAfterSeconds) * time.Second).UTC() + w.ResetsAt = &t + } + return w, true +} diff --git a/server/internal/daemon/providerusage/codex_test.go b/server/internal/daemon/providerusage/codex_test.go new file mode 100644 index 00000000000..3e281989f44 --- /dev/null +++ b/server/internal/daemon/providerusage/codex_test.go @@ -0,0 +1,130 @@ +package providerusage + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestParseCodexUsageWindows(t *testing.T) { + body := []byte(`{ + "plan_type": "plus", + "rate_limit": { + "primary_window": {"used_percent": 20, "limit_window_seconds": 18000, "reset_at": 1750000000}, + "secondary_window": {"used_percent": 5, "reset_after_seconds": 3600} + } + }`) + now := time.Unix(1_700_000_000, 0).UTC() + windows, plan, ok := ParseCodexUsage(body, now) + if !ok || plan != "plus" || len(windows) != 2 { + t.Fatalf("windows=%+v plan=%q ok=%v", windows, plan, ok) + } + if windows[0].ID != "primary" || windows[0].PercentUsed != 20 || windows[0].ResetsAt == nil { + t.Fatalf("primary = %+v", windows[0]) + } + if !windows[0].ResetsAt.Equal(time.Unix(1750000000, 0).UTC()) { + t.Fatalf("primary reset = %v", windows[0].ResetsAt) + } + if windows[1].ID != "secondary" || windows[1].PercentUsed != 5 || windows[1].ResetsAt == nil { + t.Fatalf("secondary = %+v", windows[1]) + } + if !windows[1].ResetsAt.Equal(now.Add(time.Hour)) { + t.Fatalf("secondary reset = %v", windows[1].ResetsAt) + } +} + +func TestParseCodexUsageMalformed(t *testing.T) { + if _, _, ok := ParseCodexUsage([]byte(`not-json`), time.Now()); ok { + t.Fatal("non-json parsed") + } + if _, _, ok := ParseCodexUsage([]byte(`{}`), time.Now()); ok { + t.Fatal("empty object parsed") + } + if _, _, ok := ParseCodexUsage([]byte(`{"rate_limit":{}}`), time.Now()); ok { + t.Fatal("empty rate_limit parsed as success without a plan") + } +} + +func TestCodexAPIKeyLoginIsEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + if err := os.WriteFile(path, []byte(`{"OPENAI_API_KEY":"sk-test"}`), 0o600); err != nil { + t.Fatal(err) + } + c := CodexCollector{ + AuthPath: path, + Do: func(*http.Request) (*http.Response, error) { + t.Fatal("api-key login must not call the vendor") + return nil, nil + }, + Now: func() time.Time { return time.Unix(10, 0).UTC() }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonAPIKeyOnly { + t.Fatalf("result = %+v", got) + } +} + +func TestCodexCollectorUploadsPercentsNotToken(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + raw := []byte(`{"tokens":{"access_token":"test-access-token","account_id":"acct_1"}}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + var authz, account string + c := CodexCollector{ + AuthPath: path, + Do: func(req *http.Request) (*http.Response, error) { + authz = req.Header.Get("Authorization") + account = req.Header.Get("ChatGPT-Account-Id") + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":7,"reset_at":1750000000}}}`)), + }, nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.PlanName != "plus" || len(got.Snapshot.Windows) != 1 || got.Snapshot.Windows[0].PercentUsed != 7 { + t.Fatalf("result = %+v", got) + } + if authz != "Bearer test-access-token" || account != "acct_1" { + t.Fatalf("authz=%q account=%q", authz, account) + } + encoded, _ := json.Marshal(got.Snapshot) + if strings.Contains(string(encoded), "test-access-token") || strings.Contains(string(encoded), "acct_1") { + t.Fatalf("snapshot leaked a secret: %s", encoded) + } +} + +func TestCodexMissingFileIsNotLoggedIn(t *testing.T) { + c := CodexCollector{AuthPath: filepath.Join(t.TempDir(), "missing-auth.json")} + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonNotLoggedIn { + t.Fatalf("result = %+v", got) + } +} + +func TestCodexUnauthorizedIsEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + if err := os.WriteFile(path, []byte(`{"tokens":{"access_token":"t","account_id":"a"}}`), 0o600); err != nil { + t.Fatal(err) + } + c := CodexCollector{ + AuthPath: path, + Do: func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody, Header: http.Header{}}, nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonUnauthorized { + t.Fatalf("result = %+v", got) + } +} diff --git a/server/internal/daemon/providerusage/copilot.go b/server/internal/daemon/providerusage/copilot.go new file mode 100644 index 00000000000..b8bc801c7d6 --- /dev/null +++ b/server/internal/daemon/providerusage/copilot.go @@ -0,0 +1,261 @@ +package providerusage + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "time" +) + +const copilotUsageURL = "https://api.github.com/copilot_internal/user" + +var copilotWindowOrder = []string{"premium_interactions", "chat", "completions"} + +// TokenCommand returns a GitHub CLI token. Tests inject it so production +// never has to run `gh` during unit tests. The token stays in memory. +type TokenCommand func(ctx context.Context) (string, error) + +// CopilotCollector reads GitHub Copilot quotas with the GitHub CLI session +// already on this machine. Environment API tokens are ignored: they are not +// the signed-in `gh` session. +type CopilotCollector struct { + HostsPath string + Token TokenCommand + Do HTTPDoer + Now func() time.Time +} + +func (c CopilotCollector) Collect(ctx context.Context) Result { + now := time.Now() + if c.Now != nil { + now = c.Now() + } + path := c.HostsPath + if path == "" { + path = copilotHostsPath() + } + token := copilotTokenFromHosts(path) + if token == "" { + read := c.Token + if read == nil { + read = runGHAuthToken + } + var err error + token, err = read(ctx) + token = strings.TrimSpace(token) + if err != nil || token == "" || strings.Contains(token, "\n") { + return emptyResult(ProviderCopilot, ReasonNotLoggedIn, now) + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotUsageURL, nil) + if err != nil { + return Result{Upload: false} + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + body, status, retryAfter, err := performVendor(c.Do, req) + if err != nil { + return Result{Upload: false} + } + reason, backoff, transient := classifyVendorStatus(status, retryAfter) + if transient { + return Result{Upload: false, Backoff: backoff} + } + if reason != "" { + return emptyResult(ProviderCopilot, reason, now) + } + windows, plan, ok := ParseCopilotUsage(body) + if !ok { + return Result{Upload: false} + } + return Result{ + Upload: true, + Snapshot: Snapshot{ + Provider: ProviderCopilot, + PlanName: plan, + CollectedAt: now, + Windows: windows, + }, + } +} + +func copilotHostsPath() string { + dir := os.Getenv("GH_CONFIG_DIR") + if strings.TrimSpace(dir) == "" { + if runtime.GOOS == "windows" { + dir = os.Getenv("AppData") + if dir == "" { + dir, _ = os.UserConfigDir() + } + dir = filepath.Join(dir, "GitHub CLI") + } else { + dir = os.Getenv("XDG_CONFIG_HOME") + if strings.TrimSpace(dir) == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + dir = filepath.Join(home, ".config") + } + dir = filepath.Join(dir, "gh") + } + } + return filepath.Join(dir, "hosts.yml") +} + +func copilotTokenFromHosts(path string) string { + body, err := readRegularFile(path) + if err != nil { + return "" + } + lines := strings.Split(string(body), "\n") + start := -1 + for i, line := range lines { + if strings.TrimSpace(line) == "github.com:" { + start = i + break + } + } + if start < 0 { + return "" + } + for _, line := range lines[start+1:] { + if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + break + } + trimmed := strings.TrimSpace(line) + const key = "oauth_token:" + if !strings.HasPrefix(trimmed, key) { + continue + } + value := strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, key)), `"'`) + if value == "" || strings.ContainsAny(value, " \t\r") { + return "" + } + return value + } + return "" +} + +func runGHAuthToken(ctx context.Context) (string, error) { + path, err := exec.LookPath("gh") + if err != nil || !filepath.IsAbs(path) { + return "", err + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, path, "auth", "token", "--hostname", "github.com") + cmd.Stdin = bytes.NewReader(nil) + var stdout bytes.Buffer + cmd.Stdout = &limitedWriter{w: &stdout, n: 4 * 1024} + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + return "", err + } + return strings.TrimSpace(stdout.String()), nil +} + +// ParseCopilotUsage maps quota_snapshots onto percent-used windows. +// Unlimited quotas are omitted. The token is not part of the result. +func ParseCopilotUsage(body []byte) (windows []Window, plan string, ok bool) { + root, parsed := decodeObject(body) + if !parsed { + return nil, "", false + } + if name, isString := root["copilot_plan"].(string); isString { + plan = trimPlan(name) + } + if plan == "" { + if name, isString := root["plan"].(string); isString { + plan = trimPlan(name) + } + } + quotas := asMap(root["quota_snapshots"]) + if quotas == nil { + return nil, "", false + } + seen := map[string]struct{}{} + for _, id := range copilotWindowOrder { + if window, added := copilotWindow(id, asMap(quotas[id]), root); added { + windows = append(windows, window) + seen[id] = struct{}{} + } + } + var extra []string + for id := range quotas { + if _, already := seen[id]; already || !validCopilotID(id) { + continue + } + extra = append(extra, id) + } + sort.Strings(extra) + for _, id := range extra { + if len(windows) >= 8 { + break + } + if window, added := copilotWindow(id, asMap(quotas[id]), root); added { + windows = append(windows, window) + } + } + return windows, plan, len(windows) > 0 +} + +func copilotWindow(id string, quota, root map[string]any) (Window, bool) { + if quota == nil || !validCopilotID(id) { + return Window{}, false + } + if unlimited, isBool := quota["unlimited"].(bool); isBool && unlimited { + return Window{}, false + } + entitlement, hasEntitlement := asFloat(quota["entitlement"]) + if !hasEntitlement || entitlement <= 0 { + return Window{}, false + } + used, hasUsed := asFloat(quota["used"]) + if !hasUsed { + if remaining, hasRemaining := asFloat(quota["remaining"]); hasRemaining { + used = entitlement - remaining + } + } + if used < 0 { + used = 0 + } + percent, ok := usablePercent(used / entitlement * 100) + if !ok { + return Window{}, false + } + window := Window{ID: id, PercentUsed: percent} + reset := parseResetValue(quota["reset_date"]) + if reset == nil { + reset = parseResetValue(quota["reset_at"]) + } + if reset == nil { + reset = parseResetValue(quota["resets_at"]) + } + if reset == nil { + reset = parseResetValue(root["quota_reset_date"]) + } + window.ResetsAt = reset + return window, true +} + +func validCopilotID(id string) bool { + if id == "" || len(id) > 64 { + return false + } + for _, r := range id { + if r >= 'a' && r <= 'z' || r == '_' || (r >= '0' && r <= '9') { + continue + } + return false + } + return true +} diff --git a/server/internal/daemon/providerusage/copilot_test.go b/server/internal/daemon/providerusage/copilot_test.go new file mode 100644 index 00000000000..31e543619fb --- /dev/null +++ b/server/internal/daemon/providerusage/copilot_test.go @@ -0,0 +1,90 @@ +package providerusage + +import ( + "context" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestParseCopilotUsage(t *testing.T) { + body := []byte(`{ + "copilot_plan": "individual", + "quota_reset_date": "2026-10-01T00:00:00Z", + "quota_snapshots": { + "completions": {"entitlement": 2000, "remaining": 1500}, + "chat": {"unlimited": true, "entitlement": 1}, + "premium_interactions": {"entitlement": 100, "used": 25, "reset_date": "2026-10-01T00:00:00Z"} + } + }`) + windows, plan, ok := ParseCopilotUsage(body) + if !ok || plan != "individual" || len(windows) != 2 { + t.Fatalf("windows=%+v plan=%q ok=%v", windows, plan, ok) + } + if windows[0].ID != "premium_interactions" || windows[0].PercentUsed != 25 { + t.Fatalf("premium = %+v", windows[0]) + } + if windows[1].ID != "completions" || windows[1].PercentUsed != 25 { + t.Fatalf("completions = %+v", windows[1]) + } + if windows[0].ResetsAt == nil || !windows[0].ResetsAt.Equal(time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("reset = %v", windows[0].ResetsAt) + } +} + +func TestCopilotCollectorUsesHostsTokenAndDropsIt(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hosts.yml") + const token = "gho_local_session_token" + body := "github.com:\n user: octocat\n oauth_token: " + token + "\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + var sawAuth bool + c := CopilotCollector{ + HostsPath: path, + Token: func(ctx context.Context) (string, error) { + t.Fatal("hosts.yml token must not fall through to gh") + return "", nil + }, + Do: func(req *http.Request) (*http.Response, error) { + sawAuth = req.Header.Get("Authorization") == "Bearer "+token + if strings.Contains(req.URL.String(), token) { + t.Fatal("token leaked into the URL") + } + return jsonResponse(http.StatusOK, `{"copilot_plan":"business","quota_snapshots":{"premium_interactions":{"entitlement":10,"used":4}}}`), nil + }, + Now: func() time.Time { return time.Unix(10, 0).UTC() }, + } + got := c.Collect(t.Context()) + if !sawAuth || !got.Upload || got.Snapshot.PlanName != "business" || len(got.Snapshot.Windows) != 1 { + t.Fatalf("result = %+v sawAuth=%v", got, sawAuth) + } + if got.Snapshot.Windows[0].PercentUsed != 40 { + t.Fatalf("percent = %v", got.Snapshot.Windows[0].PercentUsed) + } + encoded := got.Snapshot.Provider + got.Snapshot.PlanName + got.Snapshot.ReasonCode + if strings.Contains(encoded, token) { + t.Fatal("snapshot kept the github token") + } +} + +func TestCopilotMissingSessionDoesNotCallVendor(t *testing.T) { + c := CopilotCollector{ + HostsPath: filepath.Join(t.TempDir(), "missing.yml"), + Token: func(ctx context.Context) (string, error) { + return "", os.ErrNotExist + }, + Do: func(*http.Request) (*http.Response, error) { + t.Fatal("missing session must not call GitHub") + return nil, nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonNotLoggedIn || len(got.Snapshot.Windows) != 0 { + t.Fatalf("result = %+v", got) + } +} diff --git a/server/internal/daemon/providerusage/cursor.go b/server/internal/daemon/providerusage/cursor.go new file mode 100644 index 00000000000..f74c48c1880 --- /dev/null +++ b/server/internal/daemon/providerusage/cursor.go @@ -0,0 +1,244 @@ +package providerusage + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +const cursorUsageURL = "https://cursor.com/api/usage-summary" + +// CursorSession is the in-memory editor session used for one request. +// Callers must not persist it. +type CursorSession struct { + AccessToken string + AuthID string +} + +// CursorSessionSource reads the local editor or CLI session. Tests inject it. +type CursorSessionSource func(ctx context.Context) (CursorSession, error) + +// HTTPDoer performs one vendor request. Tests inject a fake. +type HTTPDoer func(req *http.Request) (*http.Response, error) + +// CursorCollector reads Cursor plan usage from the local editor session. +type CursorCollector struct { + Session CursorSessionSource + Do HTTPDoer + Now func() time.Time +} + +func (c CursorCollector) Collect(ctx context.Context) Result { + now := time.Now() + if c.Now != nil { + now = c.Now() + } + source := c.Session + if source == nil { + source = readCursorSession + } + session, err := source(ctx) + if err != nil || session.AccessToken == "" || session.AuthID == "" { + return emptyResult(ProviderCursor, ReasonSessionUnavailable, now) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cursorUsageURL, nil) + if err != nil { + return Result{Upload: false} + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Cookie", "WorkosCursorSessionToken="+session.AuthID+"::"+session.AccessToken) + do := c.Do + if do == nil { + client := &http.Client{Timeout: vendorTimeout} + do = client.Do + } + resp, err := do(req) + if err != nil { + return Result{Upload: false} + } + body, err := readVendorBody(resp) + if err != nil { + return Result{Upload: false} + } + reason, backoff, transient := classifyVendorStatus(resp.StatusCode, resp.Header.Get("Retry-After")) + if transient { + return Result{Upload: false, Backoff: backoff} + } + if reason != "" { + return emptyResult(ProviderCursor, reason, now) + } + windows, plan, ok := ParseCursorUsage(body) + if !ok { + return Result{Upload: false} + } + return Result{ + Upload: true, + Snapshot: Snapshot{ + Provider: ProviderCursor, + PlanName: plan, + CollectedAt: now, + Windows: windows, + }, + } +} + +type cursorSummary struct { + BillingCycleEnd string `json:"billingCycleEnd"` + MembershipType string `json:"membershipType"` + IndividualUsage struct { + Plan struct { + AutoPercentUsed *float64 `json:"autoPercentUsed"` + APIPercentUsed *float64 `json:"apiPercentUsed"` + } `json:"plan"` + } `json:"individualUsage"` +} + +// ParseCursorUsage maps a usage-summary body onto auto and api windows. +// ok is false when the body is not the expected object. +func ParseCursorUsage(body []byte) (windows []Window, plan string, ok bool) { + var summary cursorSummary + if err := json.Unmarshal(body, &summary); err != nil { + return nil, "", false + } + if summary.IndividualUsage.Plan.AutoPercentUsed == nil && + summary.IndividualUsage.Plan.APIPercentUsed == nil && + summary.MembershipType == "" && + summary.BillingCycleEnd == "" { + return nil, "", false + } + var resets *time.Time + if summary.BillingCycleEnd != "" { + if t, err := time.Parse(time.RFC3339, summary.BillingCycleEnd); err == nil { + resets = &t + } + } + if summary.IndividualUsage.Plan.AutoPercentUsed != nil { + windows = append(windows, Window{ + ID: "auto", + PercentUsed: *summary.IndividualUsage.Plan.AutoPercentUsed, + ResetsAt: resets, + }) + } + if pct := summary.IndividualUsage.Plan.APIPercentUsed; pct != nil && *pct > 0 { + windows = append(windows, Window{ + ID: "api", + PercentUsed: *pct, + ResetsAt: resets, + }) + } + return windows, summary.MembershipType, true +} + +// cursorStateDBPathsFor lists editor state databases for one OS. Linux uses +// XDG_CONFIG_HOME when set, then ~/.config/Cursor. The macOS Library path and +// the Windows APPDATA path are not used on Linux. +func cursorStateDBPathsFor(goos, home, xdgConfig, appData string) []string { + var paths []string + switch goos { + case "linux": + if xdgConfig != "" { + paths = append(paths, filepath.Join(xdgConfig, "Cursor", "User", "globalStorage", "state.vscdb")) + } + if home != "" { + paths = append(paths, filepath.Join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb")) + } + case "darwin": + if home != "" { + paths = append(paths, filepath.Join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb")) + } + case "windows": + if appData != "" { + paths = append(paths, filepath.Join(appData, "Cursor", "User", "globalStorage", "state.vscdb")) + } + default: + // An unknown GOOS has no editor database path we can honestly read. + } + return paths +} + +func cursorCLIConfigPath(home string) string { + if home == "" { + return "" + } + return filepath.Join(home, ".cursor", "cli-config.json") +} + +func readCursorSession(ctx context.Context) (CursorSession, error) { + home, _ := os.UserHomeDir() + paths := cursorStateDBPathsFor(runtime.GOOS, home, os.Getenv("XDG_CONFIG_HOME"), os.Getenv("APPDATA")) + for _, path := range paths { + session, ok, err := readCursorStateDB(ctx, path) + if err != nil { + continue + } + if ok && session.AccessToken != "" { + if session.AuthID == "" { + session.AuthID = jwtSub(session.AccessToken) + } + if session.AuthID != "" { + return session, nil + } + } + } + return readCursorCLIConfig(cursorCLIConfigPath(home)) +} + +func readCursorCLIConfig(path string) (CursorSession, error) { + if path == "" { + return CursorSession{}, fmt.Errorf("cursor cli config path missing") + } + body, err := os.ReadFile(path) + if err != nil { + return CursorSession{}, err + } + var doc struct { + AuthInfo struct { + AuthID string `json:"authId"` + AccessToken string `json:"accessToken"` + } `json:"authInfo"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return CursorSession{}, err + } + session := CursorSession{AccessToken: doc.AuthInfo.AccessToken, AuthID: doc.AuthInfo.AuthID} + if session.AccessToken != "" && session.AuthID == "" { + session.AuthID = jwtSub(session.AccessToken) + } + if session.AccessToken == "" || session.AuthID == "" { + return CursorSession{}, fmt.Errorf("cursor cli config has no session") + } + return session, nil +} + +func jwtSub(token string) string { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return "" + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + var claims struct { + Sub string `json:"sub"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + return claims.Sub +} + +// cursorDBFileURL builds a read-only SQLite URI. mode=ro still sees WAL +// frames; immutable=1 would hide a token the editor just rotated. +func cursorDBFileURL(path string) string { + u := url.URL{Scheme: "file", Path: filepath.ToSlash(path), RawQuery: "mode=ro"} + return u.String() +} diff --git a/server/internal/daemon/providerusage/cursor_db.go b/server/internal/daemon/providerusage/cursor_db.go new file mode 100644 index 00000000000..4a20a8f9a9c --- /dev/null +++ b/server/internal/daemon/providerusage/cursor_db.go @@ -0,0 +1,50 @@ +package providerusage + +import ( + "context" + "database/sql" + "errors" + "os" + "time" + + _ "modernc.org/sqlite" +) + +func readCursorStateDB(ctx context.Context, path string) (CursorSession, bool, error) { + if path == "" { + return CursorSession{}, false, errors.New("empty cursor state path") + } + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return CursorSession{}, false, err + } + db, err := sql.Open("sqlite", cursorDBFileURL(path)) + if err != nil { + return CursorSession{}, false, err + } + defer db.Close() + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + token, err := cursorItem(ctx, db, "cursorAuth/accessToken") + if err != nil { + return CursorSession{}, false, err + } + authID, err := cursorItem(ctx, db, "cursorAuth/stripeMembershipAuthId") + if err != nil { + return CursorSession{}, false, err + } + if token == "" { + return CursorSession{}, false, nil + } + return CursorSession{AccessToken: token, AuthID: authID}, true, nil +} + +func cursorItem(ctx context.Context, db *sql.DB, key string) (string, error) { + var value string + err := db.QueryRowContext(ctx, `SELECT value FROM ItemTable WHERE key = ?`, key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return value, err +} diff --git a/server/internal/daemon/providerusage/cursor_test.go b/server/internal/daemon/providerusage/cursor_test.go new file mode 100644 index 00000000000..38a4cf8d46a --- /dev/null +++ b/server/internal/daemon/providerusage/cursor_test.go @@ -0,0 +1,198 @@ +package providerusage + +import ( + "context" + "database/sql" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +func TestParseCursorUsageSummary(t *testing.T) { + body := []byte(`{ + "billingCycleStart": "2026-09-01T00:00:00Z", + "billingCycleEnd": "2026-10-01T00:00:00Z", + "membershipType": "pro", + "individualUsage": {"plan": {"autoPercentUsed": 42.5, "apiPercentUsed": 0, "used": 0, "limit": 0}} + }`) + windows, plan, ok := ParseCursorUsage(body) + if !ok || plan != "pro" || len(windows) != 1 { + t.Fatalf("windows=%+v plan=%q ok=%v", windows, plan, ok) + } + if windows[0].ID != "auto" || windows[0].PercentUsed != 42.5 { + t.Fatalf("window = %+v", windows[0]) + } + if windows[0].ResetsAt == nil || !windows[0].ResetsAt.Equal(time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("reset = %v", windows[0].ResetsAt) + } +} + +func TestParseCursorUsageIncludesAPIWhenUsed(t *testing.T) { + body := []byte(`{"membershipType":"pro","individualUsage":{"plan":{"autoPercentUsed":1,"apiPercentUsed":12.5}}}`) + windows, _, ok := ParseCursorUsage(body) + if !ok || len(windows) != 2 || windows[1].ID != "api" || windows[1].PercentUsed != 12.5 { + t.Fatalf("windows = %+v", windows) + } +} + +func TestParseCursorUsageMalformed(t *testing.T) { + if _, _, ok := ParseCursorUsage([]byte(`not-json`)); ok { + t.Fatal("non-json parsed") + } + if _, _, ok := ParseCursorUsage([]byte(`{}`)); ok { + t.Fatal("empty object parsed") + } + if _, _, ok := ParseCursorUsage([]byte(`[]`)); ok { + t.Fatal("array parsed") + } +} + +func TestCursorStateDBPathsLinux(t *testing.T) { + paths := cursorStateDBPathsFor("linux", "/home/dev", "/xdg", "") + if len(paths) != 2 { + t.Fatalf("paths = %#v", paths) + } + if !strings.HasPrefix(paths[0], "/xdg/Cursor/") || !strings.Contains(paths[1], "/home/dev/.config/Cursor/") { + t.Fatalf("linux paths = %#v", paths) + } + mac := cursorStateDBPathsFor("darwin", "/Users/dev", "", "") + if len(mac) != 1 || !strings.Contains(mac[0], "Library/Application Support/Cursor/") { + t.Fatalf("darwin paths = %#v", mac) + } + if got := cursorStateDBPathsFor("linux", "/home/dev", "", ""); len(got) != 1 || strings.Contains(got[0], "Library") { + t.Fatalf("linux must not use the macOS library path: %#v", got) + } +} + +func TestCursorCollectorUploadsPercentsNotCookie(t *testing.T) { + var gotCookie string + c := CursorCollector{ + Session: func(context.Context) (CursorSession, error) { + return CursorSession{AccessToken: "test-access-token", AuthID: "user_123"}, nil + }, + Do: func(req *http.Request) (*http.Response, error) { + gotCookie = req.Header.Get("Cookie") + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader(`{"membershipType":"pro","billingCycleEnd":"2026-10-01T00:00:00Z","individualUsage":{"plan":{"autoPercentUsed":10,"apiPercentUsed":0}}}`)), + }, nil + }, + } + got := c.Collect(context.Background()) + if !got.Upload || len(got.Snapshot.Windows) != 1 || got.Snapshot.Windows[0].PercentUsed != 10 { + t.Fatalf("result = %+v", got) + } + if !strings.Contains(gotCookie, "WorkosCursorSessionToken=user_123::test-access-token") { + t.Fatalf("cookie = %q", gotCookie) + } + encoded, _ := json.Marshal(got.Snapshot) + if strings.Contains(string(encoded), "test-access-token") || strings.Contains(string(encoded), "WorkosCursorSessionToken") { + t.Fatalf("snapshot leaked a secret: %s", encoded) + } +} + +func TestCursorCollectorUnauthorizedIsEmpty(t *testing.T) { + c := CursorCollector{ + Session: func(context.Context) (CursorSession, error) { + return CursorSession{AccessToken: "t", AuthID: "a"}, nil + }, + Do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: http.NoBody, + Header: http.Header{}, + }, nil + }, + } + got := c.Collect(context.Background()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonUnauthorized { + t.Fatalf("result = %+v", got) + } +} + +func TestCursorCollectorRateLimitKeepsLastGood(t *testing.T) { + c := CursorCollector{ + Session: func(context.Context) (CursorSession, error) { + return CursorSession{AccessToken: "t", AuthID: "a"}, nil + }, + Do: func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Body: http.NoBody, + Header: http.Header{"Retry-After": []string{"120"}}, + }, nil + }, + } + got := c.Collect(context.Background()) + if got.Upload || got.Backoff != 120*time.Second { + t.Fatalf("result = %+v", got) + } +} + +func TestReadCursorStateDB(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.vscdb") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO ItemTable (key, value) VALUES (?, ?), (?, ?)`, + "cursorAuth/accessToken", "db-token", + "cursorAuth/stripeMembershipAuthId", "auth-id", + ); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + session, ok, err := readCursorStateDB(context.Background(), path) + if err != nil || !ok { + t.Fatalf("session err=%v ok=%v", err, ok) + } + if session.AccessToken != "db-token" || session.AuthID != "auth-id" { + t.Fatalf("session = %+v", session) + } +} + +func TestCursorCLIConfigFallback(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cli-config.json") + body := []byte(`{"authInfo":{"authId":"user_9","accessToken":"cli-token","email":"a@example.com"}}`) + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + session, err := readCursorCLIConfig(path) + if err != nil { + t.Fatal(err) + } + if session.AuthID != "user_9" || session.AccessToken != "cli-token" { + t.Fatalf("session = %+v", session) + } +} + +func TestCursorMissingSessionIsEmpty(t *testing.T) { + c := CursorCollector{ + Session: func(context.Context) (CursorSession, error) { + return CursorSession{}, errString("no session") + }, + } + got := c.Collect(context.Background()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonSessionUnavailable { + t.Fatalf("result = %+v", got) + } +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/server/internal/daemon/providerusage/grok.go b/server/internal/daemon/providerusage/grok.go new file mode 100644 index 00000000000..1892dfe0bc3 --- /dev/null +++ b/server/internal/daemon/providerusage/grok.go @@ -0,0 +1,198 @@ +package providerusage + +import ( + "context" + "net/http" + "os" + "path/filepath" + "strings" + "time" + "unicode" +) + +const ( + grokUsageURL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits" + grokTrustedAuth = "https://auth.x.ai" +) + +// GrokCollector reads the weekly Grok Build allowance from the CLI session +// in ~/.grok/auth.json. Only tokens minted by auth.x.ai are used. An expired +// token is not refreshed here. +type GrokCollector struct { + AuthPath string + Do HTTPDoer + Now func() time.Time +} + +func (c GrokCollector) Collect(ctx context.Context) Result { + now := time.Now() + if c.Now != nil { + now = c.Now() + } + path := c.AuthPath + if path == "" { + path = grokAuthPath() + } + token, expired, found := loadGrokToken(path, now) + if !found { + return emptyResult(ProviderGrok, ReasonNotLoggedIn, now) + } + if expired { + return Result{Upload: false} + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, grokUsageURL, nil) + if err != nil { + return Result{Upload: false} + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli") + body, status, retryAfter, err := performVendor(c.Do, req) + if err != nil { + return Result{Upload: false} + } + reason, backoff, transient := classifyVendorStatus(status, retryAfter) + if transient { + return Result{Upload: false, Backoff: backoff} + } + if reason != "" { + return emptyResult(ProviderGrok, reason, now) + } + windows, plan, ok := ParseGrokUsage(body) + if !ok { + return Result{Upload: false} + } + return Result{ + Upload: true, + Snapshot: Snapshot{ + Provider: ProviderGrok, + PlanName: plan, + CollectedAt: now, + Windows: windows, + }, + } +} + +func grokAuthPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".grok", "auth.json") +} + +func loadGrokToken(path string, now time.Time) (token string, expired, found bool) { + body, err := readRegularFile(path) + if err != nil { + return "", false, false + } + root, ok := decodeObject(body) + if !ok { + return "", false, false + } + var fallback string + fallbackExpired := false + sawTrusted := false + for key, value := range root { + entry := asMap(value) + if entry == nil || !grokTrusted(key, entry) { + continue + } + raw, _ := entry["key"].(string) + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + sawTrusted = true + expiry, hasExpiry := parseFlexibleTime(stringField(entry, "expires_at")) + isExpired := hasExpiry && !expiry.After(now) + if !isExpired { + return raw, false, true + } + if fallback == "" { + fallback = raw + fallbackExpired = true + } + } + if !sawTrusted { + return "", false, false + } + return fallback, fallbackExpired, fallback != "" +} + +func grokTrusted(key string, entry map[string]any) bool { + issuer, _, _ := strings.Cut(key, "::") + if issuer == grokTrustedAuth { + return true + } + return stringField(entry, "oidc_issuer") == grokTrustedAuth +} + +func stringField(entry map[string]any, key string) string { + value, _ := entry[key].(string) + return strings.TrimSpace(value) +} + +// ParseGrokUsage maps the credits billing payload onto one credits window. +func ParseGrokUsage(body []byte) (windows []Window, plan string, ok bool) { + root, parsed := decodeObject(body) + if !parsed { + return nil, "", false + } + config := asMap(root["config"]) + if config == nil { + return nil, "", false + } + period := asMap(config["currentPeriod"]) + reset := parseResetValue(period["end"]) + if reset == nil { + reset = parseResetValue(config["billingPeriodEnd"]) + } + plan = grokProductName(config) + if percent, hasPercent := asFloat(config["creditUsagePercent"]); hasPercent { + value, usable := usablePercent(percent) + if !usable { + return nil, "", false + } + return []Window{{ID: "credits", PercentUsed: value, ResetsAt: reset}}, plan, true + } + for _, item := range asSlice(config["productUsage"]) { + product := asMap(item) + percent, hasPercent := asFloat(product["usagePercent"]) + if !hasPercent { + continue + } + value, usable := usablePercent(percent) + if !usable { + continue + } + if plan == "" { + plan = grokHumanize(stringField(product, "product")) + } + return []Window{{ID: "credits", PercentUsed: value, ResetsAt: reset}}, trimPlan(plan), true + } + periodType := stringField(period, "type") + if strings.Contains(periodType, "WEEKLY") { + return []Window{{ID: "credits", PercentUsed: 0, ResetsAt: reset}}, plan, true + } + return nil, "", false +} + +func grokProductName(config map[string]any) string { + products := asSlice(config["productUsage"]) + if len(products) == 0 { + return "" + } + return trimPlan(grokHumanize(stringField(asMap(products[0]), "product"))) +} + +func grokHumanize(name string) string { + var b strings.Builder + for _, r := range name { + if unicode.IsUpper(r) && b.Len() > 0 { + b.WriteByte(' ') + } + b.WriteRune(r) + } + return strings.TrimSpace(b.String()) +} diff --git a/server/internal/daemon/providerusage/grok_test.go b/server/internal/daemon/providerusage/grok_test.go new file mode 100644 index 00000000000..b2629a54e2d --- /dev/null +++ b/server/internal/daemon/providerusage/grok_test.go @@ -0,0 +1,83 @@ +package providerusage + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestParseGrokUsageCredits(t *testing.T) { + body := []byte(`{"config":{ + "currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","end":"2026-09-12T08:21:18.802818+00:00"}, + "creditUsagePercent":8.0, + "productUsage":[{"product":"GrokBuild","usagePercent":8.0}] + }}`) + windows, plan, ok := ParseGrokUsage(body) + if !ok || plan != "Grok Build" || len(windows) != 1 { + t.Fatalf("windows=%+v plan=%q ok=%v", windows, plan, ok) + } + if windows[0].ID != "credits" || windows[0].PercentUsed != 8 || windows[0].ResetsAt == nil { + t.Fatalf("window = %+v", windows[0]) + } +} + +func TestParseGrokFreshWeeklyIsZero(t *testing.T) { + body := []byte(`{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","end":"2026-09-12T00:00:00Z"}}}`) + windows, _, ok := ParseGrokUsage(body) + if !ok || len(windows) != 1 || windows[0].PercentUsed != 0 || windows[0].ID != "credits" { + t.Fatalf("windows=%+v ok=%v", windows, ok) + } +} + +func TestGrokUntrustedIssuerDoesNotCallVendor(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + raw := []byte(`{"https://customer.example/::client":{"key":"foreign-token","oidc_issuer":"https://customer.example"}}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + c := GrokCollector{ + AuthPath: path, + Do: func(*http.Request) (*http.Response, error) { + t.Fatal("untrusted grok issuer must not call the vendor") + return nil, nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonNotLoggedIn { + t.Fatalf("result = %+v", got) + } +} + +func TestGrokCollectorUploadsPercentNotToken(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + const token = "grok-access-token" + raw := []byte(`{"https://auth.x.ai::client":{"key":"` + token + `","expires_at":"2026-10-01T00:00:00Z"}}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + c := GrokCollector{ + AuthPath: path, + Now: func() time.Time { return time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) }, + Do: func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Authorization") != "Bearer "+token { + t.Fatalf("auth = %q", req.Header.Get("Authorization")) + } + if req.Header.Get("X-XAI-Token-Auth") != "xai-grok-cli" { + t.Fatalf("token auth header = %q", req.Header.Get("X-XAI-Token-Auth")) + } + return jsonResponse(http.StatusOK, `{"config":{"creditUsagePercent":12.5,"productUsage":[{"product":"GrokBuild"}]}}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.PlanName != "Grok Build" || got.Snapshot.Windows[0].PercentUsed != 12.5 { + t.Fatalf("result = %+v", got) + } + if strings.Contains(got.Snapshot.PlanName, token) { + t.Fatal("snapshot kept the access token") + } +} diff --git a/server/internal/daemon/providerusage/http.go b/server/internal/daemon/providerusage/http.go new file mode 100644 index 00000000000..8563aef41ab --- /dev/null +++ b/server/internal/daemon/providerusage/http.go @@ -0,0 +1,85 @@ +package providerusage + +import ( + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + vendorTimeout = 15 * time.Second + minRateLimitWait = 60 * time.Second + maxRateLimitWait = 30 * time.Minute + maxVendorBodySize = 1 << 20 +) + +func readVendorBody(resp *http.Response) ([]byte, error) { + defer resp.Body.Close() + return io.ReadAll(io.LimitReader(resp.Body, maxVendorBodySize)) +} + +// performVendor runs one vendor request. A nil doer uses the production +// client. The body is capped; callers still have to drop credential fields +// before building a Snapshot. +func performVendor(do HTTPDoer, req *http.Request) (body []byte, status int, retryAfter string, err error) { + if do == nil { + client := &http.Client{Timeout: vendorTimeout} + do = client.Do + } + resp, err := do(req) + if err != nil { + return nil, 0, "", err + } + retryAfter = resp.Header.Get("Retry-After") + body, err = readVendorBody(resp) + if err != nil { + return nil, resp.StatusCode, retryAfter, err + } + return body, resp.StatusCode, retryAfter, nil +} + +// classifyVendorStatus maps a vendor HTTP status onto an upload decision. +// 401 and 403 are an empty snapshot. 429 backs off and keeps the last good +// snapshot. Other non-200 statuses are transient. +func classifyVendorStatus(status int, retryAfter string) (reason string, backoff time.Duration, transient bool) { + switch status { + case http.StatusOK: + return "", 0, false + case http.StatusUnauthorized, http.StatusForbidden: + return ReasonUnauthorized, 0, false + case http.StatusTooManyRequests: + return "", parseRetryAfter(retryAfter), true + default: + return "", 0, true + } +} + +func parseRetryAfter(header string) time.Duration { + header = strings.TrimSpace(header) + if header == "" { + return minRateLimitWait + } + if secs, err := strconv.Atoi(header); err == nil && secs >= 0 { + return clampBackoff(time.Duration(secs) * time.Second) + } + if when, err := http.ParseTime(header); err == nil { + d := time.Until(when) + if d < 0 { + d = minRateLimitWait + } + return clampBackoff(d) + } + return minRateLimitWait +} + +func clampBackoff(d time.Duration) time.Duration { + if d < minRateLimitWait { + return minRateLimitWait + } + if d > maxRateLimitWait { + return maxRateLimitWait + } + return d +} diff --git a/server/internal/daemon/providerusage/kimi.go b/server/internal/daemon/providerusage/kimi.go new file mode 100644 index 00000000000..87fed841a7c --- /dev/null +++ b/server/internal/daemon/providerusage/kimi.go @@ -0,0 +1,215 @@ +package providerusage + +import ( + "context" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const kimiUsageURL = "https://api.kimi.com/coding/v1/usages" + +// KimiCollector reads Kimi Code plan windows with the CLI's OAuth file. +// An expired access token is left for the CLI to refresh: the last good +// snapshot stays, and this collector does not write a new token. +type KimiCollector struct { + AuthPath string + Do HTTPDoer + Now func() time.Time +} + +func (c KimiCollector) Collect(ctx context.Context) Result { + now := time.Now() + if c.Now != nil { + now = c.Now() + } + path := c.AuthPath + if path == "" { + path = kimiAuthPath() + } + token, expired, found := loadKimiToken(path, now) + if !found { + return emptyResult(ProviderKimi, ReasonNotLoggedIn, now) + } + if expired { + return Result{Upload: false} + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, kimiUsageURL, nil) + if err != nil { + return Result{Upload: false} + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + body, status, retryAfter, err := performVendor(c.Do, req) + if err != nil { + return Result{Upload: false} + } + if status == http.StatusNotFound { + return emptyResult(ProviderKimi, ReasonUnsupported, now) + } + reason, backoff, transient := classifyVendorStatus(status, retryAfter) + if transient { + return Result{Upload: false, Backoff: backoff} + } + if reason != "" { + return emptyResult(ProviderKimi, reason, now) + } + windows, plan, ok := ParseKimiUsage(body) + if !ok { + return Result{Upload: false} + } + return Result{ + Upload: true, + Snapshot: Snapshot{ + Provider: ProviderKimi, + PlanName: plan, + CollectedAt: now, + Windows: windows, + }, + } +} + +func kimiAuthPath() string { + root := strings.TrimSpace(os.Getenv("KIMI_CODE_HOME")) + if root == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + root = filepath.Join(home, ".kimi-code") + } + return filepath.Join(root, "credentials", "kimi-code.json") +} + +func loadKimiToken(path string, now time.Time) (token string, expired, found bool) { + body, err := readRegularFile(path) + if err != nil { + return "", false, false + } + root, ok := decodeObject(body) + if !ok { + return "", false, false + } + token, _ = root["access_token"].(string) + token = strings.TrimSpace(token) + if token == "" { + return "", false, false + } + expires, hasExpiry := asFloat(root["expires_at"]) + if !hasExpiry || expires <= 0 { + return "", false, false + } + if !time.Unix(int64(expires), 0).After(now) { + return "", true, true + } + return token, false, true +} + +// ParseKimiUsage maps the account summary and named limit rows onto +// weekly and rolling windows. Counts arrive as decimal strings. +func ParseKimiUsage(body []byte) (windows []Window, plan string, ok bool) { + root, parsed := decodeObject(body) + if !parsed { + return nil, "", false + } + plan = kimiPlanName(root) + if summary := asMap(root["usage"]); summary != nil { + if window, added := kimiRow("weekly", summary); added { + windows = append(windows, window) + } + } + for _, item := range asSlice(root["limits"]) { + entry := asMap(item) + if entry == nil { + continue + } + id, matched := kimiWindowID(asMap(entry["window"])) + if !matched { + continue + } + detail := asMap(entry["detail"]) + window, added := kimiRow(id, detail) + if !added { + continue + } + replaced := false + for i := range windows { + if windows[i].ID == window.ID { + windows[i] = window + replaced = true + break + } + } + if !replaced && len(windows) < 8 { + windows = append(windows, window) + } + } + return windows, plan, len(windows) > 0 +} + +func kimiPlanName(root map[string]any) string { + user := asMap(root["user"]) + membership := asMap(user["membership"]) + level, _ := membership["level"].(string) + level = strings.TrimSpace(level) + level = strings.TrimPrefix(level, "LEVEL_") + if level == "" { + return "" + } + lower := strings.ToLower(level) + return trimPlan(strings.ToUpper(lower[:1]) + lower[1:]) +} + +func kimiWindowID(window map[string]any) (string, bool) { + if window == nil { + return "", false + } + duration, ok := asFloat(window["duration"]) + if !ok || duration <= 0 { + return "", false + } + unit, _ := window["timeUnit"].(string) + switch unit { + case "TIME_UNIT_MINUTE": + if int(duration)%60 == 0 && int(duration)/60 == 5 { + return "rolling", true + } + case "TIME_UNIT_HOUR": + if duration == 5 { + return "rolling", true + } + case "TIME_UNIT_WEEK": + if duration == 1 { + return "weekly", true + } + } + return "", false +} + +func kimiRow(id string, detail map[string]any) (Window, bool) { + if detail == nil { + return Window{}, false + } + limit, hasLimit := asFloat(detail["limit"]) + if !hasLimit || limit <= 0 { + return Window{}, false + } + used, hasUsed := asFloat(detail["used"]) + if !hasUsed { + remaining, hasRemaining := asFloat(detail["remaining"]) + if !hasRemaining || remaining < 0 || remaining > limit { + return Window{}, false + } + used = limit - remaining + } + if used < 0 { + used = 0 + } + percent, ok := usablePercent(used / limit * 100) + if !ok { + return Window{}, false + } + return Window{ID: id, PercentUsed: percent, ResetsAt: parseResetValue(detail["resetTime"])}, true +} diff --git a/server/internal/daemon/providerusage/kimi_test.go b/server/internal/daemon/providerusage/kimi_test.go new file mode 100644 index 00000000000..c38e43323a8 --- /dev/null +++ b/server/internal/daemon/providerusage/kimi_test.go @@ -0,0 +1,98 @@ +package providerusage + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestParseKimiUsage(t *testing.T) { + body := []byte(`{ + "user": {"membership": {"level": "LEVEL_ADVANCED"}}, + "usage": {"limit": "100", "used": "2", "remaining": "98", "resetTime": "2026-09-15T19:39:34.389610Z"}, + "limits": [{ + "window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"}, + "detail": {"limit": "100", "used": "8", "remaining": "92", "resetTime": "2026-09-11T16:39:34.389610Z"} + }] + }`) + windows, plan, ok := ParseKimiUsage(body) + if !ok || plan != "Advanced" || len(windows) != 2 { + t.Fatalf("windows=%+v plan=%q ok=%v", windows, plan, ok) + } + if windows[0].ID != "weekly" || windows[0].PercentUsed != 2 { + t.Fatalf("weekly = %+v", windows[0]) + } + if windows[1].ID != "rolling" || windows[1].PercentUsed != 8 { + t.Fatalf("rolling = %+v", windows[1]) + } +} + +func TestKimiExpiredTokenSkipsVendor(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "kimi-code.json") + raw := []byte(`{"access_token":"kimi-access","expires_at":10}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + c := KimiCollector{ + AuthPath: path, + Now: func() time.Time { return time.Unix(100, 0).UTC() }, + Do: func(*http.Request) (*http.Response, error) { + t.Fatal("expired kimi token must not call the vendor") + return nil, nil + }, + } + got := c.Collect(t.Context()) + if got.Upload { + t.Fatalf("expired token uploaded %+v", got) + } +} + +func TestKimiCollectorUploadsPercentsNotToken(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "kimi-code.json") + const token = "kimi-access-token" + raw := []byte(`{"access_token":"` + token + `","expires_at":1000}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + c := KimiCollector{ + AuthPath: path, + Now: func() time.Time { return time.Unix(100, 0).UTC() }, + Do: func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Authorization") != "Bearer "+token { + t.Fatalf("auth = %q", req.Header.Get("Authorization")) + } + return jsonResponse(http.StatusOK, `{"usage":{"limit":"50","remaining":"25","resetTime":"2026-09-20T00:00:00Z"}}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || len(got.Snapshot.Windows) != 1 || got.Snapshot.Windows[0].PercentUsed != 50 { + t.Fatalf("result = %+v", got) + } + if strings.Contains(got.Snapshot.PlanName+got.Snapshot.ReasonCode, token) { + t.Fatal("snapshot kept the access token") + } +} + +func TestKimiNoPlanIsUnsupported(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "kimi-code.json") + if err := os.WriteFile(path, []byte(`{"access_token":"kimi-access","expires_at":1000}`), 0o600); err != nil { + t.Fatal(err) + } + c := KimiCollector{ + AuthPath: path, + Now: func() time.Time { return time.Unix(100, 0).UTC() }, + Do: func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusNotFound, `{"error":"missing"}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonUnsupported { + t.Fatalf("result = %+v", got) + } +} diff --git a/server/internal/daemon/providerusage/kiro.go b/server/internal/daemon/providerusage/kiro.go new file mode 100644 index 00000000000..e3a58a11fbf --- /dev/null +++ b/server/internal/daemon/providerusage/kiro.go @@ -0,0 +1,230 @@ +package providerusage + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +// kiroUsageArgs asks the signed-in CLI for its own /usage card. The daemon +// does not open Kiro's sqlite token store. +var kiroUsageArgs = []string{"chat", "--no-interactive", "/usage"} + +var ( + kiroANSI = regexp.MustCompile("\x1b\\[[0-9;:?]*[ -/]*[@-~]|\x1b\\][^\x07\x1b]*(?:\x07|\x1b\\\\)") + kiroPercent = regexp.MustCompile(`█+\s*(\d+(?:\.\d+)?)%`) + kiroCredits = regexp.MustCompile(`\((\d+(?:\.\d+)?)\s+of\s+(\d+(?:\.\d+)?)\s+covered`) + kiroPlan = regexp.MustCompile(`(?i)Plan:[ \t]*([^|\r\n]+)`) + kiroReset = regexp.MustCompile(`(?i)resets on (\d{4}-\d{2}-\d{2}|\d{1,2}/\d{1,2})`) + kiroBonus = regexp.MustCompile(`(?i)Bonus credits:\s*(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)`) + kiroLoggedOut = []string{"not logged in", "login required", "failed to initialize auth portal", "kiro-cli login", "oauth error"} +) + +// KiroCollector reads plan credits from `kiro-cli /usage` stdout. +type KiroCollector struct { + Locate func() (string, error) + Run ClaudeCommand + Now func() time.Time +} + +func (c KiroCollector) Collect(ctx context.Context) Result { + now := time.Now() + if c.Now != nil { + now = c.Now() + } + locate := c.Locate + if locate == nil { + locate = locateKiro + } + path, err := locate() + if err != nil || path == "" { + return emptyResult(ProviderKiro, ReasonCLIUnavailable, now) + } + run := c.Run + if run == nil { + run = runKiro + } + stdout, stderr, _, runErr := run(ctx, path, kiroUsageArgs, "") + text := stdout + if runErr != nil && strings.TrimSpace(text) == "" { + return Result{Upload: false} + } + if kiroLooksLoggedOut(text) || kiroLooksLoggedOut(stderr) { + return emptyResult(ProviderKiro, ReasonNotLoggedIn, now) + } + windows, plan, ok := ParseKiroUsage(text, now) + if !ok { + return Result{Upload: false} + } + return Result{ + Upload: true, + Snapshot: Snapshot{ + Provider: ProviderKiro, + PlanName: plan, + CollectedAt: now, + Windows: windows, + }, + } +} + +func locateKiro() (string, error) { + if path, err := exec.LookPath("kiro-cli"); err == nil && filepath.IsAbs(path) && isExecutable(path) { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + for _, path := range []string{ + filepath.Join(home, ".local", "bin", "kiro-cli"), + "/opt/homebrew/bin/kiro-cli", + "/usr/local/bin/kiro-cli", + "/usr/bin/kiro-cli", + } { + if isExecutable(path) { + return path, nil + } + } + return "", os.ErrNotExist +} + +func runKiro(ctx context.Context, path string, args []string, dir string) (string, string, int, error) { + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, path, args...) + if dir != "" { + cmd.Dir = dir + } + cmd.Env = append(os.Environ(), "TERM=dumb") + cmd.Stdin = bytes.NewReader(nil) + var stdout, stderr bytes.Buffer + cmd.Stdout = &limitedWriter{w: &stdout, n: 256 * 1024} + cmd.Stderr = &limitedWriter{w: &stderr, n: 4 * 1024} + err := cmd.Run() + exitCode := 0 + if err != nil { + if ctx.Err() != nil { + return stdout.String(), stderr.String(), -1, ctx.Err() + } + if ee, ok := err.(*exec.ExitError); ok { + exitCode = ee.ExitCode() + err = nil + } + } + return stdout.String(), stderr.String(), exitCode, err +} + +// ParseKiroUsage extracts the credit bar from kiro-cli /usage text. +func ParseKiroUsage(text string, now time.Time) (windows []Window, plan string, ok bool) { + text = kiroANSI.ReplaceAllString(text, "") + if kiroLooksLoggedOut(text) { + return nil, "", false + } + if match := kiroPlan.FindStringSubmatch(text); len(match) == 2 { + plan = trimPlan(kiroDisplayPlan(match[1])) + } + var percent float64 + hasPercent := false + if match := kiroPercent.FindStringSubmatch(text); len(match) == 2 { + if value, err := strconv.ParseFloat(match[1], 64); err == nil { + if value, usable := usablePercent(value); usable { + percent = value + hasPercent = true + } + } + } + if !hasPercent { + if match := kiroCredits.FindStringSubmatch(text); len(match) == 3 { + used, usedErr := strconv.ParseFloat(match[1], 64) + total, totalErr := strconv.ParseFloat(match[2], 64) + if usedErr == nil && totalErr == nil && total > 0 { + if value, usable := usablePercent(used / total * 100); usable { + percent = value + hasPercent = true + } + } + } + } + if hasPercent { + windows = append(windows, Window{ + ID: "credits", + PercentUsed: percent, + ResetsAt: kiroResetTime(text, now), + }) + } + if match := kiroBonus.FindStringSubmatch(text); len(match) == 3 { + used, usedErr := strconv.ParseFloat(match[1], 64) + total, totalErr := strconv.ParseFloat(match[2], 64) + if usedErr == nil && totalErr == nil && total > 0 { + if value, usable := usablePercent(used / total * 100); usable && len(windows) < 8 { + windows = append(windows, Window{ID: "bonus", PercentUsed: value}) + } + } + } + return windows, plan, len(windows) > 0 +} + +func kiroLooksLoggedOut(text string) bool { + lower := strings.ToLower(text) + for _, phrase := range kiroLoggedOut { + if strings.Contains(lower, phrase) { + return true + } + } + return false +} + +func kiroDisplayPlan(raw string) string { + raw = strings.Join(strings.Fields(raw), " ") + if !strings.Contains(strings.ToLower(raw), "kiro") { + return raw + } + parts := strings.Fields(raw) + for i, part := range parts { + if strings.EqualFold(part, "kiro") { + parts[i] = "Kiro" + continue + } + lower := strings.ToLower(part) + parts[i] = strings.ToUpper(lower[:1]) + lower[1:] + } + return strings.Join(parts, " ") +} + +func kiroResetTime(text string, now time.Time) *time.Time { + match := kiroReset.FindStringSubmatch(text) + if len(match) != 2 { + return nil + } + stamp := match[1] + if strings.Contains(stamp, "-") { + parsed, err := time.ParseInLocation("2006-01-02", stamp, time.Local) + if err != nil { + return nil + } + utc := parsed.UTC() + return &utc + } + parts := strings.Split(stamp, "/") + if len(parts) != 2 { + return nil + } + month, monthErr := strconv.Atoi(parts[0]) + day, dayErr := strconv.Atoi(parts[1]) + if monthErr != nil || dayErr != nil || month < 1 || month > 12 || day < 1 || day > 31 { + return nil + } + year := now.Year() + parsed := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.Local) + if parsed.Before(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)) { + parsed = parsed.AddDate(1, 0, 0) + } + utc := parsed.UTC() + return &utc +} diff --git a/server/internal/daemon/providerusage/kiro_test.go b/server/internal/daemon/providerusage/kiro_test.go new file mode 100644 index 00000000000..0c7a37cc7d5 --- /dev/null +++ b/server/internal/daemon/providerusage/kiro_test.go @@ -0,0 +1,61 @@ +package providerusage + +import ( + "context" + "os" + "testing" + "time" +) + +func TestParseKiroUsage(t *testing.T) { + text := "Plan: KIRO PRO | 1 usage breakdown\n████████ 40%\n(120 of 300 covered in plan)\nresets on 2026-10-01\nBonus credits: 10/50\n" + now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC) + windows, plan, ok := ParseKiroUsage(text, now) + if !ok || plan != "Kiro Pro" || len(windows) != 2 { + t.Fatalf("windows=%+v plan=%q ok=%v", windows, plan, ok) + } + if windows[0].ID != "credits" || windows[0].PercentUsed != 40 || windows[0].ResetsAt == nil { + t.Fatalf("credits = %+v", windows[0]) + } + if windows[1].ID != "bonus" || windows[1].PercentUsed != 20 { + t.Fatalf("bonus = %+v", windows[1]) + } +} + +func TestParseKiroUsageCreditsWithoutBar(t *testing.T) { + windows, _, ok := ParseKiroUsage("(15 of 100 covered in plan)", time.Now()) + if !ok || len(windows) != 1 || windows[0].PercentUsed != 15 { + t.Fatalf("windows=%+v ok=%v", windows, ok) + } +} + +func TestKiroLoggedOutIsEmpty(t *testing.T) { + c := KiroCollector{ + Locate: func() (string, error) { return "/usr/bin/kiro-cli", nil }, + Run: func(ctx context.Context, path string, args []string, dir string) (string, string, int, error) { + if path != "/usr/bin/kiro-cli" || len(args) != 3 || args[2] != "/usage" { + t.Fatalf("command = %s %v", path, args) + } + return "You are not logged in. Run kiro-cli login.", "", 1, nil + }, + Now: func() time.Time { return time.Unix(10, 0).UTC() }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonNotLoggedIn { + t.Fatalf("result = %+v", got) + } +} + +func TestKiroMissingCLIIsEmpty(t *testing.T) { + c := KiroCollector{ + Locate: func() (string, error) { return "", os.ErrNotExist }, + Run: func(ctx context.Context, path string, args []string, dir string) (string, string, int, error) { + t.Fatal("missing cli must not run") + return "", "", 0, nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonCLIUnavailable { + t.Fatalf("result = %+v", got) + } +} diff --git a/server/internal/daemon/providerusage/opencode.go b/server/internal/daemon/providerusage/opencode.go new file mode 100644 index 00000000000..f5dcac156df --- /dev/null +++ b/server/internal/daemon/providerusage/opencode.go @@ -0,0 +1,458 @@ +package providerusage + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "math" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +const ( + openCodeGoUsageURL = "https://opencode.ai/zen/go/v1/usage" + openCodeOAuthUsageURL = "https://opencode.ai/inference/go/v1/usage" + openCodeDefaultConsole = "https://opencode.ai/console" +) + +var openCodeWindows = []string{"rolling", "weekly", "monthly"} + +// OpenCodeCollector reads OpenCode Go plan windows from the sign-in OpenCode +// stored locally. Lookup order matches OpenCode 1.18+: auth.json's opencode-go +// key, then the credential table in opencode.db, then auth.json's mirrored +// opencode OAuth entry. Other vendors' rows are ignored. The collector uploads +// derived windows only; the token never leaves this process. +type OpenCodeCollector struct { + AuthPath string + Do HTTPDoer + Now func() time.Time +} + +type openCodeCredential struct { + token string + oauth bool + console string + org string + expires time.Time + hasExpiry bool +} + +func (c openCodeCredential) expired(now time.Time) bool { + return c.hasExpiry && !c.expires.After(now) +} + +func (c openCodeCredential) usageURL() string { + if c.oauth { + return openCodeOAuthUsageURL + } + return openCodeGoUsageURL +} + +func (c OpenCodeCollector) Collect(ctx context.Context) Result { + now := time.Now() + if c.Now != nil { + now = c.Now() + } + cred, found := c.loadCredential(ctx) + if !found { + return emptyResult(ProviderOpenCode, ReasonNotLoggedIn, now) + } + // Expired OAuth copies are not refreshed. OpenCode renews the sign-in the + // next time it runs; until then the snapshot stays empty so a stale token + // is not reported as a missing login, and collection does not fail a task. + if cred.expired(now) { + return emptyResult(ProviderOpenCode, ReasonCredentialExpired, now) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cred.usageURL(), nil) + if err != nil { + return Result{Upload: false} + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+cred.token) + if cred.org != "" { + req.Header.Set("x-opencode-org-id", cred.org) + } + body, status, retryAfter, err := performVendor(c.Do, req) + if err != nil { + return Result{Upload: false} + } + // zen/go refuses an OAuth token and inference/go refuses a Go key, each + // with a 401 that is also what an account with no Go plan returns. The + // console's /api/user answers 401 only to a bad token, so a 200 here means + // the sign-in is fine and the account simply has no Go plan. + if status == http.StatusUnauthorized && cred.oauth && openCodeConsoleSignedIn(ctx, c.Do, cred) { + return emptyResult(ProviderOpenCode, ReasonUnsupported, now) + } + if status == http.StatusForbidden { + return emptyResult(ProviderOpenCode, ReasonUnsupported, now) + } + reason, backoff, transient := classifyVendorStatus(status, retryAfter) + if transient { + return Result{Upload: false, Backoff: backoff} + } + if reason != "" { + return emptyResult(ProviderOpenCode, reason, now) + } + windows, ok := ParseOpenCodeUsage(body) + if !ok { + return Result{Upload: false} + } + return Result{ + Upload: true, + Snapshot: Snapshot{ + Provider: ProviderOpenCode, + PlanName: "Go", + CollectedAt: now, + Windows: windows, + }, + } +} + +func (c OpenCodeCollector) loadCredential(ctx context.Context) (openCodeCredential, bool) { + if c.AuthPath != "" { + return loadOpenCodeCredentialInDir(ctx, filepath.Dir(c.AuthPath), filepath.Base(c.AuthPath)) + } + return loadOpenCodeFromDirs(ctx, openCodeDataDirs()) +} + +func loadOpenCodeFromDirs(ctx context.Context, dirs []string) (openCodeCredential, bool) { + for _, dir := range dirs { + if cred, ok := loadOpenCodeCredentialInDir(ctx, dir, "auth.json"); ok { + return cred, true + } + } + return openCodeCredential{}, false +} + +// loadOpenCodeCredentialInDir tries one data directory in CodeNotch's order: +// auth.json opencode-go, then opencode.db, then auth.json's opencode OAuth entry. +func loadOpenCodeCredentialInDir(ctx context.Context, dir, authName string) (openCodeCredential, bool) { + auth := filepath.Join(dir, authName) + if cred, ok := loadOpenCodeGoKey(auth); ok { + return cred, true + } + if cred, ok := loadOpenCodeDBCredential(ctx, filepath.Join(dir, "opencode.db")); ok { + return cred, true + } + if cred, ok := loadOpenCodeOAuthFile(auth); ok { + return cred, true + } + return openCodeCredential{}, false +} + +func openCodeDataDirs() []string { + home, _ := os.UserHomeDir() + config, _ := os.UserConfigDir() + return openCodeDataDirsFor(runtime.GOOS, home, os.Getenv("XDG_DATA_HOME"), config) +} + +// openCodeDataDirsFor is OpenCode's own data dir, then the platform paths this +// collector already searched. XDG_DATA_HOME replaces ~/.local/share when set. +func openCodeDataDirsFor(goos, home, xdg, configDir string) []string { + var dirs []string + seen := map[string]struct{}{} + add := func(dir string) { + if dir == "" { + return + } + if _, ok := seen[dir]; ok { + return + } + seen[dir] = struct{}{} + dirs = append(dirs, dir) + } + if strings.TrimSpace(xdg) != "" { + add(filepath.Join(xdg, "opencode")) + } else if home != "" { + add(filepath.Join(home, ".local", "share", "opencode")) + } + if goos == "darwin" && home != "" { + add(filepath.Join(home, "Library", "Application Support", "opencode")) + } + if goos == "windows" && configDir != "" { + add(filepath.Join(configDir, "opencode")) + } + return dirs +} + +func loadOpenCodeGoKey(path string) (openCodeCredential, bool) { + root, ok := readOpenCodeAuth(path) + if !ok { + return openCodeCredential{}, false + } + entry, exists := root["opencode-go"] + if !exists { + return openCodeCredential{}, false + } + return openCodeCredentialFrom(entry) +} + +func loadOpenCodeOAuthFile(path string) (openCodeCredential, bool) { + root, ok := readOpenCodeAuth(path) + if !ok { + return openCodeCredential{}, false + } + entry := asMap(root["opencode"]) + if stringField(entry, "type") != "oauth" { + return openCodeCredential{}, false + } + return openCodeCredentialFrom(entry) +} + +func readOpenCodeAuth(path string) (map[string]any, bool) { + body, err := readRegularFile(path) + if err != nil { + return nil, false + } + return decodeObject(body) +} + +func openCodeCredentialFrom(entry any) (openCodeCredential, bool) { + if token, isString := entry.(string); isString { + token = strings.TrimSpace(token) + if token == "" { + return openCodeCredential{}, false + } + return openCodeCredential{token: token}, true + } + object := asMap(entry) + if object == nil { + return openCodeCredential{}, false + } + if stringField(object, "type") == "oauth" { + token := stringField(object, "access") + if token == "" { + return openCodeCredential{}, false + } + meta := asMap(object["metadata"]) + expires, hasExpiry := openCodeExpiry(object["expires"]) + return openCodeCredential{ + token: token, + oauth: true, + console: stringField(meta, "server"), + org: stringField(meta, "orgID"), + expires: expires, + hasExpiry: hasExpiry, + }, true + } + for _, key := range []string{"key", "apiKey", "api_key", "token", "accessToken"} { + token := stringField(object, key) + if token != "" { + return openCodeCredential{token: token}, true + } + } + return openCodeCredential{}, false +} + +// openCodeExpiry reads expires as epoch milliseconds. auth.json stores a +// number; opencode.db stores the same instant as a string. +func openCodeExpiry(v any) (time.Time, bool) { + var ms float64 + switch value := v.(type) { + case float64: + ms = value + case json.Number: + parsed, err := value.Float64() + if err != nil { + return time.Time{}, false + } + ms = parsed + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return time.Time{}, false + } + ms = parsed + default: + return time.Time{}, false + } + if ms <= 0 || math.IsNaN(ms) || math.IsInf(ms, 0) { + return time.Time{}, false + } + return time.UnixMilli(int64(ms)).UTC(), true +} + +func loadOpenCodeDBCredential(ctx context.Context, path string) (openCodeCredential, bool) { + db, err := openOpenCodeDB(ctx, path) + if err != nil { + return openCodeCredential{}, false + } + defer db.Close() + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + rows, err := db.QueryContext(ctx, ` + SELECT integration_id, value FROM credential + WHERE integration_id IN ('opencode-go', 'opencode') + AND COALESCE(active, 1) != 0 + ORDER BY time_updated DESC`) + if err != nil { + return openCodeCredential{}, false + } + defer rows.Close() + + type stored struct { + id string + value string + } + var got []stored + for rows.Next() { + var row stored + if err := rows.Scan(&row.id, &row.value); err != nil { + return openCodeCredential{}, false + } + got = append(got, row) + } + if err := rows.Err(); err != nil { + return openCodeCredential{}, false + } + // Go key first, then OAuth, so a machine holding both reads the same + // account as the auth.json order. Rows are already newest-first. + for _, wanted := range []string{"opencode-go", "opencode"} { + for _, row := range got { + if row.id != wanted { + continue + } + parsed, ok := decodeJSONValue([]byte(row.value)) + if !ok { + continue + } + if cred, ok := openCodeCredentialFrom(parsed); ok { + return cred, true + } + } + } + return openCodeCredential{}, false +} + +func decodeJSONValue(body []byte) (any, bool) { + var value any + if err := json.Unmarshal(body, &value); err != nil { + return nil, false + } + return value, true +} + +// openOpenCodeDB opens another process's database. mode=ro sees the +// write-ahead log, so a token OpenCode just rotated is visible. immutable=1 +// is the fallback for a checkpointed file whose -shm sidecar is gone; it +// ignores the log, which is safe only because that is when mode=ro failed. +func openOpenCodeDB(ctx context.Context, path string) (*sql.DB, error) { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + if err == nil { + err = os.ErrInvalid + } + return nil, err + } + var last error + for _, query := range []string{"mode=ro", "immutable=1"} { + db, err := sql.Open("sqlite", openCodeDBFileURL(path, query)) + if err != nil { + last = err + continue + } + db.SetMaxOpenConns(1) + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + err = probeOpenCodeCredentialTable(probeCtx, db) + cancel() + if err == nil { + return db, nil + } + last = err + _ = db.Close() + } + if last == nil { + last = os.ErrInvalid + } + return nil, last +} + +func probeOpenCodeCredentialTable(ctx context.Context, db *sql.DB) error { + var one int + err := db.QueryRowContext(ctx, `SELECT 1 FROM credential LIMIT 1`).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err +} + +func openCodeDBFileURL(path, query string) string { + u := url.URL{Scheme: "file", Path: filepath.ToSlash(path), RawQuery: query} + return u.String() +} + +func openCodeConsoleSignedIn(ctx context.Context, do HTTPDoer, cred openCodeCredential) bool { + endpoint, ok := openCodeUserURL(cred.console) + if !ok { + return false + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return false + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+cred.token) + if cred.org != "" { + req.Header.Set("x-opencode-org-id", cred.org) + } + _, status, _, err := performVendor(do, req) + return err == nil && status == http.StatusOK +} + +func openCodeUserURL(console string) (string, bool) { + raw := strings.TrimSpace(console) + if raw == "" { + raw = openCodeDefaultConsole + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + if strings.TrimSpace(console) == "" || raw == openCodeDefaultConsole { + return "", false + } + return openCodeUserURL("") + } + u.RawQuery = "" + u.Fragment = "" + u.Path = strings.TrimRight(u.Path, "/") + "/api/user" + return u.String(), true +} + +// ParseOpenCodeUsage maps Go plan windows. percent is already "percent used". +func ParseOpenCodeUsage(body []byte) ([]Window, bool) { + root, ok := decodeObject(body) + if !ok { + return nil, false + } + usage := asMap(root["usage"]) + if usage == nil { + return nil, false + } + var windows []Window + for _, id := range openCodeWindows { + entry := asMap(usage[id]) + percent, hasPercent := asFloat(entry["percent"]) + if !hasPercent { + continue + } + value, usable := usablePercent(percent) + if !usable { + continue + } + windows = append(windows, Window{ + ID: id, + PercentUsed: value, + ResetsAt: parseResetValue(entry["resetsAt"]), + }) + } + return windows, len(windows) > 0 +} diff --git a/server/internal/daemon/providerusage/opencode_test.go b/server/internal/daemon/providerusage/opencode_test.go new file mode 100644 index 00000000000..f2c342ebd0c --- /dev/null +++ b/server/internal/daemon/providerusage/opencode_test.go @@ -0,0 +1,504 @@ +package providerusage + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestParseOpenCodeUsage(t *testing.T) { + body := []byte(`{"usage":{ + "rolling":{"status":"ok","percent":12.5,"resetsAt":"2026-09-06T12:31:06.611Z"}, + "weekly":{"status":"ok","percent":3,"resetsAt":"2026-09-07T00:00:00.611Z"}, + "monthly":{"status":"ok","percent":1,"resetsAt":"2026-10-03T13:09:45.611Z"} + }}`) + windows, ok := ParseOpenCodeUsage(body) + if !ok || len(windows) != 3 { + t.Fatalf("windows=%+v ok=%v", windows, ok) + } + if windows[0].ID != "rolling" || windows[0].PercentUsed != 12.5 || windows[0].ResetsAt == nil { + t.Fatalf("rolling = %+v", windows[0]) + } + if windows[1].ID != "weekly" || windows[2].ID != "monthly" { + t.Fatalf("order = %+v", windows) + } +} + +func TestOpenCodeIgnoresOtherVendorKeys(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + if err := os.WriteFile(path, []byte(`{"openai":{"type":"api","key":"sk-other"}}`), 0o600); err != nil { + t.Fatal(err) + } + c := OpenCodeCollector{ + AuthPath: path, + Do: func(*http.Request) (*http.Response, error) { + t.Fatal("a non-go key must not call OpenCode") + return nil, nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonNotLoggedIn { + t.Fatalf("result = %+v", got) + } +} + +func TestOpenCodeCollectorUploadsGoWindows(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + const token = "opencode-go-key" + if err := os.WriteFile(path, []byte(`{"opencode-go":{"type":"api","key":"`+token+`"}}`), 0o600); err != nil { + t.Fatal(err) + } + c := OpenCodeCollector{ + AuthPath: path, + Now: func() time.Time { return time.Unix(10, 0).UTC() }, + Do: func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Authorization") != "Bearer "+token { + t.Fatalf("auth = %q", req.Header.Get("Authorization")) + } + return jsonResponse(http.StatusOK, `{"usage":{"rolling":{"percent":20,"resetsAt":"2026-09-06T12:31:06Z"}}}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.PlanName != "Go" || got.Snapshot.Windows[0].ID != "rolling" || got.Snapshot.Windows[0].PercentUsed != 20 { + t.Fatalf("result = %+v", got) + } +} + +func TestOpenCodeForbiddenIsUnsupported(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + if err := os.WriteFile(path, []byte(`{"opencode-go":"go-key"}`), 0o600); err != nil { + t.Fatal(err) + } + c := OpenCodeCollector{ + AuthPath: path, + Do: func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusForbidden, `{}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonUnsupported { + t.Fatalf("result = %+v", got) + } +} + +func TestOpenCodeEmptyKeyAndOAuthWithoutAccessAreMissing(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + if err := os.WriteFile(path, []byte(`{"opencode-go":{"type":"api","key":" "},"opencode":{"type":"oauth","access":""}}`), 0o600); err != nil { + t.Fatal(err) + } + c := OpenCodeCollector{AuthPath: path, Do: failVendor(t)} + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonNotLoggedIn || len(got.Snapshot.Windows) != 0 { + t.Fatalf("result = %+v", got) + } +} + +func TestOpenCodeOAuthUsesInferenceEndpoint(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + const access = "st-oauth-access" + const refresh = "rt-must-not-leak" + now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + expires := now.Add(time.Hour).UnixMilli() + body := fmt.Sprintf(`{"opencode":{"type":"oauth","refresh":"%s","access":"%s","expires":%d,"metadata":{"server":"https://opencode.ai/console","orgID":"org_1"}}}`, refresh, access, expires) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + c := OpenCodeCollector{ + AuthPath: path, + Now: func() time.Time { return now }, + Do: func(req *http.Request) (*http.Response, error) { + if req.URL.String() != openCodeOAuthUsageURL { + t.Fatalf("url = %s", req.URL.String()) + } + if req.Header.Get("Authorization") != "Bearer "+access { + t.Fatalf("auth = %q", req.Header.Get("Authorization")) + } + if req.Header.Get("x-opencode-org-id") != "org_1" { + t.Fatalf("org = %q", req.Header.Get("x-opencode-org-id")) + } + if strings.Contains(req.Header.Get("Authorization"), refresh) { + t.Fatal("refresh token was sent") + } + return jsonResponse(http.StatusOK, `{"usage":{"weekly":{"percent":4,"resetsAt":"2026-09-07T00:00:00Z"}}}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.PlanName != "Go" || got.Snapshot.Windows[0].ID != "weekly" { + t.Fatalf("result = %+v", got) + } + assertSnapshotOmits(t, got, access, refresh) +} + +func TestOpenCodeExpiredTokenIsEmptySnapshot(t *testing.T) { + now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + past := strconv.FormatInt(now.Add(-time.Minute).UnixMilli(), 10) + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + const access = "st-expired-access" + raw := fmt.Sprintf(`{"opencode":{"type":"oauth","access":"%s","expires":"%s"}}`, access, past) + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + c := OpenCodeCollector{ + AuthPath: path, + Now: func() time.Time { return now }, + Do: failVendor(t), + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonCredentialExpired || len(got.Snapshot.Windows) != 0 || got.Snapshot.PlanName != "" { + t.Fatalf("result = %+v", got) + } + assertSnapshotOmits(t, got, access) +} + +func TestOpenCodeDatabaseCredentialWithoutAuthFile(t *testing.T) { + dir := t.TempDir() + const access = "st-from-db" + now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + expires := strconv.FormatInt(now.Add(time.Hour).UnixMilli(), 10) + writeOpenCodeDB(t, filepath.Join(dir, "opencode.db"), []openCodeDBRow{ + {integration: "openrouter", value: `{"type":"api","key":"sk-or"}`, updated: 9}, + {integration: "opencode", value: fmt.Sprintf(`{"type":"oauth","access":"%s","expires":"%s","metadata":{"orgID":"org_db","server":"https://opencode.ai/console"}}`, access, expires), active: intPtr(1), updated: 3}, + }) + c := OpenCodeCollector{ + AuthPath: filepath.Join(dir, "auth.json"), + Now: func() time.Time { return now }, + Do: func(req *http.Request) (*http.Response, error) { + if req.URL.String() != openCodeOAuthUsageURL { + t.Fatalf("url = %s", req.URL.String()) + } + if req.Header.Get("Authorization") != "Bearer "+access { + t.Fatalf("auth = %q", req.Header.Get("Authorization")) + } + if req.Header.Get("x-opencode-org-id") != "org_db" { + t.Fatalf("org = %q", req.Header.Get("x-opencode-org-id")) + } + return jsonResponse(http.StatusOK, `{"usage":{"rolling":{"percent":45,"resetsAt":"2026-09-29T11:16:16Z"}}}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.Windows[0].PercentUsed != 45 { + t.Fatalf("result = %+v", got) + } + assertSnapshotOmits(t, got, access, "sk-or") +} + +func TestOpenCodeDatabaseIgnoresOtherVendors(t *testing.T) { + dir := t.TempDir() + writeOpenCodeDB(t, filepath.Join(dir, "opencode.db"), []openCodeDBRow{ + {integration: "openrouter", value: `{"type":"api","key":"sk-or"}`, updated: 5}, + {integration: "openai", value: `{"type":"api","key":"sk-openai"}`, updated: 8}, + }) + c := OpenCodeCollector{AuthPath: filepath.Join(dir, "auth.json"), Do: failVendor(t)} + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonNotLoggedIn { + t.Fatalf("result = %+v", got) + } +} + +func TestOpenCodeDatabasePrefersGoKeyAndSkipsInactive(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + writeOpenCodeDB(t, filepath.Join(dir, "opencode.db"), []openCodeDBRow{ + {integration: "opencode", value: `{"type":"oauth","access":"st-newer"}`, active: intPtr(1), updated: 9}, + {integration: "opencode-go", value: `{"type":"api","key":"sk-go-db"}`, updated: 1}, + }) + c := OpenCodeCollector{ + AuthPath: filepath.Join(dir, "auth.json"), + Now: func() time.Time { return now }, + Do: func(req *http.Request) (*http.Response, error) { + if req.URL.String() != openCodeGoUsageURL { + t.Fatalf("url = %s", req.URL.String()) + } + if req.Header.Get("Authorization") != "Bearer sk-go-db" { + t.Fatalf("auth = %q", req.Header.Get("Authorization")) + } + return jsonResponse(http.StatusOK, `{"usage":{"monthly":{"percent":2}}}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.Windows[0].ID != "monthly" { + t.Fatalf("result = %+v", got) + } + + dir = t.TempDir() + writeOpenCodeDB(t, filepath.Join(dir, "opencode.db"), []openCodeDBRow{ + {integration: "opencode", value: `{"type":"oauth","access":"st-old"}`, active: intPtr(0), updated: 9}, + {integration: "opencode", value: `{"type":"oauth","access":"st-live"}`, active: intPtr(1), updated: 1}, + }) + c = OpenCodeCollector{ + AuthPath: filepath.Join(dir, "auth.json"), + Now: func() time.Time { return now }, + Do: func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Authorization") != "Bearer st-live" { + t.Fatalf("auth = %q", req.Header.Get("Authorization")) + } + if req.URL.String() != openCodeOAuthUsageURL { + t.Fatalf("url = %s", req.URL.String()) + } + return jsonResponse(http.StatusOK, `{"usage":{"rolling":{"percent":1}}}`), nil + }, + } + got = c.Collect(t.Context()) + if !got.Upload || got.Snapshot.Windows[0].PercentUsed != 1 { + t.Fatalf("result = %+v", got) + } +} + +func TestOpenCodeGoKeyBeatsDatabase(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte(`{"opencode-go":"file-go-key"}`), 0o600); err != nil { + t.Fatal(err) + } + writeOpenCodeDB(t, filepath.Join(dir, "opencode.db"), []openCodeDBRow{ + {integration: "opencode", value: `{"type":"oauth","access":"st-db"}`, active: intPtr(1), updated: 20}, + }) + c := OpenCodeCollector{ + AuthPath: filepath.Join(dir, "auth.json"), + Do: func(req *http.Request) (*http.Response, error) { + if req.URL.String() != openCodeGoUsageURL || req.Header.Get("Authorization") != "Bearer file-go-key" { + t.Fatalf("req %s %q", req.URL, req.Header.Get("Authorization")) + } + return jsonResponse(http.StatusOK, `{"usage":{"rolling":{"percent":9}}}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.Windows[0].PercentUsed != 9 { + t.Fatalf("result = %+v", got) + } +} + +func TestOpenCodeOAuthFileIsFallbackWhenDatabaseUnreadable(t *testing.T) { + dir := t.TempDir() + const access = "st-file-oauth" + now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + raw := fmt.Sprintf(`{"opencode":{"type":"oauth","access":"%s","expires":%d}}`, access, now.Add(time.Hour).UnixMilli()) + if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "opencode.db"), []byte("not a database"), 0o600); err != nil { + t.Fatal(err) + } + c := OpenCodeCollector{ + AuthPath: filepath.Join(dir, "auth.json"), + Now: func() time.Time { return now }, + Do: func(req *http.Request) (*http.Response, error) { + if req.URL.String() != openCodeOAuthUsageURL || req.Header.Get("Authorization") != "Bearer "+access { + t.Fatalf("req %s %q", req.URL, req.Header.Get("Authorization")) + } + return jsonResponse(http.StatusOK, `{"usage":{"rolling":{"percent":6}}}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.Windows[0].PercentUsed != 6 { + t.Fatalf("result = %+v", got) + } +} + +func TestOpenCodeOAuth401SignedInIsUnsupported(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + const access = "st-no-plan" + raw := fmt.Sprintf(`{"opencode":{"type":"oauth","access":"%s","expires":%d,"metadata":{"server":"https://opencode.ai/console?ignored=1","orgID":"org_9"}}}`, access, now.Add(time.Hour).UnixMilli()) + if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + c := OpenCodeCollector{ + AuthPath: filepath.Join(dir, "auth.json"), + Now: func() time.Time { return now }, + Do: func(req *http.Request) (*http.Response, error) { + switch req.URL.String() { + case openCodeOAuthUsageURL: + return jsonResponse(http.StatusUnauthorized, `{}`), nil + case "https://opencode.ai/console/api/user": + if req.Header.Get("Authorization") != "Bearer "+access || req.Header.Get("x-opencode-org-id") != "org_9" { + t.Fatalf("console headers = %v", req.Header) + } + return jsonResponse(http.StatusOK, `{"id":"user"}`), nil + default: + t.Fatalf("unexpected url %s", req.URL.String()) + return nil, nil + } + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonUnsupported || len(got.Snapshot.Windows) != 0 { + t.Fatalf("result = %+v", got) + } + assertSnapshotOmits(t, got, access) +} + +func TestOpenCodeOAuth401ProbeFailureIsUnauthorized(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + raw := fmt.Sprintf(`{"opencode":{"type":"oauth","access":"st-bad","expires":%d}}`, now.Add(time.Hour).UnixMilli()) + if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + c := OpenCodeCollector{ + AuthPath: filepath.Join(dir, "auth.json"), + Now: func() time.Time { return now }, + Do: func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusUnauthorized, `{}`), nil + }, + } + got := c.Collect(t.Context()) + if !got.Upload || got.Snapshot.ReasonCode != ReasonUnauthorized || len(got.Snapshot.Windows) != 0 { + t.Fatalf("result = %+v", got) + } +} + +func TestOpenCodeGoKey401DoesNotProbeConsole(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte(`{"opencode-go":"go-key"}`), 0o600); err != nil { + t.Fatal(err) + } + calls := 0 + c := OpenCodeCollector{ + AuthPath: filepath.Join(dir, "auth.json"), + Do: func(req *http.Request) (*http.Response, error) { + calls++ + if req.URL.String() != openCodeGoUsageURL { + t.Fatalf("url = %s", req.URL.String()) + } + return jsonResponse(http.StatusUnauthorized, `{}`), nil + }, + } + got := c.Collect(t.Context()) + if calls != 1 || !got.Upload || got.Snapshot.ReasonCode != ReasonUnauthorized { + t.Fatalf("calls=%d result=%+v", calls, got) + } +} + +func TestOpenCodeDataDirsAndPrimaryDirectoryWins(t *testing.T) { + linux := openCodeDataDirsFor("linux", "/home/dev", "/xdg", "") + if len(linux) != 1 || linux[0] != filepath.Join("/xdg", "opencode") { + t.Fatalf("xdg dirs = %#v", linux) + } + fallback := openCodeDataDirsFor("linux", "/home/dev", " ", "") + if len(fallback) != 1 || fallback[0] != filepath.Join("/home/dev", ".local", "share", "opencode") { + t.Fatalf("fallback dirs = %#v", fallback) + } + mac := openCodeDataDirsFor("darwin", "/Users/dev", "", "") + if len(mac) != 2 || !strings.Contains(mac[1], "Library/Application Support/opencode") { + t.Fatalf("darwin dirs = %#v", mac) + } + win := openCodeDataDirsFor("windows", `C:\Users\dev`, "", `C:\Users\dev\AppData\Roaming`) + if len(win) != 2 || !strings.Contains(win[1], "AppData") { + t.Fatalf("windows dirs = %#v", win) + } + + primary := t.TempDir() + other := t.TempDir() + now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + expires := strconv.FormatInt(now.Add(time.Hour).UnixMilli(), 10) + writeOpenCodeDB(t, filepath.Join(primary, "opencode.db"), []openCodeDBRow{ + {integration: "opencode", value: fmt.Sprintf(`{"type":"oauth","access":"st-primary","expires":"%s"}`, expires), updated: 1}, + }) + if err := os.WriteFile(filepath.Join(other, "auth.json"), []byte(`{"opencode-go":"stale-go-key"}`), 0o600); err != nil { + t.Fatal(err) + } + cred, ok := loadOpenCodeFromDirs(context.Background(), []string{primary, other}) + if !ok || cred.token != "st-primary" || !cred.oauth { + t.Fatalf("cred = %+v ok=%v", cred, ok) + } +} + +func TestOpenCodeExpiryNumberAndString(t *testing.T) { + now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + number, ok := openCodeExpiry(float64(now.Add(time.Second).UnixMilli())) + if !ok || !number.Equal(now.Add(time.Second)) { + t.Fatalf("number expiry = %v ok=%v", number, ok) + } + text, ok := openCodeExpiry(strconv.FormatInt(now.UnixMilli(), 10)) + if !ok || !text.Equal(now) { + t.Fatalf("string expiry = %v ok=%v", text, ok) + } + if _, ok := openCodeExpiry("not-a-time"); ok { + t.Fatal("non-numeric expires parsed") + } + if _, ok := openCodeExpiry(float64(0)); ok { + t.Fatal("zero expires parsed") + } + cred := openCodeCredential{hasExpiry: true, expires: now} + if !cred.expired(now) { + t.Fatal("expiry equal to now must be expired") + } +} + +type openCodeDBRow struct { + integration string + value string + active *int + updated int64 +} + +func writeOpenCodeDB(t *testing.T, path string, rows []openCodeDBRow) { + t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE credential ( + id TEXT PRIMARY KEY, + integration_id TEXT, + label TEXT NOT NULL, + value TEXT NOT NULL, + connector_id TEXT, + method_id TEXT, + active INTEGER, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL + )`); err != nil { + t.Fatal(err) + } + for i, row := range rows { + var active any + if row.active != nil { + active = *row.active + } + if _, err := db.Exec( + `INSERT INTO credential (id, integration_id, label, value, active, time_created, time_updated) + VALUES (?, ?, 'x', ?, ?, 0, ?)`, + fmt.Sprintf("cred_%d", i), row.integration, row.value, active, row.updated, + ); err != nil { + t.Fatal(err) + } + } +} + +func intPtr(v int) *int { return &v } + +func failVendor(t *testing.T) HTTPDoer { + t.Helper() + return func(*http.Request) (*http.Response, error) { + t.Fatal("vendor must not be called") + return nil, nil + } +} + +func assertSnapshotOmits(t *testing.T, got Result, secrets ...string) { + t.Helper() + blob := got.Snapshot.Provider + got.Snapshot.PlanName + got.Snapshot.ReasonCode + for _, window := range got.Snapshot.Windows { + blob += window.ID + } + for _, secret := range secrets { + if secret != "" && strings.Contains(blob, secret) { + t.Fatalf("snapshot included credential material") + } + } +} diff --git a/server/internal/daemon/providerusage/parse.go b/server/internal/daemon/providerusage/parse.go new file mode 100644 index 00000000000..de45bc80527 --- /dev/null +++ b/server/internal/daemon/providerusage/parse.go @@ -0,0 +1,120 @@ +package providerusage + +import ( + "encoding/json" + "math" + "os" + "strconv" + "strings" + "time" +) + +const maxAuthFileBytes = 1 << 20 + +func readRegularFile(path string) ([]byte, error) { + if path == "" { + return nil, os.ErrNotExist + } + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > maxAuthFileBytes { + return nil, os.ErrInvalid + } + return os.ReadFile(path) +} + +func parseFlexibleTime(raw string) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + if parsed, err := time.Parse(layout, raw); err == nil { + utc := parsed.UTC() + return utc, true + } + } + return time.Time{}, false +} + +func parseResetValue(v any) *time.Time { + switch value := v.(type) { + case string: + parsed, ok := parseFlexibleTime(value) + if !ok { + return nil + } + return &parsed + case float64: + if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return nil + } + seconds := value + if seconds > 10_000_000_000 { + seconds = seconds / 1000 + } + parsed := time.UnixMilli(int64(seconds * 1000)).UTC() + return &parsed + default: + return nil + } +} + +func asFloat(v any) (float64, bool) { + switch value := v.(type) { + case float64: + if math.IsNaN(value) || math.IsInf(value, 0) { + return 0, false + } + return value, true + case json.Number: + parsed, err := value.Float64() + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return 0, false + } + return parsed, true + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return 0, false + } + return parsed, true + default: + return 0, false + } +} + +func asMap(v any) map[string]any { + mapped, _ := v.(map[string]any) + return mapped +} + +func asSlice(v any) []any { + items, _ := v.([]any) + return items +} + +func usablePercent(value float64) (float64, bool) { + if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value > 1000 { + return 0, false + } + return math.Round(value*10000) / 10000, true +} + +func trimPlan(name string) string { + name = strings.TrimSpace(name) + if len(name) <= 64 { + return name + } + return name[:64] +} + +func decodeObject(body []byte) (map[string]any, bool) { + var root map[string]any + if err := json.Unmarshal(body, &root); err != nil || root == nil { + return nil, false + } + return root, true +} diff --git a/server/internal/daemon/providerusage/snapshot.go b/server/internal/daemon/providerusage/snapshot.go new file mode 100644 index 00000000000..ec6959df242 --- /dev/null +++ b/server/internal/daemon/providerusage/snapshot.go @@ -0,0 +1,52 @@ +// Package providerusage reads plan limits from CLIs and editors that are +// already signed in on the daemon machine. It returns derived snapshots +// only. Tokens, cookies, and auth.json contents never leave this package. +package providerusage + +import "time" + +const ( + ProviderClaude = "claude" + ProviderCursor = "cursor" + ProviderCodex = "codex" + ProviderCopilot = "copilot" + ProviderAntigravity = "antigravity" + ProviderGrok = "grok" + ProviderKimi = "kimi" + ProviderKiro = "kiro" + ProviderOpenCode = "opencode" + + ReasonNotLoggedIn = "not_logged_in" + ReasonAPIKeyOnly = "api_key_only" + ReasonUnauthorized = "unauthorized" + ReasonCredentialExpired = "credential_expired" + ReasonCLIUnavailable = "cli_unavailable" + ReasonSessionUnavailable = "session_unavailable" + ReasonUnsupported = "unsupported" +) + +// Window is one vendor limit bucket, already reduced to the fields the +// server is allowed to store. +type Window struct { + ID string + PercentUsed float64 + ResetsAt *time.Time +} + +// Snapshot is the upload payload for one provider. ReasonCode is set only +// when Windows is empty: a normal absence, not a task failure. +type Snapshot struct { + Provider string + PlanName string + CollectedAt time.Time + ReasonCode string + Windows []Window +} + +// Result tells the poller whether to replace the server's copy. Upload is +// false for a transient failure so the last good snapshot stays put. +type Result struct { + Snapshot Snapshot + Upload bool + Backoff time.Duration +} diff --git a/server/internal/daemon/providerusage/testutil_test.go b/server/internal/daemon/providerusage/testutil_test.go new file mode 100644 index 00000000000..ffd87300ecf --- /dev/null +++ b/server/internal/daemon/providerusage/testutil_test.go @@ -0,0 +1,15 @@ +package providerusage + +import ( + "io" + "net/http" + "strings" +) + +func jsonResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} diff --git a/server/internal/handler/provider_usage.go b/server/internal/handler/provider_usage.go new file mode 100644 index 00000000000..6395b4389b9 --- /dev/null +++ b/server/internal/handler/provider_usage.go @@ -0,0 +1,518 @@ +package handler + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "time" + "unicode" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + obsmetrics "github.com/multica-ai/multica/server/internal/metrics" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +const providerUsageMaxBody = 64 * 1024 + +var providerUsageProviders = map[string]struct{}{ + "claude": {}, + "cursor": {}, + "codex": {}, + "copilot": {}, + "antigravity": {}, + "grok": {}, + "kimi": {}, + "kiro": {}, + "opencode": {}, +} + +var providerUsageReasons = map[string]struct{}{ + "not_logged_in": {}, + "api_key_only": {}, + "unauthorized": {}, + "credential_expired": {}, + "cli_unavailable": {}, + "session_unavailable": {}, + "unsupported": {}, +} + +var providerUsageForbiddenKeys = map[string]struct{}{ + "access_token": {}, + "refresh_token": {}, + "id_token": {}, + "token": {}, + "authorization": {}, + "cookie": {}, + "cookies": {}, + "auth_json": {}, + "password": {}, + "secret": {}, + "jwt": {}, + "session_token": {}, + "bearer": {}, + "api_key": {}, + "openai_api_key": {}, + "workoscursorsessiontoken": {}, +} + +// providerUsageReport is the daemon upload. Credential fields are rejected +// before this struct is trusted. +type providerUsageReport struct { + Provider string `json:"provider"` + PlanName string `json:"plan_name"` + CollectedAt time.Time `json:"collected_at"` + ReasonCode string `json:"reason_code"` + Windows []providerUsageWindowReport `json:"windows"` +} + +type providerUsageWindowReport struct { + ID string `json:"id"` + PercentUsed float64 `json:"percent_used"` + ResetsAt *time.Time `json:"resets_at"` +} + +type providerUsageWindowResponse struct { + ID string `json:"id"` + PercentUsed float64 `json:"percent_used"` + ResetsAt *string `json:"resets_at,omitempty"` +} + +type providerUsageSnapshotResponse struct { + Provider string `json:"provider"` + PlanName string `json:"plan_name,omitempty"` + CollectedAt string `json:"collected_at"` + ReasonCode string `json:"reason_code,omitempty"` + Windows []providerUsageWindowResponse `json:"windows"` +} + +type providerUsageResponse struct { + Providers []providerUsageSnapshotResponse `json:"providers"` +} + +type providerUsageBatchItem struct { + RuntimeID string `json:"runtime_id"` + Providers []providerUsageSnapshotResponse `json:"providers"` +} + +type providerUsageBatchResponse struct { + Runtimes []providerUsageBatchItem `json:"runtimes"` +} + +// A machine hosts a handful of runtimes. The cap keeps the list read from +// turning into an unbounded IN list. +const providerUsageBatchMaxRuntimes = 64 + +// ReportProviderUsage stores one derived plan-limit snapshot for a runtime. +// The body is rejected when it carries a token, cookie, or auth.json field. +func (h *Handler) ReportProviderUsage(w http.ResponseWriter, r *http.Request) { + runtimeID := chi.URLParam(r, "runtimeId") + rt, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID) + if !ok { + return + } + + r.Body = http.MaxBytesReader(w, r.Body, providerUsageMaxBody) + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if err := rejectCredentialFields(body); err != nil { + writeError(w, http.StatusBadRequest, "request contains a credential field") + return + } + var req providerUsageReport + if err := json.Unmarshal(body, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + normalized, err := normalizeProviderUsageReport(req) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + tx, err := h.TxStarter.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to store provider usage") + return + } + defer tx.Rollback(r.Context()) + qtx := h.Queries.WithTx(tx) + if err := qtx.DeleteRuntimeProviderUsage(r.Context(), db.DeleteRuntimeProviderUsageParams{ + RuntimeID: rt.ID, + WorkspaceID: rt.WorkspaceID, + Provider: normalized.Provider, + }); err != nil { + slog.Warn("delete provider usage failed", "runtime_id", uuidToString(rt.ID), "provider", normalized.Provider, "error", err) + writeError(w, http.StatusInternalServerError, "failed to store provider usage") + return + } + for _, row := range normalized.rows(rt.WorkspaceID, rt.ID) { + if err := qtx.InsertRuntimeProviderUsage(r.Context(), row); err != nil { + slog.Warn("insert provider usage failed", "runtime_id", uuidToString(rt.ID), "provider", normalized.Provider, "error", err) + writeError(w, http.StatusInternalServerError, "failed to store provider usage") + return + } + } + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "failed to store provider usage") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// GetRuntimeProviderUsage returns the latest derived plan-limit snapshots for +// a runtime. It does not include task token accounting. +func (h *Handler) GetRuntimeProviderUsage(w http.ResponseWriter, r *http.Request) { + runtimeID := chi.URLParam(r, "runtimeId") + rt, _, ok := h.requireRuntimeReadAccess(w, r, obsmetrics.RuntimeLookupSourceRuntimeAPI, runtimeID) + if !ok { + return + } + rows, err := h.Queries.ListRuntimeProviderUsage(r.Context(), db.ListRuntimeProviderUsageParams{ + WorkspaceID: rt.WorkspaceID, + RuntimeID: rt.ID, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list provider usage") + return + } + writeJSON(w, http.StatusOK, providerUsageResponseFromRows(rows)) +} + +// ListRuntimesProviderUsage returns derived plan-limit snapshots for the +// runtimes on one machine in a single read. Callers pass the runtime ids +// already on screen. Private runtimes the member cannot use are omitted. +func (h *Handler) ListRuntimesProviderUsage(w http.ResponseWriter, r *http.Request) { + workspaceID := h.resolveWorkspaceID(r) + member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found") + if !ok { + return + } + ids, ok := parseUUIDParamList(w, r.URL.Query().Get("runtime_ids"), "runtime_ids") + if !ok { + return + } + if len(ids) > providerUsageBatchMaxRuntimes { + writeError(w, http.StatusBadRequest, "too many runtime_ids") + return + } + empty := providerUsageBatchResponse{Runtimes: []providerUsageBatchItem{}} + if len(ids) == 0 { + writeJSON(w, http.StatusOK, empty) + return + } + found, err := h.getAgentRuntimes(r.Context(), obsmetrics.RuntimeLookupSourceRuntimeAPI, ids) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list provider usage") + return + } + allowed := providerUsageReadableIDs(workspaceID, member, ids, found) + if len(allowed) == 0 { + writeJSON(w, http.StatusOK, empty) + return + } + rows, err := h.Queries.ListRuntimeProviderUsageByRuntimeIDs(r.Context(), db.ListRuntimeProviderUsageByRuntimeIDsParams{ + WorkspaceID: parseUUID(workspaceID), + RuntimeIds: allowed, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list provider usage") + return + } + writeJSON(w, http.StatusOK, providerUsageBatchFromRows(allowed, rows)) +} + +type normalizedProviderUsage struct { + Provider string + PlanName string + CollectedAt time.Time + ReasonCode string + Windows []providerUsageWindowReport +} + +func (n normalizedProviderUsage) rows(workspaceID, runtimeID pgtype.UUID) []db.InsertRuntimeProviderUsageParams { + plan := pgtype.Text{} + if n.PlanName != "" { + plan = pgtype.Text{String: n.PlanName, Valid: true} + } + collected := pgtype.Timestamptz{Time: n.CollectedAt, Valid: true} + if len(n.Windows) == 0 { + reason := pgtype.Text{String: n.ReasonCode, Valid: n.ReasonCode != ""} + return []db.InsertRuntimeProviderUsageParams{{ + WorkspaceID: workspaceID, + RuntimeID: runtimeID, + Provider: n.Provider, + WindowID: "", + PlanName: plan, + CollectedAt: collected, + ReasonCode: reason, + }} + } + out := make([]db.InsertRuntimeProviderUsageParams, 0, len(n.Windows)) + for _, window := range n.Windows { + resets := pgtype.Timestamptz{} + if window.ResetsAt != nil { + resets = pgtype.Timestamptz{Time: window.ResetsAt.UTC(), Valid: true} + } + out = append(out, db.InsertRuntimeProviderUsageParams{ + WorkspaceID: workspaceID, + RuntimeID: runtimeID, + Provider: n.Provider, + WindowID: window.ID, + PercentUsed: pgtype.Float8{Float64: window.PercentUsed, Valid: true}, + ResetsAt: resets, + PlanName: plan, + CollectedAt: collected, + }) + } + return out +} + +func normalizeProviderUsageReport(req providerUsageReport) (normalizedProviderUsage, error) { + provider := strings.ToLower(strings.TrimSpace(req.Provider)) + if _, ok := providerUsageProviders[provider]; !ok { + return normalizedProviderUsage{}, errors.New("unknown provider") + } + if req.CollectedAt.IsZero() { + return normalizedProviderUsage{}, errors.New("collected_at is required") + } + plan := strings.TrimSpace(req.PlanName) + if len(plan) > 64 { + return normalizedProviderUsage{}, errors.New("plan_name is too long") + } + if len(req.Windows) > 8 { + return normalizedProviderUsage{}, errors.New("too many windows") + } + out := normalizedProviderUsage{ + Provider: provider, + PlanName: plan, + CollectedAt: req.CollectedAt.UTC(), + } + if len(req.Windows) == 0 { + reason := strings.TrimSpace(req.ReasonCode) + if reason == "" { + reason = "not_logged_in" + } + if _, ok := providerUsageReasons[reason]; !ok { + return normalizedProviderUsage{}, errors.New("unknown reason_code") + } + out.ReasonCode = reason + return out, nil + } + out.Windows = make([]providerUsageWindowReport, 0, len(req.Windows)) + seen := map[string]struct{}{} + for _, window := range req.Windows { + id := strings.TrimSpace(window.ID) + if !validWindowID(id) { + return normalizedProviderUsage{}, errors.New("invalid window id") + } + if _, dup := seen[id]; dup { + return normalizedProviderUsage{}, errors.New("duplicate window id") + } + seen[id] = struct{}{} + if window.PercentUsed < 0 || window.PercentUsed > 1000 { + return normalizedProviderUsage{}, errors.New("percent_used is out of range") + } + out.Windows = append(out.Windows, providerUsageWindowReport{ + ID: id, + PercentUsed: window.PercentUsed, + ResetsAt: window.ResetsAt, + }) + } + return out, nil +} + +func validWindowID(id string) bool { + if id == "" || len(id) > 64 { + return false + } + for _, r := range id { + if unicode.IsDigit(r) || (r >= 'a' && r <= 'z') || r == '_' { + continue + } + return false + } + return true +} + +func rejectCredentialFields(body []byte) error { + if len(bytesTrimSpace(body)) == 0 { + return errors.New("empty") + } + var doc any + if err := json.Unmarshal(body, &doc); err != nil { + return err + } + if credentialValue(doc) { + return errors.New("credential") + } + return nil +} + +func bytesTrimSpace(b []byte) []byte { + return []byte(strings.TrimSpace(string(b))) +} + +func credentialValue(v any) bool { + switch t := v.(type) { + case map[string]any: + for key, child := range t { + if _, ok := providerUsageForbiddenKeys[strings.ToLower(key)]; ok { + return true + } + if credentialValue(child) { + return true + } + } + case []any: + for _, child := range t { + if credentialValue(child) { + return true + } + } + case string: + return credentialString(t) + } + return false +} + +func credentialString(s string) bool { + lower := strings.ToLower(strings.TrimSpace(s)) + if strings.Contains(lower, "workoscursorsessiontoken=") || strings.HasPrefix(lower, "bearer ") { + return true + } + parts := strings.Split(s, ".") + return len(parts) == 3 && len(s) >= 40 && strings.HasPrefix(parts[0], "eyJ") +} + +type providerUsageWindowSource struct { + Provider string + WindowID string + PercentUsed pgtype.Float8 + ResetsAt pgtype.Timestamptz + PlanName pgtype.Text + CollectedAt pgtype.Timestamptz + ReasonCode pgtype.Text +} + +func providerUsageResponseFromRows(rows []db.ListRuntimeProviderUsageRow) providerUsageResponse { + sources := make([]providerUsageWindowSource, 0, len(rows)) + for _, row := range rows { + sources = append(sources, providerUsageWindowSource{ + Provider: row.Provider, + WindowID: row.WindowID, + PercentUsed: row.PercentUsed, + ResetsAt: row.ResetsAt, + PlanName: row.PlanName, + CollectedAt: row.CollectedAt, + ReasonCode: row.ReasonCode, + }) + } + return providerUsageResponse{Providers: snapshotsFromSources(sources)} +} + +func providerUsageBatchFromRows(allowed []pgtype.UUID, rows []db.ListRuntimeProviderUsageByRuntimeIDsRow) providerUsageBatchResponse { + grouped := map[string][]providerUsageWindowSource{} + for _, row := range rows { + key := uuidToString(row.RuntimeID) + grouped[key] = append(grouped[key], providerUsageWindowSource{ + Provider: row.Provider, + WindowID: row.WindowID, + PercentUsed: row.PercentUsed, + ResetsAt: row.ResetsAt, + PlanName: row.PlanName, + CollectedAt: row.CollectedAt, + ReasonCode: row.ReasonCode, + }) + } + resp := providerUsageBatchResponse{Runtimes: []providerUsageBatchItem{}} + for _, id := range allowed { + key := uuidToString(id) + sources, ok := grouped[key] + if !ok { + continue + } + resp.Runtimes = append(resp.Runtimes, providerUsageBatchItem{ + RuntimeID: key, + Providers: snapshotsFromSources(sources), + }) + } + return resp +} + +// providerUsageReadableIDs keeps runtimes in this workspace that the member +// may use. Missing, cross-workspace, and private rows are dropped so the +// batch cannot confirm that a hidden runtime exists. +func providerUsageReadableIDs(workspaceID string, member db.Member, requested []pgtype.UUID, found map[string]db.AgentRuntime) []pgtype.UUID { + seen := map[string]struct{}{} + out := make([]pgtype.UUID, 0, len(requested)) + for _, id := range requested { + key := uuidToString(id) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + rt, ok := found[key] + if !ok || uuidToString(rt.WorkspaceID) != workspaceID { + continue + } + if !canUseRuntimeForAgent(member, rt) { + continue + } + out = append(out, rt.ID) + } + return out +} + +func snapshotsFromSources(rows []providerUsageWindowSource) []providerUsageSnapshotResponse { + order := make([]string, 0) + byProvider := map[string]*providerUsageSnapshotResponse{} + for _, row := range rows { + snap, ok := byProvider[row.Provider] + if !ok { + collected := "" + if row.CollectedAt.Valid { + collected = row.CollectedAt.Time.UTC().Format(time.RFC3339) + } + snap = &providerUsageSnapshotResponse{ + Provider: row.Provider, + PlanName: textValue(row.PlanName), + CollectedAt: collected, + Windows: []providerUsageWindowResponse{}, + } + byProvider[row.Provider] = snap + order = append(order, row.Provider) + } + if row.WindowID == "" { + snap.ReasonCode = textValue(row.ReasonCode) + continue + } + window := providerUsageWindowResponse{ID: row.WindowID} + if row.PercentUsed.Valid { + window.PercentUsed = row.PercentUsed.Float64 + } + if row.ResetsAt.Valid { + formatted := row.ResetsAt.Time.UTC().Format(time.RFC3339) + window.ResetsAt = &formatted + } + snap.Windows = append(snap.Windows, window) + } + out := make([]providerUsageSnapshotResponse, 0, len(order)) + for _, provider := range order { + out = append(out, *byProvider[provider]) + } + return out +} + +func textValue(v pgtype.Text) string { + if !v.Valid { + return "" + } + return v.String +} diff --git a/server/internal/handler/provider_usage_test.go b/server/internal/handler/provider_usage_test.go new file mode 100644 index 00000000000..95d79feacd1 --- /dev/null +++ b/server/internal/handler/provider_usage_test.go @@ -0,0 +1,199 @@ +package handler + +import ( + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +func TestRejectCredentialFields(t *testing.T) { + ok := []byte(`{"provider":"claude","collected_at":"2026-09-22T00:00:00Z","windows":[{"id":"session","percent_used":12}]}`) + if err := rejectCredentialFields(ok); err != nil { + t.Fatal(err) + } + for _, body := range []string{ + `{"provider":"cursor","access_token":"secret"}`, + `{"cookie":"WorkosCursorSessionToken=abc::def"}`, + `{"provider":"codex","windows":[{"id":"primary","percent_used":1,"note":"Bearer abc"}]}`, + `{"plan_name":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature-padding"}`, + } { + if err := rejectCredentialFields([]byte(body)); err == nil { + t.Fatalf("accepted credential body %s", body) + } + } +} + +func TestNormalizeProviderUsageReport(t *testing.T) { + collected := time.Date(2026, 9, 22, 1, 2, 3, 0, time.UTC) + reset := collected.Add(time.Hour) + got, err := normalizeProviderUsageReport(providerUsageReport{ + Provider: "Claude", + PlanName: "Max", + CollectedAt: collected, + Windows: []providerUsageWindowReport{ + {ID: "session", PercentUsed: 38, ResetsAt: &reset}, + {ID: "weekly_all", PercentUsed: 4}, + }, + }) + if err != nil { + t.Fatal(err) + } + if got.Provider != "claude" || got.PlanName != "Max" || len(got.Windows) != 2 || got.ReasonCode != "" { + t.Fatalf("normalized = %+v", got) + } + + empty, err := normalizeProviderUsageReport(providerUsageReport{ + Provider: "codex", + CollectedAt: collected, + ReasonCode: "api_key_only", + }) + if err != nil || empty.ReasonCode != "api_key_only" || len(empty.Windows) != 0 { + t.Fatalf("empty = %+v err=%v", empty, err) + } + + if _, err := normalizeProviderUsageReport(providerUsageReport{Provider: "claude"}); err == nil { + t.Fatal("missing collected_at accepted") + } + if _, err := normalizeProviderUsageReport(providerUsageReport{ + Provider: "nope", + CollectedAt: collected, + }); err == nil { + t.Fatal("unknown provider accepted") + } + if _, err := normalizeProviderUsageReport(providerUsageReport{ + Provider: "qwen", + CollectedAt: collected, + Windows: []providerUsageWindowReport{{ID: "week", PercentUsed: 1}}, + }); err == nil { + t.Fatal("qwen accepted without a local plan collector") + } + accepted, err := normalizeProviderUsageReport(providerUsageReport{ + Provider: "Kimi", + CollectedAt: collected, + Windows: []providerUsageWindowReport{{ID: "rolling", PercentUsed: 8}}, + }) + if err != nil || accepted.Provider != "kimi" || accepted.Windows[0].ID != "rolling" { + t.Fatalf("kimi = %+v err=%v", accepted, err) + } + if _, err := normalizeProviderUsageReport(providerUsageReport{ + Provider: "cursor", + CollectedAt: collected, + ReasonCode: "drop table", + }); err == nil { + t.Fatal("unknown reason accepted") + } + expired, err := normalizeProviderUsageReport(providerUsageReport{ + Provider: "opencode", + CollectedAt: collected, + ReasonCode: "credential_expired", + }) + if err != nil || expired.ReasonCode != "credential_expired" || len(expired.Windows) != 0 { + t.Fatalf("credential_expired = %+v err=%v", expired, err) + } + if _, err := normalizeProviderUsageReport(providerUsageReport{ + Provider: "cursor", + CollectedAt: collected, + Windows: []providerUsageWindowReport{{ID: "Auto", PercentUsed: 1}}, + }); err == nil { + t.Fatal("non-lowercase window id accepted") + } +} + +func TestProviderUsageBatchGroupsByRuntime(t *testing.T) { + claudeID := parseUUID("11111111-1111-1111-1111-111111111111") + codexID := parseUUID("22222222-2222-2222-2222-222222222222") + reset := time.Date(2026, 9, 22, 15, 0, 0, 0, time.UTC) + rows := []db.ListRuntimeProviderUsageByRuntimeIDsRow{ + { + RuntimeID: claudeID, + Provider: "claude", + WindowID: "session", + PercentUsed: pgtype.Float8{Float64: 38.2, Valid: true}, + ResetsAt: pgtype.Timestamptz{Time: reset, Valid: true}, + PlanName: pgtype.Text{String: "Max", Valid: true}, + CollectedAt: pgtype.Timestamptz{Time: reset.Add(-time.Hour), Valid: true}, + }, + { + RuntimeID: claudeID, + Provider: "cursor", + WindowID: "auto", + PercentUsed: pgtype.Float8{Float64: 10, Valid: true}, + CollectedAt: pgtype.Timestamptz{Time: reset, Valid: true}, + }, + { + RuntimeID: codexID, + Provider: "codex", + WindowID: "", + ReasonCode: pgtype.Text{String: "not_logged_in", Valid: true}, + CollectedAt: pgtype.Timestamptz{Time: reset, Valid: true}, + }, + } + got := providerUsageBatchFromRows([]pgtype.UUID{codexID, claudeID}, rows) + if len(got.Runtimes) != 2 { + t.Fatalf("runtimes = %+v", got.Runtimes) + } + if got.Runtimes[0].RuntimeID != uuidToString(codexID) || got.Runtimes[0].Providers[0].ReasonCode != "not_logged_in" { + t.Fatalf("codex group = %+v", got.Runtimes[0]) + } + if len(got.Runtimes[0].Providers[0].Windows) != 0 { + t.Fatal("empty login snapshot included a window") + } + claude := got.Runtimes[1] + if claude.RuntimeID != uuidToString(claudeID) || len(claude.Providers) != 2 { + t.Fatalf("claude group = %+v", claude) + } + if claude.Providers[0].PlanName != "Max" || claude.Providers[0].Windows[0].PercentUsed != 38.2 { + t.Fatalf("session window = %+v", claude.Providers[0]) + } + if claude.Providers[0].Windows[0].ResetsAt == nil || *claude.Providers[0].Windows[0].ResetsAt != reset.Format(time.RFC3339) { + t.Fatalf("reset = %+v", claude.Providers[0].Windows[0].ResetsAt) + } +} + +func TestProviderUsageReadableIDsDropsPrivateAndForeign(t *testing.T) { + ws := parseUUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + otherWS := parseUUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + owner := parseUUID("cccccccc-cccc-cccc-cccc-cccccccccccc") + viewer := parseUUID("dddddddd-dddd-dddd-dddd-dddddddddddd") + publicID := parseUUID("11111111-1111-1111-1111-111111111111") + privateID := parseUUID("22222222-2222-2222-2222-222222222222") + foreignID := parseUUID("33333333-3333-3333-3333-333333333333") + member := db.Member{UserID: viewer, WorkspaceID: ws} + found := map[string]db.AgentRuntime{ + uuidToString(publicID): { + ID: publicID, + WorkspaceID: ws, + OwnerID: owner, + Visibility: "public", + }, + uuidToString(privateID): { + ID: privateID, + WorkspaceID: ws, + OwnerID: owner, + Visibility: "private", + }, + uuidToString(foreignID): { + ID: foreignID, + WorkspaceID: otherWS, + OwnerID: viewer, + Visibility: "public", + }, + } + got := providerUsageReadableIDs(uuidToString(ws), member, []pgtype.UUID{publicID, privateID, foreignID, publicID}, found) + if len(got) != 1 || got[0] != publicID { + t.Fatalf("readable = %+v", got) + } +} + +func TestProviderUsageErrorDoesNotEchoSecrets(t *testing.T) { + err := rejectCredentialFields([]byte(`{"access_token":"super-secret-token"}`)) + if err == nil { + t.Fatal("expected rejection") + } + if strings.Contains(err.Error(), "super-secret-token") { + t.Fatal("error echoed the token") + } +} diff --git a/server/internal/handler/workspace_delete_manifest_test.go b/server/internal/handler/workspace_delete_manifest_test.go index 584c7d0887a..704381bbb8c 100644 --- a/server/internal/handler/workspace_delete_manifest_test.go +++ b/server/internal/handler/workspace_delete_manifest_test.go @@ -115,6 +115,7 @@ var workspaceDeletionManifest = map[string]workspaceDeleteAction{ "project_resource": workspaceDelete, "quick_action": workspaceDelete, "runtime_profile": workspaceDelete, + "runtime_provider_usage_snapshot": workspaceDelete, "schema_migrations": workspaceDeleteKeep, "search_index_change": workspaceDelete, "search_index_prune_mark": workspaceDeleteKeep, diff --git a/server/migrations/564_runtime_provider_usage_snapshot.down.sql b/server/migrations/564_runtime_provider_usage_snapshot.down.sql new file mode 100644 index 00000000000..62a315c800d --- /dev/null +++ b/server/migrations/564_runtime_provider_usage_snapshot.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS runtime_provider_usage_snapshot; diff --git a/server/migrations/564_runtime_provider_usage_snapshot.up.sql b/server/migrations/564_runtime_provider_usage_snapshot.up.sql new file mode 100644 index 00000000000..99acf7275d0 --- /dev/null +++ b/server/migrations/564_runtime_provider_usage_snapshot.up.sql @@ -0,0 +1,19 @@ +-- Table is 564. Concurrent indexes are 565 and 566. Upstream main owns +-- through 563 (search index change indexes). +-- Derived plan-limit snapshots reported by a local daemon. Tokens, cookies, +-- and auth.json contents are not columns on purpose: the daemon uploads +-- only provider, window, percent, reset, plan, and collected_at. +CREATE TABLE runtime_provider_usage_snapshot ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + runtime_id UUID NOT NULL, + provider TEXT NOT NULL, + window_id TEXT NOT NULL DEFAULT '', + percent_used DOUBLE PRECISION, + resets_at TIMESTAMPTZ, + plan_name TEXT, + collected_at TIMESTAMPTZ NOT NULL, + reason_code TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/server/migrations/565_runtime_provider_usage_snapshot_key.down.sql b/server/migrations/565_runtime_provider_usage_snapshot_key.down.sql new file mode 100644 index 00000000000..d5b6d8c9ccc --- /dev/null +++ b/server/migrations/565_runtime_provider_usage_snapshot_key.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_runtime_provider_usage_snapshot_key; diff --git a/server/migrations/565_runtime_provider_usage_snapshot_key.up.sql b/server/migrations/565_runtime_provider_usage_snapshot_key.up.sql new file mode 100644 index 00000000000..99b5542ee0a --- /dev/null +++ b/server/migrations/565_runtime_provider_usage_snapshot_key.up.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_runtime_provider_usage_snapshot_key + ON runtime_provider_usage_snapshot (runtime_id, provider, window_id); diff --git a/server/migrations/566_runtime_provider_usage_snapshot_workspace.down.sql b/server/migrations/566_runtime_provider_usage_snapshot_workspace.down.sql new file mode 100644 index 00000000000..11f2b72a287 --- /dev/null +++ b/server/migrations/566_runtime_provider_usage_snapshot_workspace.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_runtime_provider_usage_snapshot_workspace; diff --git a/server/migrations/566_runtime_provider_usage_snapshot_workspace.up.sql b/server/migrations/566_runtime_provider_usage_snapshot_workspace.up.sql new file mode 100644 index 00000000000..eab8f38e19d --- /dev/null +++ b/server/migrations/566_runtime_provider_usage_snapshot_workspace.up.sql @@ -0,0 +1,2 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_runtime_provider_usage_snapshot_workspace + ON runtime_provider_usage_snapshot (workspace_id, runtime_id); diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 2cd4893996e..3530c7c2477 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -1361,6 +1361,21 @@ type RuntimeProfile struct { RuntimeType string `json:"runtime_type"` } +type RuntimeProviderUsageSnapshot struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RuntimeID pgtype.UUID `json:"runtime_id"` + Provider string `json:"provider"` + WindowID string `json:"window_id"` + PercentUsed pgtype.Float8 `json:"percent_used"` + ResetsAt pgtype.Timestamptz `json:"resets_at"` + PlanName pgtype.Text `json:"plan_name"` + CollectedAt pgtype.Timestamptz `json:"collected_at"` + ReasonCode pgtype.Text `json:"reason_code"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type SearchIndexChange struct { EntityType string `json:"entity_type"` EntityID pgtype.UUID `json:"entity_id"` diff --git a/server/pkg/db/generated/provider_usage.sql.go b/server/pkg/db/generated/provider_usage.sql.go new file mode 100644 index 00000000000..b28352ad6de --- /dev/null +++ b/server/pkg/db/generated/provider_usage.sql.go @@ -0,0 +1,178 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: provider_usage.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteRuntimeProviderUsage = `-- name: DeleteRuntimeProviderUsage :exec + +DELETE FROM runtime_provider_usage_snapshot +WHERE runtime_id = $1 AND workspace_id = $2 AND provider = $3 +` + +type DeleteRuntimeProviderUsageParams struct { + RuntimeID pgtype.UUID `json:"runtime_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Provider string `json:"provider"` +} + +// Derived plan-limit snapshots for one runtime. No foreign keys: runtime +// and workspace cleanup delete these rows in the same statement as the parent. +func (q *Queries) DeleteRuntimeProviderUsage(ctx context.Context, arg DeleteRuntimeProviderUsageParams) error { + _, err := q.db.Exec(ctx, deleteRuntimeProviderUsage, arg.RuntimeID, arg.WorkspaceID, arg.Provider) + return err +} + +const insertRuntimeProviderUsage = `-- name: InsertRuntimeProviderUsage :exec +INSERT INTO runtime_provider_usage_snapshot ( + workspace_id, + runtime_id, + provider, + window_id, + percent_used, + resets_at, + plan_name, + collected_at, + reason_code +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 +) +` + +type InsertRuntimeProviderUsageParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + RuntimeID pgtype.UUID `json:"runtime_id"` + Provider string `json:"provider"` + WindowID string `json:"window_id"` + PercentUsed pgtype.Float8 `json:"percent_used"` + ResetsAt pgtype.Timestamptz `json:"resets_at"` + PlanName pgtype.Text `json:"plan_name"` + CollectedAt pgtype.Timestamptz `json:"collected_at"` + ReasonCode pgtype.Text `json:"reason_code"` +} + +func (q *Queries) InsertRuntimeProviderUsage(ctx context.Context, arg InsertRuntimeProviderUsageParams) error { + _, err := q.db.Exec(ctx, insertRuntimeProviderUsage, + arg.WorkspaceID, + arg.RuntimeID, + arg.Provider, + arg.WindowID, + arg.PercentUsed, + arg.ResetsAt, + arg.PlanName, + arg.CollectedAt, + arg.ReasonCode, + ) + return err +} + +const listRuntimeProviderUsage = `-- name: ListRuntimeProviderUsage :many +SELECT provider, window_id, percent_used, resets_at, plan_name, collected_at, reason_code +FROM runtime_provider_usage_snapshot +WHERE workspace_id = $1 AND runtime_id = $2 +ORDER BY provider, window_id +` + +type ListRuntimeProviderUsageParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + RuntimeID pgtype.UUID `json:"runtime_id"` +} + +type ListRuntimeProviderUsageRow struct { + Provider string `json:"provider"` + WindowID string `json:"window_id"` + PercentUsed pgtype.Float8 `json:"percent_used"` + ResetsAt pgtype.Timestamptz `json:"resets_at"` + PlanName pgtype.Text `json:"plan_name"` + CollectedAt pgtype.Timestamptz `json:"collected_at"` + ReasonCode pgtype.Text `json:"reason_code"` +} + +func (q *Queries) ListRuntimeProviderUsage(ctx context.Context, arg ListRuntimeProviderUsageParams) ([]ListRuntimeProviderUsageRow, error) { + rows, err := q.db.Query(ctx, listRuntimeProviderUsage, arg.WorkspaceID, arg.RuntimeID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListRuntimeProviderUsageRow{} + for rows.Next() { + var i ListRuntimeProviderUsageRow + if err := rows.Scan( + &i.Provider, + &i.WindowID, + &i.PercentUsed, + &i.ResetsAt, + &i.PlanName, + &i.CollectedAt, + &i.ReasonCode, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRuntimeProviderUsageByRuntimeIDs = `-- name: ListRuntimeProviderUsageByRuntimeIDs :many +SELECT runtime_id, provider, window_id, percent_used, resets_at, plan_name, collected_at, reason_code +FROM runtime_provider_usage_snapshot +WHERE workspace_id = $1 AND runtime_id = ANY($2::uuid[]) +ORDER BY runtime_id, provider, window_id +` + +type ListRuntimeProviderUsageByRuntimeIDsParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + RuntimeIds []pgtype.UUID `json:"runtime_ids"` +} + +type ListRuntimeProviderUsageByRuntimeIDsRow struct { + RuntimeID pgtype.UUID `json:"runtime_id"` + Provider string `json:"provider"` + WindowID string `json:"window_id"` + PercentUsed pgtype.Float8 `json:"percent_used"` + ResetsAt pgtype.Timestamptz `json:"resets_at"` + PlanName pgtype.Text `json:"plan_name"` + CollectedAt pgtype.Timestamptz `json:"collected_at"` + ReasonCode pgtype.Text `json:"reason_code"` +} + +// One read for the runtimes shown on a machine. Same derived columns as +// ListRuntimeProviderUsage; runtime_id lets the caller group them. +func (q *Queries) ListRuntimeProviderUsageByRuntimeIDs(ctx context.Context, arg ListRuntimeProviderUsageByRuntimeIDsParams) ([]ListRuntimeProviderUsageByRuntimeIDsRow, error) { + rows, err := q.db.Query(ctx, listRuntimeProviderUsageByRuntimeIDs, arg.WorkspaceID, arg.RuntimeIds) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListRuntimeProviderUsageByRuntimeIDsRow{} + for rows.Next() { + var i ListRuntimeProviderUsageByRuntimeIDsRow + if err := rows.Scan( + &i.RuntimeID, + &i.Provider, + &i.WindowID, + &i.PercentUsed, + &i.ResetsAt, + &i.PlanName, + &i.CollectedAt, + &i.ReasonCode, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/server/pkg/db/generated/runtime.sql.go b/server/pkg/db/generated/runtime.sql.go index 361a2f5cc6e..2d18b622b75 100644 --- a/server/pkg/db/generated/runtime.sql.go +++ b/server/pkg/db/generated/runtime.sql.go @@ -178,7 +178,10 @@ func (q *Queries) CountUndrainedTasksByRuntimeOrAgent(ctx context.Context, arg C } const deleteAgentRuntime = `-- name: DeleteAgentRuntime :exec -DELETE FROM agent_runtime WHERE id = $1 +WITH deleted_provider_usage AS ( + DELETE FROM runtime_provider_usage_snapshot AS snap WHERE snap.runtime_id = $1 +) +DELETE FROM agent_runtime AS rt WHERE rt.id = $1 ` func (q *Queries) DeleteAgentRuntime(ctx context.Context, id pgtype.UUID) error { diff --git a/server/pkg/db/generated/runtime_profile.sql.go b/server/pkg/db/generated/runtime_profile.sql.go index d3a72d2e212..1c2a56083a5 100644 --- a/server/pkg/db/generated/runtime_profile.sql.go +++ b/server/pkg/db/generated/runtime_profile.sql.go @@ -77,9 +77,16 @@ func (q *Queries) CreateRuntimeProfile(ctx context.Context, arg CreateRuntimePro } const deleteAgentRuntimesByProfile = `-- name: DeleteAgentRuntimesByProfile :many -DELETE FROM agent_runtime -WHERE profile_id = $1 AND workspace_id = $2 -RETURNING id, workspace_id, owner_id, daemon_id, provider +WITH deleted_provider_usage AS ( + DELETE FROM runtime_provider_usage_snapshot AS snap + WHERE snap.runtime_id IN ( + SELECT rt.id FROM agent_runtime AS rt + WHERE rt.profile_id = $1 AND rt.workspace_id = $2 + ) +) +DELETE FROM agent_runtime AS rt +WHERE rt.profile_id = $1 AND rt.workspace_id = $2 +RETURNING rt.id, rt.workspace_id, rt.owner_id, rt.daemon_id, rt.provider ` type DeleteAgentRuntimesByProfileParams struct { @@ -99,6 +106,8 @@ type DeleteAgentRuntimesByProfileRow struct { // the profile-delete path must remove the profile's registered runtime // instances itself. Returns the deleted rows so the caller can broadcast / // audit. Runs inside the same transaction as DeleteRuntimeProfile. +// Plan-limit snapshots have no foreign key, so they are removed in the same +// statement before the runtime rows disappear. func (q *Queries) DeleteAgentRuntimesByProfile(ctx context.Context, arg DeleteAgentRuntimesByProfileParams) ([]DeleteAgentRuntimesByProfileRow, error) { rows, err := q.db.Query(ctx, deleteAgentRuntimesByProfile, arg.ProfileID, arg.WorkspaceID) if err != nil { diff --git a/server/pkg/db/generated/workspace_delete.sql.go b/server/pkg/db/generated/workspace_delete.sql.go index 2d067d0f470..086f5c6c89c 100644 --- a/server/pkg/db/generated/workspace_delete.sql.go +++ b/server/pkg/db/generated/workspace_delete.sql.go @@ -600,6 +600,10 @@ func (q *Queries) DeleteWorkspacePullRequests(ctx context.Context, workspaceID p const deleteWorkspaceRuntimesAndProjects = `-- name: DeleteWorkspaceRuntimesAndProjects :exec WITH +deleted_provider_usage AS ( + DELETE FROM runtime_provider_usage_snapshot + WHERE runtime_provider_usage_snapshot.workspace_id = $1 +), deleted_runtimes AS ( DELETE FROM agent_runtime WHERE agent_runtime.workspace_id = $1 ), diff --git a/server/pkg/db/queries/provider_usage.sql b/server/pkg/db/queries/provider_usage.sql new file mode 100644 index 00000000000..cb8a082f5ce --- /dev/null +++ b/server/pkg/db/queries/provider_usage.sql @@ -0,0 +1,35 @@ +-- Derived plan-limit snapshots for one runtime. No foreign keys: runtime +-- and workspace cleanup delete these rows in the same statement as the parent. + +-- name: DeleteRuntimeProviderUsage :exec +DELETE FROM runtime_provider_usage_snapshot +WHERE runtime_id = $1 AND workspace_id = $2 AND provider = $3; + +-- name: InsertRuntimeProviderUsage :exec +INSERT INTO runtime_provider_usage_snapshot ( + workspace_id, + runtime_id, + provider, + window_id, + percent_used, + resets_at, + plan_name, + collected_at, + reason_code +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 +); + +-- name: ListRuntimeProviderUsage :many +SELECT provider, window_id, percent_used, resets_at, plan_name, collected_at, reason_code +FROM runtime_provider_usage_snapshot +WHERE workspace_id = $1 AND runtime_id = $2 +ORDER BY provider, window_id; + +-- name: ListRuntimeProviderUsageByRuntimeIDs :many +-- One read for the runtimes shown on a machine. Same derived columns as +-- ListRuntimeProviderUsage; runtime_id lets the caller group them. +SELECT runtime_id, provider, window_id, percent_used, resets_at, plan_name, collected_at, reason_code +FROM runtime_provider_usage_snapshot +WHERE workspace_id = @workspace_id AND runtime_id = ANY(@runtime_ids::uuid[]) +ORDER BY runtime_id, provider, window_id; diff --git a/server/pkg/db/queries/runtime.sql b/server/pkg/db/queries/runtime.sql index f38213541a5..1634282774d 100644 --- a/server/pkg/db/queries/runtime.sql +++ b/server/pkg/db/queries/runtime.sql @@ -394,7 +394,10 @@ WHERE runtime_id = $1 AND kind = 'user' RETURNING *; -- name: DeleteAgentRuntime :exec -DELETE FROM agent_runtime WHERE id = $1; +WITH deleted_provider_usage AS ( + DELETE FROM runtime_provider_usage_snapshot AS snap WHERE snap.runtime_id = $1 +) +DELETE FROM agent_runtime AS rt WHERE rt.id = $1; -- name: DeleteSystemAgentsByRuntime :exec -- System agents are invisible execution infrastructure (for example the Agent diff --git a/server/pkg/db/queries/runtime_profile.sql b/server/pkg/db/queries/runtime_profile.sql index e6a86e591bb..47cd078c7cd 100644 --- a/server/pkg/db/queries/runtime_profile.sql +++ b/server/pkg/db/queries/runtime_profile.sql @@ -78,9 +78,18 @@ WHERE id = $1 AND workspace_id = $2; -- the profile-delete path must remove the profile's registered runtime -- instances itself. Returns the deleted rows so the caller can broadcast / -- audit. Runs inside the same transaction as DeleteRuntimeProfile. -DELETE FROM agent_runtime -WHERE profile_id = $1 AND workspace_id = $2 -RETURNING id, workspace_id, owner_id, daemon_id, provider; +-- Plan-limit snapshots have no foreign key, so they are removed in the same +-- statement before the runtime rows disappear. +WITH deleted_provider_usage AS ( + DELETE FROM runtime_provider_usage_snapshot AS snap + WHERE snap.runtime_id IN ( + SELECT rt.id FROM agent_runtime AS rt + WHERE rt.profile_id = $1 AND rt.workspace_id = $2 + ) +) +DELETE FROM agent_runtime AS rt +WHERE rt.profile_id = $1 AND rt.workspace_id = $2 +RETURNING rt.id, rt.workspace_id, rt.owner_id, rt.daemon_id, rt.provider; -- name: ListActiveAgentsByProfile :many -- Active (non-archived) agents bound to any runtime instance of this profile. diff --git a/server/pkg/db/queries/workspace_delete.sql b/server/pkg/db/queries/workspace_delete.sql index 9f6972cfb1a..309afb32318 100644 --- a/server/pkg/db/queries/workspace_delete.sql +++ b/server/pkg/db/queries/workspace_delete.sql @@ -680,6 +680,10 @@ DELETE FROM agent WHERE agent.workspace_id = $1; -- name: DeleteWorkspaceRuntimesAndProjects :exec WITH +deleted_provider_usage AS ( + DELETE FROM runtime_provider_usage_snapshot + WHERE runtime_provider_usage_snapshot.workspace_id = $1 +), deleted_runtimes AS ( DELETE FROM agent_runtime WHERE agent_runtime.workspace_id = $1 ),