diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index f980ee7ca05..051dc607cb5 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1719,6 +1719,15 @@ Legacy agents retain their authentication until validated adoption. See [AI Connections](connections/AI-CONNECTIONS.md) for company isolation, compatible methods, lifecycle, runtime enforcement, and migration details. +The selected AI connection supports an on-demand usage probe through the common +connection service, independent of legacy/native execution. The board usage +endpoint rechecks company membership and the credential's human audience before +reading its stored token. Report all returned allowance windows, model/feature +scope, reset times, exhaustion and overage observations; missing values remain +unknown and unsupported methods/provider failures are explicit. The account +detail's Check usage action triggers the probe. No automatic detection, routing, +budget enforcement, credential refresh or credit purchase follows from it. + Provider login failures create a provider-specific Connections card on the task when the run fails, before generic recovery retries. Reconnect preserves account identity and permissions. Compatible legacy agents may explicitly adopt a diff --git a/doc/SPEC.md b/doc/SPEC.md index 4a44c9fe103..ab162e3df95 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -39,6 +39,11 @@ on the task immediately after failure. Users can reconnect inline and resume; legacy agents keep their authentication until an explicit, validated adoption. See [AI Connections](connections/AI-CONNECTIONS.md). +AI connections also expose an on-demand, credential-scoped usage probe. It +reports provider windows, scoped exhaustion, reset times and overage observations +for downstream decisions, with explicit unknown/unsupported/error states. +Probing does not change runner routing or enforce provider limits automatically. + #### Board Powers (Always Available) The Board has **unrestricted access** to the entire system at all times: diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 67d8f897ffa..d31ce5fea97 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -109,6 +109,115 @@ runtime test accepts the form’s prospective adapter selection before it is sav For a saved-agent test, omitting `environmentId` uses the agent’s saved override. Sending `environmentId: null` tests a change back to the instance default. +## On-demand usage limits + +The AI account detail has **Check usage**. It reads the selected account only +when requested; opening the account, listing connections, and starting either +runner do not invoke this probe. There is no scheduling, provider selection, +quota enforcement, credit purchase, or automatic retry attached to it. + +`aiConnectionService(db).probeUsage(companyId, userId, connectionId, grantId?)` +is the common server operation for legacy and native runner connections. +`GET /api/companies/:companyId/ai-connections/:connectionId/usage?grantId=...` +exposes it to board users. The UI client calls `aiConnectionsApi.probeUsage`. +The optional grant ID must belong to this connection; omit it only when exactly +one authorized grant exists. The company membership, personal owner/shared human +audience, connection lifecycle, and credential ownership checks run before any +provider request. Agent API keys cannot call this board endpoint. Internal +execution callers must also use the normal runner connection selection checks +for agent installation and responsible-user authority before using its result. + +The returned `AiConnectionUsage` contains a timestamp and probe status (`ok`, +`unsupported`, `unavailable`, or `error`), provider/source, every reported limit +window, model/feature scope, exact window duration when known, reset time, +utilization/remaining percentage, absolute allowance when reported, and overage +settings/balance. The list summary advertises `usageProbeSupported`. +`limitReached` describes that window; `allowed` preserves explicit provider +admission for its limit group. A model-specific exhausted window must not be +treated as an account-wide denial. Codex workspace spend controls are also +returned when present. Percentages remain provider percentages: 0.5 means 0.5%, +99.99 stays below exhaustion, and values above 100 remain visible. + +Capability means the provider has a probe implementation, not that every token +has permission to read it. Claude `setup-token` credentials characterized in +this repo request only `user:inference`; the provider can require `user:profile` +for [usage access](https://github.com/anthropics/claude-code/issues/13724). +A 403 returns `permission_denied` with no limits, distinct from expired +authentication or exhausted capacity. Minting another inference-only token +does not establish usage access. This probe cannot add scopes to a stored token. + +| Connection | Read-only source | Observations | +| --- | --- | --- | +| Codex subscription | `GET https://chatgpt.com/backend-api/wham/usage` with stored access token and account ID | Primary/secondary windows, additional feature/model limits, provider admission, workspace spend control, credit balance | +| Claude subscription | `GET https://api.anthropic.com/api/oauth/usage`, with stored OAuth token and `anthropic-beta: oauth-2025-04-20` | Legacy and structured session/weekly/scoped windows, monthly extra usage, structured spend when legacy extra usage is absent | +| Grok subscription | `GET https://cli-chat-proxy.grok.com/v1/billing?format=credits`, with stored token and `x-xai-token-auth: xai-grok-cli` | Included-plan utilization/period when reported, separately reported on-demand allowance and prepaid balance in USD cents | +| OpenRouter API key | `GET https://openrouter.ai/api/v1/key` | Key credit cap, reset cadence, free-model daily request cap when returned | +| Other API-key methods | No supported single-key allowance endpoint | Explicit `unsupported`; no provider or secret read | + +These subscription sources are provider-client endpoints, not a promise of a +stable public API. Codex's current endpoint/shape is grounded in the official +[backend client](https://github.com/openai/codex/blob/main/codex-rs/backend-client/src/client.rs) +and [response models](https://github.com/openai/codex/blob/main/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs). +Claude uses the same OAuth endpoint as the existing adapter probe; official +[usage documentation](https://code.claude.com/docs/en/costs) describes plan windows, +extra usage, and usage-check rate limiting. Grok's endpoint, optional fields, and +USD-cent units follow its official +[billing implementation](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-shell/src/extensions/billing.rs). +It prefers `creditUsagePercent` and `currentPeriod`, with `used`/`monthlyLimit` +as the legacy included-allowance fallback. The official +[Grok commands](https://docs.x.ai/build/modes-and-commands) describe its usage screen. +OpenRouter documents its +[key limits endpoint](https://openrouter.ai/docs/api/reference/limits). + +Unknown values are `null`, never assumed zero. A present Grok `Cent` message `{}` +means zero under its documented proto3 encoding; an absent message or omitted +included-usage percentage remains unknown. Claude's `is_active` dashboard flag +does not establish whether requests are allowed. Named Claude limit groups keep +their own identities and scopes, including `session` and `weekly_all` groups. +They do not replace the legacy account-wide windows. An enabled extra-usage switch or remaining +spend allowance does not establish a funded usable balance; overage `available` +stays unknown unless the provider confirms it or reports it disabled/exhausted. +An unlimited OpenRouter key does not establish account balance. A Grok reset +timestamp alone does not establish weekly/monthly cadence. API rate limiting +of the *probe* is an error, not an exhausted subscription. Malformed/empty +responses and network failures return no fresh limits. Responses are `no-store`, +bounded to 256 KiB and 15 seconds, use fixed endpoints with redirects disabled, +and contain neither credentials nor raw provider errors. Expired credentials +report `authentication_required`; the probe does not exchange refresh tokens +or change connection health. + +Verification: + +```sh +pnpm exec vitest run server/src/services/ai-connection-usage.test.ts server/src/__tests__/ai-connections.test.ts ui/src/components/ai-connections/AiConnectionUsagePanel.test.tsx server/src/__tests__/openapi-routes.test.ts +pnpm check:token-gates +``` + +Fixtures prove normalization, credential isolation and manual UI behavior. Live +provider/account entitlement qualification is separate; provider-side omissions +remain unknown and must not be used downstream as proof of available capacity. +On 2026-10-02, a real local Codex access token was saved to an encrypted, +disposable test connection. The actual connection usage HTTP route returned 200 +with `status: ok`, a weekly window at 100% used, its reset time, denied included +usage, and available overage credits. The same stored connection passed selection +checks for both `codex_local` and `paperclip_runner` with the native Codex provider. +This verifies the connection API/vault/provider path and both selection paths; +it does not claim a live runner turn or browser acceptance test. The disposable +database and vault home were removed after verification. +After the operator signed in again on 2026-10-02, real Claude Keychain and Grok +file credentials were saved to encrypted disposable connections. Both the +provider requests and actual connection usage HTTP route returned 200 with +`status: ok`. Selection checks passed for `claude_local`, native Claude, +ACP Claude, `grok_local`, and ACP Grok through `paperclip_runner`. +Claude returned session and weekly usage at 0%, a scoped weekly window, and +enabled extra usage. Grok returned a weekly period and zero on-demand allowance +and prepaid balance, but omitted included-plan usage; it remains unknown rather +than being reported as 0% or available capacity. The live shapes prompted +regressions for Claude structured limits/spend and Grok legacy usage, monetary +units, absent percentages, and proto3 zero messages. The disposable database +and encrypted vault were removed. These checks started no provider turn and +exchanged no refresh tokens. + ## Runtime isolation Provider authentication failures, including `acpx_auth_required`, adapter login diff --git a/packages/shared/src/ai-connection-usage.ts b/packages/shared/src/ai-connection-usage.ts new file mode 100644 index 00000000000..4b51573ee92 --- /dev/null +++ b/packages/shared/src/ai-connection-usage.ts @@ -0,0 +1,55 @@ +import type { AiAuthMethod, AiProvider } from "./ai-connections.js"; + +/** A provider observation, never an estimate from Paperclip's run costs. */ +export interface AiConnectionUsageLimit { + id: string; + label: string; + /** Null means the account-wide allowance; named model/features stay separate. */ + scope: string | null; + windowDurationSeconds: number | null; + /** Provider-named reset cadence when no exact duration is available. */ + resetInterval?: string | null; + resetsAt: string | null; + usedPercent: number | null; + remainingPercent: number | null; + used: number | null; + limit: number | null; + remaining: number | null; + unit: string | null; + limitReached: boolean | null; + /** Only set when the provider reports admission explicitly. */ + allowed: boolean | null; +} + +export interface AiConnectionUsageOverage { + enabled: boolean | null; + /** Null when enabling overage does not prove a funded usable balance. */ + available: boolean | null; + unlimited: boolean | null; + used: number | null; + limit: number | null; + remaining: number | null; + balance: number | null; + unit: string | null; +} + +export interface AiConnectionUsage { + connectionId: string; + grantId: string; + provider: AiProvider; + method: AiAuthMethod; + status: "ok" | "unsupported" | "unavailable" | "error"; + checkedAt: string; + source: string | null; + planType: string | null; + limits: AiConnectionUsageLimit[]; + overage: AiConnectionUsageOverage | null; + errorCode?: "unsupported" | "connection_unavailable" | "authentication_required" | "permission_denied" | "rate_limited" | "provider_unavailable" | "invalid_response"; + message?: string; +} + +export function supportsAiConnectionUsage(provider: AiProvider, method: AiAuthMethod): boolean { + return method === "subscription" + ? provider === "openai" || provider === "anthropic" || provider === "xai" + : provider === "openrouter"; +} diff --git a/packages/shared/src/ai-connections.ts b/packages/shared/src/ai-connections.ts index 55bfc671361..59176427752 100644 --- a/packages/shared/src/ai-connections.ts +++ b/packages/shared/src/ai-connections.ts @@ -172,6 +172,7 @@ export interface AiManagedConnectionSummary { isDefault: boolean; status: "connected" | "needs_attention" | "expired" | "revoked"; unavailableReason?: string; + usageProbeSupported?: boolean; } export interface AiConnectionList { currentUserId: string; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4cd4afc96e7..01507a44a5a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2783,6 +2783,7 @@ export { EXECUTION_RECONCILIATION_CAUSES, requiresExecutionReconciliation } from export * from "./agent-appearance.js"; export * from "./ai-connections.js"; +export * from "./ai-connection-usage.js"; export * from "./types/email.js"; export * from "./validators/email.js"; export { configureRailwaySshSchema, type ConfigureRailwaySsh, type RailwaySshSetup } from "./railway-connection.js"; diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 7cc0a499d63..2195fafb794 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -61,6 +61,65 @@ describe("managed AI connections", () => { expect(response.body.canManageConnections).toBe(manager); }); + it("probes only the requested authorized grant using its vaulted subscription token", async () => { + const owner = "usage-probe-owner"; + await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); + const account = await service.save(companyId, owner, { provider: "anthropic", method: "subscription", ownership: "personal", name: "Usage probe account", loginSessionId: "fixture", agentIds: [], allAgents: true }, "usage-selected-secret"); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 42 }, seven_day: { utilization: 15 } }))); + const app = express(); + app.use((req, _res, next) => { + req.actor = req.header("x-agent") + ? { type: "agent", agentId, companyId, companyIds: [companyId], onBehalfOfUserId: owner } + : { type: "board", userId: req.header("x-test-user") ?? owner, companyIds: [companyId] }; + next(); + }); + app.use("/api", aiConnectionRoutes(db)); + app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); }); + const url = `/api/companies/${companyId}/ai-connections/${account.connectionId}/usage?grantId=${account.grantId}`; + try { + const result = await request(app).get(url); + expect(result.status).toBe(200); + expect(result.headers["cache-control"]).toBe("no-store"); + expect(result.body).toMatchObject({ ...account, status: "ok", provider: "anthropic", limits: [{ usedPercent: 42 }, { usedPercent: 15 }] }); + expect(fetchSpy).toHaveBeenLastCalledWith("https://api.anthropic.com/api/oauth/usage", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer usage-selected-secret" }) })); + expect(JSON.stringify(result.body)).not.toContain("usage-selected-secret"); + fetchSpy.mockClear(); + expect((await request(app).get(url).set("x-test-user", "bob")).status).toBe(404); + expect((await request(app).get(url.replace(companyId, otherCompanyId))).status).toBe(403); + expect((await request(app).get(url.replace(account.grantId, randomUUID()))).status).toBe(404); + expect((await request(app).get(url).set("x-agent", "yes")).status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId)); + const revoked = await request(app).get(url); + expect(revoked.body).toMatchObject({ status: "unavailable", errorCode: "connection_unavailable", limits: [] }); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { fetchSpy.mockRestore(); } + }); + + it("uses the same usage probe for shared accounts across legacy and native runner selections", async () => { + const account = await service.save(companyId, "alice", { provider: "anthropic", method: "subscription", ownership: "shared", name: "Shared usage account", loginSessionId: "fixture", agentIds: [], allAgents: true }, "shared-usage-secret"); + const selected = { provider: "anthropic", method: "subscription", mode: "shared", ...account } as const; + const tools = toolAccessService(db); + await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["alice"], { userId: "alice" }); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 100 }, extra_usage: { is_enabled: false } }))); + try { + await expect(service.probeUsage(companyId, "bob", account.connectionId)).rejects.toThrow("not found"); + expect(fetchSpy).not.toHaveBeenCalled(); + await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["bob"], { userId: "alice" }); + for (const config of [{ adapterType: "claude_local" }, { adapterType: "paperclip_runner", runnerProvider: "acpx", acpxAgent: "claude" }]) { + const row = await service.select({ companyId, userId: "bob", agentId, binding: selected, ...config }); + expect(await service.probeUsage(companyId, "bob", row.connection.id, row.grant.id)).toMatchObject({ status: "ok", overage: { available: false }, limits: [{ limitReached: true }, { allowed: false }] }); + } + // Unsupported methods do not resolve or send secrets. + const owner = "usage-api-owner"; + await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); + const apiAccount = await create(owner, "Unsupported usage method"); + fetchSpy.mockClear(); + expect(await service.probeUsage(companyId, owner, apiAccount.connectionId)).toMatchObject({ status: "unsupported" }); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { fetchSpy.mockRestore(); } + }); + it.each([ ["anthropic", false], ["openai", false], ["anthropic", true], ["openai", true], ] as const)("turns a %s auth failure into one card and resumes after repair (switch method: %s)", async (provider, switchMethod) => { diff --git a/server/src/routes/ai-connections.ts b/server/src/routes/ai-connections.ts index 059497dda66..335ed8067b5 100644 --- a/server/src/routes/ai-connections.ts +++ b/server/src/routes/ai-connections.ts @@ -224,6 +224,19 @@ export function aiConnectionRoutes(db: Db, options: Parameters { + const companyId = req.params.companyId as string; + assertBoard(req); + assertCompanyAccess(req, companyId); + const connectionId = z.string().uuid().safeParse(req.params.connectionId); + const grantId = z.string().uuid().optional().safeParse(req.query.grantId); + if (!connectionId.success || !grantId.success) throw unprocessable("Invalid connection or grant ID"); + res.setHeader("Cache-Control", "no-store"); + res.json(await service.probeUsage(companyId, getActorInfo(req).actorId, connectionId.data, grantId.data)); + }, + ); router.get( "/companies/:companyId/ai-connections/:connectionId/active-runs", async (req, res) => { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 1ef26262c71..f37ba2eba83 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1349,6 +1349,7 @@ const BOARD_ONLY_OPERATIONS = new Set([ "DELETE /api/companies/{companyId}/ai-connections/local/attempts/{sessionId}", "PUT /api/companies/{companyId}/ai-connections/default", "GET /api/companies/{companyId}/ai-connections/{connectionId}/active-runs", + "GET /api/companies/{companyId}/ai-connections/{connectionId}/usage", "GET /api/companies/{companyId}/ai-connections/login/{sessionId}", "GET /api/companies/{companyId}/project-repositories", @@ -10372,6 +10373,15 @@ registerCurrentRoute({ // --- AI runtime connections ------------------------------------------------- +registerCurrentRoute({ + method: "get", + path: "/api/companies/{companyId}/ai-connections/{connectionId}/usage", + tags: ["ai-connections"], + summary: "Probe the selected AI account’s provider usage limits on demand", + query: z.object({ grantId: z.string().uuid().optional() }), + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable }, +}); + registerCurrentRoute({ method: "get", path: "/api/companies/{companyId}/ai-connections", diff --git a/server/src/services/ai-connection-usage.test.ts b/server/src/services/ai-connection-usage.test.ts new file mode 100644 index 00000000000..cd4900d42e1 --- /dev/null +++ b/server/src/services/ai-connection-usage.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from "vitest"; +import { probeAiConnectionUsage } from "./ai-connection-usage.js"; + +const auth = JSON.stringify({ tokens: { access_token: "selected-access-secret", account_id: "selected-account" } }); +function fixture(body: unknown, status = 200) { + return vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })); +} + +describe("connection usage probes", () => { + it("reads every Codex window, preserves percentages and separates model restrictions", async () => { + const request = fixture({ plan_type: "pro", rate_limit: { + allowed: true, limit_reached: false, + primary_window: { used_percent: 0.4, limit_window_seconds: 14400, reset_at: 1800000000 }, + secondary_window: { used_percent: 99.99, limit_window_seconds: 604800 }, + }, additional_rate_limits: [{ limit_name: "Spark", metered_feature: "spark", rate_limit: { + allowed: false, limit_reached: true, primary_window: { used_percent: 101.2, limit_window_seconds: 7200 }, + } }], credits: { has_credits: true, unlimited: false, balance: "25.5" } }); + const result = await probeAiConnectionUsage({ provider: "openai", method: "subscription" }, auth, { request }); + expect(result.status).toBe("ok"); + expect(result.limits).toHaveLength(3); + expect(result.limits[0]).toMatchObject({ scope: null, usedPercent: 0.4, remainingPercent: 99.6, windowDurationSeconds: 14400, resetsAt: new Date(1800000000000).toISOString() }); + expect(result.limits[1]).toMatchObject({ usedPercent: 99.99, limitReached: false, resetsAt: null }); + expect(result.limits[2]).toMatchObject({ scope: "spark", usedPercent: 101.2, remainingPercent: 0, limitReached: true, allowed: false }); + expect(result.overage).toMatchObject({ enabled: null, available: true, balance: 25.5, unit: "credits" }); + expect(request).toHaveBeenCalledWith("https://chatgpt.com/backend-api/wham/usage", expect.objectContaining({ + redirect: "error", signal: expect.any(AbortSignal), + headers: { Authorization: "Bearer selected-access-secret", "ChatGPT-Account-Id": "selected-account" }, + })); + expect(JSON.stringify(result)).not.toContain("selected-access-secret"); + }); + + it("keeps group denial without pretending every Codex window is exhausted", async () => { + const result = await probeAiConnectionUsage({ provider: "openai", method: "subscription" }, auth, { request: fixture({ + rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 }, secondary_window: { used_percent: 25 } }, + code_review_rate_limit: { allowed: false, limit_reached: true }, + spend_control: { reached: true }, credits: { has_credits: false, unlimited: false, balance: "0" }, + }) }); + expect(result.limits).toHaveLength(4); + expect(result.limits[1]).toMatchObject({ limitReached: false, allowed: false }); + expect(result.limits[2]).toMatchObject({ scope: "code_review", limitReached: true }); + expect(result.overage?.available).toBe(false); + }); + + it("reports all Claude scopes and distinguishes extra-usage settings from availability", async () => { + const request = fixture({ five_hour: { utilization: 100, resets_at: "2026-10-03T01:00:00Z" }, + seven_day: { utilization: 0.5 }, seven_day_opus: { utilization: null }, + seven_day_oauth_apps: { utilization: 33 }, future_window: { utilization: 10, resets_at: null }, + extra_usage: { is_enabled: true, monthly_limit: 5000, used_credits: 1000, utilization: 20 }, + }); + const result = await probeAiConnectionUsage({ provider: "anthropic", method: "subscription" }, "claude-selected-secret", { request }); + expect(result.status).toBe("ok"); + expect(result.limits).toHaveLength(6); + expect(result.limits[0]).toMatchObject({ usedPercent: 100, limitReached: true, windowDurationSeconds: 18000 }); + expect(result.limits[1]).toMatchObject({ usedPercent: 0.5, remainingPercent: 99.5, windowDurationSeconds: 604800 }); + expect(result.limits[2]).toMatchObject({ usedPercent: null, limitReached: null }); + expect(result.limits[3].scope).toBe("seven_day_oauth_apps"); + expect(result.overage).toMatchObject({ enabled: true, available: null, remaining: 4000, unit: "cents" }); + expect(request).toHaveBeenCalledWith("https://api.anthropic.com/api/oauth/usage", expect.objectContaining({ + headers: { Authorization: "Bearer claude-selected-secret", "anthropic-beta": "oauth-2025-04-20" }, + })); + }); + + it.each([ + { is_enabled: false, available: false }, + { is_enabled: true, monthly_limit: 100, used_credits: 100, available: false }, + ])("reports unavailable Claude overage without inventing remaining allowance", async ({ available, ...extra_usage }) => { + const result = await probeAiConnectionUsage({ provider: "anthropic", method: "subscription" }, "token", { + request: fixture({ five_hour: { utilization: 100 }, extra_usage }), + }); + expect(result.overage?.available).toBe(available); + }); + + it("reads Claude's structured limits, merges legacy aliases, and keeps every scoped window", async () => { + const result = await probeAiConnectionUsage({ provider: "anthropic", method: "subscription" }, "token", { + request: fixture({ five_hour: { utilization: 15 }, seven_day: { utilization: 25 }, seven_day_sonnet: { utilization: 50 }, + limits: [ + { kind: "session", percent: 16, scope: null, is_active: false }, + { kind: "weekly_all", percent: 26, scope: null, is_active: true }, + { kind: "weekly_scoped", percent: 100, scope: { model: { id: "sonnet", display_name: "Sonnet" }, surface: null }, is_active: false }, + { kind: "weekly_scoped", percent: 40, scope: { model: { id: "sonnet" }, surface: "code" } }, + { kind: "weekly_scoped", percent: 0, scope: { model: { id: null, display_name: null }, surface: null }, resets_at: "2026-10-05T04:00:00Z" }, + { kind: "session", group: "other", percent: 12, scope: null }, + { kind: "future", percent: 0.5, scope: { surface: "chat" } }, + ], + }), + }); + expect(result.status).toBe("ok"); + expect(result.limits).toHaveLength(7); + expect(result.limits[0]).toMatchObject({ id: "five_hour", usedPercent: 16, scope: null, allowed: null }); + expect(result.limits[1]).toMatchObject({ id: "seven_day", usedPercent: 26, allowed: null }); + expect(result.limits[2]).toMatchObject({ id: "seven_day_sonnet", usedPercent: 100, scope: "model:sonnet", limitReached: true }); + expect(result.limits[3]).toMatchObject({ usedPercent: 40, scope: "model:sonnet · surface:code", windowDurationSeconds: 604800 }); + expect(result.limits[4]).toMatchObject({ scope: "weekly_scoped.4", usedPercent: 0, resetsAt: "2026-10-05T04:00:00.000Z" }); + expect(new Set(result.limits.map((entry) => entry.id)).size).toBe(7); + expect(result.limits[6]).toMatchObject({ scope: "surface:chat", usedPercent: 0.5, windowDurationSeconds: null }); + }); + + it("reads structured Claude spend when legacy extra_usage is absent, with its currency and exponent", async () => { + const result = await probeAiConnectionUsage({ provider: "anthropic", method: "subscription" }, "token", { + request: fixture({ limits: [{ kind: "session", percent: 30 }], spend: { + enabled: true, percent: 25, + used: { amount_minor: 1250, currency: "USD", exponent: 2 }, + limit: { amount_minor: 5000, currency: "USD", exponent: 2 }, + balance: { amount_minor: 2000, currency: "USD", exponent: 2 }, + } }), + }); + expect(result.overage).toMatchObject({ enabled: true, available: null, used: 12.5, limit: 50, remaining: 37.5, balance: 20, unit: "USD" }); + expect(result.limits[1]).toMatchObject({ usedPercent: 25, used: 12.5, limit: 50, unit: "USD" }); + }); + + it("keeps exhausted Claude groups separate from account-wide limits and preserves group identity across ordering", async () => { + const structured = [ + { kind: "session", group: "chat", percent: 100, scope: null }, + { kind: "session", group: "code", percent: 25, scope: null }, + { kind: "weekly_all", group: "chat", percent: 105, scope: null }, + { kind: "weekly_all", group: "code", percent: 50, scope: null }, + { kind: "weekly_scoped", group: "code", percent: 60, scope: { model: { id: "sonnet" } } }, + ]; + const probe = (limits: unknown[]) => probeAiConnectionUsage({ provider: "anthropic", method: "subscription" }, "token", { + request: fixture({ five_hour: { utilization: 10 }, seven_day: { utilization: 20 }, seven_day_sonnet: { utilization: 30 }, limits }), + }); + const result = await probe(structured); + expect(result.status).toBe("ok"); + expect(result.limits).toHaveLength(8); + expect(result.limits[0]).toMatchObject({ id: "five_hour", scope: null, usedPercent: 10, limitReached: false }); + expect(result.limits[1]).toMatchObject({ id: "seven_day", scope: null, usedPercent: 20, limitReached: false }); + expect(result.limits[2]).toMatchObject({ id: "seven_day_sonnet", scope: "seven_day_sonnet", usedPercent: 30 }); + expect(result.limits[3]).toMatchObject({ scope: "group:chat", usedPercent: 100, limitReached: true }); + expect(result.limits[4]).toMatchObject({ scope: "group:code", usedPercent: 25, limitReached: false }); + expect(result.limits[7]).toMatchObject({ scope: "group:code · model:sonnet", usedPercent: 60 }); + expect(result.limits[3].label).toContain("chat"); + expect(new Set(result.limits.map((entry) => entry.id)).size).toBe(8); + const reversed = await probe([...structured].reverse()); + expect(Object.fromEntries(reversed.limits.map((entry) => [entry.id, entry.scope]))) + .toEqual(Object.fromEntries(result.limits.map((entry) => [entry.id, entry.scope]))); + }); + + it("reports a zero extra-usage cap as exhausted without dividing by zero", async () => { + const result = await probeAiConnectionUsage({ provider: "anthropic", method: "subscription" }, "token", { + request: fixture({ five_hour: { utilization: 10 }, extra_usage: { is_enabled: true, monthly_limit: 0, used_credits: 0 } }), + }); + expect(result.limits[1]).toMatchObject({ usedPercent: null, remaining: 0, limitReached: true }); + expect(result.overage).toMatchObject({ enabled: true, available: false }); + }); + + const grokAuth = JSON.stringify({ "https://auth.x.ai::00000000-0000-4000-8000-000000000001": { key: "grok-selected-secret", refresh_token: "never-exchange" } }); + it("uses Grok subscription billing and keeps plan credits separate from on-demand credits", async () => { + const request = fixture({ onDemandEnabled: true, config: { + creditUsagePercent: 35.5, currentPeriod: { start: "2026-10-01T00:00:00Z", end: "2026-10-08T00:00:00Z" }, + onDemandCap: { val: 200 }, onDemandUsed: { val: 150 }, prepaidBalance: { val: 100 }, + } }); + const result = await probeAiConnectionUsage({ provider: "xai", method: "subscription" }, grokAuth, { request }); + expect(result.status).toBe("ok"); + expect(result.limits[0]).toMatchObject({ usedPercent: 35.5, limit: null, windowDurationSeconds: 604800, unit: "cents" }); + expect(result.limits[1]).toMatchObject({ scope: "overage", usedPercent: 75 }); + expect(result.overage).toMatchObject({ enabled: true, available: null, remaining: 50, balance: 100 }); + expect(request).toHaveBeenCalledWith("https://cli-chat-proxy.grok.com/v1/billing?format=credits", expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer grok-selected-secret", "x-xai-token-auth": "xai-grok-cli" }), + })); + }); + it("preserves the live Grok shape's omitted included usage instead of assuming zero", async () => { + const result = await probeAiConnectionUsage({ provider: "xai", method: "subscription" }, grokAuth, { + request: fixture({ config: { + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start: "2026-10-01T00:00:00Z", end: "2026-10-08T00:00:00Z" }, + isUnifiedBillingUser: true, onDemandCap: { val: 0 }, onDemandUsed: { val: 0 }, prepaidBalance: { val: 0 }, + } }), + }); + expect(result.status).toBe("ok"); + expect(result.limits[0]).toMatchObject({ usedPercent: null, used: null, limit: null, limitReached: null, allowed: null, windowDurationSeconds: 604800 }); + expect(result.overage).toMatchObject({ enabled: null, available: false, balance: 0, unit: "cents" }); + }); + + it("uses Grok's legacy used field, proto3 zero messages, and remote settings without conflating prepaid balance", async () => { + const result = await probeAiConnectionUsage({ provider: "xai", method: "subscription" }, grokAuth, { + request: fixture({ on_demand_enabled: true, subscription_tier: "SuperGrok", config: { + monthlyLimit: { val: 2000 }, used: { val: 500 }, onDemandCap: {}, onDemandUsed: {}, prepaidBalance: { val: 100 }, + billingPeriodStart: "2026-10-01T00:00:00Z", billingPeriodEnd: "2026-11-01T00:00:00Z", + } }), + }); + expect(result.planType).toBe("SuperGrok"); + expect(result.limits[0]).toMatchObject({ used: 500, limit: 2000, usedPercent: 25, remaining: 1500, unit: "cents" }); + expect(result.overage).toMatchObject({ enabled: true, used: 0, limit: 0, remaining: 0, balance: 100, available: null }); + }); + it("does not infer Grok cadence or zero usage from just a reset timestamp", async () => { + const result = await probeAiConnectionUsage({ provider: "xai", method: "subscription" }, grokAuth, { + request: fixture({ config: { currentPeriod: { end: "2026-10-08T00:00:00Z" } } }), + }); + expect(result).toMatchObject({ status: "error", errorCode: "invalid_response", limits: [] }); + }); + + it("reads OpenRouter's key cap without dividing all-time usage by a resettable cap", async () => { + const result = await probeAiConnectionUsage({ provider: "openrouter", method: "api_key" }, "key", { + request: fixture({ data: { limit: 10, limit_remaining: 2, usage: 500, limit_reset: "weekly", + free_model_daily_requests: { used: 50, limit: 50, remaining: 0 }, + } }), + }); + expect(result.limits[0]).toMatchObject({ used: 8, usedPercent: 80, limitReached: false, resetInterval: "weekly", windowDurationSeconds: 604800 }); + expect(result.limits[1]).toMatchObject({ scope: "free_models", limitReached: true }); + expect(result.overage).toBe(null); + const unknown = await probeAiConnectionUsage({ provider: "openrouter", method: "api_key" }, "key", { + request: fixture({ data: { limit: 10, usage: 500, limit_reset: "weekly" } }), + }); + expect(unknown.limits[0]).toMatchObject({ used: null, usedPercent: null, remaining: null, limitReached: null }); + }); + + it("preserves an overdrawn OpenRouter key cap and does not turn an uncapped key into available credits", async () => { + const overdrawn = await probeAiConnectionUsage({ provider: "openrouter", method: "api_key" }, "key", { + request: fixture({ data: { limit: 10, limit_remaining: -2, usage: 200 } }), + }); + expect(overdrawn.limits[0]).toMatchObject({ used: 12, usedPercent: 120, remaining: -2, limitReached: true }); + const unlimited = await probeAiConnectionUsage({ provider: "openrouter", method: "api_key" }, "key", { + request: fixture({ data: { limit: null, limit_remaining: null, usage: 20 } }), + }); + expect(unlimited).toMatchObject({ status: "ok", overage: null, limits: [{ used: 20, limit: null, usedPercent: null, limitReached: null }] }); + }); + + it.each(["openai", "anthropic", "xai"] as const)("makes unsupported %s API probes explicit without a request", async provider => { + const request = fixture({}); + expect(await probeAiConnectionUsage({ provider, method: "api_key" }, "key", { request })).toMatchObject({ status: "unsupported", limits: [], overage: null }); + expect(request).not.toHaveBeenCalled(); + }); + it.each([ + [401, "unavailable", "authentication_required"], [403, "unavailable", "permission_denied"], + [429, "error", "rate_limited"], [503, "error", "provider_unavailable"], + ])("does not misreport HTTP %s as exhausted subscription capacity", async (status, expected, code) => { + const result = await probeAiConnectionUsage({ provider: "anthropic", method: "subscription" }, "token", { + request: fixture({ error: "echoed-credential-secret" }, status as number), + }); + expect(result).toMatchObject({ status: expected, errorCode: code, limits: [], overage: null }); + expect(JSON.stringify(result)).not.toContain("echoed-credential-secret"); + }); + it.each([{}, [], { five_hour: { utilization: -1 } }, { five_hour: { utilization: "100" } }])("fails closed on missing or malformed limits: %j", async body => { + expect(await probeAiConnectionUsage({ provider: "anthropic", method: "subscription" }, "token", { request: fixture(body) })).toMatchObject({ status: "error", errorCode: "invalid_response" }); + }); + it("sanitizes network exceptions and refuses malformed credentials without a request", async () => { + const request = vi.fn().mockRejectedValue(new Error("Authorization: secret")); + expect(await probeAiConnectionUsage({ provider: "openai", method: "subscription" }, "not-json", { request })).toMatchObject({ status: "unavailable" }); + expect(request).not.toHaveBeenCalled(); + const result = await probeAiConnectionUsage({ provider: "openai", method: "subscription" }, auth, { request }); + expect(result.status).toBe("error"); + expect(JSON.stringify(result)).not.toContain("Authorization: secret"); + }); + it("bounds provider response bytes", async () => { + const request = vi.fn().mockResolvedValue(new Response(" ".repeat(256 * 1024 + 1))); + expect(await probeAiConnectionUsage({ provider: "anthropic", method: "subscription" }, "token", { request })).toMatchObject({ status: "error", errorCode: "invalid_response" }); + }); +}); diff --git a/server/src/services/ai-connection-usage.ts b/server/src/services/ai-connection-usage.ts new file mode 100644 index 00000000000..74619ef2fe2 --- /dev/null +++ b/server/src/services/ai-connection-usage.ts @@ -0,0 +1,347 @@ +import { + supportsAiConnectionUsage, + type AiConnectionMetadata, + type AiConnectionUsage, + type AiConnectionUsageLimit, + type AiConnectionUsageOverage, +} from "@paperclipai/shared"; +import { parseGrokAuthPayload } from "@paperclipai/adapter-grok-local/server"; + +type Observation = Pick; +type ObjectValue = Record; +const object = (value: unknown): ObjectValue => + value !== null && typeof value === "object" && !Array.isArray(value) ? value as ObjectValue : {}; +const number = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +const signedNumber = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; +const bool = (value: unknown): boolean | null => typeof value === "boolean" ? value : null; +const string = (value: unknown): string | null => typeof value === "string" && value.length > 0 ? value : null; +// Grok's Cent messages use proto3 JSON: a present {} means zero; an absent +// message remains unknown. Other missing scalars (including percentages) do not. +const amount = (value: unknown): number | null => { + if (value != null && typeof value === "object" && !Array.isArray(value)) { + return Object.keys(value).length === 0 ? 0 : number(object(value).val); + } + return number(value); +}; +const balance = (value: unknown): number | null => { + if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)) return signedNumber(Number(value)); + return signedNumber(value); +}; + +function timestamp(value: unknown): string | null { + if (typeof value !== "string" && typeof value !== "number") return null; + const date = new Date(typeof value === "number" ? value * 1000 : value); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; +} + +function limit(input: Partial & Pick): AiConnectionUsageLimit { + const usedPercent = input.usedPercent ?? ( + input.used != null && input.limit != null && input.limit > 0 ? input.used / input.limit * 100 : null + ); + return { + scope: null, windowDurationSeconds: null, resetsAt: null, + used: null, limit: null, remaining: null, unit: null, allowed: null, + ...input, + // Provider percentages are percentages, including values below 1 and above 100. + usedPercent, + remainingPercent: usedPercent == null ? null : Math.max(0, 100 - usedPercent), + limitReached: input.limitReached ?? (usedPercent != null ? usedPercent >= 100 + : input.used != null && input.limit != null ? input.used >= input.limit : null), + }; +} + +function overage(input: Partial): AiConnectionUsageOverage { + return { + enabled: null, available: null, unlimited: null, used: null, + limit: null, remaining: null, balance: null, unit: null, ...input, + }; +} + +class ProbeFailure extends Error { + constructor(readonly code: NonNullable) { super(code); } +} + +async function readUsage(url: string, headers: Record, request: typeof fetch): Promise { + // Fixed origins, no redirects, and a deadline covering headers AND response bytes. + const response = await request(url, { + headers, redirect: "error", signal: AbortSignal.timeout(15000), + }); + if (!response.ok) { + await response.body?.cancel(); + throw new ProbeFailure(response.status === 401 ? "authentication_required" + : response.status === 403 ? "permission_denied" + : response.status === 429 ? "rate_limited" : "provider_unavailable"); + } + // Provider errors and raw responses must never be returned to the caller. + const reader = response.body?.getReader(); + if (!reader) throw new ProbeFailure("invalid_response"); + const chunks: Uint8Array[] = []; + let bytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > 256 * 1024) throw new ProbeFailure("invalid_response"); + chunks.push(value); + } + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) throw new ProbeFailure("invalid_response"); + return parsed as ObjectValue; + } catch (error) { + await reader.cancel().catch(() => undefined); + throw error instanceof ProbeFailure ? error : new ProbeFailure("invalid_response"); + } finally { + reader.releaseLock(); + } +} + +function parseCredential(credential: string): ObjectValue { + try { return object(JSON.parse(credential)); } + catch { throw new ProbeFailure("authentication_required"); } +} + +function requireToken(value: unknown): string { + const token = string(value); + if (!token?.trim()) throw new ProbeFailure("authentication_required"); + return token; +} + +function codexLimits(body: ObjectValue): AiConnectionUsageLimit[] { + const limits: AiConnectionUsageLimit[] = []; + function add(id: string, label: string, scope: string | null, raw: unknown) { + const group = object(raw); + for (const key of ["primary_window", "secondary_window"]) { + if (!group[key] || typeof group[key] !== "object") continue; + const window = object(group[key]); + limits.push(limit({ + id: `${id}.${key}`, label: `${label} · ${key === "primary_window" ? "Primary" : "Secondary"}`, + scope, windowDurationSeconds: number(window.limit_window_seconds), + resetsAt: timestamp(window.reset_at), usedPercent: number(window.used_percent), + // Admission describes the whole group; utilization belongs to this window. + allowed: bool(group.allowed), limitReached: number(window.used_percent) == null ? bool(group.limit_reached) : null, unit: "percent", + })); + } + if (limits.every((entry) => !entry.id.startsWith(`${id}.`)) && (bool(group.limit_reached) != null || bool(group.allowed) != null)) { + limits.push(limit({ id, label, scope, allowed: bool(group.allowed), limitReached: bool(group.limit_reached) })); + } + } + add("codex", "Codex", null, body.rate_limit); + if (body.code_review_rate_limit) add("code_review", "Code review", "code_review", body.code_review_rate_limit); + if (Array.isArray(body.additional_rate_limits)) { + body.additional_rate_limits.forEach((value, index) => { + const additional = object(value); + const id = string(additional.metered_feature) ?? `additional_${index}`; + add(id, string(additional.limit_name) ?? id, id, additional.rate_limit); + }); + } + return limits; +} + +async function codex(credential: string, request: typeof fetch): Promise { + const auth = parseCredential(credential); + const tokens = object(auth.tokens); + const headers: Record = { Authorization: `Bearer ${requireToken(tokens.access_token ?? auth.accessToken)}` }; + const accountId = string(tokens.account_id ?? auth.accountId); + if (accountId) headers["ChatGPT-Account-Id"] = accountId; + const body = await readUsage("https://chatgpt.com/backend-api/wham/usage", headers, request); + const credits = object(body.credits); + const unlimited = bool(credits.unlimited); + const hasCredits = bool(credits.has_credits); + const spendControl = object(body.spend_control); + const spendLimit = object(spendControl.individual_limit); + const limits = codexLimits(body); + if (bool(spendControl.reached) != null) { + limits.push(limit({ id: "spend_control", label: "Workspace spend control", limitReached: bool(spendControl.reached), + usedPercent: number(spendLimit.used_percent), used: balance(spendLimit.used), limit: balance(spendLimit.limit), + remaining: balance(spendLimit.remaining), resetsAt: timestamp(spendLimit.reset_at), + })); + } + return { + source: "codex_wham", planType: string(body.plan_type), limits, + overage: body.credits == null ? null : overage({ + available: unlimited === true ? true : hasCredits, + unlimited, balance: balance(credits.balance), unit: "credits", + }), + }; +} + +async function claude(credential: string, request: typeof fetch): Promise { + const body = await readUsage("https://api.anthropic.com/api/oauth/usage", { + Authorization: `Bearer ${requireToken(credential)}`, "anthropic-beta": "oauth-2025-04-20", + }, request); + const limits: AiConnectionUsageLimit[] = []; + const labels: Record = { + five_hour: "5 hour limit", seven_day: "Weekly limit", seven_day_sonnet: "Sonnet weekly limit", + seven_day_opus: "Opus weekly limit", seven_day_oauth_apps: "OAuth apps weekly limit", + }; + for (const [id, value] of Object.entries(body)) { + const window = object(value); + if (id === "extra_usage" || !("utilization" in window || "resets_at" in window)) continue; + limits.push(limit({ + id, label: labels[id] ?? id.replaceAll("_", " "), + scope: id === "five_hour" || id === "seven_day" ? null : id, + windowDurationSeconds: id === "five_hour" ? 18000 : id.startsWith("seven_day") ? 604800 : null, + resetsAt: timestamp(window.resets_at), usedPercent: number(window.utilization), unit: "percent", + })); + } + if (Array.isArray(body.limits)) { + const seen = new Set(); + body.limits.forEach((value, index) => { + const window = object(value); + const kind = string(window.kind); + if (!kind || !("percent" in window || "resets_at" in window)) return; + const rawScope = object(window.scope); + const group = string(window.group); + const model = object(rawScope.model); + const modelName = string(model.id) ?? string(model.display_name); + const surface = string(rawScope.surface) ?? string(object(rawScope.surface).id) + ?? string(object(rawScope.surface).display_name); + const knownModel = !surface && modelName?.match(/\b(sonnet|opus)\b/i)?.[1].toLowerCase(); + const legacyId = kind === "session" ? "five_hour" : kind === "weekly_all" ? "seven_day" + : kind === "weekly_scoped" && knownModel ? `seven_day_${knownModel}` : `${kind}.${index}`; + const scopeParts = [group && `group:${group}`, modelName && `model:${modelName}`, surface && `surface:${surface}`].filter(Boolean); + // A named group is its own allowance, even for session/weekly_all kinds. + // Merge a legacy alias only when the structured window has the same scope. + const baseId = group || ((kind === "session" || kind === "weekly_all") && scopeParts.length) + ? `${legacyId}.scope.${encodeURIComponent(scopeParts.join("|"))}` : legacyId; + const id = seen.has(baseId) ? `${baseId}.${index}` : baseId; + seen.add(baseId); + const scope = scopeParts.join(" · ") || (kind === "session" || kind === "weekly_all" ? null : id); + const previousIndex = limits.findIndex((entry) => entry.id === id); + const previous = limits[previousIndex]; + const entry = limit({ id, label: `${labels[legacyId] ?? kind.replaceAll("_", " ")}${group ? ` · ${group}` : ""}${modelName && (!labels[legacyId] || legacyId === "five_hour" || legacyId === "seven_day") ? ` · ${modelName}` : ""}${surface ? ` · ${surface}` : ""}`, + scope, windowDurationSeconds: kind === "session" ? 18000 : kind.startsWith("weekly_") ? 604800 : null, + usedPercent: number(window.percent) ?? previous?.usedPercent, + resetsAt: timestamp(window.resets_at) ?? previous?.resetsAt ?? null, unit: "percent", + // is_active selects the dashboard's current meter, not admission. + }); + if (previousIndex < 0) limits.push(entry); + else limits[previousIndex] = entry; + }); + } + const extra = object(body.extra_usage); + const spend = object(body.spend); + const money = (value: unknown) => { + const raw = object(value); + const minor = number(raw.amount_minor); + const exponent = number(raw.exponent); + const currency = string(raw.currency); + return minor != null && exponent != null && Number.isInteger(exponent) && exponent <= 10 && currency + ? { value: minor / 10 ** exponent, currency } : null; + }; + const spendLimit = money(spend.limit); + const spendUsed = money(spend.used); + const spendBalance = money(spend.balance); + const legacyExtra = body.extra_usage != null; + const unit = legacyExtra ? "cents" : spendLimit?.currency ?? spendUsed?.currency ?? spendBalance?.currency ?? null; + const enabled = legacyExtra ? bool(extra.is_enabled) : bool(spend.enabled); + const cap = legacyExtra ? number(extra.monthly_limit) : spendLimit?.value ?? null; + const used = legacyExtra ? number(extra.used_credits) : spendUsed?.currency === unit ? spendUsed.value : null; + const prepaidBalance = spendBalance?.currency === unit ? spendBalance.value : null; + const remaining = cap == null || used == null ? null : Math.max(0, cap - used); + const hasExtra = legacyExtra || body.spend != null; + if (hasExtra) { + limits.push(limit({ id: "extra_usage", label: "Monthly extra usage", scope: "overage", + usedPercent: number(legacyExtra ? extra.utilization : spend.percent), used, limit: cap, remaining, unit, + allowed: enabled === false ? false : null, + })); + } + return { + source: "anthropic_oauth", planType: null, limits, + overage: !hasExtra ? null : overage({ + enabled, available: enabled === false || remaining === 0 ? false : null, + used, limit: cap, remaining, balance: prepaidBalance, unit, + }), + }; +} + +async function grok(credential: string, request: typeof fetch): Promise { + const payload = parseGrokAuthPayload(parseCredential(credential)); + const body = await readUsage("https://cli-chat-proxy.grok.com/v1/billing?format=credits", { + Authorization: `Bearer ${requireToken(payload?.value.key)}`, + "x-xai-token-auth": "xai-grok-cli", Accept: "application/json", + }, request); + const config = object(body.config); + const period = object(config.currentPeriod); + const start = timestamp(period.start ?? config.billingPeriodStart); + const end = timestamp(period.end ?? config.billingPeriodEnd); + const duration = start && end ? (Date.parse(end) - Date.parse(start)) / 1000 : null; + const used = amount(config.used); + const cap = amount(config.monthlyLimit); + const usedPercent = number(config.creditUsagePercent); + // Do not derive included-plan consumption from the separate on-demand pool. + const limits = [limit({ id: "grok_credits", label: "Grok plan credits", + usedPercent, used, limit: cap, remaining: cap == null || used == null ? null : Math.max(0, cap - used), + unit: "cents", resetsAt: end, windowDurationSeconds: duration != null && duration > 0 ? duration : null, + })]; + const extraUsed = amount(config.onDemandUsed); + const extraCap = amount(config.onDemandCap); + const remaining = extraUsed == null || extraCap == null ? null : Math.max(0, extraCap - extraUsed); + const enabled = bool(body.on_demand_enabled ?? body.onDemandEnabled); + const prepaidBalance = amount(config.prepaidBalance); + const extra = enabled == null && extraUsed == null && extraCap == null && prepaidBalance == null ? null + : overage({ enabled, available: enabled === false || (remaining === 0 && prepaidBalance === 0) ? false : null, + used: extraUsed, limit: extraCap, remaining, balance: prepaidBalance, unit: "cents" }); + if (extra) limits.push(limit({ id: "grok_overage", label: "Grok on-demand credits", scope: "overage", + used: extraUsed, limit: extraCap, remaining, unit: "cents", resetsAt: timestamp(config.billingPeriodEnd) ?? end })); + return { source: "grok_cli_billing", planType: string(body.subscription_tier ?? body.subscriptionTier), limits, overage: extra }; +} + +async function openrouter(credential: string, request: typeof fetch): Promise { + const body = await readUsage("https://openrouter.ai/api/v1/key", { + Authorization: `Bearer ${requireToken(credential)}`, + }, request); + const data = object(body.data); + const cap = number(data.limit); + const remaining = signedNumber(data.limit_remaining); + const resetInterval = string(data.limit_reset); + const limits = [limit({ id: "key_credits", label: "API key credit cap", scope: "api_key", + limit: cap, remaining, used: cap != null + ? remaining == null ? null : Math.max(0, cap - remaining) + : number(data.usage), + unit: "USD", resetInterval, windowDurationSeconds: resetInterval === "daily" ? 86400 : resetInterval === "weekly" ? 604800 : null, + limitReached: remaining == null ? null : remaining <= 0, + })]; + const free = object(data.free_model_daily_requests); + if (Object.keys(free).length) limits.push(limit({ id: "free_requests", label: "Free model daily requests", scope: "free_models", + used: number(free.used), limit: number(free.limit), remaining: number(free.remaining), + unit: "requests", windowDurationSeconds: 86400, + limitReached: number(free.remaining) == null ? null : number(free.remaining) === 0, + })); + // An unlimited per-key cap says nothing about the account's funded balance. + return { source: "openrouter_key", planType: null, limits, overage: null }; +} + +const probes = { openai: codex, anthropic: claude, xai: grok, openrouter }; +const messages: Record, string> = { + unsupported: "This provider does not expose usage limits through this sign-in method.", + connection_unavailable: "Reconnect or enable this AI connection before checking usage.", + authentication_required: "The provider could not authenticate this usage check. Reconnect the account.", + permission_denied: "The provider denied access to usage limits for this credential. Its sign-in permissions may not include usage access.", + rate_limited: "The provider rate limited the usage check. Try again later.", + provider_unavailable: "Could not read provider usage. Try again later.", + invalid_response: "The provider did not return usable limit data. Try again later.", +}; + +export async function probeAiConnectionUsage( + metadata: AiConnectionMetadata, + credential: string, + options: { request?: typeof fetch } = {}, +): Promise> { + const base = { ...metadata, checkedAt: new Date().toISOString(), source: null, planType: null, limits: [], overage: null }; + if (!supportsAiConnectionUsage(metadata.provider, metadata.method)) { + return { ...base, status: "unsupported", errorCode: "unsupported", message: messages.unsupported }; + } + try { + const observation = await probes[metadata.provider](credential, options.request ?? fetch); + if (!observation.limits.some((entry) => entry.usedPercent != null || entry.used != null || entry.limit != null || entry.limitReached != null || entry.allowed != null) + && !Object.values(observation.overage ?? {}).some((value) => typeof value === "number" || typeof value === "boolean")) throw new ProbeFailure("invalid_response"); + return { ...base, ...observation, status: "ok", checkedAt: new Date().toISOString() }; + } catch (error) { + const errorCode = error instanceof ProbeFailure ? error.code : "provider_unavailable"; + return { ...base, status: errorCode === "authentication_required" || errorCode === "permission_denied" ? "unavailable" : "error", errorCode, message: messages[errorCode] }; + } +} diff --git a/server/src/services/ai-connections.ts b/server/src/services/ai-connections.ts index 11cd729e577..a3d2bf640c5 100644 --- a/server/src/services/ai-connections.ts +++ b/server/src/services/ai-connections.ts @@ -22,16 +22,19 @@ import { aiConnectionMetadataSchema, aiSubscriptionNeedsIsolatedLogin, isAiConnectionCompatible, + supportsAiConnectionUsage, type AiConnectionBinding, type AiConnectionAttribution, type AiConnectionMetadata, type AiManagedConnectionSummary, + type AiConnectionUsage, type CreateAiConnection, type AiConnectionLoginIntent, } from "@paperclipai/shared"; import { forbidden, notFound, unprocessable } from "../errors.js"; import { logActivity } from "./activity-log.js"; import { secretService } from "./secrets.js"; +import { probeAiConnectionUsage } from "./ai-connection-usage.js"; /** Same human audience displayed by the existing Connections identity controls. */ function canUseCredential( @@ -132,6 +135,7 @@ export function aiConnectionService(db: Db) { grantId: grant.id, companyId, ...metadata.data, + usageProbeSupported: supportsAiConnectionUsage(metadata.data.provider, metadata.data.method), name: connection.name, accountLabel: grant.providerTenant?.name, ...(needsReconnect ? { unavailableReason: "Reconnect with a separate sign-in to protect your existing terminal login." } : {}), @@ -208,6 +212,29 @@ export function aiConnectionService(db: Db) { }); }); } + /** On-demand account observation for either runner; never probes ambient CLI auth. */ + async function probeUsage(companyId: string, userId: string, connectionId: string, grantId?: string): Promise { + if (!(await membership(companyId, userId))) throw forbidden("An active company member is required"); + const candidates = (await rows(companyId)).filter((row) => row.connection.id === connectionId && (!grantId || row.grant.id === grantId)); + const audience = await db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.companyId, companyId)); + const visible = candidates.filter(({ grant }) => canUseCredential(grant, userId, audience.filter((member) => member.grantId === grant.id))); + if (!visible.length) throw notFound("AI connection not found"); + if (visible.length > 1) throw unprocessable("Choose a credential grant for this usage check"); + const row = visible[0]!; + const metadata = aiConnectionMetadataSchema.safeParse(row.connection.config.ai); + if (!metadata.success) throw notFound("AI connection not found"); + const identity = { connectionId: row.connection.id, grantId: row.grant.id, ...metadata.data }; + const empty = { ...identity, checkedAt: new Date().toISOString(), source: null, planType: null, limits: [], overage: null }; + if (row.grant.status !== "active" || !row.connection.enabled || row.connection.status !== "active" || + row.connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(row.connection.config)) { + return { ...empty, status: "unavailable", errorCode: "connection_unavailable", message: "Reconnect or enable this AI connection before checking usage." }; + } + if (!supportsAiConnectionUsage(metadata.data.provider, metadata.data.method)) { + return { ...identity, ...await probeAiConnectionUsage(metadata.data, "") }; + } + const value = await credential(row); + return { ...identity, ...await probeAiConnectionUsage(metadata.data, value) }; + } async function select(input: { companyId: string; userId: string | null; @@ -394,6 +421,8 @@ export function aiConnectionService(db: Db) { throw unprocessable("Reconnect this AI account", { code: "ai_connection_credential_missing", }); + if (row.grant.kind === "user" && secret.scope !== "user") + throw forbidden("Credential ownership mismatch"); const context = { consumerType: "tool_connection" as const, consumerId: row.connection.id, @@ -819,5 +848,5 @@ export function aiConnectionService(db: Db) { }); }); } - return { list, select, credential, save, setDefault, membership, markAuthenticationFailed }; + return { list, select, credential, probeUsage, save, setDefault, membership, markAuthenticationFailed }; } diff --git a/ui/src/api/ai-connections.ts b/ui/src/api/ai-connections.ts index ddf09802787..c7cb9946ea4 100644 --- a/ui/src/api/ai-connections.ts +++ b/ui/src/api/ai-connections.ts @@ -1,6 +1,7 @@ -import type { AiConnectionList, CreateAiConnection, AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus } from "@paperclipai/shared"; +import type { AiConnectionList, AiConnectionUsage, CreateAiConnection, AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus } from "@paperclipai/shared"; import { api } from "./client"; export const aiConnectionsApi = { + probeUsage: (companyId: string, connectionId: string, grantId?: string) => api.get(`/companies/${companyId}/ai-connections/${connectionId}/usage${grantId ? `?grantId=${encodeURIComponent(grantId)}` : ""}`), startLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { restart?: boolean }) => api.post(`/companies/${companyId}/ai-connections/local/attempts`, input), checkLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post(`/companies/${companyId}/ai-connections/local/check`, input), cancelLocalLogin: (companyId: string, sessionId: string) => api.delete(`/companies/${companyId}/ai-connections/local/attempts/${sessionId}`), diff --git a/ui/src/components/ai-connections/AiConnectionAccountControls.tsx b/ui/src/components/ai-connections/AiConnectionAccountControls.tsx index d1e0a7048df..38605dce7db 100644 --- a/ui/src/components/ai-connections/AiConnectionAccountControls.tsx +++ b/ui/src/components/ai-connections/AiConnectionAccountControls.tsx @@ -1,10 +1,10 @@ import { useState, type ReactNode } from "react"; -import { CheckCircle2, RefreshCw, Star, TriangleAlert, Unplug } from "lucide-react"; +import { RefreshCw, Star, Unplug } from "lucide-react"; import type { ConnectionGrant } from "@paperclipai/shared"; import { RevokeGrantDialog } from "@/pages/apps/app-detail/IdentitiesSection"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { AI_PROVIDERS, aiMethodLabel, type AiConnectionSummary } from "./model"; +import type { AiConnectionSummary } from "./model"; /** AI-only account controls; identity, access and navigation belong to AppDetail. */ export function AiConnectionAccountControls({ @@ -34,25 +34,16 @@ export function AiConnectionAccountControls({ )}>
-
-

