diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b8f30a1ca97..0afb7d8b974 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -205,7 +205,7 @@ ocx logout | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | Browser consent and key exchange use `https://www.orcarouter.ai` with S256 PKCE. The returned user-owned `sk-orca-…` API key is stored in the existing credential store and reused until revoked. | -| `devin` | `devin` | `https://server.codeium.com` | Experimental unofficial Cognition/Devin bridge. Login first imports the credential the installed Devin CLI already holds (`devin auth login` writes a `devin-session-token` to its own `credentials.toml`); when none is present it opens Auth0 browser sign-in and exchanges the pasted token via Cognition's `RegisterUser` for a long-lived API key. `ocx login devin-cli` remains as a deprecated alias. Models are discovered per account with `GetCascadeModelConfigs`. Not shown in the dashboard preset by default. Chat and usage reporting are verified against a live account across three models. | +| `devin` | `devin` | `https://server.codeium.com` | Experimental unofficial Cognition/Devin bridge. Login first imports the credential the installed Devin CLI already holds (`devin auth login` writes a `devin-session-token` to its own `credentials.toml`); when none is present it opens Auth0 browser sign-in and exchanges the pasted token via Cognition's `RegisterUser` for a long-lived API key. `ocx login devin-cli` remains as a deprecated alias. Models are discovered per account with `GetCascadeModelConfigs`. Account quota comes from `GetUserStatus` under one eight-second request and body deadline: dated daily and weekly windows, plus the monthly prompt and flex credit pool for a credit-billed plan or an unknown strategy with both reset dates absent when a balance field is present. Negative used credits omit the monthly window; valid zero available credits mark it exhausted. A timed-out probe keeps the last good quota. Not shown in the dashboard preset by default. Chat and usage reporting are verified against a live account across three models. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | Google Antigravity account and provider quota probes use fixed Google accounting endpoints, including the models fallback. They support transparent Fake-IP DNS for those destinations while retaining TLS verification, redirect rejection and private-address checks. A custom provider base URL changes model requests, not quota destinations; `NO_PROXY` continues to select the direct-route policy. diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 4192b1d454a..f5ff1542df8 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -351,7 +351,7 @@ returns: ``` `--quota` adds a `QUOTA` column with each account's own usage, for providers that support a -per-account probe (Anthropic, Kiro, and Google Antigravity today). It is opt-in because the proxy probes the upstream +per-account probe (Anthropic, Kiro, Google Antigravity, and Devin today). It is opt-in because the proxy probes the upstream once per stored credential; the default listing stays a local read. `--refresh` bypasses the cached result. An account with no per-account quota shows `-`, and one whose probe failed shows `unavailable` — blank would read as "no usage" rather than "not measured". `--json` carries the @@ -364,6 +364,15 @@ configured `baseUrl`: a custom base URL is a routing choice for requests, not a Google's accounting for a stored credential. An account without a project id, or one whose probe is redirected or fails, shows `unavailable`. +Devin rows come from Cognition's `GetUserStatus` for that account's own key, sent to its +allowlisted api-server host. If an older credential has no host, the probe uses the configured +provider base URL when allowlisted, or the US default. They show the dated daily and weekly +windows the plan exposes, and a monthly credit window only on a credit-billed plan that reports +a credit balance; an unknown billing strategy uses that credit fallback only if both reset dates +are absent. Expired daily and weekly windows stay hidden without becoming monthly credit quota. +An unlimited balance, or a status with no balance at all, shows no credit window. +Only a rejected key (401) clears a cached reading; other probe failures retain the last reading. + ```text $ ocx account list anthropic --quota PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS QUOTA diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index da0966e7b43..a5a7156bbb3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -752,6 +752,7 @@ "devin-image-passthrough.test.ts": "providers", "devin-live-models.test.ts": "providers", "devin-login.test.ts": "providers", + "devin-quota.test.ts": "providers", "devin-output-budget.test.ts": "providers", "devin-prompt-cache.test.ts": "providers", "devin-provider-merge-migration.test.ts": "providers", diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 2c37ec7b3dd..5b7236f0b53 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1,4 +1,3 @@ - import { listCodexAuthAccountsSnapshot } from "../codex/auth-api"; import { resolveEnvValue } from "../config"; import { getAccountCredential, getAccountSet } from "../oauth/store"; @@ -363,6 +362,7 @@ async function readExplicitAccountQuota(provider: string, accountId: string, con case "cursor": result = await fetchCursorQuota(provider, accessToken); break; case "kimi": result = await fetchKimiQuota(provider, config, accessToken); break; case "command-code": result = await fetchCommandCodeQuota(provider, config, accessToken); break; + case "devin": result = await (await import("./quota/devin")).fetchDevinQuota(provider, accessToken, credential.apiBaseUrl, config.baseUrl); break; default: return null; } return { result, identity, isCurrent }; diff --git a/src/providers/quota/account-cache.ts b/src/providers/quota/account-cache.ts index 5fc2d46ff2a..eaa1538567a 100644 --- a/src/providers/quota/account-cache.ts +++ b/src/providers/quota/account-cache.ts @@ -180,7 +180,8 @@ export function supportsPerAccountQuota(provider: string): boolean { } export function explicitAccountReader(provider: string): boolean { - return provider === "xai" || provider === "cursor" || provider === "kimi" || provider === "command-code"; + return provider === "xai" || provider === "cursor" || provider === "kimi" || provider === "command-code" + || provider === "devin"; } export function providerOAuthAccountQuotaMode(provider: string): AccountQuotaMode { @@ -479,6 +480,9 @@ export function quotaCredentialIdentity(provider: string, accountId: string, cre provider, accountId, credential.access, credential.refresh, credential.expires, credential.accountId, credential.projectId, credential.source, target.adapter, target.baseUrl, target.authMode, target.disabled === true, + // Only credentials that carry their own endpoint (Devin tenants) extend the identity, so + // every other provider's existing cache keys stay valid. + ...(provider === "devin" && credential.apiBaseUrl ? [credential.apiBaseUrl] : []), ])).digest("hex"); } @@ -486,6 +490,7 @@ export function explicitQuotaDestination(provider: string, config: OcxProviderCo if (config.disabled === true || config.authMode !== "oauth") return false; if (provider === "kimi") return isCanonicalKimiCodeBaseUrl(config.baseUrl); if (provider === "command-code") return isCanonicalCommandCodeBaseUrl(config.baseUrl); - // These readers use fixed canonical billing origins, never config.baseUrl. - return provider === "xai" || provider === "cursor"; + // These readers use fixed canonical billing origins, never config.baseUrl. Devin reads + // the credential's own allowlisted api-server host instead (fetchDevinQuota revalidates it). + return provider === "xai" || provider === "cursor" || provider === "devin"; } diff --git a/src/providers/quota/devin.ts b/src/providers/quota/devin.ts new file mode 100644 index 00000000000..519b89df7c1 --- /dev/null +++ b/src/providers/quota/devin.ts @@ -0,0 +1,286 @@ +/** + * Devin (Cognition) account quota from `SeatManagementService/GetUserStatus`. + * + * Cognition has no REST usage endpoint; plan, credit balances and the dated quota windows + * all come back from this one unary Connect RPC. The request carries the long-lived api_key + * inside the protobuf Metadata, so the host passes the same allowlist every other Devin RPC + * uses, redirects are refused, and no response body is ever echoed. + * + * Field map, confirmed against a live account: + * + * GetUserStatusResponse { #1 UserStatus, #2 PlanInfo } + * UserStatus { #10 teams_tier, #13 PlanStatus } + * PlanStatus { #1 PlanInfo, #3 plan_end (Timestamp), + * #4 available_flex, #5 used_flow, #6 used_prompt, #7 used_flex, + * #8 available_prompt, #9 available_flow (int32; -1 = unlimited), + * #14 daily_remaining_percent, #15 weekly_remaining_percent, + * #16 overage_balance_micros (int64, may be negative), + * #17 daily_reset_unix, #18 weekly_reset_unix } + * PlanInfo { #1 teams_tier, #2 plan_name, #13 monthly_flow, #35 billing_strategy, + * #36 hide_daily_quota, #37 hide_weekly_quota } + */ +import { buildMetadata } from "../../adapters/devin/cloud-direct/metadata"; +import { encodeMessage } from "../../adapters/devin/cloud-direct/wire"; +import { resolveDevinApiBaseUrl, validateDevinApiBaseUrl } from "../../oauth/devin/api-base"; +import { readBoundedResponseBytes } from "../../lib/bounded-body"; +import { epochMillis, QUOTA_RESPONSE_MAX_BYTES, REQUEST_TIMEOUT_MS } from "../quota-wire"; +import type { ProviderQuota, ProviderQuotaWindow } from "../quota-types"; +import { AUTHORITATIVE_EMPTY_QUOTA, report, TERMINAL_QUOTA_FAILURE, type ProviderQuotaProbeResult } from "./report-cache"; + +const GET_USER_STATUS_PATH = "/exa.seat_management_pb.SeatManagementService/GetUserStatus"; +const BILLING_STRATEGY_CREDITS = 1; +const RESPONSE_WIRES = new Map([[1, 2], [2, 2]]); +const USER_WIRES = new Map([[10, 0], [13, 2]]); +const STATUS_WIRES = new Map([[1, 2], [3, 2], ...[4, 5, 6, 7, 8, 9, 14, 15, 16, 17, 18].map(n => [n, 0] as const)]); +const PLAN_WIRES = new Map([[1, 0], [2, 2], [13, 0], [35, 0], [36, 0], [37, 0]]); +const TIMESTAMP_WIRES = new Map([[1, 0], [2, 0]]); + +export interface DevinPlanInfo { + teamsTier: number; + planName: string; + monthlyFlowCredits: number; + billingStrategy: number; + hideDailyQuota: boolean; + hideWeeklyQuota: boolean; +} + +export interface DevinUserStatus { + plan: DevinPlanInfo; + planEndMs?: number; + usedPromptCredits: number; + availablePromptCredits: number; + usedFlowCredits: number; + availableFlowCredits: number; + usedFlexCredits: number; + availableFlexCredits: number; + dailyRemainingPercent: number; + weeklyRemainingPercent: number; + dailyResetMs?: number; + weeklyResetMs?: number; + overageBalanceMicros: number; + promptCreditBalancePresent: boolean; +} + +type Fields = Map; + +/** + * Last occurrence wins. Parse locally so an overlong varint, truncated field, or wrong wire + * type for a known status field cannot turn a malformed credential-bearing RPC into quota. + */ +function fieldsOf(buf: Buffer | undefined, known: Map): Fields | null { + const out: Fields = new Map(); + if (!buf) return out; + let offset = 0; + const varint = (): bigint | null => { + let value = 0n; + for (let byte = 0; byte < 10; byte++) { + if (offset >= buf.length) return null; + const part = buf[offset++]; + if (byte === 9 && part > 1) return null; + value |= BigInt(part & 0x7f) << BigInt(byte * 7); + if (!(part & 0x80)) return value; + } + return null; + }; + while (offset < buf.length) { + const tag = varint(); + if (tag === null || tag > 0xffffffffn) return null; + const num = Number(tag >> 3n); + const wire = Number(tag & 7n); + if (num === 0 || (known.has(num) && known.get(num) !== wire)) return null; + if (wire === 0) { + const value = varint(); + if (value === null) return null; + out.set(num, value); + } else if (wire === 1 || wire === 5) { + const size = wire === 1 ? 8 : 4; + if (size > buf.length - offset) return null; + out.set(num, buf.subarray(offset, offset + size)); + offset += size; + } else if (wire === 2) { + const length = varint(); + if (length === null || length > BigInt(buf.length - offset)) return null; + const end = offset + Number(length); + out.set(num, buf.subarray(offset, end)); + offset = end; + } else return null; + } + return out; +} + +/** int32/int64 on the wire are two's-complement varints; -1 arrives as 2^64-1. */ +function int(fields: Fields, num: number): number { + const value = fields.get(num); + return typeof value === "bigint" ? Number(BigInt.asIntN(64, value)) : 0; +} + +function sub(fields: Fields, num: number): Buffer | undefined { + const value = fields.get(num); + return Buffer.isBuffer(value) ? value : undefined; +} + +/** Undefined for an absent timestamp; null for one that is present but malformed. */ +function timestampMs(buf: Buffer | undefined): number | undefined | null { + if (!buf) return undefined; + const f = fieldsOf(buf, TIMESTAMP_WIRES); + return f ? epochMillis(int(f, 1)) : null; +} + +/** Null when neither plan copy is present: a zero-valued plan would read as an exhausted account. */ +function decodePlanInfo(buf: Buffer | undefined): DevinPlanInfo | null { + if (!buf) return null; + const f = fieldsOf(buf, PLAN_WIRES); + if (!f) return null; + return { + teamsTier: int(f, 1), + planName: sub(f, 2)?.toString("utf8").trim() ?? "", + monthlyFlowCredits: int(f, 13), + billingStrategy: int(f, 35), + hideDailyQuota: int(f, 36) === 1, + hideWeeklyQuota: int(f, 37) === 1, + }; +} + +/** Null when the response carries no PlanStatus, which is not something a mapper can use. */ +export function decodeDevinUserStatus(buf: Buffer): DevinUserStatus | null { + const response = fieldsOf(buf, RESPONSE_WIRES); + const user = response && fieldsOf(sub(response, 1), USER_WIRES); + const statusBuf = user && sub(user, 13); + if (!statusBuf) return null; + const status = fieldsOf(statusBuf, STATUS_WIRES); + if (!status) return null; + // The top-level PlanInfo is authoritative; the nested copy covers servers that omit it. + const plan = decodePlanInfo(sub(response, 2) ?? sub(status, 1)); + if (!plan) return null; + if (!plan.teamsTier) plan.teamsTier = int(user, 10); + const planEndMs = timestampMs(sub(status, 3)); + if (planEndMs === null) return null; + const dailyResetMs = epochMillis(int(status, 17)); + const weeklyResetMs = epochMillis(int(status, 18)); + return { + plan, + ...(planEndMs !== undefined ? { planEndMs } : {}), + usedPromptCredits: int(status, 6), + availablePromptCredits: int(status, 8), + usedFlowCredits: int(status, 5), + availableFlowCredits: int(status, 9), + usedFlexCredits: int(status, 7), + availableFlexCredits: int(status, 4), + dailyRemainingPercent: int(status, 14), + weeklyRemainingPercent: int(status, 15), + ...(dailyResetMs !== undefined ? { dailyResetMs } : {}), + ...(weeklyResetMs !== undefined ? { weeklyResetMs } : {}), + overageBalanceMicros: int(status, 16), + // proto3 omits a zero balance, so an exhausted pool arrives as `used` alone. Only a + // status with no balance field at all carries no credit evidence. + promptCreditBalancePresent: [4, 6, 7, 8].some((field) => status.has(field)), + }; +} + +function usedPercent(used: number, available: number): number | undefined { + // A negative balance is the unlimited sentinel; nothing to measure against. + if (available < 0 || used < 0 || used + available <= 0) return undefined; + return Math.min(100, (used / (used + available)) * 100); +} + +function remainingToUsed(remaining: number): number { + return 100 - Math.max(0, Math.min(100, remaining)); +} + +/** + * Only DATED percent windows whose reset is still ahead are published. A credit-billed plan + * leaves the percent fields at their zero default, and a reset already in the past describes + * a window that has rolled over; either would read as a fully spent window and mark the + * account exhausted for routing. + * + * The credit pool is published only for a credit-billed plan, or for an unknown strategy + * with both reset fields absent. Expired dated windows still identify a quota plan. + * A quota-billed plan still reports credit balances, and a zero balance there does not + * gate anything. + * + * Flow credits are decoded but not published. They meter agent tool actions, not chat + * turns, and account ranking reads the fullest custom window as the account's limit, so an + * empty flow balance would retire an account that can still serve chat. + * + * Credits are measured against the server's own balance rather than the plan grant, so + * top-ups count. Flex credits back prompt credits once those run out, so both share one + * pool: an account with prompt credits spent and flex remaining can still serve. + */ +export function devinQuotaFromStatus(status: DevinUserStatus, now = Date.now()): ProviderQuota { + const quota: ProviderQuota = { updatedAt: now }; + const customWindows: ProviderQuotaWindow[] = []; + const ahead = (resetMs: number | undefined): resetMs is number => resetMs !== undefined && resetMs > now; + if (!status.plan.hideDailyQuota && ahead(status.dailyResetMs)) { + customWindows.push({ label: "Daily", percent: remainingToUsed(status.dailyRemainingPercent), resetAt: status.dailyResetMs }); + } + if (!status.plan.hideWeeklyQuota && ahead(status.weeklyResetMs)) { + quota.weeklyPercent = remainingToUsed(status.weeklyRemainingPercent); + quota.weeklyResetAt = status.weeklyResetMs; + } + const strategy = status.plan.billingStrategy; + const creditBilled = strategy === BILLING_STRATEGY_CREDITS + || (strategy === 0 && status.dailyResetMs === undefined && status.weeklyResetMs === undefined); + const available = status.availablePromptCredits + status.availableFlexCredits; + const credits = !creditBilled || !status.promptCreditBalancePresent + || status.availablePromptCredits < 0 || status.availableFlexCredits < 0 + || status.usedPromptCredits < 0 || status.usedFlexCredits < 0 + ? undefined + // A credit-billed plan with nothing used and nothing left has no balance to serve from; + // leaving it unmeasured would rank it as untested headroom. + : available === 0 ? 100 + : usedPercent(status.usedPromptCredits + status.usedFlexCredits, available); + if (credits !== undefined) { + quota.monthlyPercent = credits; + if (status.planEndMs !== undefined) quota.monthlyResetAt = status.planEndMs; + } + if (customWindows.length > 0) quota.customWindows = customWindows; + return quota; +} + +/** + * Probe one Devin account. `null` keeps the last-good row (transient failure); + * `TERMINAL_QUOTA_FAILURE` means the credential or contract is rejected. + */ +export async function fetchDevinQuota(provider: string, apiKey: string, apiBaseUrl: string | undefined, configuredBaseUrl?: string): Promise { + const base = validateDevinApiBaseUrl(apiBaseUrl) + ?? (configuredBaseUrl !== undefined || apiBaseUrl === undefined ? resolveDevinApiBaseUrl(configuredBaseUrl) : undefined); + if (!base || !apiKey.trim()) return null; + const metadata = buildMetadata({ + apiKey, + sessionId: crypto.randomUUID(), + requestId: BigInt(Date.now()), + triggerId: crypto.randomUUID(), + }); + try { + const deadline = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + const response = await fetch(`${base}${GET_USER_STATUS_PATH}`, { + method: "POST", + headers: { "Content-Type": "application/proto", "Connect-Protocol-Version": "1" }, + // GetUserStatusRequest { #1 metadata } + body: new Uint8Array(encodeMessage(1, metadata)), + // The body carries the api_key; a redirect would replay it at another host. + redirect: "error", + signal: deadline, + }); + if (!response.ok) { + void response.body?.cancel().catch(() => undefined); + // Only 401 proves the key itself is refused. A 403 can scope this one RPC away from a + // key that still serves chat, so it keeps the last-good reading like other failures. + return response.status === 401 ? TERMINAL_QUOTA_FAILURE : null; + } + const body = await readBoundedResponseBytes(response, { + signal: deadline, + maxBytes: QUOTA_RESPONSE_MAX_BYTES, + inactivityTimeoutMs: REQUEST_TIMEOUT_MS, + }); + if (body.oversized) return null; + // Inside the try: a truncated varint throws, and a malformed body must keep last-good. + const status = decodeDevinUserStatus(Buffer.from(body.bytes.buffer, body.bytes.byteOffset, body.bytes.byteLength)); + if (!status) return null; + // A decoded status with nothing measurable (an unlimited plan, hidden windows) is an + // authoritative answer, not a failed probe, so it must replace any last-good row. + return report(provider, "devin:user-status", devinQuotaFromStatus(status)) ?? AUTHORITATIVE_EMPTY_QUOTA; + } catch { + return null; + } +} diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 15b2c77556a..c3eec950d6c 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -73,6 +73,28 @@ region-matched `/v1/api/openplatform/coding_plan/remains` endpoint. It publishes model's consumed 5-hour percentage and, when active, weekly percentage with their reset times; video quota rows are unrelated and omitted. +Devin account quota in `src/providers/quota/devin.ts` reads Cognition's unary +`SeatManagementService/GetUserStatus` with the default cloud-direct Metadata, against the +credential's allowlisted api-server host, falling back to the configured allowlisted provider +base URL (or the US default) for a legacy credential without a usable host. Redirects are +refused; one eight-second deadline covers both the fetch and bounded body read, so a +continuing byte drip keeps last-good when that deadline expires. It +publishes only daily and weekly windows the plan does not hide whose reset is still ahead, +because a credit-billed plan leaves those percents at a zero default and a past reset describes a +rolled-over window; both would read as exhausted. Prompt plus flex credits form one monthly pool +measured against the server balance, published only for a credit-billed plan (or an unknown +strategy with both reset fields absent) when at least one of the four prompt/flex balance fields +is present (proto3 omits zeros, so an exhausted pool arrives as a used count alone); a negative +available balance is the unlimited sentinel; a negative used balance is malformed and omits the +monthly window even when zero is available. Valid zero available reads as exhausted. +Expired dated windows +do not cause the credit fallback. Only a 401 rejects the credential and clears last-good; a 403 +may scope this one RPC away from a key that still serves +chat. Other HTTP failures and malformed protobufs, including a wrong +wire type for a known field or a varint longer than ten bytes, keep last-good; a decoded status +with nothing measurable is authoritative-empty. Only Devin's credential host extends its quota +cache identity; generic OAuth pause still suppresses per-account probes. + Kiro's account quota cache persists quota and an optional exhaustion verdict under one opaque account key and a non-secret login identity. Hydration admits only matching live accounts and bounds quota and verdict independently by reset and ten-minute TTL; a failed diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index d67586a0c76..9010dec46e5 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -157,7 +157,7 @@ by transports that can preserve that request-local decision: | Every caller of `providerOutboundGet` or `providerOutboundPost` in `src/lib/provider-outbound.ts` | Honoured | This includes provider discovery and model-catalog gathering in `src/codex/catalog/provider-models.ts`, management provider tests in `src/server/management/provider-routes.ts`, and the Ollama show probe in `src/providers/ollama-show.ts`. | | API-key quota probes in `src/providers/quota/vendor-probes-key.ts` | Honoured | Each probe receives its provider config and sends through `configuredOutboundFetch` with the resolved route, so a quota reading and the inference it describes leave by the same exit. | | OAuth token exchange and refresh under `src/oauth/` | Not honoured | These reach fixed vendor endpoints from modules that hold no provider config, so no provider route is in scope at the call site. A provider pinned to its own proxy or to direct still refreshes credentials by the process-wide route. | -| OAuth-backed quota probes in `src/providers/quota/vendor-probes-oauth.ts` | Not honoured | `fetchXaiQuota`, `fetchAnthropicQuota`, `fetchCursorQuota` and their neighbours receive a provider name and a token rather than a provider config. | +| OAuth-backed quota probes in `src/providers/quota/vendor-probes-oauth.ts` and `src/providers/quota/devin.ts` | Not honoured | `fetchXaiQuota`, `fetchAnthropicQuota`, `fetchCursorQuota`, `fetchDevinQuota` and their neighbours receive a provider name and a token rather than a provider config. | | API-key validation probes in `src/oauth/key-providers.ts` | Not honoured | `validateApiKey` receives a `KeyLoginProvider` derived preset, which carries no egress fields, and its caller builds the real provider record afterwards. | | Responses WebSocket upstream in `src/server/responses/ws-upstream.ts` | Not directly | The WebSocket dial selects its proxy from the process environment. An explicit provider route therefore serves that provider's turns over HTTP/SSE instead and emits one warning per provider per process. | | Caller-supplied `provider.fetch` executor | Not honoured | The caller owns that executor's transport. An explicit provider route is refused instead of being ignored. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a02bf39bd4a..429b0ab143d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -757,6 +757,7 @@ "devin-image-passthrough.test.ts": "providers", "devin-live-models.test.ts": "providers", "devin-login.test.ts": "providers", + "devin-quota.test.ts": "providers", "devin-output-budget.test.ts": "providers", "devin-prompt-cache.test.ts": "providers", "devin-provider-merge-migration.test.ts": "providers", diff --git a/tests/providers/devin-quota.test.ts b/tests/providers/devin-quota.test.ts new file mode 100644 index 00000000000..2ea4eed2104 --- /dev/null +++ b/tests/providers/devin-quota.test.ts @@ -0,0 +1,395 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { encodeMessage, encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; +import { saveCredential } from "../../src/oauth/store"; +import { clearProviderQuotaCache, fetchProviderQuotaReports, QUOTA_RESPONSE_MAX_BYTES } from "../../src/providers/quota"; +import { decodeDevinUserStatus, devinQuotaFromStatus, fetchDevinQuota } from "../../src/providers/quota/devin"; +import { AUTHORITATIVE_EMPTY_QUOTA, TERMINAL_QUOTA_FAILURE } from "../../src/providers/quota/report-cache"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const KEY = "devin-session-token$synthetic.fixture.key"; +// Far enough ahead that the fetch-path tests, which use the real clock, see live windows. +const PLAN_END = 4_000_000_000; +const DAILY_RESET = 3_990_000_000; +const WEEKLY_RESET = 3_995_000_000; + +/** Signed ints ride the wire as 64-bit two's complement, exactly like the server sends -1. */ +const int = (num: number, value: number) => encodeVarintField(num, BigInt.asUintN(64, BigInt(value))); + +function planInfo(p: { tier: number; name: string; billing: number; hideDaily?: boolean; hideWeekly?: boolean; monthlyFlow?: number }): Buffer { + return Buffer.concat([ + int(1, p.tier), + encodeString(2, p.name), + ...(p.monthlyFlow ? [int(13, p.monthlyFlow)] : []), + int(35, p.billing), + ...(p.hideDaily ? [int(36, 1)] : []), + ...(p.hideWeekly ? [int(37, 1)] : []), + ]); +} + +function userStatusResponse(plan: Buffer, status: Record, dated = true): Buffer { + const planStatus = Buffer.concat([ + encodeMessage(1, plan), + encodeMessage(3, int(1, PLAN_END)), + ...Object.entries(status).map(([num, value]) => int(Number(num), value)), + ...(dated ? [int(17, DAILY_RESET), int(18, WEEKLY_RESET)] : []), + ]); + const user = Buffer.concat([encodeString(3, "Fixture User"), int(10, 1), encodeMessage(13, planStatus)]); + return Buffer.concat([encodeMessage(1, user), encodeMessage(2, plan)]); +} + +/** Quota-billed plan, shaped like the live Max account: daily hidden, prompt credits unlimited. */ +const quotaPlan = () => userStatusResponse( + planInfo({ tier: 17, name: "Max", billing: 2, hideDaily: true }), + { 8: -1, 14: 100, 15: 84, 16: -7_937_410 }, +); + +/** Credit-billed plan: the percent fields sit at their zero default and carry no reset date. */ +const creditPlan = (status: Record) => userStatusResponse( + planInfo({ tier: 16, name: "Pro", billing: 1 }), + status, + false, +); + +const creditPlanWithFlow = (monthlyFlow: number, status: Record) => userStatusResponse( + planInfo({ tier: 16, name: "Pro", billing: 1, monthlyFlow }), + status, + false, +); + +describe("Devin GetUserStatus decode and mapping", () => { + test("quota-billed plan publishes the dated weekly window and nothing hidden or unlimited", () => { + const status = decodeDevinUserStatus(quotaPlan()); + expect(status?.plan).toMatchObject({ teamsTier: 17, planName: "Max", billingStrategy: 2, hideDailyQuota: true }); + expect(status?.availablePromptCredits).toBe(-1); + expect(status?.overageBalanceMicros).toBe(-7_937_410); + expect(status?.dailyResetMs).toBe(DAILY_RESET * 1000); + expect(devinQuotaFromStatus(status!, 1)).toEqual({ + updatedAt: 1, + weeklyPercent: 16, + weeklyResetAt: WEEKLY_RESET * 1000, + }); + }); + + test("an unhidden dated daily window becomes a custom window", () => { + const buf = userStatusResponse(planInfo({ tier: 17, name: "Max", billing: 2 }), { 8: -1, 14: 40, 15: 90 }); + expect(devinQuotaFromStatus(decodeDevinUserStatus(buf)!, 1).customWindows).toEqual([ + { label: "Daily", percent: 60, resetAt: DAILY_RESET * 1000 }, + ]); + }); + + test("credit-billed plan omits undated percent windows instead of reporting them exhausted", () => { + const quota = devinQuotaFromStatus(decodeDevinUserStatus(creditPlan({ 6: 300, 8: 200 }))!, 1); + expect(quota).toEqual({ updatedAt: 1, monthlyPercent: 60, monthlyResetAt: PLAN_END * 1000 }); + }); + + test("flex credits keep an account with spent prompt credits servable", () => { + const quota = devinQuotaFromStatus(decodeDevinUserStatus(creditPlan({ 6: 500, 8: 0, 4: 100 }))!, 1); + expect(quota.monthlyPercent).toBeCloseTo((500 / 600) * 100); + const spent = devinQuotaFromStatus(decodeDevinUserStatus(creditPlan({ 6: 500, 8: 0 }))!, 1); + expect(spent.monthlyPercent).toBe(100); + }); + + test("a quota-billed plan with a zero credit balance is not credit-exhausted", () => { + const buf = userStatusResponse(planInfo({ tier: 17, name: "Max", billing: 2 }), { 6: 10, 8: 0, 14: 100, 15: 90 }); + const quota = devinQuotaFromStatus(decodeDevinUserStatus(buf)!, 1); + expect(quota.weeklyPercent).toBe(10); + expect(quota.monthlyPercent).toBeUndefined(); + }); + + test("an unknown billing strategy uses credits only when no window is dated", () => { + const undated = userStatusResponse(planInfo({ tier: 16, name: "Pro", billing: 0 }), { 6: 50, 8: 50 }, false); + expect(devinQuotaFromStatus(decodeDevinUserStatus(undated)!, 1).monthlyPercent).toBe(50); + const dated = userStatusResponse(planInfo({ tier: 16, name: "Pro", billing: 0 }), { 6: 50, 8: 50, 14: 100, 15: 100 }); + expect(devinQuotaFromStatus(decodeDevinUserStatus(dated)!, 1).monthlyPercent).toBeUndefined(); + }); + + test("expired dated windows do not make an unknown strategy credit-billed", () => { + const dated = userStatusResponse(planInfo({ tier: 16, name: "Pro", billing: 0 }), { 6: 0, 8: 0, 14: 0, 15: 0 }); + const now = WEEKLY_RESET * 1000 + 1; + expect(devinQuotaFromStatus(decodeDevinUserStatus(dated)!, now)).toEqual({ updatedAt: now }); + }); + + test("a window whose reset has already passed is dropped rather than read as spent", () => { + const buf = userStatusResponse(planInfo({ tier: 17, name: "Max", billing: 2 }), { 8: -1, 14: 0, 15: 0 }); + const status = decodeDevinUserStatus(buf)!; + expect(devinQuotaFromStatus(status, WEEKLY_RESET * 1000 + 1)).toEqual({ updatedAt: WEEKLY_RESET * 1000 + 1 }); + expect(devinQuotaFromStatus(status, DAILY_RESET * 1000 + 1)).toEqual({ + updatedAt: DAILY_RESET * 1000 + 1, + weeklyPercent: 100, + weeklyResetAt: WEEKLY_RESET * 1000, + }); + }); + + test("hide_weekly_quota (#37) suppresses the weekly window", () => { + const buf = userStatusResponse(planInfo({ tier: 17, name: "Max", billing: 2, hideWeekly: true }), { 8: -1, 14: 70, 15: 10 }); + expect(devinQuotaFromStatus(decodeDevinUserStatus(buf)!, 1)).toEqual({ + updatedAt: 1, + customWindows: [{ label: "Daily", percent: 30, resetAt: DAILY_RESET * 1000 }], + }); + }); + + test("flow credits decode from #5/#9/#13 but never gate the account", () => { + const status = decodeDevinUserStatus(creditPlanWithFlow(100, { 5: 30, 9: 70 }))!; + expect(status.usedFlowCredits).toBe(30); + expect(status.availableFlowCredits).toBe(70); + expect(status.plan.monthlyFlowCredits).toBe(100); + const exhausted = decodeDevinUserStatus(creditPlanWithFlow(100, { 5: 100, 9: 0, 6: 10, 8: 90 }))!; + const quota = devinQuotaFromStatus(exhausted, 1); + expect(quota.customWindows).toBeUndefined(); + expect(quota.monthlyPercent).toBe(10); + }); + + test("a credit-billed plan with nothing used and nothing left reads exhausted, not unmeasured", () => { + expect(devinQuotaFromStatus(decodeDevinUserStatus(creditPlan({ 6: 0, 8: 0 }))!, 1).monthlyPercent).toBe(100); + }); + + test.each([6, 7])("a negative used credit field %i cannot mark a zero-available pool exhausted", usedField => { + const status = decodeDevinUserStatus(creditPlan({ [usedField]: -1, 8: 0, 4: 0 }))!; + expect(devinQuotaFromStatus(status, 1)).toEqual({ updatedAt: 1 }); + }); + + test("missing credit balance fields do not publish an exhausted monthly window", () => { + expect(devinQuotaFromStatus(decodeDevinUserStatus(creditPlan({}))!, 1).monthlyPercent).toBeUndefined(); + }); + + test("proto3 zero omission: a used balance alone still reads as an exhausted pool", () => { + // available_prompt = 0 is omitted on the wire; the used count is the evidence. + expect(devinQuotaFromStatus(decodeDevinUserStatus(creditPlan({ 6: 50 }))!, 1).monthlyPercent).toBe(100); + expect(devinQuotaFromStatus(decodeDevinUserStatus(creditPlan({ 8: 100 }))!, 1).monthlyPercent).toBe(0); + expect(devinQuotaFromStatus(decodeDevinUserStatus(creditPlan({ 7: 5, 4: 15 }))!, 1).monthlyPercent).toBe(25); + }); + + test("a known field with the wrong protobuf wire type rejects the status", () => { + const plan = planInfo({ tier: 16, name: "Pro", billing: 1 }); + for (const bad of [encodeMessage(6, Buffer.alloc(0)), encodeMessage(8, Buffer.alloc(0))]) { + const status = Buffer.concat([encodeMessage(1, plan), bad, int(6, 0), int(8, 0)]); + const response = Buffer.concat([encodeMessage(1, encodeMessage(13, status)), encodeMessage(2, plan)]); + expect(decodeDevinUserStatus(response)).toBeNull(); + } + }); + + test("an overlong varint in a nested status is rejected", () => { + const plan = planInfo({ tier: 16, name: "Pro", billing: 1 }); + const status = Buffer.concat([encodeMessage(1, plan), Buffer.from([0x30, ...Array(10).fill(0x80), 0x01])]); + const response = Buffer.concat([encodeMessage(1, encodeMessage(13, status)), encodeMessage(2, plan)]); + expect(decodeDevinUserStatus(response)).toBeNull(); + }); + + test("a status with no plan copy is not decoded into a zero-valued plan", () => { + // Neither the top-level PlanInfo nor PlanStatus #1: a zero plan would read as an exhausted account. + const planStatus = Buffer.concat([encodeMessage(3, int(1, PLAN_END)), int(6, 0), int(8, 0)]); + const noPlan = encodeMessage(1, Buffer.concat([int(10, 1), encodeMessage(13, planStatus)])); + expect(decodeDevinUserStatus(noPlan)).toBeNull(); + }); + + test("a present but truncated plan-end timestamp fails the decode instead of reading as absent", () => { + const plan = planInfo({ tier: 16, name: "Pro", billing: 1 }); + // PlanStatus #3 holds a field declaring 127 bytes with none present. + const planStatus = Buffer.concat([encodeMessage(1, plan), encodeMessage(3, Buffer.from([0x12, 0x7f])), int(6, 0), int(8, 0)]); + const malformed = Buffer.concat([encodeMessage(1, Buffer.concat([int(10, 1), encodeMessage(13, planStatus)])), encodeMessage(2, plan)]); + expect(decodeDevinUserStatus(malformed)).toBeNull(); + }); + + test("a response without PlanStatus is not a usable status", () => { + expect(decodeDevinUserStatus(encodeMessage(1, encodeString(3, "Fixture User")))).toBeNull(); + expect(decodeDevinUserStatus(Buffer.alloc(0))).toBeNull(); + }); +}); + +describe("fetchDevinQuota transport", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + test("sends a unary proto POST to the allowlisted host with redirects refused", async () => { + let seen: { url: string; init?: RequestInit } | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen = { url: String(input), init }; + return new Response(new Uint8Array(quotaPlan()), { status: 200 }); + }) as typeof fetch; + const result = await fetchDevinQuota("devin", KEY, "https://server.codeium.com"); + expect(typeof result === "object" && result?.quota.weeklyPercent).toBe(16); + expect(seen?.url).toBe("https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus"); + expect(seen?.init?.redirect).toBe("error"); + expect((seen?.init?.headers as Record)["Content-Type"]).toBe("application/proto"); + // GetUserStatusRequest #1 carries the Metadata, whose #3 is the api_key. + const body = Buffer.from(seen?.init?.body as Uint8Array); + const metadata = [...iterFields(body)].find(f => f.num === 1)?.value as Buffer; + expect(([...iterFields(metadata)].find(f => f.num === 3)?.value as Buffer).toString()).toBe(KEY); + }); + + test("only 401 is terminal; 403, other 4xx, 5xx and network faults keep last-good", async () => { + globalThis.fetch = (async () => new Response("unauthenticated: " + KEY, { status: 401 })) as unknown as typeof fetch; + expect(await fetchDevinQuota("devin", KEY, undefined)).toBe(TERMINAL_QUOTA_FAILURE); + // A 403 can forbid this one RPC for a key that still serves chat. + for (const status of [400, 403, 404, 408, 409, 422, 429, 499, 503]) { + globalThis.fetch = (async () => new Response("", { status })) as unknown as typeof fetch; + expect(await fetchDevinQuota("devin", KEY, undefined)).toBeNull(); + } + globalThis.fetch = (async () => { throw new TypeError("network down"); }) as unknown as typeof fetch; + expect(await fetchDevinQuota("devin", KEY, undefined)).toBeNull(); + }); + + test("a truncated or oversized body keeps the last-good row instead of throwing", async () => { + // Tag for field 1 varint, then a continuation byte with nothing after it. + globalThis.fetch = (async () => new Response(new Uint8Array([0x08, 0x80]), { status: 200 })) as unknown as typeof fetch; + expect(await fetchDevinQuota("devin", KEY, undefined)).toBeNull(); + globalThis.fetch = (async () => new Response(new Uint8Array(QUOTA_RESPONSE_MAX_BYTES + 1), { status: 200 })) as unknown as typeof fetch; + expect(await fetchDevinQuota("devin", KEY, undefined)).toBeNull(); + }); + + test("one deadline stops a continuing byte drip and keeps last-good", async () => { + const deadline = new AbortController(); + const reason = new DOMException("fixture deadline", "TimeoutError"); + const budgets: number[] = []; + const timeout = spyOn(AbortSignal, "timeout").mockImplementation(ms => { + budgets.push(ms); + return deadline.signal; + }); + const bytes = new Uint8Array(quotaPlan()); + let index = 0; + let cancelledWith: unknown; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(init?.signal).toBe(deadline.signal); + return new Response(new ReadableStream({ + pull(controller) { + controller.enqueue(bytes.subarray(index, ++index)); + if (index === 3) deadline.abort(reason); + if (index === bytes.length) controller.close(); + }, + cancel(value) { cancelledWith = value; }, + }, { highWaterMark: 0 }), { status: 200 }); + }) as typeof fetch; + try { + expect(await fetchDevinQuota("devin", KEY, undefined)).toBeNull(); + expect(budgets).toEqual([8_000]); + expect(cancelledWith).toBe(reason); + } finally { + timeout.mockRestore(); + } + }); + + test("a missing plan or a truncated nested timestamp keeps the last-good row", async () => { + const plan = planInfo({ tier: 16, name: "Pro", billing: 1 }); + const truncatedEnd = Buffer.concat([encodeMessage(1, plan), encodeMessage(3, Buffer.from([0x12, 0x7f])), int(6, 0), int(8, 0)]); + const noPlan = Buffer.concat([encodeMessage(3, int(1, PLAN_END)), int(6, 0), int(8, 0)]); + for (const planStatus of [truncatedEnd, noPlan]) { + const body = encodeMessage(1, Buffer.concat([int(10, 1), encodeMessage(13, planStatus)])); + globalThis.fetch = (async () => new Response(new Uint8Array(body), { status: 200 })) as unknown as typeof fetch; + expect(await fetchDevinQuota("devin", KEY, undefined)).toBeNull(); + } + }); + + test("a complete field followed by a truncated one is malformed, not authoritative-empty", async () => { + // An empty UserStatus, then field 2 declaring 127 bytes with none present. + globalThis.fetch = (async () => new Response(new Uint8Array([0x0a, 0x02, 0x6a, 0x00, 0x12, 0x7f]), { status: 200 })) as unknown as typeof fetch; + expect(await fetchDevinQuota("devin", KEY, undefined)).toBeNull(); + }); + + test("a decoded status with nothing measurable is authoritative-empty", async () => { + const unlimited = userStatusResponse(planInfo({ tier: 17, name: "Max", billing: 2 }), { 8: -1 }, false); + globalThis.fetch = (async () => new Response(new Uint8Array(unlimited), { status: 200 })) as unknown as typeof fetch; + expect(await fetchDevinQuota("devin", KEY, undefined)).toBe(AUTHORITATIVE_EMPTY_QUOTA); + }); + + test("never sends the key to a host outside the allowlist", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls += 1; return new Response("", { status: 200 }); }) as unknown as typeof fetch; + expect(await fetchDevinQuota("devin", KEY, "https://attacker.example")).toBeNull(); + expect(calls).toBe(0); + }); +}); + +describe("Devin provider quota through the aggregator", () => { + const originalFetch = globalThis.fetch; + const previousHome = process.env.OPENCODEX_HOME; + let home: string; + const config = { defaultProvider: "devin", providers: { devin: { adapter: "devin", authMode: "oauth", baseUrl: "https://server.codeium.com" } } } as unknown as OcxConfig; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-devin-quota-")); + process.env.OPENCODEX_HOME = home; + clearProviderQuotaCache(); + }); + afterEach(() => { + globalThis.fetch = originalFetch; + clearProviderQuotaCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + }); + + test("the active account's quota is published without leaking the key", async () => { + await saveCredential("devin", { access: KEY, refresh: KEY, expires: Number.MAX_SAFE_INTEGER, apiBaseUrl: "https://server.codeium.com" }); + globalThis.fetch = (async () => new Response(new Uint8Array(quotaPlan()), { status: 200 })) as unknown as typeof fetch; + const result = await fetchProviderQuotaReports(config, true); + expect(result.reports[0]).toMatchObject({ provider: "devin", source: "devin:user-status", quota: { weeklyPercent: 16 } }); + expect(JSON.stringify(result)).not.toContain(KEY); + }); + + test("a legacy credential probes the configured EU tenant", async () => { + await saveCredential("devin", { access: KEY, refresh: KEY, expires: Number.MAX_SAFE_INTEGER }); + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + urls.push(String(input)); + return new Response(new Uint8Array(quotaPlan()), { status: 200 }); + }) as typeof fetch; + const euConfig = { ...config, providers: { devin: { ...config.providers.devin, baseUrl: "https://eu.windsurf.com/_route/api_server" } } }; + const result = await fetchProviderQuotaReports(euConfig, true); + expect(result.reports[0]?.quota.weeklyPercent).toBe(16); + expect(urls).toEqual(["https://eu.windsurf.com/_route/api_server/exa.seat_management_pb.SeatManagementService/GetUserStatus"]); + }); + + test("a credential-owned tenant takes precedence over the configured base URL", async () => { + await saveCredential("devin", { access: KEY, refresh: KEY, expires: Number.MAX_SAFE_INTEGER, apiBaseUrl: "https://eu.windsurf.com/_route/api_server" }); + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + urls.push(String(input)); + return new Response(new Uint8Array(quotaPlan()), { status: 200 }); + }) as typeof fetch; + await fetchProviderQuotaReports(config, true); + expect(urls).toEqual(["https://eu.windsurf.com/_route/api_server/exa.seat_management_pb.SeatManagementService/GetUserStatus"]); + }); + + test("an unallowlisted configured host never receives a legacy credential key", async () => { + await saveCredential("devin", { access: KEY, refresh: KEY, expires: Number.MAX_SAFE_INTEGER }); + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + urls.push(String(input)); + return new Response(new Uint8Array(quotaPlan()), { status: 200 }); + }) as typeof fetch; + const unsafeConfig = { ...config, providers: { devin: { ...config.providers.devin, baseUrl: "https://attacker.example" } } }; + await fetchProviderQuotaReports(unsafeConfig, true); + expect(urls).toEqual(["https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus"]); + }); + + test("a rejected key publishes no row", async () => { + await saveCredential("devin", { access: KEY, refresh: KEY, expires: Number.MAX_SAFE_INTEGER, apiBaseUrl: "https://server.codeium.com" }); + globalThis.fetch = (async () => new Response("", { status: 401 })) as unknown as typeof fetch; + const result = await fetchProviderQuotaReports(config, true); + expect(result.reports.filter(r => r.provider === "devin")).toEqual([]); + }); +}); + +describe("Devin quota cache identity", () => { + test("a tenant host change invalidates the cached reading; a credential without one keeps its key", async () => { + const { quotaCredentialIdentity } = await import("../../src/providers/quota/account-cache"); + const target = { adapter: "devin", authMode: "oauth" } as any; + const base = { access: KEY, refresh: "", expires: Number.MAX_SAFE_INTEGER } as any; + const us = quotaCredentialIdentity("devin", "a1", { ...base, apiBaseUrl: "https://server.codeium.com" }, target); + const eu = quotaCredentialIdentity("devin", "a1", { ...base, apiBaseUrl: "https://eu.windsurf.com/_route/api_server" }, target); + expect(us).not.toBe(eu); + expect(quotaCredentialIdentity("devin", "a1", base, target)).toBe(quotaCredentialIdentity("devin", "a1", { ...base, apiBaseUrl: undefined }, target)); + }); + test("another provider's apiBaseUrl does not alter its quota credential identity", async () => { + const { quotaCredentialIdentity } = await import("../../src/providers/quota/account-cache"); + const target = { adapter: "openai-chat", authMode: "oauth" } as any; + const base = { access: KEY, refresh: "", expires: Number.MAX_SAFE_INTEGER } as any; + expect(quotaCredentialIdentity("github-copilot", "a1", base, target)).toBe( + quotaCredentialIdentity("github-copilot", "a1", { ...base, apiBaseUrl: "https://api.githubcopilot.com" }, target), + ); + }); +});