@bitkyc08/opencodex 2.26.0 → 2.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/gui/dist/assets/{index-RL6b1bTV.js → index-D2sP-biU.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +60 -1
  5. package/src/adapters/base.ts +16 -2
  6. package/src/adapters/command-code.ts +4 -3
  7. package/src/adapters/cursor/cursor-errors.ts +15 -0
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-antigravity-replay.ts +16 -8
  10. package/src/adapters/google.ts +22 -5
  11. package/src/adapters/openai-chat.ts +189 -60
  12. package/src/adapters/openai-responses.ts +37 -0
  13. package/src/adapters/tool-catalog-nudge.ts +1 -1
  14. package/src/bridge.ts +11 -5
  15. package/src/cli/doctor.ts +76 -0
  16. package/src/cli/help.ts +2 -0
  17. package/src/cli/index.ts +19 -6
  18. package/src/cli/models.ts +13 -6
  19. package/src/codex/account-usability.ts +3 -0
  20. package/src/codex/app-server-processes.ts +269 -37
  21. package/src/codex/auth-api.ts +22 -5
  22. package/src/codex/auth-context.ts +108 -3
  23. package/src/codex/catalog/aggregation.ts +3 -0
  24. package/src/codex/catalog/metadata.ts +17 -3
  25. package/src/codex/catalog/native-models.ts +22 -14
  26. package/src/codex/catalog/parsing.ts +20 -3
  27. package/src/codex/catalog/provider-fetch.ts +8 -0
  28. package/src/codex/catalog/sync.ts +63 -15
  29. package/src/codex/convergence.ts +61 -13
  30. package/src/codex/log-guard/path-safety.ts +52 -3
  31. package/src/codex/model-entitlements.ts +353 -0
  32. package/src/codex/native-profile-startup.ts +100 -2
  33. package/src/codex/quota.ts +28 -3
  34. package/src/codex/routing.ts +14 -8
  35. package/src/codex/user-identity.ts +21 -1
  36. package/src/config/provider-name.ts +24 -0
  37. package/src/config.ts +11 -24
  38. package/src/generated/compatibility-version.json +110 -70
  39. package/src/images/loop.ts +11 -4
  40. package/src/lib/destination-policy.ts +47 -0
  41. package/src/lib/shadow-call.ts +15 -0
  42. package/src/lib/state-store-registrations.ts +8 -2
  43. package/src/oauth/index.ts +33 -5
  44. package/src/oauth/store.ts +11 -5
  45. package/src/providers/antigravity-models.ts +70 -5
  46. package/src/providers/derive.ts +12 -2
  47. package/src/providers/fastwire.ts +39 -8
  48. package/src/providers/quota.ts +9 -2
  49. package/src/providers/registry.ts +120 -6
  50. package/src/providers/service-tier.ts +50 -15
  51. package/src/responses/parser.ts +59 -11
  52. package/src/responses/state.ts +162 -5
  53. package/src/responses/tool-search-compat.ts +301 -0
  54. package/src/router.ts +17 -3
  55. package/src/routing/capability.ts +26 -9
  56. package/src/routing/compatibility/behavior.ts +44 -6
  57. package/src/routing/profile.ts +1 -1
  58. package/src/server/chat-native.ts +11 -2
  59. package/src/server/index.ts +59 -8
  60. package/src/server/management/agent-settings-routes.ts +16 -2
  61. package/src/server/management/shared.ts +3 -1
  62. package/src/server/request-log.ts +31 -0
  63. package/src/server/responses/collaboration.ts +34 -9
  64. package/src/server/responses/compact.ts +54 -7
  65. package/src/server/responses/core.ts +259 -43
  66. package/src/server/responses/input-admission.ts +7 -2
  67. package/src/server/responses/responses-field-backfill.ts +88 -6
  68. package/src/server/responses/terminal-guard.ts +10 -0
  69. package/src/server/responses-tool-search-repair.ts +217 -0
  70. package/src/server/system-env.ts +74 -5
  71. package/src/service-manager-probe.ts +99 -0
  72. package/src/service.ts +86 -6
  73. package/src/tray/windows.ts +25 -5
  74. package/src/types/accounts.ts +37 -0
  75. package/src/types/config.ts +818 -0
  76. package/src/types/provider.ts +521 -0
  77. package/src/types/request.ts +358 -0
  78. package/src/types/tools.ts +131 -0
  79. package/src/types/wire.ts +80 -0
  80. package/src/types.ts +103 -1883
  81. package/src/usage/cost.ts +37 -1
  82. package/src/usage/log.ts +4 -0
  83. package/src/web-search/loop.ts +11 -4
@@ -14,11 +14,15 @@ import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalo
14
14
  import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
15
15
  import {
16
16
  canForwardForeignServiceTierForChatModel,
17
+ fastPolicyForModel,
17
18
  supportsServiceTierForModel,
18
19
  } from "../providers/service-tier";
19
20
  import {
20
21
  canonicalFastTierMarker,
21
22
  createAdapterTierMetadata,
23
+ decideTier,
24
+ type AdapterTierMetadata,
25
+ type ResolvedFastPolicy,
22
26
  } from "../providers/fastwire";
23
27
  import { openaiChatCompletionsUrl } from "./openai-chat-url";
24
28
  import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
@@ -95,6 +99,8 @@ export function buildOpenAIChatPassthroughRequest(
95
99
  rawBody: Record<string, unknown>,
96
100
  modelId: string,
97
101
  stream: boolean,
102
+ fastPolicy: ResolvedFastPolicy = fastPolicyForModel(provider, modelId, undefined, "chat"),
103
+ fastMode?: boolean,
98
104
  ): AdapterRequest {
99
105
  const { url, headers, hasCredential } = openAIChatTransport(provider);
100
106
 
@@ -122,7 +128,16 @@ export function buildOpenAIChatPassthroughRequest(
122
128
  // `<listed>:<tag>` siblings the operator never opted out, silently returning prose.
123
129
  if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format;
124
130
 
125
- if (provider.chatServiceTier && rawBody.service_tier !== undefined) {
131
+ // Run the same complete Fast policy as the translated Chat path, including explicit
132
+ // fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still
133
+ // retains the caller's exact spelling; forced Fast uses the policy-owned wire value.
134
+ const callerTier = typeof rawBody.service_tier === "string" ? rawBody.service_tier : undefined;
135
+ const tierDecision = decideTier(fastPolicy, fastMode, callerTier);
136
+ if (tierDecision.kind === "set") {
137
+ body.service_tier = fastMode === undefined && canonicalFastTierMarker(callerTier) !== undefined
138
+ ? callerTier
139
+ : tierDecision.value;
140
+ } else if (tierDecision.kind === "forward-caller" && rawBody.service_tier !== undefined) {
126
141
  body.service_tier = rawBody.service_tier;
127
142
  }
128
143
  if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) {
@@ -307,8 +322,13 @@ function invalidToolCallsEvent(
307
322
  rawToolCalls: unknown,
308
323
  mode: "stream" | "response",
309
324
  usage?: OcxUsage,
325
+ diagnosticOverride?: InvalidToolCallDiagnostic,
310
326
  ): Extract<AdapterEvent, { type: "error" }> {
311
- const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode);
327
+ // The streamed accumulator knows things a rescan cannot: which field on which pending call
328
+ // was actually rejected. Without the override, a stream carrying accepted padding on call 0
329
+ // and a real defect on call 1 blames call 0, because the stateless scan stops at the first
330
+ // structurally odd value it sees.
331
+ const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode);
312
332
  const detail = diagnostic
313
333
  ? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})`
314
334
  : "";
@@ -526,9 +546,13 @@ function diagnoseInvalidToolCalls(
526
546
  return undefined;
527
547
  }
528
548
 
529
- function logInvalidToolCalls(mode: "stream" | "response", rawToolCalls: unknown): void {
549
+ function logInvalidToolCalls(
550
+ mode: "stream" | "response",
551
+ rawToolCalls: unknown,
552
+ diagnosticOverride?: InvalidToolCallDiagnostic,
553
+ ): void {
530
554
  if (!isDebugEnabled()) return;
531
- const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode);
555
+ const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode);
532
556
  if (!diagnostic) return;
533
557
  const fieldShape = fingerprintInvalidField(invalidToolCallField(rawToolCalls, diagnostic));
534
558
  debugProviderDiagnostic("openai-chat", "invalid-tool-calls", {
@@ -1156,7 +1180,11 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown
1156
1180
  if (!isXaiObjectSchema(parameters)) return undefined;
1157
1181
  const resolved = resolveXaiSchemaRefs(parameters, parameters);
1158
1182
  if (!isXaiObjectSchema(resolved)) return undefined;
1159
- const variants = expandXaiRootObjectSchemas(resolved);
1183
+
1184
+ const normalizedRoot = { ...resolved };
1185
+ delete normalizedRoot.$schema;
1186
+
1187
+ const variants = expandXaiRootObjectSchemas(normalizedRoot);
1160
1188
  if (!variants) return undefined;
1161
1189
  if (variants.length === 1) {
1162
1190
  return xaiVariantIsConcreteObject(variants[0]) ? variants[0] : undefined;
@@ -1166,7 +1194,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown
1166
1194
  if (!additionalProperties.ok) return undefined;
1167
1195
  if (!xaiPropertyMergeIsLossless(variants)) return undefined;
1168
1196
 
1169
- const metadata = Object.fromEntries(Object.entries(resolved).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
1197
+ const metadata = Object.fromEntries(Object.entries(normalizedRoot).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
1170
1198
  delete metadata.properties;
1171
1199
  delete metadata.required;
1172
1200
  delete metadata.additionalProperties;
@@ -1180,6 +1208,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown
1180
1208
  propertyValues.set(name, values);
1181
1209
  }
1182
1210
  }
1211
+
1183
1212
  const properties = Object.fromEntries(
1184
1213
  [...propertyValues].map(([name, values]) => [name, mergeXaiPropertySchemas(values)]),
1185
1214
  );
@@ -1196,7 +1225,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown
1196
1225
 
1197
1226
  function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
1198
1227
  if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
1199
- const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice));
1228
+ const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools));
1200
1229
  if (tools.length === 0) return undefined;
1201
1230
  const xaiTarget = isXaiSchemaTarget(provider);
1202
1231
  const formatted = tools.flatMap(t => {
@@ -1284,6 +1313,26 @@ function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: stri
1284
1313
  return fraction === undefined ? undefined : Math.max(1, Math.floor(maxBudget * fraction));
1285
1314
  }
1286
1315
 
1316
+ function canSerializeOpenAIChatServiceTier(
1317
+ provider: OcxProviderConfig,
1318
+ modelId: string,
1319
+ serviceTier: unknown,
1320
+ tierDecision?: OcxParsedRequest["options"]["tierDecision"],
1321
+ ): boolean {
1322
+ if (serviceTier === undefined) return false;
1323
+ if (tierDecision !== undefined) {
1324
+ return tierDecision.kind === "set" || tierDecision.kind === "forward-caller";
1325
+ }
1326
+ // No decision from the router means this call did not go through the tier state machine, so
1327
+ // ask that machine rather than re-deriving a looser answer beside it. The previous fallback
1328
+ // returned true whenever foreign forwarding was allowed at all, which let a caller tier
1329
+ // reach the wire in cases `decideTier` would have dropped — the two paths disagreeing is
1330
+ // precisely the bug, so there is now only one authority.
1331
+ const callerTier = typeof serviceTier === "string" ? serviceTier : undefined;
1332
+ const decision = decideTier(fastPolicyForModel(provider, modelId, undefined, "chat"), undefined, callerTier);
1333
+ return decision.kind === "set" || decision.kind === "forward-caller";
1334
+ }
1335
+
1287
1336
  export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
1288
1337
  return {
1289
1338
  name: "openai-chat",
@@ -1306,13 +1355,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1306
1355
  // unclassified Chat routes remain behind the caller-forwarding opt-in.
1307
1356
  const serviceTier = parsed.options.serviceTier;
1308
1357
  const tierDecision = parsed.options.tierDecision;
1309
- const callerCanonicalFast = canonicalFastTierMarker(serviceTier) !== undefined;
1310
- const callerTierForwardAllowed = canForwardForeignServiceTierForChatModel(provider, parsed.modelId);
1311
- const canonicalFastCapability = callerCanonicalFast
1312
- && supportsServiceTierForModel(provider, parsed.modelId) === true;
1313
- const canSerializeServiceTier = tierDecision?.kind === "set"
1314
- || tierDecision?.kind === "forward-caller"
1315
- || (tierDecision === undefined && (callerTierForwardAllowed || canonicalFastCapability));
1358
+ const canSerializeServiceTier = canSerializeOpenAIChatServiceTier(
1359
+ provider,
1360
+ parsed.modelId,
1361
+ serviceTier,
1362
+ tierDecision,
1363
+ );
1316
1364
  if (canSerializeServiceTier && serviceTier !== undefined) {
1317
1365
  body.service_tier = serviceTier;
1318
1366
  }
@@ -1478,7 +1526,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1478
1526
  };
1479
1527
  },
1480
1528
 
1481
- async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
1529
+ async *parseStream(
1530
+ response: Response,
1531
+ budget: TranslatorBudget,
1532
+ tierMetadata?: AdapterTierMetadata,
1533
+ ): AsyncGenerator<AdapterEvent> {
1482
1534
  if (!response.body) {
1483
1535
  yield { type: "error", message: "No response body" };
1484
1536
  return;
@@ -1489,7 +1541,20 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1489
1541
  const budgetEncoder = new TextEncoder();
1490
1542
  let buffer = "";
1491
1543
  let bufferBytes = 0;
1492
- interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number }
1544
+ interface PendingToolCall {
1545
+ key: string;
1546
+ id: string;
1547
+ name: string;
1548
+ args: string;
1549
+ argsBytes: number;
1550
+ /**
1551
+ * Whether this call has ever received `arguments` as an actual string, empty included.
1552
+ * An empty string still counts: it proves the upstream sent the field with the right
1553
+ * wire type, which is what a later malformed repeat of that field would be padding for.
1554
+ * A canonical NAME is not evidence about the ARGUMENTS field and must not stand in.
1555
+ */
1556
+ sawArgumentsString: boolean;
1557
+ }
1493
1558
  const pendingToolCalls: PendingToolCall[] = [];
1494
1559
  let toolCallSeq = 0;
1495
1560
  const closeToolCalls = (): PendingToolCall[] => {
@@ -1547,11 +1612,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1547
1612
  try {
1548
1613
  parsed = JSON.parse(payload);
1549
1614
  } catch {
1615
+ tierMetadata?.markResponseUnparseable();
1550
1616
  yield { type: "error", message: "malformed upstream SSE data frame" };
1551
1617
  return "terminate";
1552
1618
  }
1553
1619
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "continue";
1554
1620
  const chunk = parsed as Record<string, unknown>;
1621
+ if (Object.hasOwn(chunk, "service_tier")) {
1622
+ tierMetadata?.observeResponseServiceTier(chunk.service_tier);
1623
+ }
1555
1624
 
1556
1625
  if (chunk.error !== undefined && chunk.error !== null) {
1557
1626
  const event = upstreamErrorEvent(chunk.error, pendingUsage);
@@ -1599,61 +1668,103 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1599
1668
  logInvalidToolCalls("stream", rawToolCalls);
1600
1669
  return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1601
1670
  }
1602
- for (const rawToolCall of rawToolCalls) {
1671
+ for (let callIndex = 0; callIndex < rawToolCalls.length; callIndex++) {
1672
+ const rawToolCall: unknown = rawToolCalls[callIndex];
1603
1673
  if (!isRecord(rawToolCall)) {
1604
- logInvalidToolCalls("stream", rawToolCalls);
1605
- return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1674
+ const diagnostic: InvalidToolCallDiagnostic = {
1675
+ reason: "tool_call_not_object",
1676
+ callIndex,
1677
+ valueType: rawToolCall === null ? "null" : Array.isArray(rawToolCall) ? "array" : typeof rawToolCall,
1678
+ };
1679
+ logInvalidToolCalls("stream", rawToolCalls, diagnostic);
1680
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, diagnostic));
1606
1681
  }
1607
- const tc = rawToolCall as {
1608
- index?: number;
1609
- id?: string;
1610
- function?: { name?: string; arguments?: string };
1611
- };
1612
- // That cast is a TypeScript convenience, not a runtime guarantee: this is
1613
- // upstream JSON. Validate the fields before they are stored, so a non-string
1614
- // name or arguments value fails closed through the #1325 channel here rather
1615
- // than escaping later as a TypeError from string handling at flush time.
1616
- const rawFunction = (rawToolCall as { function?: unknown }).function;
1617
- if (rawFunction !== undefined && rawFunction !== null) {
1618
- if (!isRecord(rawFunction)) {
1619
- logInvalidToolCalls("stream", rawToolCalls);
1620
- return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1621
- }
1622
- const rawName = rawFunction.name;
1623
- const rawArguments = rawFunction.arguments;
1624
- // Some OpenAI-compatible streamers repeat already-sent fields as null on
1625
- // continuation deltas. Treat only null/undefined as absent; every other
1626
- // non-string value still fails closed before entering the accumulator.
1627
- if (isInvalidStreamStringField(rawName) || isInvalidStreamStringField(rawArguments)) {
1628
- logInvalidToolCalls("stream", rawToolCalls);
1629
- return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1630
- }
1631
- }
1632
- if (isInvalidStreamStringField(tc.id)) {
1633
- logInvalidToolCalls("stream", rawToolCalls);
1634
- return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage));
1682
+ // This is upstream JSON, so every field is validated before it is stored: a
1683
+ // malformed value must fail closed through the #1325 channel here rather than
1684
+ // escaping later as a TypeError from string handling at flush time.
1685
+ const rawFunction = rawToolCall.function;
1686
+ if (rawFunction !== undefined && rawFunction !== null && !isRecord(rawFunction)) {
1687
+ const diagnostic: InvalidToolCallDiagnostic = {
1688
+ reason: "tool_call_function_not_object",
1689
+ callIndex,
1690
+ valueType: Array.isArray(rawFunction) ? "array" : typeof rawFunction,
1691
+ };
1692
+ logInvalidToolCalls("stream", rawToolCalls, diagnostic);
1693
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, diagnostic));
1635
1694
  }
1636
- const key = typeof tc.index === "number"
1637
- ? `i:${tc.index}`
1638
- : tc.id
1639
- ? `id:${tc.id}`
1695
+ const fnRecord = isRecord(rawFunction) ? rawFunction : undefined;
1696
+ const rawName = fnRecord?.name;
1697
+ const rawArguments = fnRecord?.arguments;
1698
+ const rawId = rawToolCall.id;
1699
+ const idDelta = typeof rawId === "string" ? rawId : "";
1700
+ const rawIndex = rawToolCall.index;
1701
+
1702
+ // Resolve the pending call BEFORE judging the fields. Some OpenAI-compatible
1703
+ // streamers repeat an already-sent field as a non-string placeholder on a
1704
+ // continuation delta; judging first meant the whole stream died with a 502 even
1705
+ // though the value being repeated was already held in canonical form.
1706
+ const key = typeof rawIndex === "number"
1707
+ ? `i:${rawIndex}`
1708
+ : idDelta
1709
+ ? `id:${idDelta}`
1640
1710
  : pendingToolCalls[pendingToolCalls.length - 1]?.key;
1641
1711
  let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined;
1642
- if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
1712
+ if (!call && idDelta) call = pendingToolCalls.find(c => c.id === idDelta);
1643
1713
  if (!call) {
1644
- call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
1714
+ call = {
1715
+ key: key ?? `seq:${pendingToolCalls.length}`,
1716
+ id: "",
1717
+ name: "",
1718
+ args: "",
1719
+ argsBytes: 0,
1720
+ sawArgumentsString: false,
1721
+ };
1645
1722
  pendingToolCalls.push(call);
1646
1723
  budget.openCall(call.key);
1647
1724
  }
1648
- if (tc.id && !call.id) call.id = tc.id;
1649
- if (tc.function?.name && !call.name) call.name = tc.function.name;
1650
- if (tc.function?.arguments) {
1725
+
1726
+ // Tolerance is per FIELD, keyed on that field's own provenance. A canonical name
1727
+ // says nothing about whether `arguments` was ever sent as a string, so it cannot
1728
+ // authorize a malformed arguments value — that would silently drop a real
1729
+ // argument payload the model intended to send.
1730
+ const rejection: InvalidToolCallDiagnostic | undefined =
1731
+ isInvalidStreamStringField(rawName) && call.name.trim() === ""
1732
+ ? { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof rawName }
1733
+ : isInvalidStreamStringField(rawArguments) && !call.sawArgumentsString
1734
+ ? { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof rawArguments }
1735
+ : isInvalidStreamStringField(rawId) && call.id === ""
1736
+ ? { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawId }
1737
+ : undefined;
1738
+ if (rejection) {
1739
+ logInvalidToolCalls("stream", rawToolCalls, rejection);
1740
+ return yield* terminateWithError(invalidToolCallsEvent(rawToolCalls, "stream", pendingUsage, rejection));
1741
+ }
1742
+
1743
+ if (idDelta && !call.id) call.id = idDelta;
1744
+ if (typeof rawName === "string" && rawName && !call.name) call.name = rawName;
1745
+ if (typeof rawArguments === "string") call.sawArgumentsString = true;
1746
+ // Tool-call deltas are BUFFERED until a terminal signal, so this adapter can
1747
+ // consume upstream frames for a long time while yielding nothing. The Responses
1748
+ // bridge reads adapter activity, not socket activity, so a model that streams a
1749
+ // large argument payload looks identical to a hung upstream and the stall
1750
+ // watchdog can abort a turn that was progressing normally.
1751
+ //
1752
+ // Found while investigating #2156, but it is NOT that bug: a stall abort emits
1753
+ // `response.incomplete` with `upstream_stall_timeout` from the bridge, whereas
1754
+ // that report shows the adapter's own end-of-stream error after `reader.read()`
1755
+ // returned EOF with tool calls still pending. Different path, different frame.
1756
+ //
1757
+ // A heartbeat is invisible downstream — the bridge consumes it to re-arm the
1758
+ // watchdog and emits nothing — which is the same remedy the Cursor, Anthropic,
1759
+ // Google, and Kiro adapters already use for their own silent phases.
1760
+ yield { type: "heartbeat" };
1761
+ if (typeof rawArguments === "string" && rawArguments) {
1651
1762
  const previousBytes = call.argsBytes;
1652
- const nextBytes = previousBytes + budgetEncoder.encode(tc.function.arguments).byteLength;
1763
+ const nextBytes = previousBytes + budgetEncoder.encode(rawArguments).byteLength;
1653
1764
  const scope = { kind: "tool_args" as const, callId: call.key };
1654
1765
  const reservation = budget.reserveTransient(nextBytes, scope);
1655
1766
  try {
1656
- call.args += tc.function.arguments;
1767
+ call.args += rawArguments;
1657
1768
  reservation.commitRetained();
1658
1769
  budget.releaseRetained(previousBytes, scope);
1659
1770
  call.argsBytes = nextBytes;
@@ -1752,8 +1863,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1752
1863
  }
1753
1864
  },
1754
1865
 
1755
- async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
1756
- const json = await response.json() as Record<string, unknown>;
1866
+ async parseResponse(
1867
+ response: Response,
1868
+ budget: TranslatorBudget,
1869
+ tierMetadata?: AdapterTierMetadata,
1870
+ ): Promise<AdapterEvent[]> {
1871
+ let parsed: unknown;
1872
+ try {
1873
+ parsed = await response.json();
1874
+ } catch (error) {
1875
+ tierMetadata?.markResponseUnparseable();
1876
+ throw error;
1877
+ }
1878
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1879
+ tierMetadata?.markResponseUnparseable();
1880
+ throw new Error("upstream response was not a JSON object");
1881
+ }
1882
+ const json = parsed as Record<string, unknown>;
1883
+ if (Object.hasOwn(json, "service_tier")) {
1884
+ tierMetadata?.observeResponseServiceTier(json.service_tier);
1885
+ }
1757
1886
  const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
1758
1887
  budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
1759
1888
  try {
@@ -11,6 +11,7 @@ import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
11
11
  import { modelRecordValue } from "../reasoning-effort";
12
12
  import type { TranslatorBudget } from "../lib/translator-budget";
13
13
  import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat";
14
+ import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat";
14
15
  import { openaiResponsesUrl } from "./openai-responses-url";
15
16
  import {
16
17
  createAdapterTierMetadata,
@@ -185,6 +186,28 @@ function stripUnsupportedReasoningParams(body: unknown): unknown {
185
186
  return { ...body, reasoning: Object.keys(rest).length > 0 ? rest : undefined };
186
187
  }
187
188
 
189
+ /**
190
+ * GPT-5.6 replaced the legacy 24-hour retention field with `prompt_cache_options.ttl`, and the
191
+ * ChatGPT backend 400s the whole request when the retired field is present (issue #2092).
192
+ *
193
+ * The retired field is NOT translated to the replacement: 5.6 carries a different TTL contract,
194
+ * and implicit caching still applies when the caller sent no replacement options. Inventing a
195
+ * value here would silently change a caching decision the caller never made.
196
+ *
197
+ * Deliberately narrow on both axes, because a wider strip is a behavior change rather than a fix:
198
+ * only the gpt-5.6 family (an older model may still honor the field), and only on the canonical
199
+ * ChatGPT backend, which is the deployment that rejects it. Matching is exact-or-dashed-prefix so
200
+ * a future `gpt-5.60` is not swept up by a bare `startsWith`.
201
+ */
202
+ function stripDeprecatedPromptCacheRetention(body: unknown, modelId: unknown): unknown {
203
+ if (!isPlainObject(body)) return body;
204
+ if (typeof modelId !== "string") return body;
205
+ if (modelId !== "gpt-5.6" && !modelId.startsWith("gpt-5.6-")) return body;
206
+ if (!Object.hasOwn(body, "prompt_cache_retention")) return body;
207
+ const { prompt_cache_retention: _retention, ...rest } = body;
208
+ return rest;
209
+ }
210
+
188
211
  /**
189
212
  * A false model capability prevents Codex from emitting summary fields after the catalog refresh.
190
213
  * Strip them here as well so an already-running client with a stale catalog cannot keep sending an
@@ -1468,6 +1491,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1468
1491
 
1469
1492
  const forward = provider.authMode === "forward";
1470
1493
  let convertedRoutedCustomToolNames: Set<string> | undefined;
1494
+ let convertedRoutedToolSearchNames: Set<string> | undefined;
1471
1495
  const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true;
1472
1496
  let outBody = stripPreviousResponseId(
1473
1497
  parsed._rawBody,
@@ -1491,6 +1515,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1491
1515
  }
1492
1516
  if (forward) {
1493
1517
  outBody = stripUnsupportedForwardParams(outBody);
1518
+ // Only the canonical ChatGPT backend rejects the retired field; a self-hosted or
1519
+ // third-party forward gateway may still accept it, so this must not be widened.
1520
+ if (isCanonicalOpenAiForwardProvider(provider)) {
1521
+ outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId);
1522
+ }
1494
1523
  } else {
1495
1524
  outBody = preferConfiguredHostedTools(
1496
1525
  outBody,
@@ -1522,6 +1551,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1522
1551
  outBody = rewritten.body;
1523
1552
  convertedRoutedCustomToolNames = rewritten.names;
1524
1553
  }
1554
+ if (!isCanonicalOpenAiForwardProvider(provider)) {
1555
+ // Run after custom-tool lowering so the search compatibility layer can choose a
1556
+ // collision-free public function name against the final routed function catalog.
1557
+ const rewritten = rewriteRoutedToolSearchForUpstream(outBody);
1558
+ outBody = rewritten.body;
1559
+ convertedRoutedToolSearchNames = rewritten.names;
1560
+ }
1525
1561
  const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true })))))));
1526
1562
  const finalBody = stripDisabledReasoningSummaries(
1527
1563
  normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
@@ -1549,6 +1585,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1549
1585
  body,
1550
1586
  releaseBodyObservation,
1551
1587
  ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
1588
+ ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}),
1552
1589
  ...(tierLog ? { tierLog } : {}),
1553
1590
  };
1554
1591
  },
@@ -135,7 +135,7 @@ export function buildNonOpenAIToolCatalogNudgeForTools(
135
135
  toolChoice?: OcxRequestOptions["toolChoice"],
136
136
  toWireName: (tool: Pick<OcxTool, "namespace" | "name">) => string = tool => namespacedToolName(tool.namespace, tool.name),
137
137
  ): string | undefined {
138
- const visible = tools?.filter(toolChoiceToolPredicate(toolChoice));
138
+ const visible = tools?.filter(toolChoiceToolPredicate(toolChoice, tools));
139
139
  const visibleNames = visible?.map(toWireName);
140
140
  // Decide code mode from the tool OBJECTS, while the `freeform` flag still exists — reducing
141
141
  // to wire names first throws away the only thing that distinguishes Codex's JavaScript
package/src/bridge.ts CHANGED
@@ -167,7 +167,7 @@ export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete";
167
167
  export function bridgeToResponsesSSE(
168
168
  events: AsyncIterable<AdapterEvent>,
169
169
  modelId: string,
170
- toolNsMap?: Map<string, { namespace: string; name: string }>,
170
+ toolNsMap?: Map<string, { namespace: string; name: string; freeform?: true }>,
171
171
  freeformToolNames?: Set<string>,
172
172
  toolSearchToolNames?: Set<string>,
173
173
  onCancel?: () => void,
@@ -1050,7 +1050,9 @@ export function bridgeToResponsesSSE(
1050
1050
  }
1051
1051
  const ns = mapped?.namespace;
1052
1052
  const toolSearch = toolSearchToolNames?.has(realName) ?? false;
1053
- const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false);
1053
+ const freeform = !toolSearch && (mapped
1054
+ ? mapped.freeform === true
1055
+ : (freeformToolNames?.has(realName) ?? false));
1054
1056
  const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`;
1055
1057
  const item = toolSearch
1056
1058
  ? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" }
@@ -1451,7 +1453,7 @@ function buildResponseJSONWithBudget(
1451
1453
  modelId: string,
1452
1454
  options?: {
1453
1455
  hideThinkingSummary?: boolean;
1454
- toolNsMap?: Map<string, { namespace: string; name: string }>;
1456
+ toolNsMap?: Map<string, { namespace: string; name: string; freeform?: true }>;
1455
1457
  /** Request-visible tool names. When present, an upstream call outside this set fails closed. */
1456
1458
  declaredToolNames?: ReadonlySet<string>;
1457
1459
  /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */
@@ -1632,7 +1634,9 @@ function buildResponseJSONWithBudget(
1632
1634
  const realName = mapped?.name ?? currentToolCallName;
1633
1635
  const ns = mapped?.namespace;
1634
1636
  const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
1635
- const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
1637
+ const freeform = !toolSearch && (mapped
1638
+ ? mapped.freeform === true
1639
+ : (options?.freeformToolNames?.has(realName) ?? false));
1636
1640
  // #1611: same integral-float repair as the streaming path. Keyed by the wire name
1637
1641
  // the request declared, which is the pre-namespace-mapping `currentToolCallName`.
1638
1642
  const coercedArgs = coerceIntegerToolArguments(
@@ -1796,7 +1800,9 @@ function buildResponseJSONWithBudget(
1796
1800
  const mapped = options?.toolNsMap?.get(currentToolCallName);
1797
1801
  const realName = mapped?.name ?? currentToolCallName;
1798
1802
  const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
1799
- const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
1803
+ const freeform = !toolSearch && (mapped
1804
+ ? mapped.freeform === true
1805
+ : (options?.freeformToolNames?.has(realName) ?? false));
1800
1806
  if (!freeform && !toolSearch) {
1801
1807
  flushToolCall("incomplete");
1802
1808
  errorEvent = {
package/src/cli/doctor.ts CHANGED
@@ -25,6 +25,11 @@ import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHome
25
25
  import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback";
26
26
  import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
27
27
  import { countPendingOpencodexHistory } from "../codex/history-provider";
28
+ import {
29
+ inspectAbandonedResponseStateTemps,
30
+ reclaimAbandonedResponseStateTemps,
31
+ type ResponseStateTempRecoveryResult,
32
+ } from "../responses/state";
28
33
  import {
29
34
  CodexUserIdentityRefusal,
30
35
  probeCodexCoordinatorNamespace,
@@ -678,6 +683,57 @@ export async function fetchServiceMemory(
678
683
 
679
684
  const mb = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))}MB`;
680
685
 
686
+ export const RECLAIM_RESPONSE_TEMPS_FLAG = "--reclaim-response-temps";
687
+ /** Matches the dry run's entry bound so report and reclaim agree on a large backlog. */
688
+ const RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS = 4_096;
689
+ /** Names the subsystem: other components mint temps with the same shape and are not covered. */
690
+ const CLEAN_RESPONSE_TEMP_LINE = " ok No abandoned response-state temp files.";
691
+
692
+ /**
693
+ * Render the abandoned-temp section (testable without console capture).
694
+ *
695
+ * Report is the DEFAULT and reclaim is opt-in: `doctor` is a diagnostic an operator runs
696
+ * to understand a machine, so deleting files as a side effect of asking a question is the
697
+ * wrong default even for cache files.
698
+ *
699
+ * Counts come from `eligible`/`eligibleBytes`, never `matched`: `matched` is incremented
700
+ * before the file-type, age, boot-floor, and liveness gates, so reporting it would tell an
701
+ * operator that live-pid temps and young temps are "abandoned".
702
+ */
703
+ export function formatResponseTempLines(
704
+ result: ResponseStateTempRecoveryResult,
705
+ reclaimed: boolean,
706
+ ): string[] {
707
+ if (reclaimed) {
708
+ if (result.removed === 0 && result.failed === 0) return [CLEAN_RESPONSE_TEMP_LINE];
709
+ const lines = [` ok Reclaimed ${result.removed} abandoned response-state temp file(s), ${mb(result.bytesRemoved)} freed.`];
710
+ if (result.failed > 0) {
711
+ // Never "retried automatically": this command exists for the operator whose proxy will
712
+ // NOT start, and in that state nothing retries anything.
713
+ 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.`);
714
+ }
715
+ // `truncated`, not `eligible > removed + failed`: outside a dry run every eligible entry
716
+ // is unlinked or failed on the same iteration it is counted, so those two are always
717
+ // equal and the comparison never fired. An operator with a backlog past the budget was
718
+ // told the reclaim had finished.
719
+ if (result.truncated) {
720
+ lines.push(" !! Cleanup budget reached; files remain. Run the command again to continue.");
721
+ }
722
+ return lines;
723
+ }
724
+ if (result.eligible === 0) return [CLEAN_RESPONSE_TEMP_LINE];
725
+ const lines = [
726
+ ` !! ${result.eligible} abandoned response-state temp file(s), ${mb(result.eligibleBytes)} reclaimable.`,
727
+ " These are interrupted snapshot writes (continuation cache only) and are safe to remove.",
728
+ " Reclaim them with: ocx doctor --reclaim-response-temps",
729
+ ];
730
+ // The dry run skips the cleanup budget but is still bounded by the entry cap, so a large
731
+ // enough backlog makes this a floor rather than a total. Say so instead of letting an
732
+ // operator size the problem from a truncated count.
733
+ if (result.truncated) lines.push(" Scan stopped at its entry budget; the real total is higher.");
734
+ return lines;
735
+ }
736
+
681
737
  /** Render the doctor "Memory / runtime" section lines (testable without console capture). */
682
738
  export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] {
683
739
  const lines: string[] = [];
@@ -805,6 +861,26 @@ export async function runDoctor(args: string[] = []): Promise<void> {
805
861
  console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`);
806
862
  }
807
863
 
864
+ // Runs without the proxy on purpose: the worst accumulation happens when the proxy will
865
+ // not start, which is exactly when the in-process periodic reclaim never ticks.
866
+ const reclaimTemps = args.includes(RECLAIM_RESPONSE_TEMPS_FLAG);
867
+ console.log("\nResponse-state temp files");
868
+ // A typo must not silently degrade into "nothing to reclaim" — the operator would read the
869
+ // report as an answer to a question they never actually asked.
870
+ for (const arg of args) {
871
+ if (arg !== RECLAIM_RESPONSE_TEMPS_FLAG && /^--reclaim/.test(arg)) {
872
+ console.log(` !! Unrecognized flag ${arg}; did you mean ${RECLAIM_RESPONSE_TEMPS_FLAG}? Reporting only.`);
873
+ }
874
+ }
875
+ for (const line of formatResponseTempLines(
876
+ // The reclaim budget matches the report budget: a report bounded by entries and a removal
877
+ // bounded by a smaller cleanup cap would tell an operator 816 and then silently free 512.
878
+ reclaimTemps
879
+ ? reclaimAbandonedResponseStateTemps({ maxCleanups: RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS })
880
+ : inspectAbandonedResponseStateTemps(),
881
+ reclaimTemps,
882
+ )) console.log(line);
883
+
808
884
  const orcaHome = collectOrcaCodexHomeDiagnostic();
809
885
  console.log("\nCodex app home targeting");
810
886
  console.log(` ${orcaHome.mismatch ? "!! " : "ok "} Effective Codex home: ${orcaHome.effectiveCodexHome}`);
package/src/cli/help.ts CHANGED
@@ -36,6 +36,8 @@ Usage:
36
36
  Refresh Codex's model cache from the active catalog
37
37
  ocx status Check proxy server status
38
38
  ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
39
+ ocx doctor --reclaim-response-temps
40
+ Reclaim abandoned response-state temp files (works without a running proxy)
39
41
  ocx debug <scope> provider/usage/injection/claude on|off|status|reset
40
42
  ocx login <provider> OAuth or API-key provider login
41
43
  ocx logout <provider> Remove a stored OAuth login