Repository navigation
feat(usage): include CLIProxy plugin providers (opencode-go) in hub accounts #24687
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,13 +16,18 @@ import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } fr | |
| import { codexPlanLabel } from "../provider/CodexProvider.ts"; | ||
| import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; | ||
| import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; | ||
| import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; | ||
| import { | ||
| clampPercent, | ||
| makeUnavailableUsageLimits, | ||
| makeUsageLimits, | ||
| } from "@t3tools/provider-core/server/usageLimits"; | ||
|
|
||
| const AuthFile = Schema.Struct({ | ||
| id: Schema.String, | ||
| auth_index: Schema.String, | ||
| provider: Schema.String, | ||
| email: Schema.optional(Schema.String), | ||
| label: Schema.optional(Schema.String), | ||
| disabled: Schema.optional(Schema.Boolean), | ||
| id_token: Schema.optional( | ||
| Schema.Struct({ | ||
|
|
@@ -133,6 +138,35 @@ const decodeConsumeResponse = Schema.decodeUnknownEffect( | |
| ), | ||
| ); | ||
|
|
||
| // Native quota endpoints exposed by CLIProxyAPI for provider plugins | ||
| // (e.g. opencode-go). Unlike the codex/claude OAuth flows above, plugin | ||
| // credentials have no upstream OAuth usage endpoint, so usage comes from | ||
| // `GET quota/providers` (discovery) + `POST quota/fetch` (windows). | ||
| const QuotaProviderEntry = Schema.Struct({ | ||
| plugin_id: Schema.optional(Schema.String), | ||
| provider: Schema.String, | ||
| display_name: Schema.optional(Schema.String), | ||
| supported_providers: Schema.optional(Schema.Array(Schema.String)), | ||
| supports_reset: Schema.optional(Schema.Boolean), | ||
| }); | ||
| const QuotaProviders = Schema.Struct({ providers: Schema.Array(QuotaProviderEntry) }); | ||
| const QuotaBucket = Schema.Struct({ | ||
| window: Schema.String, | ||
| remainingFraction: Schema.Number, | ||
| resetTime: Schema.optional(Schema.String), | ||
| }); | ||
| const QuotaGroup = Schema.Struct({ | ||
| displayName: Schema.optional(Schema.String), | ||
| buckets: Schema.Array(QuotaBucket), | ||
| }); | ||
| const QuotaFetch = Schema.Struct({ | ||
| subscription: Schema.optional(Schema.Struct({ plan: Schema.optional(Schema.String) })), | ||
| groups: Schema.Array(QuotaGroup), | ||
| }); | ||
|
|
||
| const decodeQuotaProviders = Schema.decodeUnknownEffect(QuotaProviders); | ||
| const decodeQuotaFetch = Schema.decodeUnknownEffect(QuotaFetch); | ||
|
|
||
| const CODEX_BASE = "https://chatgpt.com/backend-api/wham"; | ||
| const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`; | ||
|
|
||
|
|
@@ -195,6 +229,23 @@ export const makeCliproxyApi = Effect.gen(function* () { | |
| return (yield* decodeAuthFiles(response)).files; | ||
| }); | ||
|
|
||
| // Best-effort discovery of plugin quota providers. Older hubs answer 404; | ||
| // callers fall back to the codex/claude-only behavior in that case. | ||
| const quotaProviders = Effect.fn("CliproxyApi.quotaProviders")(function* ( | ||
| config: UsageLimitSourceConfig, | ||
| ) { | ||
| const response = yield* management(config, "quota/providers"); | ||
| return (yield* decodeQuotaProviders(response)).providers; | ||
| }); | ||
|
|
||
| const quotaFetch = Effect.fn("CliproxyApi.quotaFetch")(function* ( | ||
| config: UsageLimitSourceConfig, | ||
| auth_index: string, | ||
| ) { | ||
| const response = yield* management(config, "quota/fetch", { auth_index }); | ||
| return yield* decodeQuotaFetch(response); | ||
| }); | ||
|
|
||
| const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( | ||
| config: UsageLimitSourceConfig, | ||
| account: typeof AuthFile.Type, | ||
|
|
@@ -351,6 +402,91 @@ export const makeCliproxyApi = Effect.gen(function* () { | |
| ); | ||
| }); | ||
|
|
||
| // Usage for plugin-registered credentials (e.g. opencode-go). These have | ||
| // no upstream OAuth usage endpoint, so windows come from the hub's native | ||
| // quota API: rolling -> session, weekly -> weekly, monthly -> monthly. | ||
| const readPluginAccount = Effect.fn("CliproxyApi.readPluginAccount")(function* ( | ||
| config: UsageLimitSourceConfig, | ||
| account: typeof AuthFile.Type, | ||
| quotaEntry: typeof QuotaProviderEntry.Type | undefined, | ||
| ): Effect.fn.Return<UsageLimitSourceAccount, UsageLimitSourceError> { | ||
| const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| const displayName = quotaEntry?.display_name ?? account.provider; | ||
| const basePlan = [account.label, displayName] | ||
| .filter((part) => part !== undefined && part.trim().length > 0) | ||
| .join(" · "); | ||
| const base = { | ||
| id: account.id, | ||
| driver: ProviderDriverKind.make( | ||
| account.provider.includes("opencode") ? "opencode" : "claudeAgent", | ||
| ), | ||
| ...(account.email ? { email: account.email } : {}), | ||
| }; | ||
| const read = Effect.gen(function* () { | ||
| const quota = yield* quotaFetch(config, account.auth_index); | ||
| const subscriptionPlan = quota.subscription?.plan?.trim(); | ||
| const planSuffix = | ||
| subscriptionPlan && !displayName.toLowerCase().endsWith(subscriptionPlan.toLowerCase()) | ||
| ? ` ${subscriptionPlan}` | ||
| : ""; | ||
| const plan = `${basePlan}${planSuffix}`; | ||
| const windows = quota.groups.flatMap((group) => | ||
| group.buckets.flatMap((bucket) => { | ||
| const kind = | ||
| bucket.window === "rolling" | ||
| ? ("session" as const) | ||
| : bucket.window === "weekly" | ||
| ? ("weekly" as const) | ||
| : bucket.window === "monthly" | ||
| ? ("monthly" as const) | ||
| : undefined; | ||
| if (kind === undefined) return []; | ||
| const resetsAt = | ||
| bucket.resetTime !== undefined && !Number.isNaN(Date.parse(bucket.resetTime)) | ||
| ? bucket.resetTime | ||
| : undefined; | ||
| return [ | ||
| { | ||
| id: bucket.window, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium When two quota groups contain the same window, their buckets get the same 🤖 Copy this AI Prompt to have your agent fix this:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Handle duplicate quota windows across groups.
|
||
| kind, | ||
| label: bucket.window[0]!.toUpperCase() + bucket.window.slice(1), | ||
| usedPercent: clampPercent((1 - bucket.remainingFraction) * 100), | ||
| ...(resetsAt === undefined ? {} : { resetsAt }), | ||
| }, | ||
| ]; | ||
| }), | ||
| ); | ||
| return { | ||
| ...base, | ||
| ...(plan.trim().length > 0 ? { plan } : {}), | ||
| usageLimits: { | ||
| ...makeUsageLimits({ checkedAt, windows }), | ||
| ...(subscriptionPlan | ||
| ? { | ||
| credentialFingerprint: yield* crypto | ||
| .digest( | ||
| "SHA-256", | ||
| new TextEncoder().encode(`cliproxy\0${account.provider}\0${account.id}`), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High Accounts on separate hubs with the same provider and hub-local 🤖 Copy this AI Prompt to have your agent fix this: |
||
| ) | ||
| .pipe(Effect.map(Hex.encode), Effect.orDie), | ||
| } | ||
| : {}), | ||
|
Comment on lines
+464
to
+473
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Compute the fingerprint independently of
Compute the fingerprint for every plugin account and preserve it in the failed-fetch result.
|
||
| }, | ||
| }; | ||
| }); | ||
| return yield* read.pipe( | ||
| Effect.orElseSucceed(() => ({ | ||
| ...base, | ||
| ...(basePlan.trim().length > 0 ? { plan: basePlan } : {}), | ||
| usageLimits: makeUnavailableUsageLimits({ | ||
| checkedAt, | ||
| reason: "probeFailed", | ||
| message: "The hub could not read this account's usage.", | ||
| }), | ||
| })), | ||
| ); | ||
| }); | ||
|
|
||
| const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( | ||
| config: UsageLimitSourceConfig, | ||
| ): Effect.fn.Return<ReadonlyArray<UsageLimitSourceAccount>, UsageLimitSourceError> { | ||
|
|
@@ -361,12 +497,28 @@ export const makeCliproxyApi = Effect.gen(function* () { | |
| : new UsageLimitSourceError({ detail: "The hub could not list accounts." }), | ||
| ), | ||
| ); | ||
| return yield* Effect.forEach( | ||
| accounts.filter( | ||
| (account) => | ||
| !account.disabled && (account.provider === "codex" || account.provider === "claude"), | ||
| // Plugin quota providers (opencode-go, ...) are optional: older hubs 404. | ||
| const quota = yield* quotaProviders(config).pipe(Effect.orElseSucceed(() => [])); | ||
| const quotaByProvider = new Map( | ||
| quota.flatMap((entry) => | ||
| [entry.provider, ...(entry.supported_providers ?? [])].map( | ||
| (provider) => [provider, entry] as const, | ||
| ), | ||
| ), | ||
| (account) => readAccount(config, account), | ||
| ); | ||
| const wanted = accounts.filter( | ||
| (account) => | ||
| !account.disabled && | ||
| (account.provider === "codex" || | ||
| account.provider === "claude" || | ||
| quotaByProvider.has(account.provider)), | ||
| ); | ||
| return yield* Effect.forEach( | ||
| wanted, | ||
| (account) => | ||
| account.provider === "codex" || account.provider === "claude" | ||
| ? readAccount(config, account) | ||
| : readPluginAccount(config, account, quotaByProvider.get(account.provider)), | ||
| { concurrency: 4 }, | ||
| ); | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do not default unknown plugin providers to the
claudeAgentdriver.readAccountsselects every account whose provider appears inquotaByProvider.readPluginAccountgives all of them theclaudeAgentdriver unlessaccount.providercontains"opencode".A hub can discover a quota provider for another plugin. Its accounts would then be reported as Claude accounts. That attributes usage to the wrong provider instance and can mix it with real Claude usage.
Resolve the driver in one place. Select only accounts that map to a known driver, and leave other quota providers out until the code supports them.
♻️ Proposed direction
In
readAccounts, add a helper such aspluginDriver(provider): "opencode" | undefined. Use it in thewantedfilter so unsupported plugin providers are skipped.Committable suggestion