Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,31 @@ Provider plan, and CLI auth bridging for Go/Pro subscriptions is not yet availab
> hosts and schemas and are not routed by this preset.
> Live discovery for this preset is capped at a 1 MiB response and 256 raw model rows.

### A6API credit quota

A custom `openai-chat` provider using `authMode: "key"` and the canonical
`https://api.a6api.com` or `https://api.a6api.com/v1` base URL receives an A6API credit meter in
the dashboard and from `ocx account refresh <provider>`. The provider name is arbitrary; detection
uses the canonical HTTPS endpoint. The meter converts A6API token units into USD using the account's
hard credit limit and displays the percentage consumed plus remaining credit. Token expiration is
not shown as a quota reset because expiration does not imply that credit replenishes.

```json
{
"providers": {
"my-a6": {
"adapter": "openai-chat",
"authMode": "key",
"baseUrl": "https://api.a6api.com/v1",
"apiKey": "${A6API_API_KEY}"
}
}
}
```

Quota probes send only the active key to the canonical A6API host and reject redirects. Malformed,
negative, or internally inconsistent billing totals produce no report rather than a misleading bar.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
> **Tencent Cloud Coding Plan usage restriction:** Tencent documents this subscription for
> interactive coding tools only. General API automation, custom application backends, and
> non-interactive batch use are prohibited and may cause the plan key to be suspended.
Expand Down
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/ja/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,15 @@ API アクセスには Provider プランが必要で、Go/Pro サブスクリ
> エンドポイントはホストとスキーマが異なるため、このプリセットではルーティングされません。
> このプリセットのライブディスカバリーは、レスポンス 1 MiB、モデルの生行 256 件が上限です。

### A6API クレジットクォータ

`openai-chat`、`authMode: "key"`、正規の `https://api.a6api.com` または
`https://api.a6api.com/v1` を使うカスタムプロバイダーでは、ダッシュボードと
`ocx account refresh <provider>` に A6API クレジット使用量が表示されます。プロバイダー名は任意です。
アカウントの hard credit limit を基準にトークン単位を USD に換算し、使用率と残高を表示します。トークン期限は補充を意味しないため、クォータの
リセットとしては表示しません。アクティブキーだけを正規ホストへ送信し、リダイレクトを拒否します。負数や
整合しない請求合計からはレポートを生成しません。
Comment thread
coderabbitai[bot] marked this conversation as resolved.

> **Tencent Cloud Coding Plan の利用制限:** Tencent はこのサブスクリプションを対話型
> コーディングツール専用としています。一般的な API 自動化、カスタムアプリのバックエンド、
> 非対話型バッチ利用は禁止されており、プランキーが停止される場合があります。
Expand Down
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/ko/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,15 @@ Bearer API 키를 사용합니다. registry가 소유하는 DeepInfra 모델 목
> 호스트와 스키마가 다르므로 이 프리셋으로 라우팅되지 않습니다.
> 이 프리셋의 실시간 검색은 응답 1 MiB와 원시 모델 행 256개로 제한됩니다.

### A6API 크레딧 쿼터

`openai-chat`, `authMode: "key"`, 공식 `https://api.a6api.com` 또는
`https://api.a6api.com/v1` 주소를 사용하는 사용자 지정 프로바이더는 대시보드와
`ocx account refresh <provider>`에서 A6API 크레딧 사용량을 표시합니다. 프로바이더 이름은 자유롭게 정할 수
있습니다. 계정의 hard credit limit을 기준으로 토큰 단위를 USD로 환산해 사용률과 남은 크레딧을 표시하며, 토큰 만료는 충전을 뜻하지 않으므로 쿼터
리셋으로 표시하지 않습니다. 활성 키만 공식 호스트로 전송하고 리디렉션을 거부하며, 음수이거나 서로 일치하지
않는 결제 합계에는 보고서를 만들지 않습니다.

