| author | |
| committer | |
| log | 0a89364f153f7ba890ae4dedd33d16675d73744f |
| tree | 4928eb4d489fdf74e78fc50e2f3e9b005e6be32d |
| parent | 45387700bf49401fd9de4f2db36575e50f9649eb |
| signature | Signed by PGP key B5690EEEBB952194 |
18 files changed, 978 insertions(+), 398 deletions(-)
apps/mobile/src/features/threads/ComposerUsageLimits.tsx+5-2| ... | @@ -42,6 +42,9 @@ export function ComposerUsageLimits({ | ... | @@ -42,6 +42,9 @@ export function ComposerUsageLimits({ |
| 42 | style={{ maxHeight: Math.round(height * 0.4) }} | 42 | style={{ maxHeight: Math.round(height * 0.4) }} |
| 43 | > | 43 | > |
| 44 | {report.accounts.map((account, index) => { | 44 | {report.accounts.map((account, index) => { |
| 45 | const resetCreditInput = | ||
| 46 | account.resetCreditInput ?? | ||
| 47 | (account.instanceId ? { instanceId: account.instanceId } : undefined); | ||
| 45 | const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver); | 48 | const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver); |
| 46 | return ( | 49 | return ( |
| 47 | <AccountLimits | 50 | <AccountLimits |
| ... | @@ -65,11 +68,11 @@ export function ComposerUsageLimits({ | ... | @@ -65,11 +68,11 @@ export function ComposerUsageLimits({ |
| 65 | now={now} | 68 | now={now} |
| 66 | trailing={index === 0 ? close : undefined} | 69 | trailing={index === 0 ? close : undefined} |
| 67 | footer={ | 70 | footer={ |
| 68 | account.instanceId && account.limits.resetCredits ? ( | 71 | resetCreditInput && account.limits.resetCredits ? ( |
| 69 | <ResetCredits | 72 | <ResetCredits |
| 70 | dense | 73 | dense |
| 71 | environmentId={environmentId} | 74 | environmentId={environmentId} |
| 72 | instanceId={account.instanceId} | 75 | input={resetCreditInput} |
| 73 | credits={account.limits.resetCredits} | 76 | credits={account.limits.resetCredits} |
| 74 | now={now} | 77 | now={now} |
| 75 | /> | 78 | /> |
apps/mobile/src/features/usage/UsageLimitsPooled.tsx+3-3| ... | @@ -140,7 +140,7 @@ function PoolWindowCard({ | ... | @@ -140,7 +140,7 @@ function PoolWindowCard({ |
| 140 | </View> | 140 | </View> |
| 141 | <View> | 141 | <View> |
| 142 | {pool.members.map(({ account, window }, index) => { | 142 | {pool.members.map(({ account, window }, index) => { |
| 143 | const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; | 143 | const credits = account.limits.resetCredits?.availableCount ?? 0; |
| 144 | const resetsIn = formatResetsIn(window, now); | 144 | const resetsIn = formatResetsIn(window, now); |
| 145 | return ( | 145 | return ( |
| 146 | <Pressable | 146 | <Pressable |
| ... | @@ -358,9 +358,9 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { | ... | @@ -358,9 +358,9 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { |
| 358 | <View className="gap-3 rounded-[24px] border-continuous bg-card p-4"> | 358 | <View className="gap-3 rounded-[24px] border-continuous bg-card p-4"> |
| 359 | <Text className="text-sm font-t3-medium text-foreground">Reset credits</Text> | 359 | <Text className="text-sm font-t3-medium text-foreground">Reset credits</Text> |
| 360 | <ResetCredits | 360 | <ResetCredits |
| 361 | key={`${account.redeem.environmentId}:${account.redeem.instanceId}`} | 361 | key={account.key} |
| 362 | environmentId={account.redeem.environmentId} | 362 | environmentId={account.redeem.environmentId} |
| 363 | instanceId={account.redeem.instanceId} | 363 | input={account.redeem.input} |
| 364 | credits={account.limits.resetCredits} | 364 | credits={account.limits.resetCredits} |
| 365 | now={now} | 365 | now={now} |
| 366 | /> | 366 | /> |
apps/mobile/src/features/usage/UsageLimitsSection.tsx+5-5| ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; | ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { | 2 | import type { |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | ProviderConsumeResetCreditOutcome, | 4 | ProviderConsumeResetCreditOutcome, |
| 5 | ProviderInstanceId, | 5 | ProviderConsumeResetCreditInput, |
| 6 | ServerProvider, | 6 | ServerProvider, |
| 7 | ServerProviderResetCredits, | 7 | ServerProviderResetCredits, |
| 8 | ServerProviderUsageWindow, | 8 | ServerProviderUsageWindow, |
| ... | @@ -168,13 +168,13 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { | ... | @@ -168,13 +168,13 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { |
| 168 | */ | 168 | */ |
| 169 | export function ResetCredits(props: { | 169 | export function ResetCredits(props: { |
| 170 | readonly environmentId: EnvironmentId; | 170 | readonly environmentId: EnvironmentId; |
| 171 | readonly instanceId: ProviderInstanceId; | 171 | readonly input: ProviderConsumeResetCreditInput; |
| 172 | readonly credits: ServerProviderResetCredits; | 172 | readonly credits: ServerProviderResetCredits; |
| 173 | readonly now: number; | 173 | readonly now: number; |
| 174 | /** A smaller pill for the composer card. */ | 174 | /** A smaller pill for the composer card. */ |
| 175 | readonly dense?: boolean; | 175 | readonly dense?: boolean; |
| 176 | }) { | 176 | }) { |
| 177 | const { environmentId, instanceId, credits, now, dense = false } = props; | 177 | const { environmentId, input, credits, now, dense = false } = props; |
| 178 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { | 178 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { |
| 179 | reportFailure: false, | 179 | reportFailure: false, |
| 180 | }); | 180 | }); |
| ... | @@ -195,10 +195,10 @@ export function ResetCredits(props: { | ... | @@ -195,10 +195,10 @@ export function ResetCredits(props: { |
| 195 | const redeem = async () => { | 195 | const redeem = async () => { |
| 196 | setBusy(true); | 196 | setBusy(true); |
| 197 | setStatus(null); | 197 | setStatus(null); |
| 198 | const result = await consume({ environmentId, input: { instanceId } }); | 198 | const result = await consume({ environmentId, input }); |
| 199 | setBusy(false); | 199 | setBusy(false); |
| 200 | if (result._tag === "Success") { | 200 | if (result._tag === "Success") { |
| 201 | setStatus(OUTCOME_TEXT[result.value.outcome]); | 201 | setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]); |
| 202 | return; | 202 | return; |
| 203 | } | 203 | } |
| 204 | setStatus( | 204 | setStatus( |
apps/server/src/usage/UsageLimitSources.ts+32-50| ... | @@ -13,15 +13,16 @@ | ... | @@ -13,15 +13,16 @@ |
| 13 | */ | 13 | */ |
| 14 | import { | 14 | import { |
| 15 | DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, | 15 | DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, |
| 16 | UsageLimitSourceError, | ||
| 17 | type UsageLimitSourceConsumeResetCreditInput, | ||
| 18 | type ProviderConsumeResetCreditResult, | ||
| 16 | type ServerSettings, | 19 | type ServerSettings, |
| 17 | type UsageLimitSourceConfig, | 20 | type UsageLimitSourceConfig, |
| 18 | type UsageLimitSourceId, | 21 | type UsageLimitSourceId, |
| 19 | type UsageLimitSourceSnapshot, | 22 | type UsageLimitSourceSnapshot, |
| 20 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 21 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; | 24 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; |
| 22 | import type * as Cause from "effect/Cause"; | ||
| 23 | import * as Context from "effect/Context"; | 25 | import * as Context from "effect/Context"; |
| 24 | import * as Data from "effect/Data"; | ||
| 25 | import * as DateTime from "effect/DateTime"; | 26 | import * as DateTime from "effect/DateTime"; |
| 26 | import * as Duration from "effect/Duration"; | 27 | import * as Duration from "effect/Duration"; |
| 27 | import * as Effect from "effect/Effect"; | 28 | import * as Effect from "effect/Effect"; |
| ... | @@ -30,16 +31,11 @@ import * as Layer from "effect/Layer"; | ... | @@ -30,16 +31,11 @@ import * as Layer from "effect/Layer"; |
| 30 | import * as PubSub from "effect/PubSub"; | 31 | import * as PubSub from "effect/PubSub"; |
| 31 | import * as Ref from "effect/Ref"; | 32 | import * as Ref from "effect/Ref"; |
| 32 | import * as Semaphore from "effect/Semaphore"; | 33 | import * as Semaphore from "effect/Semaphore"; |
| 33 | import type * as Schema from "effect/Schema"; | ||
| 34 | import * as Stream from "effect/Stream"; | 34 | import * as Stream from "effect/Stream"; |
| 35 | import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/unstable/http"; | ||
| 36 | 35 | ||
| 37 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | 36 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 38 | import { ServerSettingsService } from "../serverSettings.ts"; | 37 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 39 | import { cliproxyStatusToAccounts, decodeCliproxyQuotaStatus } from "./cliproxyUsageLimits.ts"; | 38 | import { makeCliproxyApi } from "./cliproxyApi.ts"; |
| 40 | |||
| 41 | const FETCH_TIMEOUT = "10 seconds"; | ||
| 42 | const QUOTA_STATUS_PATH = "/v0/management/quota-scheduler/status"; | ||
| 43 | 39 | ||
| 44 | export class UsageLimitSources extends Context.Service< | 40 | export class UsageLimitSources extends Context.Service< |
| 45 | UsageLimitSources, | 41 | UsageLimitSources, |
| ... | @@ -49,35 +45,12 @@ export class UsageLimitSources extends Context.Service< | ... | @@ -49,35 +45,12 @@ export class UsageLimitSources extends Context.Service< |
| 49 | readonly streamChanges: Stream.Stream<ReadonlyArray<UsageLimitSourceSnapshot>>; | 45 | readonly streamChanges: Stream.Stream<ReadonlyArray<UsageLimitSourceSnapshot>>; |
| 50 | /** Re-read every source now. Never fails; failures land on the snapshot. */ | 46 | /** Re-read every source now. Never fails; failures land on the snapshot. */ |
| 51 | readonly refresh: Effect.Effect<void>; | 47 | readonly refresh: Effect.Effect<void>; |
| 48 | readonly consumeResetCredit: ( | ||
| 49 | input: UsageLimitSourceConsumeResetCreditInput, | ||
| 50 | ) => Effect.Effect<ProviderConsumeResetCreditResult, UsageLimitSourceError>; | ||
| 52 | } | 51 | } |
| 53 | >()("t3/usage/UsageLimitSources") {} | 52 | >()("t3/usage/UsageLimitSources") {} |
| 54 | 53 | ||
| 55 | /** | ||
| 56 | * A bounded, client-safe reason for a failed hub read. The exact failure | ||
| 57 | * (which can carry the request URL and response body) goes to the log. | ||
| 58 | */ | ||
| 59 | function readFailureMessage( | ||
| 60 | error: HttpClientError.HttpClientError | Schema.SchemaError | Cause.TimeoutError | InvalidUrl, | ||
| 61 | ): string { | ||
| 62 | switch (error._tag) { | ||
| 63 | case "InvalidUrl": | ||
| 64 | return "The hub URL is not valid."; | ||
| 65 | case "TimeoutError": | ||
| 66 | return "The hub did not answer in time."; | ||
| 67 | case "SchemaError": | ||
| 68 | return "The hub answered with an unexpected shape."; | ||
| 69 | case "HttpClientError": | ||
| 70 | return error.reason._tag === "StatusCodeError" | ||
| 71 | ? `The hub refused the request (HTTP ${error.reason.response.status}).` | ||
| 72 | : "The hub could not be reached."; | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | class InvalidUrl extends Data.TaggedError("InvalidUrl")<{ | ||
| 77 | readonly url: string; | ||
| 78 | readonly cause: unknown; | ||
| 79 | }> {} | ||
| 80 | |||
| 81 | function sourceLabel(id: string, config: UsageLimitSourceConfig): string { | 54 | function sourceLabel(id: string, config: UsageLimitSourceConfig): string { |
| 82 | if (config.label) return config.label; | 55 | if (config.label) return config.label; |
| 83 | try { | 56 | try { |
| ... | @@ -88,7 +61,7 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { | ... | @@ -88,7 +61,7 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { |
| 88 | } | 61 | } |
| 89 | 62 | ||
| 90 | export const make = Effect.gen(function* () { | 63 | export const make = Effect.gen(function* () { |
| 91 | const httpClient = yield* HttpClient.HttpClient; | 64 | const api = yield* makeCliproxyApi; |
| 92 | const settingsService = yield* ServerSettingsService; | 65 | const settingsService = yield* ServerSettingsService; |
| 93 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; | 66 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; |
| 94 | const stateRef = yield* Ref.make<ReadonlyArray<UsageLimitSourceSnapshot>>([]); | 67 | const stateRef = yield* Ref.make<ReadonlyArray<UsageLimitSourceSnapshot>>([]); |
| ... | @@ -106,23 +79,10 @@ export const make = Effect.gen(function* () { | ... | @@ -106,23 +79,10 @@ export const make = Effect.gen(function* () { |
| 106 | if (config.managementKey.length === 0) { | 79 | if (config.managementKey.length === 0) { |
| 107 | return { ...base, accounts: [], error: "No management key configured." }; | 80 | return { ...base, accounts: [], error: "No management key configured." }; |
| 108 | } | 81 | } |
| 109 | const accounts = yield* Effect.try({ | 82 | const accounts = yield* api.readAccounts(config).pipe(Effect.result); |
| 110 | try: () => new URL(QUOTA_STATUS_PATH, config.url).toString(), | ||
| 111 | catch: (cause) => new InvalidUrl({ url: config.url, cause }), | ||
| 112 | }).pipe( | ||
| 113 | Effect.flatMap((url) => | ||
| 114 | httpClient.get(url, { headers: { Authorization: `Bearer ${config.managementKey}` } }), | ||
| 115 | ), | ||
| 116 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 117 | Effect.flatMap((response) => response.json), | ||
| 118 | Effect.flatMap(decodeCliproxyQuotaStatus), | ||
| 119 | Effect.map((status) => cliproxyStatusToAccounts(status, checkedAt)), | ||
| 120 | Effect.timeout(FETCH_TIMEOUT), | ||
| 121 | Effect.result, | ||
| 122 | ); | ||
| 123 | if (accounts._tag === "Failure") { | 83 | if (accounts._tag === "Failure") { |
| 124 | yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); | 84 | yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); |
| 125 | return { ...base, accounts: [], error: readFailureMessage(accounts.failure) }; | 85 | return { ...base, accounts: [], error: accounts.failure.detail }; |
| 126 | } | 86 | } |
| 127 | return { ...base, accounts: accounts.success }; | 87 | return { ...base, accounts: accounts.success }; |
| 128 | }); | 88 | }); |
| ... | @@ -154,6 +114,27 @@ export const make = Effect.gen(function* () { | ... | @@ -154,6 +114,27 @@ export const make = Effect.gen(function* () { |
| 154 | yield* publish(snapshots); | 114 | yield* publish(snapshots); |
| 155 | }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); | 115 | }).pipe(refreshLock.withPermits(1), Effect.ignoreCause({ log: true })); |
| 156 | 116 | ||
| 117 | // Shares the refresh lock so a stale in-flight read cannot overwrite a redemption. | ||
| 118 | const consumeResetCredit = (input: UsageLimitSourceConsumeResetCreditInput) => | ||
| 119 | Effect.gen(function* () { | ||
| 120 | const settings = yield* settingsService.getSettings.pipe( | ||
| 121 | Effect.mapError( | ||
| 122 | () => new UsageLimitSourceError({ detail: "Could not read hub settings." }), | ||
| 123 | ), | ||
| 124 | ); | ||
| 125 | const config = settings.usageLimitSources[input.sourceId]; | ||
| 126 | if (!config?.enabled || !config.managementKey) { | ||
| 127 | return yield* new UsageLimitSourceError({ | ||
| 128 | detail: "The usage limit source is missing or disabled.", | ||
| 129 | }); | ||
| 130 | } | ||
| 131 | const result = yield* api.consume(config, input.accountId, input.creditId); | ||
| 132 | const snapshot = yield* readSource(input.sourceId, config); | ||
| 133 | const previous = yield* Ref.get(stateRef); | ||
| 134 | yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source))); | ||
| 135 | return result; | ||
| 136 | }).pipe(refreshLock.withPermits(1)); | ||
| 137 | |||
| 157 | // Settings edits re-read straight away so a new hub shows up without | 138 | // Settings edits re-read straight away so a new hub shows up without |
| 158 | // waiting for the interval, and a removed one leaves the list. | 139 | // waiting for the interval, and a removed one leaves the list. |
| 159 | yield* settingsService.streamChanges.pipe( | 140 | yield* settingsService.streamChanges.pipe( |
| ... | @@ -184,6 +165,7 @@ export const make = Effect.gen(function* () { | ... | @@ -184,6 +165,7 @@ export const make = Effect.gen(function* () { |
| 184 | 165 | ||
| 185 | return { | 166 | return { |
| 186 | current: Ref.get(stateRef), | 167 | current: Ref.get(stateRef), |
| 168 | consumeResetCredit, | ||
| 187 | refresh, | 169 | refresh, |
| 188 | get streamChanges() { | 170 | get streamChanges() { |
| 189 | return Stream.unwrap( | 171 | return Stream.unwrap( |
apps/server/src/usage/cliproxyApi.test.ts created+310| ... | @@ -0,0 +1,310 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | import * as TestClock from "effect/testing/TestClock"; | ||
| 5 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 6 | |||
| 7 | import { creditRedeemRequestId, makeCliproxyApi } from "./cliproxyApi.ts"; | ||
| 8 | |||
| 9 | const config = { | ||
| 10 | kind: "cliproxy", | ||
| 11 | url: "http://hub.test:8317", | ||
| 12 | managementKey: "management-secret", | ||
| 13 | enabled: true, | ||
| 14 | } as const; | ||
| 15 | const accounts = [ | ||
| 16 | { | ||
| 17 | id: "first.json", | ||
| 18 | auth_index: "a", | ||
| 19 | provider: "codex", | ||
| 20 | email: "first@example.com", | ||
| 21 | id_token: { chatgpt_account_id: "account-a" }, | ||
| 22 | }, | ||
| 23 | { | ||
| 24 | id: "second.json", | ||
| 25 | auth_index: "b", | ||
| 26 | provider: "codex", | ||
| 27 | email: "second@example.com", | ||
| 28 | id_token: { chatgpt_account_id: "account-b" }, | ||
| 29 | }, | ||
| 30 | ]; | ||
| 31 | const credit = (id: string, expires_at = "2099-01-01T00:00:00Z") => ({ | ||
| 32 | id, | ||
| 33 | expires_at, | ||
| 34 | status: "available", | ||
| 35 | reset_type: "codex_rate_limits", | ||
| 36 | }); | ||
| 37 | const RequestBody = Schema.Struct({ | ||
| 38 | auth_index: Schema.String, | ||
| 39 | method: Schema.optional(Schema.String), | ||
| 40 | url: Schema.optional(Schema.String), | ||
| 41 | header: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 42 | data: Schema.optional(Schema.String), | ||
| 43 | }); | ||
| 44 | type RequestBody = typeof RequestBody.Type; | ||
| 45 | const decodeRequest = Schema.decodeUnknownSync(Schema.fromJsonString(RequestBody)); | ||
| 46 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 47 | |||
| 48 | function fixture( | ||
| 49 | options: { | ||
| 50 | accounts?: Array<(typeof accounts)[number] & { disabled?: boolean }>; | ||
| 51 | upstream?: (request: RequestBody) => { status: number; body: unknown }; | ||
| 52 | cooldownStatus?: number; | ||
| 53 | } = {}, | ||
| 54 | ) { | ||
| 55 | const requests: Array<{ path: string; body?: RequestBody }> = []; | ||
| 56 | const http = HttpClient.make((request) => | ||
| 57 | Effect.sync(() => { | ||
| 58 | expect(request.headers.authorization).toBe("Bearer management-secret"); | ||
| 59 | const path = new URL(request.url).pathname; | ||
| 60 | const body = | ||
| 61 | request.body._tag === "Uint8Array" | ||
| 62 | ? decodeRequest(new TextDecoder().decode(request.body.body)) | ||
| 63 | : undefined; | ||
| 64 | requests.push({ path, ...(body ? { body } : {}) }); | ||
| 65 | if (path.endsWith("/auth-files")) | ||
| 66 | return HttpClientResponse.fromWeb( | ||
| 67 | request, | ||
| 68 | Response.json({ files: options.accounts ?? accounts }), | ||
| 69 | ); | ||
| 70 | if (path.endsWith("/reset-quota")) | ||
| 71 | return HttpClientResponse.fromWeb( | ||
| 72 | request, | ||
| 73 | Response.json({}, { status: options.cooldownStatus ?? 200 }), | ||
| 74 | ); | ||
| 75 | expect(path).toBe("/v0/management/api-call"); | ||
| 76 | expect(body?.header?.Authorization).toBe("Bearer $TOKEN$"); | ||
| 77 | const upstream = options.upstream?.(body!) ?? { | ||
| 78 | status: 200, | ||
| 79 | body: body?.url?.endsWith("/consume") | ||
| 80 | ? { code: "reset" } | ||
| 81 | : body?.url?.endsWith("/rate-limit-reset-credits") | ||
| 82 | ? { | ||
| 83 | credits: [ | ||
| 84 | credit("later", "2099-02-01T00:00:00Z"), | ||
| 85 | credit("first"), | ||
| 86 | credit("expired", "2000-01-01T00:00:00Z"), | ||
| 87 | { ...credit("used"), status: "redeemed" }, | ||
| 88 | ], | ||
| 89 | } | ||
| 90 | : { | ||
| 91 | plan_type: "pro", | ||
| 92 | rate_limit: { | ||
| 93 | secondary_window: { | ||
| 94 | used_percent: 78, | ||
| 95 | reset_at: 4070908800, | ||
| 96 | limit_window_seconds: 604800, | ||
| 97 | }, | ||
| 98 | }, | ||
| 99 | }, | ||
| 100 | }; | ||
| 101 | return HttpClientResponse.fromWeb( | ||
| 102 | request, | ||
| 103 | Response.json({ status_code: upstream.status, body: encodeJson(upstream.body) }), | ||
| 104 | ); | ||
| 105 | }), | ||
| 106 | ); | ||
| 107 | return { | ||
| 108 | requests, | ||
| 109 | api: makeCliproxyApi.pipe(Effect.provideService(HttpClient.HttpClient, http)), | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | describe("CLIProxyAPI built-in management API", () => { | ||
| 114 | it.effect( | ||
| 115 | "reads both accounts and their earliest unexpired credits without plugin endpoints", | ||
| 116 | () => | ||
| 117 | Effect.gen(function* () { | ||
| 118 | yield* TestClock.setTime(1788710400000); | ||
| 119 | const test = fixture(); | ||
| 120 | const api = yield* test.api; | ||
| 121 | const result = yield* api.readAccounts(config); | ||
| 122 | expect(result.map((account) => account.usageLimits.resetCredits)).toEqual([ | ||
| 123 | { availableCount: 2, nextCreditId: "first", nextExpiresAt: "2099-01-01T00:00:00.000Z" }, | ||
| 124 | { availableCount: 2, nextCreditId: "first", nextExpiresAt: "2099-01-01T00:00:00.000Z" }, | ||
| 125 | ]); | ||
| 126 | expect(result[0]?.usageLimits.windows).toMatchObject([ | ||
| 127 | { id: "secondary", usedPercent: 78, kind: "weekly" }, | ||
| 128 | ]); | ||
| 129 | const calls = test.requests.filter((request) => request.body?.url); | ||
| 130 | expect(calls.map((request) => request.body?.auth_index).sort()).toEqual([ | ||
| 131 | "a", | ||
| 132 | "a", | ||
| 133 | "b", | ||
| 134 | "b", | ||
| 135 | ]); | ||
| 136 | expect( | ||
| 137 | calls.find((request) => request.body?.auth_index === "b")?.body?.header?.[ | ||
| 138 | "Chatgpt-Account-Id" | ||
| 139 | ], | ||
| 140 | ).toBe("account-b"); | ||
| 141 | }), | ||
| 142 | ); | ||
| 143 | |||
| 144 | it.effect("keeps usage when the credits endpoint fails", () => | ||
| 145 | Effect.gen(function* () { | ||
| 146 | const test = fixture({ | ||
| 147 | upstream: (request) => | ||
| 148 | request.url?.endsWith("rate-limit-reset-credits") | ||
| 149 | ? { status: 503, body: { token: "do-not-publish" } } | ||
| 150 | : { status: 200, body: { rate_limit: { primary_window: { used_percent: 12 } } } }, | ||
| 151 | }); | ||
| 152 | const api = yield* test.api; | ||
| 153 | const result = yield* api.readAccounts(config); | ||
| 154 | expect(result[0]?.usageLimits.windows[0]?.usedPercent).toBe(12); | ||
| 155 | expect(result[0]?.usageLimits.resetCredits).toBeUndefined(); | ||
| 156 | }), | ||
| 157 | ); | ||
| 158 | |||
| 159 | it.effect("isolates a failed account and never publishes upstream error bodies", () => | ||
| 160 | Effect.gen(function* () { | ||
| 161 | const test = fixture({ | ||
| 162 | upstream: (request) => | ||
| 163 | request.auth_index === "a" | ||
| 164 | ? { status: 401, body: { token: "do-not-publish" } } | ||
| 165 | : { | ||
| 166 | status: 200, | ||
| 167 | body: request.url?.endsWith("rate-limit-reset-credits") | ||
| 168 | ? { credits: [] } | ||
| 169 | : { rate_limit: { primary_window: { used_percent: 12 } } }, | ||
| 170 | }, | ||
| 171 | }); | ||
| 172 | const api = yield* test.api; | ||
| 173 | const result = yield* api.readAccounts(config); | ||
| 174 | expect(result[0]?.usageLimits.unavailable?.reason).toBe("probeFailed"); | ||
| 175 | expect(result[1]?.usageLimits.windows[0]?.usedPercent).toBe(12); | ||
| 176 | expect(encodeJson(result)).not.toContain("do-not-publish"); | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | |||
| 180 | it.effect("maps Claude scoped windows without a scheduler plugin", () => | ||
| 181 | Effect.gen(function* () { | ||
| 182 | const test = fixture({ | ||
| 183 | accounts: [{ ...accounts[0]!, provider: "claude" }], | ||
| 184 | upstream: () => ({ | ||
| 185 | status: 200, | ||
| 186 | body: { | ||
| 187 | five_hour: { utilization: 10, resets_at: null }, | ||
| 188 | seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" }, | ||
| 189 | limits: [ | ||
| 190 | { | ||
| 191 | kind: "weekly_scoped", | ||
| 192 | percent: 80, | ||
| 193 | resets_at: null, | ||
| 194 | scope: { model: { display_name: "Fable" } }, | ||
| 195 | }, | ||
| 196 | ], | ||
| 197 | }, | ||
| 198 | }), | ||
| 199 | }); | ||
| 200 | const api = yield* test.api; | ||
| 201 | const result = yield* api.readAccounts(config); | ||
| 202 | expect( | ||
| 203 | result[0]?.usageLimits.windows.map((window) => [window.id, window.usedPercent]), | ||
| 204 | ).toEqual([ | ||
| 205 | ["five_hour", 10], | ||
| 206 | ["seven_day", 50], | ||
| 207 | ["seven_day_fable", 80], | ||
| 208 | ]); | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | |||
| 212 | it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () => | ||
| 213 | Effect.gen(function* () { | ||
| 214 | const test = fixture(); | ||
| 215 | const api = yield* test.api; | ||
| 216 | expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome: "reset" }); | ||
| 217 | expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome: "reset" }); | ||
| 218 | const redemptions = test.requests.filter((request) => | ||
| 219 | request.body?.url?.endsWith("/consume"), | ||
| 220 | ); | ||
| 221 | expect(redemptions).toHaveLength(2); | ||
| 222 | expect(redemptions[0]?.body?.data).toBe(redemptions[1]?.body?.data); | ||
| 223 | expect(redemptions[0]?.body?.data).toBe( | ||
| 224 | encodeJson({ | ||
| 225 | redeem_request_id: creditRedeemRequestId("account-b", "credit-b"), | ||
| 226 | credit_id: "credit-b", | ||
| 227 | }), | ||
| 228 | ); | ||
| 229 | expect( | ||
| 230 | test.requests | ||
| 231 | .filter((request) => request.path.endsWith("/reset-quota")) | ||
| 232 | .map((request) => request.body?.auth_index), | ||
| 233 | ).toEqual(["b", "b"]); | ||
| 234 | }), | ||
| 235 | ); | ||
| 236 | |||
| 237 | for (const [code, outcome] of [ | ||
| 238 | ["nothing_to_reset", "nothingToReset"], | ||
| 239 | ["no_credit", "noCredit"], | ||
| 240 | ["already_redeemed", "alreadyRedeemed"], | ||
| 241 | ] as const) { | ||
| 242 | it.effect(`reports ${code} accurately`, () => | ||
| 243 | Effect.gen(function* () { | ||
| 244 | const test = fixture({ upstream: () => ({ status: 200, body: { code } }) }); | ||
| 245 | const api = yield* test.api; | ||
| 246 | expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome }); | ||
| 247 | expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe( | ||
| 248 | code === "already_redeemed", | ||
| 249 | ); | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | } | ||
| 253 | |||
| 254 | it.effect("reports redemption success even if cooldown clearing fails", () => | ||
| 255 | Effect.gen(function* () { | ||
| 256 | const api = yield* fixture({ cooldownStatus: 404 }).api; | ||
| 257 | const result = yield* api.consume(config, "first.json", "credit"); | ||
| 258 | expect(result.outcome).toBe("reset"); | ||
| 259 | expect(result.warning).toContain("cooldown"); | ||
| 260 | }), | ||
| 261 | ); | ||
| 262 | |||
| 263 | it.effect("skips disabled accounts and rejects redemption on them", () => | ||
| 264 | Effect.gen(function* () { | ||
| 265 | const test = fixture({ accounts: [{ ...accounts[0]!, disabled: true }] }); | ||
| 266 | const api = yield* test.api; | ||
| 267 | expect(yield* api.readAccounts(config)).toEqual([]); | ||
| 268 | expect((yield* api.consume(config, "first.json", "credit").pipe(Effect.result))._tag).toBe( | ||
| 269 | "Failure", | ||
| 270 | ); | ||
| 271 | expect(test.requests.every((request) => request.path.endsWith("/auth-files"))).toBe(true); | ||
| 272 | }), | ||
| 273 | ); | ||
| 274 | |||
| 275 | it.effect("keeps the same redemption id after an uncertain upstream failure", () => | ||
| 276 | Effect.gen(function* () { | ||
| 277 | let attempts = 0; | ||
| 278 | const test = fixture({ | ||
| 279 | upstream: () => | ||
| 280 | ++attempts === 1 | ||
| 281 | ? { status: 503, body: {} } | ||
| 282 | : { status: 200, body: { code: "already_redeemed" } }, | ||
| 283 | }); | ||
| 284 | const api = yield* test.api; | ||
| 285 | expect((yield* api.consume(config, "first.json", "credit").pipe(Effect.result))._tag).toBe( | ||
| 286 | "Failure", | ||
| 287 | ); | ||
| 288 | expect(yield* api.consume(config, "first.json", "credit")).toEqual({ | ||
| 289 | outcome: "alreadyRedeemed", | ||
| 290 | }); | ||
| 291 | const data = test.requests | ||
| 292 | .filter((request) => request.body?.url?.endsWith("/consume")) | ||
| 293 | .map((request) => request.body?.data); | ||
| 294 | expect(data[0]).toBe(data[1]); | ||
| 295 | expect(test.requests.filter((request) => request.path.endsWith("/reset-quota"))).toHaveLength( | ||
| 296 | 1, | ||
| 297 | ); | ||
| 298 | }), | ||
| 299 | ); | ||
| 300 | |||
| 301 | it.effect("rejects unknown accounts without forwarding a redemption", () => | ||
| 302 | Effect.gen(function* () { | ||
| 303 | const test = fixture(); | ||
| 304 | const api = yield* test.api; | ||
| 305 | const result = yield* api.consume(config, "missing.json", "credit").pipe(Effect.result); | ||
| 306 | expect(result._tag).toBe("Failure"); | ||
| 307 | expect(test.requests).toHaveLength(1); | ||
| 308 | }), | ||
| 309 | ); | ||
| 310 | }); | ||
apps/server/src/usage/cliproxyApi.ts created+366| ... | @@ -0,0 +1,366 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | ProviderDriverKind, | ||
| 5 | UsageLimitSourceError, | ||
| 6 | type ProviderConsumeResetCreditResult, | ||
| 7 | type UsageLimitSourceAccount, | ||
| 8 | type UsageLimitSourceConfig, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as DateTime from "effect/DateTime"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 14 | |||
| 15 | import { codexPlanLabel } from "../provider/Layers/CodexProvider.ts"; | ||
| 16 | import { codexRateLimitsToLimits } from "../provider/Layers/codexUsageLimits.ts"; | ||
| 17 | import { claudeUsageResponseToLimits } from "../provider/Layers/claudeUsageLimits.ts"; | ||
| 18 | import { makeUnavailableUsageLimits } from "../provider/providerUsageLimits.ts"; | ||
| 19 | |||
| 20 | const AuthFile = Schema.Struct({ | ||
| 21 | id: Schema.String, | ||
| 22 | auth_index: Schema.String, | ||
| 23 | provider: Schema.String, | ||
| 24 | email: Schema.optional(Schema.String), | ||
| 25 | disabled: Schema.optional(Schema.Boolean), | ||
| 26 | id_token: Schema.optional( | ||
| 27 | Schema.Struct({ | ||
| 28 | chatgpt_account_id: Schema.optional(Schema.String), | ||
| 29 | chatgpt_plan_type: Schema.optional(Schema.String), | ||
| 30 | }), | ||
| 31 | ), | ||
| 32 | }); | ||
| 33 | const AuthFiles = Schema.Struct({ files: Schema.Array(AuthFile) }); | ||
| 34 | const ApiResponse = Schema.Struct({ status_code: Schema.Number, body: Schema.String }); | ||
| 35 | const CodexWindow = Schema.Struct({ | ||
| 36 | used_percent: Schema.Number, | ||
| 37 | reset_at: Schema.optional(Schema.NullOr(Schema.Number)), | ||
| 38 | limit_window_seconds: Schema.optional(Schema.Number), | ||
| 39 | }); | ||
| 40 | const CodexUsage = Schema.Struct({ | ||
| 41 | plan_type: Schema.optional(Schema.String), | ||
| 42 | rate_limit: Schema.NullOr( | ||
| 43 | Schema.Struct({ | ||
| 44 | primary_window: Schema.optional(Schema.NullOr(CodexWindow)), | ||
| 45 | secondary_window: Schema.optional(Schema.NullOr(CodexWindow)), | ||
| 46 | }), | ||
| 47 | ), | ||
| 48 | }); | ||
| 49 | const ClaudeWindow = Schema.Struct({ | ||
| 50 | utilization: Schema.Number, | ||
| 51 | resets_at: Schema.NullOr(Schema.String), | ||
| 52 | }); | ||
| 53 | const ClaudeUsage = Schema.Struct({ | ||
| 54 | five_hour: Schema.optional(Schema.NullOr(ClaudeWindow)), | ||
| 55 | seven_day: Schema.optional(Schema.NullOr(ClaudeWindow)), | ||
| 56 | limits: Schema.optional( | ||
| 57 | Schema.Array( | ||
| 58 | Schema.Struct({ | ||
| 59 | kind: Schema.String, | ||
| 60 | percent: Schema.optional(Schema.NullOr(Schema.Number)), | ||
| 61 | resets_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 62 | scope: Schema.optional( | ||
| 63 | Schema.NullOr( | ||
| 64 | Schema.Struct({ | ||
| 65 | model: Schema.optional(Schema.NullOr(Schema.Struct({ display_name: Schema.String }))), | ||
| 66 | }), | ||
| 67 | ), | ||
| 68 | ), | ||
| 69 | }), | ||
| 70 | ), | ||
| 71 | ), | ||
| 72 | }); | ||
| 73 | const CreditList = Schema.Struct({ | ||
| 74 | credits: Schema.Array( | ||
| 75 | Schema.Struct({ | ||
| 76 | id: Schema.String, | ||
| 77 | status: Schema.String, | ||
| 78 | reset_type: Schema.String, | ||
| 79 | expires_at: Schema.String, | ||
| 80 | }), | ||
| 81 | ), | ||
| 82 | }); | ||
| 83 | |||
| 84 | const decodeAuthFiles = Schema.decodeUnknownEffect(AuthFiles); | ||
| 85 | const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 86 | const decodeApiResponse = Schema.decodeUnknownEffect(ApiResponse); | ||
| 87 | const decodeCreditList = Schema.decodeUnknownEffect(Schema.fromJsonString(CreditList)); | ||
| 88 | const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(ClaudeUsage)); | ||
| 89 | const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage)); | ||
| 90 | const isUsageLimitSourceError = Schema.is(UsageLimitSourceError); | ||
| 91 | const decodeConsumeResponse = Schema.decodeUnknownEffect( | ||
| 92 | Schema.fromJsonString( | ||
| 93 | Schema.Struct({ | ||
| 94 | code: Schema.Literals(["reset", "nothing_to_reset", "no_credit", "already_redeemed"]), | ||
| 95 | }), | ||
| 96 | ), | ||
| 97 | ); | ||
| 98 | |||
| 99 | const CODEX_BASE = "https://chatgpt.com/backend-api/wham"; | ||
| 100 | const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`; | ||
| 101 | |||
| 102 | // UUIDv5 per account and credit also deduplicates retries across T3 environments. | ||
| 103 | export function creditRedeemRequestId(accountId: string, creditId: string): string { | ||
| 104 | const bytes = NodeCrypto.createHash("sha1") | ||
| 105 | .update(Buffer.from("6f1c2a9e2d4b4c1e9a7f3b8d5e0c1a42", "hex")) | ||
| 106 | .update(`${accountId}:${creditId}`) | ||
| 107 | .digest() | ||
| 108 | .subarray(0, 16); | ||
| 109 | bytes[6] = (bytes[6]! & 0x0f) | 0x50; | ||
| 110 | bytes[8] = (bytes[8]! & 0x3f) | 0x80; | ||
| 111 | const hex = bytes.toString("hex"); | ||
| 112 | return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; | ||
| 113 | } | ||
| 114 | |||
| 115 | export const makeCliproxyApi = Effect.gen(function* () { | ||
| 116 | const client = yield* HttpClient.HttpClient; | ||
| 117 | |||
| 118 | const management = Effect.fn("CliproxyApi.management")(function* ( | ||
| 119 | config: UsageLimitSourceConfig, | ||
| 120 | path: string, | ||
| 121 | body?: unknown, | ||
| 122 | ) { | ||
| 123 | const url = yield* Effect.try({ | ||
| 124 | try: () => new URL(`/v0/management/${path}`, config.url).toString(), | ||
| 125 | catch: () => new UsageLimitSourceError({ detail: "The hub URL is not valid." }), | ||
| 126 | }); | ||
| 127 | const request = ( | ||
| 128 | body === undefined ? HttpClientRequest.get(url) : HttpClientRequest.post(url) | ||
| 129 | ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${config.managementKey}`)); | ||
| 130 | const response = yield* client | ||
| 131 | .execute(body === undefined ? request : request.pipe(HttpClientRequest.bodyJsonUnsafe(body))) | ||
| 132 | .pipe( | ||
| 133 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 134 | Effect.flatMap((response) => response.json), | ||
| 135 | Effect.timeout("15 seconds"), | ||
| 136 | Effect.mapError( | ||
| 137 | () => new UsageLimitSourceError({ detail: "The hub management request failed." }), | ||
| 138 | ), | ||
| 139 | ); | ||
| 140 | return response; | ||
| 141 | }); | ||
| 142 | |||
| 143 | const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (config: UsageLimitSourceConfig) { | ||
| 144 | const response = yield* management(config, "auth-files"); | ||
| 145 | return (yield* decodeAuthFiles(response)).files; | ||
| 146 | }); | ||
| 147 | |||
| 148 | const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( | ||
| 149 | config: UsageLimitSourceConfig, | ||
| 150 | account: typeof AuthFile.Type, | ||
| 151 | url: string, | ||
| 152 | data?: unknown, | ||
| 153 | ) { | ||
| 154 | const header = | ||
| 155 | account.provider === "codex" | ||
| 156 | ? { | ||
| 157 | Authorization: "Bearer $TOKEN$", | ||
| 158 | "Content-Type": "application/json", | ||
| 159 | "OpenAI-Beta": "codex-1", | ||
| 160 | Originator: "Codex Desktop", | ||
| 161 | ...(account.id_token?.chatgpt_account_id | ||
| 162 | ? { "Chatgpt-Account-Id": account.id_token.chatgpt_account_id } | ||
| 163 | : {}), | ||
| 164 | } | ||
| 165 | : { Authorization: "Bearer $TOKEN$", "anthropic-beta": "oauth-2025-04-20" }; | ||
| 166 | const raw = yield* management(config, "api-call", { | ||
| 167 | auth_index: account.auth_index, | ||
| 168 | method: data === undefined ? "GET" : "POST", | ||
| 169 | url, | ||
| 170 | header, | ||
| 171 | ...(data === undefined ? {} : { data: yield* encodeJson(data) }), | ||
| 172 | }); | ||
| 173 | const response = yield* decodeApiResponse(raw); | ||
| 174 | if (response.status_code < 200 || response.status_code >= 300) { | ||
| 175 | return yield* new UsageLimitSourceError({ | ||
| 176 | detail: `The provider refused the hub request (HTTP ${response.status_code}).`, | ||
| 177 | }); | ||
| 178 | } | ||
| 179 | return response.body; | ||
| 180 | }); | ||
| 181 | |||
| 182 | const credits = Effect.fn("CliproxyApi.credits")(function* ( | ||
| 183 | config: UsageLimitSourceConfig, | ||
| 184 | account: typeof AuthFile.Type, | ||
| 185 | ) { | ||
| 186 | const body = yield* apiCall(config, account, CREDIT_URL); | ||
| 187 | const response = yield* decodeCreditList(body); | ||
| 188 | const now = DateTime.toEpochMillis(yield* DateTime.now); | ||
| 189 | return response.credits | ||
| 190 | .filter( | ||
| 191 | (credit) => | ||
| 192 | credit.reset_type === "codex_rate_limits" && | ||
| 193 | credit.status === "available" && | ||
| 194 | Date.parse(credit.expires_at) > now, | ||
| 195 | ) | ||
| 196 | .toSorted((a, b) => Date.parse(a.expires_at) - Date.parse(b.expires_at)); | ||
| 197 | }); | ||
| 198 | |||
| 199 | const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( | ||
| 200 | config: UsageLimitSourceConfig, | ||
| 201 | account: typeof AuthFile.Type, | ||
| 202 | ) { | ||
| 203 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 204 | const base = { | ||
| 205 | id: account.id, | ||
| 206 | driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), | ||
| 207 | ...(account.email ? { email: account.email } : {}), | ||
| 208 | }; | ||
| 209 | const read = Effect.gen(function* () { | ||
| 210 | if (account.provider === "claude") { | ||
| 211 | const body = yield* apiCall(config, account, "https://api.anthropic.com/api/oauth/usage"); | ||
| 212 | const usage = yield* decodeClaudeUsage(body); | ||
| 213 | const model_scoped = (usage.limits ?? []).flatMap((limit) => | ||
| 214 | limit.kind === "weekly_scoped" && limit.scope?.model && typeof limit.percent === "number" | ||
| 215 | ? [ | ||
| 216 | { | ||
| 217 | display_name: limit.scope.model.display_name, | ||
| 218 | utilization: limit.percent, | ||
| 219 | resets_at: limit.resets_at ?? null, | ||
| 220 | }, | ||
| 221 | ] | ||
| 222 | : [], | ||
| 223 | ); | ||
| 224 | return { | ||
| 225 | ...base, | ||
| 226 | plan: "Claude Subscription", | ||
| 227 | usageLimits: claudeUsageResponseToLimits({ | ||
| 228 | checkedAt, | ||
| 229 | response: { | ||
| 230 | rate_limits_available: true, | ||
| 231 | rate_limits: { | ||
| 232 | five_hour: usage.five_hour ?? null, | ||
| 233 | seven_day: usage.seven_day ?? null, | ||
| 234 | model_scoped, | ||
| 235 | }, | ||
| 236 | }, | ||
| 237 | }).limits, | ||
| 238 | }; | ||
| 239 | } | ||
| 240 | const body = yield* apiCall(config, account, `${CODEX_BASE}/usage`); | ||
| 241 | const usage = yield* decodeCodexUsage(body); | ||
| 242 | const toWindow = (window: typeof CodexWindow.Type | null | undefined) => | ||
| 243 | window | ||
| 244 | ? { | ||
| 245 | usedPercent: window.used_percent, | ||
| 246 | resetsAt: window.reset_at ?? null, | ||
| 247 | ...(window.limit_window_seconds === undefined | ||
| 248 | ? {} | ||
| 249 | : { windowDurationMins: window.limit_window_seconds / 60 }), | ||
| 250 | } | ||
| 251 | : null; | ||
| 252 | // A credits outage must not hide successfully fetched quota windows. | ||
| 253 | const available = yield* credits(config, account).pipe(Effect.orElseSucceed(() => undefined)); | ||
| 254 | const next = available?.[0]; | ||
| 255 | return { | ||
| 256 | ...base, | ||
| 257 | plan: codexPlanLabel(usage.plan_type ?? account.id_token?.chatgpt_plan_type), | ||
| 258 | usageLimits: { | ||
| 259 | ...codexRateLimitsToLimits({ | ||
| 260 | checkedAt, | ||
| 261 | snapshot: { | ||
| 262 | planType: usage.plan_type ?? null, | ||
| 263 | primary: toWindow(usage.rate_limit?.primary_window), | ||
| 264 | secondary: toWindow(usage.rate_limit?.secondary_window), | ||
| 265 | }, | ||
| 266 | }), | ||
| 267 | ...(available | ||
| 268 | ? { | ||
| 269 | resetCredits: { | ||
| 270 | availableCount: available.length, | ||
| 271 | ...(next | ||
| 272 | ? { | ||
| 273 | nextCreditId: next.id, | ||
| 274 | nextExpiresAt: DateTime.formatIso(DateTime.makeUnsafe(next.expires_at)), | ||
| 275 | } | ||
| 276 | : {}), | ||
| 277 | }, | ||
| 278 | } | ||
| 279 | : {}), | ||
| 280 | }, | ||
| 281 | }; | ||
| 282 | }); | ||
| 283 | return yield* read.pipe( | ||
| 284 | Effect.orElseSucceed(() => ({ | ||
| 285 | ...base, | ||
| 286 | usageLimits: makeUnavailableUsageLimits({ | ||
| 287 | checkedAt, | ||
| 288 | reason: "probeFailed", | ||
| 289 | message: "The hub could not read this account's usage.", | ||
| 290 | }), | ||
| 291 | })), | ||
| 292 | ); | ||
| 293 | }); | ||
| 294 | |||
| 295 | const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( | ||
| 296 | config: UsageLimitSourceConfig, | ||
| 297 | ): Effect.fn.Return<ReadonlyArray<UsageLimitSourceAccount>, UsageLimitSourceError> { | ||
| 298 | const accounts = yield* authFiles(config).pipe( | ||
| 299 | Effect.mapError( | ||
| 300 | () => new UsageLimitSourceError({ detail: "The hub could not list accounts." }), | ||
| 301 | ), | ||
| 302 | ); | ||
| 303 | return yield* Effect.forEach( | ||
| 304 | accounts.filter( | ||
| 305 | (account) => | ||
| 306 | !account.disabled && (account.provider === "codex" || account.provider === "claude"), | ||
| 307 | ), | ||
| 308 | (account) => readAccount(config, account), | ||
| 309 | { concurrency: 4 }, | ||
| 310 | ); | ||
| 311 | }); | ||
| 312 | |||
| 313 | const consume = Effect.fn("CliproxyApi.consume")(function* ( | ||
| 314 | config: UsageLimitSourceConfig, | ||
| 315 | accountId: string, | ||
| 316 | creditId: string, | ||
| 317 | ): Effect.fn.Return<ProviderConsumeResetCreditResult, UsageLimitSourceError> { | ||
| 318 | const operation = Effect.gen(function* () { | ||
| 319 | const account = (yield* authFiles(config)).find((account) => account.id === accountId); | ||
| 320 | if (!account || account.disabled || account.provider !== "codex") { | ||
| 321 | return yield* new UsageLimitSourceError({ | ||
| 322 | detail: "The Codex hub account is missing or disabled.", | ||
| 323 | }); | ||
| 324 | } | ||
| 325 | const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, { | ||
| 326 | redeem_request_id: creditRedeemRequestId( | ||
| 327 | account.id_token?.chatgpt_account_id ?? account.id, | ||
| 328 | creditId, | ||
| 329 | ), | ||
| 330 | credit_id: creditId, | ||
| 331 | }); | ||
| 332 | const response = yield* decodeConsumeResponse(body); | ||
| 333 | const outcome = ( | ||
| 334 | { | ||
| 335 | reset: "reset", | ||
| 336 | nothing_to_reset: "nothingToReset", | ||
| 337 | no_credit: "noCredit", | ||
| 338 | already_redeemed: "alreadyRedeemed", | ||
| 339 | } as const | ||
| 340 | )[response.code]; | ||
| 341 | if (outcome !== "reset" && outcome !== "alreadyRedeemed") return { outcome }; | ||
| 342 | const cleared = yield* management(config, "reset-quota", { | ||
| 343 | auth_index: account.auth_index, | ||
| 344 | }).pipe(Effect.result); | ||
| 345 | return { | ||
| 346 | outcome, | ||
| 347 | ...(cleared._tag === "Failure" | ||
| 348 | ? { | ||
| 349 | warning: | ||
| 350 | "Credit redeemed, but the hub cooldown could not be cleared. Routing may resume after its cooldown expires.", | ||
| 351 | } | ||
| 352 | : {}), | ||
| 353 | } as const; | ||
| 354 | }); | ||
| 355 | return yield* operation.pipe( | ||
| 356 | Effect.mapError((error) => | ||
| 357 | isUsageLimitSourceError(error) | ||
| 358 | ? error | ||
| 359 | : new UsageLimitSourceError({ | ||
| 360 | detail: "The hub returned an unexpected reset-credit response.", | ||
| 361 | }), | ||
| 362 | ), | ||
| 363 | ); | ||
| 364 | }); | ||
| 365 | return { readAccounts, consume }; | ||
| 366 | }); | ||
apps/server/src/usage/cliproxyUsageLimits.test.ts deleted-134| ... | @@ -1,134 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { accountEmailFromAuthFile, cliproxyStatusToAccounts } from "./cliproxyUsageLimits.ts"; | ||
| 4 | |||
| 5 | const checkedAt = "2026-09-03T22:00:00.000Z"; | ||
| 6 | |||
| 7 | describe("cliproxyStatusToAccounts", () => { | ||
| 8 | // Trimmed from a live `quota-scheduler/status`: one Claude account with a | ||
| 9 | // hard-limited Fable bucket, one Codex account whose 5h window is unknown. | ||
| 10 | it("maps each pooled account onto the windows the provider drivers use", () => { | ||
| 11 | const accounts = cliproxyStatusToAccounts( | ||
| 12 | { | ||
| 13 | accounts: { | ||
| 14 | "claude-jmarminge@gmail.com.json": { | ||
| 15 | provider: "claude", | ||
| 16 | fetched_at: "2026-09-03T15:06:07-07:00", | ||
| 17 | five_hour: { hard_limited: false, known: true, used_percent: 0 }, | ||
| 18 | seven_day: { | ||
| 19 | hard_limited: false, | ||
| 20 | known: true, | ||
| 21 | reset_at: "2026-09-07T07:59:59Z", | ||
| 22 | used_percent: 51, | ||
| 23 | }, | ||
| 24 | fable: { | ||
| 25 | hard_limited: true, | ||
| 26 | known: true, | ||
| 27 | reset_at: "2026-09-07T07:59:59Z", | ||
| 28 | used_percent: 100, | ||
| 29 | }, | ||
| 30 | }, | ||
| 31 | "codex-7f42123a-jmarminge@gmail.com-pro.json": { | ||
| 32 | provider: "codex", | ||
| 33 | plan: "pro", | ||
| 34 | fetched_at: "2026-09-03T15:07:07-07:00", | ||
| 35 | five_hour: { hard_limited: false, known: false, used_percent: 0 }, | ||
| 36 | weekly: { | ||
| 37 | hard_limited: false, | ||
| 38 | known: true, | ||
| 39 | reset_at: "2026-09-06T19:52:53-07:00", | ||
| 40 | used_percent: 12, | ||
| 41 | }, | ||
| 42 | }, | ||
| 43 | "xai-someone@example.com.json": { provider: "xai" }, | ||
| 44 | }, | ||
| 45 | }, | ||
| 46 | checkedAt, | ||
| 47 | ); | ||
| 48 | |||
| 49 | expect(accounts).toEqual([ | ||
| 50 | { | ||
| 51 | id: "claude-jmarminge@gmail.com.json", | ||
| 52 | driver: "claudeAgent", | ||
| 53 | email: "jmarminge@gmail.com", | ||
| 54 | plan: "Claude Subscription", | ||
| 55 | usageLimits: { | ||
| 56 | checkedAt: "2026-09-03T22:06:07.000Z", | ||
| 57 | windows: [ | ||
| 58 | { | ||
| 59 | id: "five_hour", | ||
| 60 | kind: "session", | ||
| 61 | label: "Session", | ||
| 62 | usedPercent: 0, | ||
| 63 | windowDurationMins: 300, | ||
| 64 | }, | ||
| 65 | { | ||
| 66 | id: "seven_day", | ||
| 67 | kind: "weekly", | ||
| 68 | label: "Weekly", | ||
| 69 | usedPercent: 51, | ||
| 70 | windowDurationMins: 10080, | ||
| 71 | resetsAt: "2026-09-07T07:59:59.000Z", | ||
| 72 | }, | ||
| 73 | { | ||
| 74 | id: "seven_day_fable", | ||
| 75 | kind: "weekly", | ||
| 76 | label: "Weekly · Fable", | ||
| 77 | usedPercent: 100, | ||
| 78 | windowDurationMins: 10080, | ||
| 79 | resetsAt: "2026-09-07T07:59:59.000Z", | ||
| 80 | }, | ||
| 81 | ], | ||
| 82 | }, | ||
| 83 | }, | ||
| 84 | { | ||
| 85 | id: "codex-7f42123a-jmarminge@gmail.com-pro.json", | ||
| 86 | driver: "codex", | ||
| 87 | email: "jmarminge@gmail.com", | ||
| 88 | plan: "ChatGPT Pro 20x Subscription", | ||
| 89 | usageLimits: { | ||
| 90 | checkedAt: "2026-09-03T22:07:07.000Z", | ||
| 91 | windows: [ | ||
| 92 | { | ||
| 93 | id: "secondary", | ||
| 94 | kind: "weekly", | ||
| 95 | label: "Weekly", | ||
| 96 | usedPercent: 12, | ||
| 97 | windowDurationMins: 10080, | ||
| 98 | resetsAt: "2026-09-07T02:52:53.000Z", | ||
| 99 | }, | ||
| 100 | ], | ||
| 101 | }, | ||
| 102 | }, | ||
| 103 | ]); | ||
| 104 | }); | ||
| 105 | |||
| 106 | it("names a Codex five-hour window `primary`, as the Codex driver does", () => { | ||
| 107 | const accounts = cliproxyStatusToAccounts( | ||
| 108 | { | ||
| 109 | accounts: { | ||
| 110 | "codex-abc-someone@example.com-pro.json": { | ||
| 111 | provider: "codex", | ||
| 112 | plan: "pro", | ||
| 113 | five_hour: { hard_limited: false, known: true, used_percent: 40 }, | ||
| 114 | }, | ||
| 115 | }, | ||
| 116 | }, | ||
| 117 | checkedAt, | ||
| 118 | ); | ||
| 119 | expect(accounts[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["primary"]); | ||
| 120 | }); | ||
| 121 | }); | ||
| 122 | |||
| 123 | describe("accountEmailFromAuthFile", () => { | ||
| 124 | it("pulls the email out of the hub's auth file names", () => { | ||
| 125 | expect(accountEmailFromAuthFile("claude-julius@ping.gg.json")).toBe("julius@ping.gg"); | ||
| 126 | expect(accountEmailFromAuthFile("codex-e413dce6-julius@ping.gg-pro.json")).toBe( | ||
| 127 | "julius@ping.gg", | ||
| 128 | ); | ||
| 129 | expect(accountEmailFromAuthFile("claude-first-last@example.com.json")).toBe( | ||
| 130 | "first-last@example.com", | ||
| 131 | ); | ||
| 132 | expect(accountEmailFromAuthFile("mystery.json")).toBeUndefined(); | ||
| 133 | }); | ||
| 134 | }); | ||
apps/server/src/usage/cliproxyUsageLimits.ts deleted-162| ... | @@ -1,162 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Maps a CLIProxyAPI hub's `quota-scheduler/status` response onto the usage | ||
| 3 | * limit windows the Limits view renders, one account per pooled auth file. | ||
| 4 | * | ||
| 5 | * The hub already normalises each upstream: Claude accounts carry | ||
| 6 | * `five_hour` / `seven_day` / `fable`, Codex accounts `five_hour` / `weekly`. | ||
| 7 | * Every window is `{ used_percent, reset_at?, known, hard_limited }`. | ||
| 8 | * | ||
| 9 | * @module usage/cliproxyUsageLimits | ||
| 10 | */ | ||
| 11 | import { | ||
| 12 | ProviderDriverKind, | ||
| 13 | type ServerProviderUsageLimits, | ||
| 14 | type ServerProviderUsageWindow, | ||
| 15 | type UsageLimitSourceAccount, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import * as DateTime from "effect/DateTime"; | ||
| 18 | import * as Option from "effect/Option"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | |||
| 21 | import { codexPlanLabel } from "../provider/Layers/CodexProvider.ts"; | ||
| 22 | import { clampPercent, makeUsageLimits } from "../provider/providerUsageLimits.ts"; | ||
| 23 | |||
| 24 | const QuotaWindow = Schema.Struct({ | ||
| 25 | used_percent: Schema.Number, | ||
| 26 | reset_at: Schema.optional(Schema.String), | ||
| 27 | known: Schema.optional(Schema.Boolean), | ||
| 28 | hard_limited: Schema.optional(Schema.Boolean), | ||
| 29 | }); | ||
| 30 | |||
| 31 | const QuotaAccount = Schema.Struct({ | ||
| 32 | provider: Schema.String, | ||
| 33 | plan: Schema.optional(Schema.String), | ||
| 34 | fetched_at: Schema.optional(Schema.String), | ||
| 35 | five_hour: Schema.optional(QuotaWindow), | ||
| 36 | seven_day: Schema.optional(QuotaWindow), | ||
| 37 | weekly: Schema.optional(QuotaWindow), | ||
| 38 | fable: Schema.optional(QuotaWindow), | ||
| 39 | }); | ||
| 40 | |||
| 41 | export const CliproxyQuotaStatus = Schema.Struct({ | ||
| 42 | accounts: Schema.Record(Schema.String, QuotaAccount), | ||
| 43 | }); | ||
| 44 | export type CliproxyQuotaStatus = typeof CliproxyQuotaStatus.Type; | ||
| 45 | export const decodeCliproxyQuotaStatus = Schema.decodeUnknownEffect(CliproxyQuotaStatus); | ||
| 46 | |||
| 47 | const SESSION_MINS = 5 * 60; | ||
| 48 | const WEEK_MINS = 7 * 24 * 60; | ||
| 49 | |||
| 50 | /** Window ids match what the provider drivers emit, so rows read the same across sources. */ | ||
| 51 | const WINDOWS: ReadonlyArray<{ | ||
| 52 | readonly key: keyof Omit<typeof QuotaAccount.Type, "provider" | "plan" | "fetched_at">; | ||
| 53 | readonly id: string; | ||
| 54 | readonly kind: ServerProviderUsageWindow["kind"]; | ||
| 55 | readonly label: string; | ||
| 56 | readonly windowDurationMins: number; | ||
| 57 | }> = [ | ||
| 58 | { | ||
| 59 | key: "five_hour", | ||
| 60 | id: "five_hour", | ||
| 61 | kind: "session", | ||
| 62 | label: "Session", | ||
| 63 | windowDurationMins: SESSION_MINS, | ||
| 64 | }, | ||
| 65 | { | ||
| 66 | key: "seven_day", | ||
| 67 | id: "seven_day", | ||
| 68 | kind: "weekly", | ||
| 69 | label: "Weekly", | ||
| 70 | windowDurationMins: WEEK_MINS, | ||
| 71 | }, | ||
| 72 | { | ||
| 73 | key: "weekly", | ||
| 74 | id: "secondary", | ||
| 75 | kind: "weekly", | ||
| 76 | label: "Weekly", | ||
| 77 | windowDurationMins: WEEK_MINS, | ||
| 78 | }, | ||
| 79 | { | ||
| 80 | key: "fable", | ||
| 81 | id: "seven_day_fable", | ||
| 82 | kind: "weekly", | ||
| 83 | label: "Weekly · Fable", | ||
| 84 | windowDurationMins: WEEK_MINS, | ||
| 85 | }, | ||
| 86 | ]; | ||
| 87 | |||
| 88 | const DRIVER_BY_HUB_PROVIDER: Readonly<Record<string, ProviderDriverKind>> = { | ||
| 89 | claude: ProviderDriverKind.make("claudeAgent"), | ||
| 90 | codex: ProviderDriverKind.make("codex"), | ||
| 91 | }; | ||
| 92 | |||
| 93 | function isoFromHub(value: string | undefined): string | undefined { | ||
| 94 | if (!value) return undefined; | ||
| 95 | const dt = DateTime.make(value); | ||
| 96 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; | ||
| 97 | } | ||
| 98 | |||
| 99 | /** `claude-julius@ping.gg.json` → `julius@ping.gg`; `codex-<hash>-x@y-pro.json` → `x@y`. */ | ||
| 100 | export function accountEmailFromAuthFile(fileName: string): string | undefined { | ||
| 101 | const stem = fileName.replace(/\.json$/i, ""); | ||
| 102 | // Strip the provider prefix (and Codex's hash) rather than splitting on | ||
| 103 | // `-`, so a hyphenated local part such as `first-last@` survives. | ||
| 104 | return stem.match(/^(?:claude-|codex-[a-z0-9]+-)?([^\s/]+@[^\s/]+?)(?:-[a-z0-9]+)?$/i)?.[1]; | ||
| 105 | } | ||
| 106 | |||
| 107 | /** | ||
| 108 | * The hub only reports a plan slug for Codex. Claude accounts carry no tier | ||
| 109 | * in the scheduler status, so the row says what it is: a Claude subscription. | ||
| 110 | */ | ||
| 111 | function planLabel(account: typeof QuotaAccount.Type): string | undefined { | ||
| 112 | if (account.provider === "codex") return codexPlanLabel(account.plan); | ||
| 113 | if (account.provider === "claude") return "Claude Subscription"; | ||
| 114 | return undefined; | ||
| 115 | } | ||
| 116 | |||
| 117 | export function cliproxyAccountToUsageLimits( | ||
| 118 | account: typeof QuotaAccount.Type, | ||
| 119 | checkedAt: string, | ||
| 120 | ): ServerProviderUsageLimits { | ||
| 121 | const windows: ServerProviderUsageWindow[] = []; | ||
| 122 | for (const spec of WINDOWS) { | ||
| 123 | const window = account[spec.key]; | ||
| 124 | if (!window || window.known === false) continue; | ||
| 125 | const resetsAt = isoFromHub(window.reset_at); | ||
| 126 | windows.push({ | ||
| 127 | // Codex names its five-hour window by position, so a hub row and a | ||
| 128 | // native row for the same account pool together. | ||
| 129 | id: spec.key === "five_hour" && account.provider === "codex" ? "primary" : spec.id, | ||
| 130 | kind: spec.kind, | ||
| 131 | label: spec.label, | ||
| 132 | windowDurationMins: spec.windowDurationMins, | ||
| 133 | // The hub flags a window it has seen a 429 on; the percent may lag. | ||
| 134 | usedPercent: window.hard_limited ? 100 : clampPercent(window.used_percent), | ||
| 135 | ...(resetsAt ? { resetsAt } : {}), | ||
| 136 | }); | ||
| 137 | } | ||
| 138 | return makeUsageLimits({ checkedAt: isoFromHub(account.fetched_at) ?? checkedAt, windows }); | ||
| 139 | } | ||
| 140 | |||
| 141 | export function cliproxyStatusToAccounts( | ||
| 142 | status: CliproxyQuotaStatus, | ||
| 143 | checkedAt: string, | ||
| 144 | ): ReadonlyArray<UsageLimitSourceAccount> { | ||
| 145 | const accounts: UsageLimitSourceAccount[] = []; | ||
| 146 | for (const [fileName, account] of Object.entries(status.accounts)) { | ||
| 147 | const driver = DRIVER_BY_HUB_PROVIDER[account.provider]; | ||
| 148 | if (!driver) continue; | ||
| 149 | const email = accountEmailFromAuthFile(fileName); | ||
| 150 | const plan = planLabel(account); | ||
| 151 | accounts.push({ | ||
| 152 | id: fileName, | ||
| 153 | driver, | ||
| 154 | ...(email ? { email } : {}), | ||
| 155 | ...(plan ? { plan } : {}), | ||
| 156 | usageLimits: cliproxyAccountToUsageLimits(account, checkedAt), | ||
| 157 | }); | ||
| 158 | } | ||
| 159 | return accounts.toSorted( | ||
| 160 | (left, right) => left.driver.localeCompare(right.driver) || left.id.localeCompare(right.id), | ||
| 161 | ); | ||
| 162 | } | ||
apps/server/src/ws.ts+1| ... | @@ -1847,6 +1847,7 @@ const makeWsRpcLayer = ( | ... | @@ -1847,6 +1847,7 @@ const makeWsRpcLayer = ( |
| 1847 | observeRpcEffect( | 1847 | observeRpcEffect( |
| 1848 | WS_METHODS.providerConsumeResetCredit, | 1848 | WS_METHODS.providerConsumeResetCredit, |
| 1849 | Effect.gen(function* () { | 1849 | Effect.gen(function* () { |
| 1850 | if ("sourceId" in input) return yield* usageLimitSources.consumeResetCredit(input); | ||
| 1850 | const instance = yield* providerInstances.getInstance(input.instanceId); | 1851 | const instance = yield* providerInstances.getInstance(input.instanceId); |
| 1851 | // A disabled instance must not spend anything on its account. | 1852 | // A disabled instance must not spend anything on its account. |
| 1852 | if (instance === undefined || !instance.enabled) { | 1853 | if (instance === undefined || !instance.enabled) { |
apps/web/src/components/chat/ComposerUsageLimits.tsx+5-2| ... | @@ -57,6 +57,9 @@ function UsageLimitsBannerBody({ | ... | @@ -57,6 +57,9 @@ function UsageLimitsBannerBody({ |
| 57 | <ComposerBanner.Scroll> | 57 | <ComposerBanner.Scroll> |
| 58 | <ComposerBanner.Body className="flex flex-col gap-2 pt-1 pb-1.5 pe-2"> | 58 | <ComposerBanner.Body className="flex flex-col gap-2 pt-1 pb-1.5 pe-2"> |
| 59 | {report.accounts.map((account) => { | 59 | {report.accounts.map((account) => { |
| 60 | const resetCreditInput = | ||
| 61 | account.resetCreditInput ?? | ||
| 62 | (account.instanceId ? { instanceId: account.instanceId } : undefined); | ||
| 60 | const notice = limitsNotice(account.limits); | 63 | const notice = limitsNotice(account.limits); |
| 61 | return ( | 64 | return ( |
| 62 | <div key={account.id} className="flex min-w-0 flex-col gap-1"> | 65 | <div key={account.id} className="flex min-w-0 flex-col gap-1"> |
| ... | @@ -75,10 +78,10 @@ function UsageLimitsBannerBody({ | ... | @@ -75,10 +78,10 @@ function UsageLimitsBannerBody({ |
| 75 | now={now} | 78 | now={now} |
| 76 | /> | 79 | /> |
| 77 | )} | 80 | )} |
| 78 | {account.instanceId && account.limits.resetCredits ? ( | 81 | {resetCreditInput && account.limits.resetCredits ? ( |
| 79 | <ResetCredits | 82 | <ResetCredits |
| 80 | environmentId={environmentId} | 83 | environmentId={environmentId} |
| 81 | instanceId={account.instanceId} | 84 | input={resetCreditInput} |
| 82 | credits={account.limits.resetCredits} | 85 | credits={account.limits.resetCredits} |
| 83 | now={now} | 86 | now={now} |
| 84 | /> | 87 | /> |
apps/web/src/components/usage/UsageLimits.tsx+10-10| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | 2 | type EnvironmentId, |
| 3 | type ProviderConsumeResetCreditOutcome, | 3 | type ProviderConsumeResetCreditOutcome, |
| 4 | ProviderInstanceId, | 4 | ProviderConsumeResetCreditInput, |
| 5 | ServerProvider, | 5 | ServerProvider, |
| 6 | ServerProviderResetCredits, | 6 | ServerProviderResetCredits, |
| 7 | ServerProviderUsageWindow, | 7 | ServerProviderUsageWindow, |
| ... | @@ -202,7 +202,10 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { | ... | @@ -202,7 +202,10 @@ const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = { |
| 202 | }; | 202 | }; |
| 203 | 203 | ||
| 204 | /** Everything a redeem needs: where to send it and what to say afterwards. */ | 204 | /** Everything a redeem needs: where to send it and what to say afterwards. */ |
| 205 | export function useResetCredit(environmentId: EnvironmentId, instanceId: ProviderInstanceId) { | 205 | export function useResetCredit( |
| 206 | environmentId: EnvironmentId, | ||
| 207 | input: ProviderConsumeResetCreditInput, | ||
| 208 | ) { | ||
| 206 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); | 209 | const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); |
| 207 | const [confirming, setConfirming] = useState(false); | 210 | const [confirming, setConfirming] = useState(false); |
| 208 | const [busy, setBusy] = useState(false); | 211 | const [busy, setBusy] = useState(false); |
| ... | @@ -212,10 +215,10 @@ export function useResetCredit(environmentId: EnvironmentId, instanceId: Provide | ... | @@ -212,10 +215,10 @@ export function useResetCredit(environmentId: EnvironmentId, instanceId: Provide |
| 212 | setConfirming(false); | 215 | setConfirming(false); |
| 213 | setBusy(true); | 216 | setBusy(true); |
| 214 | setStatus(null); | 217 | setStatus(null); |
| 215 | const result = await consume({ environmentId, input: { instanceId } }); | 218 | const result = await consume({ environmentId, input }); |
| 216 | setBusy(false); | 219 | setBusy(false); |
| 217 | if (result._tag === "Success") { | 220 | if (result._tag === "Success") { |
| 218 | setStatus(OUTCOME_TEXT[result.value.outcome]); | 221 | setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]); |
| 219 | return; | 222 | return; |
| 220 | } | 223 | } |
| 221 | setStatus( | 224 | setStatus( |
| ... | @@ -282,19 +285,16 @@ export function resetCreditsSummary( | ... | @@ -282,19 +285,16 @@ export function resetCreditsSummary( |
| 282 | /** Banked reset credits with the redeem button and its confirm, self-contained. */ | 285 | /** Banked reset credits with the redeem button and its confirm, self-contained. */ |
| 283 | export function ResetCredits({ | 286 | export function ResetCredits({ |
| 284 | environmentId, | 287 | environmentId, |
| 285 | instanceId, | 288 | input, |
| 286 | credits, | 289 | credits, |
| 287 | now, | 290 | now, |
| 288 | }: { | 291 | }: { |
| 289 | readonly environmentId: EnvironmentId; | 292 | readonly environmentId: EnvironmentId; |
| 290 | readonly instanceId: ProviderInstanceId; | 293 | readonly input: ProviderConsumeResetCreditInput; |
| 291 | readonly credits: ServerProviderResetCredits; | 294 | readonly credits: ServerProviderResetCredits; |
| 292 | readonly now: number; | 295 | readonly now: number; |
| 293 | }) { | 296 | }) { |
| 294 | const { confirming, setConfirming, busy, status, redeem } = useResetCredit( | 297 | const { confirming, setConfirming, busy, status, redeem } = useResetCredit(environmentId, input); |
| 295 | environmentId, | ||
| 296 | instanceId, | ||
| 297 | ); | ||
| 298 | if (credits.availableCount === 0 && status === null) return null; | 298 | if (credits.availableCount === 0 && status === null) return null; |
| 299 | return ( | 299 | return ( |
| 300 | <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> | 300 | <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> |
apps/web/src/components/usage/UsageLimitsPooled.tsx+3-3| ... | @@ -235,7 +235,7 @@ function PoolSegment({ | ... | @@ -235,7 +235,7 @@ function PoolSegment({ |
| 235 | const [open, setOpen] = useState(false); | 235 | const [open, setOpen] = useState(false); |
| 236 | const remaining = remainingPercent(window); | 236 | const remaining = remainingPercent(window); |
| 237 | const resetsIn = formatResetsIn(window, now); | 237 | const resetsIn = formatResetsIn(window, now); |
| 238 | const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; | 238 | const credits = account.limits.resetCredits?.availableCount ?? 0; |
| 239 | return ( | 239 | return ( |
| 240 | <Popover open={open} onOpenChange={setOpen}> | 240 | <Popover open={open} onOpenChange={setOpen}> |
| 241 | <PopoverTrigger | 241 | <PopoverTrigger |
| ... | @@ -340,7 +340,7 @@ function LegendRow({ | ... | @@ -340,7 +340,7 @@ function LegendRow({ |
| 340 | }) { | 340 | }) { |
| 341 | const remaining = remainingPercent(window); | 341 | const remaining = remainingPercent(window); |
| 342 | const resetsIn = formatResetsIn(window, now); | 342 | const resetsIn = formatResetsIn(window, now); |
| 343 | const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; | 343 | const credits = account.limits.resetCredits?.availableCount ?? 0; |
| 344 | return ( | 344 | return ( |
| 345 | <PopoverTrigger | 345 | <PopoverTrigger |
| 346 | style={{ gridColumn: "1 / -1", gridRow: index + 1 }} | 346 | style={{ gridColumn: "1 / -1", gridRow: index + 1 }} |
| ... | @@ -395,7 +395,7 @@ function RedeemableSegmentPopup({ | ... | @@ -395,7 +395,7 @@ function RedeemableSegmentPopup({ |
| 395 | readonly redeemAt: NonNullable<LimitAccount["redeem"]>; | 395 | readonly redeemAt: NonNullable<LimitAccount["redeem"]>; |
| 396 | readonly closePopover: () => void; | 396 | readonly closePopover: () => void; |
| 397 | }) { | 397 | }) { |
| 398 | const redeem = useResetCredit(redeemAt.environmentId, redeemAt.instanceId); | 398 | const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input); |
| 399 | return ( | 399 | return ( |
| 400 | <> | 400 | <> |
| 401 | <PopoverPopup side="top" sideOffset={6}> | 401 | <PopoverPopup side="top" sideOffset={6}> |
docs/user/usage.md+4-1| ... | @@ -67,6 +67,9 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`. | ... | @@ -67,6 +67,9 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`. |
| 67 | To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the | 67 | To see pooled accounts, open **Settings → Providers → Usage providers → Add hub**. Choose the |
| 68 | environment that will connect to the hub and enter its URL and management key. | 68 | environment that will connect to the hub and enter its URL and management key. |
| 69 | 69 | ||
| 70 | The accounts appear under **Usage → Limits**. This connection supplies usage information; configure | 70 | The accounts appear under **Usage → Limits**. Codex accounts show banked reset credits; select an |
| 71 | account and choose **Use reset** to redeem one. No hub plugin is required. | ||
| 72 | |||
| 73 | This connection supplies usage information; configure | ||
| 71 | the provider separately to send agent requests through the hub. Remove the hub from the same | 74 | the provider separately to send agent requests through the hub. Remove the hub from the same |
| 72 | settings section when you no longer need it. | 75 | settings section when you no longer need it. |
packages/client-runtime/src/state/server.ts+2-2| ... | @@ -975,7 +975,7 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -975,7 +975,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 975 | tag: WS_METHODS.providerAuthStart, | 975 | tag: WS_METHODS.providerAuthStart, |
| 976 | concurrency: { | 976 | concurrency: { |
| 977 | mode: "singleFlight", | 977 | mode: "singleFlight", |
| 978 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input.instanceId]), | 978 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input]), |
| 979 | }, | 979 | }, |
| 980 | }), | 980 | }), |
| 981 | completeProviderAuth: createEnvironmentRpcCommand(runtime, { | 981 | completeProviderAuth: createEnvironmentRpcCommand(runtime, { |
| ... | @@ -1056,7 +1056,7 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -1056,7 +1056,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 1056 | concurrency: { | 1056 | concurrency: { |
| 1057 | mode: "singleFlight", | 1057 | mode: "singleFlight", |
| 1058 | // Both ids are free-form strings; a delimiter could collide. | 1058 | // Both ids are free-form strings; a delimiter could collide. |
| 1059 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input.instanceId]), | 1059 | key: ({ environmentId, input }) => JSON.stringify([environmentId, input]), |
| 1060 | }, | 1060 | }, |
| 1061 | }), | 1061 | }), |
| 1062 | refreshProviders: createEnvironmentRpcCommand(runtime, { | 1062 | refreshProviders: createEnvironmentRpcCommand(runtime, { |
packages/contracts/src/providerUsageLimits.ts+25-2| ... | @@ -35,6 +35,8 @@ export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; | ... | @@ -35,6 +35,8 @@ export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; |
| 35 | export const ServerProviderResetCredits = Schema.Struct({ | 35 | export const ServerProviderResetCredits = Schema.Struct({ |
| 36 | availableCount: NonNegativeInt, | 36 | availableCount: NonNegativeInt, |
| 37 | nextExpiresAt: Schema.optional(IsoDateTime), | 37 | nextExpiresAt: Schema.optional(IsoDateTime), |
| 38 | /** Pins hub redemption to the displayed credit, including retries from another client. */ | ||
| 39 | nextCreditId: Schema.optional(TrimmedNonEmptyString), | ||
| 38 | }); | 40 | }); |
| 39 | export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; | 41 | export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; |
| 40 | 42 | ||
| ... | @@ -103,11 +105,29 @@ export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type; | ... | @@ -103,11 +105,29 @@ export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type; |
| 103 | export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot); | 105 | export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot); |
| 104 | export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type; | 106 | export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type; |
| 105 | 107 | ||
| 106 | export const ProviderConsumeResetCreditInput = Schema.Struct({ | 108 | export const UsageLimitSourceConsumeResetCreditInput = Schema.Struct({ |
| 107 | instanceId: ProviderInstanceId, | 109 | sourceId: UsageLimitSourceId, |
| 110 | accountId: TrimmedNonEmptyString, | ||
| 111 | creditId: TrimmedNonEmptyString, | ||
| 108 | }); | 112 | }); |
| 113 | export type UsageLimitSourceConsumeResetCreditInput = | ||
| 114 | typeof UsageLimitSourceConsumeResetCreditInput.Type; | ||
| 115 | |||
| 116 | export const ProviderConsumeResetCreditInput = Schema.Union([ | ||
| 117 | Schema.Struct({ instanceId: ProviderInstanceId }), | ||
| 118 | UsageLimitSourceConsumeResetCreditInput, | ||
| 119 | ]); | ||
| 109 | export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type; | 120 | export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type; |
| 110 | 121 | ||
| 122 | export class UsageLimitSourceError extends Schema.TaggedErrorClass<UsageLimitSourceError>()( | ||
| 123 | "UsageLimitSourceError", | ||
| 124 | { detail: Schema.String }, | ||
| 125 | ) { | ||
| 126 | override get message(): string { | ||
| 127 | return this.detail; | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 111 | /** Mirrors Codex's own outcome set; other providers map onto it. */ | 131 | /** Mirrors Codex's own outcome set; other providers map onto it. */ |
| 112 | export const ProviderConsumeResetCreditOutcome = Schema.Literals([ | 132 | export const ProviderConsumeResetCreditOutcome = Schema.Literals([ |
| 113 | "reset", | 133 | "reset", |
| ... | @@ -119,6 +139,8 @@ export type ProviderConsumeResetCreditOutcome = typeof ProviderConsumeResetCredi | ... | @@ -119,6 +139,8 @@ export type ProviderConsumeResetCreditOutcome = typeof ProviderConsumeResetCredi |
| 119 | 139 | ||
| 120 | export const ProviderConsumeResetCreditResult = Schema.Struct({ | 140 | export const ProviderConsumeResetCreditResult = Schema.Struct({ |
| 121 | outcome: ProviderConsumeResetCreditOutcome, | 141 | outcome: ProviderConsumeResetCreditOutcome, |
| 142 | /** Redemption succeeded, but a follow-up such as clearing the hub cooldown failed. */ | ||
| 143 | warning: Schema.optional(TrimmedNonEmptyString), | ||
| 122 | }); | 144 | }); |
| 123 | export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type; | 145 | export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type; |
| 124 | 146 | ||
| ... | @@ -134,6 +156,7 @@ export const UsageLimitsReport = Schema.Struct({ | ... | @@ -134,6 +156,7 @@ export const UsageLimitsReport = Schema.Struct({ |
| 134 | email: Schema.optional(TrimmedNonEmptyString), | 156 | email: Schema.optional(TrimmedNonEmptyString), |
| 135 | sourceLabel: Schema.optional(TrimmedNonEmptyString), | 157 | sourceLabel: Schema.optional(TrimmedNonEmptyString), |
| 136 | instanceId: Schema.optional(ProviderInstanceId), | 158 | instanceId: Schema.optional(ProviderInstanceId), |
| 159 | resetCreditInput: Schema.optional(ProviderConsumeResetCreditInput), | ||
| 137 | displayName: Schema.optional(Schema.String), | 160 | displayName: Schema.optional(Schema.String), |
| 138 | accentColor: Schema.optional(Schema.String), | 161 | accentColor: Schema.optional(Schema.String), |
| 139 | limits: ServerProviderUsageLimits, | 162 | limits: ServerProviderUsageLimits, |
packages/contracts/src/rpc.ts+2-1| ... | @@ -217,6 +217,7 @@ import { | ... | @@ -217,6 +217,7 @@ import { |
| 217 | ResourceTelemetrySnapshot, | 217 | ResourceTelemetrySnapshot, |
| 218 | } from "./resourceTelemetry.ts"; | 218 | } from "./resourceTelemetry.ts"; |
| 219 | import { | 219 | import { |
| 220 | UsageLimitSourceError, | ||
| 220 | ProviderConsumeResetCreditInput, | 221 | ProviderConsumeResetCreditInput, |
| 221 | ProviderConsumeResetCreditResult, | 222 | ProviderConsumeResetCreditResult, |
| 222 | } from "./providerUsageLimits.ts"; | 223 | } from "./providerUsageLimits.ts"; |
| ... | @@ -432,7 +433,7 @@ const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAutho | ... | @@ -432,7 +433,7 @@ const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAutho |
| 432 | const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { | 433 | const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { |
| 433 | payload: ProviderConsumeResetCreditInput, | 434 | payload: ProviderConsumeResetCreditInput, |
| 434 | success: ProviderConsumeResetCreditResult, | 435 | success: ProviderConsumeResetCreditResult, |
| 435 | error: ProviderSetupRpcError, | 436 | error: Schema.Union([ProviderSetupError, UsageLimitSourceError, EnvironmentAuthorizationError]), |
| 436 | }); | 437 | }); |
| 437 | 438 | ||
| 438 | const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { | 439 | const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { |
packages/shared/src/usageLimits.test.ts+129-3| ... | @@ -378,7 +378,7 @@ describe("pools", () => { | ... | @@ -378,7 +378,7 @@ describe("pools", () => { |
| 378 | key: "env-a:claude", | 378 | key: "env-a:claude", |
| 379 | sourceLabel: null, | 379 | sourceLabel: null, |
| 380 | // Desktop's read is fresher, so its credits and its redeem are the ones on show. | 380 | // Desktop's read is fresher, so its credits and its redeem are the ones on show. |
| 381 | redeem: { environmentId: "env-b", instanceId: "claude" }, | 381 | redeem: { environmentId: "env-b", input: { instanceId: "claude" } }, |
| 382 | environments: [ | 382 | environments: [ |
| 383 | { environmentId: "env-a", label: "Laptop" }, | 383 | { environmentId: "env-a", label: "Laptop" }, |
| 384 | { environmentId: "env-b", label: "Desktop" }, | 384 | { environmentId: "env-b", label: "Desktop" }, |
| ... | @@ -429,7 +429,7 @@ describe("pools", () => { | ... | @@ -429,7 +429,7 @@ describe("pools", () => { |
| 429 | const [account] = collectLimitAccounts(input); | 429 | const [account] = collectLimitAccounts(input); |
| 430 | expect(account?.limits.windows[0]?.usedPercent).toBe(55); | 430 | expect(account?.limits.windows[0]?.usedPercent).toBe(55); |
| 431 | expect(account?.limits.resetCredits?.availableCount).toBe(2); | 431 | expect(account?.limits.resetCredits?.availableCount).toBe(2); |
| 432 | expect(account?.redeem).toEqual({ environmentId: "env-a", instanceId: "claude" }); | 432 | expect(account?.redeem).toEqual({ environmentId: "env-a", input: { instanceId: "claude" } }); |
| 433 | expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]); | 433 | expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]); |
| 434 | }); | 434 | }); |
| 435 | 435 | ||
| ... | @@ -459,7 +459,108 @@ describe("pools", () => { | ... | @@ -459,7 +459,108 @@ describe("pools", () => { |
| 459 | ]); | 459 | ]); |
| 460 | const [account] = collectLimitAccounts(input); | 460 | const [account] = collectLimitAccounts(input); |
| 461 | expect(account?.limits.resetCredits?.availableCount).toBe(2); | 461 | expect(account?.limits.resetCredits?.availableCount).toBe(2); |
| 462 | expect(account?.redeem).toEqual({ environmentId: "env-b", instanceId: "codex" }); | 462 | expect(account?.redeem).toEqual({ environmentId: "env-b", input: { instanceId: "codex" } }); |
| 463 | }); | ||
| 464 | |||
| 465 | it("uses the freshest hub credit and its environment even when the account is also native", () => { | ||
| 466 | const native = provider({ | ||
| 467 | auth: { status: "authenticated", email: "same@example.com" }, | ||
| 468 | usageLimits: { checkedAt, windows: [window], resetCredits: { availableCount: 1 } }, | ||
| 469 | }); | ||
| 470 | const hubAccount = { | ||
| 471 | id: "codex-same.json", | ||
| 472 | driver: native.driver, | ||
| 473 | email: "same@example.com", | ||
| 474 | usageLimits: { | ||
| 475 | checkedAt: "2026-09-03T11:30:00.000Z", | ||
| 476 | windows: [window], | ||
| 477 | resetCredits: { availableCount: 2, nextCreditId: "credit-2" }, | ||
| 478 | }, | ||
| 479 | }; | ||
| 480 | const input = new Map([ | ||
| 481 | [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [native] } }], | ||
| 482 | [ | ||
| 483 | EnvironmentId.make("env-b"), | ||
| 484 | { | ||
| 485 | ...laptop, | ||
| 486 | serverConfig: { | ||
| 487 | providers: [], | ||
| 488 | usageLimitSources: [{ ...source, accounts: [hubAccount] }], | ||
| 489 | }, | ||
| 490 | }, | ||
| 491 | ], | ||
| 492 | ]); | ||
| 493 | const [account] = collectLimitAccounts(input); | ||
| 494 | expect(account?.limits.resetCredits?.availableCount).toBe(2); | ||
| 495 | expect(account?.redeem).toEqual({ | ||
| 496 | environmentId: "env-b", | ||
| 497 | input: { sourceId: "hub", accountId: "codex-same.json", creditId: "credit-2" }, | ||
| 498 | }); | ||
| 499 | hubAccount.usageLimits.resetCredits.availableCount = 0; | ||
| 500 | expect(collectLimitAccounts(input)[0]?.limits.resetCredits?.availableCount).toBe(0); | ||
| 501 | }); | ||
| 502 | |||
| 503 | it("keeps distinct hub accounts redeemable through their own source", () => { | ||
| 504 | const hubAccounts = ["first", "second"].map((id) => ({ | ||
| 505 | id, | ||
| 506 | driver: ProviderDriverKind.make("codex"), | ||
| 507 | email: `${id}@example.com`, | ||
| 508 | usageLimits: { | ||
| 509 | checkedAt, | ||
| 510 | windows: [window], | ||
| 511 | resetCredits: { availableCount: 2, nextCreditId: `${id}-credit` }, | ||
| 512 | }, | ||
| 513 | })); | ||
| 514 | const input = new Map([ | ||
| 515 | [ | ||
| 516 | EnvironmentId.make("env-a"), | ||
| 517 | { | ||
| 518 | ...laptop, | ||
| 519 | serverConfig: { | ||
| 520 | providers: [], | ||
| 521 | usageLimitSources: [{ ...source, accounts: hubAccounts }], | ||
| 522 | }, | ||
| 523 | }, | ||
| 524 | ], | ||
| 525 | ]); | ||
| 526 | expect(collectLimitAccounts(input).map((account) => account.redeem)).toEqual( | ||
| 527 | hubAccounts.map((account) => ({ | ||
| 528 | environmentId: "env-a", | ||
| 529 | input: { sourceId: "hub", accountId: account.id, creditId: `${account.id}-credit` }, | ||
| 530 | })), | ||
| 531 | ); | ||
| 532 | }); | ||
| 533 | |||
| 534 | it("does not give old credits the timestamp of a newer window-only read", () => { | ||
| 535 | const snapshots = [ | ||
| 536 | { checkedAt, windows: [window], resetCredits: { availableCount: 2 } }, | ||
| 537 | { checkedAt: "2026-09-03T12:00:00.000Z", windows: [window] }, | ||
| 538 | { | ||
| 539 | checkedAt: "2026-09-03T11:30:00.000Z", | ||
| 540 | windows: [window], | ||
| 541 | resetCredits: { availableCount: 1 }, | ||
| 542 | }, | ||
| 543 | ]; | ||
| 544 | const input = new Map( | ||
| 545 | snapshots.map((usageLimits, i) => [ | ||
| 546 | EnvironmentId.make(`env-${i}`), | ||
| 547 | { | ||
| 548 | ...laptop, | ||
| 549 | serverConfig: { | ||
| 550 | providers: [ | ||
| 551 | provider({ | ||
| 552 | auth: { status: "authenticated", email: "same@example.com" }, | ||
| 553 | usageLimits, | ||
| 554 | }), | ||
| 555 | ], | ||
| 556 | }, | ||
| 557 | }, | ||
| 558 | ]), | ||
| 559 | ); | ||
| 560 | const [account] = collectLimitAccounts(input); | ||
| 561 | expect(account?.limits.checkedAt).toBe("2026-09-03T12:00:00.000Z"); | ||
| 562 | expect(account?.limits.resetCredits?.availableCount).toBe(1); | ||
| 563 | expect(account?.redeem?.environmentId).toBe("env-2"); | ||
| 463 | }); | 564 | }); |
| 464 | 565 | ||
| 465 | it("names an environment once however many of its instances share the account", () => { | 566 | it("names an environment once however many of its instances share the account", () => { |
| ... | @@ -713,6 +814,31 @@ describe("/usage-limits", () => { | ... | @@ -713,6 +814,31 @@ describe("/usage-limits", () => { |
| 713 | }, | 814 | }, |
| 714 | ]; | 815 | ]; |
| 715 | 816 | ||
| 817 | it("uses hub credit balances and redemption targets in the composer, including native duplicates", () => { | ||
| 818 | const hubs = sources.map((source) => ({ | ||
| 819 | ...source, | ||
| 820 | accounts: source.accounts.map((account) => ({ | ||
| 821 | ...account, | ||
| 822 | usageLimits: { | ||
| 823 | ...account.usageLimits, | ||
| 824 | resetCredits: { availableCount: 2, nextCreditId: `${account.id}-credit` }, | ||
| 825 | }, | ||
| 826 | })), | ||
| 827 | })); | ||
| 828 | const report = collectProviderUsageLimits(selected.instanceId, [selected], hubs, now); | ||
| 829 | expect(report?.accounts[0]?.limits.resetCredits?.availableCount).toBe(2); | ||
| 830 | expect(report?.accounts[0]?.resetCreditInput).toEqual({ | ||
| 831 | sourceId: "hub", | ||
| 832 | accountId: "duplicate", | ||
| 833 | creditId: "duplicate-credit", | ||
| 834 | }); | ||
| 835 | expect(report?.accounts.find((account) => account.id === "hub:oss")?.resetCreditInput).toEqual({ | ||
| 836 | sourceId: "hub", | ||
| 837 | accountId: "oss", | ||
| 838 | creditId: "oss-credit", | ||
| 839 | }); | ||
| 840 | }); | ||
| 841 | |||
| 716 | it("keeps accounts and custom instances separate, filtering by driver", () => { | 842 | it("keeps accounts and custom instances separate, filtering by driver", () => { |
| 717 | const report = collectProviderUsageLimits( | 843 | const report = collectProviderUsageLimits( |
| 718 | selected.instanceId, | 844 | selected.instanceId, |
packages/shared/src/usageLimits.ts+76-18| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | type EnvironmentId, | 9 | type EnvironmentId, |
| 10 | type UsageLimitsReport, | 10 | type UsageLimitsReport, |
| 11 | type ProviderInstanceId, | 11 | type ProviderInstanceId, |
| 12 | type ProviderConsumeResetCreditInput, | ||
| 12 | type ServerProviderSlashCommand, | 13 | type ServerProviderSlashCommand, |
| 13 | isProviderAvailable, | 14 | isProviderAvailable, |
| 14 | type ServerProvider, | 15 | type ServerProvider, |
| ... | @@ -170,24 +171,33 @@ export interface LimitAccount { | ... | @@ -170,24 +171,33 @@ export interface LimitAccount { |
| 170 | }>; | 171 | }>; |
| 171 | /** The hub that reported it, when no environment has it natively. */ | 172 | /** The hub that reported it, when no environment has it natively. */ |
| 172 | readonly sourceLabel: string | null; | 173 | readonly sourceLabel: string | null; |
| 173 | /** Where a reset credit can be redeemed; only native instances can. */ | 174 | /** Where the displayed reset credit can be redeemed. */ |
| 174 | readonly redeem: { | 175 | readonly redeem: { |
| 175 | readonly environmentId: EnvironmentId; | 176 | readonly environmentId: EnvironmentId; |
| 176 | readonly instanceId: ProviderInstanceId; | 177 | readonly input: ProviderConsumeResetCreditInput; |
| 177 | } | null; | 178 | } | null; |
| 178 | readonly limits: ServerProviderUsageLimits; | 179 | readonly limits: ServerProviderUsageLimits; |
| 179 | } | 180 | } |
| 180 | 181 | ||
| 181 | /** | 182 | /** |
| 182 | * Every account with usable windows across the connected environments, one | 183 | * Every account with usable windows across the connected environments, one |
| 183 | * entry per distinct account. Native instances win over hub reports, and the | 184 | * entry per distinct account. The freshest reads supply windows and credits; |
| 184 | * freshest snapshot wins when the same account is reported twice. | 185 | * native instances supply names and environment labels. |
| 185 | */ | 186 | */ |
| 186 | export function collectLimitAccounts( | 187 | export function collectLimitAccounts( |
| 187 | presentations: Parameters<typeof collectLimitSources>[0], | 188 | presentations: Parameters<typeof collectLimitSources>[0], |
| 188 | ): readonly LimitAccount[] { | 189 | ): readonly LimitAccount[] { |
| 189 | const accounts = new Map<string, LimitAccount>(); | 190 | const accounts = new Map<string, LimitAccount>(); |
| 191 | const creditSources = new Map<string, LimitAccount>(); | ||
| 190 | const merge = (key: string, next: LimitAccount) => { | 192 | const merge = (key: string, next: LimitAccount) => { |
| 193 | const previousCredit = creditSources.get(key); | ||
| 194 | if ( | ||
| 195 | next.limits.resetCredits && | ||
| 196 | (!previousCredit || | ||
| 197 | Date.parse(next.limits.checkedAt) > Date.parse(previousCredit.limits.checkedAt)) | ||
| 198 | ) { | ||
| 199 | creditSources.set(key, next); | ||
| 200 | } | ||
| 191 | const previous = accounts.get(key); | 201 | const previous = accounts.get(key); |
| 192 | if (!previous) { | 202 | if (!previous) { |
| 193 | accounts.set(key, next); | 203 | accounts.set(key, next); |
| ... | @@ -203,13 +213,9 @@ export function collectLimitAccounts( | ... | @@ -203,13 +213,9 @@ export function collectLimitAccounts( |
| 203 | ), | 213 | ), |
| 204 | ]; | 214 | ]; |
| 205 | const winner = fresher ? next : previous; | 215 | const winner = fresher ? next : previous; |
| 206 | // Windows come from the freshest snapshot, wherever it was read. Reset | 216 | // Credits and their redemption target travel together. A failed credit |
| 207 | // credits only ever come from a native instance, and the redeem must go | 217 | // probe must not erase a successful read from another environment. |
| 208 | // to the instance whose credits are on show, so the two travel together: | 218 | const creditSource = creditSources.get(key); |
| 209 | // the freshest native snapshot supplies both, or neither. | ||
| 210 | const native = [previous, next] | ||
| 211 | .filter((candidate) => candidate.redeem !== null) | ||
| 212 | .sort((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; | ||
| 213 | accounts.set(key, { | 219 | accounts.set(key, { |
| 214 | ...previous, | 220 | ...previous, |
| 215 | displayName: previous.displayName ?? next.displayName, | 221 | displayName: previous.displayName ?? next.displayName, |
| ... | @@ -218,11 +224,13 @@ export function collectLimitAccounts( | ... | @@ -218,11 +224,13 @@ export function collectLimitAccounts( |
| 218 | environments, | 224 | environments, |
| 219 | // A hub only names the account when no environment has it natively. | 225 | // A hub only names the account when no environment has it natively. |
| 220 | sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel), | 226 | sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel), |
| 221 | redeem: native?.redeem ?? null, | 227 | redeem: creditSource |
| 228 | ? creditSource.redeem | ||
| 229 | : (winner.redeem ?? previous.redeem ?? next.redeem), | ||
| 222 | limits: { | 230 | limits: { |
| 223 | ...winner.limits, | 231 | ...winner.limits, |
| 224 | ...(native?.limits.resetCredits | 232 | ...(creditSource?.limits.resetCredits |
| 225 | ? { resetCredits: native.limits.resetCredits } | 233 | ? { resetCredits: creditSource.limits.resetCredits } |
| 226 | : { resetCredits: undefined }), | 234 | : { resetCredits: undefined }), |
| 227 | }, | 235 | }, |
| 228 | }); | 236 | }); |
| ... | @@ -243,7 +251,7 @@ export function collectLimitAccounts( | ... | @@ -243,7 +251,7 @@ export function collectLimitAccounts( |
| 243 | accentColor: provider.accentColor, | 251 | accentColor: provider.accentColor, |
| 244 | environments: [{ environmentId, label }], | 252 | environments: [{ environmentId, label }], |
| 245 | sourceLabel: null, | 253 | sourceLabel: null, |
| 246 | redeem: { environmentId, instanceId: provider.instanceId }, | 254 | redeem: { environmentId, input: { instanceId: provider.instanceId } }, |
| 247 | limits: provider.usageLimits, | 255 | limits: provider.usageLimits, |
| 248 | }, | 256 | }, |
| 249 | ); | 257 | ); |
| ... | @@ -253,7 +261,7 @@ export function collectLimitAccounts( | ... | @@ -253,7 +261,7 @@ export function collectLimitAccounts( |
| 253 | // may hold a fresher read of the same subscription, and the merge above | 261 | // may hold a fresher read of the same subscription, and the merge above |
| 254 | // keeps the redeem target consistent with whichever snapshot wins. | 262 | // keeps the redeem target consistent with whichever snapshot wins. |
| 255 | const labelEnvironment = presentations.size > 1; | 263 | const labelEnvironment = presentations.size > 1; |
| 256 | for (const presentation of presentations.values()) { | 264 | for (const [environmentId, presentation] of presentations) { |
| 257 | for (const source of presentation.serverConfig?.usageLimitSources ?? []) { | 265 | for (const source of presentation.serverConfig?.usageLimitSources ?? []) { |
| 258 | const sourceLabel = labelEnvironment | 266 | const sourceLabel = labelEnvironment |
| 259 | ? `${presentation.entry.target.label} · ${source.label}` | 267 | ? `${presentation.entry.target.label} · ${source.label}` |
| ... | @@ -269,7 +277,16 @@ export function collectLimitAccounts( | ... | @@ -269,7 +277,16 @@ export function collectLimitAccounts( |
| 269 | accentColor: undefined, | 277 | accentColor: undefined, |
| 270 | environments: [], | 278 | environments: [], |
| 271 | sourceLabel, | 279 | sourceLabel, |
| 272 | redeem: null, | 280 | redeem: account.usageLimits.resetCredits?.nextCreditId |
| 281 | ? { | ||
| 282 | environmentId, | ||
| 283 | input: { | ||
| 284 | sourceId: source.id, | ||
| 285 | accountId: account.id, | ||
| 286 | creditId: account.usageLimits.resetCredits.nextCreditId, | ||
| 287 | }, | ||
| 288 | } | ||
| 289 | : null, | ||
| 273 | limits: account.usageLimits, | 290 | limits: account.usageLimits, |
| 274 | }); | 291 | }); |
| 275 | } | 292 | } |
| ... | @@ -614,16 +631,48 @@ export function collectProviderUsageLimits( | ... | @@ -614,16 +631,48 @@ export function collectProviderUsageLimits( |
| 614 | const notices: string[] = []; | 631 | const notices: string[] = []; |
| 615 | for (const provider of native) { | 632 | for (const provider of native) { |
| 616 | if (!provider.usageLimits) continue; | 633 | if (!provider.usageLimits) continue; |
| 634 | const key = accountKey(provider.driver, provider.auth.email); | ||
| 635 | const hubCredits = sources | ||
| 636 | .flatMap((source) => source.accounts.map((account) => ({ source, account }))) | ||
| 637 | .filter( | ||
| 638 | ({ account }) => | ||
| 639 | key !== null && | ||
| 640 | accountKey(account.driver, account.email) === key && | ||
| 641 | account.usageLimits.resetCredits && | ||
| 642 | !limitsNotice(account.usageLimits), | ||
| 643 | ) | ||
| 644 | .sort( | ||
| 645 | (a, b) => | ||
| 646 | Date.parse(b.account.usageLimits.checkedAt) - Date.parse(a.account.usageLimits.checkedAt), | ||
| 647 | )[0]; | ||
| 648 | const useHubCredits = | ||
| 649 | hubCredits && | ||
| 650 | (!provider.usageLimits.resetCredits || | ||
| 651 | Date.parse(hubCredits.account.usageLimits.checkedAt) > | ||
| 652 | Date.parse(provider.usageLimits.checkedAt)); | ||
| 653 | const hubCreditId = useHubCredits | ||
| 654 | ? hubCredits.account.usageLimits.resetCredits?.nextCreditId | ||
| 655 | : undefined; | ||
| 617 | accounts.push({ | 656 | accounts.push({ |
| 618 | id: provider.instanceId, | 657 | id: provider.instanceId, |
| 619 | driver: provider.driver, | 658 | driver: provider.driver, |
| 620 | label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`, | 659 | label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`, |
| 621 | ...(provider.auth.label ? { plan: provider.auth.label } : {}), | 660 | ...(provider.auth.label ? { plan: provider.auth.label } : {}), |
| 622 | instanceId: provider.instanceId, | 661 | instanceId: provider.instanceId, |
| 662 | resetCreditInput: | ||
| 663 | hubCreditId && hubCredits | ||
| 664 | ? { | ||
| 665 | sourceId: hubCredits.source.id, | ||
| 666 | accountId: hubCredits.account.id, | ||
| 667 | creditId: hubCreditId, | ||
| 668 | } | ||
| 669 | : { instanceId: provider.instanceId }, | ||
| 623 | ...(provider.displayName ? { displayName: provider.displayName } : {}), | 670 | ...(provider.displayName ? { displayName: provider.displayName } : {}), |
| 624 | ...(provider.accentColor ? { accentColor: provider.accentColor } : {}), | 671 | ...(provider.accentColor ? { accentColor: provider.accentColor } : {}), |
| 625 | ...(provider.auth.email ? { email: provider.auth.email } : {}), | 672 | ...(provider.auth.email ? { email: provider.auth.email } : {}), |
| 626 | limits: provider.usageLimits, | 673 | limits: useHubCredits |
| 674 | ? { ...provider.usageLimits, resetCredits: hubCredits.account.usageLimits.resetCredits } | ||
| 675 | : provider.usageLimits, | ||
| 627 | }); | 676 | }); |
| 628 | } | 677 | } |
| 629 | for (const source of sources) { | 678 | for (const source of sources) { |
| ... | @@ -636,6 +685,15 @@ export function collectProviderUsageLimits( | ... | @@ -636,6 +685,15 @@ export function collectProviderUsageLimits( |
| 636 | driver: account.driver, | 685 | driver: account.driver, |
| 637 | label: `${source.label} · ${account.id}`, | 686 | label: `${source.label} · ${account.id}`, |
| 638 | sourceLabel: "CLI Proxy", | 687 | sourceLabel: "CLI Proxy", |
| 688 | ...(account.usageLimits.resetCredits?.nextCreditId | ||
| 689 | ? { | ||
| 690 | resetCreditInput: { | ||
| 691 | sourceId: source.id, | ||
| 692 | accountId: account.id, | ||
| 693 | creditId: account.usageLimits.resetCredits.nextCreditId, | ||
| 694 | }, | ||
| 695 | } | ||
| 696 | : {}), | ||
| 639 | ...(account.plan ? { plan: account.plan } : {}), | 697 | ...(account.plan ? { plan: account.plan } : {}), |
| 640 | ...(account.email ? { email: account.email } : {}), | 698 | ...(account.email ? { email: account.email } : {}), |
| 641 | limits: account.usageLimits, | 699 | limits: account.usageLimits, |