From ec76393372c4231e3688bb291c7e72a2d1b9e179 Mon Sep 17 00:00:00 2001 From: byongshintv <47180856+byongshintv@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:27:10 +0900 Subject: [PATCH 1/8] feat(quota): report A6API credit usage Co-authored-by: OpenAI Codex --- src/providers/quota.ts | 59 ++++++++++++++++++++++++++++++ tests/provider-quota.test.ts | 69 ++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 8e0228ec6..fb9e66283 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -32,6 +32,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(); @@ -228,6 +229,61 @@ function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConf return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider); } +function isCanonicalA6apiBaseUrl(baseUrl: string): boolean { + const normalized = baseUrl.trim().replace(/\/+$/, ""); + 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) return 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"]); + if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined + || availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0) return null; + 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 null; + const resetAt = normalizeResetAt(token?.expires_at); + const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`; + return report(provider, "a6api:billing", { + customWindows: [{ label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }], + updatedAt: Date.now(), + }); +} + function report( provider: string, source: string, @@ -1095,6 +1151,9 @@ async function maybeFetchProviderQuota( if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) { return fetchKimiQuota(name, provider); } + if ((provider.authMode ?? "key") === "key" && name === "a6api") { + return fetchA6apiQuota(name, provider); + } return null; } catch { return null; diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index da77da257..10e9cb3d5 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -248,6 +248,75 @@ 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, + resetAt: Date.parse("2026-08-01T00:00:00Z"), + }]); + 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("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[] = []; From 734f65a4378f511a03171ee99c7d50aa23bee4e5 Mon Sep 17 00:00:00 2001 From: byongshintv <47180856+byongshintv@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:05:05 +0900 Subject: [PATCH 2/8] fix(quota): address A6API review findings Co-authored-by: OpenAI Codex --- src/providers/quota.ts | 15 ++++++---- tests/provider-quota.test.ts | 54 +++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index fb9e66283..6d07f370f 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"; @@ -90,7 +91,11 @@ 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 = resolveEnvValue(provider.apiKey)?.trim(); + 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}`; @@ -270,16 +275,16 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro const usedUnits = firstFinite(token, ["total_used"]); const availableUnits = firstFinite(token, ["total_available"]); if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined - || availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0) return null; + || availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0 + || usedUnits < 0 || availableUnits < 0) return null; 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 null; - const resetAt = normalizeResetAt(token?.expires_at); const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`; return report(provider, "a6api:billing", { - customWindows: [{ label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }], + customWindows: [{ label, percent }], updatedAt: Date.now(), }); } @@ -1151,7 +1156,7 @@ async function maybeFetchProviderQuota( if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) { return fetchKimiQuota(name, provider); } - if ((provider.authMode ?? "key") === "key" && name === "a6api") { + if ((provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl)) { return fetchA6apiQuota(name, provider); } return null; diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 10e9cb3d5..af583ce94 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -281,7 +281,6 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "API credits ($15.00 of $20.00 remaining)", percent: 25, - resetAt: Date.parse("2026-08-01T00:00:00Z"), }]); expect(seen.map(row => row.url).sort()).toEqual([ "https://api.a6api.com/api/usage/token/", @@ -317,6 +316,59 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports).toEqual([]); }); + test.each([ + { total_used: -1, total_available: 101 }, + { total_used: 1, total_available: -1 }, + ])("A6API quota drops negative 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 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("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[] = []; From 0905059f5338375bd6d5f38c10fa9ebdde2b4f52 Mon Sep 17 00:00:00 2001 From: byongshintv <47180856+byongshintv@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:20:39 +0900 Subject: [PATCH 3/8] fix(quota): harden A6API billing validation Co-authored-by: OpenAI Codex --- .../src/content/docs/guides/providers.md | 25 +++++++++++ src/providers/quota.ts | 11 +++-- tests/provider-quota.test.ts | 41 ++++++++++++++++++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e49aa93e6..656c2cd67 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 the active key only 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/src/providers/quota.ts b/src/providers/quota.ts index 6d07f370f..f44e6e870 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -92,7 +92,9 @@ export function clearProviderQuotaCache(): void { function cacheKey(config: OcxConfig): string { const providers = Object.entries(config.providers) .map(([name, provider]) => { - const resolvedKey = resolveEnvValue(provider.apiKey)?.trim(); + 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}`; }) @@ -235,7 +237,7 @@ function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConf } function isCanonicalA6apiBaseUrl(baseUrl: string): boolean { - const normalized = baseUrl.trim().replace(/\/+$/, ""); + const normalized = normalizedBaseUrl(baseUrl); return normalized === A6API_BASE_URL || normalized === `${A6API_BASE_URL}/v1`; } @@ -276,7 +278,8 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro const availableUnits = firstFinite(token, ["total_available"]); if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined || availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0 - || usedUnits < 0 || availableUnits < 0) return null; + || usedUnits < 0 || availableUnits < 0 + || usedUnits + availableUnits > grantedUnits) return null; const usdPerUnit = limitUsd / grantedUnits; const usedUsd = usedUnits * usdPerUnit; const remainingUsd = Math.max(0, availableUnits * usdPerUnit); @@ -732,7 +735,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; diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index af583ce94..e36242428 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -319,7 +319,8 @@ describe("fetchProviderQuotaReports", () => { test.each([ { total_used: -1, total_available: 101 }, { total_used: 1, total_available: -1 }, - ])("A6API quota drops negative usage totals", async usage => { + { total_used: 80, total_available: 80 }, + ])("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") @@ -332,6 +333,44 @@ describe("fetchProviderQuotaReports", () => { 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 equivalent = await fetchProviderQuotaReports(a6apiOnlyConfig("https://API.A6API.COM:443/v1/"), true); + const credentialedUrl = "https://user" + "@api.a6api.com/v1"; + const credentialed = await fetchProviderQuotaReports(a6apiOnlyConfig(credentialedUrl), true); + + expect(equivalent.reports).toHaveLength(1); + expect(credentialed.reports).toEqual([]); + expect(seen).toHaveLength(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") From 65ee7f4a4b5a3cbc60e52f1f6869b8dd174c64de Mon Sep 17 00:00:00 2001 From: byongshintv <47180856+byongshintv@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:35:35 +0900 Subject: [PATCH 4/8] fix(quota): reconcile A6API billing totals Co-authored-by: OpenAI Codex --- docs-site/src/content/docs/guides/providers.md | 2 +- docs-site/src/content/docs/ja/guides/providers.md | 9 +++++++++ docs-site/src/content/docs/ko/guides/providers.md | 9 +++++++++ docs-site/src/content/docs/ru/guides/providers.md | 10 ++++++++++ .../src/content/docs/zh-cn/guides/providers.md | 8 ++++++++ src/providers/quota.ts | 9 ++++++++- tests/provider-quota.test.ts | 14 +++++++++++--- 7 files changed, 56 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 656c2cd67..fc7b25048 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -330,7 +330,7 @@ not shown as a quota reset because expiration does not imply that credit repleni } ``` -Quota probes send the active key only to the canonical A6API host and reject redirects. Malformed, +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 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 87cb363b8..646dc05f6 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 クレジット使用量が表示されます。プロバイダー名は任意です。 +トークン単位を 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..9d6a789af 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 크레딧 사용량을 표시합니다. 프로바이더 이름은 자유롭게 정할 수 +있습니다. 토큰 단위를 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..d544a951c 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; отображаются процент расхода и остаток. Срок действия токена не считается +сбросом квоты, поскольку он не означает пополнение. Только активный ключ отправляется на +канонический хост, перенаправления отклоняются, а отрицательные или несогласованные итоги биллинга +не создают отчёт. + > **Ограничение 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..a2cdb7875 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 信用使用情况;提供商名称可以自定义。系统将令牌单位换算为 USD,并显示已用百分比和剩余额度。 +令牌到期不代表额度补充,因此不会显示为配额重置。只有当前活动密钥会发送到规范主机,重定向会被拒绝;负数 +或内部不一致的计费总数不会生成报告。 + > **腾讯云 Coding Plan 使用限制:**腾讯将此订阅限定为交互式编程工具使用。禁止通用 API > 自动化、自定义应用后端和非交互式批量调用;违规使用可能导致套餐密钥被停用。 diff --git a/src/providers/quota.ts b/src/providers/quota.ts index f44e6e870..6c4abf630 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -276,10 +276,17 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro 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.max(1, Math.abs(grantedUnits)) * 1e-9 + : 0; if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined || availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0 || usedUnits < 0 || availableUnits < 0 - || usedUnits + availableUnits > grantedUnits) return null; + || reconciledUnits === undefined + || Math.abs(reconciledUnits - grantedUnits) > reconciliationTolerance) return null; const usdPerUnit = limitUsd / grantedUnits; const usedUsd = usedUnits * usdPerUnit; const remainingUsd = Math.max(0, availableUnits * usdPerUnit); diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index e36242428..2e94dd4b9 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -320,6 +320,7 @@ describe("fetchProviderQuotaReports", () => { { 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); @@ -342,13 +343,20 @@ describe("fetchProviderQuotaReports", () => { : { data: { total_granted: 100, total_used: 25, total_available: 75 } }), { status: 200 }); }) as typeof fetch; - const equivalent = await fetchProviderQuotaReports(a6apiOnlyConfig("https://API.A6API.COM:443/v1/"), true); + 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(equivalent.reports).toHaveLength(1); expect(credentialed.reports).toEqual([]); - expect(seen).toHaveLength(2); + expect(seen).toHaveLength(canonicalUrls.length * 2); }); test("malformed API-key fields do not break unrelated quota reports", async () => { From 7e724aa37474702bceabe501b20dcf9d4eed9c81 Mon Sep 17 00:00:00 2001 From: byongshintv <47180856+byongshintv@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:48:55 +0900 Subject: [PATCH 5/8] fix(quota): tighten A6API reconciliation tolerance Co-authored-by: OpenAI Codex --- docs-site/src/content/docs/ja/guides/providers.md | 2 +- docs-site/src/content/docs/ko/guides/providers.md | 2 +- docs-site/src/content/docs/ru/guides/providers.md | 2 +- docs-site/src/content/docs/zh-cn/guides/providers.md | 2 +- src/providers/quota.ts | 2 +- tests/provider-quota.test.ts | 12 ++++++++++++ 6 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 646dc05f6..6792c0a91 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -235,7 +235,7 @@ API アクセスには Provider プランが必要で、Go/Pro サブスクリ `openai-chat`、`authMode: "key"`、正規の `https://api.a6api.com` または `https://api.a6api.com/v1` を使うカスタムプロバイダーでは、ダッシュボードと `ocx account refresh ` に A6API クレジット使用量が表示されます。プロバイダー名は任意です。 -トークン単位を USD に換算し、使用率と残高を表示します。トークン期限は補充を意味しないため、クォータの +アカウントの hard credit limit を基準にトークン単位を USD に換算し、使用率と残高を表示します。トークン期限は補充を意味しないため、クォータの リセットとしては表示しません。アクティブキーだけを正規ホストへ送信し、リダイレクトを拒否します。負数や 整合しない請求合計からはレポートを生成しません。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 9d6a789af..35a27e000 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -234,7 +234,7 @@ Bearer API 키를 사용합니다. registry가 소유하는 DeepInfra 모델 목 `openai-chat`, `authMode: "key"`, 공식 `https://api.a6api.com` 또는 `https://api.a6api.com/v1` 주소를 사용하는 사용자 지정 프로바이더는 대시보드와 `ocx account refresh `에서 A6API 크레딧 사용량을 표시합니다. 프로바이더 이름은 자유롭게 정할 수 -있습니다. 토큰 단위를 USD로 환산해 사용률과 남은 크레딧을 표시하며, 토큰 만료는 충전을 뜻하지 않으므로 쿼터 +있습니다. 계정의 hard credit limit을 기준으로 토큰 단위를 USD로 환산해 사용률과 남은 크레딧을 표시하며, 토큰 만료는 충전을 뜻하지 않으므로 쿼터 리셋으로 표시하지 않습니다. 활성 키만 공식 호스트로 전송하고 리디렉션을 거부하며, 음수이거나 서로 일치하지 않는 결제 합계에는 보고서를 만들지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index d544a951c..9511d937e 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -246,7 +246,7 @@ endpoint в него не входят. Ключи создаются в [Hyperb Пользовательский провайдер с `openai-chat`, `authMode: "key"` и каноническим адресом `https://api.a6api.com` или `https://api.a6api.com/v1` показывает расход кредитов A6API в дашборде и в `ocx account refresh `. Имя провайдера может быть любым. Единицы токенов -пересчитываются в USD; отображаются процент расхода и остаток. Срок действия токена не считается +пересчитываются в USD по hard credit limit учётной записи; отображаются процент расхода и остаток. Срок действия токена не считается сбросом квоты, поскольку он не означает пополнение. Только активный ключ отправляется на канонический хост, перенаправления отклоняются, а отрицательные или несогласованные итоги биллинга не создают отчёт. 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 a2cdb7875..d8fb1b86a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -217,7 +217,7 @@ bearer 密钥;API 访问需要 Provider 套餐,Go/Pro 订阅用户的 CLI 使用 `openai-chat`、`authMode: "key"` 以及规范地址 `https://api.a6api.com` 或 `https://api.a6api.com/v1` 的自定义提供商,会在仪表板和 `ocx account refresh ` -中显示 A6API 信用使用情况;提供商名称可以自定义。系统将令牌单位换算为 USD,并显示已用百分比和剩余额度。 +中显示 A6API 信用使用情况;提供商名称可以自定义。系统依据账户的 hard credit limit 将令牌单位换算为 USD,并显示已用百分比和剩余额度。 令牌到期不代表额度补充,因此不会显示为配额重置。只有当前活动密钥会发送到规范主机,重定向会被拒绝;负数 或内部不一致的计费总数不会生成报告。 diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 6c4abf630..0fff448e3 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -280,7 +280,7 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro ? usedUnits + availableUnits : undefined; const reconciliationTolerance = grantedUnits !== undefined - ? Math.max(1, Math.abs(grantedUnits)) * 1e-9 + ? Math.abs(grantedUnits) * 1e-9 : 0; if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined || availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0 diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 2e94dd4b9..05d2f6f75 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -334,6 +334,18 @@ describe("fetchProviderQuotaReports", () => { 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) => { From 940b0b83eede58ce064d25340cd15ab7162bf904 Mon Sep 17 00:00:00 2001 From: byongshintv <47180856+byongshintv@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:21:11 +0900 Subject: [PATCH 6/8] fix(quota): suppress terminal-invalid A6API cache Co-authored-by: OpenAI Codex --- src/providers/quota.ts | 38 +++++++++++++++++++++++++----------- tests/provider-quota.test.ts | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 0fff448e3..e7b588ef2 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -45,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; @@ -255,7 +257,7 @@ function firstFinite(record: Record | null, names: string[]): n return undefined; } -async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Promise { +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(); @@ -269,7 +271,12 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }), ]); - if (!subscriptionResponse.ok || !tokenResponse.ok) return null; + 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"]); @@ -286,12 +293,12 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro || availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0 || usedUnits < 0 || availableUnits < 0 || reconciledUnits === undefined - || Math.abs(reconciledUnits - grantedUnits) > reconciliationTolerance) return null; + || 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 null; + 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 }], @@ -1150,7 +1157,7 @@ async function maybeFetchProviderQuota( config: OcxConfig, forceRefresh: boolean, prefetchedCodexSnapshot?: CodexAuthAccountsSnapshotPromise, -): Promise { +): Promise { if (provider.disabled === true) return null; try { if (isBuiltInChatGptForwardProvider(name, provider)) { @@ -1199,11 +1206,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) { @@ -1211,8 +1222,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. @@ -1233,6 +1244,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 05d2f6f75..f0626d688 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -428,6 +428,42 @@ describe("fetchProviderQuotaReports", () => { 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[] = []; From 678120b3fbb0a952df56d76b229fbb28d3be00af Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:39:10 +0200 Subject: [PATCH 7/8] fix(quota): treat A6API 429 probes as transient A throttled billing probe is not an invalid-account signal: keep the last-good row like 5xx/network failures, while 401/403 and 404 remain terminal. Adds regressions for both the 429 keep-last-good path and the 401 drop-last-good path. --- src/providers/quota.ts | 4 +++- tests/provider-quota.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index e7b588ef2..e8d726177 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -273,7 +273,9 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro ]); if (!subscriptionResponse.ok || !tokenResponse.ok) { const statuses = [subscriptionResponse.status, tokenResponse.status]; - return statuses.some(status => status >= 400 && status < 500) + // 429 is a throttle, not an invalid-account signal: 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) ? TERMINAL_QUOTA_FAILURE : null; } diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index f0626d688..c0a602bdf 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -464,6 +464,41 @@ describe("fetchProviderQuotaReports", () => { 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 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[] = []; From 8a25043708376f16bc6b34e0d7043fd8cb67152e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:26:28 +0200 Subject: [PATCH 8/8] fix(quota): treat A6API 408 probes as transient --- src/providers/quota.ts | 7 ++++--- tests/provider-quota.test.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index e8d726177..1e5b46fb5 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -273,9 +273,10 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro ]); if (!subscriptionResponse.ok || !tokenResponse.ok) { const statuses = [subscriptionResponse.status, tokenResponse.status]; - // 429 is a throttle, not an invalid-account signal: 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) + // 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; } diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index c0a602bdf..353c54b7a 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -481,6 +481,25 @@ describe("fetchProviderQuotaReports", () => { 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) => {