Skip to content
Closed
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
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ja/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ Authorization: Bearer <admin-token>

行が既存のパーサーのサイズ上限を超えた場合、`GET /api/usage` と `GET /api/keys` は読み取れる行の集計を維持し、応答全体に `usageIncomplete: true` と `usageIncompleteReason: "oversized_rows"` を追加します。この診断はキャッシュや増分追記後も維持され、結果が空または一致なしでも返されます。再構築時には再計算されます。プロバイダー、モデル、API キーの識別子は短縮しません。フラグがないことは全行が有効だった証明にはなりません。`historyTruncated`、`entriesTruncated`、トークン測定カバレッジとは別の情報です。

API キーの `accountLogLabel` の導出、再試行時のキー選択と使用量、フラットな `attempts` の集計規則は、[英語版の使用量帰属の仕様](/reference/management-api/#api-key-usage-attribution)を参照してください。コンボの親合計は重ねて加算せず、未報告の使用量や過去のラベルなし記録を推定しません。サブスクリプションのクォータは別の観測値です。

`models`、`providers`、および `days[].models` の各行にも `cacheHitRate` が含まれます。これは、プロバイダーのプロンプト キャッシュから供給された入力トークンの割合で、`[0, 1]` の範囲に制限されます。プロバイダーがキャッシュ テレメトリを報告しなかった場合、または行に入力トークンがない場合は、`0` ではなく `null` になります。「キャッシュ データなし」と「実際のヒット率 0%」は異なる事実であり、それらを同じように描画するチャートは誤解を招くためです。

:::caution
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ko/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ Authorization: Bearer <admin-token>

행이 기존 파서의 크기 제한을 넘으면 `GET /api/usage`와 `GET /api/keys`는 읽을 수 있는 행의 집계를 유지하고 응답 전체에 `usageIncomplete: true`, `usageIncompleteReason: "oversized_rows"`를 추가합니다. 이 진단은 캐시와 증분 추가에서도 유지되며, 빈 결과나 필터 일치 결과가 없는 경우에도 반환됩니다. 재구축 시에는 다시 계산합니다. 행을 맞추기 위해 공급자·모델·API 키 식별자를 줄이지 않습니다. 플래그가 없다고 모든 기록이 유효했다는 뜻은 아닙니다. `historyTruncated`, `entriesTruncated`, 토큰 측정 커버리지와는 별개입니다.

API 키의 `accountLogLabel` 계산 방식, 재시도 시 키 선택과 사용량 기록, 평탄한 `attempts` 집계 규칙은 [영문 사용량 귀속 규칙](/reference/management-api/#api-key-usage-attribution)을 기준으로 합니다. 콤보 부모의 합계는 중복해서 더하지 않으며, 보고되지 않은 사용량이나 라벨이 없는 과거 기록은 추정하지 않습니다. 구독 쿼타는 별도로 관측한 값입니다.

`models`, `providers`, `days[].models`의 행에도 `cacheHitRate`가 포함됩니다. 이 값은 공급자의 프롬프트 캐시에서
제공된 입력 토큰의 비율이며 `[0, 1]` 범위로 제한됩니다. 공급자가 캐시 텔레메트리를 보고하지 않았거나 행에 입력
토큰이 없으면 `0`이 아니라 항상 `null`입니다. "캐시 데이터 없음"과 "실제 적중률 0%"는 서로 다른 사실이며,
Expand Down
23 changes: 23 additions & 0 deletions docs-site/src/content/docs/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,29 @@ final provider. Custom destinations and historic rows omit the field; consumers
infer subscription usage from the current configuration, model name, or inbound API key.
The log reports usage, not subscription invoice amounts.

<a id="api-key-usage-attribution"></a>

API-key attempts also record `accountLogLabel` as `k` followed by 32 lowercase hex digits.
The label is the first 128 bits of SHA-256 over
`JSON.stringify(["ocx-key-account-v1", providerName, entryId ?? null, reference])`.
The reference is the configured key value captured for the physical request, before environment
or keychain resolution. Raw keys, references, and pool IDs are not written to the label field.
A consumer can derive the same label from its local configuration without resolving secrets.
This is a deterministic pseudonymous identifier, not encryption: anyone with a candidate
configuration can recompute its label.
Changing a literal key or reference changes the label; replacing the secret behind an unchanged
reference keeps the same logical account. Older unlabeled records cannot be attributed reliably.

Key selection is recorded after queued requests have been rebuilt for the current selection.
When a retry changes keys, `attempts` retains a separate record for the preceding key, including
reported usage from failed responses. Missing usage remains unreported. Routed adapter terminals
are observed before image/search loops or continuation guards combine their usage. Consumers
sum the flat attempts by provider/account and do not add the parent combo total again. These
records identify usage; provider quota percentages remain separate upstream observations.
Command Code reasoning-effort retries consume the shared request send budget and record one
additional physical send as `reasoning-effort-downgrade`. A refused retry preserves the original
upstream error response and does not add a send.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
`GET /api/usage` reads `~/.opencodex/usage.jsonl` from the beginning through the current ledger
snapshot on a cold start. It processes fixed 1 MiB chunks and retains compact aggregate state rather
than every normalized request row. Later refreshes validate the previous line boundary and fold only
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ru/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ GUI-сессия в стиле loopback не выпускается.

Если строка превышает существующий лимит размера парсера, `GET /api/usage` и `GET /api/keys` сохраняют агрегаты читаемых строк и добавляют в ответ `usageIncomplete: true` и `usageIncompleteReason: "oversized_rows"`. Диагностика сохраняется в кеше и при инкрементальных добавлениях, в том числе для пустых результатов и отсутствующих совпадений; при перестроении она вычисляется заново. Идентификаторы провайдеров, моделей и API-ключей не сокращаются. Отсутствие флага не доказывает корректность всех строк. Это отдельный сигнал от `historyTruncated`, `entriesTruncated` и покрытия измерений токенов.

Правила вычисления `accountLogLabel` для API-ключей, выбора ключа и учёта использования при повторных запросах, а также суммирования плоского массива `attempts` приведены в [канонической спецификации на английском языке](/reference/management-api/#api-key-usage-attribution). Итог родительского комбо не добавляется повторно; отсутствующие данные использования и старые записи без меток не восстанавливаются предположениями. Квоты подписки измеряются отдельно.

Строки в `models`, `providers` и `days[].models` также содержат `cacheHitRate` — долю входных
токенов, полученных из кэша промптов провайдера и ограниченную диапазоном `[0, 1]`. Значение равно
`null`, а не `0`, если провайдер не передал телеметрию кэша или в строке нет входных токенов: отсутствие
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ Authorization: Bearer <admin-token>

如果某行超过现有解析器的大小限制,`GET /api/usage` 和 `GET /api/keys` 会保留可读取行的汇总,并在响应级别添加 `usageIncomplete: true` 和 `usageIncompleteReason: "oversized_rows"`。缓存和增量追加会保留该诊断,即使结果为空或没有筛选匹配;重建时会重新计算。不会缩短供应商、模型或 API 密钥标识来容纳该行。没有此标记不代表所有记录均有效。它与 `historyTruncated`、`entriesTruncated` 及 token 测量覆盖率相互独立。

API 密钥的 `accountLogLabel` 生成方式、重试时的密钥选择与用量记录,以及扁平 `attempts` 的汇总规则,以[英文用量归属规范](/reference/management-api/#api-key-usage-attribution)为准。不要重复累加组合的父级总量,也不要推断未报告的用量或历史无标签记录。订阅配额是单独观测的数据。

`models`、`providers` 和 `days[].models` 中的记录也带有 `cacheHitRate`:它表示由提供方提示缓存提供的输入 token 比例,并限制在 `[0, 1]` 范围内。当提供方未报告缓存遥测数据或该记录没有输入 token 时,其值为 `null`,绝不会是 `0`,因为“没有缓存数据”与“实际命中率为 0%”是不同的事实,将两者显示为相同结果的图表会产生误导。

:::caution
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
}
},
"explicit": {
"key-attribution.test.ts": "usage",
"responses-core-modules.test.ts": "responses",
"chat-responses-control-integration.test.ts": "responses",
"coding-agent-tool-result-images.test.ts": "adapters",
Expand Down Expand Up @@ -548,6 +549,7 @@
"combos.test.ts": "codex-integration",
"command-code-error-finish.test.ts": "providers",
"command-code-provider.test.ts": "providers",
"command-code-retry.test.ts": "providers",
"command-code-quota.test.ts": "providers",
"command-code-workspace-cache.test.ts": "providers",
"commandcode-provider.test.ts": "providers",
Expand Down
10 changes: 9 additions & 1 deletion src/adapters/command-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./bas
import type { TranslatorBudget } from "../lib/translator-budget";
import { readBoundedResponseBody } from "../lib/bounded-body";
import { debugDroppedFrame } from "../lib/debug";
import { SendBudgetExhaustedError } from "../lib/upstream-retry";
import { configuredReasoningEfforts } from "../reasoning-effort";
import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from "../providers/command-code-efforts";
import { identifyRoutedModel } from "./identity";
Expand Down Expand Up @@ -469,7 +470,7 @@ async function fetchCommandCode(request: AdapterRequest, ctx: AdapterFetchContex
const timer = setTimeout(() => timeout.abort(new DOMException("Timeout elapsed", "TimeoutError")), ctx?.timeoutMs ?? 200_000);
const callerSignal = ctx?.abortSignal ?? new AbortController().signal;
try {
return await executor(request.url, {
return await (ctx?.executor ?? executor)(request.url, {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
method: request.method,
headers: request.headers,
body: request.body,
Expand Down Expand Up @@ -556,6 +557,9 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
};
},
async fetchResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response> {
// The outer caller records the entry send but does not reserve adapter-owned dispatches.
const initial = ctx?.sendBudget?.reserveDispatch({ sendClass: "initial", targetKey: request.url });
if (initial && (!initial.allowed || !initial.permit.use())) throw new SendBudgetExhaustedError(request.url);
const response = await fetchCommandCode(request, ctx, executor);
if (response.ok) return response;
const currentEffort = (() => {
Expand All @@ -577,7 +581,11 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
if (!refreshed || refreshed.includes(currentEffort)) return response;
const retry = requestWithoutReasoningEffort(request);
if (!retry) return response;
const decision = ctx?.sendBudget?.reserveDispatch({ sendClass: "repair", targetKey: retry.url });
if (decision && (!decision.allowed || !decision.permit.use())) return response;
try { void response.body?.cancel(); } catch { /* already closed */ }
// The caller owns the entry send; this adapter owns only its additional retry.
ctx?.onPhysicalSend?.({ ordinal: 2, recovery: "reasoning-effort-downgrade" });
return fetchCommandCode(retry, ctx, executor);
},
async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
Expand Down
17 changes: 14 additions & 3 deletions src/codex/account-label.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { createHash, randomBytes } from "node:crypto";
import type { CodexAccount, OcxConfig } from "../types";
import type { CodexAuthContext } from "./auth-context";
import type { ProviderApiKeySelection } from "../types/provider";
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";

export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;

/**
* Account log labels come in two families (#2699):
* Account log labels come in three families:
*
* - `p<hex6>` (plus the literal `main`) — a Codex pool account.
* - `o<hex6>` — a non-Codex OAuth provider account (xai, cursor, and siblings).
* - `k<hex32>` — a request-owned API-key selection, scoped to provider and reference.
*
* Both are sha256-derived digests, never an email and never a raw provider account id. That is
* Labels never contain an email, raw key/reference, or raw provider account id. That is
* a privacy requirement, not a formatting preference: these labels are written to the usage log
* and served over the management API.
*
Expand All @@ -20,7 +22,16 @@ export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;
* accepted cost of keeping the existing `p` format byte-compatible.
*/
export const OAUTH_ACCOUNT_LOG_LABEL_RE = /^o[a-f0-9]{6}$/;
export const ACCOUNT_LOG_LABEL_RE = /^(?:main|[po][a-f0-9]{6})$/;
export const KEY_ACCOUNT_LOG_LABEL_RE = /^k[a-f0-9]{32}$/;
export const ACCOUNT_LOG_LABEL_RE = /^(?:main|[po][a-f0-9]{6}|k[a-f0-9]{32})$/;

/** Digest the request-owned configured selection, never serialize its key/reference. */
export function apiKeyAccountLogLabel(provider: string, selection: ProviderApiKeySelection | undefined): `k${string}` | undefined {
if (!selection || typeof selection.reference !== "string" || !selection.reference.length) return undefined;
return `k${createHash("sha256").update(JSON.stringify([
"ocx-key-account-v1", provider, selection.entryId ?? null, selection.reference,
])).digest("hex").slice(0, 32)}`;
}

export function oauthAccountLogLabel(accountId: string, provider = ""): string {
return `o${createHash("sha256").update(`${provider}\0${accountId}`).digest("hex").slice(0, 6)}`;
Expand Down
20 changes: 19 additions & 1 deletion src/providers/label.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import { CODEX_ACCOUNT_LOG_LABEL_RE, oauthAccountLogLabel } from "../codex/account-label";
import { CODEX_ACCOUNT_LOG_LABEL_RE, KEY_ACCOUNT_LOG_LABEL_RE, apiKeyAccountLogLabel, oauthAccountLogLabel } from "../codex/account-label";
import type { OcxProviderConfig } from "../types";

export function canonicalUsageProviderLabel(provider: string): string {
return provider === "chatgpt" || provider === "openai-multi" ? "openai" : provider;
}

export function usesApiKeyAccount(provider: Pick<OcxProviderConfig, "authMode" | "_apiKeyAttempt">): boolean {
return provider.authMode === "key"
|| (provider.authMode === undefined && !!provider._apiKeyAttempt?.reference);
}

/** Key identity comes from the captured selection, before env/keychain resolution. */
export function stampApiKeyAccountLabel(
logCtx: { accountLogLabel?: string },
providerName: string,
provider: Pick<OcxProviderConfig, "authMode" | "_apiKeyAttempt">,
): void {
if (usesApiKeyAccount(provider)) {
logCtx.accountLogLabel = apiKeyAccountLogLabel(providerName, provider._apiKeyAttempt);
} else if (KEY_ACCOUNT_LOG_LABEL_RE.test(logCtx.accountLogLabel ?? "")) {
delete logCtx.accountLogLabel;
}
}

export function baseProviderLabel(provider: string): string {
const canonical = canonicalUsageProviderLabel(provider);
if (canonical !== provider) return canonical;
Expand Down
22 changes: 15 additions & 7 deletions src/server/chat-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ import { linkAbortSignal } from "./responses";
import {
addFinalRequestLog,
beginRequestAttempt,
noteAttemptSend,
noteProviderAttemptSend,
recordKeyAttemptFailure,
recordKeyWireAttemptUsage,
recordFirstOutput,
recordAttemptCredentialSource,
sealRequestAttemptIdentity,
Expand Down Expand Up @@ -344,10 +346,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
const encoding = new Headers(init.headers).get("accept-encoding");
if (!headers.has("accept-encoding") && encoding) headers.set("accept-encoding", encoding);
if (init.signal?.aborted) throw init.signal.reason;
noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
return ((activeProvider as OcxProviderTransport).fetch ?? execute)(request.url, applyUpstreamRecoveryInit({
noteProviderAttemptSend(logCtx, route.providerName, activeProvider, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
const dispatched = await ((activeProvider as OcxProviderTransport).fetch ?? execute)(request.url, applyUpstreamRecoveryInit({
...init, method: request.method, headers, body: request.body,
}, transportRecovery));
if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal);
return dispatched;
},
}),
);
Expand Down Expand Up @@ -509,8 +513,10 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
stallTimeoutSec: config.stallTimeoutSec,
onFirstOutput: logIds ? () => recordFirstOutput(logCtx, logIds.start) : undefined,
onUsage: usage => {
logCtx.usage = usage;
attempt.usage = usage;
if (!recordKeyWireAttemptUsage(logCtx, usage)) {
logCtx.usage = usage;
attempt.usage = usage;
}
},
onTerminal: (status: number, message?: string) => {
terminalStatus = status;
Expand Down Expand Up @@ -600,8 +606,10 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
if (!completion) return fail(502, "upstream response contained no choices", "upstream_error");
const usage = usageFromChat(completion.usage);
if (usage) {
logCtx.usage = usage;
attempt.usage = usage;
if (!recordKeyWireAttemptUsage(logCtx, usage)) {
logCtx.usage = usage;
attempt.usage = usage;
}
}
if (logIds) recordFirstOutput(logCtx, logIds.start);
try {
Expand Down
Loading
Loading