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
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ additional permissions. Native passthrough and compaction retain their raw-body
provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local), and more.
**Auth:** `key` (Bearer).

For xAI, the resolved upstream adapter can be `openai-chat` or `openai-responses`,
depending on model defaults and explicit `modelAdapters` overrides. Both support
public xAI API-key authentication and Grok CLI OAuth. The usage log's
[`attempts[].credentialSource`](/reference/management-api/) follows that resolved
transport; it does not infer subscription attribution from the inbound protocol.

- Converts internal messages to OpenAI roles; maps tools to `{type:"function", function:{…}}` and
`tool_choice` (`auto`/`none`/`required` or a named function).
- **Tool-result images** ride in a follow-up user vision message (`image_url` parts) released once
Expand Down
8 changes: 8 additions & 0 deletions docs-site/src/content/docs/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou
| `POST /api/storage/cleanup-policy/run` | Start a manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` |
| `GET /api/storage/cleanup-policy/test-stream` | Test-only policy stream hook | 404 `not_found` when unavailable |

New xAI attempts in `usage.jsonl` include a request-time `credentialSource`: `grok-oauth`
for the resolved Grok CLI OAuth transport, or `xai-api-key` for the public xAI API key
transport. This fixed label contains no credential or account identifier. It belongs to
each item in `attempts`, so a combo's aggregate token total must not be attributed to its
final provider. Custom destinations and historic rows omit the field; consumers must not
infer subscription usage from the current configuration, model name, or inbound API key.
The log reports usage, not subscription invoice amounts.

`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
20 changes: 12 additions & 8 deletions src/server/chat-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
beginRequestAttempt,
noteAttemptSend,
recordFirstOutput,
recordAttemptCredentialSource,
sealRequestAttemptIdentity,
type RequestLogContext,
} from "./request-log";
Expand Down Expand Up @@ -183,14 +184,17 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
translatorBudget.chargeRetained(bytes, { kind: "request_copies" });
retainedRequestBytes = bytes;
};
const buildActiveRequest = () => buildOpenAIChatPassthroughRequest(
activeProvider,
options.chatBody,
route.modelId,
requestedStream,
fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"),
config.fastMode,
);
const buildActiveRequest = () => {
recordAttemptCredentialSource(attempt, route.providerName, activeProvider, activeAdapter.name);
return buildOpenAIChatPassthroughRequest(
activeProvider,
options.chatBody,
route.modelId,
requestedStream,
fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"),
config.fastMode,
);
};
try {
activeRequest = buildActiveRequest();
retainRequest(activeRequest);
Expand Down
30 changes: 29 additions & 1 deletion src/server/request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "../lib/errors";
import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
import { readCodexCatalogPath } from "../codex/catalog";
import type { AttemptTierOutcome, OcxUsage } from "../types";
import type { AttemptTierOutcome, OcxProviderConfig, OcxUsage } from "../types";
import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace";
import type { AdapterRequest } from "../adapters/base";
import type { AdapterTierMetadata } from "../providers/fastwire";
Expand Down Expand Up @@ -1211,11 +1211,39 @@ export function sealRequestAttemptIdentity(
accountLogLabel?: string,
): void {
if (!attempt) return;
if (attempt.provider !== provider || attempt.adapter !== adapter) delete attempt.credentialSource;
attempt.provider = provider;
attempt.adapter = adapter;
if (isCodexUsageAccountLogLabel(accountLogLabel)) attempt.accountLogLabel = accountLogLabel;
}

/** Capture only the resolved upstream route; inbound auth and today's config cannot label old usage. */
export function recordAttemptCredentialSource(
attempt: PersistedUsageAttempt | undefined,
providerName: string,
provider: Pick<OcxProviderConfig, "authMode" | "baseUrl" | "adapter">,
adapterName: string = provider.adapter,
): void {
if (!attempt) return;
// Rebinding an attempt to an unrecognized route must not retain its previous attribution.
delete attempt.credentialSource;
if (providerName !== "xai"
|| !["openai-chat", "openai-responses"].includes(adapterName)) return;
try {
const url = new URL(provider.baseUrl ?? "");
if (url.protocol !== "https:" || url.port || url.username || url.password
|| url.search || url.hash || !["/v1", "/v1/"].includes(url.pathname)) return;
if (provider.authMode === "oauth" && url.hostname === "cli-chat-proxy.grok.com") {
attempt.credentialSource = "grok-oauth";
} else if ((provider.authMode === "key" || provider.authMode === undefined)
&& url.hostname === "api.x.ai") {
attempt.credentialSource = "xai-api-key";
}
} catch {
// Invalid/custom destinations have no known subscription provenance.
}
}

export function noteAttemptSend(
attempt: PersistedUsageAttempt | undefined,
inputTokenEstimate: number | undefined,
Expand Down
12 changes: 12 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ import {
recordAttemptRequestedEffort,
requestLogSpeedLabel,
sealRequestAttemptIdentity,
recordAttemptCredentialSource,
usageFromResponsesPayload,
type RequestLogContext,
} from "../request-log";
Expand Down Expand Up @@ -1422,6 +1423,7 @@ async function retryCodexPoolOnAlternateAccount(
retryAdapter.name,
logCtx.accountLogLabel,
);
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, retryAdapter.name);

const retrySameConfirmedAccount = outcomeStatus === 400
&& ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId)
Expand Down Expand Up @@ -3925,6 +3927,7 @@ async function handleResponsesInner(
(logCtx.attempts ??= []).push(attempt);
}
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name, logCtx.accountLogLabel);
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, adapterProvider, adapter.name);
let runTurnAdapter = adapter;
if (adapter.runTurn) {
recordAdapterTierMetadata(logCtx, adapter.tierLogForRunTurn?.(parsed));
Expand Down Expand Up @@ -4590,6 +4593,7 @@ async function handleResponsesInner(
retryAdapter.name,
logCtx.accountLogLabel,
);
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, retryAdapter.name);
const rebuiltBodyRefusal = refuseOversizedOutboundBody(request);
if (rebuiltBodyRefusal) return { failed: rebuiltBodyRefusal };
try {
Expand Down Expand Up @@ -4678,6 +4682,7 @@ async function handleResponsesInner(
});
logCtx.providerAdapter = replayAdapter.name;
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, replayAdapter.name, logCtx.accountLogLabel);
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, replayAdapter.name);
try {
request = await replayAdapter.buildRequest(parsed, {
headers: selectedForwardHeaders,
Expand Down Expand Up @@ -4797,6 +4802,7 @@ async function handleResponsesInner(
refreshedAdapter.name,
logCtx.accountLogLabel,
);
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, refreshedAdapter.name);
try {
request = await refreshedAdapter.buildRequest(parsed, {
headers: selectedForwardHeaders,
Expand Down Expand Up @@ -6123,6 +6129,7 @@ async function handleResponsesInner(
forwardHeaders: selectedForwardHeaders,
});
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, rotatedAdapter.name, logCtx.accountLogLabel);
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, rotatedAdapter.name);
return true;
} catch {
return false;
Expand Down Expand Up @@ -6542,6 +6549,7 @@ async function handleResponsesInner(
if (retryEstimate !== undefined) logCtx.usageLogInputTokens = retryEstimate;
logCtx.providerAdapter = activeAdapter.name;
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name);
noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
try {
try {
Expand Down Expand Up @@ -6789,6 +6797,7 @@ async function handleResponsesInner(
config.cacheRetention,
);
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name);
const result = await rebuildAndRefetch("anthropic-oauth-429");
if ("failed" in result) return result.failed;
upstreamResponse = result;
Expand Down Expand Up @@ -6832,6 +6841,7 @@ async function handleResponsesInner(
config.cacheRetention,
);
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name);
const result = await rebuildAndRefetch("oauth-account-429");
if ("failed" in result) return result.failed;
upstreamResponse = result;
Expand Down Expand Up @@ -7199,6 +7209,7 @@ async function handleResponsesInner(
config.cacheRetention,
);
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name);
nextContinuationRecoveryKind = "anthropic-oauth-429";
continue;
} catch {
Expand Down Expand Up @@ -7240,6 +7251,7 @@ async function handleResponsesInner(
config.cacheRetention,
);
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, activeAdapter.name);
nextContinuationRecoveryKind = "oauth-account-429";
continue;
}
Expand Down
9 changes: 9 additions & 0 deletions src/usage/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,14 @@ export type AttemptRecoveryKind =
| "opaque-blob-rejection"
| "empty-completion";

