;
/**
@@ -345,29 +332,20 @@ function summarizeFilteredLogs(entries: LogEntry[]): {
requests: number;
totalTokens: number;
estimatedCostUsd: number;
+ priorityLowerBound: boolean;
unpricedRequests: number;
unmeteredRequests: number;
} {
let totalTokens = 0;
- let estimatedCostUsd = 0;
- let unpricedRequests = 0;
- let unmeteredRequests = 0;
for (const entry of entries) {
const tokens = displayTokenTotal(entry);
if (tokens !== undefined) totalTokens += tokens;
- if (entry.usageStatus === "unsupported") {
- unmeteredRequests += 1;
- continue;
- }
- const cost = entry.displayMetrics?.cost;
- const total = cost?.kind === "value" ? cost.estimate.cost.total : undefined;
- if (total !== undefined && Number.isFinite(total) && total >= 0) {
- estimatedCostUsd += total;
- continue;
- }
- unpricedRequests += 1;
}
- return { requests: entries.length, totalTokens, estimatedCostUsd, unpricedRequests, unmeteredRequests };
+ return {
+ requests: entries.length,
+ totalTokens,
+ ...summarizeEstimatedCosts(entries),
+ };
}
export default function Logs({ apiBase }: { apiBase: string }) {
@@ -612,7 +590,12 @@ export default function Logs({ apiBase }: { apiBase: string }) {
{t("logs.conversation.totals", {
requests: conversationTotals.requests,
tokens: formatTokens(conversationTotals.totalTokens, localeTag ?? locale),
- cost: formatEstimatedUsdValue(conversationTotals.estimatedCostUsd, localeTag),
+ cost: formatEstimatedUsdValue(
+ conversationTotals.estimatedCostUsd,
+ t,
+ localeTag,
+ conversationTotals.priorityLowerBound,
+ ),
})}
{" "}
@@ -731,7 +714,7 @@ export default function Logs({ apiBase }: { apiBase: string }) {
{formatTokPerSecond(log.displayMetrics?.tokPerSecond, localeTag)}
|
- {formatEstimatedUsd(log.displayMetrics?.cost, localeTag)}
+ {formatEstimatedUsd(log.displayMetrics?.cost, t, localeTag)}
|
@@ -956,11 +939,11 @@ function LogDetailDialog({
{cost?.kind === "value" ? (
<>
- {t("logs.detail.costTotal")}{formatEstimatedUsdValue(cost.estimate.cost.total, localeTag)}
- {t("logs.tokens.input")}{formatEstimatedUsdValue(cost.estimate.cost.input, localeTag)}
- {t("logs.tokens.cacheRead")}{formatEstimatedUsdValue(cost.estimate.cost.cacheRead, localeTag)}
- {t("logs.tokens.cacheWrite")}{formatEstimatedUsdValue(cost.estimate.cost.cacheWrite, localeTag)}
- {t("logs.tokens.output")}{formatEstimatedUsdValue(cost.estimate.cost.output, localeTag)}
+ {t("logs.detail.costTotal")}{formatEstimatedUsdValue(cost.estimate.cost.total, t, localeTag, cost.estimate.priorityLowerBound)}
+ {t("logs.tokens.input")}{formatEstimatedUsdValue(cost.estimate.cost.input, t, localeTag, cost.estimate.priorityLowerBound)}
+ {t("logs.tokens.cacheRead")}{formatEstimatedUsdValue(cost.estimate.cost.cacheRead, t, localeTag, cost.estimate.priorityLowerBound)}
+ {t("logs.tokens.cacheWrite")}{formatEstimatedUsdValue(cost.estimate.cost.cacheWrite, t, localeTag, cost.estimate.priorityLowerBound)}
+ {t("logs.tokens.output")}{formatEstimatedUsdValue(cost.estimate.cost.output, t, localeTag, cost.estimate.priorityLowerBound)}
{cost.estimate.price && (
<>
{t("logs.detail.matchedKey")}
@@ -978,7 +961,7 @@ function LogDetailDialog({
>
) : (
- {t("logs.detail.costTotal")}{"\u2014"}
+ {t("logs.detail.costTotal")}{t("logs.cost.unavailable")}
{t("logs.detail.unavailableReason")}
{cost?.kind === "unavailable" ? t(metricReasonKey(cost.reason)) : t("logs.detail.reason.usage_missing")}
@@ -1033,7 +1016,7 @@ function LogDetailDialog({
|
{attempt.durationMs}ms |
{formatTokPerSecond(attempt.displayMetrics?.tokPerSecond, localeTag)} |
- {formatEstimatedUsd(attemptCost, localeTag)} |
+ {formatEstimatedUsd(attemptCost, t, localeTag)} |
{reason} |
);
diff --git a/gui/src/pages/logs-cost-format.ts b/gui/src/pages/logs-cost-format.ts
new file mode 100644
index 0000000000..48a0814097
--- /dev/null
+++ b/gui/src/pages/logs-cost-format.ts
@@ -0,0 +1,77 @@
+import type { TFn } from "../i18n/shared";
+
+type EstimatedCostResult = {
+ kind: "value";
+ estimate: { cost: { total: number }; priorityLowerBound?: boolean };
+} | { kind: "unavailable" };
+
+export function formatEstimatedUsdValue(
+ value: number,
+ t: TFn,
+ localeTag?: string,
+ priorityLowerBound = false,
+): string {
+ if (!Number.isFinite(value) || value < 0) return t("logs.cost.unavailable");
+ const amount = new Intl.NumberFormat(localeTag, {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 4,
+ maximumFractionDigits: 4,
+ }).format(value);
+ return t(priorityLowerBound ? "logs.cost.lowerBound" : "logs.cost.approximate", { amount });
+}
+
+export function formatEstimatedUsd(
+ result: EstimatedCostResult | undefined,
+ t: TFn,
+ localeTag?: string,
+): string {
+ if (!result || result.kind === "unavailable") return t("logs.cost.unavailable");
+ return formatEstimatedUsdValue(
+ result.estimate.cost.total,
+ t,
+ localeTag,
+ result.estimate.priorityLowerBound,
+ );
+}
+
+interface CostSummaryEntry {
+ usageStatus?: string;
+ displayMetrics?: { cost: EstimatedCostResult };
+}
+
+export function summarizeEstimatedCosts(entries: readonly CostSummaryEntry[]): {
+ estimatedCostUsd: number;
+ priorityLowerBound: boolean;
+ unpricedRequests: number;
+ unmeteredRequests: number;
+} {
+ let estimatedCostUsd = 0;
+ let everyPricedEstimateIsLowerBound = true;
+ let pricedEstimates = 0;
+ let unpricedRequests = 0;
+ let unmeteredRequests = 0;
+ for (const entry of entries) {
+ if (entry.usageStatus === "unsupported") {
+ unmeteredRequests += 1;
+ continue;
+ }
+ const cost = entry.displayMetrics?.cost;
+ if (cost?.kind === "value") {
+ const total = cost.estimate.cost.total;
+ if (Number.isFinite(total) && total >= 0) {
+ estimatedCostUsd += total;
+ pricedEstimates += 1;
+ everyPricedEstimateIsLowerBound &&= cost.estimate.priorityLowerBound === true;
+ continue;
+ }
+ }
+ unpricedRequests += 1;
+ }
+ return {
+ estimatedCostUsd,
+ priorityLowerBound: pricedEstimates > 0 && everyPricedEstimateIsLowerBound,
+ unpricedRequests,
+ unmeteredRequests,
+ };
+}
diff --git a/gui/tests/logs-priority-lower-bound.test.ts b/gui/tests/logs-priority-lower-bound.test.ts
new file mode 100644
index 0000000000..877252c07f
--- /dev/null
+++ b/gui/tests/logs-priority-lower-bound.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, test } from "bun:test";
+import { DICTS } from "../src/i18n/catalogs";
+import { interpolate, type TFn } from "../src/i18n/shared";
+import {
+ formatEstimatedUsd,
+ formatEstimatedUsdValue,
+ summarizeEstimatedCosts,
+} from "../src/pages/logs-cost-format";
+
+function translator(locale: keyof typeof DICTS): TFn {
+ return (key, vars) => interpolate(DICTS[locale][key], vars);
+}
+
+const en = translator("en");
+const de = translator("de");
+
+describe("Logs priority lower-bound formatting", () => {
+ test("prefixes confirmed unpriced priority estimates with the lower-bound marker", () => {
+ expect(formatEstimatedUsdValue(1.6, en, "en-US", true)).toBe("≥$1.6000");
+ });
+
+ test("keeps ordinary standard-price estimates unchanged", () => {
+ expect(formatEstimatedUsdValue(1.6, en, "en-US", false)).toBe("~$1.6000");
+ });
+
+ test("uses locale-aware USD placement and separators", () => {
+ expect(formatEstimatedUsdValue(1.6, de, "de-DE", false)).toBe("ca. 1,6000\u00a0$");
+ });
+});
+
+describe("Logs table cost formatting", () => {
+ test("uses the shared value formatter for lower bounds, ordinary estimates, and unavailable costs", () => {
+ expect(formatEstimatedUsd({
+ kind: "value",
+ estimate: { cost: { total: 1.6 }, priorityLowerBound: true },
+ }, en, "en-US")).toBe("≥$1.6000");
+ expect(formatEstimatedUsd({
+ kind: "value",
+ estimate: { cost: { total: 1.6 } },
+ }, en, "en-US")).toBe("~$1.6000");
+ expect(formatEstimatedUsd({ kind: "unavailable" }, en, "en-US")).toBe("—");
+ expect(formatEstimatedUsdValue(Number.NaN, en, "en-US")).toBe("—");
+ });
+});
+
+describe("Logs conversation cost aggregation", () => {
+ test("does not mark a mixed ordinary and lower-bound total as a lower bound", () => {
+ const summary = summarizeEstimatedCosts([
+ {
+ usageStatus: "reported",
+ displayMetrics: {
+ cost: {
+ kind: "value",
+ estimate: { cost: { total: 1.6 }, priorityLowerBound: true },
+ },
+ },
+ },
+ {
+ usageStatus: "reported",
+ displayMetrics: {
+ cost: {
+ kind: "value",
+ estimate: { cost: { total: 0.4 } },
+ },
+ },
+ },
+ ]);
+
+ expect(summary.estimatedCostUsd).toBe(2);
+ expect(summary.priorityLowerBound).toBe(false);
+ });
+
+ test("marks a total as a lower bound only when every priced estimate is one", () => {
+ const summary = summarizeEstimatedCosts([
+ {
+ usageStatus: "reported",
+ displayMetrics: {
+ cost: {
+ kind: "value",
+ estimate: { cost: { total: 1.6 }, priorityLowerBound: true },
+ },
+ },
+ },
+ {
+ usageStatus: "reported",
+ displayMetrics: {
+ cost: {
+ kind: "value",
+ estimate: { cost: { total: 0.4 }, priorityLowerBound: true },
+ },
+ },
+ },
+ ]);
+
+ expect(summary.estimatedCostUsd).toBe(2);
+ expect(summary.priorityLowerBound).toBe(true);
+ });
+
+ test("does not mark an unpriced-only total as a lower bound", () => {
+ const summary = summarizeEstimatedCosts([
+ {
+ usageStatus: "reported",
+ displayMetrics: { cost: { kind: "unavailable" } },
+ },
+ ]);
+
+ expect(summary.estimatedCostUsd).toBe(0);
+ expect(summary.priorityLowerBound).toBe(false);
+ expect(summary.unpricedRequests).toBe(1);
+ });
+});
diff --git a/package.json b/package.json
index ae0571e503..ecb4a81645 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@bitkyc08/opencodex",
- "version": "2.26.0",
+ "version": "2.27.0",
"description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
"type": "module",
"main": "./bin/package-main.mjs",
diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts
index fd141ccfb8..e012a78198 100644
--- a/src/adapters/anthropic.ts
+++ b/src/adapters/anthropic.ts
@@ -743,7 +743,7 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (
? new Set(parsed.options.toolChoice.allowedTools)
: undefined;
const tools = allowed
- ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
+ ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools))
: parsed.context.tools;
if (tools.length === 0) return undefined;
const converted = tools.map(t => ({
diff --git a/src/adapters/base.ts b/src/adapters/base.ts
index bcdd657344..7d3a8be07b 100644
--- a/src/adapters/base.ts
+++ b/src/adapters/base.ts
@@ -39,8 +39,20 @@ export interface ProviderAdapter {
fetchResponse?(request: AdapterRequest, ctx?: AdapterFetchContext): Promise;
- parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator;
- parseResponse?(response: Response, budget: TranslatorBudget): Promise;
+ /**
+ * Parse one upstream response. `tierMetadata` is the same live observer returned on the
+ * corresponding AdapterRequest; adapters that receive a documented tier echo may update it.
+ */
+ parseStream(
+ response: Response,
+ budget: TranslatorBudget,
+ tierMetadata?: AdapterTierMetadata,
+ ): AsyncGenerator;
+ parseResponse?(
+ response: Response,
+ budget: TranslatorBudget,
+ tierMetadata?: AdapterTierMetadata,
+ ): Promise;
runTurn?(
parsed: OcxParsedRequest,
incoming: IncomingMeta,
diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts
index 156ba5130a..8f2edbdb55 100644
--- a/src/adapters/command-code.ts
+++ b/src/adapters/command-code.ts
@@ -3,7 +3,7 @@ import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { opendir } from "node:fs/promises";
import type { AdapterEvent, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxUsage } from "../types";
-import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice, toolChoiceAliases } from "../types";
+import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base";
import type { TranslatorBudget } from "../lib/translator-budget";
import { readBoundedResponseBody } from "../lib/bounded-body";
@@ -157,10 +157,11 @@ function visibleTools(parsed: OcxParsedRequest): OcxTool[] {
const tools = parsed.context.tools ?? [];
if (isAllowedToolChoice(choice)) {
const allowed = new Set(choice.allowedTools);
- return tools.filter(tool => toolAllowedByChoice(tool, allowed));
+ return tools.filter(tool => toolAllowedByChoice(tool, allowed, tools));
}
if (choice && typeof choice !== "string") {
- return tools.filter(tool => toolChoiceAliases(tool).includes(choice.name));
+ const selected = resolveToolChoiceWireName(tools, choice.name);
+ return tools.filter(tool => namespacedToolName(tool.namespace, tool.name) === selected);
}
return tools;
}
diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts
index 294adc4a7c..f2e13c578c 100644
--- a/src/adapters/cursor/cursor-errors.ts
+++ b/src/adapters/cursor/cursor-errors.ts
@@ -85,6 +85,21 @@ export function isCursorBenignCancelError(value: unknown): boolean {
return false;
}
+/**
+ * True when the turn was torn down by an `AbortSignal` rather than by a transport fault.
+ *
+ * This is deliberately NOT part of `isCursorBenignCancelError`: an abort mid-turn is a real
+ * failure and must still surface. It is only meaningful in combination with a terminal frame
+ * having already been emitted, where it means "the answer landed and then the connection went
+ * away" (#1527).
+ */
+export function isCursorAbortError(value: unknown): boolean {
+ const message = errorMessage(value).toLowerCase();
+ if (message.includes("cursor request was aborted")) return true;
+ const name = (value as { name?: unknown })?.name;
+ return typeof name === "string" && name === "AbortError";
+}
+
/**
* True when Cursor Connect rejected the turn with invalid_argument.
* Seen after stepCompleted on brittle external-model continuations.
diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts
index 3f8ce957a9..5775de025e 100644
--- a/src/adapters/cursor/live-transport.ts
+++ b/src/adapters/cursor/live-transport.ts
@@ -48,7 +48,7 @@ import {
type InteractionResponse,
} from "./gen/agent_pb";
import { debugProviderDiagnostic } from "../../lib/debug";
-import { classifyCursorError, CursorUnexpectedCancelError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors";
+import { classifyCursorError, CursorUnexpectedCancelError, isCursorAbortError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors";
import { mcpArgsFromToolCall } from "./protobuf-events";
import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
import {
@@ -650,6 +650,18 @@ class LiveCursorTransport implements CursorTransport {
// A CANCEL is benign only on the client-tool suspend path (expectedClose); an
// unexpected server-side NGHTTP2_CANCEL must surface as a real transport error.
if (this.expectedClose && isCursorBenignCancelError(failure)) return;
+ // A teardown error arriving AFTER the turn's terminal frame describes the connection,
+ // not the turn: the answer is committed and every queued message has been yielded.
+ //
+ // Narrow on purpose. A benign cancel after a terminal is already swallowed one layer
+ // up (`cursor.ts:183`), so widening this to every post-terminal error would change
+ // what the adapter sees for genuine faults. What it does cover is the abort case
+ // from #1527: `signal.abort` fires `failAndClear(new Error("Cursor request was
+ // aborted"))`, which is NOT benign (`cursor-errors.ts:74`), so an ordinary completed
+ // turn that is then torn down still surfaced as `turn-failed` with
+ // `expectedClose:false`. Only `cancelCursorRun()` sets `expectedClose`, so a normal
+ // completion never qualified for the branch above.
+ if (this.emittedTerminal && isCursorAbortError(failure)) return;
throw attachPartialUsage(classifyTurnFailure(failure), state);
}
if (done) break;
@@ -659,6 +671,7 @@ class LiveCursorTransport implements CursorTransport {
}
if (failure) {
if (this.expectedClose && isCursorBenignCancelError(failure)) return;
+ if (this.emittedTerminal && isCursorAbortError(failure)) return;
throw attachPartialUsage(classifyTurnFailure(failure), state);
}
}
diff --git a/src/adapters/google.ts b/src/adapters/google.ts
index 9043d1f929..a3be021a76 100644
--- a/src/adapters/google.ts
+++ b/src/adapters/google.ts
@@ -262,7 +262,7 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined {
? new Set(parsed.options.toolChoice.allowedTools)
: undefined;
const tools = allowed
- ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
+ ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools))
: parsed.context.tools;
if (tools.length === 0) return undefined;
return [{
diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts
index b93222d6ac..c5338d5eaa 100644
--- a/src/adapters/openai-chat.ts
+++ b/src/adapters/openai-chat.ts
@@ -19,6 +19,7 @@ import {
import {
canonicalFastTierMarker,
createAdapterTierMetadata,
+ type AdapterTierMetadata,
} from "../providers/fastwire";
import { openaiChatCompletionsUrl } from "./openai-chat-url";
import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
@@ -1156,7 +1157,11 @@ function normalizeXaiToolParameters(parameters: unknown): Record key !== "oneOf" && key !== "anyOf" && key !== "type"));
+ const metadata = Object.fromEntries(Object.entries(normalizedRoot).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
delete metadata.properties;
delete metadata.required;
delete metadata.additionalProperties;
@@ -1180,6 +1185,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record [name, mergeXaiPropertySchemas(values)]),
);
@@ -1196,7 +1202,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record {
@@ -1478,7 +1484,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
};
},
- async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator {
+ async *parseStream(
+ response: Response,
+ budget: TranslatorBudget,
+ tierMetadata?: AdapterTierMetadata,
+ ): AsyncGenerator {
if (!response.body) {
yield { type: "error", message: "No response body" };
return;
@@ -1547,11 +1557,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
try {
parsed = JSON.parse(payload);
} catch {
+ tierMetadata?.markResponseUnparseable();
yield { type: "error", message: "malformed upstream SSE data frame" };
return "terminate";
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "continue";
const chunk = parsed as Record;
+ if (Object.hasOwn(chunk, "service_tier")) {
+ tierMetadata?.observeResponseServiceTier(chunk.service_tier);
+ }
if (chunk.error !== undefined && chunk.error !== null) {
const event = upstreamErrorEvent(chunk.error, pendingUsage);
@@ -1752,8 +1766,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
}
},
- async parseResponse(response: Response, budget: TranslatorBudget): Promise {
- const json = await response.json() as Record;
+ async parseResponse(
+ response: Response,
+ budget: TranslatorBudget,
+ tierMetadata?: AdapterTierMetadata,
+ ): Promise {
+ let parsed: unknown;
+ try {
+ parsed = await response.json();
+ } catch (error) {
+ tierMetadata?.markResponseUnparseable();
+ throw error;
+ }
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
+ tierMetadata?.markResponseUnparseable();
+ throw new Error("upstream response was not a JSON object");
+ }
+ const json = parsed as Record;
+ if (Object.hasOwn(json, "service_tier")) {
+ tierMetadata?.observeResponseServiceTier(json.service_tier);
+ }
const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
try {
diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts
index 9325125691..626bd93d8d 100644
--- a/src/adapters/tool-catalog-nudge.ts
+++ b/src/adapters/tool-catalog-nudge.ts
@@ -135,7 +135,7 @@ export function buildNonOpenAIToolCatalogNudgeForTools(
toolChoice?: OcxRequestOptions["toolChoice"],
toWireName: (tool: Pick) => string = tool => namespacedToolName(tool.namespace, tool.name),
): string | undefined {
- const visible = tools?.filter(toolChoiceToolPredicate(toolChoice));
+ const visible = tools?.filter(toolChoiceToolPredicate(toolChoice, tools));
const visibleNames = visible?.map(toWireName);
// Decide code mode from the tool OBJECTS, while the `freeform` flag still exists — reducing
// to wire names first throws away the only thing that distinguishes Codex's JavaScript
diff --git a/src/bridge.ts b/src/bridge.ts
index e5621faa80..44745bd75f 100644
--- a/src/bridge.ts
+++ b/src/bridge.ts
@@ -167,7 +167,7 @@ export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete";
export function bridgeToResponsesSSE(
events: AsyncIterable,
modelId: string,
- toolNsMap?: Map,
+ toolNsMap?: Map,
freeformToolNames?: Set,
toolSearchToolNames?: Set,
onCancel?: () => void,
@@ -1050,7 +1050,9 @@ export function bridgeToResponsesSSE(
}
const ns = mapped?.namespace;
const toolSearch = toolSearchToolNames?.has(realName) ?? false;
- const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false);
+ const freeform = !toolSearch && (mapped
+ ? mapped.freeform === true
+ : (freeformToolNames?.has(realName) ?? false));
const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`;
const item = toolSearch
? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" }
@@ -1451,7 +1453,7 @@ function buildResponseJSONWithBudget(
modelId: string,
options?: {
hideThinkingSummary?: boolean;
- toolNsMap?: Map;
+ toolNsMap?: Map;
/** Request-visible tool names. When present, an upstream call outside this set fails closed. */
declaredToolNames?: ReadonlySet;
/** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */
@@ -1632,7 +1634,9 @@ function buildResponseJSONWithBudget(
const realName = mapped?.name ?? currentToolCallName;
const ns = mapped?.namespace;
const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
- const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
+ const freeform = !toolSearch && (mapped
+ ? mapped.freeform === true
+ : (options?.freeformToolNames?.has(realName) ?? false));
// #1611: same integral-float repair as the streaming path. Keyed by the wire name
// the request declared, which is the pre-namespace-mapping `currentToolCallName`.
const coercedArgs = coerceIntegerToolArguments(
@@ -1796,7 +1800,9 @@ function buildResponseJSONWithBudget(
const mapped = options?.toolNsMap?.get(currentToolCallName);
const realName = mapped?.name ?? currentToolCallName;
const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
- const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
+ const freeform = !toolSearch && (mapped
+ ? mapped.freeform === true
+ : (options?.freeformToolNames?.has(realName) ?? false));
if (!freeform && !toolSearch) {
flushToolCall("incomplete");
errorEvent = {
diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts
index 7962c06672..8af24a2693 100644
--- a/src/cli/doctor.ts
+++ b/src/cli/doctor.ts
@@ -25,6 +25,11 @@ import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHome
import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback";
import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
import { countPendingOpencodexHistory } from "../codex/history-provider";
+import {
+ inspectAbandonedResponseStateTemps,
+ reclaimAbandonedResponseStateTemps,
+ type ResponseStateTempRecoveryResult,
+} from "../responses/state";
import {
CodexUserIdentityRefusal,
probeCodexCoordinatorNamespace,
@@ -678,6 +683,57 @@ export async function fetchServiceMemory(
const mb = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))}MB`;
+export const RECLAIM_RESPONSE_TEMPS_FLAG = "--reclaim-response-temps";
+/** Matches the dry run's entry bound so report and reclaim agree on a large backlog. */
+const RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS = 4_096;
+/** Names the subsystem: other components mint temps with the same shape and are not covered. */
+const CLEAN_RESPONSE_TEMP_LINE = " ok No abandoned response-state temp files.";
+
+/**
+ * Render the abandoned-temp section (testable without console capture).
+ *
+ * Report is the DEFAULT and reclaim is opt-in: `doctor` is a diagnostic an operator runs
+ * to understand a machine, so deleting files as a side effect of asking a question is the
+ * wrong default even for cache files.
+ *
+ * Counts come from `eligible`/`eligibleBytes`, never `matched`: `matched` is incremented
+ * before the file-type, age, boot-floor, and liveness gates, so reporting it would tell an
+ * operator that live-pid temps and young temps are "abandoned".
+ */
+export function formatResponseTempLines(
+ result: ResponseStateTempRecoveryResult,
+ reclaimed: boolean,
+): string[] {
+ if (reclaimed) {
+ if (result.removed === 0 && result.failed === 0) return [CLEAN_RESPONSE_TEMP_LINE];
+ const lines = [` ok Reclaimed ${result.removed} abandoned response-state temp file(s), ${mb(result.bytesRemoved)} freed.`];
+ if (result.failed > 0) {
+ // Never "retried automatically": this command exists for the operator whose proxy will
+ // NOT start, and in that state nothing retries anything.
+ lines.push(` !! ${result.failed} file(s) could not be removed (in use or locked). Retried on the next reclaim — automatically while the proxy runs, otherwise re-run this command.`);
+ }
+ // `truncated`, not `eligible > removed + failed`: outside a dry run every eligible entry
+ // is unlinked or failed on the same iteration it is counted, so those two are always
+ // equal and the comparison never fired. An operator with a backlog past the budget was
+ // told the reclaim had finished.
+ if (result.truncated) {
+ lines.push(" !! Cleanup budget reached; files remain. Run the command again to continue.");
+ }
+ return lines;
+ }
+ if (result.eligible === 0) return [CLEAN_RESPONSE_TEMP_LINE];
+ const lines = [
+ ` !! ${result.eligible} abandoned response-state temp file(s), ${mb(result.eligibleBytes)} reclaimable.`,
+ " These are interrupted snapshot writes (continuation cache only) and are safe to remove.",
+ " Reclaim them with: ocx doctor --reclaim-response-temps",
+ ];
+ // The dry run skips the cleanup budget but is still bounded by the entry cap, so a large
+ // enough backlog makes this a floor rather than a total. Say so instead of letting an
+ // operator size the problem from a truncated count.
+ if (result.truncated) lines.push(" Scan stopped at its entry budget; the real total is higher.");
+ return lines;
+}
+
/** Render the doctor "Memory / runtime" section lines (testable without console capture). */
export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] {
const lines: string[] = [];
@@ -805,6 +861,26 @@ export async function runDoctor(args: string[] = []): Promise {
console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`);
}
+ // Runs without the proxy on purpose: the worst accumulation happens when the proxy will
+ // not start, which is exactly when the in-process periodic reclaim never ticks.
+ const reclaimTemps = args.includes(RECLAIM_RESPONSE_TEMPS_FLAG);
+ console.log("\nResponse-state temp files");
+ // A typo must not silently degrade into "nothing to reclaim" — the operator would read the
+ // report as an answer to a question they never actually asked.
+ for (const arg of args) {
+ if (arg !== RECLAIM_RESPONSE_TEMPS_FLAG && /^--reclaim/.test(arg)) {
+ console.log(` !! Unrecognized flag ${arg}; did you mean ${RECLAIM_RESPONSE_TEMPS_FLAG}? Reporting only.`);
+ }
+ }
+ for (const line of formatResponseTempLines(
+ // The reclaim budget matches the report budget: a report bounded by entries and a removal
+ // bounded by a smaller cleanup cap would tell an operator 816 and then silently free 512.
+ reclaimTemps
+ ? reclaimAbandonedResponseStateTemps({ maxCleanups: RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS })
+ : inspectAbandonedResponseStateTemps(),
+ reclaimTemps,
+ )) console.log(line);
+
const orcaHome = collectOrcaCodexHomeDiagnostic();
console.log("\nCodex app home targeting");
console.log(` ${orcaHome.mismatch ? "!! " : "ok "} Effective Codex home: ${orcaHome.effectiveCodexHome}`);
diff --git a/src/cli/help.ts b/src/cli/help.ts
index 19843c2e01..c335c347b5 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -36,6 +36,8 @@ Usage:
Refresh Codex's model cache from the active catalog
ocx status Check proxy server status
ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
+ ocx doctor --reclaim-response-temps
+ Reclaim abandoned response-state temp files (works without a running proxy)
ocx debug provider/usage/injection/claude on|off|status|reset
ocx login OAuth or API-key provider login
ocx logout Remove a stored OAuth login
diff --git a/src/cli/models.ts b/src/cli/models.ts
index db4f20742d..a20ba18472 100644
--- a/src/cli/models.ts
+++ b/src/cli/models.ts
@@ -5,11 +5,11 @@ import { randomUUID } from "node:crypto";
import { createInterface } from "node:readline/promises";
import { syncModelsToCodex } from "../codex/sync";
import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config";
-import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort";
+import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort";
import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec";
import { knownModelIdsForProvider } from "../router";
import { findLiveProxy } from "../server/proxy-liveness";
-import type { OcxConfig, OcxCustomModel } from "../types";
+import { modelInList, type OcxConfig, type OcxCustomModel } from "../types";
const ADD_USAGE = "Usage: ocx models add [--display-name ] [--context-window ] [--modalities text,image,audio] [--reasoning-efforts ] [--default-reasoning-effort ]";
const REMOVE_USAGE = "Usage: ocx models remove [--yes]";
@@ -98,15 +98,22 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[]
if (seen.has(model)) return;
seen.add(model);
- const noVision = prov.noVisionModels?.includes(model);
- const modalities = inputModalities[model] ?? (noVision ? ["text"] : null);
- const efforts = reasoningEfforts[model] ?? prov.reasoningEfforts ?? null;
+ // Resolve exactly as the runtime does, or this command reports capabilities the
+ // proxy will not honour: `isModelTextOnly` matches noVisionModels with modelInList
+ // and reads modelInputModalities with modelRecordValue, so a `gpt-oss` entry covers
+ // `gpt-oss:120b`. A bare lookup reported that model as unclassified on every field.
+ // noVisionModels is checked first because `isModelTextOnly` returns true on that
+ // match before it ever reads modelInputModalities: a `gpt-oss` noVision entry beats
+ // an exact `gpt-oss:120b` entry that lists "image", and the proxy rejects the image.
+ const noVision = modelInList(prov.noVisionModels, model);
+ const modalities = noVision ? ["text"] : (modelRecordValue(inputModalities, model) ?? null);
+ const efforts = modelRecordValue(reasoningEfforts, model) ?? prov.reasoningEfforts ?? null;
entries.push({
provider: provName,
model,
isDefault,
- contextWindow: contextWindows[model] ?? globalContext,
+ contextWindow: modelRecordValue(contextWindows, model) ?? globalContext,
inputModalities: modalities,
reasoningEfforts: efforts,
});
diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts
index c70c3af5c5..8d51d8efa9 100644
--- a/src/codex/app-server-processes.ts
+++ b/src/codex/app-server-processes.ts
@@ -7,7 +7,7 @@
* Never match broad `*codex*` patterns that hit unrelated tools such as
* `hermes-codex-bridge-mcp`.
*/
-import { execFileSync } from "node:child_process";
+import { execFile, execFileSync, type ExecFileException } from "node:child_process";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { isProcessAlive, waitForExit } from "../lib/process-control";
import {
@@ -98,9 +98,30 @@ export interface CodexAppServerProcessIo {
waitExit?: (pid: number, timeoutMs: number) => boolean;
now?: () => number;
readStartMs?: (pid: number) => number | null;
+ /** Async process-list seam used by the request-path Windows collector. */
+ listSnapshotsAsync?: () => Promise;
+ /** Async batch start-time seam used by the request-path Windows collector. */
+ readStartMsBatchAsync?: (pids: readonly number[]) => Promise