> **Tencent Cloud Coding Plan 사용 제한:** Tencent는 이 구독을 대화형 코딩 도구 전용으로
> 안내합니다. 일반 API 자동화, 사용자 애플리케이션 백엔드 및 비대화형 일괄 호출은 금지되며
> 플랜 키가 정지될 수 있습니다.
Expand Down
10 changes: 10 additions & 0 deletions docs-site/src/content/docs/ru/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,16 @@ endpoint в него не входят. Ключи создаются в [Hyperb
> схемы и этим пресетом не маршрутизируются.
> Для этого пресета live discovery ограничен ответом размером 1 MiB и 256 исходными строками моделей.

### Квота кредитов A6API

Пользовательский провайдер с `openai-chat`, `authMode: "key"` и каноническим адресом
`https://api.a6api.com` или `https://api.a6api.com/v1` показывает расход кредитов A6API в
дашборде и в `ocx account refresh <provider>`. Имя провайдера может быть любым. Единицы токенов
пересчитываются в USD по hard credit limit учётной записи; отображаются процент расхода и остаток. Срок действия токена не считается
сбросом квоты, поскольку он не означает пополнение. Только активный ключ отправляется на
канонический хост, перенаправления отклоняются, а отрицательные или несогласованные итоги биллинга
не создают отчёт.

> **Ограничение Tencent Cloud Coding Plan:** Tencent разрешает использовать эту подписку только
> в интерактивных инструментах программирования. Автоматизация общего API, серверы пользовательских
> приложений и неинтерактивные пакетные вызовы запрещены и могут привести к блокировке ключа плана.
Expand Down
8 changes: 8 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,14 @@ bearer 密钥;API 访问需要 Provider 套餐,Go/Pro 订阅用户的 CLI
> 专用 Truss `predict` 端点使用不同的主机和请求 schema,不由此预设路由。
> 该预设的实时发现上限为 1 MiB 响应和 256 条原始模型记录。

### A6API 信用额度

使用 `openai-chat`、`authMode: "key"` 以及规范地址 `https://api.a6api.com` 或
`https://api.a6api.com/v1` 的自定义提供商,会在仪表板和 `ocx account refresh <provider>`
中显示 A6API 信用使用情况;提供商名称可以自定义。系统依据账户的 hard credit limit 将令牌单位换算为 USD,并显示已用百分比和剩余额度。
令牌到期不代表额度补充,因此不会显示为配额重置。只有当前活动密钥会发送到规范主机,重定向会被拒绝;负数
或内部不一致的计费总数不会生成报告。

> **腾讯云 Coding Plan 使用限制:**腾讯将此订阅限定为交互式编程工具使用。禁止通用 API
> 自动化、自定义应用后端和非交互式批量调用;违规使用可能导致套餐密钥被停用。

Expand Down
111 changes: 102 additions & 9 deletions src/providers/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { resolveEnvValue } from "../config";
import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
import { antigravityUserAgent } from "../adapters/client-fingerprint";
import { apiKeyPoolEntryId } from "./api-keys";
import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry";
import type { OcxConfig, OcxProviderConfig } from "../types";
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers";
Expand All @@ -32,6 +33,7 @@ const CACHE_TTL_MS = 5 * 60_000;
const REQUEST_TIMEOUT_MS = 8_000;
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`;
const A6API_BASE_URL = "https://api.a6api.com";
/** Keep a failed probe's previous row at most this long before dropping it. */
const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
const nativeMainReportGenerations = new WeakMap<ProviderQuotaReport, number>();
Expand All @@ -43,6 +45,8 @@ export function setProviderQuotaBeforePublishForTests(
): void {
providerQuotaBeforePublishForTests = hook;
}
const TERMINAL_QUOTA_FAILURE = Symbol("terminal-quota-failure");
type ProviderQuotaProbeResult = ProviderQuotaReport | null | typeof TERMINAL_QUOTA_FAILURE;

export interface ProviderQuotaWindow {
label: string;
Expand Down Expand Up @@ -89,7 +93,13 @@ export function clearProviderQuotaCache(): void {

function cacheKey(config: OcxConfig): string {
const providers = Object.entries(config.providers)
.map(([name, provider]) => `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}`)
.map(([name, provider]) => {
const resolvedKey = typeof provider.apiKey === "string"
? resolveEnvValue(provider.apiKey)?.trim()
: undefined;
const activeKeyId = resolvedKey ? apiKeyPoolEntryId(resolvedKey) : "none";
return `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}:${activeKeyId}`;
})
.sort()
.join("|");
return `${config.defaultProvider}|${providers}`;
Expand Down Expand Up @@ -228,6 +238,77 @@ function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConf
return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider);
}

function isCanonicalA6apiBaseUrl(baseUrl: string): boolean {
const normalized = normalizedBaseUrl(baseUrl);
return normalized === A6API_BASE_URL || normalized === `${A6API_BASE_URL}/v1`;
}

function a6apiPayload(value: unknown): Record<string, unknown> | null {
const body = asRecord(value);
return asRecord(body?.data) ?? body;
}

function firstFinite(record: Record<string, unknown> | null, names: string[]): number | undefined {
if (!record) return undefined;
for (const name of names) {
const value = toFiniteNumber(record[name]);
if (value !== undefined) return value;
}
return undefined;
}

async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
// Never send a configured API key to a lookalike host or through a redirect.
if (!isCanonicalA6apiBaseUrl(config.baseUrl)) return null;
const apiKey = resolveEnvValue(config.apiKey)?.trim();
if (!apiKey) return null;
Comment thread
byongshintv marked this conversation as resolved.
const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}` } as const;
const [subscriptionResponse, tokenResponse] = await Promise.all([
fetch(`${A6API_BASE_URL}/dashboard/billing/subscription`, {
headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}),
fetch(`${A6API_BASE_URL}/api/usage/token/`, {
headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}),
]);
if (!subscriptionResponse.ok || !tokenResponse.ok) {
const statuses = [subscriptionResponse.status, tokenResponse.status];
// 408/429 are transient (timeout/throttle), not invalid-account signals: keep the
// last-good row like 5xx/network failures. 401/403 (bad key) and 404 (contract change)
// stay terminal.
return statuses.some(status => status >= 400 && status < 500 && status !== 429 && status !== 408)
? TERMINAL_QUOTA_FAILURE
: null;
}
const subscription = a6apiPayload(await subscriptionResponse.json().catch(() => null));
const token = a6apiPayload(await tokenResponse.json().catch(() => null));
const limitUsd = firstFinite(subscription, ["hard_limit_usd"]);
const grantedUnits = firstFinite(token, ["total_granted"]);
const usedUnits = firstFinite(token, ["total_used"]);
const availableUnits = firstFinite(token, ["total_available"]);
const reconciledUnits = usedUnits !== undefined && availableUnits !== undefined
? usedUnits + availableUnits
: undefined;
const reconciliationTolerance = grantedUnits !== undefined
? Math.abs(grantedUnits) * 1e-9
: 0;
if (limitUsd === undefined || grantedUnits === undefined || usedUnits === undefined
|| availableUnits === undefined || limitUsd <= 0 || grantedUnits <= 0
|| usedUnits < 0 || availableUnits < 0
|| reconciledUnits === undefined
|| Math.abs(reconciledUnits - grantedUnits) > reconciliationTolerance) return TERMINAL_QUOTA_FAILURE;
const usdPerUnit = limitUsd / grantedUnits;
const usedUsd = usedUnits * usdPerUnit;
const remainingUsd = Math.max(0, availableUnits * usdPerUnit);
const percent = normalizePercent((usedUsd / limitUsd) * 100);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (percent === undefined) return TERMINAL_QUOTA_FAILURE;
const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`;
Comment thread
byongshintv marked this conversation as resolved.
return report(provider, "a6api:billing", {
customWindows: [{ label, percent }],
updatedAt: Date.now(),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function report(
provider: string,
source: string,
Expand Down Expand Up @@ -671,7 +752,7 @@ export async function fetchProviderAccountQuotas(
function normalizedBaseUrl(value: string): string | null {
try {
const url = new URL(value);
if (url.search || url.hash) return null;
if (url.username || url.password || url.search || url.hash) return null;
return `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`;
} catch {
return null;
Expand Down Expand Up @@ -1079,7 +1160,7 @@ async function maybeFetchProviderQuota(
config: OcxConfig,
forceRefresh: boolean,
prefetchedCodexSnapshot?: CodexAuthAccountsSnapshotPromise,
): Promise<ProviderQuotaReport | null> {
): Promise<ProviderQuotaProbeResult> {
if (provider.disabled === true) return null;
try {
if (isBuiltInChatGptForwardProvider(name, provider)) {
Expand All @@ -1095,6 +1176,9 @@ async function maybeFetchProviderQuota(
if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) {
return fetchKimiQuota(name, provider);
}
if ((provider.authMode ?? "key") === "key" && isCanonicalA6apiBaseUrl(provider.baseUrl)) {
return fetchA6apiQuota(name, provider);
}
return null;
} catch {
return null;
Expand Down Expand Up @@ -1125,20 +1209,24 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh

const promise = (async (): Promise<ProviderQuotaResponse> => {
const previous = cache && cache.key === key ? cache.response.reports : [];
const fresh = (await Promise.all(
const probeResults = await Promise.all(
Object.entries(config.providers).map(([name, provider]) => (
maybeFetchProviderQuota(name, provider, config, forceRefresh, prefetchedCodexSnapshot)
)),
)).filter((item): item is ProviderQuotaReport => item !== null);
maybeFetchProviderQuota(name, provider, config, forceRefresh, prefetchedCodexSnapshot)
)),
);
const fresh = probeResults.filter((item): item is ProviderQuotaReport => item !== null && item !== TERMINAL_QUOTA_FAILURE);
const terminalFailures = new Set(
Object.keys(config.providers).filter((_, index) => probeResults[index] === TERMINAL_QUOTA_FAILURE),
);
await providerQuotaBeforePublishForTests?.();
let commitKey: string | null = null;
if (epoch === invalidationEpoch) {
const commitKeyCandidate = cacheKeyWithAggregationState(config);
commitKey = typeof commitKeyCandidate === "string" ? commitKeyCandidate : await commitKeyCandidate;
}

// Keep bounded last-good rows when a probe fails (e.g. transient upstream flake); never
// re-stamp their timestamps, and drop rows older than LAST_GOOD_MAX_AGE_MS.
// Keep bounded last-good rows when a probe fails transiently; terminal-invalid provider
// responses explicitly suppress their old row. Never re-stamp preserved timestamps.
// Note: the cache key encodes the provider set (name/adapter/authMode/disabled/baseUrl),
// so previous rows always correspond to currently configured, enabled providers — a
// disabled or removed provider changes the key and starts from an empty previous set.
Expand All @@ -1159,6 +1247,11 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
generationMismatchedProviders.add(item.provider);
}
}
// Terminal-invalid probes suppress their previous row (transient failures keep it).
for (const provider of terminalFailures) {
byProvider.delete(provider);
generationMismatchedProviders.delete(provider);
}

const response = { generatedAt: Date.now(), reports: [...byProvider.values()] };
// Commit only when this probe still holds authority (no clear/force superseded it).
Expand Down
Loading
Loading