From c299ed7c10316247cfa8a0ee081f197ff979b5d2 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/6] 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 9a5831df7..9e69e6271 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -15,6 +15,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 = 30 * 60_000; @@ -118,6 +119,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, quota: ProviderQuota): ProviderQuotaReport | null { if (!hasQuotaRows(quota)) return null; return { @@ -853,6 +909,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 11e22aaa4..3aab19ca1 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -237,6 +237,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 2903177e9b7ca03e838851ef9e36785a1771dc2a 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/6] 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 9e69e6271..360cd2123 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"; @@ -63,7 +64,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}|${config.activeCodexAccountId ?? ""}|${providers}`; @@ -160,16 +165,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(), }); } @@ -909,7 +914,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 3aab19ca1..4ada84e50 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -270,7 +270,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/", @@ -306,6 +305,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 93d994f25da3791e44ef31532738327e4c9df984 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/6] 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 4615c0726..3d635fa6b 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 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 360cd2123..abf1d4fd3 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -65,7 +65,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}`; }) @@ -125,7 +127,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`; } @@ -166,7 +168,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); @@ -493,7 +496,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 4ada84e50..115b0ce15 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -308,7 +308,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") @@ -321,6 +322,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 16e9b7521e6eef96e57ac37d18d95d976cd7ca7b 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/6] 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 3d635fa6b..cdd5d36b9 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -243,7 +243,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 ef2dd1d54..ed2742dbc 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 クレジット使用量が表示されます。プロバイダー名は任意です。 +トークン単位を 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..efdb3f728 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 크레딧 사용량을 표시합니다. 프로바이더 이름은 자유롭게 정할 수 +있습니다. 토큰 단위를 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..ef037c483 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; отображаются процент расхода и остаток. Срок действия токена не считается +сбросом квоты, поскольку он не означает пополнение. Только активный ключ отправляется на +канонический хост, перенаправления отклоняются, а отрицательные или несогласованные итоги биллинга +не создают отчёт. + > **Ограничение 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..521f3c934 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 信用使用情况;提供商名称可以自定义。系统将令牌单位换算为 USD,并显示已用百分比和剩余额度。 +令牌到期不代表额度补充,因此不会显示为配额重置。只有当前活动密钥会发送到规范主机,重定向会被拒绝;负数 +或内部不一致的计费总数不会生成报告。 + > **腾讯云 Coding Plan 使用限制:**腾讯将此订阅限定为交互式编程工具使用。禁止通用 API > 自动化、自定义应用后端和非交互式批量调用;违规使用可能导致套餐密钥被停用。 diff --git a/src/providers/quota.ts b/src/providers/quota.ts index abf1d4fd3..953e91626 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -166,10 +166,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 115b0ce15..f29779f6d 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -309,6 +309,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); @@ -331,13 +332,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 ed5f95c1d0a6084b6bc639cca021b394dbf7fa18 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/6] 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 ed2742dbc..39c409315 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -157,7 +157,7 @@ opencodex v2.7.1 には組み込みプリセットが 50 個含まれていま `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 efdb3f728..7eb3651f0 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -157,7 +157,7 @@ opencodex v2.7.1에는 빌트인 프리셋이 50개 들어 있습니다. 키 방 `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 ef037c483..7abe11970 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -168,7 +168,7 @@ opencodex поставляется с 53 встроенными пресетам Пользовательский провайдер с `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 521f3c934..d3579aee4 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -147,7 +147,7 @@ ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提 使用 `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 953e91626..57003abfe 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -170,7 +170,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 f29779f6d..7098ab69b 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -323,6 +323,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 5dd8a7dcf73f43a720e838fe4aa31ab6ed1bd3be 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/6] fix(quota): suppress terminal-invalid A6API cache Co-authored-by: OpenAI Codex --- src/providers/quota.ts | 33 ++++++++++++++++++++++----------- tests/provider-quota.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 57003abfe..2b3859d32 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -19,6 +19,8 @@ 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; @@ -145,7 +147,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(); @@ -159,7 +161,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"]); @@ -176,12 +183,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 }], @@ -910,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); @@ -949,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 7098ab69b..258483dff 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -417,6 +417,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[] = [];