diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5d2571e72dc3..3c6808f700c7 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -6,6 +6,7 @@ import { resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, + UsageLimitSourceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; @@ -1181,6 +1182,43 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); } + // The secret path keys off `managementKey`, which every source kind carries, + // so a credit source must travel the same route as a hub without its own code. + it.effect("stores an OpenRouter key outside settings.json and restores it on edit", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const sourceId = UsageLimitSourceId.make("openrouter"); + + const next = yield* serverSettings.updateSettings({ + usageLimitSources: { + [sourceId]: { kind: "openrouter", managementKey: "sk-or-provisioning", enabled: true }, + }, + }); + assert.equal(next.usageLimitSources[sourceId]?.kind, "openrouter"); + assert.equal(next.usageLimitSources[sourceId]?.managementKey, "sk-or-provisioning"); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "sk-or-provisioning"); + + // A client edits the label while echoing the redaction marker back; the + // stored key must survive rather than being overwritten with the marker. + const relabelled = yield* serverSettings.updateSettings({ + usageLimitSources: { + [sourceId]: { + kind: "openrouter", + label: "Work account", + managementKey: "••••••", + enabled: true, + }, + }, + }); + assert.equal(relabelled.usageLimitSources[sourceId]?.managementKey, "sk-or-provisioning"); + assert.equal(relabelled.usageLimitSources[sourceId]?.label, "Work account"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("stores sensitive provider instance environment values outside settings.json", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 957a6ab3321e..dd12e3893313 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -36,6 +36,7 @@ import * as Stream from "effect/Stream"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { makeCliproxyApi } from "./cliproxyApi.ts"; +import { makeOpenRouterApi } from "./openrouterApi.ts"; export class UsageLimitSources extends Context.Service< UsageLimitSources, @@ -53,6 +54,7 @@ export class UsageLimitSources extends Context.Service< function sourceLabel(id: string, config: UsageLimitSourceConfig): string { if (config.label) return config.label; + if (config.kind === "openrouter") return "OpenRouter"; try { return new URL(config.url).host; } catch { @@ -63,6 +65,7 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; + const openrouter = yield* makeOpenRouterApi; const settingsService = yield* ServerSettingsService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const stateRef = yield* Ref.make>([]); @@ -78,7 +81,22 @@ export const make = Effect.gen(function* () { const checkedAt = DateTime.formatIso(yield* DateTime.now); const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const; if (config.managementKey.length === 0) { - return { ...base, accounts: [], error: "No management key configured." }; + return { + ...base, + accounts: [], + error: + config.kind === "openrouter" ? "No API key configured." : "No management key configured.", + }; + } + // A credit source reports money left rather than pooled accounts, so it + // fills `credits` and leaves `accounts` empty. + if (config.kind === "openrouter") { + const credits = yield* openrouter.readCredits(config).pipe(Effect.result); + if (credits._tag === "Failure") { + yield* Effect.logDebug("usage limit source read failed", { id, cause: credits.failure }); + return { ...base, accounts: [], error: credits.failure.detail }; + } + return { ...base, accounts: [], credits: credits.success }; } const accounts = yield* api.readAccounts(config).pipe(Effect.result); if (accounts._tag === "Failure") { @@ -129,6 +147,12 @@ export const make = Effect.gen(function* () { detail: "The usage limit source is missing or disabled.", }); } + // Only a hub banks redeemable reset credits; a credit balance has none. + if (config.kind !== "cliproxy") { + return yield* new UsageLimitSourceError({ + detail: "This usage limit source has no reset credits.", + }); + } const result = yield* api.consume(config, input.accountId, input.creditId); const snapshot = yield* readSource(input.sourceId, config); const previous = yield* Ref.get(stateRef); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 6bfd3fe4772e..9c1dd5ecd86c 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -5,7 +5,7 @@ import { UsageLimitSourceError, type ProviderConsumeResetCreditResult, type UsageLimitSourceAccount, - type UsageLimitSourceConfig, + type CliproxyUsageLimitSourceConfig, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -116,7 +116,7 @@ export const makeCliproxyApi = Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const management = Effect.fn("CliproxyApi.management")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, path: string, body?: unknown, ) { @@ -140,13 +140,15 @@ export const makeCliproxyApi = Effect.gen(function* () { return response; }); - const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (config: UsageLimitSourceConfig) { + const authFiles = Effect.fn("CliproxyApi.authFiles")(function* ( + config: CliproxyUsageLimitSourceConfig, + ) { const response = yield* management(config, "auth-files"); return (yield* decodeAuthFiles(response)).files; }); const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, account: typeof AuthFile.Type, url: string, data?: unknown, @@ -180,7 +182,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const credits = Effect.fn("CliproxyApi.credits")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, account: typeof AuthFile.Type, ) { const body = yield* apiCall(config, account, CREDIT_URL); @@ -197,7 +199,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, account: typeof AuthFile.Type, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -293,7 +295,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, ): Effect.fn.Return, UsageLimitSourceError> { const accounts = yield* authFiles(config).pipe( Effect.mapError( @@ -311,7 +313,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const consume = Effect.fn("CliproxyApi.consume")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, accountId: string, creditId: string, ): Effect.fn.Return { diff --git a/apps/server/src/usage/openrouterApi.test.ts b/apps/server/src/usage/openrouterApi.test.ts new file mode 100644 index 000000000000..887a5cb7958f --- /dev/null +++ b/apps/server/src/usage/openrouterApi.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { makeOpenRouterApi } from "./openrouterApi.ts"; + +const config = { + kind: "openrouter", + managementKey: "sk-or-secret", + enabled: true, +} as const; + +/** Headers arrive, the body never does: the shape that used to hang forever. */ +const STALLED_BODY = Symbol("stalled-body"); +type Reply = { status: number; body: unknown } | typeof STALLED_BODY; + +/** One canned reply per endpoint path, plus the paths actually requested. */ +function fixture(replies: Record) { + const paths: string[] = []; + const http = HttpClient.make((request) => + Effect.sync(() => { + expect(request.headers.authorization).toBe("Bearer sk-or-secret"); + const path = new URL(request.url).pathname; + paths.push(path); + const reply = replies[path] ?? { status: 404, body: {} }; + if (reply === STALLED_BODY) { + return HttpClientResponse.fromWeb( + request, + new Response(new ReadableStream({ start() {} }), { status: 200 }), + ); + } + return HttpClientResponse.fromWeb( + request, + Response.json(reply.body, { status: reply.status }), + ); + }), + ); + return { paths, api: makeOpenRouterApi.pipe(Effect.provideService(HttpClient.HttpClient, http)) }; +} + +describe("OpenRouter credit reads", () => { + it.effect("reports the account balance a provisioning key can see", () => + Effect.gen(function* () { + const test = fixture({ + "/api/v1/credits": { + status: 200, + body: { data: { total_credits: 100.5, total_usage: 25.75 } }, + }, + }); + const api = yield* test.api; + + expect(yield* api.readCredits(config)).toEqual({ + scope: "account", + usedUsd: 25.75, + purchasedUsd: 100.5, + remainingUsd: 74.75, + }); + // The narrower read is never made when the account answers. + expect(test.paths).toEqual(["/api/v1/credits"]); + }), + ); + + it.effect("falls back to the key's own allowance when OpenRouter refuses with 403", () => + Effect.gen(function* () { + const test = fixture({ + "/api/v1/credits": { + status: 403, + body: { + error: { code: 403, message: "Only management keys can perform this operation" }, + }, + }, + "/api/v1/key": { + status: 200, + body: { data: { usage: 1.42, limit: 10, limit_remaining: 8.58, is_free_tier: false } }, + }, + }); + const api = yield* test.api; + + expect(yield* api.readCredits(config)).toEqual({ + scope: "key", + usedUsd: 1.42, + limitUsd: 10, + remainingUsd: 8.58, + isFreeTier: false, + }); + expect(test.paths).toEqual(["/api/v1/credits", "/api/v1/key"]); + }), + ); + + it.effect("leaves an uncapped key with spend only, so no balance is invented", () => + Effect.gen(function* () { + const test = fixture({ + "/api/v1/credits": { status: 403, body: {} }, + "/api/v1/key": { + status: 200, + body: { data: { usage: 3.5, limit: null, limit_remaining: null, is_free_tier: true } }, + }, + }); + const api = yield* test.api; + + expect(yield* api.readCredits(config)).toEqual({ + scope: "key", + usedUsd: 3.5, + isFreeTier: true, + }); + }), + ); + + it.effect("reports a rejected key rather than retrying it against the other endpoint", () => + Effect.gen(function* () { + const test = fixture({ "/api/v1/credits": { status: 401, body: {} } }); + const api = yield* test.api; + const result = yield* api.readCredits(config).pipe(Effect.result); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure.detail).toBe("OpenRouter rejected the API key."); + } + expect(test.paths).toEqual(["/api/v1/credits"]); + }), + ); + + it.effect("reports a body it cannot read instead of publishing a partial balance", () => + Effect.gen(function* () { + const test = fixture({ + "/api/v1/credits": { status: 200, body: { data: { total_credits: "lots" } } }, + }); + const api = yield* test.api; + const result = yield* api.readCredits(config).pipe(Effect.result); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure.detail).toBe("OpenRouter returned an unexpected balance."); + } + }), + ); + + // The read runs while UsageLimitSources holds its refresh lock, so a body + // that never completes would starve every later refresh and redemption. + it.effect("gives up on a response whose body never arrives", () => + Effect.gen(function* () { + const test = fixture({ "/api/v1/credits": STALLED_BODY }); + const api = yield* test.api; + const fiber = yield* api.readCredits(config).pipe(Effect.result, Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(16)); + const result = yield* Fiber.join(fiber); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure.detail).toBe("Could not reach OpenRouter."); + } + }), + ); + + it.effect("never calls OpenRouter without a key", () => + Effect.gen(function* () { + const test = fixture({}); + const api = yield* test.api; + const result = yield* api.readCredits({ ...config, managementKey: "" }).pipe(Effect.result); + + expect(result._tag).toBe("Failure"); + expect(test.paths).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/usage/openrouterApi.ts b/apps/server/src/usage/openrouterApi.ts new file mode 100644 index 000000000000..73c4deacfc17 --- /dev/null +++ b/apps/server/src/usage/openrouterApi.ts @@ -0,0 +1,165 @@ +/** + * OpenRouter credit reads for `usageLimitSources`. + * + * OpenRouter splits the balance across two endpoints by key type. `/credits` + * reports the account (credits bought against credits spent) but accepts only a + * provisioning key, answering an ordinary inference key with 403. `/key` + * accepts any key and reports that key's own spend, plus a remaining figure + * only when the key carries a spend limit. + * + * So we ask for the better answer first and fall back, rather than making the + * user know which kind of key they pasted. + * + * @module usage/openrouterApi + */ +import { + UsageLimitSourceError, + type OpenRouterUsageLimitSourceConfig, + type UsageLimitSourceCredits, +} from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +const BASE = "https://openrouter.ai/api/v1"; + +const CreditsResponse = Schema.Struct({ + data: Schema.Struct({ + total_credits: Schema.Number, + total_usage: Schema.Number, + }), +}); +const KeyResponse = Schema.Struct({ + data: Schema.Struct({ + usage: Schema.Number, + limit: Schema.optional(Schema.NullOr(Schema.Number)), + limit_remaining: Schema.optional(Schema.NullOr(Schema.Number)), + is_free_tier: Schema.optional(Schema.Boolean), + }), +}); + +const decodeCredits = Schema.decodeUnknownEffect(CreditsResponse); +const decodeKey = Schema.decodeUnknownEffect(KeyResponse); + +/** + * Carries the HTTP status so `readCredits` can tell "wrong kind of key" (403, + * worth a second try) from a real failure. Never leaves this module: the status + * is an implementation detail of the fallback, not something a client acts on. + */ +class OpenRouterRequestError extends Data.TaggedError("OpenRouterRequestError")<{ + readonly detail: string; + readonly status?: number; +}> {} + +export const makeOpenRouterApi = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + + const exchange = Effect.fn("OpenRouterApi.request")(function* ( + config: OpenRouterUsageLimitSourceConfig, + path: string, + ) { + const response = yield* client + .execute( + HttpClientRequest.get(`${BASE}${path}`).pipe( + HttpClientRequest.setHeader("Authorization", `Bearer ${config.managementKey}`), + ), + ) + .pipe( + Effect.mapError( + () => new OpenRouterRequestError({ detail: "Could not reach OpenRouter." }), + ), + ); + if (response.status < 200 || response.status >= 300) { + return yield* new OpenRouterRequestError({ + status: response.status, + detail: + response.status === 401 + ? "OpenRouter rejected the API key." + : `OpenRouter refused the request (HTTP ${response.status}).`, + }); + } + return yield* response.json.pipe( + Effect.mapError( + () => new OpenRouterRequestError({ detail: "OpenRouter returned an unreadable response." }), + ), + ); + }); + + /** + * One deadline over the whole exchange, as the hub client has. Bounding only + * the header read leaves a response that stalls mid-body hanging forever, and + * `UsageLimitSources` reads sources while holding its refresh lock: every + * later refresh and reset-credit redemption would queue behind it. + */ + const request = (config: OpenRouterUsageLimitSourceConfig, path: string) => + exchange(config, path).pipe( + Effect.timeout("15 seconds"), + // Only the timeout is remapped. Flattening every failure here would lose + // the 403 that sends `readCredits` to the `/key` fallback. + Effect.mapError((error) => + error instanceof OpenRouterRequestError + ? error + : new OpenRouterRequestError({ detail: "Could not reach OpenRouter." }), + ), + ); + + /** Account-wide balance. Only a provisioning key gets past OpenRouter's 403 here. */ + const readAccountCredits = Effect.fn("OpenRouterApi.readAccountCredits")(function* ( + config: OpenRouterUsageLimitSourceConfig, + ) { + const body = yield* request(config, "/credits"); + const { data } = yield* decodeCredits(body).pipe( + Effect.mapError( + () => new OpenRouterRequestError({ detail: "OpenRouter returned an unexpected balance." }), + ), + ); + return { + scope: "account", + usedUsd: data.total_usage, + purchasedUsd: data.total_credits, + remainingUsd: data.total_credits - data.total_usage, + } as const satisfies UsageLimitSourceCredits; + }); + + /** This key's own allowance; `remainingUsd` exists only when the key is capped. */ + const readKeyCredits = Effect.fn("OpenRouterApi.readKeyCredits")(function* ( + config: OpenRouterUsageLimitSourceConfig, + ) { + const body = yield* request(config, "/key"); + const { data } = yield* decodeKey(body).pipe( + Effect.mapError( + () => + new OpenRouterRequestError({ detail: "OpenRouter returned an unexpected key report." }), + ), + ); + return { + scope: "key", + usedUsd: data.usage, + ...(typeof data.limit === "number" ? { limitUsd: data.limit } : {}), + ...(typeof data.limit_remaining === "number" ? { remainingUsd: data.limit_remaining } : {}), + ...(data.is_free_tier === undefined ? {} : { isFreeTier: data.is_free_tier }), + } satisfies UsageLimitSourceCredits; + }); + + /** + * The best balance this key can see. A 403 from `/credits` means an ordinary + * inference key, which `/key` still answers; any other failure there is worth + * reporting rather than masking behind a narrower read. + */ + const readCredits = Effect.fn("OpenRouterApi.readCredits")(function* ( + config: OpenRouterUsageLimitSourceConfig, + ): Effect.fn.Return { + if (config.managementKey.length === 0) { + return yield* new UsageLimitSourceError({ detail: "No API key configured." }); + } + const account = yield* readAccountCredits(config).pipe(Effect.result); + if (account._tag === "Success") return account.success; + const credits = + account.failure.status === 403 ? yield* readKeyCredits(config).pipe(Effect.result) : account; + if (credits._tag === "Success") return credits.success; + return yield* new UsageLimitSourceError({ detail: credits.failure.detail }); + }); + + return { readCredits }; +}); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index b13040152e18..d252717e3faf 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -526,6 +526,18 @@ export const OpenAI: Icon = ({ className, ...props }) => ( ); +/** + * OpenRouter's current mark. Unlike the square driver glyphs this is a wide + * wordmark-style logo (roughly 1.4:1), so size it by height and leave the width + * automatic; forcing it into a square box letterboxes it. Takes the surrounding + * text colour, as the neutral Grok mark does, so it reads in either theme. + */ +export const OpenRouterIcon: Icon = ({ className, ...props }) => ( + + + +); + export const ClaudeAI: Icon = ({ className, ...props }) => ( { + it("uses the plain id when nothing has claimed it", () => { + expect(openRouterSourceId("", new Set())).toBe("openrouter"); + expect(openRouterSourceId("Work", new Set())).toBe("openrouter-work"); + }); + + // Settings merge by id and the server keys the secret store off it, so a + // reused id would replace the first account's key rather than add a second. + it("never hands back an id an existing source already holds", () => { + expect(openRouterSourceId("", new Set(["openrouter"]))).toBe("openrouter-2"); + expect(openRouterSourceId("", new Set(["openrouter", "openrouter-2"]))).toBe("openrouter-3"); + expect(openRouterSourceId("Work", new Set(["openrouter-work"]))).toBe("openrouter-work-2"); + }); + + it("separates labels that normalize to the same slug", () => { + const first = openRouterSourceId("Work", new Set()); + const second = openRouterSourceId("work!", new Set([first])); + expect(first).toBe("openrouter-work"); + expect(second).toBe("openrouter-work-2"); + }); + + it("ignores unrelated ids when picking a suffix", () => { + expect(openRouterSourceId("", new Set(["cliproxy-hub", "openrouter-work"]))).toBe("openrouter"); + }); +}); diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index 80727a8e970f..8bd085369cf8 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -15,10 +15,15 @@ import { import { Input } from "../ui/input"; import { Label } from "../ui/label"; +export type UsageLimitSourceKind = "cliproxy" | "openrouter"; + /** * Stable per hub and readable in settings.json. Dots and dashes in the host * are kept so `foo-bar.com` and `foo.bar.com` do not collide; anything else * (a port's colon, a path) is folded to a dash. + * + * Two hubs on one URL are the same hub, so re-adding it deliberately updates + * the existing entry rather than making a second one. */ function sourceIdFromUrl(url: string): UsageLimitSourceId { let host = url; @@ -27,35 +32,61 @@ function sourceIdFromUrl(url: string): UsageLimitSourceId { } catch { // Keep the raw text; the server reports the bad URL on its row. } - const slug = host + return UsageLimitSourceId.make(`cliproxy-${slug(host) || "hub"}`); +} + +function slug(value: string): string { + return value .toLowerCase() .replace(/[^a-z0-9.-]+/g, "-") .replace(/^-+|-+$/g, ""); - return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); } /** - * Adds a CLIProxyAPI hub from provider settings on one environment. The - * management key is sent once and kept in that server's secret store; - * settings only ever carry a redaction marker for it afterwards. + * An OpenRouter account has no URL to key an id off, so the label does it — + * but two accounts may share a label, or have none, and `Work` and `work!` + * normalize alike. Unlike a hub URL, a repeated label names a *different* + * account, and settings merge by id, so reusing one would silently replace the + * first account's config and its stored key. Suffix until the id is free; + * sources already saved keep the id they were saved under. + */ +export function openRouterSourceId(label: string, taken: ReadonlySet): UsageLimitSourceId { + const suffix = slug(label); + const base = suffix ? `openrouter-${suffix}` : "openrouter"; + if (!taken.has(base)) return UsageLimitSourceId.make(base); + let index = 2; + while (taken.has(`${base}-${index}`)) index += 1; + return UsageLimitSourceId.make(`${base}-${index}`); +} + +/** + * Adds a usage limit source from provider settings on one environment. The key + * is sent once and kept in that server's secret store; settings only ever carry + * a redaction marker for it afterwards. */ export function AddUsageLimitSourceDialog({ open, onOpenChange, environmentId, environmentLabel, + kind, + existingIds, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: EnvironmentId; readonly environmentLabel: string; + readonly kind: UsageLimitSourceKind; + /** Ids already configured on this environment, so a new source cannot take one. */ + readonly existingIds: ReadonlySet; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); const [label, setLabel] = useState(""); const [url, setUrl] = useState(""); const [managementKey, setManagementKey] = useState(""); + const isOpenRouter = kind === "openrouter"; const trimmedUrl = url.trim(); - const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0; + const canSave = managementKey.trim().length > 0 && (isOpenRouter || trimmedUrl.length > 0); const reset = () => { setLabel(""); @@ -65,17 +96,27 @@ export function AddUsageLimitSourceDialog({ const save = () => { if (!canSave) return; - const id = sourceIdFromUrl(trimmedUrl); - // The patch names only this entry; the server merges it into its map. - updateSettings({ - usageLimitSources: { - [id]: { - kind: "cliproxy", - ...(label.trim() ? { label: label.trim() } : {}), + const trimmedLabel = label.trim(); + const entry = isOpenRouter + ? { + kind: "openrouter" as const, + ...(trimmedLabel ? { label: trimmedLabel } : {}), + managementKey: managementKey.trim(), + enabled: true, + } + : { + kind: "cliproxy" as const, + ...(trimmedLabel ? { label: trimmedLabel } : {}), url: trimmedUrl, managementKey: managementKey.trim(), enabled: true, - }, + }; + // The patch names only this entry; the server merges it into its map. + updateSettings({ + usageLimitSources: { + [isOpenRouter + ? openRouterSourceId(trimmedLabel, existingIds) + : sourceIdFromUrl(trimmedUrl)]: entry, }, }); reset(); @@ -92,10 +133,11 @@ export function AddUsageLimitSourceDialog({ > - Add a CLIProxyAPI hub + {isOpenRouter ? "Add OpenRouter" : "Add a CLIProxyAPI hub"} - Show the quota of every account the hub pools, next to the providers on{" "} - {environmentLabel}. The key stays on that server. + {isOpenRouter + ? `Show your OpenRouter credit balance under Usage → Limits on ${environmentLabel}. The key stays on that server.` + : `Show the quota of every account the hub pools, next to the providers on ${environmentLabel}. The key stays on that server.`} @@ -106,31 +148,44 @@ export function AddUsageLimitSourceDialog({ save(); }} > + {isOpenRouter ? null : ( +
+ + setUrl(event.target.value)} + autoFocus + /> +
+ )}
- - setUrl(event.target.value)} - autoFocus - /> -
-
- + setManagementKey(event.target.value)} + autoFocus={isOpenRouter} /> + {isOpenRouter ? ( +

+ A provisioning key reports the account balance. An ordinary API key still works, + but only reports that key's own spend and limit. +

+ ) : null}
setLabel(event.target.value)} /> @@ -148,7 +203,7 @@ export function AddUsageLimitSourceDialog({ Cancel diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index cf2887f39684..1d4ede3ff01c 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -13,7 +13,8 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; -import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { AddUsageLimitSourceDialog, type UsageLimitSourceKind } from "./AddUsageLimitSourceDialog"; import { searchableSetting } from "./settingsSearch"; import { SettingsRow, SettingsSection } from "./settingsLayout"; @@ -30,7 +31,7 @@ export function UsageProviderSettings({ readonly readOnly: boolean; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); - const [adding, setAdding] = useState(false); + const [adding, setAdding] = useState(null); const entries = Object.entries(sources); return ( @@ -39,10 +40,16 @@ export function UsageProviderSettings({ {...searchableSetting("usage-providers")} headerAction={ !readOnly ? ( - + + }> + + Add source + + + setAdding("cliproxy")}>CLIProxyAPI hub + setAdding("openrouter")}>OpenRouter + + ) : null } > @@ -50,21 +57,25 @@ export function UsageProviderSettings({ ) : ( entries.map(([id, source]) => { - const label = source.label?.trim() || source.url; + const isOpenRouter = source.kind === "openrouter"; + const label = source.label?.trim() || (isOpenRouter ? "OpenRouter" : source.url); + const url = isOpenRouter ? null : source.url; return ( - CLI Proxy{source.enabled ? "" : " · Disabled"} - {label !== source.url ? ` · ${source.url}` : ""} + {isOpenRouter ? "OpenRouter credits" : "CLI Proxy"} + {source.enabled ? "" : " · Disabled"} + {url && label !== url ? ` · ${url}` : ""} } control={ !readOnly ? ( updateSettings({ usageLimitSources: { [id]: null } })} /> ) : null @@ -74,10 +85,14 @@ export function UsageProviderSettings({ }) )} - {adding && !readOnly ? ( + {adding !== null && !readOnly ? ( { + if (!next) setAdding(null); + }} + kind={adding} + existingIds={new Set(entries.map(([id]) => id))} environmentId={environmentId} environmentLabel={environmentLabel} /> @@ -86,12 +101,14 @@ export function UsageProviderSettings({ ); } -/** Removing a hub deletes its stored management key, so it requires confirmation. */ +/** Removing a source deletes its stored key, so it requires confirmation. */ function RemoveUsageProviderButton({ label, + isOpenRouter, onConfirm, }: { readonly label: string; + readonly isOpenRouter: boolean; readonly onConfirm: () => void; }) { const [open, setOpen] = useState(false); @@ -105,9 +122,9 @@ function RemoveUsageProviderButton({ Remove {label}? - The hub's management key is deleted from this server. Its accounts leave the Limits - view; the hub itself is untouched. Add it again with the URL and key to bring them - back. + {isOpenRouter + ? "The API key is deleted from this server. The credit balance leaves the Limits view; your OpenRouter account is untouched. Add the key again to bring it back." + : "The hub's management key is deleted from this server. Its accounts leave the Limits view; the hub itself is untouched. Add it again with the URL and key to bring them back."} @@ -119,7 +136,7 @@ function RemoveUsageProviderButton({ onConfirm(); }} > - Remove hub + {isOpenRouter ? "Remove key" : "Remove hub"} diff --git a/apps/web/src/components/usage/UsageCreditBalances.tsx b/apps/web/src/components/usage/UsageCreditBalances.tsx new file mode 100644 index 000000000000..1f0ae9ddfe6d --- /dev/null +++ b/apps/web/src/components/usage/UsageCreditBalances.tsx @@ -0,0 +1,199 @@ +import type { UsageLimitSourceCredits } from "@t3tools/contracts"; +import type { CreditBalance } from "@t3tools/shared/usageLimits"; +import { formatUsd } from "@t3tools/shared/usageFormat"; + +import { type Icon, OpenRouterIcon } from "../Icons"; + +/** + * Heading for each kind of credit source, mirroring how a pool section is + * headed by its driver's mark and name. `cliproxy` is absent because a hub + * reports pooled accounts, never a balance. + */ +const CREDIT_SOURCE_PRESENTATION: Partial< + Record +> = { + openrouter: { label: "OpenRouter", mark: OpenRouterIcon }, +}; + +/** + * What `barColor` in ./UsageLimits falls back to for a driver with no brand + * colour. OpenRouter is a source rather than a driver, so it takes that same + * neutral and the card reads as one system with the pooled bars beside it. + */ +const BAR_COLOR = "var(--foreground)"; + +/** + * The denominator a balance can be drawn against: what was bought for an + * account, the cap for a single key. Without one there is a number but no bar, + * because an uncapped key has no full to be a share of. + */ +function totalUsd(credits: UsageLimitSourceCredits): number | null { + const total = credits.scope === "account" ? credits.purchasedUsd : credits.limitUsd; + return total !== undefined && total > 0 ? total : null; +} + +/** + * What the big number counts. Money, not a share: the percentage belongs on + * the bar, as it does on a pooled segment. An uncapped key knows only what it + * has spent. + */ +function headline(credits: UsageLimitSourceCredits): { value: number; caption: string } { + return credits.remainingUsd === undefined + ? { value: credits.usedUsd, caption: "spent" } + : { value: credits.remainingUsd, caption: "left" }; +} + +function detail(credits: UsageLimitSourceCredits): string { + const spent = `${formatUsd(credits.usedUsd)} spent`; + if (credits.scope === "account") { + return credits.purchasedUsd === undefined + ? spent + : `${spent} of ${formatUsd(credits.purchasedUsd)} purchased`; + } + return credits.limitUsd === undefined + ? `${spent} · no key limit` + : `${spent} · key limit ${formatUsd(credits.limitUsd)}`; +} + +/** + * What the card adds over its section heading: a renamed source, the + * environment when several report a balance, or nothing at all. A card headed + * "OpenRouter" under a heading that already says so is noise. + */ +function cardSubtitle(balance: CreditBalance, providerLabel: string): string | null { + const parts = [ + balance.label === providerLabel ? null : balance.label, + balance.environmentLabel, + ].filter((part): part is string => part !== null && part.length > 0); + return parts.length > 0 ? parts.join(" · ") : null; +} + +/** + * One prepaid balance. Shares `PoolWindowCard`'s frame so credits and quota + * read as one view, but carries no countdown: credits do not reset, they run + * out. The bar is a static fill for the same reason. + */ +function CreditCard({ + balance, + providerLabel, +}: { + readonly balance: CreditBalance; + readonly providerLabel: string; +}) { + const { credits } = balance; + const subtitle = cardSubtitle(balance, providerLabel); + const total = totalUsd(credits); + const remaining = credits.remainingUsd; + // Both the bar and the percentage need a denominator. An uncapped key has + // none, so it shows money only rather than a share invented from nothing. + const share = + total === null || remaining === undefined + ? null + : Math.max(0, Math.min(100, (remaining / total) * 100)); + const percentLeft = share === null ? null : Math.round(share); + const { value, caption } = headline(credits); + const bar = + share === null || remaining === undefined || total === null + ? null + : { + share, + label: `${percentLeft}% left · ${formatUsd(remaining)} of ${formatUsd(total)}`, + }; + + return ( +
+
+ {subtitle ? {subtitle} : null} + + + {formatUsd(value)} + + {caption} + + {detail(credits)} +
+
+ {bar === null ? null : ( +
+ {/* Translucent, as the pooled segments are, so the fill reads in either theme. */} +
+ {/* Hatched rather than blank, matching a pooled segment's spent share. Here it + marks credits already burned: nothing resets them, only buying more. */} + {bar.share < 100 ? ( +
+ ) : null} + {/* Relative so it sits above the fill and hatching, as a pooled + segment's own label does. The bar's aria-label already says it. */} +
+ + {percentLeft}% + +
+
+ )} + + {credits.scope === "account" + ? "Account balance" + : "This key's allowance · add a provisioning key to read the account balance"} + +
+
+ ); +} + +/** + * Prepaid balances above the pooled quota, one section per provider so a + * credit source is headed by its own mark and name exactly as a driver's pool + * is. Sections follow first appearance, as the pools do. + */ +export function UsageCreditBalances({ balances }: { readonly balances: readonly CreditBalance[] }) { + const groups = new Map(); + for (const balance of balances) { + const group = groups.get(balance.kind); + if (group) group.push(balance); + else groups.set(balance.kind, [balance]); + } + if (groups.size === 0) return null; + + return ( + <> + {[...groups].map(([kind, group]) => { + const presentation = CREDIT_SOURCE_PRESENTATION[kind]; + // An unknown credit source still shows its balance, named by the + // source itself rather than vanishing behind a missing mark. + const label = presentation?.label ?? group[0]!.label; + const Mark = presentation?.mark; + return ( +
+

+ {/* Height-matched to the pooled sections' size-4 glyphs; the width + follows the mark's own ratio rather than being squared off. */} + {Mark ? ( + + ) : null} + {label} +

+ {group.map((balance) => ( + + ))} +
+ ); + })} + + ); +} diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 37654140c282..a9535ab30baf 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -1,4 +1,5 @@ import { + collectCreditBalances, collectLimitAccounts, collectLimitNotices, collectLimitPools, @@ -28,6 +29,7 @@ import { resetCreditsSummary, useResetCredit, } from "./UsageLimits"; +import { UsageCreditBalances } from "./UsageCreditBalances"; /** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */ function accountInitials(email: string): string { @@ -542,14 +544,16 @@ export function UsageLimitsPooled({ readonly now: number; }) { const pools = collectLimitPools(collectLimitAccounts(presentations), now); + const balances = collectCreditBalances(presentations); const notices = collectLimitNotices(presentations); return (
- {pools.length === 0 ? ( + {pools.length === 0 && balances.length === 0 ? (

No provider on the selected environments reports subscription limits.

) : null} + {pools.map((pool) => ( ))} diff --git a/docs/user/usage.md b/docs/user/usage.md index fba493156dc2..19ed20c696d8 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -64,10 +64,20 @@ anything. The command is offered only for providers that appear under **Usage API-key accounts may not report subscription limits. This also applies to Claude connections using a proxy through `ANTHROPIC_AUTH_TOKEN`. +## Check your OpenRouter credit balance + +Open **Settings → Providers → Usage providers → Add source → OpenRouter** and enter an API key. The +balance then appears under **Usage → Limits**, and refreshing Limits re-checks it. + +Which key you use decides what the balance covers. A provisioning key reads the whole account, so +you see credits remaining against credits purchased. An ordinary API key only reads its own +allowance: you see what that key has spent, and a remaining figure only if the key carries a spend +limit. The key is stored on the environment you chose and never leaves it. + ## Connect a CLIProxyAPI hub -To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the -environment that will connect to the hub and enter its URL and management key. +To see pooled accounts, open **Settings → Providers → Usage providers → Add source → CLIProxyAPI +hub**. Choose the environment that will connect to the hub and enter its URL and management key. The accounts appear under **Usage → Limits**. Codex accounts show banked reset credits; select an account and choose **Use reset** to redeem one. No hub plugin is required. diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 0f126f291aec..e2a19a8df17d 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -87,17 +87,42 @@ export const UsageLimitSourceAccount = Schema.Struct({ }); export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; +/** + * Prepaid balance a source reports in dollars, as OpenRouter does. Distinct + * from `ServerProviderUsageWindow`: there is no rolling window to pool and no + * reset to count down to, only money left. + * + * `scope` says how much the configured key could see. A provisioning key reads + * the whole account (`purchasedUsd` and a true `remainingUsd`); an ordinary + * inference key only reads its own allowance, where `remainingUsd` exists only + * if that key carries a spend limit. + */ +export const UsageLimitSourceCredits = Schema.Struct({ + scope: Schema.Literals(["account", "key"]), + usedUsd: Schema.Number, + remainingUsd: Schema.optional(Schema.Number), + purchasedUsd: Schema.optional(Schema.Number), + limitUsd: Schema.optional(Schema.Number), + isFreeTier: Schema.optional(Schema.Boolean), +}); +export type UsageLimitSourceCredits = typeof UsageLimitSourceCredits.Type; + /** * The published state of one configured `usageLimitSources` entry. A source * that could not be read keeps `error` beside an empty account list rather * than vanishing, so the user can see it is configured but failing. + * + * A source reports quota one way or the other: a hub fills `accounts`, a + * credit source fills `credits` and leaves `accounts` empty. Clients that + * treat an empty account list as a fault must check `credits` first. */ export const UsageLimitSourceSnapshot = Schema.Struct({ id: UsageLimitSourceId, - kind: Schema.Literal("cliproxy"), + kind: Schema.Literals(["cliproxy", "openrouter"]), label: TrimmedNonEmptyString, checkedAt: IsoDateTime, accounts: ForwardCompatibleArray(UsageLimitSourceAccount), + credits: Schema.optional(UsageLimitSourceCredits), error: Schema.optional(TrimmedNonEmptyString), }); export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dd6136461fc1..bca57c2ab56d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -848,18 +848,43 @@ export const OpenCodeSettings = makeProviderSettingsSchema( export type OpenCodeSettings = typeof OpenCodeSettings.Type; /** - * A read-only quota source outside this environment's provider CLIs. The - * only kind today is a CLIProxyAPI hub, whose management API reports the - * windows of every pooled account. The key travels in settings for now, like - * provider environment secrets; it is redacted before reaching a client. + * A CLIProxyAPI hub, whose management API reports the windows of every pooled + * account. */ -export const UsageLimitSourceConfig = Schema.Struct({ +export const CliproxyUsageLimitSourceConfig = Schema.Struct({ kind: Schema.Literal("cliproxy"), label: Schema.optional(TrimmedNonEmptyString), url: TrimmedNonEmptyString, managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); +export type CliproxyUsageLimitSourceConfig = typeof CliproxyUsageLimitSourceConfig.Type; + +/** + * An OpenRouter account, which reports a credit balance rather than rolling + * windows. `managementKey` is OpenRouter's own term: a provisioning key sees + * the account balance, while an ordinary inference key only sees its own + * allowance. There is no URL to configure. + */ +export const OpenRouterUsageLimitSourceConfig = Schema.Struct({ + kind: Schema.Literal("openrouter"), + label: Schema.optional(TrimmedNonEmptyString), + managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), +}); +export type OpenRouterUsageLimitSourceConfig = typeof OpenRouterUsageLimitSourceConfig.Type; + +/** + * A read-only quota source outside this environment's provider CLIs. Every + * kind carries `label`, `managementKey`, and `enabled`, so the secret store + * and the settings redaction path treat them all alike. The key travels in + * settings for now, like provider environment secrets; it is redacted before + * reaching a client. + */ +export const UsageLimitSourceConfig = Schema.Union([ + CliproxyUsageLimitSourceConfig, + OpenRouterUsageLimitSourceConfig, +]); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; export const ObservabilitySettings = Schema.Struct({ diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b814e66da459..cd38a953c517 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -4,7 +4,9 @@ import { ProviderInstanceId, type ServerProvider, type UsageLimitSourceAccount, + type UsageLimitSourceCredits, UsageLimitSourceId, + type UsageLimitSourceSnapshot, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -14,6 +16,7 @@ import { collectProviderUsageLimits, sameUsageLimitCommandCoverage, withUsageLimitsCommands, + collectCreditBalances, collectLimitAccounts, collectLimitNotices, collectLimitPools, @@ -21,6 +24,7 @@ import { collectLimitsGroups, elapsedShare, formatResetsIn, + hasProviderUsageLimits, limitsNotice, paceOf, providersWithLimits, @@ -1196,3 +1200,97 @@ describe("isUsageLimitsCommand", () => { expect(isUsageLimitsCommand("/usage")).toBe(false); }); }); + +describe("credit balances", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const credits = { + scope: "account", + usedUsd: 25.75, + purchasedUsd: 100.5, + remainingUsd: 74.75, + } as const satisfies UsageLimitSourceCredits; + const source: UsageLimitSourceSnapshot = { + id: UsageLimitSourceId.make("openrouter"), + kind: "openrouter", + label: "OpenRouter", + checkedAt, + accounts: [], + credits, + }; + const failed: UsageLimitSourceSnapshot = { + id: source.id, + kind: "openrouter", + label: "OpenRouter", + checkedAt, + accounts: [], + error: "OpenRouter rejected the API key.", + }; + const environment = (label: string, sources: readonly UsageLimitSourceSnapshot[]) => ({ + entry: { target: { label } }, + serverConfig: { providers: [], usageLimitSources: sources }, + }); + + it("names the environment only when more than one reports a balance", () => { + const one = new Map([[EnvironmentId.make("env-a"), environment("Laptop", [source])]]); + expect(collectCreditBalances(one as never)).toEqual([ + { + key: "env-a:openrouter", + environmentId: "env-a", + kind: "openrouter", + label: "OpenRouter", + environmentLabel: null, + checkedAt, + credits, + }, + ]); + + const two = new Map([ + [EnvironmentId.make("env-a"), environment("Laptop", [source])], + [EnvironmentId.make("env-b"), environment("Desktop", [source])], + ]); + expect(collectCreditBalances(two as never).map((balance) => balance.environmentLabel)).toEqual([ + "Laptop", + "Desktop", + ]); + }); + + it("leaves out a source that reports no balance", () => { + const hub: UsageLimitSourceSnapshot = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "hub", + checkedAt, + accounts: [], + }; + const input = new Map([[EnvironmentId.make("env-a"), environment("Laptop", [hub])]]); + expect(collectCreditBalances(input as never)).toEqual([]); + }); + + // An empty account list is how a healthy credit source looks, so the notice + // meant for a silent hub must not fire for it. + it("does not call a healthy balance a source reporting nothing", () => { + const input = new Map([[EnvironmentId.make("env-a"), environment("Laptop", [source])]]); + expect(collectLimitNotices(input as never)).toEqual([]); + }); + + it("still reports a balance that could not be read", () => { + const input = new Map([[EnvironmentId.make("env-a"), environment("Laptop", [failed])]]); + expect(collectLimitNotices(input as never)).toEqual([ + "OpenRouter: OpenRouter rejected the API key.", + ]); + }); + + // A hub failure stands in for the accounts it would have listed; a credit + // source has none, so its failure must not offer /usage-limits everywhere. + it("keeps a failed balance out of slash-command coverage", () => { + const codex = provider({ usageLimits: { checkedAt, windows: [window] } }); + + expect(hasProviderUsageLimits(ProviderDriverKind.make("claudeAgent"), [], [failed])).toBe( + false, + ); + expect(sameUsageLimitCommandCoverage([source], [failed])).toBe(true); + expect( + withUsageLimitsCommands([codex], [failed])[0]?.slashCommands.map((command) => command.name), + ).toEqual(["usage-limits"]); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 5c32cc0343b7..8e32f4fce494 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -15,6 +15,7 @@ import { type ServerProvider, type ServerProviderUsageLimits, type ServerProviderUsageWindow, + type UsageLimitSourceCredits, type UsageLimitSourceSnapshot, type UsageLimitSourceSnapshots, } from "@t3tools/contracts"; @@ -150,6 +151,65 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) return normalizedEmail ? `${driver}:${normalizedEmail}` : null; } +/** One source's prepaid balance, ready to render. */ +export interface CreditBalance { + readonly key: string; + readonly environmentId: EnvironmentId; + /** Groups balances under one provider heading, as pools group by driver. */ + readonly kind: UsageLimitSourceSnapshot["kind"]; + /** The source's own label, which the user may have renamed in settings. */ + readonly label: string; + /** Set only when more than one environment reports a balance to tell apart. */ + readonly environmentLabel: string | null; + readonly checkedAt: string; + readonly credits: UsageLimitSourceCredits; +} + +/** + * Every source reporting a prepaid balance across the connected environments. + * Unlike quota windows there is nothing to pool: two keys are two balances, so + * each source keeps its own card. The environment is named only when more than + * one reports a balance, as the other collectors do. + */ +export function collectCreditBalances( + presentations: Parameters[0], +): readonly CreditBalance[] { + const perEnvironment: Array<{ + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly sources: ReadonlyArray; + }> = []; + for (const [environmentId, presentation] of presentations) { + const sources = (presentation.serverConfig?.usageLimitSources ?? []).filter( + (source) => source.credits !== undefined, + ); + if (sources.length === 0) continue; + perEnvironment.push({ + environmentId, + environmentLabel: presentation.entry.target.label, + sources, + }); + } + const labelEnvironment = perEnvironment.length > 1; + return perEnvironment.flatMap(({ environmentId, environmentLabel, sources }) => + sources.flatMap((source) => + source.credits === undefined + ? [] + : [ + { + key: `${environmentId}:${source.id}`, + environmentId, + kind: source.kind, + label: source.label, + environmentLabel: labelEnvironment ? environmentLabel : null, + checkedAt: source.checkedAt, + credits: source.credits, + }, + ], + ), + ); +} + /** * One subscription account as the pooled views see it, whichever way it was * reported. The same email signed in natively on two environments, or reported @@ -334,7 +394,9 @@ export function collectLimitNotices( for (const source of presentation.serverConfig?.usageLimitSources ?? []) { if (source.error) { notices.push(`${label(environmentLabel, source.label)}: ${source.error}`); - } else if (source.accounts.length === 0) { + } else if (source.accounts.length === 0 && source.credits === undefined) { + // A credit source reports a balance instead of accounts; an empty list + // is how it is meant to look, not a fault worth a line. notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`); } } @@ -567,6 +629,16 @@ export function isUsageLimitsCommand(prompt: string): boolean { return prompt.trim().toLowerCase() === "/usage-limits"; } +/** + * Whether a source's failure should count for every driver. A hub that failed + * to read keeps no accounts, so its error stands in for the accounts it would + * have reported. A credit source never reports accounts in the first place, so + * its failure says nothing about any driver. + */ +function coversEveryDriver(source: UsageLimitSourceSnapshot): boolean { + return source.kind === "cliproxy" && source.error !== undefined && source.accounts.length === 0; +} + /** * Whether Limits has anything to say about this driver. A source that failed to * read keeps no accounts, so its error counts for every driver rather than @@ -581,8 +653,7 @@ export function hasProviderUsageLimits( providersWithLimits(providers).some((provider) => provider.driver === driver) || sources.some( (source) => - source.accounts.some((account) => account.driver === driver) || - (source.error !== undefined && source.accounts.length === 0), + source.accounts.some((account) => account.driver === driver) || coversEveryDriver(source), ) ); } @@ -599,7 +670,7 @@ export function sameUsageLimitCommandCoverage( const coverage = (sources: UsageLimitSourceSnapshots) => new Set( sources.flatMap((source) => - source.error !== undefined && source.accounts.length === 0 + coversEveryDriver(source) ? ["*"] : source.accounts.map((account) => String(account.driver)), ), @@ -733,9 +804,10 @@ export function collectProviderUsageLimits( limits: account.usageLimits, }); } - // A source that failed to read has no accounts left to match on, so its - // error is reported to every provider rather than silently dropped. - if (source.error && (matching.length > 0 || source.accounts.length === 0)) { + // A hub that failed to read has no accounts left to match on, so its error + // is reported to every provider rather than silently dropped. A credit + // source has no accounts by design and no bearing on this driver's quota. + if (source.error && (matching.length > 0 || coversEveryDriver(source))) { notices.push(`${source.label}: ${source.error}`); } }