diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 4615c0726..cdd5d36b9 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -221,6 +221,31 @@ validates the key, and stores it. Notable entries: Most use the `openai-chat` adapter with a bearer key; a few that expose only an Anthropic-compatible endpoint (e.g. **Xiaomi MiMo**) use the `anthropic` adapter (`x-api-key`). +### 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 ef2dd1d54..39c409315 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -152,6 +152,15 @@ opencodex v2.7.1 には組み込みプリセットが 50 個含まれていま 大半は bearer キーと共に `openai-chat` アダプターを使い、Anthropic 互換エンドポイントのみを公開する一部 (例: **Xiaomi MiMo**)は `anthropic` アダプター(`x-api-key`)を使います。 +### 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 208839d81..7eb3651f0 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -152,6 +152,15 @@ opencodex v2.7.1에는 빌트인 프리셋이 50개 들어 있습니다. 키 방 대부분은 bearer 키와 함께 `openai-chat` 어댑터를 사용하며, Anthropic 호환 엔드포인트만 노출하는 일부 (예: **Xiaomi MiMo**)는 `anthropic` 어댑터(`x-api-key`)를 사용합니다. +### 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 d6df82506..7abe11970 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -163,6 +163,16 @@ opencodex поставляется с 53 встроенными пресетам только Anthropic-совместимую конечную точку (например, **Xiaomi MiMo**), используют адаптер `anthropic` (`x-api-key`). +### Квота кредитов 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 357ddbcac..d3579aee4 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -143,6 +143,14 @@ ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提 大多数使用带 bearer 密钥的 `openai-chat` adapter;少数仅暴露 Anthropic 兼容端点的提供商(例如 **Xiaomi MiMo**)使用 `anthropic` adapter(`x-api-key`)。 +### 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 9a5831df7..2b3859d32 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -4,6 +4,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"; @@ -15,8 +16,11 @@ 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 = 30 * 60_000; +const TERMINAL_QUOTA_FAILURE = Symbol("terminal-quota-failure"); +type ProviderQuotaProbeResult = ProviderQuotaReport | null | typeof TERMINAL_QUOTA_FAILURE; export interface ProviderQuotaWindow { label: string; @@ -62,7 +66,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}|${config.activeCodexAccountId ?? ""}|${providers}`; @@ -118,6 +128,74 @@ 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]; + return statuses.some(status => status >= 400 && status < 500) + ? 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, quota: ProviderQuota): ProviderQuotaReport | null { if (!hasQuotaRows(quota)) return null; return { @@ -432,7 +510,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; @@ -839,7 +917,7 @@ async function maybeFetchProviderQuota( provider: OcxProviderConfig, config: OcxConfig, forceRefresh: boolean, -): Promise { +): Promise { if (provider.disabled === true) return null; try { if (isBuiltInChatGptForwardProvider(name, provider)) return fetchChatGptForwardQuota(config, name, provider, forceRefresh); @@ -853,6 +931,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; @@ -875,19 +956,23 @@ 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)), - )).filter((item): item is ProviderQuotaReport => item !== null); - - // 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. + ); + 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), + ); + + // 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. const cutoff = Date.now() - LAST_GOOD_MAX_AGE_MS; const byProvider = new Map(); for (const item of previous) { - if (item.updatedAt >= cutoff) byProvider.set(item.provider, item); + if (item.updatedAt >= cutoff && !terminalFailures.has(item.provider)) byProvider.set(item.provider, item); } for (const item of fresh) byProvider.set(item.provider, item); diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 11e22aaa4..258483dff 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -237,6 +237,222 @@ 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("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[] = [];