/** Request-time upstream credential class, never a credential or account identifier. */
export type UsageCredentialSource = "grok-oauth" | "xai-api-key";

export interface PersistedUsageAttempt {
ordinal: number;
provider: string;
/** Absent on historic attempts and routes whose subscription attribution is unknown. */
credentialSource?: UsageCredentialSource;
model: string;
adapter: string;
status: number;
Expand Down Expand Up @@ -400,6 +405,10 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
return {
ordinal: attempt.ordinal as number,
provider: attempt.provider,
...(attempt.provider === "xai"
&& (attempt.credentialSource === "grok-oauth" || attempt.credentialSource === "xai-api-key")
? { credentialSource: attempt.credentialSource }
: {}),
model: attempt.model,
adapter: attempt.adapter,
status: attempt.status,
Expand Down
102 changes: 101 additions & 1 deletion tests/server/server-xai-oauth-401-replay.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync} from "node:fs";
import { mkdtempSync, readFileSync} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { saveConfig } from "../../src/config";
import { XAI_OAUTH_DISCOVERY_URL } from "../../src/oauth/xai";
import { saveCredential } from "../../src/oauth/store";
import { XAI_GROK_CLI_BASE_URL } from "../../src/providers/xai-transport";
import { readUsageEntries, usageLogPath } from "../../src/usage/log";
import { startServer } from "../../src/server";
import type { OcxConfig } from "../../src/types";
import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home";
Expand Down Expand Up @@ -209,6 +210,14 @@ describe("xAI OAuth Responses opt-in upstream 401 replay", () => {
expect(json.output?.find(item => item.type === "message")?.content?.[0]?.text).toBe("ok after refresh");
expect(observed.counts.refresh).toBe(1);
expect(observed.chatAuth).toEqual(["Bearer rejected-access", "Bearer fresh-access"]);
const attempt = readUsageEntries().at(-1)?.attempts?.[0];
expect(attempt?.credentialSource).toBe("grok-oauth");
expect(attempt?.sendCount).toBe(2);
expect(attempt?.totalTokens).toBe(5);
const persisted = readFileSync(usageLogPath(), "utf8");
expect(persisted).not.toContain("rejected-access");
expect(persisted).not.toContain("fresh-access");
expect(persisted).not.toContain("xai-test-account");
} finally {
await server.stop(true);
}
Expand All @@ -231,6 +240,96 @@ describe("xAI OAuth Responses opt-in upstream 401 replay", () => {
}
});

test("native Chat records canonical API-key provenance", async () => {
saveConfig(xaiConfig("key"));
globalThis.fetch = (async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
expect(url).toBe("https://api.x.ai/v1/chat/completions");
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer xai-api-key");
return Response.json({
id: "chat-native-xai", object: "chat.completion", model: "grok-4.5",
choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
});
}) as typeof fetch;
const server = startServer(0);
try {
const response = await originalFetch(new URL("/v1/chat/completions", server.url), {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "xai/grok-4.5", messages: [{ role: "user", content: "hello" }], stream: false }),
});
expect(response.status).toBe(200);
await response.json();
const entry = readUsageEntries().at(-1);
expect(entry?.inboundProtocol).toBe("chat");
expect(entry?.attempts?.[0]?.credentialSource).toBe("xai-api-key");
expect(entry?.attempts?.[0]?.totalTokens).toBe(5);
expect(entry?.attempts?.[0]?.sendCount).toBe(1);
} finally {
await server.stop(true);
}
});

