diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e49aa93e6..fc7b25048 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -308,6 +308,31 @@ Provider plan, and CLI auth bridging for Go/Pro subscriptions is not yet availab > hosts and schemas and are not routed by this preset. > Live discovery for this preset is capped at a 1 MiB response and 256 raw model rows. +### A6API credit quota + +A custom `openai-chat` provider using `authMode: "key"` and the canonical +`https://api.a6api.com` or `https://api.a6api.com/v1` base URL receives an A6API credit meter in +the dashboard and from `ocx account refresh `. The provider name is arbitrary; detection +uses the canonical HTTPS endpoint. The meter converts A6API token units into USD using the account's +hard credit limit and displays the percentage consumed plus remaining credit. Token expiration is +not shown as a quota reset because expiration does not imply that credit replenishes. + +```json +{ + "providers": { + "my-a6": { + "adapter": "openai-chat", + "authMode": "key", + "baseUrl": "https://api.a6api.com/v1", + "apiKey": "${A6API_API_KEY}" + } + } +} +``` + +Quota probes send only the active key to the canonical A6API host and reject redirects. Malformed, +negative, or internally inconsistent billing totals produce no report rather than a misleading bar. + > **Tencent Cloud Coding Plan usage restriction:** Tencent documents this subscription for > interactive coding tools only. General API automation, custom application backends, and > non-interactive batch use are prohibited and may cause the plan key to be suspended. diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 87cb363b8..6792c0a91 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -230,6 +230,15 @@ API アクセスには Provider プランが必要で、Go/Pro サブスクリ > エンドポイントはホストとスキーマが異なるため、このプリセットではルーティングされません。 > このプリセットのライブディスカバリーは、レスポンス 1 MiB、モデルの生行 256 件が上限です。 +### A6API クレジットクォータ + +`openai-chat`、`authMode: "key"`、正規の `https://api.a6api.com` または +`https://api.a6api.com/v1` を使うカスタムプロバイダーでは、ダッシュボードと +`ocx account refresh ` に A6API クレジット使用量が表示されます。プロバイダー名は任意です。 +アカウントの hard credit limit を基準にトークン単位を USD に換算し、使用率と残高を表示します。トークン期限は補充を意味しないため、クォータの +リセットとしては表示しません。アクティブキーだけを正規ホストへ送信し、リダイレクトを拒否します。負数や +整合しない請求合計からはレポートを生成しません。 + > **Tencent Cloud Coding Plan の利用制限:** Tencent はこのサブスクリプションを対話型 > コーディングツール専用としています。一般的な API 自動化、カスタムアプリのバックエンド、 > 非対話型バッチ利用は禁止されており、プランキーが停止される場合があります。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 84e093b67..35a27e000 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -229,6 +229,15 @@ Bearer API 키를 사용합니다. registry가 소유하는 DeepInfra 모델 목 > 호스트와 스키마가 다르므로 이 프리셋으로 라우팅되지 않습니다. > 이 프리셋의 실시간 검색은 응답 1 MiB와 원시 모델 행 256개로 제한됩니다. +### A6API 크레딧 쿼터 + +`openai-chat`, `authMode: "key"`, 공식 `https://api.a6api.com` 또는 +`https://api.a6api.com/v1` 주소를 사용하는 사용자 지정 프로바이더는 대시보드와 +`ocx account refresh `에서 A6API 크레딧 사용량을 표시합니다. 프로바이더 이름은 자유롭게 정할 수 +있습니다. 계정의 hard credit limit을 기준으로 토큰 단위를 USD로 환산해 사용률과 남은 크레딧을 표시하며, 토큰 만료는 충전을 뜻하지 않으므로 쿼터 +리셋으로 표시하지 않습니다. 활성 키만 공식 호스트로 전송하고 리디렉션을 거부하며, 음수이거나 서로 일치하지 +않는 결제 합계에는 보고서를 만들지 않습니다. + > **Tencent Cloud Coding Plan 사용 제한:** Tencent는 이 구독을 대화형 코딩 도구 전용으로 > 안내합니다. 일반 API 자동화, 사용자 애플리케이션 백엔드 및 비대화형 일괄 호출은 금지되며 > 플랜 키가 정지될 수 있습니다. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 64926e0a7..9511d937e 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -241,6 +241,16 @@ endpoint в него не входят. Ключи создаются в [Hyperb > схемы и этим пресетом не маршрутизируются. > Для этого пресета live discovery ограничен ответом размером 1 MiB и 256 исходными строками моделей. +### Квота кредитов A6API + +Пользовательский провайдер с `openai-chat`, `authMode: "key"` и каноническим адресом +`https://api.a6api.com` или `https://api.a6api.com/v1` показывает расход кредитов A6API в +дашборде и в `ocx account refresh `. Имя провайдера может быть любым. Единицы токенов +пересчитываются в USD по hard credit limit учётной записи; отображаются процент расхода и остаток. Срок действия токена не считается +сбросом квоты, поскольку он не означает пополнение. Только активный ключ отправляется на +канонический хост, перенаправления отклоняются, а отрицательные или несогласованные итоги биллинга +не создают отчёт. + > **Ограничение Tencent Cloud Coding Plan:** Tencent разрешает использовать эту подписку только > в интерактивных инструментах программирования. Автоматизация общего API, серверы пользовательских > приложений и неинтерактивные пакетные вызовы запрещены и могут привести к блокировке ключа плана. diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index b46f8a7f2..d8fb1b86a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -213,6 +213,14 @@ bearer 密钥;API 访问需要 Provider 套餐,Go/Pro 订阅用户的 CLI > 专用 Truss `predict` 端点使用不同的主机和请求 schema,不由此预设路由。 > 该预设的实时发现上限为 1 MiB 响应和 256 条原始模型记录。 +### A6API 信用额度 + +使用 `openai-chat`、`authMode: "key"` 以及规范地址 `https://api.a6api.com` 或 +`https://api.a6api.com/v1` 的自定义提供商,会在仪表板和 `ocx account refresh ` +中显示 A6API 信用使用情况;提供商名称可以自定义。系统依据账户的 hard credit limit 将令牌单位换算为 USD,并显示已用百分比和剩余额度。 +令牌到期不代表额度补充,因此不会显示为配额重置。只有当前活动密钥会发送到规范主机,重定向会被拒绝;负数 +或内部不一致的计费总数不会生成报告。 + > **腾讯云 Coding Plan 使用限制:**腾讯将此订阅限定为交互式编程工具使用。禁止通用 API > 自动化、自定义应用后端和非交互式批量调用;违规使用可能导致套餐密钥被停用。 diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 8e0228ec6..1e5b46fb5 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -10,6 +10,7 @@ import { resolveEnvValue } from "../config"; import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth"; import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; +import { apiKeyPoolEntryId } from "./api-keys"; import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers"; @@ -32,6 +33,7 @@ const CACHE_TTL_MS = 5 * 60_000; const REQUEST_TIMEOUT_MS = 8_000; const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`; +const A6API_BASE_URL = "https://api.a6api.com"; /** Keep a failed probe's previous row at most this long before dropping it. */ const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS; const nativeMainReportGenerations = new WeakMap(); @@ -43,6 +45,8 @@ export function setProviderQuotaBeforePublishForTests( ): void { providerQuotaBeforePublishForTests = hook; } +const TERMINAL_QUOTA_FAILURE = Symbol("terminal-quota-failure"); +type ProviderQuotaProbeResult = ProviderQuotaReport | null | typeof TERMINAL_QUOTA_FAILURE; export interface ProviderQuotaWindow { label: string; @@ -89,7 +93,13 @@ export function clearProviderQuotaCache(): void { function cacheKey(config: OcxConfig): string { const providers = Object.entries(config.providers) - .map(([name, provider]) => `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}`) + .map(([name, provider]) => { + const resolvedKey = typeof provider.apiKey === "string" + ? resolveEnvValue(provider.apiKey)?.trim() + : undefined; + const activeKeyId = resolvedKey ? apiKeyPoolEntryId(resolvedKey) : "none"; + return `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}:${activeKeyId}`; + }) .sort() .join("|"); return `${config.defaultProvider}|${providers}`; @@ -228,6 +238,77 @@ function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConf return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider); } +function isCanonicalA6apiBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === A6API_BASE_URL || normalized === `${A6API_BASE_URL}/v1`; +} + +function a6apiPayload(value: unknown): Record | null { + const body = asRecord(value); + return asRecord(body?.data) ?? body; +} + +function firstFinite(record: Record | null, names: string[]): number | undefined { + if (!record) return undefined; + for (const name of names) { + const value = toFiniteNumber(record[name]); + if (value !== undefined) return value; + } + return undefined; +} + +async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Promise { + // Never send a configured API key to a lookalike host or through a redirect. + if (!isCanonicalA6apiBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}` } as const; + const [subscriptionResponse, tokenResponse] = await Promise.all([ + fetch(`${A6API_BASE_URL}/dashboard/billing/subscription`, { + headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }), + fetch(`${A6API_BASE_URL}/api/usage/token/`, { + headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }), + ]); + if (!subscriptionResponse.ok || !tokenResponse.ok) { + const statuses = [subscriptionResponse.status, tokenResponse.status]; + // 408/429 are transient (timeout/throttle), not invalid-account signals: keep the + // last-good row like 5xx/network failures. 401/403 (bad key) and 404 (contract change) + // stay terminal. + return statuses.some(status => status >= 400 && status < 500 && status !== 429 && status !== 408) + ? TERMINAL_QUOTA_FAILURE + : null; + } + const subscription = a6apiPayload(await subscriptionResponse.json().catch(() => null)); + const token = a6apiPayload(await tokenResponse.json().catch(() => null)); + const limitUsd = firstFinite(subscription, ["hard_limit_usd"]); + const grantedUnits = firstFinite(token, ["total_granted"]); + const usedUnits = firstFinite(token, ["total_used"]); + const availableUnits = firstFinite(token, ["total_available"]); + const reconciledUnits = usedUnits !== undefined && availableUnits !== undefined + ? usedUnits + availableUnits + : undefined; + const reconciliationTolerance = grantedUnits !== undefined + ? Math.abs(grantedUnits) * 1e-9 + : 0; + if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined + || availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0 + || usedUnits < 0 || availableUnits < 0 + || reconciledUnits === undefined + || Math.abs(reconciledUnits - grantedUnits) > reconciliationTolerance) return TERMINAL_QUOTA_FAILURE; + const usdPerUnit = limitUsd / grantedUnits; + const usedUsd = usedUnits * usdPerUnit; + const remainingUsd = Math.max(0, availableUnits * usdPerUnit); + const percent = normalizePercent((usedUsd / limitUsd) * 100); + if (percent === undefined) return TERMINAL_QUOTA_FAILURE; + const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`; + return report(provider, "a6api:billing", { + customWindows: [{ label, percent }], + updatedAt: Date.now(), + }); +} + function report( provider: string, source: string, @@ -671,7 +752,7 @@ export async function fetchProviderAccountQuotas( function normalizedBaseUrl(value: string): string | null { try { const url = new URL(value); - if (url.search || url.hash) return null; + if (url.username || url.password || url.search || url.hash) return null; return `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`; } catch { return null; @@ -1079,7 +1160,7 @@ async function maybeFetchProviderQuota( config: OcxConfig, forceRefresh: boolean, prefetchedCodexSnapshot?: CodexAuthAccountsSnapshotPromise, -): Promise { +): Promise { if (provider.disabled === true) return null; try { if (isBuiltInChatGptForwardProvider(name, provider)) { @@ -1095,6 +1176,9 @@ async function maybeFetchProviderQuota( if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) { return fetchKimiQuota(name, provider); } + if ((provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl)) { + return fetchA6apiQuota(name, provider); + } return null; } catch { return null; @@ -1125,11 +1209,15 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh const promise = (async (): Promise => { const previous = cache && cache.key === key ? cache.response.reports : []; - const fresh = (await Promise.all( + const probeResults = await Promise.all( Object.entries(config.providers).map(([name, provider]) => ( - maybeFetchProviderQuota(name, provider, config, forceRefresh, prefetchedCodexSnapshot) - )), - )).filter((item): item is ProviderQuotaReport => item !== null); + maybeFetchProviderQuota(name, provider, config, forceRefresh, prefetchedCodexSnapshot) + )), + ); + const fresh = probeResults.filter((item): item is ProviderQuotaReport => item !== null && item !== TERMINAL_QUOTA_FAILURE); + const terminalFailures = new Set( + Object.keys(config.providers).filter((_, index) => probeResults[index] === TERMINAL_QUOTA_FAILURE), + ); await providerQuotaBeforePublishForTests?.(); let commitKey: string | null = null; if (epoch === invalidationEpoch) { @@ -1137,8 +1225,8 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh commitKey = typeof commitKeyCandidate === "string" ? commitKeyCandidate : await commitKeyCandidate; } - // Keep bounded last-good rows when a probe fails (e.g. transient upstream flake); never - // re-stamp their timestamps, and drop rows older than LAST_GOOD_MAX_AGE_MS. + // Keep bounded last-good rows when a probe fails transiently; terminal-invalid provider + // responses explicitly suppress their old row. Never re-stamp preserved timestamps. // Note: the cache key encodes the provider set (name/adapter/authMode/disabled/baseUrl), // so previous rows always correspond to currently configured, enabled providers — a // disabled or removed provider changes the key and starts from an empty previous set. @@ -1159,6 +1247,11 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh generationMismatchedProviders.add(item.provider); } } + // Terminal-invalid probes suppress their previous row (transient failures keep it). + for (const provider of terminalFailures) { + byProvider.delete(provider); + generationMismatchedProviders.delete(provider); + } const response = { generatedAt: Date.now(), reports: [...byProvider.values()] }; // Commit only when this probe still holds authority (no clear/force superseded it). diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index da77da257..353c54b7a 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -248,6 +248,276 @@ describe("fetchProviderQuotaReports", () => { } as OcxConfig; } + function a6apiOnlyConfig(baseUrl = "https://api.a6api.com/v1"): OcxConfig { + return { + defaultProvider: "a6api", + providers: { + a6api: { adapter: "openai-chat", authMode: "key", baseUrl, apiKey: "a6api-secret" }, + }, + } as OcxConfig; + } + + test("A6API quota converts provider units to USD and exposes a displayable credit window", async () => { + const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const headers = init?.headers as Record | undefined; + seen.push({ url, authorization: headers?.Authorization, redirect: init?.redirect }); + if (url.endsWith("/dashboard/billing/subscription")) { + return new Response(JSON.stringify({ data: { hard_limit_usd: "20" } }), { status: 200 }); + } + return new Response(JSON.stringify({ data: { + total_granted: "20000000", + total_used: "5000000", + total_available: "15000000", + expires_at: "2026-08-01T00:00:00Z", + } }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(a6apiOnlyConfig(), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("a6api:billing"); + expect(result.reports[0]?.quota.customWindows).toEqual([{ + label: "API credits ($15.00 of $20.00 remaining)", + percent: 25, + }]); + expect(seen.map(row => row.url).sort()).toEqual([ + "https://api.a6api.com/api/usage/token/", + "https://api.a6api.com/dashboard/billing/subscription", + ]); + expect(seen.every(row => row.authorization === "Bearer a6api-secret")).toBe(true); + expect(seen.every(row => row.redirect === "error")).toBe(true); + }); + + test("A6API quota never sends API keys to a non-canonical base URL", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(a6apiOnlyConfig("https://attacker.example/v1"), true); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + + test("A6API quota drops incomplete or zero-limit billing payloads", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + return new Response(JSON.stringify(url.includes("subscription") + ? { data: { hard_limit_usd: 0 } } + : { data: { total_granted: 100, total_used: 20, total_available: 80 } }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(a6apiOnlyConfig(), true); + + expect(result.reports).toEqual([]); + }); + + test.each([ + { total_used: -1, total_available: 101 }, + { total_used: 1, total_available: -1 }, + { total_used: 80, total_available: 80 }, + { total_used: 20, total_available: 70 }, + ])("A6API quota drops malformed usage totals", async usage => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + return new Response(JSON.stringify(url.includes("subscription") + ? { data: { hard_limit_usd: 10 } } + : { data: { total_granted: 100, ...usage } }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(a6apiOnlyConfig(), true); + + expect(result.reports).toEqual([]); + }); + + test("A6API quota applies reconciliation tolerance relative to sub-unit grants", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => new Response(JSON.stringify( + String(input).includes("subscription") + ? { data: { hard_limit_usd: 10 } } + : { data: { total_granted: 0.1, total_used: 0.05, total_available: 0.0500000005 } }, + ), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(a6apiOnlyConfig(), true); + + expect(result.reports).toEqual([]); + }); + + test("A6API quota accepts equivalent canonical HTTPS URLs only", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response(JSON.stringify(String(input).includes("subscription") + ? { data: { hard_limit_usd: 10 } } + : { data: { total_granted: 100, total_used: 25, total_available: 75 } }), { status: 200 }); + }) as typeof fetch; + + const canonicalUrls = [ + "https://api.a6api.com", + "https://api.a6api.com/v1", + "https://API.A6API.COM:443/v1/", + ]; + for (const baseUrl of canonicalUrls) { + const result = await fetchProviderQuotaReports(a6apiOnlyConfig(baseUrl), true); + expect(result.reports).toHaveLength(1); + } + const credentialedUrl = "https://user" + "@api.a6api.com/v1"; + const credentialed = await fetchProviderQuotaReports(a6apiOnlyConfig(credentialedUrl), true); + + expect(credentialed.reports).toEqual([]); + expect(seen).toHaveLength(canonicalUrls.length * 2); + }); + + test("malformed API-key fields do not break unrelated quota reports", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => new Response(JSON.stringify( + String(input).includes("subscription") + ? { data: { hard_limit_usd: 10 } } + : { data: { total_granted: 100, total_used: 25, total_available: 75 } }, + ), { status: 200 })) as typeof fetch; + const config = a6apiOnlyConfig(); + config.providers.broken = { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://example.com/v1", + apiKey: 42, + } as unknown as OcxConfig["providers"][string]; + + const result = await fetchProviderQuotaReports(config, true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.provider).toBe("a6api"); + }); + + test("A6API quota is detected by canonical base URL for custom provider names", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => new Response(JSON.stringify( + String(input).includes("subscription") + ? { data: { hard_limit_usd: 10 } } + : { data: { total_granted: 100, total_used: 25, total_available: 75 } }, + ), { status: 200 })) as typeof fetch; + const config = a6apiOnlyConfig(); + config.defaultProvider = "my-a6"; + config.providers = { "my-a6": config.providers.a6api! }; + + const result = await fetchProviderQuotaReports(config, true); + + expect(result.reports[0]?.provider).toBe("my-a6"); + expect(result.reports[0]?.quota.customWindows?.[0]?.percent).toBe(25); + }); + + test("A6API quota cache follows the active API key", async () => { + const authorizations: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = init?.headers as Record | undefined; + authorizations.push(headers?.Authorization ?? ""); + const secondAccount = headers?.Authorization === "Bearer second-account-key"; + return new Response(JSON.stringify(String(input).includes("subscription") + ? { data: { hard_limit_usd: secondAccount ? 30 : 10 } } + : { data: { total_granted: 100, total_used: 20, total_available: 80 } }), { status: 200 }); + }) as typeof fetch; + const config = a6apiOnlyConfig(); + + const first = await fetchProviderQuotaReports(config); + config.providers.a6api!.apiKey = "second-account-key"; + const second = await fetchProviderQuotaReports(config); + + expect(first.reports[0]?.quota.customWindows?.[0]?.label).toContain("of $10.00 remaining"); + expect(second.reports[0]?.quota.customWindows?.[0]?.label).toContain("of $30.00 remaining"); + expect(authorizations).toContain("Bearer second-account-key"); + }); + + test("A6API quota drops a last-good row after a terminal-invalid refresh", async () => { + let malformed = false; + globalThis.fetch = (async (input: RequestInfo | URL) => new Response(JSON.stringify( + String(input).includes("subscription") + ? { data: { hard_limit_usd: 10 } } + : { data: malformed + ? { total_granted: 100, total_used: 20, total_available: 70 } + : { total_granted: 100, total_used: 20, total_available: 80 } }, + ), { status: 200 })) as typeof fetch; + const config = a6apiOnlyConfig(); + + const valid = await fetchProviderQuotaReports(config, true); + malformed = true; + const invalid = await fetchProviderQuotaReports(config, true); + + expect(valid.reports).toHaveLength(1); + expect(invalid.reports).toEqual([]); + }); + + test("A6API quota preserves a last-good row after a transient server failure", async () => { + let unavailable = false; + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (unavailable) return new Response("unavailable", { status: 503 }); + return new Response(JSON.stringify(String(input).includes("subscription") + ? { data: { hard_limit_usd: 10 } } + : { data: { total_granted: 100, total_used: 20, total_available: 80 } }), { status: 200 }); + }) as typeof fetch; + const config = a6apiOnlyConfig(); + + const valid = await fetchProviderQuotaReports(config, true); + unavailable = true; + const transientFailure = await fetchProviderQuotaReports(config, true); + + expect(transientFailure.reports).toEqual(valid.reports); + }); + + test("A6API quota treats a throttled 429 refresh as transient and keeps the last-good row", async () => { + let throttled = false; + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (throttled) return new Response("rate limited", { status: 429 }); + return new Response(JSON.stringify(String(input).includes("subscription") + ? { data: { hard_limit_usd: 10 } } + : { data: { total_granted: 100, total_used: 20, total_available: 80 } }), { status: 200 }); + }) as typeof fetch; + const config = a6apiOnlyConfig(); + + const valid = await fetchProviderQuotaReports(config, true); + throttled = true; + const throttledRefresh = await fetchProviderQuotaReports(config, true); + + expect(throttledRefresh.reports).toEqual(valid.reports); + }); + + test("A6API quota treats a timed-out 408 refresh as transient and keeps the last-good row", async () => { + let timedOut = false; + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (timedOut) return new Response("request timeout", { status: 408 }); + return new Response(JSON.stringify(String(input).includes("subscription") + ? { data: { hard_limit_usd: 10 } } + : { data: { total_granted: 100, total_used: 20, total_available: 80 } }), { status: 200 }); + }) as typeof fetch; + const config = a6apiOnlyConfig(); + + const valid = await fetchProviderQuotaReports(config, true); + const validUpdatedAt = valid.reports[0]?.quota.updatedAt; + timedOut = true; + const timedOutRefresh = await fetchProviderQuotaReports(config, true); + + expect(timedOutRefresh.reports).toEqual(valid.reports); + expect(timedOutRefresh.reports[0]?.quota.updatedAt).toBe(validUpdatedAt); + }); + + test("A6API quota drops the last-good row after a credential 401 refresh", async () => { + let rejected = false; + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (rejected) return new Response("unauthorized", { status: 401 }); + return new Response(JSON.stringify(String(input).includes("subscription") + ? { data: { hard_limit_usd: 10 } } + : { data: { total_granted: 100, total_used: 20, total_available: 80 } }), { status: 200 }); + }) as typeof fetch; + const config = a6apiOnlyConfig(); + + const valid = await fetchProviderQuotaReports(config, true); + rejected = true; + const rejectedRefresh = await fetchProviderQuotaReports(config, true); + + expect(valid.reports).toHaveLength(1); + expect(rejectedRefresh.reports).toEqual([]); + }); + test("Kimi quota never sends OAuth credentials to a non-canonical base URL", async () => { await saveCredential("kimi", { access: "kimi-access-secret", refresh: "kimi-refresh-secret", expires: Date.now() + 3600_000 }); const seen: string[] = [];