diff --git a/.github/pr-assets/260922-models-tab-status.png b/.github/pr-assets/260922-models-tab-status.png new file mode 100644 index 00000000000..cf10d22d635 Binary files /dev/null and b/.github/pr-assets/260922-models-tab-status.png differ diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 7d099bd877f..0dabe8ea505 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -296,9 +296,8 @@ export default function Models({ apiBase, restartEpoch = 0, catalogSyncedAt, rep pickerFlight.current?.controller.abort(); pickerFlight.current?.clear(); pickerFlight.current = null; - cancelAppServerRead(); }; - }, [apiBase, catalogActive, cancelAppServerRead]); + }, [apiBase, catalogActive]); useLayoutEffect(() => { // Pin inferred Custom before any late GET can switch mode and unmount its draft. if (catalogActive && pickerDraft === null && pickerMode === "custom") setPickerDraft("custom"); diff --git a/gui/tests/models-status-toast.test.tsx b/gui/tests/models-status-toast.test.tsx index 66bb641359c..0622e41023a 100644 --- a/gui/tests/models-status-toast.test.tsx +++ b/gui/tests/models-status-toast.test.tsx @@ -613,6 +613,31 @@ test("leaving Models aborts its pending picker save", async () => { expect(container.querySelector(".action-toast")).toBeNull(); }); +test("changing Models tabs preserves the pending app-server status read", async () => { + const baseFetch = globalThis.fetch; + let statusSignal: AbortSignal | null | undefined; + let releaseStatus!: (response: Response) => void; + globalThis.fetch = (async (input, init) => { + if (String(input).endsWith("/api/system/codex-app-server")) { + statusSignal = init?.signal; + return new Promise(resolve => { releaseStatus = resolve; }); + } + return baseFetch(input, init); + }) as typeof fetch; + + await mountModelsForRefreshWarning(); + await waitForModelsFeedback(() => releaseStatus !== undefined); + const combosTab = [...container.querySelectorAll('[role="tab"]')] + .find(button => button.textContent?.startsWith("Combos")); + expect(combosTab).toBeDefined(); + await act(async () => { combosTab!.click(); }); + + expect(statusSignal?.aborted).toBe(false); + await act(async () => { releaseStatus(Response.json({ state: "stale", runningCount: 1 })); }); + await waitForModelsFeedback(() => container.querySelector(".codex-stale-banner") !== null); + expect(container.querySelector(".codex-stale-banner")).not.toBeNull(); +}); + function holdPostSaveAppServerRead() { const baseFetch = globalThis.fetch; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7117b4679ea..ce5d2bf714b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -335,6 +335,7 @@ "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-deferred-tools.test.ts": "responses", "chat-completions-endpoint.test.ts": "responses", + "chat-native-spend.test.ts": "responses", "chat-conversation-affinity.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", diff --git a/src/cli/access.ts b/src/cli/access.ts index 9cc0ec091a2..dc541db5ce2 100644 --- a/src/cli/access.ts +++ b/src/cli/access.ts @@ -38,6 +38,13 @@ const USAGE = `Usage: */ function formatKeyRows(payload: Record, keys: Array>): string[] { const cells: string[][] = [["ID", "NAME", "PREFIX", "REQ 7D", "TOTAL", "LAST USED"]]; + // A string that does not parse is not attribution data: treat it like an absent + // field so malformed payloads still render "unavailable" instead of usage values. + const attributionSince = typeof payload.attributionSince === "string" + && !Number.isNaN(Date.parse(payload.attributionSince)) + ? payload.attributionSince + : undefined; + const usageAvailable = attributionSince !== undefined; for (const entry of keys) { const usage = (entry.usage ?? {}) as Record; const ambiguous = usage.ambiguous === true; @@ -47,16 +54,16 @@ function formatKeyRows(payload: Record, keys: Array Math.max(...cells.map(row => (row[column] ?? "").length))); const lines = cells.map(row => row.map((cell, i) => (cell ?? "").padEnd(widths[i]!)).join(" ").trimEnd()); const footer: string[] = []; - if (typeof payload.attributionSince === "string") { - footer.push(`attribution since ${payload.attributionSince}`); + if (attributionSince !== undefined) { + footer.push(`attribution since ${attributionSince}`); } if (payload.historyTruncated === true) { footer.push("older history truncated"); diff --git a/src/providers/label.ts b/src/providers/label.ts index 38b472a1a05..10f1a09e705 100644 --- a/src/providers/label.ts +++ b/src/providers/label.ts @@ -5,6 +5,8 @@ export function canonicalUsageProviderLabel(provider: string): string { return provider === "chatgpt" || provider === "openai-multi" ? "openai" : provider; } +const LEGACY_MAIN_ACCOUNT_PROVIDER_LABELS = new Set(["openai-main", "chatgpt-main", "openai-multi-main"]); + export function usesApiKeyAccount(provider: Pick): boolean { return provider.authMode === "key" || (provider.authMode === undefined && !!provider._apiKeyAttempt?.reference); @@ -29,11 +31,14 @@ export function baseProviderLabel(provider: string): string { const cut = provider.lastIndexOf("-"); if (cut <= 0) return canonicalUsageProviderLabel(provider); const suffix = provider.slice(cut + 1); - // `-main` is the legacy log label for the main Codex account (MAIN_CODEX_ACCOUNT_ID). New entries - // log under the base provider name, but historical `-main` entries must still collapse. + // `-main` was the legacy log label for the main Codex account (MAIN_CODEX_ACCOUNT_ID). Restrict + // that compatibility mapping to the known Codex provider labels so configured providers whose + // names naturally end in `-main` remain distinct. // ChatGPT auth-pool and OpenAI passthrough are the same Codex/OpenAI usage surface, so display // summaries normalize them to one `openai` row after recognized main/pool suffixes are removed. - if (suffix === "main") return canonicalUsageProviderLabel(provider.slice(0, cut)); + if (LEGACY_MAIN_ACCOUNT_PROVIDER_LABELS.has(provider)) { + return canonicalUsageProviderLabel(provider.slice(0, cut)); + } return CODEX_ACCOUNT_LOG_LABEL_RE.test(suffix) ? canonicalUsageProviderLabel(provider.slice(0, cut)) : provider; } diff --git a/src/routing/history/indexer.ts b/src/routing/history/indexer.ts index 186860fa943..3ac745ebfba 100644 --- a/src/routing/history/indexer.ts +++ b/src/routing/history/indexer.ts @@ -22,6 +22,7 @@ import { getConfigDir } from "../../config"; import { recordOwnedConfigPath } from "../../lib/config-ownership"; import { currentUsageLogRevision, + encodePersistedRequestedModel, normalizeUsageEntryForTest, usageLogPath, type PersistedUsageEntry, @@ -510,7 +511,9 @@ function queryRows( }; if (filters.provider !== undefined) add("provider = ?", filters.provider); if (filters.model !== undefined) add("model = ?", filters.model); - if (filters.requestedModel !== undefined) add("requested_model = ?", filters.requestedModel); + // Rows store the bounded encoded form, so the lookup value must be encoded the + // same way — short selectors encode to themselves and still match verbatim. + if (filters.requestedModel !== undefined) add("requested_model = ?", encodePersistedRequestedModel(filters.requestedModel)); if (filters.status !== undefined) add("status = ?", filters.status); if (filters.conversationId !== undefined) add("conversation_id = ?", filters.conversationId); if (filters.surface !== undefined) add("surface = ?", filters.surface); diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 3cb6c9a3798..083428f295d 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -199,7 +199,18 @@ async function handleChatCompletionsWithBudget( } // Combos must enter the Responses routing path so child selection, forced default // effort, failover, and per-attempt telemetry run before any native Chat send. - if (!route.combo && !effortRow && isNativeChatRouteEligible(route, chatBody, config)) chatNativeRoute = route; + if (!route.combo && !effortRow && isNativeChatRouteEligible(route, chatBody, config)) { + chatNativeRoute = route; + if (logCtx.usageLogInputTokens === undefined) { + const parts = [JSON.stringify(chatBody.messages ?? [])]; + if (chatBody.tools !== undefined) parts.push(JSON.stringify(chatBody.tools)); + logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel)); + } + const outputCeiling = chatBody.max_completion_tokens ?? chatBody.max_tokens; + if (typeof outputCeiling === "number" && outputCeiling > 0) { + logCtx.spendOutputCeilingTokens = Math.trunc(outputCeiling); + } + } } catch (err) { if (err instanceof AdmissionModelDeniedError) { logCtx.requestedModel = requestedModel; diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 71ef323ebf5..4d759fbef5d 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -48,6 +48,7 @@ import { transientRetryPolicyFor, } from "../providers/key-failover"; import { fastPolicyForModel } from "../providers/service-tier"; +import { stampApiKeyAccountLabel } from "../providers/label"; import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection"; import { enrichOpenCodeZenFreeTierMessage } from "../providers/opencode-zen-rate-limit"; import type { OcxProviderTransport } from "../providers/xai-transport"; @@ -68,12 +69,16 @@ import { } from "./request-log"; import { jsonCompletionSse, nativeChatSse, structuredError, usageFromChat } from "./chat-native-sse"; import { registerTurn, unregisterTurn } from "./lifecycle"; +import { attachRequestSpendTracker } from "./responses/request-spend"; +import { workflowRefusalResponse } from "./workflow-refusal"; type Rec = Record; const MAX_NATIVE_CHAT_JSON_BYTES = 32 * 1024 * 1024; const MAX_NATIVE_CHAT_ERROR_BYTES = 64 * 1024; +class NativeChatSpendRefusal extends Error {} + const chatEffortSnapshots = new WeakMap {}); } catch { /* already closed */ } activeProvider = rotated; + stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider); activeAdapter = createOpenAIChatAdapter(activeProvider); releaseRetainedRequest(); activeRequest = buildActiveRequest(); @@ -443,6 +453,11 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio cleanupAbort(); upstream.abort(); if (req.signal.aborted) return fail(499, "Client cancelled request", "client_cancelled"); + if (error instanceof NativeChatSpendRefusal) { + const refusal = workflowRefusalResponse("workflow-spend-exhausted", logCtx); + finishLog(429); + return refusal; + } if (isTranslatorBudgetExceededError(error)) { return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); } diff --git a/src/usage/log.ts b/src/usage/log.ts index 6955db2073a..bf54a0727f3 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -222,8 +222,33 @@ export interface PersistedRequestSpend extends RequestSpendTotals { } const MAX_PERSISTED_MOVE_REASONS = 8; +// Model selectors are NOT length-bound at admission: configured and discovered +// model ids reach MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH, and the wire `model` +// field is raw client input. Persisting a plain prefix would merge selectors +// that share it, so over-long selectors persist as prefix + a digest of the +// FULL selector — bounded, deterministic, and still exact-matchable. +const MAX_PERSISTED_REQUESTED_MODEL_LEN = 130; +const REQUESTED_MODEL_DIGEST_HEX_LEN = 16; const LOGICAL_REQUEST_ID_RE = /^[A-Za-z0-9_.:-]{1,64}$/; +/** + * Persisted form of the wire model selector. Selectors within the bound persist + * verbatim; longer selectors persist as a prefix plus a short digest of the full + * value, so two distinct selectors that share the prefix never collapse into one + * persisted identity. Exact-match readers (`requested_model = ?`) must encode + * lookup input through this same function. Idempotent — encoded forms fit the + * bound — which matters because rows are normalized again on read. + */ +export function encodePersistedRequestedModel(selector: string): string { + if (selector.length <= MAX_PERSISTED_REQUESTED_MODEL_LEN) return selector; + const digest = createHash("sha256") + .update(selector) + .digest("hex") + .slice(0, REQUESTED_MODEL_DIGEST_HEX_LEN); + const prefixLen = MAX_PERSISTED_REQUESTED_MODEL_LEN - REQUESTED_MODEL_DIGEST_HEX_LEN - 1; + return `${selector.slice(0, prefixLen)}~${digest}`; +} + export function isLogicalRequestId(value: unknown): value is string { return typeof value === "string" && LOGICAL_REQUEST_ID_RE.test(value); } @@ -856,7 +881,9 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ? { conversationId: entry.conversationId.trim().slice(0, 128) } : {}), ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}), - ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}), + ...(typeof entry.requestedModel === "string" && entry.requestedModel + ? { requestedModel: encodePersistedRequestedModel(entry.requestedModel) } + : {}), ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}), ...(typeof entry.requestedEffort === "string" && entry.requestedEffort ? { requestedEffort: capMetadataString(entry.requestedEffort) } diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5be71c0bfbc..b202ddc583f 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -540,6 +540,11 @@ status, so an unexpected management response cannot add raw upstream material. > Decision record: [ADR-0078](decisions/ADR-0078-usage-accounting.md) +Requested selectors longer than 130 characters persist as a prefix plus a digest of the complete +selector; the request-history exact-match filter applies the same idempotent encoding. Serving-model +identities remain unchanged. Only historical Codex `openai`, `chatgpt` and `openai-multi` main labels +collapse for reporting; configured provider names ending in `-main` remain separate. CLI access-key +usage is unavailable without a valid attribution timestamp, rather than a measured zero or never-used key. `src/usage/log.ts` writes append-only JSONL to `~/.opencodex/usage.jsonl` with file mode `0o600` inside an owner-only `0o700` directory. Consecutive appends reuse the directory and permission check for at most one second; the first append at or after that boundary attempts to reapply both @@ -739,6 +744,8 @@ converge the Codex catalog once and return its disposition. The Models UI owns a picker data resource so failure cannot erase the ordinary model inventory; Apply publishes through the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode survives availability drift, while complete/native custom orders await explicit replacement. +The Models app-server status read is owned by its API-base/restart effect, not the picker tab; +switching to Combos preserves a pending read and its existing stale-state banner. The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`. Remote Workspace uses a separate, explicitly enabled server surface with structural WebSocket callbacks and awaited per-server cleanup; [its contract](remote-workspace.md) owns that integration and records its isolated owner and support limits. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index a82e8f8cb08..ad8aaebde34 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -1229,10 +1229,12 @@ target's own recovery decision, while the physical-send total is what binds ever The request's send budget bounds how many times it may reach upstream; the spend ledger bounds what those sends may cost, and it is the only bound here that survives a restart. Its production caller is `request-spend.ts`, installed on the execution budget at genuine ingress in `core.ts` -and parked on the log context so `addFinalRequestLog` can settle it. +and parked on the log context so `addFinalRequestLog` can settle it. Native Chat installs the +same tracker before its independent physical-send ladder and charges it immediately before each +dispatch, so taking that fast path cannot bypass root, identity, or provider-pool ceilings. -It books by observing the budget's own send counter rather than by being called from each -dispatch site. That counter moves exactly once per physical send — a reservation increments it, a +The Responses path books by observing its budget's own send counter rather than calling each +dispatch site; Native Chat directly charges messages, tool definitions and the output ceiling. That counter moves exactly once per physical send — a reservation increments it, a refund decrements it, and an externally reported send settles against a booking already counted — so one ledger entry per increment is one entry per send, and a dispatch path added later cannot forget to book. The previous attempt at this wiring shipped the whole reserve/dispatch/settle diff --git a/tests/cli/cli-dto-fidelity.test.ts b/tests/cli/cli-dto-fidelity.test.ts index 67cc148795a..9cfca772c69 100644 --- a/tests/cli/cli-dto-fidelity.test.ts +++ b/tests/cli/cli-dto-fidelity.test.ts @@ -216,6 +216,7 @@ describe("#2705 access key usage columns", () => { id: "k_9f2a", name: "ci-runner", prefix: "ocx_data_abc...", usage: { requests7d: 1204, totalRequests: 18330, lastUsedAt: "2026-08-27T04:11:00Z" }, }], + attributionSince: "2026-07-29T00:00:00Z", }); expect(out).toContain("REQ 7D"); expect(out).toContain("1,204"); @@ -229,6 +230,7 @@ describe("#2705 access key usage columns", () => { // use is the dangerous answer for someone deciding what to delete. const out = await listOutput({ keys: [{ id: "k_11bd", name: "laptop", prefix: "ocx_data_def...", usage: { ambiguous: true } }], + attributionSince: "2026-07-29T00:00:00Z", }); expect(out).toContain("ambiguous"); expect(out).not.toMatch(/\b0\b/); @@ -237,10 +239,30 @@ describe("#2705 access key usage columns", () => { test("a never-used key says never rather than showing an empty cell", async () => { const out = await listOutput({ keys: [{ id: "k_new", name: "fresh", prefix: "ocx_data_ghi...", usage: { requests7d: 0, totalRequests: 0 } }], + attributionSince: "2026-08-29T00:00:00Z", }); expect(out).toContain("never"); }); + test("unavailable attribution does not report zero usage or never used", async () => { + const out = await listOutput({ + keys: [{ id: "k_unknown", name: "unknown", prefix: "ocx_data_jkl...", usage: { requests7d: 0, totalRequests: 0 } }], + }); + expect(out).toContain("unavailable"); + expect(out).not.toMatch(/\b0\b/); + expect(out).not.toContain("never"); + }); + + test("a malformed attributionSince string stays unavailable", async () => { + const out = await listOutput({ + keys: [{ id: "k_bad", name: "bad", prefix: "ocx_data_mno...", usage: { requests7d: 0, totalRequests: 0 } }], + attributionSince: "not-a-timestamp", + }); + expect(out).toContain("unavailable"); + expect(out).not.toContain("attribution since"); + expect(out).not.toMatch(/\b0\b/); + }); + test("dataset-level attribution and truncation print ONCE as a footer", async () => { // They describe the usage log, not a key. Without attributionSince an absent lastUsedAt is // unreadable: "never used" and "nothing attributable yet" look identical. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1fd592857c0..5dd8523cb18 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -166,6 +166,7 @@ "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-deferred-tools.test.ts": "responses", "chat-completions-endpoint.test.ts": "responses", + "chat-native-spend.test.ts": "responses", "chat-conversation-affinity.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", diff --git a/tests/responses/chat-native-spend.test.ts b/tests/responses/chat-native-spend.test.ts new file mode 100644 index 00000000000..0fd9f5f2920 --- /dev/null +++ b/tests/responses/chat-native-spend.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { flushNativeMainStartupReleases } from "../../src/codex/native-profile-startup"; +import { startServer } from "../../src/server"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { spendLedgerOwnerSnapshot } from "../../src/lib/spend-ledger-owner"; +import { flushWindowsSecretAclReapsBeforeRemoval } from "../../src/lib/windows-secret-acl"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; + +let previousHome: string | undefined; +let testDir = ""; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let activeServer: ReturnType | undefined; +let activeUpstream: ReturnType | undefined; +let stopping: Promise | undefined; +function stopFixtureServers(): Promise { + // A timed-out body and its afterEach join one owner instead of racing two stops. + return stopping ??= (async () => { + try { await activeServer?.stop(true); } + finally { await activeUpstream?.stop(true); } + })(); +} +beforeEach(() => { + activeServer = undefined; + activeUpstream = undefined; + stopping = undefined; + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-chat-spend-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-chat-spend-")); + process.env.OPENCODEX_HOME = testDir; +}); +afterEach(async () => { + await stopFixtureServers(); + // Stop background owners before removing the home they can still harden/open. + await flushNativeMainStartupReleases(); + await flushConfigDirHardeningForTests(); + // A caller-facing ACL timeout is not evidence that its child released the path. + await flushWindowsSecretAclReapsBeforeRemoval(testDir); + if (isolatedCodexHome) await flushWindowsSecretAclReapsBeforeRemoval(isolatedCodexHome.path); + expect(spendLedgerOwnerSnapshot().ownership).toBe("unheld"); + resetProviderRequestPacingForTest(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); +}); + +function mockChatUpstreamCapturing() { + const captured: Array> = []; + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (!url.pathname.endsWith("/chat/completions")) { + return Response.json({ error: { message: `unexpected path ${url.pathname}` } }, { status: 404 }); + } + try { captured.push(await req.json() as Record); } catch { /* keep streaming */ } + const frames = [ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "Hello" } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: " from mock" } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 12, completion_tokens: 3 } })}\n\n`, + "data: [DONE]\n\n", + ]; + return new Response(frames.join(""), { headers: { "Content-Type": "text/event-stream" } }); + }, + }); + activeUpstream = server; + return { server, captured }; +} + +function mockConfig(baseUrl: string, providerOverrides: Partial = {}): OcxConfig { + return { + port: 0, + defaultProvider: "mock", + providers: { + mock: { + adapter: "openai-chat", + baseUrl, + apiKey: "k", + allowPrivateNetwork: true, + ...providerOverrides, + }, + }, + } as OcxConfig; +} + +test("native Chat refuses a physical send that exceeds the configured pool spend ceiling", async () => { + const upstream = mockChatUpstreamCapturing(); + const config = mockConfig(`${upstream.server.url.toString().replace(/\/$/, "")}/v1`); + config.spend = { pool: { maxTokens: 1 } }; + saveConfig(config); + const server = startServer(0); + activeServer = server; + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", messages: [{ role: "user", content: "hello" }] }), + }); + expect(response.status).toBe(429); + expect(response.headers.get("x-opencodex-local-refusal")).toBe("workflow_spend_exhausted"); + expect(upstream.captured).toHaveLength(0); + } finally { + await stopFixtureServers(); + } +}); + +test("native Chat includes tool definitions in its pre-dispatch spend reservation", async () => { + const upstream = mockChatUpstreamCapturing(); + const config = mockConfig(`${upstream.server.url.toString().replace(/\/$/, "")}/v1`); + config.spend = { pool: { maxTokens: 500 } }; + saveConfig(config); + const server = startServer(0); + activeServer = server; + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", messages: [{ role: "user", content: "hello" }], + max_tokens: 1, tools: [{ type: "function", function: { name: "large_tool", description: "large schema ".repeat(2_000), parameters: { type: "object" } } }] }), + }); + expect(response.status).toBe(429); + expect(response.headers.get("x-opencodex-local-refusal")).toBe("workflow_spend_exhausted"); + expect(upstream.captured).toHaveLength(0); + } finally { + await stopFixtureServers(); + } +}); diff --git a/tests/usage/request-history-index.test.ts b/tests/usage/request-history-index.test.ts index 240fd391c59..c141350ac2b 100644 --- a/tests/usage/request-history-index.test.ts +++ b/tests/usage/request-history-index.test.ts @@ -15,6 +15,7 @@ import { handleManagementAPI } from "../../src/server/management-api"; import { ManagementRequest } from "../helpers/management-auth"; import { appendUsageEntry, + normalizeUsageEntryForTest, resetUsageReadCacheForTests, usageLogPath, type PersistedUsageEntry, @@ -31,6 +32,8 @@ import { import { InvalidCursorError } from "../../src/routing/history/cursor"; import { HISTORY_DB_FILENAME } from "../../src/routing/history/schema"; import { getConfigDir } from "../../src/config"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { flushWindowsSecretAclReapsBeforeRemoval } from "../../src/lib/windows-secret-acl"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -72,8 +75,11 @@ beforeEach(() => { closeRequestHistoryIndex(); }); -afterEach(() => { +afterEach(async () => { closeRequestHistoryIndex(); + // Management/config reads may still own a Windows ACL child after the query ends. + await flushConfigDirHardeningForTests(); + await flushWindowsSecretAclReapsBeforeRemoval(testDir); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (testDir) removeTreeWithRetry(testDir); @@ -163,7 +169,12 @@ describe("request-history index (RI-02)", () => { test("large history indexes fully and paginates without duplicates or misses", async () => { const rows = seedRows(1500, 10_000); - for (const row of rows) appendUsageEntry(row); + // Exercise all 1,500 indexed rows and 15 pages without timing 1,500 filesystem opens. + // This is byte-for-byte the append writer's normalization + JSON + LF representation; + // the incremental-append cases above continue to exercise appendUsageEntry itself. + writeFileSync(usageLogPath(), rows.map(row => `${JSON.stringify(normalizeUsageEntryForTest(row))}\n`).join(""), { + encoding: "utf-8", mode: 0o600, + }); const seen = new Set(); let cursor: string | undefined; let pages = 0; @@ -300,6 +311,29 @@ describe("request-history index (RI-02)", () => { expect(byRange.rows.map(row => row.requestId)).toEqual(["f2"]); }); + test("requestedModel filter matches the encoded form of over-long selectors", async () => { + // Two valid selectors sharing the first 130 chars must stay distinguishable: + // the persisted form is prefix + digest, and the filter encodes identically. + const sharedPrefix = `a/${"m".repeat(200)}`; + const selectorA = `${sharedPrefix}-alpha`; + const selectorB = `${sharedPrefix}-omega`; + appendUsageEntry(entry("sel-a", 1000, "a", "m1", { requestedModel: selectorA })); + appendUsageEntry(entry("sel-b", 2000, "a", "m1", { requestedModel: selectorB })); + + const pageA = await queryRequestHistory({ requestedModel: selectorA }, undefined, 10); + expect(pageA.rows.map(row => row.requestId)).toEqual(["sel-a"]); + const pageB = await queryRequestHistory({ requestedModel: selectorB }, undefined, 10); + expect(pageB.rows.map(row => row.requestId)).toEqual(["sel-b"]); + + // Rows surface the bounded persisted form; filtering by that displayed value + // round-trips because the encoding is idempotent. + const persistedA = pageA.rows[0]!.requestedModel!; + expect(persistedA).not.toBe(selectorA); + expect(persistedA.length).toBeLessThanOrEqual(130); + const roundTrip = await queryRequestHistory({ requestedModel: persistedA }, undefined, 10); + expect(roundTrip.rows.map(row => row.requestId)).toEqual(["sel-a"]); + }); + test("row-by-id returns the canonical entry and unknown ids 404 through the API", async () => { appendUsageEntry(entry("target-id", 1234)); const row = await requestHistoryRowById("target-id"); diff --git a/tests/usage/usage-cost.test.ts b/tests/usage/usage-cost.test.ts index dffe93f2ab2..5eebe22de26 100644 --- a/tests/usage/usage-cost.test.ts +++ b/tests/usage/usage-cost.test.ts @@ -1510,13 +1510,13 @@ describe("Codex account pricing identity", () => { } }); - test("only recognized historical phex and main suffixes retain the existing fallback", () => { + test("only recognized historical phex suffixes retain the existing fallback", () => { refreshUserCostOverlays(config([])); const custom = { ...row, provider: "legacy" }; - for (const provider of ["legacy-pabcdef", "legacy-main"]) { + for (const provider of ["legacy-pabcdef"]) { expect(resolveMatchedPrice(provider, modelId, [custom], [])?.cost4).toEqual(RATE); } - for (const provider of ["legacy-unknown", "legacy-pABCDEF", "legacy-pabcde", "legacy-oabcdef", "legacy-__main__"]) { + for (const provider of ["legacy-unknown", "legacy-pABCDEF", "legacy-pabcde", "legacy-oabcdef", "legacy-__main__", "legacy-main"]) { expect(resolveMatchedPrice(provider, modelId, [custom], [])).toBeNull(); } }); diff --git a/tests/usage/usage-log.test.ts b/tests/usage/usage-log.test.ts index 02c3b346750..fc43c30931e 100644 --- a/tests/usage/usage-log.test.ts +++ b/tests/usage/usage-log.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { appendUsageEntry, currentUsageLogRevision, + encodePersistedRequestedModel, normalizeUsageEntryForTest, normalizeClaudeCompatibilityUsageLog, normalizePersistedUsageRow, @@ -150,6 +151,47 @@ describe("usage log", () => { expect(normalized.attempts).toEqual([]); }); + test("bounds requested model selectors before appending usage rows", () => { + const requestedModel = `policy/${"x".repeat(1024 * 1024)}`; + appendUsageEntry({ + requestId: "ocx-bounded-selector", + timestamp: 1, + provider: "unknown", + model: "unknown", + requestedModel, + status: 404, + durationMs: 1, + usageStatus: "unreported", + }); + + const raw = readFileSync(usageLogPath(), "utf8"); + const persisted = JSON.parse(raw) as PersistedUsageEntry; + expect(persisted.requestedModel).toBe(encodePersistedRequestedModel(requestedModel)); + expect(persisted.requestedModel!.length).toBeLessThanOrEqual(130); + expect(raw.length).toBeLessThan(1024); + }); + + test("keeps over-long selectors that share the bounded prefix distinguishable", () => { + // Selectors are not length-bound at admission, so two valid selectors can + // agree past the persistence bound; they must not collapse into one identity. + const sharedPrefix = `provider/${"m".repeat(200)}`; + const selectorA = `${sharedPrefix}-alpha`; + const selectorB = `${sharedPrefix}-omega`; + expect(selectorA.slice(0, 130)).toBe(selectorB.slice(0, 130)); + + const encodedA = encodePersistedRequestedModel(selectorA); + const encodedB = encodePersistedRequestedModel(selectorB); + expect(encodedA).not.toBe(encodedB); + expect(encodedA.length).toBeLessThanOrEqual(130); + expect(encodedB.length).toBeLessThanOrEqual(130); + + // Short selectors persist verbatim, and re-normalizing a persisted row is a + // no-op — normalizeUsageEntry also runs on every read. + const short = "provider/model"; + expect(encodePersistedRequestedModel(short)).toBe(short); + expect(encodePersistedRequestedModel(encodedA)).toBe(encodedA); + }); + test("preserves only valid non-PII Codex account log labels", () => { const normalized = normalizeUsageEntryForTest({ requestId: "ocx-account-label", diff --git a/tests/usage/usage-provider-label.test.ts b/tests/usage/usage-provider-label.test.ts index 66fefefe9a9..3627efbd727 100644 --- a/tests/usage/usage-provider-label.test.ts +++ b/tests/usage/usage-provider-label.test.ts @@ -1,7 +1,41 @@ import { describe, expect, test } from "bun:test"; import { baseProviderLabel } from "../../src/providers/label"; +import { summarizeUsage } from "../../src/usage/summary"; +import type { PersistedUsageEntry } from "../../src/usage/log"; + +const FIXED_NOW = Date.UTC(2026, 5, 28, 12, 0, 0); +function entry(overrides: Partial & { ts: number }): PersistedUsageEntry { + const { ts, ...rest } = overrides; + return { + requestId: rest.requestId ?? `req-${ts}`, + timestamp: ts, + provider: rest.provider ?? "openai", + model: rest.model ?? "gpt-5.5", + status: rest.status ?? 200, + durationMs: rest.durationMs ?? 10, + usageStatus: rest.usageStatus ?? "unreported", + ...(rest.surface === "claude" ? { surface: rest.surface } : {}), + ...(rest.accountLogLabel !== undefined ? { accountLogLabel: rest.accountLogLabel } : {}), + ...(rest.resolvedModel !== undefined ? { resolvedModel: rest.resolvedModel } : {}), + ...(rest.usage ? { usage: rest.usage } : {}), + ...(rest.totalTokens !== undefined ? { totalTokens: rest.totalTokens } : {}), + ...(rest.attempts ? { attempts: rest.attempts } : {}), + ...(rest.apiKeyId !== undefined ? { apiKeyId: rest.apiKeyId } : {}), + ...(rest.routeDecision ? { routeDecision: rest.routeDecision } : {}), + }; +} describe("baseProviderLabel", () => { + test("keeps configured providers ending in -main in distinct usage rows", () => { + const entries: PersistedUsageEntry[] = [ + entry({ ts: FIXED_NOW - 1, provider: "openrouter", model: "shared-model", usageStatus: "reported", usage: { inputTokens: 4, outputTokens: 1 }, totalTokens: 5 }), + entry({ ts: FIXED_NOW - 2, provider: "openrouter-main", model: "shared-model", usageStatus: "reported", usage: { inputTokens: 2, outputTokens: 1 }, totalTokens: 3 }), + ]; + const sum = summarizeUsage(entries, "30d", FIXED_NOW); + expect(sum.providers.map(provider => provider.provider).sort()).toEqual(["openrouter", "openrouter-main"]); + expect(sum.models.map(model => model.provider).sort()).toEqual(["openrouter", "openrouter-main"]); + }); + test("returns the input when there is no pool suffix", () => { expect(baseProviderLabel("openai")).toBe("openai"); expect(baseProviderLabel("anthropic")).toBe("anthropic"); @@ -25,9 +59,15 @@ describe("baseProviderLabel", () => { expect(baseProviderLabel("anthropic-pabc123")).toBe("anthropic"); }); - test("strips the legacy -main suffix so historical main-account rows aggregate", () => { + test("keeps configured provider names ending in -main distinct", () => { + expect(baseProviderLabel("openrouter-main")).toBe("openrouter-main"); + expect(baseProviderLabel("azure-main")).toBe("azure-main"); + }); + + test("strips the legacy -main suffix from the known Codex provider labels", () => { expect(baseProviderLabel("openai-main")).toBe("openai"); - expect(baseProviderLabel("codex-main")).toBe("codex"); + expect(baseProviderLabel("chatgpt-main")).toBe("openai"); + expect(baseProviderLabel("openai-multi-main")).toBe("openai"); }); test("keeps suffixes that do not match the pool log-label shape", () => {