mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
feat(connections): probe provider usage limits on demand (#14936)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connections store the AI accounts used by legacy and native runners. > - Subscription accounts can reach session, weekly, model, or paid usage limits. > - Operators need to read these limits for a specific stored account before making a routing decision. > - This pull request adds an on-demand usage probe to the connection service and account detail. > - The result preserves provider limits, reset times, paid usage, and unknown values for later consumers. ## Linked Issues or Issue Description **Subsystem affected** Shared contracts, the connection service and API, and the account detail UI. **Problem or motivation** Managed AI accounts lack a common operation to read their current usage limits. A local harness probe can read a different login from the account selected for an agent. **Proposed solution** Add `aiConnectionService.probeUsage()` and a board-only connection usage endpoint. Probe the selected credential grant on request. Support Codex, Claude, and Grok subscriptions, plus OpenRouter API key limits. **Alternatives considered** Harness-specific automatic polling would couple the read to execution and can read ambient credentials. This change uses the managed connection credential and leaves scheduling and admission decisions to later work. **Roadmap alignment** This extends the existing Personal & Shared AI Accounts capability. It adds no routing or quota enforcement. Related: Refs #14459 for managed OpenAI quota reads; Refs #14781 and Refs #13379 for downstream pacing and budget work. This operation reads one requested account across all three subscription providers. ## What Changed - Add typed usage snapshots and a probe capability flag to managed AI connections. - Normalize Codex, Claude, Grok, and OpenRouter responses. Keep model scopes, provider admission, reset periods, and paid allowances separate. Preserve unknown values. - Enforce company membership, credential audience, grant identity, and connection lifecycle before reading the stored secret. - Add a board-only `GET /api/companies/:companyId/ai-connections/:connectionId/usage` endpoint with `no-store` responses. - Add manual **Check usage** and **Refresh** actions to account details. Show compact usage bars, resets, admission and overage status; remove repeated descriptions and account-default copy. Clear previous results during a new request or error. - Add Storybook previews using the production account components for all four providers, initial checks, loading, and permission errors. - Add provider, authorization, runner selection, API, and UI coverage. Document provider sources and live qualification. ## Verification - Initial provider, authorization, selection, API, and UI validation passed (96 focused tests): `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 -r typecheck` passes for the initial implementation. After simplifying the UI, 9 usage-panel and date-helper tests, UI typecheck, token gates, and Storybook build pass. The initial feature module boundary check also passed. - Real Codex, Claude, and Grok credentials were saved to encrypted disposable connections. The actual usage HTTP route returned 200 with `status: ok`. Legacy and native runner selection checks passed. The tests started no model turn and exchanged no refresh token. The disposable databases and vaults were removed. - Live Claude responses added structured scoped limits. Live Grok responses omitted included-plan usage. Tests now cover both shapes and preserve the Grok omission as unknown. - The full workspace build passes. A full local test run hit a heartbeat feedback timeout. That case passes in isolation. The duplicate local run was stopped after all remote checks passed. The Slack ordering and OpenCode transport CI flakes also pass in isolation and on the CI rerun. - Current head: `ff3d479029a1c4248190323e221b2803cfb0d79d`. All 54 active checks pass. Two Storybook checks are intentionally skipped by the workflow. Greptile is 5/5 with no unresolved review findings; the branch is mergeable. ## Risks - Subscription usage endpoints can change. Credentials can lack usage-read permission. The probe returns explicit errors without fresh limits in these cases. - A successful probe can contain partial data. Missing utilization or admission remains unknown. An enabled paid-usage switch does not prove a funded balance. - This change adds no migration. It does not change runner admission or automatic provider selection. Provider requests use fixed endpoints, disabled redirects, bounded response sizes, and a 15-second deadline. ## Model Used OpenAI Codex, GPT-6, with reasoning, file editing, shell execution, and HTTP tools. The session does not expose the exact runtime model variant or context window size. Real provider credentials were used only for the authorized live checks. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cf8ad63c80
commit
7d59de6113
21 files changed
+1353
-24
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -172,6 +172,7 @@ export interface AiManagedConnectionSummary {
|
||||
isDefault: boolean;
|
||||
status: "connected" | "needs_attention" | "expired" | "revoked";
|
||||
unavailableReason?: string;
|
||||
usageProbeSupported?: boolean;
|
||||
}
|
||||
export interface AiConnectionList {
|
||||
currentUserId: string;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -224,6 +224,19 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
|
||||
),
|
||||
} satisfies AiConnectionList);
|
||||
});
|
||||
router.get(
|
||||
"/companies/:companyId/ai-connections/:connectionId/usage",
|
||||
async (req, res) => {
|
||||
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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().mockResolvedValue(new Response(" ".repeat(256 * 1024 + 1)));
|
||||
expect(await probeAiConnectionUsage({ provider: "anthropic", method: "subscription" }, "token", { request })).toMatchObject({ status: "error", errorCode: "invalid_response" });
|
||||
});
|
||||
});
|
||||
@@ -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<AiConnectionUsage, "source" | "planType" | "limits" | "overage">;
|
||||
type ObjectValue = Record<string, unknown>;
|
||||
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<AiConnectionUsageLimit> & Pick<AiConnectionUsageLimit, "id" | "label">): 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>): 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<AiConnectionUsage["errorCode"]>) { super(code); }
|
||||
}
|
||||
|
||||
async function readUsage(url: string, headers: Record<string, string>, request: typeof fetch): Promise<ObjectValue> {
|
||||
// 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<Observation> {
|
||||
const auth = parseCredential(credential);
|
||||
const tokens = object(auth.tokens);
|
||||
const headers: Record<string, string> = { 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<Observation> {
|
||||
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<string, string> = {
|
||||
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<string>();
|
||||
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<Observation> {
|
||||
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<Observation> {
|
||||
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<NonNullable<AiConnectionUsage["errorCode"]>, 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<Omit<AiConnectionUsage, "connectionId" | "grantId">> {
|
||||
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] };
|
||||
}
|
||||
}
|
||||
@@ -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<AiConnectionUsage> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<AiConnectionUsage>(`/companies/${companyId}/ai-connections/${connectionId}/usage${grantId ? `?grantId=${encodeURIComponent(grantId)}` : ""}`),
|
||||
startLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { restart?: boolean }) => api.post<LocalAiLoginAttempt>(`/companies/${companyId}/ai-connections/local/attempts`, input),
|
||||
checkLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post<LocalAiLoginStatus>(`/companies/${companyId}/ai-connections/local/check`, input),
|
||||
cancelLocalLogin: (companyId: string, sessionId: string) => api.delete(`/companies/${companyId}/ai-connections/local/attempts/${sessionId}`),
|
||||
|
||||
@@ -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({
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Star aria-hidden className={cn("size-5 shrink-0", activeDefault ? "fill-current text-(--status-task-icon-done)" : "text-muted-foreground")} />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Personal default</h3>
|
||||
<p className="text-xs text-muted-foreground">For your {AI_PROVIDERS[account.provider].name} tasks</p>
|
||||
</div>
|
||||
<h3 role={account.isDefault ? "status" : undefined} className={cn("text-sm font-semibold", account.isDefault && !available && "text-destructive")}>{account.isDefault && !available ? "Default unavailable" : "Personal default"}</h3>
|
||||
</div>
|
||||
{account.isDefault ? (
|
||||
<span role="status" className={cn("inline-flex items-center gap-1.5 text-sm font-medium", available ? "text-(--status-task-icon-done)" : "text-destructive")}>
|
||||
{available ? <CheckCircle2 className="size-4" aria-hidden /> : <TriangleAlert className="size-4" aria-hidden />}
|
||||
{available ? "Your default" : "Default unavailable"}
|
||||
</span>
|
||||
) : !readOnly ? (
|
||||
{!account.isDefault && (!readOnly ? (
|
||||
<Button variant="outline" size="sm" disabled={!available} onClick={onMakeDefault}>Make default</Button>
|
||||
) : <span className="text-xs text-muted-foreground">Not your default</span>}
|
||||
) : <span className="text-xs text-muted-foreground">Not default</span>)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 text-sm">
|
||||
<p className="font-medium">{aiMethodLabel(account.provider, account.method)}</p>
|
||||
{account.accountLabel && <p className="break-words text-xs text-muted-foreground">{account.accountLabel}</p>}
|
||||
<p className="break-words text-xs text-muted-foreground">{account.method === "subscription" ? "Subscription" : "API key"}{account.accountLabel ? ` · ${account.accountLabel}` : ""}</p>
|
||||
</div>
|
||||
{!readOnly && grant.capabilities?.canRevoke && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -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<typeof createRoot>;
|
||||
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(<QueryClientProvider client={client}><AiConnectionUsagePanel key={value.id} account={value} /></QueryClientProvider>));
|
||||
}
|
||||
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();
|
||||
});
|
||||
@@ -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 (
|
||||
<section aria-label="Account usage limits" className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">Usage</h3>
|
||||
{supported && <Button variant="outline" size="sm" disabled={probe.isPending || account.status !== "connected"} onClick={() => probe.mutate()}>
|
||||
{probe.isPending ? "Checking…" : usage?.status === "ok" ? "Refresh" : "Check usage"}
|
||||
</Button>}
|
||||
</div>
|
||||
{!supported && <p className="text-xs text-muted-foreground">Unavailable for this sign-in method.</p>}
|
||||
{probe.error && <p role="alert" className="text-sm text-destructive">{probe.error.message}</p>}
|
||||
{usage && usage.status !== "ok" && <p role={usage.status === "unsupported" ? "status" : "alert"} className="text-sm text-muted-foreground">{usageError(usage)}</p>}
|
||||
{usage?.status === "ok" && (
|
||||
<div className="space-y-3" aria-live="polite">
|
||||
{usage.limits.length === 0 && <p className="text-xs text-muted-foreground">Usage not reported.</p>}
|
||||
{usage.limits.map((window) => {
|
||||
const details = limitDetails(window);
|
||||
return <div key={window.id} className="space-y-1.5">
|
||||
{window.usedPercent != null ? <QuotaBar label={limitLabel(window)} percentUsed={window.usedPercent} leftLabel={limitValue(window)} />
|
||||
: <div className="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{limitLabel(window)}</span>
|
||||
<span className="font-mono">{limitValue(window)}</span>
|
||||
</div>}
|
||||
{details && <p className="text-xs text-muted-foreground">{details}</p>}
|
||||
</div>;
|
||||
})}
|
||||
{usage.overage && <div className="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground">Overage</span>
|
||||
<span className="font-mono">{overageSummary(usage)}</span>
|
||||
</div>}
|
||||
<p className="text-xs text-muted-foreground">Updated <time className="font-mono" dateTime={usage.checkedAt} title={formatDateTime(usage.checkedAt)}>{formatDateTime(usage.checkedAt, { includeYear: false })}</time>{usage.planType ? ` · ${usage.planType}` : ""}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<AiConnectionUsagePanel key={`${account.id}:${account.grantId}:${account.status}`} account={account} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -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 } : {}),
|
||||
|
||||
@@ -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> = {}): 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<AiConnectionUsage>): 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<Response>(() => {});
|
||||
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 <p className="p-6">Loading account details…</p>;
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
<div className={scenarios.length > 1 ? "grid gap-6 md:grid-cols-2" : "max-w-2xl"}>
|
||||
{scenarios.map(({ account: row }) => (
|
||||
<section key={row.id} className="space-y-4 rounded-lg border border-border p-4" aria-label={row.name}>
|
||||
<h2 className="text-lg font-semibold">{row.name}</h2>
|
||||
<ManagedAiConnectionDetails connection={connection(row)} />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
export const USAGE_REVIEW_SCENARIOS = { codex, claude, grok, openRouter, unavailable };
|
||||
@@ -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<typeof AiConnectionUsageReview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const checkUsage: NonNullable<Story["play"]> = 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();
|
||||
},
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user