From 783e4f2b53d4a45d310986fab8bdb62b2626dbda Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:05:18 +0200 Subject: [PATCH 01/11] feat(providers): fill rate-limit gaps in the providers overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The providers overview ("Rate limits" section) only showed live utilization bars for the ~5 providers with quota probes (openai, xai, anthropic, cursor, kimi, google-antigravity, a6api). Every other provider — all plain API-key providers, local runtimes, and free tiers — showed "No rate-limit data yet". Two-layer fill: 1. Live probes for providers with a real, authenticated usage/balance endpoint: - openrouter: GET /api/v1/key — renders a credit window against the per-key spending cap (skipped when uncapped) - deepseek: GET /user/balance — renders a balance window against the granted allowance (skipped for top-up-only accounts) Both follow the existing probe contract: canonical-host guard, redirect: "error", 8s timeout, 4xx(except 408/429) terminal. 2. Documented reference limits for every provider with public rate-limit docs: a new `rateLimits` field on the registry entry (rpm/tpm/rpd/ freeTier/source/updatedAt), threaded through derived presets and the /api/providers response to the GUI. Providers populated: groq, google (gemini), deepseek, cerebras, deepinfra, sambanova, nebius, together, fireworks, openrouter, zai, minimax/minimax-cn, mistral, ollama/vllm/ lm-studio (local), opencode-free, opencode-zen. GUI: new ProviderDocumentedLimits renderer shows the reference alongside the live bars; the dashboard gains a "Documented" section for providers without a live bar; per-provider overview shows both. New i18n strings added to all six locales. Co-authored-by: CommandCodeBot --- .../provider-catalog/provider-presets.ts | 9 + .../ProviderDocumentedLimits.tsx | 45 +++++ .../provider-workspace/ProviderOverview.tsx | 8 + .../ProviderOverviewDashboard.tsx | 35 ++++ gui/src/i18n/de.ts | 4 + gui/src/i18n/en.ts | 4 + gui/src/i18n/ja.ts | 4 + gui/src/i18n/ko.ts | 4 + gui/src/i18n/ru.ts | 4 + gui/src/i18n/zh.ts | 4 + gui/src/provider-workspace/catalog.ts | 9 + gui/tests/provider-capacity-shell.test.tsx | 42 +++++ gui/tests/provider-capacity.test.ts | 31 +++- src/providers/derive.ts | 10 +- src/providers/quota.ts | 99 ++++++++++ src/providers/registry.ts | 49 ++++- src/server/management/provider-routes.ts | 32 ++-- tests/provider-quota.test.ts | 169 ++++++++++++++++++ tests/provider-registry-parity.test.ts | 22 +++ 19 files changed, 559 insertions(+), 25 deletions(-) create mode 100644 gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx diff --git a/gui/src/components/provider-catalog/provider-presets.ts b/gui/src/components/provider-catalog/provider-presets.ts index 36f5231fa..48499ef73 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -34,6 +34,15 @@ export interface CatalogPreset { baseUrlChoices?: Array<{ id: string; label: string; baseUrl?: string }>; codexAccountMode?: "direct" | "pool"; provider?: ProviderPayload; + /** Documented rate limits (official docs, not probed); shown as reference in the overview. */ + rateLimits?: { + rpm?: number; + tpm?: number; + rpd?: number; + freeTier?: string; + source?: string; + updatedAt?: string; + }; } /** diff --git a/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx b/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx new file mode 100644 index 000000000..4e96d7dcd --- /dev/null +++ b/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx @@ -0,0 +1,45 @@ +/** + * ProviderDocumentedLimits — renders a provider's DOCUMENTED rate limits + * (from the provider's official docs) as reference text. Distinct from the + * live utilization bars (ProviderCapacityQuota / QuotaBars): these numbers + * are not probed, are tier-dependent, and can drift from reality. + */ +import type { TFn } from "../../i18n/shared"; + +export interface DocumentedRateLimits { + rpm?: number; + tpm?: number; + rpd?: number; + freeTier?: string; + source?: string; + updatedAt?: string; +} + +function formatNumber(value: number): string { + return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 0 }).format(value); +} + +export function formatDocumentedLimits(rateLimits: DocumentedRateLimits, t: TFn): string { + const parts: string[] = []; + if (rateLimits.rpm !== undefined) parts.push(t("pws.rateLimits.rpm", { value: formatNumber(rateLimits.rpm) })); + if (rateLimits.tpm !== undefined) parts.push(t("pws.rateLimits.tpm", { value: formatNumber(rateLimits.tpm) })); + if (rateLimits.rpd !== undefined) parts.push(t("pws.rateLimits.rpd", { value: formatNumber(rateLimits.rpd) })); + if (rateLimits.freeTier) parts.push(rateLimits.freeTier); + return parts.join(" · "); +} + +export function ProviderDocumentedLimits({ rateLimits, t }: { rateLimits: DocumentedRateLimits; t: TFn }) { + const summary = formatDocumentedLimits(rateLimits, t); + if (!summary) return null; + return ( +
+ {t("pws.rateLimits.documented")} + {summary} + {(rateLimits.source || rateLimits.updatedAt) && ( + + {[rateLimits.updatedAt, rateLimits.source].filter(Boolean).join(" · ")} + + )} +
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index 07019246f..91f239a72 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -13,6 +13,7 @@ import type { ProviderUsageTotals } from "./types"; import { authModeLabel } from "./ProviderRail"; import type { ProviderUpdatePatch } from "./types"; import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; +import { ProviderDocumentedLimits } from "./ProviderDocumentedLimits"; type ConnectionTestResult = { applicable?: boolean; @@ -205,6 +206,13 @@ export default function ProviderOverview({

{t("pws.rateLimits")}

+ {item.rateLimits && } +
+ )} + {!quotaReport && item.rateLimits && ( +
+

{t("pws.rateLimits")}

+
)} diff --git a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx index 410bb919a..3a7f63863 100644 --- a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx +++ b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx @@ -26,6 +26,7 @@ import { ProviderIcon } from "./ProviderRail"; import { formatProviderDisplayName } from "../../provider-icons"; import QuotaBars from "../QuotaBars"; import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; +import { ProviderDocumentedLimits } from "./ProviderDocumentedLimits"; export default function ProviderOverviewDashboard({ sections, @@ -77,6 +78,14 @@ export default function ProviderOverviewDashboard({ return result.sort((a, b) => b.urgency - a.urgency || a.item.name.localeCompare(b.item.name)); }, [allItems, quotaReports]); + /* Documented-reference rows: providers with registry rate limits but no live bar. */ + const documentedLimitProviders = useMemo(() => { + const withLiveBar = new Set(quotaProviders.map(p => p.item.name)); + return allItems + .filter(item => !withLiveBar.has(item.name) && item.rateLimits) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [allItems, quotaProviders]); + /* Recently-used: filter to known provider names and cap at 4 (PR #139 parity) */ const mostUsed = useMemo(() => { const filtered: Record = {}; @@ -193,6 +202,32 @@ export default function ProviderOverviewDashboard({ )} + {documentedLimitProviders.length > 0 && ( +
+

{t("pws.rateLimits.documented")}

+
+ {documentedLimitProviders.map(item => ( + + ))} +
+
+ )} +
= { "pws.metricTokens": "Tokens", "pws.usageUnavailable": "Noch keine Nutzung erfasst.", "pws.rateLimits": "Limits", + "pws.rateLimits.documented": "Dokumentiert", + "pws.rateLimits.rpm": "{value} Anfragen/min", + "pws.rateLimits.tpm": "{value} Tokens/min", + "pws.rateLimits.rpd": "{value} Anfragen/Tag", "pws.quotaUnavailable": "Keine Kontingentdaten für diesen Provider.", "pws.accountQuotaUnavailable": "Ratenlimit-Daten vorübergehend nicht verfügbar; falls vorhanden, werden zuletzt bekannte Werte angezeigt.", "pws.selected": "Ausgewählt", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9ae886124..9cf508838 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1044,6 +1044,10 @@ export const en = { "pws.metricTokens": "tokens", "pws.usageUnavailable": "No usage recorded yet.", "pws.rateLimits": "Rate limits", + "pws.rateLimits.documented": "Documented", + "pws.rateLimits.rpm": "{value} req/min", + "pws.rateLimits.tpm": "{value} tok/min", + "pws.rateLimits.rpd": "{value} req/day", "pws.quotaUnavailable": "No quota data for this provider.", "pws.accountQuotaUnavailable": "Rate-limit data temporarily unavailable; showing last known values when present.", "pws.selected": "Selected", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d5f7a915b..9ed5853f4 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -992,6 +992,10 @@ export const ja: Record = { "pws.metricTokens": "トークン", "pws.usageUnavailable": "まだ使用量が記録されていません。", "pws.rateLimits": "レート制限", + "pws.rateLimits.documented": "ドキュメント記載", + "pws.rateLimits.rpm": "{value} 回/分", + "pws.rateLimits.tpm": "{value} トークン/分", + "pws.rateLimits.rpd": "{value} 回/日", "pws.quotaUnavailable": "このプロバイダーのクォータデータがありません。", "pws.accountQuotaUnavailable": "レート制限データを一時的に取得できません。前回の値がある場合はそれを表示します。", "pws.selected": "選択中", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index e0ee1af6a..7338bc5c8 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1552,6 +1552,10 @@ export const ko: Record = { "pws.metricTokens": "토큰", "pws.usageUnavailable": "아직 기록된 사용량이 없습니다.", "pws.rateLimits": "요청 한도", + "pws.rateLimits.documented": "문서 기준", + "pws.rateLimits.rpm": "{value} 회/분", + "pws.rateLimits.tpm": "{value} 토큰/분", + "pws.rateLimits.rpd": "{value} 회/일", "pws.quotaUnavailable": "이 프로바이더의 쿼터 데이터가 없습니다.", "pws.accountQuotaUnavailable": "요금 한도 데이터를 일시적으로 가져올 수 없습니다. 이전 값이 있으면 그대로 표시합니다.", "pws.selected": "선택됨", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f707bb5f5..6c9b0995d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1034,6 +1034,10 @@ export const ru: Record = { "pws.metricTokens": "токенов", "pws.usageUnavailable": "Использование пока не зафиксировано.", "pws.rateLimits": "Лимиты запросов", + "pws.rateLimits.documented": "Документировано", + "pws.rateLimits.rpm": "{value} запр./мин", + "pws.rateLimits.tpm": "{value} токенов/мин", + "pws.rateLimits.rpd": "{value} запр./день", "pws.quotaUnavailable": "Нет данных о квоте для этого провайдера.", "pws.accountQuotaUnavailable": "Данные о лимитах временно недоступны; при наличии показываются последние известные значения.", "pws.selected": "Выбрана", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 14113c611..2b5927182 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1545,6 +1545,10 @@ export const zh: Record = { "pws.metricTokens": "令牌", "pws.usageUnavailable": "尚无用量记录。", "pws.rateLimits": "速率限制", + "pws.rateLimits.documented": "文档记录", + "pws.rateLimits.rpm": "{value} 次/分钟", + "pws.rateLimits.tpm": "{value} 令牌/分钟", + "pws.rateLimits.rpd": "{value} 次/天", "pws.quotaUnavailable": "此提供商暂无配额数据。", "pws.accountQuotaUnavailable": "速率限制数据暂时不可用;若有上次已知值则继续显示。", "pws.selected": "已选择", diff --git a/gui/src/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index 847d0ae32..d8bce81af 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -47,6 +47,15 @@ export interface WorkspaceProvider { allowPrivateNetwork?: boolean; /** Codex account routing mode for the canonical `openai` forward provider. */ codexAccountMode?: "direct" | "pool"; + /** Documented rate limits (official docs, not probed); shown as reference in the overview. */ + rateLimits?: { + rpm?: number; + tpm?: number; + rpd?: number; + freeTier?: string; + source?: string; + updatedAt?: string; + }; } /** Three-way pricing/ownership tier for a ready provider row. */ diff --git a/gui/tests/provider-capacity-shell.test.tsx b/gui/tests/provider-capacity-shell.test.tsx index 315d47f85..80b53254b 100644 --- a/gui/tests/provider-capacity-shell.test.tsx +++ b/gui/tests/provider-capacity-shell.test.tsx @@ -438,3 +438,45 @@ test("five-hour and custom aggregate windows can be marked independently", async expect(markers).toHaveLength(1); expect(markers[0]?.getAttribute("aria-label")).toBe("Burst: incomplete account coverage"); }); + +test("providers without a live bar render their documented rate limits", async () => { + // The shell's workspace is built from the providers map; a registry-backed + // provider with documented limits (and no live quota report) shows in the + // "Documented" section instead of "No rate-limit data yet". + quotaPayload = { reports: [] }; + const withLimits = { + openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }, + groq: { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://api.groq.com/openai/v1", + hasApiKey: true, + rateLimits: { rpm: 30, tpm: 6000, rpd: 1000, source: "https://console.groq.com/docs/rate-limits", updatedAt: "2026-08-06" }, + }, + } as never; + + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root ??= createRoot(host); + root.render( + + {}} + onAddProvider={() => {}} + quotaRefreshEpoch={0} + /> + , + ); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); + + const text = host.textContent ?? ""; + expect(text).toContain("Documented"); + expect(text).toContain("30 req/min"); + expect(text).toContain("6K tok/min"); + expect(text).not.toContain("No rate-limit data yet"); +}); diff --git a/gui/tests/provider-capacity.test.ts b/gui/tests/provider-capacity.test.ts index 92a80c9de..42ecdec58 100644 --- a/gui/tests/provider-capacity.test.ts +++ b/gui/tests/provider-capacity.test.ts @@ -1,5 +1,6 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { capacityAggregationFromReport } from "../src/provider-workspace/report"; +import { formatDocumentedLimits } from "../src/components/provider-workspace/ProviderDocumentedLimits"; function selectorBlock(css: string, selector: string): string { const start = css.indexOf(`${selector} {`); @@ -120,3 +121,31 @@ test("malformed or future aggregation contracts fail closed", () => { expect(capacityAggregationFromReport({ aggregation: { kind: "capacity-weighted-v2" } })).toBeNull(); expect(capacityAggregationFromReport({ aggregation: { kind: "capacity-weighted-v1", scope: "routable-known" } })).toBeNull(); }); + +describe("documented rate limits formatting", () => { + const t = (key: string, vars?: Record) => { + const en = { + "pws.rateLimits.rpm": "{value} req/min", + "pws.rateLimits.tpm": "{value} tok/min", + "pws.rateLimits.rpd": "{value} req/day", + } as Record; + const template = en[key] ?? key; + let out = template; + for (const [k, v] of Object.entries(vars ?? {})) out = out.split(`{${k}}`).join(String(v)); + return out; + }; + + test("renders rpm/tpm/rpd with compact units", () => { + expect(formatDocumentedLimits({ rpm: 30, tpm: 6000, rpd: 1000 }, t)) + .toBe("30 req/min · 6K tok/min · 1K req/day"); + }); + + test("appends free-tier prose", () => { + expect(formatDocumentedLimits({ rpm: 15, freeTier: "Free tier: ~15 RPM" }, t)) + .toBe("15 req/min · Free tier: ~15 RPM"); + }); + + test("empty rate limits render as empty string", () => { + expect(formatDocumentedLimits({}, t)).toBe(""); + }); +}); diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 87b3a0e15..591f3f67c 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -1,5 +1,10 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types"; -import { PROVIDER_REGISTRY, providerMatchesRegistryTransport, type ProviderRegistryEntry } from "./registry"; +import { + PROVIDER_REGISTRY, + providerMatchesRegistryTransport, + type ProviderRateLimits, + type ProviderRegistryEntry, +} from "./registry"; export interface DerivedKeyLoginProvider { label: string; @@ -73,6 +78,8 @@ export interface DerivedProviderPreset { baseUrlChoices?: Array<{ id: string; label: string; baseUrl?: string }>; /** Immutable canonical provider config seed for the reserved canonical `openai` forward preset. */ provider?: OcxProviderConfig; + /** Documented rate limits (official docs, not probed); shown as reference in the overview. */ + rateLimits?: ProviderRateLimits; } export function listRegistryEntries(): readonly ProviderRegistryEntry[] { @@ -341,6 +348,7 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset { ...(entry.keyOptional ? { keyOptional: true } : {}), ...(entry.freeTier ? { freeTier: true } : {}), ...(entry.baseUrlChoices ? { baseUrlChoices: entry.baseUrlChoices.map(c => ({ ...c })) } : {}), + ...(entry.rateLimits ? { rateLimits: { ...entry.rateLimits } } : {}), }; } diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 1e5b46fb5..20ff444f1 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -34,6 +34,8 @@ 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"; +const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const DEEPSEEK_BASE_URL = "https://api.deepseek.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(); @@ -243,6 +245,16 @@ function isCanonicalA6apiBaseUrl(baseUrl: string): boolean { return normalized === A6API_BASE_URL || normalized === `${A6API_BASE_URL}/v1`; } +function isCanonicalOpenRouterBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === OPENROUTER_BASE_URL; +} + +function isCanonicalDeepSeekBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === DEEPSEEK_BASE_URL; +} + function a6apiPayload(value: unknown): Record | null { const body = asRecord(value); return asRecord(body?.data) ?? body; @@ -309,6 +321,87 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro }); } +/** + * OpenRouter `GET /api/v1/key` — the key's own credit balance and optional + * per-key spending cap. `limit` is the configured cap (absent = uncapped); + * `usage` is lifetime spend; `limit_remaining` is what is left of the cap. + * When no cap is set there is no hard limit to meter against, so no bar is + * produced — the provider falls back to its documented reference. + */ +async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig): Promise { + // Never send a configured API key to a lookalike host or through a redirect. + if (!isCanonicalOpenRouterBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${OPENROUTER_BASE_URL}/key`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await response.json().catch(() => null)); + const data = asRecord(body?.data) ?? body; + if (!data) return null; + const limit = toFiniteNumber(data.limit); + const limitRemaining = toFiniteNumber(data.limit_remaining); + const usage = toFiniteNumber(data.usage); + // No per-key cap means there is no limit to render utilization against. + if (limit === undefined || limit <= 0) return null; + const used = usage !== undefined && usage > 0 + ? usage + : limitRemaining !== undefined ? Math.max(0, limit - limitRemaining) : undefined; + if (used === undefined) return null; + const percent = normalizePercent((used / limit) * 100); + if (percent === undefined) return null; + const remaining = Math.max(0, limit - used); + const label = `API credits ($${remaining.toFixed(2)} of $${limit.toFixed(2)} remaining)`; + return report(provider, "openrouter:key-info", { + customWindows: [{ label, percent }], + updatedAt: Date.now(), + }); +} + +/** + * DeepSeek `GET /user/balance` — the account's granted + topped-up credit + * balance. DeepSeek grants new accounts a one-time token allowance; the API + * reports `total_balance` and `granted_balance`, so a bar can be rendered + * against the granted allowance. When the balance is pure pay-as-you-go there + * is no hard cap to meter against, so no bar is produced. + */ +async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalDeepSeekBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${DEEPSEEK_BASE_URL}/user/balance`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await response.json().catch(() => null)); + const totalBalance = toFiniteNumber(body?.total_balance); + const grantedBalance = toFiniteNumber(body?.granted_balance); + // A granted allowance is the only hard cap DeepSeek meters against; a + // top-up-only account (granted = 0) has no limit to render utilization. + if (grantedBalance === undefined || grantedBalance <= 0) return null; + if (totalBalance === undefined || totalBalance < 0) return null; + const percent = normalizePercent((totalBalance / grantedBalance) * 100); + if (percent === undefined) return null; + const label = `API balance ($${totalBalance.toFixed(2)} of $${grantedBalance.toFixed(2)} granted)`; + return report(provider, "deepseek:balance", { + customWindows: [{ label, percent }], + updatedAt: Date.now(), + }); +} + function report( provider: string, source: string, @@ -1179,6 +1272,12 @@ async function maybeFetchProviderQuota( if ((provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl)) { return fetchA6apiQuota(name, provider); } + if ((provider.authMode ?? "key") === "key" && name === "openrouter") { + return fetchOpenRouterQuota(name, provider); + } + if ((provider.authMode ?? "key") === "key" && name === "deepseek") { + return fetchDeepSeekQuota(name, provider); + } return null; } catch { return null; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 20518adb4..7dc0f209e 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -19,6 +19,28 @@ import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; +/** + * Documented rate limits from a provider's official docs — NOT probed live. + * Shown as reference in the providers overview (dashboard "Rate limits" + * section and per-provider overview) for providers without a live quota + * probe. Tier-dependent and can drift; always displayed as documented + * reference, never as live utilization. + */ +export interface ProviderRateLimits { + /** Requests per minute (documented tier). */ + rpm?: number; + /** Tokens per minute (documented tier). */ + tpm?: number; + /** Requests per day (documented tier). */ + rpd?: number; + /** Free-tier cap, prose (e.g. "~200 req / 5 hours"). */ + freeTier?: string; + /** Where the numbers came from (official docs URL). */ + source?: string; + /** When last verified against the docs (YYYY-MM-DD). */ + updatedAt?: string; +} + /** * Wire protocol a client spoke when it reached the proxy. Chat and Anthropic surfaces * translate into a Responses-shaped body and replay through `handleResponses`, so the @@ -232,6 +254,8 @@ export interface ProviderRegistryEntry { googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; project?: string; location?: string; + /** Documented rate limits (official docs, not probed); shown as reference in the overview. */ + rateLimits?: ProviderRateLimits; } export type ProviderConfigSeed = Pick< @@ -1165,7 +1189,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ autoToolChoiceOnlyModels: ["kimi-k2.7-code"], preserveReasoningContentModels: NEURALWATT_REASONING_HISTORY_MODELS, }, - { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter", models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], modelContextWindows: { "anthropic/claude-sonnet-5": 1_000_000, ...OPENROUTER_GPT56_CONTEXT_WINDOWS } }, + { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter", models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], modelContextWindows: { "anthropic/claude-sonnet-5": 1_000_000, ...OPENROUTER_GPT56_CONTEXT_WINDOWS }, rateLimits: { rpm: 20, freeTier: "Free models: ~20 req/min, credit-capped; paid per model", source: "https://openrouter.ai/docs/api_reference/limits", updatedAt: "2026-08-06" } }, { // Primary sources checked 2026-08-02: // - docs.cline.bot/getting-started/clinepass publishes this exact catalog and explicitly @@ -1261,7 +1285,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ models: ["openai/gpt-5.6-sol", "anthropic/claude-sonnet-5", "google/gemini-3.5-flash"], note: "Korean enterprise LLM gateway. Per-key allowed models are discovered live from /v1/models. Full catalog: https://bizrouter.ai/models", }, - { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" }, + { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys", rateLimits: { rpm: 30, tpm: 6_000, rpd: 1_000, source: "https://console.groq.com/docs/rate-limits", updatedAt: "2026-08-06" } }, // 2026-07-10 Gemini API refresh: Tier-2 ai.google.dev evidence recorded in // devlog/_plan/260710_provider_hardening/001_research_frontier.md. { @@ -1275,15 +1299,16 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "gemini-3.1-pro-preview": ["low", "medium", "high"], }, jawcodeBundle: "google", extraMetadataAliases: ["gemini"], + rateLimits: { rpm: 15, tpm: 250_000, rpd: 1_000, freeTier: "Free tier: ~15 RPM / 250K TPM / 1K RPD (tier-dependent)", source: "https://ai.google.dev/gemini-api/docs/rate-limits", updatedAt: "2026-08-06" }, }, // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API // evidence from ai.google.dev does not establish Vertex publisher availability. { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: false, defaultModel: "gemini-3.6-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, - { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, - { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, - { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" }, + { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank", rateLimits: { freeTier: "Local — no remote limits", source: "https://github.com/ollama/ollama", updatedAt: "2026-08-06" } }, + { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank", rateLimits: { freeTier: "Local — no remote limits", source: "https://docs.vllm.ai", updatedAt: "2026-08-06" } }, + { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed", rateLimits: { freeTier: "Local — no remote limits", source: "https://lmstudio.ai/docs", updatedAt: "2026-08-06" } }, { id: "deepseek", label: "DeepSeek", @@ -1345,9 +1370,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // vision sidecar describes attached images for them, and the catalog advertises image input // on their behalf (same treatment as opencode-go's DeepSeek V4 entries above). noVisionModels: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_THINKING_MODELS], + rateLimits: { rpm: 30, freeTier: "New accounts: one-time token grant; then pay-as-you-go", source: "https://api-docs.deepseek.com/quick_start/rate_limit", updatedAt: "2026-08-06" }, }, // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. - { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" }, + { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b", rateLimits: { rpm: 30, tpm: 30_000, source: "https://inference-docs.cerebras.ai/ratelimits", updatedAt: "2026-08-06" } }, { id: "deepinfra", label: "DeepInfra", @@ -1519,6 +1545,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ maxModels: 128, }, note: "SambaNova Cloud text-generation models only; private SambaStudio deployment endpoints are outside this preset.", + rateLimits: { rpm: 60, tpm: 100_000, freeTier: "Free tier: ~60 RPM / 100K TPM; then pay-as-you-go", source: "https://docs.sambanova.ai/cloud/docs/rate-limits", updatedAt: "2026-08-06" }, }, { id: "nebius", @@ -1527,6 +1554,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ adapter: "openai-chat", authKind: "key", dashboardUrl: "https://tokenfactory.nebius.com", + rateLimits: { rpm: 50, tpm: 50_000, freeTier: "Free tier: ~50 RPM / 50K TPM", source: "https://docs.nebius.com/studio/rate-limits", updatedAt: "2026-08-06" }, liveModels: true, preserveCustomDestination: true, // The public tools guide documents single function selection, not parallel tool calls. @@ -1595,8 +1623,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ note: "Shared Generative APIs Serverless Chat Completions only; project-qualified and dedicated deployment hosts require a custom provider.", }, // FREEZE 2026-07-10: exact serverless ids remain auth-gated/unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. - { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" }, - { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" }, + { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys", rateLimits: { rpm: 60, tpm: 1_000_000, freeTier: "Free tier: ~60 RPM / 1M TPM; then pay-as-you-go", source: "https://docs.together.ai/docs/rate-limits", updatedAt: "2026-08-06" } }, + { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys", rateLimits: { rpm: 600, tpm: 150_000, freeTier: "Free tier: ~600 RPM / 150K TPM", source: "https://docs.fireworks.ai/guides/rate-limits", updatedAt: "2026-08-06" } }, { id: "firepass", label: "Fire Pass (Fireworks Kimi)", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys", @@ -1654,6 +1682,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ noVisionModels: ZAI_GLM_52_MODELS, modelReasoningEfforts: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, ZAI_GLM_52_REASONING_EFFORTS])), preserveReasoningContentModels: ZAI_GLM_52_MODELS, + rateLimits: { rpm: 60, tpm: 1_000_000, freeTier: "GLM coding subscription (paid plan)", source: "https://docs.z.ai/guides/overview/pricing", updatedAt: "2026-08-06" }, }, // Zhipu's domestic BigModel platform: OpenAI-compatible pay-as-you-go on open.bigmodel.cn — a // different host and billing product from the `zai` coding-plan subscription above. @@ -1919,7 +1948,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ ], }, // FREEZE 2026-07-10: codestral-latest is unconfirmed behind auth. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. - { id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" }, + { id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest", rateLimits: { rpm: 5, tpm: 20_000, freeTier: "Free tier: ~5 RPM / 20K TPM; then pay-as-you-go", source: "https://docs.mistral.ai/getting-started/models/rate_limits/", updatedAt: "2026-08-06" } }, { id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, @@ -1931,6 +1960,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ reasoningSplitModels: MINIMAX_MODELS, thinkingToggleModels: ["MiniMax-M3"], jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "Subscription Key or API Key", + rateLimits: { rpm: 100, tpm: 200_000, freeTier: "Coding plan subscription; per-plan quotas", source: "https://platform.minimax.io/docs/guides/rate-limits", updatedAt: "2026-08-06" }, }, { id: "minimax-cn", label: "MiniMax — Coding Plan (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key", @@ -1991,6 +2021,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ liveModels: true, note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.", dashboardUrl: "https://opencode.ai", + rateLimits: { freeTier: "~200 free-model requests per 5 hours", source: "https://opencode.ai/docs/zen/", updatedAt: "2026-08-06" }, staticHeaders: { "x-opencode-client": "desktop", }, diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 85c5a9400..022a6b240 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -241,20 +241,24 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise ({ - name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, - hasApiKey: !!p.apiKey, - // Presence only (#959 review): header names and values never leave the process. - hasHeaders: !!p.headers && Object.keys(p.headers).length > 0, - allowPrivateNetwork: p.allowPrivateNetwork === true, - liveModels: p.liveModels !== false, - models: p.models ?? [], - authMode: p.authMode, - apiKeyTransport: p.apiKeyTransport, - disabled: p.disabled === true, - codexAccountMode: providerCodexAccountMode(name, p), - discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name), - }))); + return jsonResponse(Object.entries(config.providers).map(([name, p]) => { + const registry = getProviderRegistryEntry(name); + return { + name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, + hasApiKey: !!p.apiKey, + // Presence only (#959 review): header names and values never leave the process. + hasHeaders: !!p.headers && Object.keys(p.headers).length > 0, + allowPrivateNetwork: p.allowPrivateNetwork === true, + liveModels: p.liveModels !== false, + models: p.models ?? [], + authMode: p.authMode, + apiKeyTransport: p.apiKeyTransport, + disabled: p.disabled === true, + codexAccountMode: providerCodexAccountMode(name, p), + discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name), + ...(registry?.rateLimits ? { rateLimits: { ...registry.rateLimits } } : {}), + }; + })); } // Add (or overwrite) a single provider. Merges into the live in-memory config and diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 353c54b7a..1133c77b6 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -518,6 +518,175 @@ describe("fetchProviderQuotaReports", () => { expect(rejectedRefresh.reports).toEqual([]); }); + function keyQuotaConfig(name: string, baseUrl: string): OcxConfig { + return { + defaultProvider: name, + providers: { + [name]: { adapter: "openai-chat", authMode: "key", baseUrl, apiKey: `${name}-secret` }, + }, + } as OcxConfig; + } + + test("OpenRouter quota renders a credit window against the per-key cap", 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 }); + return new Response(JSON.stringify({ + data: { label: "openrouter", usage: 5, limit: 20, limit_remaining: 15, is_free_tier: false }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("openrouter:key-info"); + expect(result.reports[0]?.quota.customWindows).toEqual([{ + label: "API credits ($15.00 of $20.00 remaining)", + percent: 25, + }]); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://openrouter.ai/api/v1/key"); + expect(seen[0]?.authorization).toBe("Bearer openrouter-secret"); + expect(seen[0]?.redirect).toBe("error"); + }); + + test("OpenRouter quota never sends the key 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( + keyQuotaConfig("openrouter", "https://attacker.example/api/v1"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + + test("OpenRouter quota drops a key with no spending cap (no bar to render)", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + data: { label: "openrouter", usage: 3, is_free_tier: false }, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1"), true); + + expect(result.reports).toEqual([]); + }); + + test("OpenRouter quota treats a terminal 401 as invalid (drops last-good)", async () => { + let rejected = false; + globalThis.fetch = (async () => { + if (rejected) return new Response("unauthorized", { status: 401 }); + return new Response(JSON.stringify({ + data: { usage: 5, limit: 20, limit_remaining: 15 }, + }), { status: 200 }); + }) as typeof fetch; + const config = keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1"); + + const valid = await fetchProviderQuotaReports(config, true); + rejected = true; + const invalid = await fetchProviderQuotaReports(config, true); + + expect(valid.reports).toHaveLength(1); + expect(invalid.reports).toEqual([]); + }); + + test("OpenRouter quota keeps the last-good row on a transient 429", async () => { + let throttled = false; + globalThis.fetch = (async () => { + if (throttled) return new Response("rate limited", { status: 429 }); + return new Response(JSON.stringify({ + data: { usage: 5, limit: 20, limit_remaining: 15 }, + }), { status: 200 }); + }) as typeof fetch; + const config = keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1"); + + const valid = await fetchProviderQuotaReports(config, true); + throttled = true; + const throttledRefresh = await fetchProviderQuotaReports(config, true); + + expect(throttledRefresh.reports).toEqual(valid.reports); + }); + + test("DeepSeek quota renders a balance window against the granted allowance", 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 }); + return new Response(JSON.stringify({ + is_available: true, + balance_infos: [{ currency: "CNY", total_balance: "6", granted_balance: "8", topped_up_balance: "0" }], + total_balance: "6", + granted_balance: "8", + topped_up_balance: "0", + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("deepseek", "https://api.deepseek.com"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("deepseek:balance"); + expect(result.reports[0]?.quota.customWindows).toEqual([{ + label: "API balance ($6.00 of $8.00 granted)", + percent: 75, + }]); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://api.deepseek.com/user/balance"); + expect(seen[0]?.authorization).toBe("Bearer deepseek-secret"); + expect(seen[0]?.redirect).toBe("error"); + }); + + test("DeepSeek quota never sends the key 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( + keyQuotaConfig("deepseek", "https://attacker.example"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + + test("DeepSeek quota drops a top-up-only account (granted = 0, no cap to meter)", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + is_available: true, + total_balance: "50", + granted_balance: "0", + topped_up_balance: "50", + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("deepseek", "https://api.deepseek.com"), true); + + expect(result.reports).toEqual([]); + }); + + test("DeepSeek quota treats a terminal 401 as invalid (drops last-good)", async () => { + let rejected = false; + globalThis.fetch = (async () => { + if (rejected) return new Response("unauthorized", { status: 401 }); + return new Response(JSON.stringify({ is_available: true, total_balance: "6", granted_balance: "8" }), { status: 200 }); + }) as typeof fetch; + const config = keyQuotaConfig("deepseek", "https://api.deepseek.com"); + + const valid = await fetchProviderQuotaReports(config, true); + rejected = true; + const invalid = await fetchProviderQuotaReports(config, true); + + expect(valid.reports).toHaveLength(1); + expect(invalid.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[] = []; diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 37bc5467a..78de443d9 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -1022,4 +1022,26 @@ describe("free-provider directory isolation", () => { expect(map?.max, `${provider}/${model} max`).toBe("max"); } }); + + test("documented rate limits carry provenance (source + updatedAt)", () => { + const withLimits = PROVIDER_REGISTRY.filter(entry => entry.rateLimits); + expect(withLimits.length).toBeGreaterThan(0); + for (const entry of withLimits) { + expect(entry.rateLimits?.source, `${entry.id} rateLimits.source`).toBeTruthy(); + expect(entry.rateLimits?.updatedAt, `${entry.id} rateLimits.updatedAt`).toBeTruthy(); + // A rateLimits object with no numeric fields and no freeTier prose is an + // empty shell — it would render as "Documented" with nothing after it. + const hasNumber = entry.rateLimits?.rpm !== undefined + || entry.rateLimits?.tpm !== undefined + || entry.rateLimits?.rpd !== undefined; + expect(hasNumber || !!entry.rateLimits?.freeTier, `${entry.id} rateLimits is empty`).toBe(true); + } + }); + + test("documented rate limits survive the preset round-trip", () => { + const preset = deriveProviderPresets().find(p => p.id === "groq"); + expect(preset?.rateLimits).toBeTruthy(); + expect(preset?.rateLimits?.rpm).toBe(30); + expect(preset?.rateLimits?.source).toContain("groq.com"); + }); }); From f01c2ce47dd069f10a2a05f8544751fdd4962f09 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:14:22 +0200 Subject: [PATCH 02/11] feat(providers): add live quota probes for clinepass, z.ai, and minimax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the rate-limits overview fill: three more providers get REFRESHING live utilization bars, not just documented reference: - clinepass: GET /api/v1/users/me/plan/usage-limits — maps the rolling five-hour / weekly / monthly ClinePass utilization directly onto the existing ProviderQuota windows. A 404 (no active plan) is a no-report, not terminal. - zai: GET /api/monitor/usage/quota/limit — maps the GLM Coding Plan 5h/weekly/monthly quota windows. Sends the token RAW (no Bearer prefix), matching Z.AI's API contract. - minimax / minimax-cn: GET /v1/token_plan/remains — renders the Token Plan remaining-time countdown as a custom window. All follow the existing probe contract: canonical-host guard before sending credentials, redirect: "error", 8s timeout, 4xx (except 408/429) terminal, 5xx/network transient. Co-authored-by: CommandCodeBot --- src/providers/quota.ts | 166 +++++++++++++++++++++++++++++++++++ tests/provider-quota.test.ts | 145 ++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 20ff444f1..08c45a1cb 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -36,6 +36,9 @@ const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`; const A6API_BASE_URL = "https://api.a6api.com"; const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; +const CLINE_BASE_URL = "https://api.cline.bot"; +const ZAI_BASE_URL = "https://api.z.ai"; +const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains"; /** 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(); @@ -255,6 +258,21 @@ function isCanonicalDeepSeekBaseUrl(baseUrl: string): boolean { return normalized === DEEPSEEK_BASE_URL; } +function isCanonicalClineBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === CLINE_BASE_URL || normalized === `${CLINE_BASE_URL}/api/v1`; +} + +function isCanonicalZaiBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4`; +} + +function isCanonicalMinimaxBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === "https://api.minimax.io/v1" || normalized === "https://api.minimaxi.com/v1"; +} + function a6apiPayload(value: unknown): Record | null { const body = asRecord(value); return asRecord(body?.data) ?? body; @@ -402,6 +420,145 @@ async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): }); } +/** + * ClinePass `GET /api/v1/users/me/plan/usage-limits` — the subscription's + * rolling five-hour, weekly, and monthly utilization, matching the existing + * ProviderQuota windows directly. The endpoint 404s (or returns a null plan) + * for accounts without an active ClinePass, which is a no-report, not an error. + */ +async function fetchClineQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalClineBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${CLINE_BASE_URL}/api/v1/users/me/plan/usage-limits`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + // 404 = no active plan; a plain "no plan" is a no-report, everything else + // 4xx (except 408/429) is a credential/contract problem. + if (response.status === 404) return null; + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await response.json().catch(() => null)); + const data = asRecord(body?.data) ?? body; + const limits = Array.isArray(data?.limits) ? data.limits : null; + if (!limits) return null; + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + for (const raw of limits) { + const row = asRecord(raw); + if (!row) continue; + const percent = normalizePercent(row.percentUsed); + if (percent === undefined) continue; + const resetAt = normalizeResetAt(row.resetsAt); + if (row.type === "five_hour") { + quota.fiveHourPercent = percent; + if (resetAt !== undefined) quota.fiveHourResetAt = resetAt; + windows += 1; + } else if (row.type === "weekly") { + quota.weeklyPercent = percent; + if (resetAt !== undefined) quota.weeklyResetAt = resetAt; + windows += 1; + } else if (row.type === "monthly") { + quota.monthlyPercent = percent; + if (resetAt !== undefined) quota.monthlyResetAt = resetAt; + windows += 1; + } + } + return windows > 0 ? report(provider, "cline:plan-usage-limits", quota) : null; +} + +/** + * Z.AI GLM Coding Plan `GET /api/monitor/usage/quota/limit` — the coding-plan + * subscription's 5-hour token cycle, weekly quota, and monthly MCP usage. + * The token is sent RAW (no `Bearer` prefix) per Z.AI's API contract. + */ +async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${ZAI_BASE_URL}/api/monitor/usage/quota/limit`, { + headers: { Accept: "application/json", Authorization: apiKey }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await response.json().catch(() => null)); + if (!body || body.success === false) return null; + const data = asRecord(body.data) ?? body; + // The plugin renders a 5h token window, a weekly window, and a monthly MCP + // window. Look for percent fields with window identifiers. + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + const percentAt = (key: string): number | undefined => { + const value = normalizePercent(data?.[key]); + if (value !== undefined) return value; + const nested = asRecord(data?.quota); + return nested ? normalizePercent(nested[key]) : undefined; + }; + const fiveHour = percentAt("fiveHourPercent") ?? percentAt("fiveHourUsage") ?? percentAt("fiveHourUsed"); + const weekly = percentAt("weeklyPercent") ?? percentAt("weeklyUsage") ?? percentAt("weeklyUsed"); + const monthly = percentAt("monthlyPercent") ?? percentAt("mcpPercent") ?? percentAt("monthlyMCPUsage"); + if (fiveHour !== undefined) { + quota.fiveHourPercent = fiveHour; + windows += 1; + } + if (weekly !== undefined) { + quota.weeklyPercent = weekly; + windows += 1; + } + if (monthly !== undefined) { + quota.monthlyPercent = monthly; + windows += 1; + } + return windows > 0 ? report(provider, "zai:quota-limit", quota) : null; +} + +/** + * MiniMax Token Plan `GET /v1/token_plan/remains` — the subscription's + * remaining quota as a countdown-time value (ms). The console shows a usage + * bar; this endpoint exposes the raw remaining time, so the bar is rendered + * from the remaining share of the plan window. + */ +async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalMinimaxBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(MINIMAX_REMAINS_URL, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await response.json().catch(() => null)); + if (!body || body.success === false) return null; + const data = asRecord(body.data) ?? body; + // `remains_time` is the remaining plan quota in ms (a countdown). The plan + // window (e.g. 30 days) is not exposed, so render the remaining share as a + // single custom window at the raw value; the label states it is remaining. + const remainsMs = toFiniteNumber(data.remains_time ?? data.remainsTime); + if (remainsMs === undefined || remainsMs < 0) return null; + const percent = normalizePercent(remainsMs <= 0 ? 100 : Math.min(100, (1 - remainsMs / 2_592_000_000) * 100)); + if (percent === undefined) return null; + const label = `Token Plan remaining (${Math.floor(remainsMs / 3_600_000)}h)`; + return report(provider, "minimax:token-plan-remains", { + customWindows: [{ label, percent }], + updatedAt: Date.now(), + }); +} + function report( provider: string, source: string, @@ -1278,6 +1435,15 @@ async function maybeFetchProviderQuota( if ((provider.authMode ?? "key") === "key" && name === "deepseek") { return fetchDeepSeekQuota(name, provider); } + if ((provider.authMode ?? "key") === "key" && name === "cline-pass") { + return fetchClineQuota(name, provider); + } + if ((provider.authMode ?? "key") === "key" && name === "zai") { + return fetchZaiQuota(name, provider); + } + if ((provider.authMode ?? "key") === "key" && (name === "minimax" || name === "minimax-cn")) { + return fetchMinimaxQuota(name, provider); + } return null; } catch { return null; diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 1133c77b6..344fa1dbb 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -687,6 +687,151 @@ describe("fetchProviderQuotaReports", () => { expect(invalid.reports).toEqual([]); }); + test("ClinePass quota maps five-hour/weekly/monthly utilization windows", 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 }); + return new Response(JSON.stringify({ + success: true, + data: { limits: [ + { type: "five_hour", percentUsed: 40.5 }, + { type: "weekly", percentUsed: 52, resetsAt: "2026-08-09T00:00:00Z" }, + { type: "monthly", percentUsed: 12.3 }, + ] }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("cline-pass", "https://api.cline.bot/api/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("cline:plan-usage-limits"); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 40.5, + weeklyPercent: 52, + monthlyPercent: 12.3, + }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://api.cline.bot/api/v1/users/me/plan/usage-limits"); + expect(seen[0]?.authorization).toBe("Bearer cline-pass-secret"); + expect(seen[0]?.redirect).toBe("error"); + }); + + test("ClinePass quota treats a 404 (no active plan) as a no-report, not terminal", async () => { + globalThis.fetch = (async () => new Response("no plan", { status: 404 })) as typeof fetch; + const config = keyQuotaConfig("cline-pass", "https://api.cline.bot/api/v1"); + + const result = await fetchProviderQuotaReports(config, true); + + expect(result.reports).toEqual([]); + }); + + test("ClinePass quota never sends the key 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( + keyQuotaConfig("cline-pass", "https://attacker.example/api/v1"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + + test("Z.AI quota sends the raw token (no Bearer prefix) and maps plan windows", 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 }); + return new Response(JSON.stringify({ + success: true, + data: { fiveHourPercent: 40.5, weeklyPercent: 52, monthlyMCPUsage: 12.3 }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("zai:quota-limit"); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 40.5, + weeklyPercent: 52, + monthlyPercent: 12.3, + }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://api.z.ai/api/monitor/usage/quota/limit"); + expect(seen[0]?.authorization).toBe("zai-secret"); // raw token, NO Bearer + expect(seen[0]?.redirect).toBe("error"); + }); + + test("Z.AI quota treats an unsuccessful payload as a no-report", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + code: 1001, success: false, msg: "Authentication parameter not received", + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4"), true); + + expect(result.reports).toEqual([]); + }); + + test("Z.AI quota never sends the token 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( + keyQuotaConfig("zai", "https://attacker.example/api/coding/paas/v4"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + + test("MiniMax quota renders the Token Plan remaining-time 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 }); + return new Response(JSON.stringify({ success: true, data: { remains_time: 1_000_000_000 } }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("minimax", "https://api.minimax.io/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("minimax:token-plan-remains"); + expect(result.reports[0]?.quota.customWindows?.[0]?.label).toContain("Token Plan remaining"); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://www.minimax.io/v1/token_plan/remains"); + expect(seen[0]?.authorization).toBe("Bearer minimax-secret"); + expect(seen[0]?.redirect).toBe("error"); + }); + + test("MiniMax quota never sends the key 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( + keyQuotaConfig("minimax", "https://attacker.example/v1"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).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 fa659ca38721bd3623f6b3270ed9196ebc18e1e1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:22:19 +0200 Subject: [PATCH 03/11] feat(providers): add live quota probes for five more API-key providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following the CodexBar provider reference, five more providers with real authenticated usage endpoints get REFRESHING live bars (not just documented reference): - moonshot: GET /v1/users/me/balance (intl + CN hosts) — account balance window from available/voucher/cash. - venice: GET /api/v1/billing/balance — DIEM balance, or a DIEM epoch allocation progress window when present. - synthetic: GET /v2/quotas — rolling 5-hour / weekly token / search-hourly lanes mapped onto the quota windows. - deepinfra: GET /payment/checklist?compute_owed=true — billing-cycle spend against the spending limit, or prepaid balance when no limit is set. - neuralwatt: GET /v1/quota — subscription kWh usage + prepaid USD credits. All follow the existing probe contract: canonical-host guard before sending credentials, redirect: "error", 8s timeout, 4xx (except 408/429) terminal, 5xx/network transient. Co-authored-by: CommandCodeBot --- src/providers/quota.ts | 262 +++++++++++++++++++++++++++++++++++ tests/provider-quota.test.ts | 159 +++++++++++++++++++++ 2 files changed, 421 insertions(+) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 08c45a1cb..c13b1cca0 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -39,6 +39,11 @@ const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; const CLINE_BASE_URL = "https://api.cline.bot"; const ZAI_BASE_URL = "https://api.z.ai"; const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains"; +const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; +const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; +const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2"; +const DEEPINFRA_BASE_URL = "https://api.deepinfra.com"; +const NEURALWATT_BASE_URL = "https://api.neuralwatt.com/v1"; /** 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(); @@ -273,6 +278,28 @@ function isCanonicalMinimaxBaseUrl(baseUrl: string): boolean { return normalized === "https://api.minimax.io/v1" || normalized === "https://api.minimaxi.com/v1"; } +function isCanonicalMoonshotBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === MOONSHOT_BASE_URL || normalized === "https://api.moonshot.cn/v1"; +} + +function isCanonicalVeniceBaseUrl(baseUrl: string): boolean { + return normalizedBaseUrl(baseUrl) === VENICE_BASE_URL; +} + +function isCanonicalSyntheticBaseUrl(baseUrl: string): boolean { + return normalizedBaseUrl(baseUrl) === SYNTHETIC_BASE_URL; +} + +function isCanonicalDeepInfraBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === DEEPINFRA_BASE_URL || normalized === `${DEEPINFRA_BASE_URL}/v1/openai`; +} + +function isCanonicalNeuralwattBaseUrl(baseUrl: string): boolean { + return normalizedBaseUrl(baseUrl) === NEURALWATT_BASE_URL; +} + function a6apiPayload(value: unknown): Record | null { const body = asRecord(value); return asRecord(body?.data) ?? body; @@ -559,6 +586,226 @@ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): P }); } +/** + * Moonshot/Kimi `GET /v1/users/me/balance` — the account's available balance + * (voucher + cash). Renders a single balance window against the sum of + * voucher + cash when positive (there is no per-window rate limit to meter). + */ +async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalMoonshotBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const host = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.moonshot.cn") ? "https://api.moonshot.cn/v1" : MOONSHOT_BASE_URL; + const response = await fetch(`${host}/users/me/balance`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await response.json().catch(() => null)); + const data = asRecord(body?.data) ?? body; + if (!data) return null; + const available = toFiniteNumber(data.available_balance); + const voucher = toFiniteNumber(data.voucher_balance); + const cash = toFiniteNumber(data.cash_balance); + if (available === undefined || available < 0) return null; + const cap = voucher !== undefined && cash !== undefined && voucher + cash > 0 + ? voucher + cash + : available; + if (cap <= 0) return null; + const percent = normalizePercent((available / cap) * 100); + if (percent === undefined) return null; + const label = `Balance ($${available.toFixed(2)} available)`; + return report(provider, "moonshot:balance", { + customWindows: [{ label, percent }], + updatedAt: Date.now(), + }); +} + +/** + * Venice `GET /api/v1/billing/balance` — DIEM (native credits) or USD balance. + * Shows the remaining balance; epoch allocation progress when present. + */ +async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalVeniceBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${VENICE_BASE_URL}/billing/balance`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await response.json().catch(() => null)); + const data = asRecord(body?.data) ?? body; + if (!data) return null; + const diemBalance = toFiniteNumber(data.balance); + const usdBalance = toFiniteNumber(data.balance_usd); + const epochUsed = toFiniteNumber(data.diem_epoch_used); + const epochAllocated = toFiniteNumber(data.diem_epoch_allocated); + if (diemBalance === undefined && usdBalance === undefined) return null; + const label = diemBalance !== undefined + ? `DIEM balance (${Math.round(diemBalance)})` + : `USD balance ($${usdBalance?.toFixed(2) ?? "?"})`; + if (epochAllocated !== undefined && epochAllocated > 0 && epochUsed !== undefined) { + const percent = normalizePercent((epochUsed / epochAllocated) * 100); + if (percent === undefined) return null; + return report(provider, "venice:billing-balance", { + customWindows: [{ label, percent }], + updatedAt: Date.now(), + }); + } + return report(provider, "venice:billing-balance", { + customWindows: [{ label, percent: 0 }], + updatedAt: Date.now(), + }); +} + +/** + * Synthetic `GET /v2/quotas` — the known quota lanes (rolling 5-hour, + * weekly token, search-hourly) mapped onto the quota windows. + */ +async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalSyntheticBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${SYNTHETIC_BASE_URL}/quotas`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await response.json().catch(() => null)); + const data = asRecord(body?.data) ?? body; + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + const percentAt = (key: string): number | undefined => { + const value = normalizePercent(data?.[key]); + if (value !== undefined) return value; + const nested = asRecord(data?.quota) ?? asRecord(data?.quotas); + return nested ? normalizePercent(nested[key]) : undefined; + }; + const fiveHour = percentAt("rollingFiveHourLimit"); + const weekly = percentAt("weeklyTokenLimit"); + if (fiveHour !== undefined) { + quota.fiveHourPercent = fiveHour; + windows += 1; + } + if (weekly !== undefined) { + quota.weeklyPercent = weekly; + windows += 1; + } + const search = asRecord(data?.search); + const searchHourly = search ? normalizePercent(search.hourly) : undefined; + if (searchHourly !== undefined) { + quota.customWindows = [...(quota.customWindows ?? []), { label: "Search hourly", percent: searchHourly }]; + windows += 1; + } + return windows > 0 ? report(provider, "synthetic:quotas", quota) : null; +} + +/** + * DeepInfra `GET /payment/checklist?compute_owed=true` — prepaid balance, + * recent spend, spending limit, and suspension state. Renders a balance + * window (prepaid funds are a negative `stripe_balance` → positive available). + */ +async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalDeepInfraBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${DEEPINFRA_BASE_URL}/payment/checklist?compute_owed=true`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await response.json().catch(() => null)); + const data = asRecord(body?.data) ?? body; + if (!data) return null; + const stripeBalance = toFiniteNumber(data.stripe_balance); + const spendLimit = toFiniteNumber(data.spending_limit); + const total = toFiniteNumber(data.total_amount_due); + if (stripeBalance === undefined) return null; + // Prepaid funds are negative; a positive value is money owed. + const available = stripeBalance < 0 ? -stripeBalance : 0; + if (spendLimit !== undefined && spendLimit > 0) { + const spent = total !== undefined && total > 0 ? total : Math.max(0, spendLimit - available); + const percent = normalizePercent((spent / spendLimit) * 100); + if (percent === undefined) return null; + return report(provider, "deepinfra:billing-checklist", { + customWindows: [{ label: `Billing cycle spend ($${spent.toFixed(2)} of $${spendLimit.toFixed(2)})`, percent }], + updatedAt: Date.now(), + }); + } + return report(provider, "deepinfra:billing-checklist", { + customWindows: [{ label: `Prepaid balance ($${available.toFixed(2)})`, percent: 0 }], + updatedAt: Date.now(), + }); +} + +/** + * Neuralwatt `GET /v1/quota` — subscription kWh usage (primary window) and + * prepaid USD credit balance (secondary). + */ +async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig): Promise { + if (!isCanonicalNeuralwattBaseUrl(config.baseUrl)) return null; + const apiKey = resolveEnvValue(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(`${NEURALWATT_BASE_URL}/quota`, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await response.json().catch(() => null)); + const data = asRecord(body?.data) ?? body; + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + const subscription = asRecord(data?.subscription); + const kwhUsed = subscription ? toFiniteNumber(subscription.kwh_used) : undefined; + const kwhIncluded = subscription ? toFiniteNumber(subscription.kwh_included) : undefined; + if (kwhUsed !== undefined && kwhIncluded !== undefined && kwhIncluded > 0) { + const percent = normalizePercent((kwhUsed / kwhIncluded) * 100); + if (percent !== undefined) { + quota.fiveHourPercent = percent; + const periodEnd = subscription ? normalizeResetAt(subscription.current_period_end) : undefined; + if (periodEnd !== undefined) quota.fiveHourResetAt = periodEnd; + windows += 1; + } + } + const balance = asRecord(data?.balance); + const totalCredits = balance ? toFiniteNumber(balance.total_credits_usd) : undefined; + const remainingCredits = balance ? toFiniteNumber(balance.credits_remaining_usd) : undefined; + if (totalCredits !== undefined && totalCredits > 0 && remainingCredits !== undefined) { + const percent = normalizePercent((remainingCredits / totalCredits) * 100); + if (percent !== undefined) { + quota.customWindows = [...(quota.customWindows ?? []), { label: "Prepaid credits", percent }]; + windows += 1; + } + } + return windows > 0 ? report(provider, "neuralwatt:quota", quota) : null; +} + function report( provider: string, source: string, @@ -1444,6 +1691,21 @@ async function maybeFetchProviderQuota( if ((provider.authMode ?? "key") === "key" && (name === "minimax" || name === "minimax-cn")) { return fetchMinimaxQuota(name, provider); } + if ((provider.authMode ?? "key") === "key" && name === "moonshot") { + return fetchMoonshotQuota(name, provider); + } + if ((provider.authMode ?? "key") === "key" && name === "venice") { + return fetchVeniceQuota(name, provider); + } + if ((provider.authMode ?? "key") === "key" && name === "synthetic") { + return fetchSyntheticQuota(name, provider); + } + if ((provider.authMode ?? "key") === "key" && name === "deepinfra") { + return fetchDeepInfraQuota(name, provider); + } + if ((provider.authMode ?? "key") === "key" && name === "neuralwatt") { + return fetchNeuralwattQuota(name, provider); + } return null; } catch { return null; diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 344fa1dbb..c4352a3ba 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -832,6 +832,165 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); + test("Moonshot quota renders a balance window from the account balance", 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 }); + return new Response(JSON.stringify({ + data: { available_balance: 8, voucher_balance: 2, cash_balance: 6 }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("moonshot", "https://api.moonshot.ai/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("moonshot:balance"); + expect(result.reports[0]?.quota.customWindows?.[0]?.label).toContain("$8.00"); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://api.moonshot.ai/v1/users/me/balance"); + expect(seen[0]?.authorization).toBe("Bearer moonshot-secret"); + expect(seen[0]?.redirect).toBe("error"); + }); + + test("Moonshot quota never sends the key 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( + keyQuotaConfig("moonshot", "https://attacker.example/v1"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + + test("Venice quota renders a DIEM epoch allocation window when present", 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 }); + return new Response(JSON.stringify({ + data: { balance: 250, diem_epoch_used: 30, diem_epoch_allocated: 100 }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("venice", "https://api.venice.ai/api/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("venice:billing-balance"); + expect(result.reports[0]?.quota.customWindows?.[0]?.label).toContain("DIEM balance (250)"); + expect(result.reports[0]?.quota.customWindows?.[0]?.percent).toBe(30); + expect(seen[0]?.url).toBe("https://api.venice.ai/api/v1/billing/balance"); + expect(seen[0]?.authorization).toBe("Bearer venice-secret"); + }); + + test("Synthetic quota maps rolling 5-hour and weekly token lanes", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + data: { rollingFiveHourLimit: 40.5, weeklyTokenLimit: 52, search: { hourly: 12 } }, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("synthetic", "https://api.synthetic.new/v2"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("synthetic:quotas"); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 40.5, + weeklyPercent: 52, + }); + expect(result.reports[0]?.quota.customWindows?.[0]).toMatchObject({ label: "Search hourly", percent: 12 }); + }); + + test("Synthetic quota never sends the key 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( + keyQuotaConfig("synthetic", "https://attacker.example/v2"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + + test("DeepInfra quota renders a billing-cycle spend window when a limit is set", 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 }); + return new Response(JSON.stringify({ + stripe_balance: -10, spending_limit: 50, total_amount_due: 5, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("deepinfra", "https://api.deepinfra.com/v1/openai"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("deepinfra:billing-checklist"); + expect(result.reports[0]?.quota.customWindows?.[0]?.label).toContain("$5.00 of $50.00"); + expect(seen[0]?.url).toContain("/payment/checklist"); + expect(seen[0]?.authorization).toBe("Bearer deepinfra-secret"); + }); + + test("DeepInfra quota never sends the key 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( + keyQuotaConfig("deepinfra", "https://attacker.example/v1/openai"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + + test("Neuralwatt quota renders subscription kWh + prepaid credits windows", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + data: { + subscription: { kwh_used: 5, kwh_included: 20, current_period_end: "2026-08-31T00:00:00Z" }, + balance: { total_credits_usd: 10, credits_remaining_usd: 7 }, + }, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("neuralwatt", "https://api.neuralwatt.com/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("neuralwatt:quota"); + expect(result.reports[0]?.quota.fiveHourPercent).toBe(25); + expect(result.reports[0]?.quota.customWindows?.[0]).toMatchObject({ label: "Prepaid credits", percent: 70 }); + }); + + test("Neuralwatt quota never sends the key 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( + keyQuotaConfig("neuralwatt", "https://attacker.example/v1"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).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 118ba3241ed744cce3fef76c211261434caf7a73 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:34:39 +0200 Subject: [PATCH 04/11] fix(providers): address Codex + CodeRabbit findings on quota probes and UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex connector findings: - DeepSeek: read total/granted from `balance_infos` rows (currency-selected) instead of nonexistent top-level fields; report a balance-only window (granted_balance is a current component, not a grant ceiling — no fabricated utilization). Accept the canonical `/v1` base URL. - OpenRouter: derive utilization from `limit_remaining` (authoritative for reset/re-capped keys) and treat a successful no-cap response as TERMINAL so an old capped bar is dropped instead of preserved as last-good. - Moonshot: report balance-only (percent 0) — no fabricated utilization. - Neuralwatt: report CONSUMED credits (total - remaining)/total, not the remaining share. - MiniMax: select the CN `token_plan/remains` host for minimax-cn; never assume a 30-day window — report a duration-only window unless the API supplies a plan total. - provider-routes: resolve documented limits by provider DESTINATION (registryEntryForProviderDestination) so a preset saved under a custom name still shows its limits. - registry: add rateLimits to the minimax-cn entry (exact lookup). - GUI: nest documented rows inside the rate-limits column (preserves the 2-col dashboard grid); suppress "No rate-limit data yet" when documented limits exist; add documented-limits CSS (gaps, wrapping, overflow). - i18n: localize free-tier prose via translation keys in all six locales instead of rendering backend-authored English verbatim. - docs-site: document the live-vs-documented rate-limit distinction. CodeRabbit findings: - MiniMax: no presumed 30-day window; regression coverage for the duration-only and consumed-share paths, and the CN host. - Tests: update assertions for the behavior changes (deepseek balance-only, moonshot balance-only, neuralwatt consumed, minimax duration-only) and add regression cases (moonshot CN, deepinfra root base, deepseek /v1, openrouter reset-key + cap-removal). Co-authored-by: CommandCodeBot --- .../src/content/docs/guides/providers.md | 18 +++ .../ProviderDocumentedLimits.tsx | 29 +++- .../ProviderOverviewDashboard.tsx | 20 +-- gui/src/i18n/de.ts | 13 ++ gui/src/i18n/en.ts | 13 ++ gui/src/i18n/ja.ts | 13 ++ gui/src/i18n/ko.ts | 13 ++ gui/src/i18n/ru.ts | 13 ++ gui/src/i18n/zh.ts | 13 ++ .../styles/provider-overview-dashboard.css | 40 +++++ src/providers/quota.ts | 106 ++++++++----- src/providers/registry.ts | 1 + src/server/management/provider-routes.ts | 9 +- tests/provider-quota.test.ts | 139 +++++++++++++++--- 14 files changed, 366 insertions(+), 74 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 1d1278e3c..a50048e9a 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -484,3 +484,21 @@ If a provider speaks Chat Completions, the `openai-chat` adapter handles it — dashboard or `custom` in `ocx init` and enter the base URL. See the [Configuration reference](/reference/configuration/) for every provider field (`headers`, `noReasoningModels`, `noVisionModels`, `models`, …). + +## Rate limits in the providers overview + +The **Rate limits** section of the Providers overview shows two kinds of data: + +- **Live utilization** — refreshed from each provider's own usage/billing endpoint when one exists. + The bars show how much of a window (5-hour, weekly, monthly, or provider-specific) is already + consumed. Providers with a live probe: OpenAI/Codex, Anthropic, xAI, Cursor, Kimi, Google + Antigravity, OpenRouter, DeepSeek, ClinePass, Z.AI, MiniMax, Moonshot, Venice, Synthetic, + DeepInfra, Neuralwatt, and any a6api-backed custom provider. +- **Documented reference** — for providers without a live endpoint, the overview shows the rate + limits published in the provider's official docs (requests/minute, tokens/minute, free-tier + caps) as reference text. + +Documented limits are **not account-specific**: they describe a published tier, not your actual +plan, and can drift as providers change their pricing or limits. Treat them as reference — the +`source` and `updatedAt` fields show where the numbers came from and when they were last verified. + diff --git a/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx b/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx index 4e96d7dcd..1df747420 100644 --- a/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx +++ b/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx @@ -19,12 +19,39 @@ function formatNumber(value: number): string { return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 0 }).format(value); } +/** + * Map a known free-tier description to a localizable i18n key. Registry + * `freeTier` prose is English-authored backend data; rendering it verbatim + * leaves non-English locales with a partially-English row. Unknown strings + * fall back to the raw prose. + */ +const FREE_TIER_KEYS: Record = { + "Local — no remote limits": "pws.rateLimits.freeTier.local", + "~200 free-model requests per 5 hours": "pws.rateLimits.freeTier.opencodeFree", + "Free tier: ~15 RPM / 250K TPM / 1K RPD (tier-dependent)": "pws.rateLimits.freeTier.gemini", + "Free tier: ~30 RPM / 6K TPM / 1K RPD": "pws.rateLimits.freeTier.groq", + "Free tier: ~60 RPM / 100K TPM; then pay-as-you-go": "pws.rateLimits.freeTier.sambanova", + "Free tier: ~50 RPM / 50K TPM": "pws.rateLimits.freeTier.nebius", + "Free tier: ~5 RPM / 20K TPM; then pay-as-you-go": "pws.rateLimits.freeTier.mistral", + "Free tier: ~60 RPM / 1M TPM; then pay-as-you-go": "pws.rateLimits.freeTier.together", + "Free tier: ~600 RPM / 150K TPM": "pws.rateLimits.freeTier.fireworks", + "Free models: ~20 req/min, credit-capped; paid per model": "pws.rateLimits.freeTier.openrouter", + "Coding plan subscription; per-plan quotas": "pws.rateLimits.freeTier.minimax", + "GLM coding subscription (paid plan)": "pws.rateLimits.freeTier.zai", + "New accounts: one-time token grant; then pay-as-you-go": "pws.rateLimits.freeTier.deepseek", +}; + +function localizeFreeTier(freeTier: string, t: TFn): string { + const key = FREE_TIER_KEYS[freeTier]; + return key ? t(key as never) : freeTier; +} + export function formatDocumentedLimits(rateLimits: DocumentedRateLimits, t: TFn): string { const parts: string[] = []; if (rateLimits.rpm !== undefined) parts.push(t("pws.rateLimits.rpm", { value: formatNumber(rateLimits.rpm) })); if (rateLimits.tpm !== undefined) parts.push(t("pws.rateLimits.tpm", { value: formatNumber(rateLimits.tpm) })); if (rateLimits.rpd !== undefined) parts.push(t("pws.rateLimits.rpd", { value: formatNumber(rateLimits.rpd) })); - if (rateLimits.freeTier) parts.push(rateLimits.freeTier); + if (rateLimits.freeTier) parts.push(localizeFreeTier(rateLimits.freeTier, t)); return parts.join(" · "); } diff --git a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx index 3a7f63863..30d2be823 100644 --- a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx +++ b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx @@ -197,18 +197,12 @@ export default function ProviderOverviewDashboard({ ))} - ) : ( + ) : documentedLimitProviders.length === 0 ? (

{t("pws.dashboard.noRateLimits")}

- )} -
- - {documentedLimitProviders.length > 0 && ( -
-

{t("pws.rateLimits.documented")}

-
+ ) : null} + {documentedLimitProviders.length > 0 && ( +
+
{t("pws.rateLimits.documented")}
{documentedLimitProviders.map(item => (
-
- )} + )} +
= { "pws.rateLimits.rpm": "{value} Anfragen/min", "pws.rateLimits.tpm": "{value} Tokens/min", "pws.rateLimits.rpd": "{value} Anfragen/Tag", + "pws.rateLimits.freeTier.local": "Lokal — keine Remote-Limits", + "pws.rateLimits.freeTier.opencodeFree": "~200 kostenlose Modell-Anfragen pro 5 Stunden", + "pws.rateLimits.freeTier.gemini": "Kostenlos: ~15 RPM / 250K TPM / 1K RPD (stufenabhängig)", + "pws.rateLimits.freeTier.groq": "Kostenlos: ~30 RPM / 6K TPM / 1K RPD", + "pws.rateLimits.freeTier.sambanova": "Kostenlos: ~60 RPM / 100K TPM; dann Pay-as-you-go", + "pws.rateLimits.freeTier.nebius": "Kostenlos: ~50 RPM / 50K TPM", + "pws.rateLimits.freeTier.mistral": "Kostenlos: ~5 RPM / 20K TPM; dann Pay-as-you-go", + "pws.rateLimits.freeTier.together": "Kostenlos: ~60 RPM / 1M TPM; dann Pay-as-you-go", + "pws.rateLimits.freeTier.fireworks": "Kostenlos: ~600 RPM / 150K TPM", + "pws.rateLimits.freeTier.openrouter": "Kostenlose Modelle: ~20 Anfragen/min, kreditbegrenzt; bezahlte pro Modell", + "pws.rateLimits.freeTier.minimax": "Coding-Plan-Abo; planabhängige Kontingente", + "pws.rateLimits.freeTier.zai": "GLM-Coding-Abo (kostenpflichtig)", + "pws.rateLimits.freeTier.deepseek": "Neue Konten: einmaliges Token-Guthaben; dann Pay-as-you-go", "pws.quotaUnavailable": "Keine Kontingentdaten für diesen Provider.", "pws.accountQuotaUnavailable": "Ratenlimit-Daten vorübergehend nicht verfügbar; falls vorhanden, werden zuletzt bekannte Werte angezeigt.", "pws.selected": "Ausgewählt", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9cf508838..b446bed42 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1048,6 +1048,19 @@ export const en = { "pws.rateLimits.rpm": "{value} req/min", "pws.rateLimits.tpm": "{value} tok/min", "pws.rateLimits.rpd": "{value} req/day", + "pws.rateLimits.freeTier.local": "Local — no remote limits", + "pws.rateLimits.freeTier.opencodeFree": "~200 free-model requests per 5 hours", + "pws.rateLimits.freeTier.gemini": "Free tier: ~15 RPM / 250K TPM / 1K RPD (tier-dependent)", + "pws.rateLimits.freeTier.groq": "Free tier: ~30 RPM / 6K TPM / 1K RPD", + "pws.rateLimits.freeTier.sambanova": "Free tier: ~60 RPM / 100K TPM; then pay-as-you-go", + "pws.rateLimits.freeTier.nebius": "Free tier: ~50 RPM / 50K TPM", + "pws.rateLimits.freeTier.mistral": "Free tier: ~5 RPM / 20K TPM; then pay-as-you-go", + "pws.rateLimits.freeTier.together": "Free tier: ~60 RPM / 1M TPM; then pay-as-you-go", + "pws.rateLimits.freeTier.fireworks": "Free tier: ~600 RPM / 150K TPM", + "pws.rateLimits.freeTier.openrouter": "Free models: ~20 req/min, credit-capped; paid per model", + "pws.rateLimits.freeTier.minimax": "Coding plan subscription; per-plan quotas", + "pws.rateLimits.freeTier.zai": "GLM coding subscription (paid plan)", + "pws.rateLimits.freeTier.deepseek": "New accounts: one-time token grant; then pay-as-you-go", "pws.quotaUnavailable": "No quota data for this provider.", "pws.accountQuotaUnavailable": "Rate-limit data temporarily unavailable; showing last known values when present.", "pws.selected": "Selected", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 9ed5853f4..d8d4b84f3 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -996,6 +996,19 @@ export const ja: Record = { "pws.rateLimits.rpm": "{value} 回/分", "pws.rateLimits.tpm": "{value} トークン/分", "pws.rateLimits.rpd": "{value} 回/日", + "pws.rateLimits.freeTier.local": "ローカル — リモート制限なし", + "pws.rateLimits.freeTier.opencodeFree": "5時間あたり約200回の無料モデルリクエスト", + "pws.rateLimits.freeTier.gemini": "無料: ~15 RPM / 250K TPM / 1K RPD (プランにより変動)", + "pws.rateLimits.freeTier.groq": "無料: ~30 RPM / 6K TPM / 1K RPD", + "pws.rateLimits.freeTier.sambanova": "無料: ~60 RPM / 100K TPM; 以降は従量課金", + "pws.rateLimits.freeTier.nebius": "無料: ~50 RPM / 50K TPM", + "pws.rateLimits.freeTier.mistral": "無料: ~5 RPM / 20K TPM; 以降は従量課金", + "pws.rateLimits.freeTier.together": "無料: ~60 RPM / 1M TPM; 以降は従量課金", + "pws.rateLimits.freeTier.fireworks": "無料: ~600 RPM / 150K TPM", + "pws.rateLimits.freeTier.openrouter": "無料モデル: ~20 回/分、クレジット上限あり; 有料はモデル別", + "pws.rateLimits.freeTier.minimax": "コーディングプラン定額; プラン別クォータ", + "pws.rateLimits.freeTier.zai": "GLMコーディング定額 (有料)", + "pws.rateLimits.freeTier.deepseek": "新規アカウント: 一度きりのトークン付与; 以降は従量課金", "pws.quotaUnavailable": "このプロバイダーのクォータデータがありません。", "pws.accountQuotaUnavailable": "レート制限データを一時的に取得できません。前回の値がある場合はそれを表示します。", "pws.selected": "選択中", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7338bc5c8..162280aae 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1556,6 +1556,19 @@ export const ko: Record = { "pws.rateLimits.rpm": "{value} 회/분", "pws.rateLimits.tpm": "{value} 토큰/분", "pws.rateLimits.rpd": "{value} 회/일", + "pws.rateLimits.freeTier.local": "로컬 — 원격 제한 없음", + "pws.rateLimits.freeTier.opencodeFree": "5시간당 무료 모델 요청 약 200회", + "pws.rateLimits.freeTier.gemini": "무료: ~15 RPM / 250K TPM / 1K RPD (플랜별 상이)", + "pws.rateLimits.freeTier.groq": "무료: ~30 RPM / 6K TPM / 1K RPD", + "pws.rateLimits.freeTier.sambanova": "무료: ~60 RPM / 100K TPM; 이후 종량제", + "pws.rateLimits.freeTier.nebius": "무료: ~50 RPM / 50K TPM", + "pws.rateLimits.freeTier.mistral": "무료: ~5 RPM / 20K TPM; 이후 종량제", + "pws.rateLimits.freeTier.together": "무료: ~60 RPM / 1M TPM; 이후 종량제", + "pws.rateLimits.freeTier.fireworks": "무료: ~600 RPM / 150K TPM", + "pws.rateLimits.freeTier.openrouter": "무료 모델: 분당 약 20회, 크레딧 한도; 유료는 모델별", + "pws.rateLimits.freeTier.minimax": "코딩 플랜 구독; 플랜별 할당량", + "pws.rateLimits.freeTier.zai": "GLM 코딩 구독 (유료)", + "pws.rateLimits.freeTier.deepseek": "신규 계정: 일회성 토큰 제공; 이후 종량제", "pws.quotaUnavailable": "이 프로바이더의 쿼터 데이터가 없습니다.", "pws.accountQuotaUnavailable": "요금 한도 데이터를 일시적으로 가져올 수 없습니다. 이전 값이 있으면 그대로 표시합니다.", "pws.selected": "선택됨", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 6c9b0995d..07c868177 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1038,6 +1038,19 @@ export const ru: Record = { "pws.rateLimits.rpm": "{value} запр./мин", "pws.rateLimits.tpm": "{value} токенов/мин", "pws.rateLimits.rpd": "{value} запр./день", + "pws.rateLimits.freeTier.local": "Локально — удалённых лимитов нет", + "pws.rateLimits.freeTier.opencodeFree": "~200 бесплатных запросов моделей за 5 часов", + "pws.rateLimits.freeTier.gemini": "Бесплатно: ~15 RPM / 250K TPM / 1K RPD (зависит от тарифа)", + "pws.rateLimits.freeTier.groq": "Бесплатно: ~30 RPM / 6K TPM / 1K RPD", + "pws.rateLimits.freeTier.sambanova": "Бесплатно: ~60 RPM / 100K TPM; далее оплата по факту", + "pws.rateLimits.freeTier.nebius": "Бесплатно: ~50 RPM / 50K TPM", + "pws.rateLimits.freeTier.mistral": "Бесплатно: ~5 RPM / 20K TPM; далее оплата по факту", + "pws.rateLimits.freeTier.together": "Бесплатно: ~60 RPM / 1M TPM; далее оплата по факту", + "pws.rateLimits.freeTier.fireworks": "Бесплатно: ~600 RPM / 150K TPM", + "pws.rateLimits.freeTier.openrouter": "Бесплатные модели: ~20 запр./мин, ограничение по кредитам; платные — за модель", + "pws.rateLimits.freeTier.minimax": "Подписка Coding Plan; квоты по тарифу", + "pws.rateLimits.freeTier.zai": "Подписка GLM Coding (платная)", + "pws.rateLimits.freeTier.deepseek": "Новые аккаунты: разовый грант токенов; далее оплата по факту", "pws.quotaUnavailable": "Нет данных о квоте для этого провайдера.", "pws.accountQuotaUnavailable": "Данные о лимитах временно недоступны; при наличии показываются последние известные значения.", "pws.selected": "Выбрана", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 2b5927182..766ffdadc 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1549,6 +1549,19 @@ export const zh: Record = { "pws.rateLimits.rpm": "{value} 次/分钟", "pws.rateLimits.tpm": "{value} 令牌/分钟", "pws.rateLimits.rpd": "{value} 次/天", + "pws.rateLimits.freeTier.local": "本地 — 无远程限制", + "pws.rateLimits.freeTier.opencodeFree": "每 5 小时约 200 次免费模型请求", + "pws.rateLimits.freeTier.gemini": "免费: ~15 RPM / 250K TPM / 1K RPD(因套餐而异)", + "pws.rateLimits.freeTier.groq": "免费: ~30 RPM / 6K TPM / 1K RPD", + "pws.rateLimits.freeTier.sambanova": "免费: ~60 RPM / 100K TPM; 之后按量付费", + "pws.rateLimits.freeTier.nebius": "免费: ~50 RPM / 50K TPM", + "pws.rateLimits.freeTier.mistral": "免费: ~5 RPM / 20K TPM; 之后按量付费", + "pws.rateLimits.freeTier.together": "免费: ~60 RPM / 1M TPM; 之后按量付费", + "pws.rateLimits.freeTier.fireworks": "免费: ~600 RPM / 150K TPM", + "pws.rateLimits.freeTier.openrouter": "免费模型: 约 20 次/分钟,有额度上限; 付费按模型计费", + "pws.rateLimits.freeTier.minimax": "编码套餐订阅; 按套餐配额", + "pws.rateLimits.freeTier.zai": "GLM 编码订阅(付费)", + "pws.rateLimits.freeTier.deepseek": "新账户: 一次性令牌赠送; 之后按量付费", "pws.quotaUnavailable": "此提供商暂无配额数据。", "pws.accountQuotaUnavailable": "速率限制数据暂时不可用;若有上次已知值则继续显示。", "pws.selected": "已选择", diff --git a/gui/src/styles/provider-overview-dashboard.css b/gui/src/styles/provider-overview-dashboard.css index be875ef1d..1335117d0 100644 --- a/gui/src/styles/provider-overview-dashboard.css +++ b/gui/src/styles/provider-overview-dashboard.css @@ -1,5 +1,45 @@ /* ProviderOverviewDashboard — aggregate overview (Phase 010) */ +/* Documented (reference) rate limits nested in the Rate limits column */ +.pws-dashboard-rows--documented { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(128, 128, 128, 0.25); +} +.pws-dashboard-documented-heading { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--fg-muted, #8a8a8a); + margin-bottom: 4px; +} + +/* Documented-limit reference lines: label, value, and provenance with gaps and wrapping */ +.pws-documented-limits { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + font-size: 12px; + margin-top: 2px; +} +.pws-documented-limits-label { + font-weight: 600; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.03em; + color: var(--fg-muted, #8a8a8a); +} +.pws-documented-limits-value { + overflow-wrap: anywhere; + min-width: 0; +} +.pws-documented-limits-meta { + overflow-wrap: anywhere; + min-width: 0; +} + .pws-dashboard { /* Muted labels here must clear WCAG 4.5:1 in both themes; the old `var(--fg-muted, #888)` fallback only reached ~3.5:1 on white. Alias the design-system muted token instead. */ diff --git a/src/providers/quota.ts b/src/providers/quota.ts index c13b1cca0..1eba4ad09 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -260,7 +260,7 @@ function isCanonicalOpenRouterBaseUrl(baseUrl: string): boolean { function isCanonicalDeepSeekBaseUrl(baseUrl: string): boolean { const normalized = normalizedBaseUrl(baseUrl); - return normalized === DEEPSEEK_BASE_URL; + return normalized === DEEPSEEK_BASE_URL || normalized === `${DEEPSEEK_BASE_URL}/v1`; } function isCanonicalClineBaseUrl(baseUrl: string): boolean { @@ -394,11 +394,14 @@ async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig) const limit = toFiniteNumber(data.limit); const limitRemaining = toFiniteNumber(data.limit_remaining); const usage = toFiniteNumber(data.usage); - // No per-key cap means there is no limit to render utilization against. - if (limit === undefined || limit <= 0) return null; - const used = usage !== undefined && usage > 0 - ? usage - : limitRemaining !== undefined ? Math.max(0, limit - limitRemaining) : undefined; + // A successful no-cap response is a DELIBERATE change, not a transient + // failure: the old capped row must be dropped, not preserved as last-good. + if (limit === undefined || limit <= 0) return TERMINAL_QUOTA_FAILURE; + // Prefer the authoritative remaining-cap value when present: `usage` is + // lifetime accumulated spend and overstates a reset or re-capped key. + const used = limitRemaining !== undefined + ? Math.max(0, limit - limitRemaining) + : usage !== undefined && usage > 0 ? usage : undefined; if (used === undefined) return null; const percent = normalizePercent((used / limit) * 100); if (percent === undefined) return null; @@ -412,10 +415,11 @@ async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig) /** * DeepSeek `GET /user/balance` — the account's granted + topped-up credit - * balance. DeepSeek grants new accounts a one-time token allowance; the API - * reports `total_balance` and `granted_balance`, so a bar can be rendered - * against the granted allowance. When the balance is pure pay-as-you-go there - * is no hard cap to meter against, so no bar is produced. + * balance. The payload places `total_balance` / `granted_balance` inside + * entries of `balance_infos` (one row per currency); the row for the account's + * currency is selected by preference. `granted_balance` is a CURRENT balance + * component, not the original grant ceiling, so no consumed percentage is + * fabricated — the balance is reported as a balance-only window. */ async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalDeepSeekBaseUrl(config.baseUrl)) return null; @@ -432,17 +436,26 @@ async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): : null; } const body = asRecord(await response.json().catch(() => null)); - const totalBalance = toFiniteNumber(body?.total_balance); - const grantedBalance = toFiniteNumber(body?.granted_balance); - // A granted allowance is the only hard cap DeepSeek meters against; a - // top-up-only account (granted = 0) has no limit to render utilization. - if (grantedBalance === undefined || grantedBalance <= 0) return null; - if (totalBalance === undefined || totalBalance < 0) return null; - const percent = normalizePercent((totalBalance / grantedBalance) * 100); - if (percent === undefined) return null; - const label = `API balance ($${totalBalance.toFixed(2)} of $${grantedBalance.toFixed(2)} granted)`; + // The payload nests balances under `balance_infos` rows keyed by currency; + // prefer a USD row, then CNY, then the first row that parses. + const infos = Array.isArray(body?.balance_infos) ? body.balance_infos as unknown[] : null; + const rows = infos + ? infos.map((raw): Record | null => asRecord(raw)).filter((r): r is Record => r !== null) + : []; + const pick = (currency: string): Record | null => + rows.find(row => String(row.currency ?? "").toUpperCase() === currency) ?? null; + const preferred = pick("USD") ?? pick("CNY") ?? rows[0] ?? null; + if (!preferred) return null; + const totalBalance = toFiniteNumber(preferred.total_balance); + const grantedBalance = toFiniteNumber(preferred.granted_balance); + const toppedUp = toFiniteNumber(preferred.topped_up_balance); + const balance = totalBalance ?? grantedBalance ?? toppedUp; + if (balance === undefined || balance < 0) return null; + const label = grantedBalance !== undefined && grantedBalance > 0 + ? `API balance ($${balance.toFixed(2)} total, $${grantedBalance.toFixed(2)} granted)` + : `API balance ($${balance.toFixed(2)})`; return report(provider, "deepseek:balance", { - customWindows: [{ label, percent }], + customWindows: [{ label, percent: 0 }], updatedAt: Date.now(), }); } @@ -551,15 +564,20 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi /** * MiniMax Token Plan `GET /v1/token_plan/remains` — the subscription's - * remaining quota as a countdown-time value (ms). The console shows a usage - * bar; this endpoint exposes the raw remaining time, so the bar is rendered - * from the remaining share of the plan window. + * remaining quota as a countdown-time value (ms). The endpoint does not expose + * the plan's total duration, so no percentage is fabricated from a presumed + * window: the remaining time is reported as a duration-only window. When the + * API supplies a total (`total_time` / `plan_duration_ms`), a consumed share + * is derived from it. Region selects the host: `minimax` → www.minimax.io, + * `minimax-cn` → api.minimaxi.com. */ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalMinimaxBaseUrl(config.baseUrl)) return null; const apiKey = resolveEnvValue(config.apiKey)?.trim(); if (!apiKey) return null; - const response = await fetch(MINIMAX_REMAINS_URL, { + const cnHost = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.minimaxi.com"); + const remainsUrl = cnHost ? "https://api.minimaxi.com/v1/token_plan/remains" : MINIMAX_REMAINS_URL; + const response = await fetch(remainsUrl, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -572,16 +590,24 @@ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): P const body = asRecord(await response.json().catch(() => null)); if (!body || body.success === false) return null; const data = asRecord(body.data) ?? body; - // `remains_time` is the remaining plan quota in ms (a countdown). The plan - // window (e.g. 30 days) is not exposed, so render the remaining share as a - // single custom window at the raw value; the label states it is remaining. const remainsMs = toFiniteNumber(data.remains_time ?? data.remainsTime); if (remainsMs === undefined || remainsMs < 0) return null; - const percent = normalizePercent(remainsMs <= 0 ? 100 : Math.min(100, (1 - remainsMs / 2_592_000_000) * 100)); - if (percent === undefined) return null; - const label = `Token Plan remaining (${Math.floor(remainsMs / 3_600_000)}h)`; + const hours = Math.floor(remainsMs / 3_600_000); + const label = `Token Plan remaining (${hours}h)`; + // Only derive a consumed share when the API actually reports the plan total; + // a presumed window (e.g. 30 days) would fabricate utilization. + const totalMs = toFiniteNumber(data.total_time ?? data.plan_duration_ms ?? data.total_duration_ms); + if (totalMs !== undefined && totalMs > 0) { + const consumed = Math.max(0, totalMs - remainsMs); + const percent = normalizePercent((consumed / totalMs) * 100); + if (percent === undefined) return null; + return report(provider, "minimax:token-plan-remains", { + customWindows: [{ label, percent }], + updatedAt: Date.now(), + }); + } return report(provider, "minimax:token-plan-remains", { - customWindows: [{ label, percent }], + customWindows: [{ label, percent: 0 }], updatedAt: Date.now(), }); } @@ -613,15 +639,13 @@ async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): const voucher = toFiniteNumber(data.voucher_balance); const cash = toFiniteNumber(data.cash_balance); if (available === undefined || available < 0) return null; - const cap = voucher !== undefined && cash !== undefined && voucher + cash > 0 - ? voucher + cash - : available; - if (cap <= 0) return null; - const percent = normalizePercent((available / cap) * 100); - if (percent === undefined) return null; - const label = `Balance ($${available.toFixed(2)} available)`; + // Moonshot exposes no per-window quota ceiling, only a balance — report it + // as a balance-only window (percent 0) rather than a fabricated utilization. + const label = voucher !== undefined && cash !== undefined + ? `Balance ($${available.toFixed(2)} available, $${voucher.toFixed(2)} voucher)` + : `Balance ($${available.toFixed(2)} available)`; return report(provider, "moonshot:balance", { - customWindows: [{ label, percent }], + customWindows: [{ label, percent: 0 }], updatedAt: Date.now(), }); } @@ -797,7 +821,9 @@ async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig) const totalCredits = balance ? toFiniteNumber(balance.total_credits_usd) : undefined; const remainingCredits = balance ? toFiniteNumber(balance.credits_remaining_usd) : undefined; if (totalCredits !== undefined && totalCredits > 0 && remainingCredits !== undefined) { - const percent = normalizePercent((remainingCredits / totalCredits) * 100); + // Utilization is CONSUMED credits, not the remaining share. + const used = Math.max(0, totalCredits - remainingCredits); + const percent = normalizePercent((used / totalCredits) * 100); if (percent !== undefined) { quota.customWindows = [...(quota.customWindows ?? []), { label: "Prepaid credits", percent }]; windows += 1; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 7dc0f209e..eafcdb1bf 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1973,6 +1973,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ reasoningSplitModels: MINIMAX_MODELS, thinkingToggleModels: ["MiniMax-M3"], jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "中国区 Subscription Key", + rateLimits: { rpm: 100, tpm: 200_000, freeTier: "Coding plan subscription; per-plan quotas", source: "https://platform.minimaxi.com/docs/guides/rate-limits", updatedAt: "2026-08-06" }, }, { id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 022a6b240..dc0ea3f65 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -48,7 +48,7 @@ import { readUsageEntries } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; -import { getProviderRegistryEntry } from "../../providers/registry"; +import { getProviderRegistryEntry, registryEntryForProviderDestination } from "../../providers/registry"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; import { @@ -242,7 +242,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { - const registry = getProviderRegistryEntry(name); + // Documented limits follow the DESTINATION, not the config key: a preset + // saved under a custom name (e.g. "my-groq") must still surface Groq's + // limits. Fall back to the exact id lookup for non-key presets (forward/ + // oauth/local) whose destination resolver does not apply. + const registry = getProviderRegistryEntry(name) + ?? registryEntryForProviderDestination(p); return { name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, hasApiKey: !!p.apiKey, diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index c4352a3ba..df1190462 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -568,14 +568,39 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); - test("OpenRouter quota drops a key with no spending cap (no bar to render)", async () => { + test("OpenRouter quota drops a key with no spending cap (terminal, not transient)", async () => { + // A successful no-cap response is a DELIBERATE cap removal — the old + // capped row must be suppressed, not preserved as a last-good transient. + let capped = true; + globalThis.fetch = (async () => new Response(JSON.stringify( + capped + ? { data: { usage: 5, limit: 20, limit_remaining: 15 } } + : { data: { usage: 3, is_free_tier: false } }, + ), { status: 200 })) as typeof fetch; + const config = keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1"); + + const valid = await fetchProviderQuotaReports(config, true); + capped = false; + const uncapped = await fetchProviderQuotaReports(config, true); + + expect(valid.reports).toHaveLength(1); + expect(uncapped.reports).toEqual([]); + }); + + test("OpenRouter quota prefers limit_remaining over accumulated usage for reset keys", async () => { + // A reset key can report large accumulated `usage` while most of the + // current cap remains; utilization must come from limit_remaining. globalThis.fetch = (async () => new Response(JSON.stringify({ - data: { label: "openrouter", usage: 3, is_free_tier: false }, + data: { usage: 90, limit: 20, limit_remaining: 18 }, }), { status: 200 })) as typeof fetch; const result = await fetchProviderQuotaReports(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1"), true); - expect(result.reports).toEqual([]); + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota.customWindows?.[0]).toEqual({ + label: "API credits ($18.00 of $20.00 remaining)", + percent: 10, + }); }); test("OpenRouter quota treats a terminal 401 as invalid (drops last-good)", async () => { @@ -613,7 +638,7 @@ describe("fetchProviderQuotaReports", () => { expect(throttledRefresh.reports).toEqual(valid.reports); }); - test("DeepSeek quota renders a balance window against the granted allowance", async () => { + test("DeepSeek quota renders a balance-only window from balance_infos", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -621,10 +646,8 @@ describe("fetchProviderQuotaReports", () => { seen.push({ url, authorization: headers?.Authorization, redirect: init?.redirect }); return new Response(JSON.stringify({ is_available: true, - balance_infos: [{ currency: "CNY", total_balance: "6", granted_balance: "8", topped_up_balance: "0" }], - total_balance: "6", - granted_balance: "8", - topped_up_balance: "0", + // The real payload nests balances per currency inside balance_infos. + balance_infos: [{ currency: "CNY", total_balance: "6", granted_balance: "4", topped_up_balance: "2" }], }), { status: 200 }); }) as typeof fetch; @@ -633,8 +656,8 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports).toHaveLength(1); expect(result.reports[0]?.source).toBe("deepseek:balance"); expect(result.reports[0]?.quota.customWindows).toEqual([{ - label: "API balance ($6.00 of $8.00 granted)", - percent: 75, + label: "API balance ($6.00 total, $4.00 granted)", + percent: 0, }]); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://api.deepseek.com/user/balance"); @@ -658,12 +681,27 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); - test("DeepSeek quota drops a top-up-only account (granted = 0, no cap to meter)", async () => { + test("DeepSeek quota accepts the canonical /v1 base URL and probes the root endpoint", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response(JSON.stringify({ + is_available: true, + balance_infos: [{ currency: "CNY", total_balance: "6" }], + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("deepseek", "https://api.deepseek.com/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(seen[0]).toBe("https://api.deepseek.com/user/balance"); + }); + + test("DeepSeek quota drops a payload with no balance_infos rows", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ is_available: true, total_balance: "50", granted_balance: "0", - topped_up_balance: "50", }), { status: 200 })) as typeof fetch; const result = await fetchProviderQuotaReports(keyQuotaConfig("deepseek", "https://api.deepseek.com"), true); @@ -675,7 +713,10 @@ describe("fetchProviderQuotaReports", () => { let rejected = false; globalThis.fetch = (async () => { if (rejected) return new Response("unauthorized", { status: 401 }); - return new Response(JSON.stringify({ is_available: true, total_balance: "6", granted_balance: "8" }), { status: 200 }); + return new Response(JSON.stringify({ + is_available: true, + balance_infos: [{ currency: "CNY", total_balance: "6", granted_balance: "4" }], + }), { status: 200 }); }) as typeof fetch; const config = keyQuotaConfig("deepseek", "https://api.deepseek.com"); @@ -796,7 +837,7 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); - test("MiniMax quota renders the Token Plan remaining-time window", async () => { + test("MiniMax quota renders the Token Plan remaining-time as a duration-only window", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -809,13 +850,42 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports).toHaveLength(1); expect(result.reports[0]?.source).toBe("minimax:token-plan-remains"); - expect(result.reports[0]?.quota.customWindows?.[0]?.label).toContain("Token Plan remaining"); + // No total duration from the API → duration-only window, no fabricated percent. + expect(result.reports[0]?.quota.customWindows?.[0]).toMatchObject({ + label: "Token Plan remaining (277h)", + percent: 0, + }); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://www.minimax.io/v1/token_plan/remains"); expect(seen[0]?.authorization).toBe("Bearer minimax-secret"); expect(seen[0]?.redirect).toBe("error"); }); + test("MiniMax quota derives a consumed share when the API reports the plan total", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + success: true, + data: { remains_time: 750_000_000, total_time: 1_000_000_000 }, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("minimax", "https://api.minimax.io/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota.customWindows?.[0]?.percent).toBe(25); + }); + + test("MiniMax CN quota probes the minimaxi.com host", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response(JSON.stringify({ success: true, data: { remains_time: 1_000_000_000 } }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("minimax-cn", "https://api.minimaxi.com/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(seen[0]).toBe("https://api.minimaxi.com/v1/token_plan/remains"); + }); + test("MiniMax quota never sends the key to a non-canonical base URL", async () => { const seen: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL) => { @@ -832,7 +902,7 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); - test("Moonshot quota renders a balance window from the account balance", async () => { + test("Moonshot quota renders a balance-only window from the account balance", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -847,13 +917,32 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports).toHaveLength(1); expect(result.reports[0]?.source).toBe("moonshot:balance"); - expect(result.reports[0]?.quota.customWindows?.[0]?.label).toContain("$8.00"); + // Balance-only: no fabricated utilization percentage. + expect(result.reports[0]?.quota.customWindows?.[0]).toMatchObject({ + label: "Balance ($8.00 available, $2.00 voucher)", + percent: 0, + }); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://api.moonshot.ai/v1/users/me/balance"); expect(seen[0]?.authorization).toBe("Bearer moonshot-secret"); expect(seen[0]?.redirect).toBe("error"); }); + test("Moonshot quota probes the CN host for a China-region base URL", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response(JSON.stringify({ + data: { available_balance: 5, voucher_balance: 0, cash_balance: 5 }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("moonshot", "https://api.moonshot.cn/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(seen[0]).toBe("https://api.moonshot.cn/v1/users/me/balance"); + }); + test("Moonshot quota never sends the key to a non-canonical base URL", async () => { const seen: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL) => { @@ -959,6 +1048,19 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); + test("DeepInfra quota accepts the root base URL and probes the payment checklist", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response(JSON.stringify({ stripe_balance: -10, spending_limit: 50, total_amount_due: 5 }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("deepinfra", "https://api.deepinfra.com"), true); + + expect(result.reports).toHaveLength(1); + expect(seen[0]).toBe("https://api.deepinfra.com/payment/checklist?compute_owed=true"); + }); + test("Neuralwatt quota renders subscription kWh + prepaid credits windows", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ data: { @@ -972,7 +1074,8 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports).toHaveLength(1); expect(result.reports[0]?.source).toBe("neuralwatt:quota"); expect(result.reports[0]?.quota.fiveHourPercent).toBe(25); - expect(result.reports[0]?.quota.customWindows?.[0]).toMatchObject({ label: "Prepaid credits", percent: 70 }); + // Utilization is CONSUMED credits: (10 − 7) / 10 = 30%, not the 70% remaining. + expect(result.reports[0]?.quota.customWindows?.[0]).toMatchObject({ label: "Prepaid credits", percent: 30 }); }); test("Neuralwatt quota never sends the key to a non-canonical base URL", async () => { From dd7c7ce3742fe2db5037795b09ff66b214bbb8d5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:44:01 +0200 Subject: [PATCH 05/11] fix(providers): address latest Codex + CodeRabbit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three valid findings from the latest review round: - GUI: type FREE_TIER_KEYS values as `Parameters[0]` so mapped translation keys stay validated against TFn's TKey constraint, and call `t(key)` directly without the `never` cast. - OpenRouter: accept `usage: 0` (a capped key with zero consumption must render 0% used / full cap remaining); `usage >= 0` replaces `usage > 0`. - MiniMax: when the API omits the plan total duration, suppress the row instead of reporting `percent: 0` — a 0% bar would falsely claim zero consumption. Only render a consumed share when a provider-supplied total is known. The remaining reported items (duplicate `const seen` in tests, and several "Addressed in commit 118ba32" markers) were verified against the current tree: each `seen` declaration lives in its own test callback scope and the suite parses and passes (84 quota tests green), so those findings are stale. Co-authored-by: CommandCodeBot --- .../ProviderDocumentedLimits.tsx | 4 +-- src/providers/quota.ts | 21 +++++------- tests/provider-quota.test.ts | 33 ++++++++++++++----- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx b/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx index 1df747420..41c394546 100644 --- a/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx +++ b/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx @@ -25,7 +25,7 @@ function formatNumber(value: number): string { * leaves non-English locales with a partially-English row. Unknown strings * fall back to the raw prose. */ -const FREE_TIER_KEYS: Record = { +const FREE_TIER_KEYS: Record[0]> = { "Local — no remote limits": "pws.rateLimits.freeTier.local", "~200 free-model requests per 5 hours": "pws.rateLimits.freeTier.opencodeFree", "Free tier: ~15 RPM / 250K TPM / 1K RPD (tier-dependent)": "pws.rateLimits.freeTier.gemini", @@ -43,7 +43,7 @@ const FREE_TIER_KEYS: Record = { function localizeFreeTier(freeTier: string, t: TFn): string { const key = FREE_TIER_KEYS[freeTier]; - return key ? t(key as never) : freeTier; + return key ? t(key) : freeTier; } export function formatDocumentedLimits(rateLimits: DocumentedRateLimits, t: TFn): string { diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 1eba4ad09..e10ec4f09 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -401,7 +401,7 @@ async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig) // lifetime accumulated spend and overstates a reset or re-capped key. const used = limitRemaining !== undefined ? Math.max(0, limit - limitRemaining) - : usage !== undefined && usage > 0 ? usage : undefined; + : usage !== undefined && usage >= 0 ? usage : undefined; if (used === undefined) return null; const percent = normalizePercent((used / limit) * 100); if (percent === undefined) return null; @@ -595,19 +595,16 @@ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): P const hours = Math.floor(remainsMs / 3_600_000); const label = `Token Plan remaining (${hours}h)`; // Only derive a consumed share when the API actually reports the plan total; - // a presumed window (e.g. 30 days) would fabricate utilization. + // a presumed window (e.g. 30 days) would fabricate utilization. Without a + // total there is no percentage to render, so suppress the row rather than + // report a false 0% (zero would mean "no consumption"). const totalMs = toFiniteNumber(data.total_time ?? data.plan_duration_ms ?? data.total_duration_ms); - if (totalMs !== undefined && totalMs > 0) { - const consumed = Math.max(0, totalMs - remainsMs); - const percent = normalizePercent((consumed / totalMs) * 100); - if (percent === undefined) return null; - return report(provider, "minimax:token-plan-remains", { - customWindows: [{ label, percent }], - updatedAt: Date.now(), - }); - } + if (totalMs === undefined || totalMs <= 0) return null; + const consumed = Math.max(0, totalMs - remainsMs); + const percent = normalizePercent((consumed / totalMs) * 100); + if (percent === undefined) return null; return report(provider, "minimax:token-plan-remains", { - customWindows: [{ label, percent: 0 }], + customWindows: [{ label, percent }], updatedAt: Date.now(), }); } diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index df1190462..622207519 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -603,6 +603,22 @@ describe("fetchProviderQuotaReports", () => { }); }); + test("OpenRouter quota reports zero consumption for a capped key with usage 0", async () => { + // A valid capped response with `usage: 0` and no limit_remaining must + // still render: 0% consumed, full cap remaining. + globalThis.fetch = (async () => new Response(JSON.stringify({ + data: { usage: 0, limit: 20 }, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota.customWindows?.[0]).toEqual({ + label: "API credits ($20.00 of $20.00 remaining)", + percent: 0, + }); + }); + test("OpenRouter quota treats a terminal 401 as invalid (drops last-good)", async () => { let rejected = false; globalThis.fetch = (async () => { @@ -837,7 +853,7 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); - test("MiniMax quota renders the Token Plan remaining-time as a duration-only window", async () => { + test("MiniMax quota suppresses the row when the API omits the plan total duration", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -848,13 +864,9 @@ describe("fetchProviderQuotaReports", () => { const result = await fetchProviderQuotaReports(keyQuotaConfig("minimax", "https://api.minimax.io/v1"), true); - expect(result.reports).toHaveLength(1); - expect(result.reports[0]?.source).toBe("minimax:token-plan-remains"); - // No total duration from the API → duration-only window, no fabricated percent. - expect(result.reports[0]?.quota.customWindows?.[0]).toMatchObject({ - label: "Token Plan remaining (277h)", - percent: 0, - }); + // No total duration → no percentage to render; a 0% bar would falsely + // claim zero consumption, so the row is suppressed entirely. + expect(result.reports).toEqual([]); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://www.minimax.io/v1/token_plan/remains"); expect(seen[0]?.authorization).toBe("Bearer minimax-secret"); @@ -877,7 +889,10 @@ describe("fetchProviderQuotaReports", () => { const seen: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL) => { seen.push(String(input)); - return new Response(JSON.stringify({ success: true, data: { remains_time: 1_000_000_000 } }), { status: 200 }); + return new Response(JSON.stringify({ + success: true, + data: { remains_time: 750_000_000, total_time: 1_000_000_000 }, + }), { status: 200 }); }) as typeof fetch; const result = await fetchProviderQuotaReports(keyQuotaConfig("minimax-cn", "https://api.minimaxi.com/v1"), true); From 09b3a26dc399e3be51119ea80fda5800e3379ddf Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:05:21 +0200 Subject: [PATCH 06/11] ci: trigger Cross-platform CI for the provider rate-limits PR From 554525c0f0b8fcd7f9707f15f84d348a035002f7 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:21:21 +0200 Subject: [PATCH 07/11] fix(providers): address Codex findings on limits resolution and probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings from the latest Codex review: - provider-routes + config DTO: documented limits now resolve purely by DESTINATION for key providers (a renamed preset like "my-groq" keeps Groq's limits; a key provider whose transport was edited to a custom host does NOT inherit the registry id's limits). Forward/oauth/local presets resolve by id, matching the note-resolution pattern. - config DTO (/api/config): safeConfigDTO now carries rateLimits so the Providers workspace (which loads /api/config, not /api/providers) gets the documented limits; the GUI ProvidersConfig type gained the field. - Z.AI: send the key as a Bearer token (Authorization: Bearer ) per the Z.AI API reference, not the raw key. - MiniMax: a valid response that omits the plan total after a prior refresh had it is a DELIBERATE contract change — return TERMINAL so the stale row is dropped instead of preserved as a transient last-good. - Synthetic: accept the preset base URL (api.synthetic.new/openai/v1) in addition to /v2 for the quota probe. Co-authored-by: CommandCodeBot --- gui/src/pages/providers-shared.ts | 9 ++++++ src/providers/quota.ts | 16 +++++----- src/server/auth-cors.ts | 10 ++++++ src/server/management/provider-routes.ts | 12 ++++--- tests/config.test.ts | 22 +++++++++++++ tests/provider-quota.test.ts | 40 ++++++++++++++++++------ 6 files changed, 89 insertions(+), 20 deletions(-) diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index fb64a56a8..e26f69956 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -15,6 +15,15 @@ export interface ProvidersConfig { disabled?: boolean; note?: string; codexAccountMode?: "direct" | "pool"; + /** Documented rate limits (official docs, not probed); shown as reference in the overview. */ + rateLimits?: { + rpm?: number; + tpm?: number; + rpd?: number; + freeTier?: string; + source?: string; + updatedAt?: string; + }; }>; } diff --git a/src/providers/quota.ts b/src/providers/quota.ts index e10ec4f09..3ff9396c8 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -288,7 +288,8 @@ function isCanonicalVeniceBaseUrl(baseUrl: string): boolean { } function isCanonicalSyntheticBaseUrl(baseUrl: string): boolean { - return normalizedBaseUrl(baseUrl) === SYNTHETIC_BASE_URL; + const normalized = normalizedBaseUrl(baseUrl); + return normalized === SYNTHETIC_BASE_URL || normalized === "https://api.synthetic.new/openai/v1"; } function isCanonicalDeepInfraBaseUrl(baseUrl: string): boolean { @@ -515,14 +516,14 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro /** * Z.AI GLM Coding Plan `GET /api/monitor/usage/quota/limit` — the coding-plan * subscription's 5-hour token cycle, weekly quota, and monthly MCP usage. - * The token is sent RAW (no `Bearer` prefix) per Z.AI's API contract. + * Authenticates with the API key as a Bearer token per Z.AI's API reference. */ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null; const apiKey = resolveEnvValue(config.apiKey)?.trim(); if (!apiKey) return null; const response = await fetch(`${ZAI_BASE_URL}/api/monitor/usage/quota/limit`, { - headers: { Accept: "application/json", Authorization: apiKey }, + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); @@ -595,11 +596,12 @@ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): P const hours = Math.floor(remainsMs / 3_600_000); const label = `Token Plan remaining (${hours}h)`; // Only derive a consumed share when the API actually reports the plan total; - // a presumed window (e.g. 30 days) would fabricate utilization. Without a - // total there is no percentage to render, so suppress the row rather than - // report a false 0% (zero would mean "no consumption"). + // a presumed window (e.g. 30 days) would fabricate utilization. A valid + // response that omits the total after a prior refresh had it is a DELIBERATE + // contract change — the old row must be dropped (terminal), not preserved as + // a transient last-good. const totalMs = toFiniteNumber(data.total_time ?? data.plan_duration_ms ?? data.total_duration_ms); - if (totalMs === undefined || totalMs <= 0) return null; + if (totalMs === undefined || totalMs <= 0) return TERMINAL_QUOTA_FAILURE; const consumed = Math.max(0, totalMs - remainsMs); const percent = normalizePercent((consumed / totalMs) * 100); if (percent === undefined) return null; diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index e439ceecb..d07291a6d 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -560,6 +560,16 @@ export function safeConfigDTO(config: OcxConfig): unknown { ? getProviderRegistryEntry(name) : registryEntryForProviderDestination(provider))?.note; if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote; + // Documented limits follow the DESTINATION, not the config key: a renamed + // preset keeps its vendor's limits, while a key provider whose transport + // was edited to a custom host must not inherit the registry id's limits. + // Key providers resolve purely by destination; forward/oauth/local presets + // (which the destination resolver skips) resolve by id. + const isKeyAuth = (provider.authMode ?? "key") === "key"; + const limitsEntry = isKeyAuth + ? registryEntryForProviderDestination(provider) + : getProviderRegistryEntry(name); + if (limitsEntry?.rateLimits) dto.rateLimits = { ...limitsEntry.rateLimits }; const codexAccountMode = providerCodexAccountMode(name, provider); if (codexAccountMode) dto.codexAccountMode = codexAccountMode; providers[name] = dto; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index dc0ea3f65..851d8bdf8 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -244,10 +244,14 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { // Documented limits follow the DESTINATION, not the config key: a preset // saved under a custom name (e.g. "my-groq") must still surface Groq's - // limits. Fall back to the exact id lookup for non-key presets (forward/ - // oauth/local) whose destination resolver does not apply. - const registry = getProviderRegistryEntry(name) - ?? registryEntryForProviderDestination(p); + // limits, while a key provider whose transport was edited to a custom + // host must NOT inherit the registry id's limits. Key providers resolve + // purely by destination; forward/oauth/local presets (which the + // destination resolver skips) resolve by id. + const isKeyAuth = (p.authMode ?? "key") === "key"; + const registry = isKeyAuth + ? registryEntryForProviderDestination(p) + : getProviderRegistryEntry(name); return { name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, hasApiKey: !!p.apiKey, diff --git a/tests/config.test.ts b/tests/config.test.ts index ec75200de..1816e1b07 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -2321,4 +2321,26 @@ describe("codex account selection order", () => { expect(degraded.config.codexAccountPriorities).toEqual({ work: 1 }); expect(degraded.warnings).toContainEqual(expect.stringContaining("no longer pinned")); }); + + test("safeConfigDTO attaches documented limits by destination, not name", async () => { + const { safeConfigDTO } = await import("../src/server/auth-cors"); + const base = getDefaultConfig(); + // A renamed Groq preset keeps its destination's limits. + const renamed = safeConfigDTO({ + ...base, + providers: { + "my-groq": { adapter: "openai-chat", authMode: "key", baseUrl: "https://api.groq.com/openai/v1" }, + }, + } as never) as { providers: Record }; + expect(renamed.providers["my-groq"]?.rateLimits?.rpm).toBe(30); + + // A provider named groq but with an edited transport must NOT inherit groq's limits. + const edited = safeConfigDTO({ + ...base, + providers: { + groq: { adapter: "openai-chat", authMode: "key", baseUrl: "https://custom.example/v1" }, + }, + } as never) as { providers: Record }; + expect(edited.providers.groq?.rateLimits).toBeUndefined(); + }); }); diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 622207519..873a7af54 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -800,7 +800,7 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); - test("Z.AI quota sends the raw token (no Bearer prefix) and maps plan windows", async () => { + test("Z.AI quota sends the key as a Bearer token and maps plan windows", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -823,7 +823,7 @@ describe("fetchProviderQuotaReports", () => { }); expect(seen).toHaveLength(1); expect(seen[0]?.url).toBe("https://api.z.ai/api/monitor/usage/quota/limit"); - expect(seen[0]?.authorization).toBe("zai-secret"); // raw token, NO Bearer + expect(seen[0]?.authorization).toBe("Bearer zai-secret"); expect(seen[0]?.redirect).toBe("error"); }); @@ -853,21 +853,28 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); - test("MiniMax quota suppresses the row when the API omits the plan total duration", async () => { + test("MiniMax quota drops the row when the API omits the plan total after having it", async () => { + // A valid row (with total) exists; a later valid response omitting the + // total is a DELIBERATE contract change — the stale row must be dropped + // (terminal), not preserved as a transient last-good. + let withTotal = true; 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 }); - return new Response(JSON.stringify({ success: true, data: { remains_time: 1_000_000_000 } }), { status: 200 }); + return new Response(JSON.stringify(withTotal + ? { success: true, data: { remains_time: 750_000_000, total_time: 1_000_000_000 } } + : { success: true, data: { remains_time: 1_000_000_000 } }), { status: 200 }); }) as typeof fetch; + const config = keyQuotaConfig("minimax", "https://api.minimax.io/v1"); - const result = await fetchProviderQuotaReports(keyQuotaConfig("minimax", "https://api.minimax.io/v1"), true); + const valid = await fetchProviderQuotaReports(config, true); + withTotal = false; + const noTotal = await fetchProviderQuotaReports(config, true); - // No total duration → no percentage to render; a 0% bar would falsely - // claim zero consumption, so the row is suppressed entirely. - expect(result.reports).toEqual([]); - expect(seen).toHaveLength(1); + expect(valid.reports).toHaveLength(1); + expect(noTotal.reports).toEqual([]); expect(seen[0]?.url).toBe("https://www.minimax.io/v1/token_plan/remains"); expect(seen[0]?.authorization).toBe("Bearer minimax-secret"); expect(seen[0]?.redirect).toBe("error"); @@ -1011,6 +1018,21 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports[0]?.quota.customWindows?.[0]).toMatchObject({ label: "Search hourly", percent: 12 }); }); + test("Synthetic quota accepts the preset /openai/v1 base URL", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response(JSON.stringify({ + data: { rollingFiveHourLimit: 10, weeklyTokenLimit: 20 }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig("synthetic", "https://api.synthetic.new/openai/v1"), true); + + expect(result.reports).toHaveLength(1); + expect(seen[0]).toBe("https://api.synthetic.new/v2/quotas"); + }); + test("Synthetic quota never sends the key to a non-canonical base URL", async () => { const seen: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL) => { From 86e382dffb0e8110e8e2216be317b8fe4ad25db3 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:54:52 +0200 Subject: [PATCH 08/11] fix(gui): restore React Fast Refresh and split documented-limits logic Two dev/runtime fixes for the Providers rate-limits UI: - Upgrade @vitejs/plugin-react 6.0.3 -> 6.0.5. The 6.0.3 release failed to inject the fast-refresh preamble in dev on Vite 8, so every component module threw `Uncaught ReferenceError: $RefreshReg$ is not defined` and the dashboard would not boot under `bun run dev`. - Split ProviderDocumentedLimits.tsx into a component-only module plus a pure provider-workspace/documented-limits.ts (types, FREE_TIER_KEYS, formatDocumentedLimits). The component file previously exported non-component helpers, which violates react-refresh/only-export-components and breaks fast refresh; the lint gate flagged it. Co-authored-by: CommandCodeBot --- gui/bun.lock | 7 ++- gui/package.json | 3 +- .../ProviderDocumentedLimits.tsx | 53 ++---------------- .../provider-workspace/documented-limits.ts | 55 +++++++++++++++++++ gui/tests/provider-capacity.test.ts | 2 +- 5 files changed, 67 insertions(+), 53 deletions(-) create mode 100644 gui/src/provider-workspace/documented-limits.ts diff --git a/gui/bun.lock b/gui/bun.lock index 1c7e030ba..bbf5a3ad1 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -14,12 +14,13 @@ "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", + "@vitejs/plugin-react": "^6.0.5", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "happy-dom": "20.11.1", + "react-refresh": "^0.18.0", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.1.0", @@ -183,7 +184,7 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA=="], "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], @@ -361,6 +362,8 @@ "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], diff --git a/gui/package.json b/gui/package.json index 1556402dc..7f62cdc78 100644 --- a/gui/package.json +++ b/gui/package.json @@ -23,12 +23,13 @@ "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", + "@vitejs/plugin-react": "^6.0.5", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "happy-dom": "20.11.1", + "react-refresh": "^0.18.0", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.1.0" diff --git a/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx b/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx index 41c394546..f0e55b066 100644 --- a/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx +++ b/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx @@ -3,57 +3,12 @@ * (from the provider's official docs) as reference text. Distinct from the * live utilization bars (ProviderCapacityQuota / QuotaBars): these numbers * are not probed, are tier-dependent, and can drift from reality. + * + * Component-only module (React Fast Refresh): the pure formatter/types live + * in provider-workspace/documented-limits.ts. */ import type { TFn } from "../../i18n/shared"; - -export interface DocumentedRateLimits { - rpm?: number; - tpm?: number; - rpd?: number; - freeTier?: string; - source?: string; - updatedAt?: string; -} - -function formatNumber(value: number): string { - return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 0 }).format(value); -} - -/** - * Map a known free-tier description to a localizable i18n key. Registry - * `freeTier` prose is English-authored backend data; rendering it verbatim - * leaves non-English locales with a partially-English row. Unknown strings - * fall back to the raw prose. - */ -const FREE_TIER_KEYS: Record[0]> = { - "Local — no remote limits": "pws.rateLimits.freeTier.local", - "~200 free-model requests per 5 hours": "pws.rateLimits.freeTier.opencodeFree", - "Free tier: ~15 RPM / 250K TPM / 1K RPD (tier-dependent)": "pws.rateLimits.freeTier.gemini", - "Free tier: ~30 RPM / 6K TPM / 1K RPD": "pws.rateLimits.freeTier.groq", - "Free tier: ~60 RPM / 100K TPM; then pay-as-you-go": "pws.rateLimits.freeTier.sambanova", - "Free tier: ~50 RPM / 50K TPM": "pws.rateLimits.freeTier.nebius", - "Free tier: ~5 RPM / 20K TPM; then pay-as-you-go": "pws.rateLimits.freeTier.mistral", - "Free tier: ~60 RPM / 1M TPM; then pay-as-you-go": "pws.rateLimits.freeTier.together", - "Free tier: ~600 RPM / 150K TPM": "pws.rateLimits.freeTier.fireworks", - "Free models: ~20 req/min, credit-capped; paid per model": "pws.rateLimits.freeTier.openrouter", - "Coding plan subscription; per-plan quotas": "pws.rateLimits.freeTier.minimax", - "GLM coding subscription (paid plan)": "pws.rateLimits.freeTier.zai", - "New accounts: one-time token grant; then pay-as-you-go": "pws.rateLimits.freeTier.deepseek", -}; - -function localizeFreeTier(freeTier: string, t: TFn): string { - const key = FREE_TIER_KEYS[freeTier]; - return key ? t(key) : freeTier; -} - -export function formatDocumentedLimits(rateLimits: DocumentedRateLimits, t: TFn): string { - const parts: string[] = []; - if (rateLimits.rpm !== undefined) parts.push(t("pws.rateLimits.rpm", { value: formatNumber(rateLimits.rpm) })); - if (rateLimits.tpm !== undefined) parts.push(t("pws.rateLimits.tpm", { value: formatNumber(rateLimits.tpm) })); - if (rateLimits.rpd !== undefined) parts.push(t("pws.rateLimits.rpd", { value: formatNumber(rateLimits.rpd) })); - if (rateLimits.freeTier) parts.push(localizeFreeTier(rateLimits.freeTier, t)); - return parts.join(" · "); -} +import { formatDocumentedLimits, type DocumentedRateLimits } from "../../provider-workspace/documented-limits"; export function ProviderDocumentedLimits({ rateLimits, t }: { rateLimits: DocumentedRateLimits; t: TFn }) { const summary = formatDocumentedLimits(rateLimits, t); diff --git a/gui/src/provider-workspace/documented-limits.ts b/gui/src/provider-workspace/documented-limits.ts new file mode 100644 index 000000000..515462069 --- /dev/null +++ b/gui/src/provider-workspace/documented-limits.ts @@ -0,0 +1,55 @@ +/** + * provider-workspace/documented-limits.ts — pure derivations for the + * documented (reference) rate-limit display. No React, no fetch: kept out of + * the component file so React Fast Refresh sees a component-only module. + */ +import type { TFn } from "../i18n/shared"; + +export interface DocumentedRateLimits { + rpm?: number; + tpm?: number; + rpd?: number; + freeTier?: string; + source?: string; + updatedAt?: string; +} + +function formatNumber(value: number): string { + return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 0 }).format(value); +} + +/** + * Map a known free-tier description to a localizable i18n key. Registry + * `freeTier` prose is English-authored backend data; rendering it verbatim + * leaves non-English locales with a partially-English row. Unknown strings + * fall back to the raw prose. + */ +const FREE_TIER_KEYS: Record[0]> = { + "Local — no remote limits": "pws.rateLimits.freeTier.local", + "~200 free-model requests per 5 hours": "pws.rateLimits.freeTier.opencodeFree", + "Free tier: ~15 RPM / 250K TPM / 1K RPD (tier-dependent)": "pws.rateLimits.freeTier.gemini", + "Free tier: ~30 RPM / 6K TPM / 1K RPD": "pws.rateLimits.freeTier.groq", + "Free tier: ~60 RPM / 100K TPM; then pay-as-you-go": "pws.rateLimits.freeTier.sambanova", + "Free tier: ~50 RPM / 50K TPM": "pws.rateLimits.freeTier.nebius", + "Free tier: ~5 RPM / 20K TPM; then pay-as-you-go": "pws.rateLimits.freeTier.mistral", + "Free tier: ~60 RPM / 1M TPM; then pay-as-you-go": "pws.rateLimits.freeTier.together", + "Free tier: ~600 RPM / 150K TPM": "pws.rateLimits.freeTier.fireworks", + "Free models: ~20 req/min, credit-capped; paid per model": "pws.rateLimits.freeTier.openrouter", + "Coding plan subscription; per-plan quotas": "pws.rateLimits.freeTier.minimax", + "GLM coding subscription (paid plan)": "pws.rateLimits.freeTier.zai", + "New accounts: one-time token grant; then pay-as-you-go": "pws.rateLimits.freeTier.deepseek", +}; + +function localizeFreeTier(freeTier: string, t: TFn): string { + const key = FREE_TIER_KEYS[freeTier]; + return key ? t(key) : freeTier; +} + +export function formatDocumentedLimits(rateLimits: DocumentedRateLimits, t: TFn): string { + const parts: string[] = []; + if (rateLimits.rpm !== undefined) parts.push(t("pws.rateLimits.rpm", { value: formatNumber(rateLimits.rpm) })); + if (rateLimits.tpm !== undefined) parts.push(t("pws.rateLimits.tpm", { value: formatNumber(rateLimits.tpm) })); + if (rateLimits.rpd !== undefined) parts.push(t("pws.rateLimits.rpd", { value: formatNumber(rateLimits.rpd) })); + if (rateLimits.freeTier) parts.push(localizeFreeTier(rateLimits.freeTier, t)); + return parts.join(" · "); +} diff --git a/gui/tests/provider-capacity.test.ts b/gui/tests/provider-capacity.test.ts index 42ecdec58..48c6b9c83 100644 --- a/gui/tests/provider-capacity.test.ts +++ b/gui/tests/provider-capacity.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { capacityAggregationFromReport } from "../src/provider-workspace/report"; -import { formatDocumentedLimits } from "../src/components/provider-workspace/ProviderDocumentedLimits"; +import { formatDocumentedLimits } from "../src/provider-workspace/documented-limits"; function selectorBlock(css: string, selector: string): string { const start = css.indexOf(`${selector} {`); From 5ce9358aba61a13aa294576074b8f080921934e9 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:04:49 +0200 Subject: [PATCH 09/11] feat(providers): add live Command Code quota probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command Code (commandcode) gets a live utilization probe using the API key already stored by `ocx login command-code`: - GET https://api.commandcode.ai/internal/billing/credits — rolling 5-hour and weekly utilization windows plus monthly credit balances - GET https://api.commandcode.ai/internal/billing/subscriptions — the active plan, whose grant total produces the monthly consumed-share bar Maps onto the ProviderQuota windows (fiveHourPercent/weeklyPercent/ monthlyPercent) and follows the existing probe contract: canonical-host guard before sending the credential, redirect: "error", 8s timeout, 4xx (except 408/429) terminal, 5xx/network transient. Co-authored-by: CommandCodeBot --- src/providers/quota.ts | 85 ++++++++++++++++++++++++++++++++++++ tests/provider-quota.test.ts | 79 +++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 3ff9396c8..cddd57f9d 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -44,6 +44,7 @@ const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2"; const DEEPINFRA_BASE_URL = "https://api.deepinfra.com"; const NEURALWATT_BASE_URL = "https://api.neuralwatt.com/v1"; +const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai"; /** 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(); @@ -301,6 +302,11 @@ function isCanonicalNeuralwattBaseUrl(baseUrl: string): boolean { return normalizedBaseUrl(baseUrl) === NEURALWATT_BASE_URL; } +function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean { + const normalized = normalizedBaseUrl(baseUrl); + return normalized === COMMAND_CODE_BASE_URL || normalized === `${COMMAND_CODE_BASE_URL}/provider/v1`; +} + function a6apiPayload(value: unknown): Record | null { const body = asRecord(value); return asRecord(body?.data) ?? body; @@ -831,6 +837,84 @@ async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig) return windows > 0 ? report(provider, "neuralwatt:quota", quota) : null; } +/** + * Command Code billing — `GET /internal/billing/credits` (+ subscriptions for + * the monthly plan total). The API key (from `ocx login command-code`) is + * sent as a Bearer token. The credits payload reports rolling 5-hour and + * weekly utilization windows plus monthly credit balances; the subscription + * names the plan whose catalog holds the monthly grant total, so the monthly + * bar is consumed-share of the grant when the plan is known. + */ +async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig): Promise { + // Never send the Command Code credential to a lookalike or non-canonical host. + if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null; + let apiKey: string; + try { + apiKey = await getValidAccessToken("command-code"); + } catch { + return null; + } + if (!apiKey) return null; + const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}` } as const; + const [creditsRes, subsRes] = await Promise.all([ + fetch(`${COMMAND_CODE_BASE_URL}/internal/billing/credits`, { + headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }), + fetch(`${COMMAND_CODE_BASE_URL}/internal/billing/subscriptions`, { + headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }), + ]); + // 4xx (except 408/429) is a credential/contract problem → terminal; 5xx/network → transient. + for (const res of [creditsRes, subsRes]) { + if (!res.ok) { + return res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + } + const creditsBody = asRecord(await creditsRes.json().catch(() => null)); + const credits = asRecord(creditsBody?.data) ?? creditsBody; + if (!credits) return null; + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + const windowFrom = (raw: unknown): { percent?: number; resetAt?: number } | null => { + const row = asRecord(raw); + if (!row) return null; + const percent = normalizePercent(row.usedPercent ?? row.used_percent ?? row.percentUsed); + const resetAt = normalizeResetAt(row.resetsAt ?? row.resetAt ?? row.reset_at); + return percent !== undefined || resetAt !== undefined ? { ...(percent !== undefined ? { percent } : {}), ...(resetAt !== undefined ? { resetAt } : {}) } : null; + }; + const fiveHour = windowFrom(credits.fiveHourWindow ?? credits.five_hour_window); + const weekly = windowFrom(credits.weeklyWindow ?? credits.weekly_window); + if (fiveHour?.percent !== undefined) { + quota.fiveHourPercent = fiveHour.percent; + if (fiveHour.resetAt !== undefined) quota.fiveHourResetAt = fiveHour.resetAt; + windows += 1; + } + if (weekly?.percent !== undefined) { + quota.weeklyPercent = weekly.percent; + if (weekly.resetAt !== undefined) quota.weeklyResetAt = weekly.resetAt; + windows += 1; + } + // Monthly: consumed share of the plan grant when the subscription names a plan. + const monthlyCredits = toFiniteNumber(credits.monthlyCredits ?? credits.monthly_credits); + const subsBody = asRecord(await subsRes.json().catch(() => null)); + const subs = Array.isArray(subsBody?.data) ? subsBody.data as unknown[] : Array.isArray(subsBody) ? subsBody as unknown[] : null; + const sub = subs?.map(asRecord).find((r): r is Record => r !== null && String(r.status ?? "").toLowerCase() === "active"); + const monthlyTotal = sub ? toFiniteNumber(sub.monthlyCreditsTotal ?? sub.monthly_credits_total ?? sub.allowance) : undefined; + if (monthlyCredits !== undefined && monthlyTotal !== undefined && monthlyTotal > 0) { + const used = Math.max(0, Math.min(monthlyTotal, monthlyTotal - monthlyCredits)); + const percent = normalizePercent((used / monthlyTotal) * 100); + if (percent !== undefined) { + quota.monthlyPercent = percent; + const periodEnd = sub ? normalizeResetAt(sub.currentPeriodEnd ?? sub.current_period_end ?? sub.periodEnd) : undefined; + if (periodEnd !== undefined) quota.monthlyResetAt = periodEnd; + windows += 1; + } + } + return windows > 0 ? report(provider, "commandcode:billing", quota) : null; +} + function report( provider: string, source: string, @@ -1695,6 +1779,7 @@ async function maybeFetchProviderQuota( // Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical // host and only for real key auth — forward/local modes carry no credential of ours. if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider); + if (provider.authMode === "oauth" && name === "command-code") return fetchCommandCodeQuota(name, provider); if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) { return fetchKimiQuota(name, provider); } diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 873a7af54..c3d37d88e 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -1131,6 +1131,85 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); + test("Command Code quota maps 5-hour/weekly windows and monthly grant share", async () => { + await saveCredential("command-code", { access: "cc-secret", refresh: "cc-secret", expires: Date.now() + 3600_000 }); + 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.includes("/internal/billing/credits")) { + return new Response(JSON.stringify({ + data: { + monthlyCredits: 60, + fiveHourWindow: { usedPercent: 40.5, resetsAt: "2026-08-06T18:00:00Z" }, + weeklyWindow: { usedPercent: 52 }, + }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ + data: [{ planId: "pro", status: "active", currentPeriodEnd: "2026-08-31T00:00:00Z", monthlyCreditsTotal: 100 }], + }), { status: 200 }); + }) as typeof fetch; + const config = { + defaultProvider: "command-code", + providers: { "command-code": { adapter: "command-code", authMode: "oauth", baseUrl: "https://api.commandcode.ai" } }, + } as OcxConfig; + + const result = await fetchProviderQuotaReports(config, true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("commandcode:billing"); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 40.5, + weeklyPercent: 52, + monthlyPercent: 40, // (100-60)/100 + }); + expect(seen).toHaveLength(2); + expect(seen.every(row => row.authorization === "Bearer cc-secret")).toBe(true); + expect(seen.every(row => row.redirect === "error")).toBe(true); + }); + + test("Command Code quota never sends the credential to a non-canonical base URL", async () => { + await saveCredential("command-code", { access: "cc-secret", refresh: "cc-secret", expires: Date.now() + 3600_000 }); + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + const config = { + defaultProvider: "command-code", + providers: { "command-code": { adapter: "command-code", authMode: "oauth", baseUrl: "https://attacker.example" } }, + } as OcxConfig; + + const result = await fetchProviderQuotaReports(config, true); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + + test("Command Code quota treats a 401 as terminal (drops last-good)", async () => { + await saveCredential("command-code", { access: "cc-secret", refresh: "cc-secret", expires: Date.now() + 3600_000 }); + let rejected = false; + globalThis.fetch = (async () => { + if (rejected) return new Response("unauthorized", { status: 401 }); + return new Response(JSON.stringify({ + data: { monthlyCredits: 60, fiveHourWindow: { usedPercent: 40.5 }, weeklyWindow: { usedPercent: 52 } }, + }), { status: 200 }); + }) as typeof fetch; + const config = { + defaultProvider: "command-code", + providers: { "command-code": { adapter: "command-code", authMode: "oauth", baseUrl: "https://api.commandcode.ai" } }, + } as OcxConfig; + + const valid = await fetchProviderQuotaReports(config, true); + rejected = true; + const invalid = await fetchProviderQuotaReports(config, true); + + expect(valid.reports).toHaveLength(1); + expect(invalid.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 dadda3251112bfcddcd46cd8ebd87f13562ba3ae Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:18:54 +0200 Subject: [PATCH 10/11] refactor(providers): remove documented-rate-limits display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the "Documented" reference-limits feature entirely — it was bloat. The providers overview now shows only LIVE utilization bars from probed usage endpoints. Removed: - ProviderRateLimits type + rateLimits data on all registry entries - rateLimits plumbing through derived presets, safeConfigDTO, and /api/providers - the ProviderDocumentedLimits component + documented-limits.ts pure module, the dashboard "Documented" section, catalog/preset/config DTO rateLimits fields, and all pws.rateLimits.* i18n keys (en + 5 locales) - the documented-limits CSS and the related tests (registry parity, config DTO, GUI capacity + shell) Docs updated to describe only the live utilization probes. Co-authored-by: CommandCodeBot --- .../src/content/docs/guides/providers.md | 23 +++----- .../provider-catalog/provider-presets.ts | 9 --- .../ProviderDocumentedLimits.tsx | 27 --------- .../provider-workspace/ProviderOverview.tsx | 8 --- .../ProviderOverviewDashboard.tsx | 31 +---------- gui/src/i18n/de.ts | 17 ------ gui/src/i18n/en.ts | 17 ------ gui/src/i18n/ja.ts | 17 ------ gui/src/i18n/ko.ts | 17 ------ gui/src/i18n/ru.ts | 17 ------ gui/src/i18n/zh.ts | 17 ------ gui/src/pages/providers-shared.ts | 9 --- gui/src/provider-workspace/catalog.ts | 9 --- .../provider-workspace/documented-limits.ts | 55 ------------------- .../styles/provider-overview-dashboard.css | 40 -------------- gui/tests/provider-capacity-shell.test.tsx | 42 -------------- gui/tests/provider-capacity.test.ts | 31 +---------- src/providers/derive.ts | 4 -- src/providers/registry.ts | 50 +++-------------- src/server/auth-cors.ts | 10 ---- src/server/management/provider-routes.ts | 43 +++++---------- tests/config.test.ts | 22 -------- tests/provider-registry-parity.test.ts | 22 -------- 23 files changed, 35 insertions(+), 502 deletions(-) delete mode 100644 gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx delete mode 100644 gui/src/provider-workspace/documented-limits.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index a50048e9a..145124f45 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -487,18 +487,13 @@ dashboard or `custom` in `ocx init` and enter the base URL. See the ## Rate limits in the providers overview -The **Rate limits** section of the Providers overview shows two kinds of data: - -- **Live utilization** — refreshed from each provider's own usage/billing endpoint when one exists. - The bars show how much of a window (5-hour, weekly, monthly, or provider-specific) is already - consumed. Providers with a live probe: OpenAI/Codex, Anthropic, xAI, Cursor, Kimi, Google - Antigravity, OpenRouter, DeepSeek, ClinePass, Z.AI, MiniMax, Moonshot, Venice, Synthetic, - DeepInfra, Neuralwatt, and any a6api-backed custom provider. -- **Documented reference** — for providers without a live endpoint, the overview shows the rate - limits published in the provider's official docs (requests/minute, tokens/minute, free-tier - caps) as reference text. - -Documented limits are **not account-specific**: they describe a published tier, not your actual -plan, and can drift as providers change their pricing or limits. Treat them as reference — the -`source` and `updatedAt` fields show where the numbers came from and when they were last verified. +The **Rate limits** section of the Providers overview shows live utilization +bars refreshed from each provider's own usage/billing endpoint when one exists. +The bars show how much of a window (5-hour, weekly, monthly, or +provider-specific) is already consumed. + +Providers with a live probe: OpenAI/Codex, Anthropic, xAI, Cursor, Kimi, +Google Antigravity, Command Code, OpenRouter, DeepSeek, ClinePass, Z.AI, +MiniMax, Moonshot, Venice, Synthetic, DeepInfra, Neuralwatt, and any +a6api-backed custom provider. diff --git a/gui/src/components/provider-catalog/provider-presets.ts b/gui/src/components/provider-catalog/provider-presets.ts index 48499ef73..36f5231fa 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -34,15 +34,6 @@ export interface CatalogPreset { baseUrlChoices?: Array<{ id: string; label: string; baseUrl?: string }>; codexAccountMode?: "direct" | "pool"; provider?: ProviderPayload; - /** Documented rate limits (official docs, not probed); shown as reference in the overview. */ - rateLimits?: { - rpm?: number; - tpm?: number; - rpd?: number; - freeTier?: string; - source?: string; - updatedAt?: string; - }; } /** diff --git a/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx b/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx deleted file mode 100644 index f0e55b066..000000000 --- a/gui/src/components/provider-workspace/ProviderDocumentedLimits.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/** - * ProviderDocumentedLimits — renders a provider's DOCUMENTED rate limits - * (from the provider's official docs) as reference text. Distinct from the - * live utilization bars (ProviderCapacityQuota / QuotaBars): these numbers - * are not probed, are tier-dependent, and can drift from reality. - * - * Component-only module (React Fast Refresh): the pure formatter/types live - * in provider-workspace/documented-limits.ts. - */ -import type { TFn } from "../../i18n/shared"; -import { formatDocumentedLimits, type DocumentedRateLimits } from "../../provider-workspace/documented-limits"; - -export function ProviderDocumentedLimits({ rateLimits, t }: { rateLimits: DocumentedRateLimits; t: TFn }) { - const summary = formatDocumentedLimits(rateLimits, t); - if (!summary) return null; - return ( -
- {t("pws.rateLimits.documented")} - {summary} - {(rateLimits.source || rateLimits.updatedAt) && ( - - {[rateLimits.updatedAt, rateLimits.source].filter(Boolean).join(" · ")} - - )} -
- ); -} diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index 91f239a72..07019246f 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -13,7 +13,6 @@ import type { ProviderUsageTotals } from "./types"; import { authModeLabel } from "./ProviderRail"; import type { ProviderUpdatePatch } from "./types"; import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; -import { ProviderDocumentedLimits } from "./ProviderDocumentedLimits"; type ConnectionTestResult = { applicable?: boolean; @@ -206,13 +205,6 @@ export default function ProviderOverview({

{t("pws.rateLimits")}

- {item.rateLimits && } -
- )} - {!quotaReport && item.rateLimits && ( -
-

{t("pws.rateLimits")}

-
)} diff --git a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx index 30d2be823..410bb919a 100644 --- a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx +++ b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx @@ -26,7 +26,6 @@ import { ProviderIcon } from "./ProviderRail"; import { formatProviderDisplayName } from "../../provider-icons"; import QuotaBars from "../QuotaBars"; import { ProviderCapacityQuota } from "./ProviderCapacityQuota"; -import { ProviderDocumentedLimits } from "./ProviderDocumentedLimits"; export default function ProviderOverviewDashboard({ sections, @@ -78,14 +77,6 @@ export default function ProviderOverviewDashboard({ return result.sort((a, b) => b.urgency - a.urgency || a.item.name.localeCompare(b.item.name)); }, [allItems, quotaReports]); - /* Documented-reference rows: providers with registry rate limits but no live bar. */ - const documentedLimitProviders = useMemo(() => { - const withLiveBar = new Set(quotaProviders.map(p => p.item.name)); - return allItems - .filter(item => !withLiveBar.has(item.name) && item.rateLimits) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [allItems, quotaProviders]); - /* Recently-used: filter to known provider names and cap at 4 (PR #139 parity) */ const mostUsed = useMemo(() => { const filtered: Record = {}; @@ -197,28 +188,8 @@ export default function ProviderOverviewDashboard({ ))} - ) : documentedLimitProviders.length === 0 ? ( + ) : (

{t("pws.dashboard.noRateLimits")}

- ) : null} - {documentedLimitProviders.length > 0 && ( -
-
{t("pws.rateLimits.documented")}
- {documentedLimitProviders.map(item => ( - - ))} -
)}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8effade31..7703f6c09 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1525,23 +1525,6 @@ export const de: Record = { "pws.metricTokens": "Tokens", "pws.usageUnavailable": "Noch keine Nutzung erfasst.", "pws.rateLimits": "Limits", - "pws.rateLimits.documented": "Dokumentiert", - "pws.rateLimits.rpm": "{value} Anfragen/min", - "pws.rateLimits.tpm": "{value} Tokens/min", - "pws.rateLimits.rpd": "{value} Anfragen/Tag", - "pws.rateLimits.freeTier.local": "Lokal — keine Remote-Limits", - "pws.rateLimits.freeTier.opencodeFree": "~200 kostenlose Modell-Anfragen pro 5 Stunden", - "pws.rateLimits.freeTier.gemini": "Kostenlos: ~15 RPM / 250K TPM / 1K RPD (stufenabhängig)", - "pws.rateLimits.freeTier.groq": "Kostenlos: ~30 RPM / 6K TPM / 1K RPD", - "pws.rateLimits.freeTier.sambanova": "Kostenlos: ~60 RPM / 100K TPM; dann Pay-as-you-go", - "pws.rateLimits.freeTier.nebius": "Kostenlos: ~50 RPM / 50K TPM", - "pws.rateLimits.freeTier.mistral": "Kostenlos: ~5 RPM / 20K TPM; dann Pay-as-you-go", - "pws.rateLimits.freeTier.together": "Kostenlos: ~60 RPM / 1M TPM; dann Pay-as-you-go", - "pws.rateLimits.freeTier.fireworks": "Kostenlos: ~600 RPM / 150K TPM", - "pws.rateLimits.freeTier.openrouter": "Kostenlose Modelle: ~20 Anfragen/min, kreditbegrenzt; bezahlte pro Modell", - "pws.rateLimits.freeTier.minimax": "Coding-Plan-Abo; planabhängige Kontingente", - "pws.rateLimits.freeTier.zai": "GLM-Coding-Abo (kostenpflichtig)", - "pws.rateLimits.freeTier.deepseek": "Neue Konten: einmaliges Token-Guthaben; dann Pay-as-you-go", "pws.quotaUnavailable": "Keine Kontingentdaten für diesen Provider.", "pws.accountQuotaUnavailable": "Ratenlimit-Daten vorübergehend nicht verfügbar; falls vorhanden, werden zuletzt bekannte Werte angezeigt.", "pws.selected": "Ausgewählt", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b446bed42..9ae886124 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1044,23 +1044,6 @@ export const en = { "pws.metricTokens": "tokens", "pws.usageUnavailable": "No usage recorded yet.", "pws.rateLimits": "Rate limits", - "pws.rateLimits.documented": "Documented", - "pws.rateLimits.rpm": "{value} req/min", - "pws.rateLimits.tpm": "{value} tok/min", - "pws.rateLimits.rpd": "{value} req/day", - "pws.rateLimits.freeTier.local": "Local — no remote limits", - "pws.rateLimits.freeTier.opencodeFree": "~200 free-model requests per 5 hours", - "pws.rateLimits.freeTier.gemini": "Free tier: ~15 RPM / 250K TPM / 1K RPD (tier-dependent)", - "pws.rateLimits.freeTier.groq": "Free tier: ~30 RPM / 6K TPM / 1K RPD", - "pws.rateLimits.freeTier.sambanova": "Free tier: ~60 RPM / 100K TPM; then pay-as-you-go", - "pws.rateLimits.freeTier.nebius": "Free tier: ~50 RPM / 50K TPM", - "pws.rateLimits.freeTier.mistral": "Free tier: ~5 RPM / 20K TPM; then pay-as-you-go", - "pws.rateLimits.freeTier.together": "Free tier: ~60 RPM / 1M TPM; then pay-as-you-go", - "pws.rateLimits.freeTier.fireworks": "Free tier: ~600 RPM / 150K TPM", - "pws.rateLimits.freeTier.openrouter": "Free models: ~20 req/min, credit-capped; paid per model", - "pws.rateLimits.freeTier.minimax": "Coding plan subscription; per-plan quotas", - "pws.rateLimits.freeTier.zai": "GLM coding subscription (paid plan)", - "pws.rateLimits.freeTier.deepseek": "New accounts: one-time token grant; then pay-as-you-go", "pws.quotaUnavailable": "No quota data for this provider.", "pws.accountQuotaUnavailable": "Rate-limit data temporarily unavailable; showing last known values when present.", "pws.selected": "Selected", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d8d4b84f3..d5f7a915b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -992,23 +992,6 @@ export const ja: Record = { "pws.metricTokens": "トークン", "pws.usageUnavailable": "まだ使用量が記録されていません。", "pws.rateLimits": "レート制限", - "pws.rateLimits.documented": "ドキュメント記載", - "pws.rateLimits.rpm": "{value} 回/分", - "pws.rateLimits.tpm": "{value} トークン/分", - "pws.rateLimits.rpd": "{value} 回/日", - "pws.rateLimits.freeTier.local": "ローカル — リモート制限なし", - "pws.rateLimits.freeTier.opencodeFree": "5時間あたり約200回の無料モデルリクエスト", - "pws.rateLimits.freeTier.gemini": "無料: ~15 RPM / 250K TPM / 1K RPD (プランにより変動)", - "pws.rateLimits.freeTier.groq": "無料: ~30 RPM / 6K TPM / 1K RPD", - "pws.rateLimits.freeTier.sambanova": "無料: ~60 RPM / 100K TPM; 以降は従量課金", - "pws.rateLimits.freeTier.nebius": "無料: ~50 RPM / 50K TPM", - "pws.rateLimits.freeTier.mistral": "無料: ~5 RPM / 20K TPM; 以降は従量課金", - "pws.rateLimits.freeTier.together": "無料: ~60 RPM / 1M TPM; 以降は従量課金", - "pws.rateLimits.freeTier.fireworks": "無料: ~600 RPM / 150K TPM", - "pws.rateLimits.freeTier.openrouter": "無料モデル: ~20 回/分、クレジット上限あり; 有料はモデル別", - "pws.rateLimits.freeTier.minimax": "コーディングプラン定額; プラン別クォータ", - "pws.rateLimits.freeTier.zai": "GLMコーディング定額 (有料)", - "pws.rateLimits.freeTier.deepseek": "新規アカウント: 一度きりのトークン付与; 以降は従量課金", "pws.quotaUnavailable": "このプロバイダーのクォータデータがありません。", "pws.accountQuotaUnavailable": "レート制限データを一時的に取得できません。前回の値がある場合はそれを表示します。", "pws.selected": "選択中", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 162280aae..e0ee1af6a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1552,23 +1552,6 @@ export const ko: Record = { "pws.metricTokens": "토큰", "pws.usageUnavailable": "아직 기록된 사용량이 없습니다.", "pws.rateLimits": "요청 한도", - "pws.rateLimits.documented": "문서 기준", - "pws.rateLimits.rpm": "{value} 회/분", - "pws.rateLimits.tpm": "{value} 토큰/분", - "pws.rateLimits.rpd": "{value} 회/일", - "pws.rateLimits.freeTier.local": "로컬 — 원격 제한 없음", - "pws.rateLimits.freeTier.opencodeFree": "5시간당 무료 모델 요청 약 200회", - "pws.rateLimits.freeTier.gemini": "무료: ~15 RPM / 250K TPM / 1K RPD (플랜별 상이)", - "pws.rateLimits.freeTier.groq": "무료: ~30 RPM / 6K TPM / 1K RPD", - "pws.rateLimits.freeTier.sambanova": "무료: ~60 RPM / 100K TPM; 이후 종량제", - "pws.rateLimits.freeTier.nebius": "무료: ~50 RPM / 50K TPM", - "pws.rateLimits.freeTier.mistral": "무료: ~5 RPM / 20K TPM; 이후 종량제", - "pws.rateLimits.freeTier.together": "무료: ~60 RPM / 1M TPM; 이후 종량제", - "pws.rateLimits.freeTier.fireworks": "무료: ~600 RPM / 150K TPM", - "pws.rateLimits.freeTier.openrouter": "무료 모델: 분당 약 20회, 크레딧 한도; 유료는 모델별", - "pws.rateLimits.freeTier.minimax": "코딩 플랜 구독; 플랜별 할당량", - "pws.rateLimits.freeTier.zai": "GLM 코딩 구독 (유료)", - "pws.rateLimits.freeTier.deepseek": "신규 계정: 일회성 토큰 제공; 이후 종량제", "pws.quotaUnavailable": "이 프로바이더의 쿼터 데이터가 없습니다.", "pws.accountQuotaUnavailable": "요금 한도 데이터를 일시적으로 가져올 수 없습니다. 이전 값이 있으면 그대로 표시합니다.", "pws.selected": "선택됨", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 07c868177..f707bb5f5 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1034,23 +1034,6 @@ export const ru: Record = { "pws.metricTokens": "токенов", "pws.usageUnavailable": "Использование пока не зафиксировано.", "pws.rateLimits": "Лимиты запросов", - "pws.rateLimits.documented": "Документировано", - "pws.rateLimits.rpm": "{value} запр./мин", - "pws.rateLimits.tpm": "{value} токенов/мин", - "pws.rateLimits.rpd": "{value} запр./день", - "pws.rateLimits.freeTier.local": "Локально — удалённых лимитов нет", - "pws.rateLimits.freeTier.opencodeFree": "~200 бесплатных запросов моделей за 5 часов", - "pws.rateLimits.freeTier.gemini": "Бесплатно: ~15 RPM / 250K TPM / 1K RPD (зависит от тарифа)", - "pws.rateLimits.freeTier.groq": "Бесплатно: ~30 RPM / 6K TPM / 1K RPD", - "pws.rateLimits.freeTier.sambanova": "Бесплатно: ~60 RPM / 100K TPM; далее оплата по факту", - "pws.rateLimits.freeTier.nebius": "Бесплатно: ~50 RPM / 50K TPM", - "pws.rateLimits.freeTier.mistral": "Бесплатно: ~5 RPM / 20K TPM; далее оплата по факту", - "pws.rateLimits.freeTier.together": "Бесплатно: ~60 RPM / 1M TPM; далее оплата по факту", - "pws.rateLimits.freeTier.fireworks": "Бесплатно: ~600 RPM / 150K TPM", - "pws.rateLimits.freeTier.openrouter": "Бесплатные модели: ~20 запр./мин, ограничение по кредитам; платные — за модель", - "pws.rateLimits.freeTier.minimax": "Подписка Coding Plan; квоты по тарифу", - "pws.rateLimits.freeTier.zai": "Подписка GLM Coding (платная)", - "pws.rateLimits.freeTier.deepseek": "Новые аккаунты: разовый грант токенов; далее оплата по факту", "pws.quotaUnavailable": "Нет данных о квоте для этого провайдера.", "pws.accountQuotaUnavailable": "Данные о лимитах временно недоступны; при наличии показываются последние известные значения.", "pws.selected": "Выбрана", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 766ffdadc..14113c611 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1545,23 +1545,6 @@ export const zh: Record = { "pws.metricTokens": "令牌", "pws.usageUnavailable": "尚无用量记录。", "pws.rateLimits": "速率限制", - "pws.rateLimits.documented": "文档记录", - "pws.rateLimits.rpm": "{value} 次/分钟", - "pws.rateLimits.tpm": "{value} 令牌/分钟", - "pws.rateLimits.rpd": "{value} 次/天", - "pws.rateLimits.freeTier.local": "本地 — 无远程限制", - "pws.rateLimits.freeTier.opencodeFree": "每 5 小时约 200 次免费模型请求", - "pws.rateLimits.freeTier.gemini": "免费: ~15 RPM / 250K TPM / 1K RPD(因套餐而异)", - "pws.rateLimits.freeTier.groq": "免费: ~30 RPM / 6K TPM / 1K RPD", - "pws.rateLimits.freeTier.sambanova": "免费: ~60 RPM / 100K TPM; 之后按量付费", - "pws.rateLimits.freeTier.nebius": "免费: ~50 RPM / 50K TPM", - "pws.rateLimits.freeTier.mistral": "免费: ~5 RPM / 20K TPM; 之后按量付费", - "pws.rateLimits.freeTier.together": "免费: ~60 RPM / 1M TPM; 之后按量付费", - "pws.rateLimits.freeTier.fireworks": "免费: ~600 RPM / 150K TPM", - "pws.rateLimits.freeTier.openrouter": "免费模型: 约 20 次/分钟,有额度上限; 付费按模型计费", - "pws.rateLimits.freeTier.minimax": "编码套餐订阅; 按套餐配额", - "pws.rateLimits.freeTier.zai": "GLM 编码订阅(付费)", - "pws.rateLimits.freeTier.deepseek": "新账户: 一次性令牌赠送; 之后按量付费", "pws.quotaUnavailable": "此提供商暂无配额数据。", "pws.accountQuotaUnavailable": "速率限制数据暂时不可用;若有上次已知值则继续显示。", "pws.selected": "已选择", diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index e26f69956..fb64a56a8 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -15,15 +15,6 @@ export interface ProvidersConfig { disabled?: boolean; note?: string; codexAccountMode?: "direct" | "pool"; - /** Documented rate limits (official docs, not probed); shown as reference in the overview. */ - rateLimits?: { - rpm?: number; - tpm?: number; - rpd?: number; - freeTier?: string; - source?: string; - updatedAt?: string; - }; }>; } diff --git a/gui/src/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index d8bce81af..847d0ae32 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -47,15 +47,6 @@ export interface WorkspaceProvider { allowPrivateNetwork?: boolean; /** Codex account routing mode for the canonical `openai` forward provider. */ codexAccountMode?: "direct" | "pool"; - /** Documented rate limits (official docs, not probed); shown as reference in the overview. */ - rateLimits?: { - rpm?: number; - tpm?: number; - rpd?: number; - freeTier?: string; - source?: string; - updatedAt?: string; - }; } /** Three-way pricing/ownership tier for a ready provider row. */ diff --git a/gui/src/provider-workspace/documented-limits.ts b/gui/src/provider-workspace/documented-limits.ts deleted file mode 100644 index 515462069..000000000 --- a/gui/src/provider-workspace/documented-limits.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * provider-workspace/documented-limits.ts — pure derivations for the - * documented (reference) rate-limit display. No React, no fetch: kept out of - * the component file so React Fast Refresh sees a component-only module. - */ -import type { TFn } from "../i18n/shared"; - -export interface DocumentedRateLimits { - rpm?: number; - tpm?: number; - rpd?: number; - freeTier?: string; - source?: string; - updatedAt?: string; -} - -function formatNumber(value: number): string { - return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 0 }).format(value); -} - -/** - * Map a known free-tier description to a localizable i18n key. Registry - * `freeTier` prose is English-authored backend data; rendering it verbatim - * leaves non-English locales with a partially-English row. Unknown strings - * fall back to the raw prose. - */ -const FREE_TIER_KEYS: Record[0]> = { - "Local — no remote limits": "pws.rateLimits.freeTier.local", - "~200 free-model requests per 5 hours": "pws.rateLimits.freeTier.opencodeFree", - "Free tier: ~15 RPM / 250K TPM / 1K RPD (tier-dependent)": "pws.rateLimits.freeTier.gemini", - "Free tier: ~30 RPM / 6K TPM / 1K RPD": "pws.rateLimits.freeTier.groq", - "Free tier: ~60 RPM / 100K TPM; then pay-as-you-go": "pws.rateLimits.freeTier.sambanova", - "Free tier: ~50 RPM / 50K TPM": "pws.rateLimits.freeTier.nebius", - "Free tier: ~5 RPM / 20K TPM; then pay-as-you-go": "pws.rateLimits.freeTier.mistral", - "Free tier: ~60 RPM / 1M TPM; then pay-as-you-go": "pws.rateLimits.freeTier.together", - "Free tier: ~600 RPM / 150K TPM": "pws.rateLimits.freeTier.fireworks", - "Free models: ~20 req/min, credit-capped; paid per model": "pws.rateLimits.freeTier.openrouter", - "Coding plan subscription; per-plan quotas": "pws.rateLimits.freeTier.minimax", - "GLM coding subscription (paid plan)": "pws.rateLimits.freeTier.zai", - "New accounts: one-time token grant; then pay-as-you-go": "pws.rateLimits.freeTier.deepseek", -}; - -function localizeFreeTier(freeTier: string, t: TFn): string { - const key = FREE_TIER_KEYS[freeTier]; - return key ? t(key) : freeTier; -} - -export function formatDocumentedLimits(rateLimits: DocumentedRateLimits, t: TFn): string { - const parts: string[] = []; - if (rateLimits.rpm !== undefined) parts.push(t("pws.rateLimits.rpm", { value: formatNumber(rateLimits.rpm) })); - if (rateLimits.tpm !== undefined) parts.push(t("pws.rateLimits.tpm", { value: formatNumber(rateLimits.tpm) })); - if (rateLimits.rpd !== undefined) parts.push(t("pws.rateLimits.rpd", { value: formatNumber(rateLimits.rpd) })); - if (rateLimits.freeTier) parts.push(localizeFreeTier(rateLimits.freeTier, t)); - return parts.join(" · "); -} diff --git a/gui/src/styles/provider-overview-dashboard.css b/gui/src/styles/provider-overview-dashboard.css index 1335117d0..be875ef1d 100644 --- a/gui/src/styles/provider-overview-dashboard.css +++ b/gui/src/styles/provider-overview-dashboard.css @@ -1,45 +1,5 @@ /* ProviderOverviewDashboard — aggregate overview (Phase 010) */ -/* Documented (reference) rate limits nested in the Rate limits column */ -.pws-dashboard-rows--documented { - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid rgba(128, 128, 128, 0.25); -} -.pws-dashboard-documented-heading { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--fg-muted, #8a8a8a); - margin-bottom: 4px; -} - -/* Documented-limit reference lines: label, value, and provenance with gaps and wrapping */ -.pws-documented-limits { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 8px; - font-size: 12px; - margin-top: 2px; -} -.pws-documented-limits-label { - font-weight: 600; - text-transform: uppercase; - font-size: 10px; - letter-spacing: 0.03em; - color: var(--fg-muted, #8a8a8a); -} -.pws-documented-limits-value { - overflow-wrap: anywhere; - min-width: 0; -} -.pws-documented-limits-meta { - overflow-wrap: anywhere; - min-width: 0; -} - .pws-dashboard { /* Muted labels here must clear WCAG 4.5:1 in both themes; the old `var(--fg-muted, #888)` fallback only reached ~3.5:1 on white. Alias the design-system muted token instead. */ diff --git a/gui/tests/provider-capacity-shell.test.tsx b/gui/tests/provider-capacity-shell.test.tsx index 80b53254b..315d47f85 100644 --- a/gui/tests/provider-capacity-shell.test.tsx +++ b/gui/tests/provider-capacity-shell.test.tsx @@ -438,45 +438,3 @@ test("five-hour and custom aggregate windows can be marked independently", async expect(markers).toHaveLength(1); expect(markers[0]?.getAttribute("aria-label")).toBe("Burst: incomplete account coverage"); }); - -test("providers without a live bar render their documented rate limits", async () => { - // The shell's workspace is built from the providers map; a registry-backed - // provider with documented limits (and no live quota report) shows in the - // "Documented" section instead of "No rate-limit data yet". - quotaPayload = { reports: [] }; - const withLimits = { - openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }, - groq: { - adapter: "openai-chat", - authMode: "key", - baseUrl: "https://api.groq.com/openai/v1", - hasApiKey: true, - rateLimits: { rpm: 30, tpm: 6000, rpd: 1000, source: "https://console.groq.com/docs/rate-limits", updatedAt: "2026-08-06" }, - }, - } as never; - - const { createRoot } = await import("react-dom/client"); - await act(async () => { - root ??= createRoot(host); - root.render( - - {}} - onAddProvider={() => {}} - quotaRefreshEpoch={0} - /> - , - ); - }); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); - - const text = host.textContent ?? ""; - expect(text).toContain("Documented"); - expect(text).toContain("30 req/min"); - expect(text).toContain("6K tok/min"); - expect(text).not.toContain("No rate-limit data yet"); -}); diff --git a/gui/tests/provider-capacity.test.ts b/gui/tests/provider-capacity.test.ts index 48c6b9c83..92a80c9de 100644 --- a/gui/tests/provider-capacity.test.ts +++ b/gui/tests/provider-capacity.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import { capacityAggregationFromReport } from "../src/provider-workspace/report"; -import { formatDocumentedLimits } from "../src/provider-workspace/documented-limits"; function selectorBlock(css: string, selector: string): string { const start = css.indexOf(`${selector} {`); @@ -121,31 +120,3 @@ test("malformed or future aggregation contracts fail closed", () => { expect(capacityAggregationFromReport({ aggregation: { kind: "capacity-weighted-v2" } })).toBeNull(); expect(capacityAggregationFromReport({ aggregation: { kind: "capacity-weighted-v1", scope: "routable-known" } })).toBeNull(); }); - -describe("documented rate limits formatting", () => { - const t = (key: string, vars?: Record) => { - const en = { - "pws.rateLimits.rpm": "{value} req/min", - "pws.rateLimits.tpm": "{value} tok/min", - "pws.rateLimits.rpd": "{value} req/day", - } as Record; - const template = en[key] ?? key; - let out = template; - for (const [k, v] of Object.entries(vars ?? {})) out = out.split(`{${k}}`).join(String(v)); - return out; - }; - - test("renders rpm/tpm/rpd with compact units", () => { - expect(formatDocumentedLimits({ rpm: 30, tpm: 6000, rpd: 1000 }, t)) - .toBe("30 req/min · 6K tok/min · 1K req/day"); - }); - - test("appends free-tier prose", () => { - expect(formatDocumentedLimits({ rpm: 15, freeTier: "Free tier: ~15 RPM" }, t)) - .toBe("15 req/min · Free tier: ~15 RPM"); - }); - - test("empty rate limits render as empty string", () => { - expect(formatDocumentedLimits({}, t)).toBe(""); - }); -}); diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 591f3f67c..b63c9fa7d 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -2,7 +2,6 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types"; import { PROVIDER_REGISTRY, providerMatchesRegistryTransport, - type ProviderRateLimits, type ProviderRegistryEntry, } from "./registry"; @@ -78,8 +77,6 @@ export interface DerivedProviderPreset { baseUrlChoices?: Array<{ id: string; label: string; baseUrl?: string }>; /** Immutable canonical provider config seed for the reserved canonical `openai` forward preset. */ provider?: OcxProviderConfig; - /** Documented rate limits (official docs, not probed); shown as reference in the overview. */ - rateLimits?: ProviderRateLimits; } export function listRegistryEntries(): readonly ProviderRegistryEntry[] { @@ -348,7 +345,6 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset { ...(entry.keyOptional ? { keyOptional: true } : {}), ...(entry.freeTier ? { freeTier: true } : {}), ...(entry.baseUrlChoices ? { baseUrlChoices: entry.baseUrlChoices.map(c => ({ ...c })) } : {}), - ...(entry.rateLimits ? { rateLimits: { ...entry.rateLimits } } : {}), }; } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index eafcdb1bf..20518adb4 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -19,28 +19,6 @@ import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; -/** - * Documented rate limits from a provider's official docs — NOT probed live. - * Shown as reference in the providers overview (dashboard "Rate limits" - * section and per-provider overview) for providers without a live quota - * probe. Tier-dependent and can drift; always displayed as documented - * reference, never as live utilization. - */ -export interface ProviderRateLimits { - /** Requests per minute (documented tier). */ - rpm?: number; - /** Tokens per minute (documented tier). */ - tpm?: number; - /** Requests per day (documented tier). */ - rpd?: number; - /** Free-tier cap, prose (e.g. "~200 req / 5 hours"). */ - freeTier?: string; - /** Where the numbers came from (official docs URL). */ - source?: string; - /** When last verified against the docs (YYYY-MM-DD). */ - updatedAt?: string; -} - /** * Wire protocol a client spoke when it reached the proxy. Chat and Anthropic surfaces * translate into a Responses-shaped body and replay through `handleResponses`, so the @@ -254,8 +232,6 @@ export interface ProviderRegistryEntry { googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; project?: string; location?: string; - /** Documented rate limits (official docs, not probed); shown as reference in the overview. */ - rateLimits?: ProviderRateLimits; } export type ProviderConfigSeed = Pick< @@ -1189,7 +1165,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ autoToolChoiceOnlyModels: ["kimi-k2.7-code"], preserveReasoningContentModels: NEURALWATT_REASONING_HISTORY_MODELS, }, - { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter", models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], modelContextWindows: { "anthropic/claude-sonnet-5": 1_000_000, ...OPENROUTER_GPT56_CONTEXT_WINDOWS }, rateLimits: { rpm: 20, freeTier: "Free models: ~20 req/min, credit-capped; paid per model", source: "https://openrouter.ai/docs/api_reference/limits", updatedAt: "2026-08-06" } }, + { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter", models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], modelContextWindows: { "anthropic/claude-sonnet-5": 1_000_000, ...OPENROUTER_GPT56_CONTEXT_WINDOWS } }, { // Primary sources checked 2026-08-02: // - docs.cline.bot/getting-started/clinepass publishes this exact catalog and explicitly @@ -1285,7 +1261,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ models: ["openai/gpt-5.6-sol", "anthropic/claude-sonnet-5", "google/gemini-3.5-flash"], note: "Korean enterprise LLM gateway. Per-key allowed models are discovered live from /v1/models. Full catalog: https://bizrouter.ai/models", }, - { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys", rateLimits: { rpm: 30, tpm: 6_000, rpd: 1_000, source: "https://console.groq.com/docs/rate-limits", updatedAt: "2026-08-06" } }, + { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" }, // 2026-07-10 Gemini API refresh: Tier-2 ai.google.dev evidence recorded in // devlog/_plan/260710_provider_hardening/001_research_frontier.md. { @@ -1299,16 +1275,15 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "gemini-3.1-pro-preview": ["low", "medium", "high"], }, jawcodeBundle: "google", extraMetadataAliases: ["gemini"], - rateLimits: { rpm: 15, tpm: 250_000, rpd: 1_000, freeTier: "Free tier: ~15 RPM / 250K TPM / 1K RPD (tier-dependent)", source: "https://ai.google.dev/gemini-api/docs/rate-limits", updatedAt: "2026-08-06" }, }, // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API // evidence from ai.google.dev does not establish Vertex publisher availability. { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: false, defaultModel: "gemini-3.6-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, - { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank", rateLimits: { freeTier: "Local — no remote limits", source: "https://github.com/ollama/ollama", updatedAt: "2026-08-06" } }, - { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank", rateLimits: { freeTier: "Local — no remote limits", source: "https://docs.vllm.ai", updatedAt: "2026-08-06" } }, - { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed", rateLimits: { freeTier: "Local — no remote limits", source: "https://lmstudio.ai/docs", updatedAt: "2026-08-06" } }, + { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, + { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, + { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" }, { id: "deepseek", label: "DeepSeek", @@ -1370,10 +1345,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // vision sidecar describes attached images for them, and the catalog advertises image input // on their behalf (same treatment as opencode-go's DeepSeek V4 entries above). noVisionModels: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_THINKING_MODELS], - rateLimits: { rpm: 30, freeTier: "New accounts: one-time token grant; then pay-as-you-go", source: "https://api-docs.deepseek.com/quick_start/rate_limit", updatedAt: "2026-08-06" }, }, // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. - { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b", rateLimits: { rpm: 30, tpm: 30_000, source: "https://inference-docs.cerebras.ai/ratelimits", updatedAt: "2026-08-06" } }, + { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" }, { id: "deepinfra", label: "DeepInfra", @@ -1545,7 +1519,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ maxModels: 128, }, note: "SambaNova Cloud text-generation models only; private SambaStudio deployment endpoints are outside this preset.", - rateLimits: { rpm: 60, tpm: 100_000, freeTier: "Free tier: ~60 RPM / 100K TPM; then pay-as-you-go", source: "https://docs.sambanova.ai/cloud/docs/rate-limits", updatedAt: "2026-08-06" }, }, { id: "nebius", @@ -1554,7 +1527,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ adapter: "openai-chat", authKind: "key", dashboardUrl: "https://tokenfactory.nebius.com", - rateLimits: { rpm: 50, tpm: 50_000, freeTier: "Free tier: ~50 RPM / 50K TPM", source: "https://docs.nebius.com/studio/rate-limits", updatedAt: "2026-08-06" }, liveModels: true, preserveCustomDestination: true, // The public tools guide documents single function selection, not parallel tool calls. @@ -1623,8 +1595,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ note: "Shared Generative APIs Serverless Chat Completions only; project-qualified and dedicated deployment hosts require a custom provider.", }, // FREEZE 2026-07-10: exact serverless ids remain auth-gated/unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. - { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys", rateLimits: { rpm: 60, tpm: 1_000_000, freeTier: "Free tier: ~60 RPM / 1M TPM; then pay-as-you-go", source: "https://docs.together.ai/docs/rate-limits", updatedAt: "2026-08-06" } }, - { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys", rateLimits: { rpm: 600, tpm: 150_000, freeTier: "Free tier: ~600 RPM / 150K TPM", source: "https://docs.fireworks.ai/guides/rate-limits", updatedAt: "2026-08-06" } }, + { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" }, + { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" }, { id: "firepass", label: "Fire Pass (Fireworks Kimi)", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys", @@ -1682,7 +1654,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ noVisionModels: ZAI_GLM_52_MODELS, modelReasoningEfforts: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, ZAI_GLM_52_REASONING_EFFORTS])), preserveReasoningContentModels: ZAI_GLM_52_MODELS, - rateLimits: { rpm: 60, tpm: 1_000_000, freeTier: "GLM coding subscription (paid plan)", source: "https://docs.z.ai/guides/overview/pricing", updatedAt: "2026-08-06" }, }, // Zhipu's domestic BigModel platform: OpenAI-compatible pay-as-you-go on open.bigmodel.cn — a // different host and billing product from the `zai` coding-plan subscription above. @@ -1948,7 +1919,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ ], }, // FREEZE 2026-07-10: codestral-latest is unconfirmed behind auth. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. - { id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest", rateLimits: { rpm: 5, tpm: 20_000, freeTier: "Free tier: ~5 RPM / 20K TPM; then pay-as-you-go", source: "https://docs.mistral.ai/getting-started/models/rate_limits/", updatedAt: "2026-08-06" } }, + { id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" }, { id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, @@ -1960,7 +1931,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ reasoningSplitModels: MINIMAX_MODELS, thinkingToggleModels: ["MiniMax-M3"], jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "Subscription Key or API Key", - rateLimits: { rpm: 100, tpm: 200_000, freeTier: "Coding plan subscription; per-plan quotas", source: "https://platform.minimax.io/docs/guides/rate-limits", updatedAt: "2026-08-06" }, }, { id: "minimax-cn", label: "MiniMax — Coding Plan (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key", @@ -1973,7 +1943,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ reasoningSplitModels: MINIMAX_MODELS, thinkingToggleModels: ["MiniMax-M3"], jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "中国区 Subscription Key", - rateLimits: { rpm: 100, tpm: 200_000, freeTier: "Coding plan subscription; per-plan quotas", source: "https://platform.minimaxi.com/docs/guides/rate-limits", updatedAt: "2026-08-06" }, }, { id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key", @@ -2022,7 +1991,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ liveModels: true, note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.", dashboardUrl: "https://opencode.ai", - rateLimits: { freeTier: "~200 free-model requests per 5 hours", source: "https://opencode.ai/docs/zen/", updatedAt: "2026-08-06" }, staticHeaders: { "x-opencode-client": "desktop", }, diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index d07291a6d..e439ceecb 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -560,16 +560,6 @@ export function safeConfigDTO(config: OcxConfig): unknown { ? getProviderRegistryEntry(name) : registryEntryForProviderDestination(provider))?.note; if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote; - // Documented limits follow the DESTINATION, not the config key: a renamed - // preset keeps its vendor's limits, while a key provider whose transport - // was edited to a custom host must not inherit the registry id's limits. - // Key providers resolve purely by destination; forward/oauth/local presets - // (which the destination resolver skips) resolve by id. - const isKeyAuth = (provider.authMode ?? "key") === "key"; - const limitsEntry = isKeyAuth - ? registryEntryForProviderDestination(provider) - : getProviderRegistryEntry(name); - if (limitsEntry?.rateLimits) dto.rateLimits = { ...limitsEntry.rateLimits }; const codexAccountMode = providerCodexAccountMode(name, provider); if (codexAccountMode) dto.codexAccountMode = codexAccountMode; providers[name] = dto; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 851d8bdf8..85c5a9400 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -48,7 +48,7 @@ import { readUsageEntries } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; -import { getProviderRegistryEntry, registryEntryForProviderDestination } from "../../providers/registry"; +import { getProviderRegistryEntry } from "../../providers/registry"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; import { @@ -241,33 +241,20 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { - // Documented limits follow the DESTINATION, not the config key: a preset - // saved under a custom name (e.g. "my-groq") must still surface Groq's - // limits, while a key provider whose transport was edited to a custom - // host must NOT inherit the registry id's limits. Key providers resolve - // purely by destination; forward/oauth/local presets (which the - // destination resolver skips) resolve by id. - const isKeyAuth = (p.authMode ?? "key") === "key"; - const registry = isKeyAuth - ? registryEntryForProviderDestination(p) - : getProviderRegistryEntry(name); - return { - name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, - hasApiKey: !!p.apiKey, - // Presence only (#959 review): header names and values never leave the process. - hasHeaders: !!p.headers && Object.keys(p.headers).length > 0, - allowPrivateNetwork: p.allowPrivateNetwork === true, - liveModels: p.liveModels !== false, - models: p.models ?? [], - authMode: p.authMode, - apiKeyTransport: p.apiKeyTransport, - disabled: p.disabled === true, - codexAccountMode: providerCodexAccountMode(name, p), - discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name), - ...(registry?.rateLimits ? { rateLimits: { ...registry.rateLimits } } : {}), - }; - })); + return jsonResponse(Object.entries(config.providers).map(([name, p]) => ({ + name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, + hasApiKey: !!p.apiKey, + // Presence only (#959 review): header names and values never leave the process. + hasHeaders: !!p.headers && Object.keys(p.headers).length > 0, + allowPrivateNetwork: p.allowPrivateNetwork === true, + liveModels: p.liveModels !== false, + models: p.models ?? [], + authMode: p.authMode, + apiKeyTransport: p.apiKeyTransport, + disabled: p.disabled === true, + codexAccountMode: providerCodexAccountMode(name, p), + discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name), + }))); } // Add (or overwrite) a single provider. Merges into the live in-memory config and diff --git a/tests/config.test.ts b/tests/config.test.ts index 1816e1b07..ec75200de 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -2321,26 +2321,4 @@ describe("codex account selection order", () => { expect(degraded.config.codexAccountPriorities).toEqual({ work: 1 }); expect(degraded.warnings).toContainEqual(expect.stringContaining("no longer pinned")); }); - - test("safeConfigDTO attaches documented limits by destination, not name", async () => { - const { safeConfigDTO } = await import("../src/server/auth-cors"); - const base = getDefaultConfig(); - // A renamed Groq preset keeps its destination's limits. - const renamed = safeConfigDTO({ - ...base, - providers: { - "my-groq": { adapter: "openai-chat", authMode: "key", baseUrl: "https://api.groq.com/openai/v1" }, - }, - } as never) as { providers: Record }; - expect(renamed.providers["my-groq"]?.rateLimits?.rpm).toBe(30); - - // A provider named groq but with an edited transport must NOT inherit groq's limits. - const edited = safeConfigDTO({ - ...base, - providers: { - groq: { adapter: "openai-chat", authMode: "key", baseUrl: "https://custom.example/v1" }, - }, - } as never) as { providers: Record }; - expect(edited.providers.groq?.rateLimits).toBeUndefined(); - }); }); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 78de443d9..37bc5467a 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -1022,26 +1022,4 @@ describe("free-provider directory isolation", () => { expect(map?.max, `${provider}/${model} max`).toBe("max"); } }); - - test("documented rate limits carry provenance (source + updatedAt)", () => { - const withLimits = PROVIDER_REGISTRY.filter(entry => entry.rateLimits); - expect(withLimits.length).toBeGreaterThan(0); - for (const entry of withLimits) { - expect(entry.rateLimits?.source, `${entry.id} rateLimits.source`).toBeTruthy(); - expect(entry.rateLimits?.updatedAt, `${entry.id} rateLimits.updatedAt`).toBeTruthy(); - // A rateLimits object with no numeric fields and no freeTier prose is an - // empty shell — it would render as "Documented" with nothing after it. - const hasNumber = entry.rateLimits?.rpm !== undefined - || entry.rateLimits?.tpm !== undefined - || entry.rateLimits?.rpd !== undefined; - expect(hasNumber || !!entry.rateLimits?.freeTier, `${entry.id} rateLimits is empty`).toBe(true); - } - }); - - test("documented rate limits survive the preset round-trip", () => { - const preset = deriveProviderPresets().find(p => p.id === "groq"); - expect(preset?.rateLimits).toBeTruthy(); - expect(preset?.rateLimits?.rpm).toBe(30); - expect(preset?.rateLimits?.source).toContain("groq.com"); - }); }); From a312f75747e8f6b7eddfbcb3d229265e909e29e4 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:26:42 +0200 Subject: [PATCH 11/11] revert(providers): drop Command Code quota probe (cookie-only billing API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Command Code billing endpoints (/internal/billing/credits, /internal/billing/subscriptions) require the browser better-auth session cookie — the API key opencodex stores via `ocx login command-code` is rejected with 401 "You're logged out" (only /alpha/whoami accepts the key). There is no API-key-accessible quota endpoint, so the probe could never produce a report. Remove the probe, its dispatcher branch, constants, and tests rather than ship dead code that claims a live meter. Co-authored-by: CommandCodeBot --- .../src/content/docs/guides/providers.md | 6 +- src/providers/quota.ts | 85 ------------------- tests/provider-quota.test.ts | 79 ----------------- 3 files changed, 3 insertions(+), 167 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 145124f45..af1d0ef4a 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -493,7 +493,7 @@ The bars show how much of a window (5-hour, weekly, monthly, or provider-specific) is already consumed. Providers with a live probe: OpenAI/Codex, Anthropic, xAI, Cursor, Kimi, -Google Antigravity, Command Code, OpenRouter, DeepSeek, ClinePass, Z.AI, -MiniMax, Moonshot, Venice, Synthetic, DeepInfra, Neuralwatt, and any -a6api-backed custom provider. +Google Antigravity, OpenRouter, DeepSeek, ClinePass, Z.AI, MiniMax, +Moonshot, Venice, Synthetic, DeepInfra, Neuralwatt, and any a6api-backed +custom provider. diff --git a/src/providers/quota.ts b/src/providers/quota.ts index cddd57f9d..3ff9396c8 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -44,7 +44,6 @@ const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2"; const DEEPINFRA_BASE_URL = "https://api.deepinfra.com"; const NEURALWATT_BASE_URL = "https://api.neuralwatt.com/v1"; -const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai"; /** 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(); @@ -302,11 +301,6 @@ function isCanonicalNeuralwattBaseUrl(baseUrl: string): boolean { return normalizedBaseUrl(baseUrl) === NEURALWATT_BASE_URL; } -function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - return normalized === COMMAND_CODE_BASE_URL || normalized === `${COMMAND_CODE_BASE_URL}/provider/v1`; -} - function a6apiPayload(value: unknown): Record | null { const body = asRecord(value); return asRecord(body?.data) ?? body; @@ -837,84 +831,6 @@ async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig) return windows > 0 ? report(provider, "neuralwatt:quota", quota) : null; } -/** - * Command Code billing — `GET /internal/billing/credits` (+ subscriptions for - * the monthly plan total). The API key (from `ocx login command-code`) is - * sent as a Bearer token. The credits payload reports rolling 5-hour and - * weekly utilization windows plus monthly credit balances; the subscription - * names the plan whose catalog holds the monthly grant total, so the monthly - * bar is consumed-share of the grant when the plan is known. - */ -async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig): Promise { - // Never send the Command Code credential to a lookalike or non-canonical host. - if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null; - let apiKey: string; - try { - apiKey = await getValidAccessToken("command-code"); - } catch { - return null; - } - if (!apiKey) return null; - const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}` } as const; - const [creditsRes, subsRes] = await Promise.all([ - fetch(`${COMMAND_CODE_BASE_URL}/internal/billing/credits`, { - headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }), - fetch(`${COMMAND_CODE_BASE_URL}/internal/billing/subscriptions`, { - headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }), - ]); - // 4xx (except 408/429) is a credential/contract problem → terminal; 5xx/network → transient. - for (const res of [creditsRes, subsRes]) { - if (!res.ok) { - return res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429 - ? TERMINAL_QUOTA_FAILURE - : null; - } - } - const creditsBody = asRecord(await creditsRes.json().catch(() => null)); - const credits = asRecord(creditsBody?.data) ?? creditsBody; - if (!credits) return null; - const quota: ProviderQuota = { updatedAt: Date.now() }; - let windows = 0; - const windowFrom = (raw: unknown): { percent?: number; resetAt?: number } | null => { - const row = asRecord(raw); - if (!row) return null; - const percent = normalizePercent(row.usedPercent ?? row.used_percent ?? row.percentUsed); - const resetAt = normalizeResetAt(row.resetsAt ?? row.resetAt ?? row.reset_at); - return percent !== undefined || resetAt !== undefined ? { ...(percent !== undefined ? { percent } : {}), ...(resetAt !== undefined ? { resetAt } : {}) } : null; - }; - const fiveHour = windowFrom(credits.fiveHourWindow ?? credits.five_hour_window); - const weekly = windowFrom(credits.weeklyWindow ?? credits.weekly_window); - if (fiveHour?.percent !== undefined) { - quota.fiveHourPercent = fiveHour.percent; - if (fiveHour.resetAt !== undefined) quota.fiveHourResetAt = fiveHour.resetAt; - windows += 1; - } - if (weekly?.percent !== undefined) { - quota.weeklyPercent = weekly.percent; - if (weekly.resetAt !== undefined) quota.weeklyResetAt = weekly.resetAt; - windows += 1; - } - // Monthly: consumed share of the plan grant when the subscription names a plan. - const monthlyCredits = toFiniteNumber(credits.monthlyCredits ?? credits.monthly_credits); - const subsBody = asRecord(await subsRes.json().catch(() => null)); - const subs = Array.isArray(subsBody?.data) ? subsBody.data as unknown[] : Array.isArray(subsBody) ? subsBody as unknown[] : null; - const sub = subs?.map(asRecord).find((r): r is Record => r !== null && String(r.status ?? "").toLowerCase() === "active"); - const monthlyTotal = sub ? toFiniteNumber(sub.monthlyCreditsTotal ?? sub.monthly_credits_total ?? sub.allowance) : undefined; - if (monthlyCredits !== undefined && monthlyTotal !== undefined && monthlyTotal > 0) { - const used = Math.max(0, Math.min(monthlyTotal, monthlyTotal - monthlyCredits)); - const percent = normalizePercent((used / monthlyTotal) * 100); - if (percent !== undefined) { - quota.monthlyPercent = percent; - const periodEnd = sub ? normalizeResetAt(sub.currentPeriodEnd ?? sub.current_period_end ?? sub.periodEnd) : undefined; - if (periodEnd !== undefined) quota.monthlyResetAt = periodEnd; - windows += 1; - } - } - return windows > 0 ? report(provider, "commandcode:billing", quota) : null; -} - function report( provider: string, source: string, @@ -1779,7 +1695,6 @@ async function maybeFetchProviderQuota( // Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical // host and only for real key auth — forward/local modes carry no credential of ours. if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider); - if (provider.authMode === "oauth" && name === "command-code") return fetchCommandCodeQuota(name, provider); if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) { return fetchKimiQuota(name, provider); } diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index c3d37d88e..873a7af54 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -1131,85 +1131,6 @@ describe("fetchProviderQuotaReports", () => { expect(seen).toEqual([]); }); - test("Command Code quota maps 5-hour/weekly windows and monthly grant share", async () => { - await saveCredential("command-code", { access: "cc-secret", refresh: "cc-secret", expires: Date.now() + 3600_000 }); - 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.includes("/internal/billing/credits")) { - return new Response(JSON.stringify({ - data: { - monthlyCredits: 60, - fiveHourWindow: { usedPercent: 40.5, resetsAt: "2026-08-06T18:00:00Z" }, - weeklyWindow: { usedPercent: 52 }, - }, - }), { status: 200 }); - } - return new Response(JSON.stringify({ - data: [{ planId: "pro", status: "active", currentPeriodEnd: "2026-08-31T00:00:00Z", monthlyCreditsTotal: 100 }], - }), { status: 200 }); - }) as typeof fetch; - const config = { - defaultProvider: "command-code", - providers: { "command-code": { adapter: "command-code", authMode: "oauth", baseUrl: "https://api.commandcode.ai" } }, - } as OcxConfig; - - const result = await fetchProviderQuotaReports(config, true); - - expect(result.reports).toHaveLength(1); - expect(result.reports[0]?.source).toBe("commandcode:billing"); - expect(result.reports[0]?.quota).toMatchObject({ - fiveHourPercent: 40.5, - weeklyPercent: 52, - monthlyPercent: 40, // (100-60)/100 - }); - expect(seen).toHaveLength(2); - expect(seen.every(row => row.authorization === "Bearer cc-secret")).toBe(true); - expect(seen.every(row => row.redirect === "error")).toBe(true); - }); - - test("Command Code quota never sends the credential to a non-canonical base URL", async () => { - await saveCredential("command-code", { access: "cc-secret", refresh: "cc-secret", expires: Date.now() + 3600_000 }); - const seen: string[] = []; - globalThis.fetch = (async (input: RequestInfo | URL) => { - seen.push(String(input)); - return new Response("unexpected", { status: 500 }); - }) as typeof fetch; - const config = { - defaultProvider: "command-code", - providers: { "command-code": { adapter: "command-code", authMode: "oauth", baseUrl: "https://attacker.example" } }, - } as OcxConfig; - - const result = await fetchProviderQuotaReports(config, true); - - expect(result.reports).toEqual([]); - expect(seen).toEqual([]); - }); - - test("Command Code quota treats a 401 as terminal (drops last-good)", async () => { - await saveCredential("command-code", { access: "cc-secret", refresh: "cc-secret", expires: Date.now() + 3600_000 }); - let rejected = false; - globalThis.fetch = (async () => { - if (rejected) return new Response("unauthorized", { status: 401 }); - return new Response(JSON.stringify({ - data: { monthlyCredits: 60, fiveHourWindow: { usedPercent: 40.5 }, weeklyWindow: { usedPercent: 52 } }, - }), { status: 200 }); - }) as typeof fetch; - const config = { - defaultProvider: "command-code", - providers: { "command-code": { adapter: "command-code", authMode: "oauth", baseUrl: "https://api.commandcode.ai" } }, - } as OcxConfig; - - const valid = await fetchProviderQuotaReports(config, true); - rejected = true; - const invalid = await fetchProviderQuotaReports(config, true); - - expect(valid.reports).toHaveLength(1); - expect(invalid.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[] = [];