Personal default

-

For your {AI_PROVIDERS[account.provider].name} tasks

-
+

{account.isDefault && !available ? "Default unavailable" : "Personal default"}

- {account.isDefault ? ( - - {available ? : } - {available ? "Your default" : "Default unavailable"} - - ) : !readOnly ? ( + {!account.isDefault && (!readOnly ? ( - ) : Not your default} + ) : Not default)} )}
-

{aiMethodLabel(account.provider, account.method)}

- {account.accountLabel &&

{account.accountLabel}

} +

{account.method === "subscription" ? "Subscription" : "API key"}{account.accountLabel ? ` · ${account.accountLabel}` : ""}

{!readOnly && grant.capabilities?.canRevoke && (
diff --git a/ui/src/components/ai-connections/AiConnectionUsagePanel.test.tsx b/ui/src/components/ai-connections/AiConnectionUsagePanel.test.tsx new file mode 100644 index 00000000000..a75043c8946 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionUsagePanel.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { AiManagedConnectionSummary, AiConnectionUsage } from "@paperclipai/shared"; +import { AiConnectionUsagePanel } from "./AiConnectionUsagePanel"; + +const api = vi.hoisted(() => ({ probeUsage: vi.fn() })); +vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: api })); +const account: AiManagedConnectionSummary = { id: "connection", grantId: "grant", companyId: "company", provider: "anthropic", method: "subscription", name: "My Claude", ownership: "personal", isDefault: true, status: "connected" }; +let root: ReturnType; +let host: HTMLDivElement; +let client: QueryClient; +beforeEach(() => { + vi.resetAllMocks(); + client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + host = document.createElement("div"); document.body.append(host); root = createRoot(host); +}); +afterEach(() => { flushSync(() => root.unmount()); client.clear(); host.remove(); }); +function render(value = account) { + flushSync(() => root.render()); +} +const click = () => flushSync(() => host.querySelector("button")!.click()); +it("checks only on demand and replaces a successful observation with a failed probe", async () => { + const observation: AiConnectionUsage = { ...account, connectionId: account.id, status: "ok", checkedAt: "2026-10-02T12:00:00Z", source: "anthropic_oauth", planType: null, + overage: { enabled: false, available: false, unlimited: null, used: null, limit: null, remaining: null, balance: null, unit: "cents" }, + limits: [{ id: "five_hour", label: "5 hour limit", scope: null, windowDurationSeconds: 18000, resetsAt: null, + usedPercent: 100, remainingPercent: 0, used: null, limit: null, remaining: null, unit: "percent", limitReached: true, allowed: null }], + }; + api.probeUsage.mockResolvedValue(observation); + render(); + expect(api.probeUsage).not.toHaveBeenCalled(); + click(); + await vi.waitFor(() => expect(host.textContent).toContain("100% used")); + expect(api.probeUsage).toHaveBeenCalledWith("company", "connection", "grant"); + expect(host.textContent).toContain("Limit reached"); + expect(host.textContent).toContain("OverageOff"); + expect(host.textContent).not.toContain("remaining"); + expect(host.textContent).not.toContain("hour window"); + api.probeUsage.mockResolvedValue({ ...observation, status: "error", limits: [], overage: null, message: "The provider rate limited the usage check. Try again later." }); + click(); + await vi.waitFor(() => expect(host.querySelector('[role="alert"]')?.textContent).toContain("rate limited")); + expect(host.textContent).not.toContain("100% used"); + expect(host.querySelectorAll('[role="progressbar"]')).toHaveLength(0); +}); +it("clears the prior account's snapshot when changing accounts", async () => { + api.probeUsage.mockResolvedValue({ status: "error", message: "Old account failure" }); + render(); click(); + await vi.waitFor(() => expect(host.textContent).toContain("Old account failure")); + render({ ...account, id: "other", grantId: "other-grant" }); + expect(host.textContent).not.toContain("Old account failure"); + expect(api.probeUsage).toHaveBeenCalledTimes(1); +}); +it("does not round almost-exhausted usage up to an exhausted allowance", async () => { + api.probeUsage.mockResolvedValue({ status: "ok", checkedAt: "2026-10-02T12:00:00Z", overage: { balance: 0.00001, unit: "USD" }, + limits: [{ id: "five_hour", label: "5 hour limit", usedPercent: 99.99999, remainingPercent: 0.00001, + limitReached: false, allowed: null, windowDurationSeconds: 18000, resetsAt: null, limit: 1, remaining: 0.0000001, unit: "USD" }], + }); + render(); click(); + await vi.waitFor(() => expect(host.textContent).toContain("99.99999% used")); + expect(host.textContent).not.toContain("0.00001% remaining"); + expect(host.textContent).toContain("$0.0000001 left"); + expect(host.textContent).toContain("$0.00001"); + expect(host.textContent).not.toContain("Limit reached"); +}); +it("explains unsupported methods and disables checks for a revoked grant", () => { + render({ ...account, method: "api_key" }); + expect(host.textContent).toContain("Unavailable for this sign-in method"); + expect(host.querySelector("button")).toBeNull(); + render({ ...account, status: "revoked" }); + expect(host.querySelector("button")?.disabled).toBe(true); + expect(api.probeUsage).not.toHaveBeenCalled(); +}); +it("keeps provider admission and unknown overage availability distinct from exhaustion", async () => { + api.probeUsage.mockResolvedValue({ status: "ok", checkedAt: "2026-10-02T12:00:00Z", + overage: { enabled: true, available: null, unlimited: null, balance: null, remaining: null }, + limits: [{ id: "primary", label: "Plan usage · Primary", windowDurationSeconds: 18000, usedPercent: 100, + remainingPercent: 0, limitReached: true, allowed: true, resetsAt: null, used: null, limit: null, remaining: null, unit: "percent" }], + }); + render(); click(); + await vi.waitFor(() => expect(host.textContent).toContain("Limit reached · Usage allowed")); + expect(host.textContent).toContain("Plan usage · Primary · 5h"); + expect(host.textContent).toContain("On · Availability unknown"); + expect(host.textContent).not.toContain("Provider denies"); +}); +it("shows explicit denial and distinguishes equal-duration provider windows", async () => { + const window = { scope: null, windowDurationSeconds: 18000, resetsAt: null, used: null, limit: null, remaining: null, unit: "percent" }; + api.probeUsage.mockResolvedValue({ status: "ok", checkedAt: "2026-10-02T12:00:00Z", overage: null, + limits: [ + { ...window, id: "primary", label: "Plan usage · Primary", usedPercent: 100, remainingPercent: 0, limitReached: true, allowed: false }, + { ...window, id: "secondary", label: "Plan usage · Secondary", usedPercent: 50, remainingPercent: 50, limitReached: false, allowed: true }, + ], + }); + render(); click(); + await vi.waitFor(() => expect(host.textContent).toContain("Blocked")); + expect(host.querySelector('[aria-label="Plan usage · Primary · 5h: 100%"]')).not.toBeNull(); + expect(host.querySelector('[aria-label="Plan usage · Secondary · 5h: 50%"]')).not.toBeNull(); +}); diff --git a/ui/src/components/ai-connections/AiConnectionUsagePanel.tsx b/ui/src/components/ai-connections/AiConnectionUsagePanel.tsx new file mode 100644 index 00000000000..546993765e1 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionUsagePanel.tsx @@ -0,0 +1,115 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AiConnectionUsage, AiConnectionUsageLimit, AiManagedConnectionSummary } from "@paperclipai/shared"; +import { supportsAiConnectionUsage } from "@paperclipai/shared"; +import { aiConnectionsApi } from "@/api/ai-connections"; +import { Button } from "@/components/ui/button"; +import { QuotaBar } from "@/components/QuotaBar"; +import { formatDateTime, formatNumber } from "@/lib/utils"; + +const usageNumber = (value: number) => formatNumber(value, { maximumFractionDigits: 20 }); +const amount = (value: number, unit: string | null) => unit === "USD" + ? formatNumber(value, { style: "currency", currency: "USD", minimumFractionDigits: 0, maximumFractionDigits: 20 }) + : `${usageNumber(value)}${unit ? ` ${unit}` : ""}`; + +function limitLabel(window: AiConnectionUsageLimit) { + const label = window.label.replace(/\b(\d+(?:\.\d+)?) hour limit\b/i, "$1h").replace(/\bweekly limit\b/i, "Weekly"); + if (window.windowDurationSeconds == null) return label; + const hours = window.windowDurationSeconds / 3600; + const period = hours === 168 ? "Weekly" : `${usageNumber(hours)}h`; + if (/\b(?:Primary|Secondary)$/.test(label)) return `${label} · ${period}`; + if ((hours === 168 && /weekly/i.test(label)) || label.split(/[\s·()]+/).includes(period)) return label; + return `${label} · ${period}`; +} + +function limitValue(window: AiConnectionUsageLimit) { + if (window.used === 0 && window.limit === 0) return `${amount(window.limit, window.unit)} cap`; + if (window.used != null && window.limit != null) return `${amount(window.used, window.unit)} / ${amount(window.limit, window.unit)} used`; + if (window.usedPercent != null) return `${usageNumber(window.usedPercent)}% used`; + if (window.used != null) return `${amount(window.used, window.unit)} used`; + if (window.remainingPercent != null) return `${usageNumber(window.remainingPercent)}% left`; + return "Not reported"; +} + +function limitDetails(window: AiConnectionUsageLimit) { + const details: string[] = []; + if (window.allowed === false) details.push("Blocked"); + else if (window.limitReached === true) details.push("Limit reached"); + if (window.allowed === true && (window.limitReached === true || window.usedPercent == null)) details.push("Usage allowed"); + if (window.used == null || window.limit == null) { + if (window.remaining != null) details.push(`${amount(window.remaining, window.unit)} left`); + if (window.limit != null) details.push(`${amount(window.limit, window.unit)} cap`); + } + if (window.resetsAt) details.push(`Resets ${formatDateTime(window.resetsAt, { includeYear: false })}`); + else if (window.resetInterval) details.push(`Resets ${window.resetInterval}`); + return details.join(" · "); +} + +function overageSummary(usage: AiConnectionUsage) { + const overage = usage.overage!; + const details = [overage.available === true ? "Available" + : overage.enabled === false ? "Off" + : overage.available === false ? (overage.enabled === true ? "On · Unavailable" : "Unavailable") + : overage.enabled === true ? "On · Availability unknown" : "Not reported"]; + if (overage.unlimited === true) details.push("Unlimited"); + else if (overage.balance != null && (overage.balance !== 0 || overage.enabled !== false)) details.push(amount(overage.balance, overage.unit)); + if (overage.remaining != null && (overage.remaining !== 0 || overage.enabled !== false) + && !usage.limits.some((window) => window.scope === "overage" && window.remaining === overage.remaining && window.unit === overage.unit)) { + details.push(`${amount(overage.remaining, overage.unit)} left`); + } + return details.join(" · "); +} + +function usageError(usage: AiConnectionUsage) { + switch (usage.errorCode) { + case "authentication_required": return "Sign in again to check usage."; + case "permission_denied": return "Usage access denied."; + case "rate_limited": return "Too many checks. Try again later."; + case "provider_unavailable": return "Provider unavailable. Try again."; + case "invalid_response": return "Couldn’t read usage. Try again."; + case "connection_unavailable": return "Reconnect to check usage."; + case "unsupported": return "Usage unavailable."; + default: return usage.message; + } +} + +export function AiConnectionUsagePanel({ account }: { account: AiManagedConnectionSummary }) { + const probe = useMutation({ + mutationFn: () => aiConnectionsApi.probeUsage(account.companyId, account.id, account.grantId), + }); + const supported = supportsAiConnectionUsage(account.provider, account.method); + const usage = probe.isSuccess ? probe.data : undefined; + return ( +
+
+

Usage

+ {supported && } +
+ {!supported &&

Unavailable for this sign-in method.

} + {probe.error &&

{probe.error.message}

} + {usage && usage.status !== "ok" &&

{usageError(usage)}

} + {usage?.status === "ok" && ( +
+ {usage.limits.length === 0 &&

Usage not reported.

} + {usage.limits.map((window) => { + const details = limitDetails(window); + return
+ {window.usedPercent != null ? + :
+ {limitLabel(window)} + {limitValue(window)} +
} + {details &&

{details}

} +
; + })} + {usage.overage &&
+ Overage + {overageSummary(usage)} +
} +

Updated {usage.planType ? ` · ${usage.planType}` : ""}

+
+ )} +
+ ); +} diff --git a/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx index ede259e7b7e..1fc4b55428a 100644 --- a/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx +++ b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx @@ -5,6 +5,7 @@ import { aiConnectionsApi } from "@/api/ai-connections"; import { toolsApi } from "@/api/tools"; import { useNavigate } from "@/lib/router"; import { AiConnectionAccountControls } from "./AiConnectionAccountControls"; +import { AiConnectionUsagePanel } from "./AiConnectionUsagePanel"; import type { ToolConnection } from "@paperclipai/shared"; import { aiMethodLabel } from "./model"; @@ -128,7 +129,7 @@ export function ManagedAiConnectionDetails({ ) } /> - +
); } diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 8e946ec19a7..41fb267aa63 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -37,8 +37,8 @@ export function formatCents(cents: number): string { return `$${(cents / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } -export function formatNumber(n: number): string { - return n.toLocaleString("en-US"); +export function formatNumber(n: number, options?: Intl.NumberFormatOptions): string { + return n.toLocaleString("en-US", options); } /** @@ -60,12 +60,12 @@ export function formatDate(date: Date | string): string { export function formatDateTime( date: Date | string, - options: { includeSeconds?: boolean } = {}, + options: { includeSeconds?: boolean; includeYear?: boolean } = {}, ): string { return new Date(date).toLocaleString("en-US", { month: "short", day: "numeric", - year: "numeric", + ...(options.includeYear === false ? {} : { year: "numeric" as const }), hour: "numeric", minute: "2-digit", ...(options.includeSeconds ? { second: "2-digit" as const } : {}), diff --git a/ui/storybook/prototypes/AiConnectionUsageReview.tsx b/ui/storybook/prototypes/AiConnectionUsageReview.tsx new file mode 100644 index 00000000000..9fb4a285a20 --- /dev/null +++ b/ui/storybook/prototypes/AiConnectionUsageReview.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { + AiConnectionUsage, + AiConnectionUsageLimit, + AiManagedConnectionSummary, + ConnectionGrantsResponse, + ToolConnection, +} from "@paperclipai/shared"; +import { ManagedAiConnectionDetails } from "@/components/ai-connections/ManagedAiConnectionDetails"; + +const companyId = "company-storybook"; +const currentUserId = "usage-review-user"; +const checkedAt = "2026-10-02T15:00:00Z"; +const date = new Date(checkedAt); + +function account(provider: AiManagedConnectionSummary["provider"], name: string): AiManagedConnectionSummary { + return { + id: `usage-review-${provider}`, grantId: `usage-grant-${provider}`, companyId, + provider, method: provider === "openrouter" ? "api_key" : "subscription", name, + accountLabel: "alex@example.test", ownership: "personal", ownerUserId: currentUserId, + ownerName: "Alex", isDefault: true, status: "connected", + }; +} + +function limit(id: string, label: string, usedPercent: number | null, overrides: Partial = {}): AiConnectionUsageLimit { + return { + id, label, scope: null, windowDurationSeconds: null, resetsAt: null, + usedPercent, remainingPercent: usedPercent === null ? null : 100 - usedPercent, + used: null, limit: null, remaining: null, unit: "percent", + limitReached: usedPercent === null ? null : usedPercent >= 100, allowed: null, + ...overrides, + }; +} + +interface Scenario { + account: AiManagedConnectionSummary; + usage: AiConnectionUsage; + /** Keep the request pending so the loading state can be inspected. */ + pending?: boolean; +} + +function scenario(provider: AiManagedConnectionSummary["provider"], name: string, usage: Partial): Scenario { + const row = account(provider, name); + return { + account: row, + usage: { + connectionId: row.id, grantId: row.grantId, provider, method: row.method, + status: "ok", checkedAt, source: null, planType: null, limits: [], overage: null, + ...usage, + }, + }; +} + +const codex = scenario("openai", "My ChatGPT subscription", { + planType: "pro", + limits: [ + limit("primary", "5 hour limit", 62, { windowDurationSeconds: 18_000, resetsAt: "2026-10-02T18:00:00Z" }), + limit("secondary", "Weekly limit", 100, { windowDurationSeconds: 604_800, resetsAt: "2026-10-05T15:00:00Z", allowed: false }), + ], + overage: { enabled: true, available: true, unlimited: false, used: null, limit: null, remaining: null, balance: 12.5, unit: "credits" }, +}); + +const claude = scenario("anthropic", "My Claude subscription", { + planType: "max", + limits: [ + limit("five_hour", "5 hour limit", 38, { windowDurationSeconds: 18_000, resetsAt: "2026-10-02T19:00:00Z" }), + limit("seven_day", "Weekly limit", 76, { windowDurationSeconds: 604_800, resetsAt: "2026-10-06T15:00:00Z" }), + limit("weekly_code", "Weekly limit · code", 100, { + scope: "group:code", windowDurationSeconds: 604_800, resetsAt: "2026-10-06T15:00:00Z", + }), + ], + overage: { enabled: true, available: true, unlimited: false, used: 7.5, limit: 50, remaining: 42.5, balance: null, unit: "USD" }, +}); + +// The live billing response omitted included consumption: missing remains unknown. +const grok = scenario("xai", "My Grok subscription", { + planType: "SuperGrok", + limits: [ + limit("included", "Included plan", null, { unit: null, resetsAt: "2026-10-07T12:59:00Z" }), + limit("on_demand", "On-demand spending", null, { + used: 0, limit: 0, remaining: 0, unit: "USD", limitReached: true, resetInterval: "monthly", + }), + ], + overage: { enabled: false, available: false, unlimited: false, used: 0, limit: 0, remaining: 0, balance: 0, unit: "USD" }, +}); + +const openRouter = scenario("openrouter", "OpenRouter research key", { + limits: [limit("key_limit", "API key spending limit", 75, { + used: 75, limit: 100, remaining: 25, unit: "USD", resetInterval: "monthly", + })], +}); + +const unavailable: Scenario = { + ...claude, + usage: { ...claude.usage, status: "error", limits: [], overage: null, errorCode: "permission_denied", + message: "This credential does not have permission to read subscription usage." }, +}; + +function connection(row: AiManagedConnectionSummary): ToolConnection { + return { + id: row.id, companyId, applicationId: `app-${row.provider}`, name: row.name, uid: row.id, + connectionKind: "managed", ownership: "customer", connectionPurpose: "ai", transport: "runtime_auth", + authKind: row.method === "subscription" ? "oauth" : "api_key", credentialSource: "paperclip_vault", + credentialPolicy: "per_user", status: "active", enabled: true, transportConfig: {}, + config: { ai: { provider: row.provider, method: row.method }, aiIsolatedSubscription: true }, credentialSecretRefs: [], + healthStatus: "ok", healthCheckedAt: date, healthMessage: null, lastError: null, + createdByAgentId: null, createdByUserId: currentUserId, createdAt: date, updatedAt: date, + }; +} + +function grants(row: AiManagedConnectionSummary): ConnectionGrantsResponse { + return { + connection: { id: row.id, uid: row.id }, currentUserId, + members: [{ userId: currentUserId, name: "Alex", email: "alex@example.test" }], + capabilities: { + canConfigure: true, canCreateOrganizationGrant: true, canSetCompanyInstall: true, + canConnectAsCurrentUser: true, canManageAgentInstalls: true, + canViewOtherPersonalIdentities: true, editableAgentIds: [], + }, + grants: [{ + id: row.grantId, companyId, connectionId: row.id, kind: "user", subjectUserId: currentUserId, + providerTenant: { name: row.accountLabel }, credentialSecretRefs: [], status: "active", isDefault: false, + createdByAgentId: null, createdByUserId: currentUserId, revokedAt: null, revokedByAgentId: null, + revokedByUserId: null, lastUsedAt: null, createdAt: date, updatedAt: date, members: [], + capabilities: { canRevoke: true, canEditAudience: false }, + }], + }; +} + +/** Production account details; only HTTP responses are deterministic fixtures. */ +export function AiConnectionUsageReview({ scenarios }: { scenarios: Scenario[] }) { + const [client] = useState(() => new QueryClient({ defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, mutations: { retry: false }, + } })); + const [ready, setReady] = useState(false); + useEffect(() => { + const previous = window.fetch; + const fixtureFetch: typeof window.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), window.location.origin); + const path = url.pathname; + if (path === `/api/companies/${companyId}/ai-connections`) { + return Response.json({ currentUserId, canManageConnections: true, connections: scenarios.map((entry) => entry.account) }); + } + for (const entry of scenarios) { + const base = `/api/companies/${companyId}/ai-connections/${entry.account.id}`; + if (path === `${base}/usage`) { + if (entry.pending) return new Promise(() => {}); + return Response.json(entry.usage); + } + if (path === `${base}/active-runs`) return Response.json([]); + if (path === `/api/tool-connections/${entry.account.id}/grants`) return Response.json(grants(entry.account)); + } + // Fixture account controls cannot send mutations to a live instance. + if (path.startsWith(`/api/companies/${companyId}/ai-connections`) || path.startsWith("/api/tool-connections/usage-review-")) { + return Response.json({ error: "This Storybook preview only simulates usage checks." }, { status: 400 }); + } + return previous(input, init); + }; + window.fetch = fixtureFetch; + setReady(true); + return () => { if (window.fetch === fixtureFetch) window.fetch = previous; client.clear(); }; + }, [client, scenarios]); + if (!ready) return

Loading account details…

; + return ( + +
+
1 ? "grid gap-6 md:grid-cols-2" : "max-w-2xl"}> + {scenarios.map(({ account: row }) => ( +
+

{row.name}

+ +
+ ))} +
+
+
+ ); +} +export const USAGE_REVIEW_SCENARIOS = { codex, claude, grok, openRouter, unavailable }; diff --git a/ui/storybook/stories/ai-connection-usage.stories.tsx b/ui/storybook/stories/ai-connection-usage.stories.tsx new file mode 100644 index 00000000000..d3b47a3a386 --- /dev/null +++ b/ui/storybook/stories/ai-connection-usage.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { AiConnectionUsageReview, USAGE_REVIEW_SCENARIOS } from "../prototypes/AiConnectionUsageReview"; + +const { codex, claude, grok, openRouter, unavailable } = USAGE_REVIEW_SCENARIOS; +const meta = { + title: "AI Connections/Usage limits", + component: AiConnectionUsageReview, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const checkUsage: NonNullable = async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await waitFor(() => expect(canvas.getAllByRole("button", { name: "Check usage" })).toHaveLength(args.scenarios.length)); + const buttons = canvas.getAllByRole("button", { name: "Check usage" }); + for (const button of buttons) await userEvent.click(button); + await waitFor(() => expect(canvas.getAllByText(/Updated/)).toHaveLength(buttons.length)); +}; + +export const AllProviders: Story = { + args: { scenarios: [codex, claude, grok, openRouter] }, + play: checkUsage, +}; + +export const BeforeChecking: Story = { + args: { scenarios: [claude] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("button", { name: "Check usage" })).toBeEnabled(); + await expect(canvas.queryByRole("progressbar")).not.toBeInTheDocument(); + }, +}; + +export const CodexLimitReached: Story = { args: { scenarios: [codex] }, play: checkUsage }; +export const ClaudeScopedLimits: Story = { args: { scenarios: [claude] }, play: checkUsage }; +export const GrokPartialUsage: Story = { args: { scenarios: [grok] }, play: checkUsage }; +export const OpenRouterKeyLimit: Story = { args: { scenarios: [openRouter] }, play: checkUsage }; + +export const CheckingUsage: Story = { + args: { scenarios: [{ ...claude, pending: true }] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Check usage" })); + await expect(await canvas.findByRole("button", { name: "Checking…" })).toBeDisabled(); + }, +}; + +export const UsagePermissionDenied: Story = { + args: { scenarios: [unavailable] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Check usage" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Usage access denied."); + await expect(canvas.queryByRole("progressbar")).not.toBeInTheDocument(); + }, +}; diff --git a/ui/storybook/stories/ai-connections.stories.tsx b/ui/storybook/stories/ai-connections.stories.tsx index 6871af80b32..1364048abb2 100644 --- a/ui/storybook/stories/ai-connections.stories.tsx +++ b/ui/storybook/stories/ai-connections.stories.tsx @@ -249,7 +249,6 @@ export const ChangePersonalDefault: Story = { const canvas = within(canvasElement); await userEvent.click(await canvas.findByRole("button", { name: "Make default" })); await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Personal default"); - await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Your default"); await expect(canvas.queryByRole("button", { name: "Make default" })).not.toBeInTheDocument(); }, }; @@ -511,7 +510,7 @@ export const ReconnectExisting: Story = { await userEvent.click(canvas.getByRole("button", { name: "Submit code" })); await userEvent.click(canvas.getByRole("button", { name: "Use connection" })); await expect(await canvas.findByRole("heading", { name: "My Claude subscription" })).toBeVisible(); - await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Your default"); + await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Personal default"); }, }; export const RevokeConnection: Story = {