test("native Chat 429 rotates configured apiKeyPool and keeps canonical xAI source", async () => {
const firstKey = "xai-pool-key-alpha-000111222333";
const secondKey = "xai-pool-key-beta-444555666777";
saveConfig({
...xaiConfig("key"),
providers: {
xai: {
adapter: "openai-chat",
baseUrl: "https://api.x.ai/v1",
authMode: "key",
apiKey: firstKey,
apiKeyPool: [
{ id: "k1", key: firstKey, addedAt: 1 },
{ id: "k2", key: secondKey, addedAt: 2 },
],
models: ["grok-4.5"],
},
},
} as OcxConfig);
const seenAuth: string[] = [];
globalThis.fetch = (async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
expect(url).toBe("https://api.x.ai/v1/chat/completions");
seenAuth.push(new Headers(init?.headers).get("authorization") ?? "");
if (seenAuth.length === 1) {
return new Response(JSON.stringify({ error: { message: "rate limited" } }), {
status: 429,
headers: { "retry-after": "30", "content-type": "application/json" },
});
}
return Response.json({
id: "chat-native-xai-rotate", object: "chat.completion", model: "grok-4.5",
choices: [{ index: 0, message: { role: "assistant", content: "ok after rotate" }, finish_reason: "stop" }],
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
});
}) as typeof fetch;
const server = startServer(0);
try {
const response = await originalFetch(new URL("/v1/chat/completions", server.url), {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "xai/grok-4.5", messages: [{ role: "user", content: "hello" }], stream: false }),
});
expect(response.status).toBe(200);
await response.json();
expect(seenAuth).toEqual([`Bearer ${firstKey}`, `Bearer ${secondKey}`]);
const entries = readUsageEntries();
expect(entries).toHaveLength(1);
const attempt = entries[0]?.attempts?.[0];
expect(entries[0]?.attempts).toHaveLength(1);
expect(attempt?.credentialSource).toBe("xai-api-key");
expect(attempt?.sendCount).toBe(2);
expect(attempt?.adapter).toBe("openai-chat");
const persisted = readFileSync(usageLogPath(), "utf8");
expect(persisted).not.toContain(firstKey);
expect(persisted).not.toContain(secondKey);
} finally {
await server.stop(true);
}
});

test("API-key xAI path never attempts OAuth refresh", async () => {
saveConfig(xaiConfig("key"));
let refreshCalls = 0;
Expand All @@ -257,6 +356,7 @@ describe("xAI OAuth Responses opt-in upstream 401 replay", () => {
expect(response.status).toBe(401);
expect(chatCalls).toBe(1);
expect(refreshCalls).toBe(0);
expect(readUsageEntries().at(-1)?.attempts?.[0]?.credentialSource).toBe("xai-api-key");
} finally {
await server.stop(true);
}
Expand Down
Loading
Loading