diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 0119adfd940f3..28894196543ee 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -51,11 +51,20 @@ function fixture( accounts?: Array< (typeof accounts)[number] & { disabled?: boolean; + provider?: string; + label?: string; id_token: { chatgpt_account_id: string; plan_type?: string; chatgpt_plan_type?: string }; } >; upstream?: (request: RequestBody) => { status: number; body: unknown }; cooldownStatus?: number; + quotaProvidersStatus?: number; + quotaProviders?: Array<{ + provider: string; + display_name?: string; + supported_providers?: Array; + }>; + quotaFetch?: (auth_index: string) => { status: number; body: unknown }; } = {}, ) { const requests: Array<{ path: string; body?: RequestBody }> = []; @@ -78,6 +87,46 @@ function fixture( request, Response.json({}, { status: options.cooldownStatus ?? 200 }), ); + if (path.endsWith("/quota/providers")) { + const status = options.quotaProvidersStatus ?? 200; + if (status < 200 || status >= 300) + return HttpClientResponse.fromWeb(request, Response.json({}, { status })); + return HttpClientResponse.fromWeb( + request, + Response.json({ + providers: options.quotaProviders ?? [ + { + plugin_id: "opencode-go-cliproxyapi", + provider: "opencode-go", + display_name: "OpenCode Go", + supported_providers: ["opencode-go"], + }, + ], + }), + ); + } + if (path.endsWith("/quota/fetch")) { + const fetch = options.quotaFetch?.(body?.auth_index ?? "") ?? { + status: 200, + body: { + subscription: { plan: "Go" }, + groups: [ + { + displayName: "OpenCode Go", + buckets: [ + { window: "rolling", remainingFraction: 0.5, resetTime: "2099-01-01T00:00:00Z" }, + { window: "weekly", remainingFraction: 0.25, resetTime: "2099-01-08T00:00:00Z" }, + { window: "monthly", remainingFraction: 0.75 }, + { window: "unknown-future", remainingFraction: 0.1 }, + ], + }, + ], + }, + }; + if (fetch.status < 200 || fetch.status >= 300) + return HttpClientResponse.fromWeb(request, Response.json({}, { status: fetch.status })); + return HttpClientResponse.fromWeb(request, Response.json(fetch.body)); + } expect(path).toMatch(/\/v0\/management\/api-call$/); expect(body?.header?.Authorization).toBe("Bearer $TOKEN$"); const upstream = options.upstream?.(body!) ?? { @@ -387,7 +436,12 @@ describe("CLIProxyAPI built-in management API", () => { expect((yield* api.consume(config, "first.json", "credit").pipe(Effect.result))._tag).toBe( "Failure", ); - expect(test.requests.every((request) => request.path.endsWith("/auth-files"))).toBe(true); + expect( + test.requests.every( + (request) => + request.path.endsWith("/auth-files") || request.path.endsWith("/quota/providers"), + ), + ).toBe(true); }), ); @@ -427,3 +481,86 @@ describe("CLIProxyAPI built-in management API", () => { }), ); }); + +describe("CLIProxyAPI plugin quota providers", () => { + const pluginAccounts = [ + { ...accounts[0]!, id: "personal.json", auth_index: "p", provider: "opencode-go", label: "Personal" }, + { ...accounts[1]!, id: "work.json", auth_index: "w", provider: "opencode-go", label: "Work" }, + ]; + + it.effect("reads plugin accounts through the hub quota API", () => + Effect.gen(function* () { + const test = fixture({ accounts: pluginAccounts }); + const api = yield* test.api; + const result = yield* api.readAccounts(config); + expect(result.map((account) => [account.id, account.plan])).toEqual([ + ["personal.json", "Personal · OpenCode Go"], + ["work.json", "Work · OpenCode Go"], + ]); + expect(result[0]?.driver).toBe("opencode"); + expect(result[0]?.usageLimits.windows).toEqual([ + { + id: "rolling", + kind: "session", + label: "Rolling", + usedPercent: 50, + resetsAt: "2099-01-01T00:00:00Z", + }, + { + id: "weekly", + kind: "weekly", + label: "Weekly", + usedPercent: 75, + resetsAt: "2099-01-08T00:00:00Z", + }, + { id: "monthly", kind: "monthly", label: "Monthly", usedPercent: 25 }, + ]); + expect(result[0]?.usageLimits.credentialFingerprint).toMatch(/^[0-9a-f]{64}$/); + const fetches = test.requests.filter((request) => request.path.endsWith("/quota/fetch")); + expect(fetches.map((request) => request.body?.auth_index).sort()).toEqual(["p", "w"]); + }), + ); + + it.effect("keeps codex and claude when the quota discovery endpoint is missing", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ ...accounts[0]! }, { ...pluginAccounts[0]! }], + quotaProvidersStatus: 404, + }); + const api = yield* test.api; + const result = yield* api.readAccounts(config); + expect(result.map((account) => account.id)).toEqual(["first.json"]); + }), + ); + + it.effect("isolates a failed plugin quota fetch without leaking bodies", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ ...accounts[0]! }, { ...pluginAccounts[0]! }], + quotaFetch: (auth_index) => + auth_index === "p" + ? { status: 500, body: { secret: "do-not-publish" } } + : { + status: 200, + body: { + subscription: { plan: "Go" }, + groups: [ + { + buckets: [ + { window: "weekly", remainingFraction: 0.5 }, + ], + }, + ], + }, + }, + }); + const api = yield* test.api; + const result = yield* api.readAccounts(config); + expect(result.map((account) => account.id)).toEqual(["first.json", "personal.json"]); + expect(result[1]?.usageLimits.unavailable?.reason).toBe("probeFailed"); + expect(result[1]?.plan).toBe("Personal · OpenCode Go"); + expect(result[0]?.usageLimits.windows[0]?.usedPercent).toBe(78); + expect(encodeJson(result)).not.toContain("do-not-publish"); + }), + ); +}); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 91504d8002295..e1970c37dd333 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -16,13 +16,18 @@ import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } fr import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; -import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; const AuthFile = Schema.Struct({ id: Schema.String, auth_index: Schema.String, provider: Schema.String, email: Schema.optional(Schema.String), + label: Schema.optional(Schema.String), disabled: Schema.optional(Schema.Boolean), id_token: Schema.optional( Schema.Struct({ @@ -133,6 +138,35 @@ const decodeConsumeResponse = Schema.decodeUnknownEffect( ), ); +// Native quota endpoints exposed by CLIProxyAPI for provider plugins +// (e.g. opencode-go). Unlike the codex/claude OAuth flows above, plugin +// credentials have no upstream OAuth usage endpoint, so usage comes from +// `GET quota/providers` (discovery) + `POST quota/fetch` (windows). +const QuotaProviderEntry = Schema.Struct({ + plugin_id: Schema.optional(Schema.String), + provider: Schema.String, + display_name: Schema.optional(Schema.String), + supported_providers: Schema.optional(Schema.Array(Schema.String)), + supports_reset: Schema.optional(Schema.Boolean), +}); +const QuotaProviders = Schema.Struct({ providers: Schema.Array(QuotaProviderEntry) }); +const QuotaBucket = Schema.Struct({ + window: Schema.String, + remainingFraction: Schema.Number, + resetTime: Schema.optional(Schema.String), +}); +const QuotaGroup = Schema.Struct({ + displayName: Schema.optional(Schema.String), + buckets: Schema.Array(QuotaBucket), +}); +const QuotaFetch = Schema.Struct({ + subscription: Schema.optional(Schema.Struct({ plan: Schema.optional(Schema.String) })), + groups: Schema.Array(QuotaGroup), +}); + +const decodeQuotaProviders = Schema.decodeUnknownEffect(QuotaProviders); +const decodeQuotaFetch = Schema.decodeUnknownEffect(QuotaFetch); + const CODEX_BASE = "https://chatgpt.com/backend-api/wham"; const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`; @@ -195,6 +229,23 @@ export const makeCliproxyApi = Effect.gen(function* () { return (yield* decodeAuthFiles(response)).files; }); + // Best-effort discovery of plugin quota providers. Older hubs answer 404; + // callers fall back to the codex/claude-only behavior in that case. + const quotaProviders = Effect.fn("CliproxyApi.quotaProviders")(function* ( + config: UsageLimitSourceConfig, + ) { + const response = yield* management(config, "quota/providers"); + return (yield* decodeQuotaProviders(response)).providers; + }); + + const quotaFetch = Effect.fn("CliproxyApi.quotaFetch")(function* ( + config: UsageLimitSourceConfig, + auth_index: string, + ) { + const response = yield* management(config, "quota/fetch", { auth_index }); + return yield* decodeQuotaFetch(response); + }); + const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( config: UsageLimitSourceConfig, account: typeof AuthFile.Type, @@ -351,6 +402,91 @@ export const makeCliproxyApi = Effect.gen(function* () { ); }); + // Usage for plugin-registered credentials (e.g. opencode-go). These have + // no upstream OAuth usage endpoint, so windows come from the hub's native + // quota API: rolling -> session, weekly -> weekly, monthly -> monthly. + const readPluginAccount = Effect.fn("CliproxyApi.readPluginAccount")(function* ( + config: UsageLimitSourceConfig, + account: typeof AuthFile.Type, + quotaEntry: typeof QuotaProviderEntry.Type | undefined, + ): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const displayName = quotaEntry?.display_name ?? account.provider; + const basePlan = [account.label, displayName] + .filter((part) => part !== undefined && part.trim().length > 0) + .join(" · "); + const base = { + id: account.id, + driver: ProviderDriverKind.make( + account.provider.includes("opencode") ? "opencode" : "claudeAgent", + ), + ...(account.email ? { email: account.email } : {}), + }; + const read = Effect.gen(function* () { + const quota = yield* quotaFetch(config, account.auth_index); + const subscriptionPlan = quota.subscription?.plan?.trim(); + const planSuffix = + subscriptionPlan && !displayName.toLowerCase().endsWith(subscriptionPlan.toLowerCase()) + ? ` ${subscriptionPlan}` + : ""; + const plan = `${basePlan}${planSuffix}`; + const windows = quota.groups.flatMap((group) => + group.buckets.flatMap((bucket) => { + const kind = + bucket.window === "rolling" + ? ("session" as const) + : bucket.window === "weekly" + ? ("weekly" as const) + : bucket.window === "monthly" + ? ("monthly" as const) + : undefined; + if (kind === undefined) return []; + const resetsAt = + bucket.resetTime !== undefined && !Number.isNaN(Date.parse(bucket.resetTime)) + ? bucket.resetTime + : undefined; + return [ + { + id: bucket.window, + kind, + label: bucket.window[0]!.toUpperCase() + bucket.window.slice(1), + usedPercent: clampPercent((1 - bucket.remainingFraction) * 100), + ...(resetsAt === undefined ? {} : { resetsAt }), + }, + ]; + }), + ); + return { + ...base, + ...(plan.trim().length > 0 ? { plan } : {}), + usageLimits: { + ...makeUsageLimits({ checkedAt, windows }), + ...(subscriptionPlan + ? { + credentialFingerprint: yield* crypto + .digest( + "SHA-256", + new TextEncoder().encode(`cliproxy\0${account.provider}\0${account.id}`), + ) + .pipe(Effect.map(Hex.encode), Effect.orDie), + } + : {}), + }, + }; + }); + return yield* read.pipe( + Effect.orElseSucceed(() => ({ + ...base, + ...(basePlan.trim().length > 0 ? { plan: basePlan } : {}), + usageLimits: makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "The hub could not read this account's usage.", + }), + })), + ); + }); + const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( config: UsageLimitSourceConfig, ): Effect.fn.Return, UsageLimitSourceError> { @@ -361,12 +497,28 @@ export const makeCliproxyApi = Effect.gen(function* () { : new UsageLimitSourceError({ detail: "The hub could not list accounts." }), ), ); - return yield* Effect.forEach( - accounts.filter( - (account) => - !account.disabled && (account.provider === "codex" || account.provider === "claude"), + // Plugin quota providers (opencode-go, ...) are optional: older hubs 404. + const quota = yield* quotaProviders(config).pipe(Effect.orElseSucceed(() => [])); + const quotaByProvider = new Map( + quota.flatMap((entry) => + [entry.provider, ...(entry.supported_providers ?? [])].map( + (provider) => [provider, entry] as const, + ), ), - (account) => readAccount(config, account), + ); + const wanted = accounts.filter( + (account) => + !account.disabled && + (account.provider === "codex" || + account.provider === "claude" || + quotaByProvider.has(account.provider)), + ); + return yield* Effect.forEach( + wanted, + (account) => + account.provider === "codex" || account.provider === "claude" + ? readAccount(config, account) + : readPluginAccount(config, account, quotaByProvider.get(account.provider)), { concurrency: 4 }, ); });