@bitkyc08/opencodex 2.28.0 → 2.29.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 (90) hide show
  1. package/README.md +9 -1
  2. package/gui/dist/assets/index-BNESwCzn.js +102 -0
  3. package/gui/dist/assets/index-CH7ncHCC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/base.ts +3 -1
  7. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  8. package/src/adapters/cursor/discovery.ts +25 -0
  9. package/src/adapters/cursor/live-transport.ts +19 -2
  10. package/src/adapters/cursor/native-exec.ts +47 -2
  11. package/src/adapters/cursor/protobuf-request.ts +80 -23
  12. package/src/adapters/cursor/request-builder.ts +119 -5
  13. package/src/adapters/cursor/transport.ts +5 -0
  14. package/src/adapters/cursor/types.ts +13 -0
  15. package/src/adapters/cursor.ts +109 -3
  16. package/src/adapters/google-antigravity-replay.ts +31 -4
  17. package/src/adapters/google.ts +323 -38
  18. package/src/adapters/openai-chat.ts +19 -0
  19. package/src/adapters/openai-responses.ts +218 -37
  20. package/src/bridge.ts +38 -20
  21. package/src/claude/desktop-3p.ts +15 -6
  22. package/src/cli/agent.ts +44 -1
  23. package/src/cli/claude-agent-startup-sync.ts +73 -0
  24. package/src/cli/dispatch.ts +24 -1
  25. package/src/cli/ensure-desired-integrations.ts +152 -0
  26. package/src/cli/help.ts +1 -1
  27. package/src/cli/index.ts +40 -38
  28. package/src/cli/integrations.ts +1 -1
  29. package/src/cli/registry.ts +2 -2
  30. package/src/clients/config-export.ts +119 -20
  31. package/src/codex/affinity-debug.ts +162 -0
  32. package/src/codex/inject.ts +46 -14
  33. package/src/codex/journal.ts +22 -8
  34. package/src/config.ts +1 -0
  35. package/src/generated/compatibility-version.json +134 -66
  36. package/src/integrations/mutation-flight.ts +71 -0
  37. package/src/integrations/owned-refresh.ts +74 -0
  38. package/src/integrations/registry.ts +25 -2
  39. package/src/integrations/writer.ts +32 -1
  40. package/src/lab/public/signature.ts +25 -1
  41. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  42. package/src/lib/redact.ts +2 -2
  43. package/src/oauth/log.ts +3 -1
  44. package/src/providers/derive.ts +9 -0
  45. package/src/providers/fastwire.ts +24 -18
  46. package/src/providers/openai-tiers.ts +60 -1
  47. package/src/providers/registry.ts +14 -7
  48. package/src/providers/xai-responses-opt-in.ts +15 -0
  49. package/src/responses/compaction.ts +18 -0
  50. package/src/responses/custom-tool-compat.ts +70 -5
  51. package/src/responses/namespace-tool-compat.ts +356 -0
  52. package/src/responses/parser.ts +2 -2
  53. package/src/responses/provider-continuation.ts +98 -0
  54. package/src/responses/reasoning-replay-cache.ts +125 -7
  55. package/src/responses/spill-store.ts +6 -1
  56. package/src/responses/state.ts +11 -0
  57. package/src/router.ts +14 -0
  58. package/src/routing/compatibility/behavior.ts +1 -0
  59. package/src/server/auth-cors.ts +4 -0
  60. package/src/server/management/agent-settings-routes.ts +57 -9
  61. package/src/server/management/config-routes.ts +134 -15
  62. package/src/server/management/integration-routes.ts +8 -55
  63. package/src/server/management/model-routes.ts +23 -1
  64. package/src/server/management/provider-routes.ts +22 -0
  65. package/src/server/management/vision-sidecar-options.ts +18 -11
  66. package/src/server/management/web-search-sidecar-options.ts +120 -0
  67. package/src/server/responses/core.ts +588 -78
  68. package/src/server/responses/responses-field-backfill.ts +7 -11
  69. package/src/server/responses/terminal-guard.ts +22 -11
  70. package/src/server/responses-custom-tool-repair.ts +3 -1
  71. package/src/server/responses-reasoning-summary-rewrite.ts +7 -0
  72. package/src/server/responses-tool-search-repair.ts +64 -14
  73. package/src/sidecar/auth.ts +92 -0
  74. package/src/sidecar/candidates.ts +83 -0
  75. package/src/types/config.ts +26 -5
  76. package/src/types/provider.ts +18 -0
  77. package/src/types/request.ts +26 -0
  78. package/src/types.ts +1 -0
  79. package/src/usage/log.ts +2 -0
  80. package/src/vision/index.ts +8 -9
  81. package/src/web-search/backends.ts +108 -0
  82. package/src/web-search/exa-executor.ts +88 -0
  83. package/src/web-search/gemini-executor.ts +141 -0
  84. package/src/web-search/index.ts +139 -12
  85. package/src/web-search/loop.ts +45 -5
  86. package/src/web-search/parse.ts +34 -23
  87. package/src/web-search/sources.ts +60 -0
  88. package/src/web-search/xai-executor.ts +219 -0
  89. package/gui/dist/assets/index-D2sP-biU.js +0 -102
  90. package/gui/dist/assets/index-DQsMZzI5.css +0 -1
@@ -2,16 +2,22 @@ import { createHash } from "node:crypto";
2
2
  import type { IncomingMeta, ProviderAdapter } from "./base";
3
3
  import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types";
4
4
  import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
5
- import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
5
+ import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../responses/compaction";
6
6
  import { collectResponsesToolGroups } from "../responses/tool-groups";
7
7
  import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy";
8
8
  import { decodeServerSentEvents } from "../lib/sse-decoder";
9
- import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
9
+ import {
10
+ CODEX_FORWARD_BASE_URL,
11
+ destinationDecodesNativeCompactionBlob,
12
+ isCanonicalOpenAiForwardProvider,
13
+ isOpenAiOperatedResponsesDestination,
14
+ } from "../providers/openai-tiers";
10
15
  import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
11
16
  import { modelRecordValue } from "../reasoning-effort";
12
17
  import type { TranslatorBudget } from "../lib/translator-budget";
13
18
  import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat";
14
19
  import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat";
20
+ import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat";
15
21
  import { openaiResponsesUrl } from "./openai-responses-url";
16
22
  import {
17
23
  createAdapterTierMetadata,
@@ -41,7 +47,11 @@ export const FORWARD_HEADERS = [
41
47
 
42
48
  export function sanitizeReasoningInputContent(
43
49
  body: unknown,
44
- opts?: { preserveRawReasoningContent?: boolean },
50
+ opts?: {
51
+ preserveRawReasoningContent?: boolean;
52
+ dropNullContentChannel?: boolean;
53
+ stripEncryptedContent?: boolean;
54
+ },
45
55
  ): unknown {
46
56
  if (!body || typeof body !== "object" || Array.isArray(body)) return body;
47
57
  const raw = body as Record<string, unknown>;
@@ -56,14 +66,40 @@ export function sanitizeReasoningInputContent(
56
66
  // ocxr1 envelopes are proxy-minted (Anthropic signatures), not OpenAI encryption — the native
57
67
  // backend cannot decrypt them and would reject the request. Strip regardless of content shape.
58
68
  const hasOcxEnvelope = typeof rec.encrypted_content === "string" && rec.encrypted_content.startsWith(OCX_REASONING_PREFIX);
59
- if (!hasRawContent && !hasOcxEnvelope) return item;
60
- if (hasOcxEnvelope) {
61
- changed = true;
62
- const next: Record<string, unknown> = { ...rec };
63
- delete next.encrypted_content;
64
- if (!opts?.preserveRawReasoningContent) next.content = [];
65
- return next;
69
+ const hasOutputStatus = Object.prototype.hasOwnProperty.call(rec, "status");
70
+ const hasEncryptedContent = Object.prototype.hasOwnProperty.call(rec, "encrypted_content");
71
+ const stripEncryptedContent = hasOcxEnvelope
72
+ || (opts?.stripEncryptedContent === true && hasEncryptedContent);
73
+ // Codex serializes an absent reasoning content channel as `"content": null`. The field is
74
+ // optional and null carries nothing, but a strict gateway rejects the item on its declared type
75
+ // — xAI answers `Could not decode the compaction blob`, naming the sibling `encrypted_content`
76
+ // rather than the field it actually refused, which is why this reads as a blob failure. Drop the
77
+ // key so the item matches the shape the upstream issued.
78
+ //
79
+ // Gated to routed destinations. An OpenAI-operated backend binds the blob to the item's exact
80
+ // shape, so deleting a field there invalidates it (`The encrypted content ... could not be
81
+ // verified`); the two requirements are exactly opposed, and a live regression proved it. That
82
+ // gate is also why this drop may touch an item that keeps its blob: xAI demonstrably accepts its
83
+ // own blob without the null channel, and the destinations that bind blobs to item shape never
84
+ // reach this branch. This is independent of the output-only status removal below.
85
+ const dropNullContentChannel = opts?.dropNullContentChannel === true
86
+ && "content" in rec && !Array.isArray(rec.content);
87
+ // `status` is output-only. Measured OpenAI reasoning items never contain it, and Grok accepts
88
+ // its own encrypted_content with status removed. Keeping a foreign status beside a retained
89
+ // blob makes OpenAI reject the field before blob validation, starving the provenance recovery
90
+ // of the opaque-blob error it needs. Content blanking remains the separate pre-existing rule.
91
+ const stripOutputStatus = hasOutputStatus;
92
+ const blankContent = !dropNullContentChannel
93
+ && !opts?.preserveRawReasoningContent
94
+ && (hasRawContent || hasOcxEnvelope);
95
+ if (!blankContent && !stripOutputStatus && !stripEncryptedContent && !dropNullContentChannel) {
96
+ return item;
66
97
  }
98
+ changed = true;
99
+ const next: Record<string, unknown> = { ...rec };
100
+ if (dropNullContentChannel) delete next.content;
101
+ if (stripOutputStatus) delete next.status;
102
+ if (stripEncryptedContent) delete next.encrypted_content;
67
103
  // Routed models can produce raw `reasoning_text` output items. Codex echoes those in later
68
104
  // native GPT requests, but ChatGPT's Responses backend accepts reasoning input only with empty
69
105
  // `content`; keep summaries/ids and drop the raw content so native passthrough does not 400.
@@ -71,9 +107,8 @@ export function sanitizeReasoningInputContent(
71
107
  // guide merges reasoning items into the adjacent assistant message), so providers flagged
72
108
  // `preserveResponsesReasoningContent` keep it — deleting valid replay content there breaks
73
109
  // continuations after tool calls (issue #875 family).
74
- if (opts?.preserveRawReasoningContent) return item;
75
- changed = true;
76
- return { ...rec, content: [] };
110
+ if (blankContent) next.content = [];
111
+ return next;
77
112
  });
78
113
 
79
114
  return changed ? { ...raw, input } : body;
@@ -120,6 +155,71 @@ function stripInvalidItemIds(body: unknown): unknown {
120
155
  return changed ? { ...body, input } : body;
121
156
  }
122
157
 
158
+ /**
159
+ * Codex-private tool fields that only the ChatGPT backend understands.
160
+ *
161
+ * A third-party Responses gateway validates its schema and rejects the whole request before
162
+ * inference — xAI answers `Argument not supported: external_web_access` — so these are removed at
163
+ * the noncanonical boundary while the tool and every public option stay.
164
+ *
165
+ * Keep this a table. Each private bit Codex attaches has so far arrived as its own bespoke strip
166
+ * with its own traversal, and the traversals disagreed about which containers they covered; a new
167
+ * one should be a row here instead. `toolTypes` omitted means the field is private on any tool.
168
+ */
169
+ const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet<string>; capabilityGated?: boolean }[] = [
170
+ // ChatGPT's browsing policy bit. The public hosted tool is enabled by its presence alone.
171
+ // OWNERSHIP: official OpenAI API-key traffic and unclassified gateways ACCEPT this field, so
172
+ // it is only stripped when the provider capability denies it (supportsOpenAiWebSearchToolFields
173
+ // === false), matching stripOpenAiOnlyWebSearchFields; see
174
+ // tests/responses-routed-web-search-fields.test.ts.
175
+ { field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]), capabilityGated: true },
176
+ // Deferred-discovery marker. `activateDeferredTool` clears it only for tools a `tool_search_output`
177
+ // already loaded, so a still-deferred declaration — including one promoted out of a namespace
178
+ // group — otherwise reaches the wire carrying it.
179
+ { field: "defer_loading" },
180
+ ];
181
+
182
+ function stripCanonicalOnlyToolFields(body: unknown, includeCapabilityGated: boolean): unknown {
183
+ if (!isPlainObject(body)) return body;
184
+
185
+ const rewriteTools = (tools: unknown[]): unknown[] => {
186
+ let changed = false;
187
+ const rewritten = tools.map(tool => {
188
+ if (!isPlainObject(tool)) return tool;
189
+ let next = tool;
190
+ for (const { field, toolTypes, capabilityGated } of CANONICAL_ONLY_TOOL_FIELDS) {
191
+ if (capabilityGated && !includeCapabilityGated) continue;
192
+ if (!Object.hasOwn(next, field)) continue;
193
+ if (toolTypes && (typeof next.type !== "string" || !toolTypes.has(next.type))) continue;
194
+ const { [field]: _private, ...rest } = next;
195
+ next = rest;
196
+ }
197
+ if (next === tool) return tool;
198
+ changed = true;
199
+ return next;
200
+ });
201
+ return changed ? rewritten : tools;
202
+ };
203
+
204
+ let rewrittenBody = body;
205
+ if (Array.isArray(body.tools)) {
206
+ const tools = rewriteTools(body.tools);
207
+ if (tools !== body.tools) rewrittenBody = { ...rewrittenBody, tools };
208
+ }
209
+ if (!Array.isArray(body.input)) return rewrittenBody;
210
+
211
+ let input: unknown[] | undefined;
212
+ for (let index = 0; index < body.input.length; index += 1) {
213
+ const item = body.input[index];
214
+ if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) continue;
215
+ const tools = rewriteTools(item.tools);
216
+ if (tools === item.tools) continue;
217
+ input ??= [...body.input];
218
+ input[index] = { ...item, tools };
219
+ }
220
+ return input ? { ...rewrittenBody, input } : rewrittenBody;
221
+ }
222
+
123
223
  /**
124
224
  * When `store` is false, the upstream API does not persist response items. Any item ID
125
225
  * forwarded in `input` is then interpreted as a reference to a stored item that does not
@@ -143,25 +243,41 @@ function stripItemIdsWhenUnstored(body: unknown): unknown {
143
243
  }
144
244
 
145
245
  /**
146
- * Replace proxy-minted compaction items (`encrypted_content` starting with `ocx1:`) with plain
147
- * user messages before forwarding to the ChatGPT backend. Our envelope is transparent base64, not
148
- * OpenAI encryption the native backend cannot decrypt it and would reject the request. Real
149
- * OpenAI-encrypted compaction items are forwarded untouched.
246
+ * Normalize replayed compaction items for the destination backend.
247
+ *
248
+ * A compaction item carries an `encrypted_content` blob the client replays verbatim on every later
249
+ * turn, and only the backend that minted it can decode it. Proxy-minted `ocx1:` envelopes are
250
+ * transparent base64 rather than encryption, so no upstream can read them and they always become
251
+ * plain user messages. Native blobs have multiple possible minters, so a destination's ability to
252
+ * decode its own blobs does not make a blob from a previous serving identity portable. On a known
253
+ * identity mismatch the blob degrades to the same note the bridged parser uses, even when the
254
+ * destination normally accepts native blobs. Without a known mismatch, the destination capability
255
+ * keeps the existing behavior.
256
+ *
257
+ * A bare `context_compaction` marker carries no blob and is forwarded untouched.
150
258
  */
151
- function scrubOcxCompactionItems(body: unknown): unknown {
259
+ function scrubOcxCompactionItems(
260
+ body: unknown,
261
+ destinationDecodesNativeBlob: boolean,
262
+ threadServingIdentityChanged: boolean,
263
+ ): unknown {
152
264
  if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
153
265
 
154
266
  let changed = false;
155
267
  const input = body.input.map(item => {
156
- if (!isPlainObject(item)) return item;
157
- if (item.type !== "compaction" && item.type !== "compaction_summary" && item.type !== "context_compaction") return item;
158
- const decoded = typeof item.encrypted_content === "string" ? decodeCompactionSummary(item.encrypted_content) : null;
159
- if (decoded === null) return item;
268
+ if (!isPlainObject(item) || !isCompactionItemType(item.type)) return item;
269
+ const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : undefined;
270
+ if (encrypted === undefined) return item;
271
+ if (
272
+ decodeCompactionSummary(encrypted) === null
273
+ && destinationDecodesNativeBlob
274
+ && !threadServingIdentityChanged
275
+ ) return item;
160
276
  changed = true;
161
277
  return {
162
278
  type: "message",
163
279
  role: "user",
164
- content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\n${decoded}` }],
280
+ content: [{ type: "input_text", text: compactionItemToText(encrypted) }],
165
281
  };
166
282
  });
167
283
 
@@ -725,8 +841,18 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes
725
841
  };
726
842
  const reorderBatchOutputs = (items: unknown[]): unknown[] => {
727
843
  const ordered: unknown[] = [];
844
+ const claimedOutputIndexes = new Set<number>();
845
+ const outputIndexesByKey = new Map<string, { indexes: number[]; offset: number }>();
846
+ for (let outputIndex = 0; outputIndex < items.length; outputIndex += 1) {
847
+ const outputKey = outputKeyOf(items[outputIndex]);
848
+ if (outputKey === null) continue;
849
+ const bucket = outputIndexesByKey.get(outputKey);
850
+ if (bucket) bucket.indexes.push(outputIndex);
851
+ else outputIndexesByKey.set(outputKey, { indexes: [outputIndex], offset: 0 });
852
+ }
728
853
  let index = 0;
729
854
  while (index < items.length) {
855
+ if (claimedOutputIndexes.has(index)) { index += 1; continue; }
730
856
  const key = callKeyOf(items[index]);
731
857
  if (key === null) { ordered.push(items[index]); index += 1; continue; }
732
858
  const batch: unknown[] = [];
@@ -745,20 +871,24 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthes
745
871
  index = cursor;
746
872
  continue;
747
873
  }
748
- const remainder: unknown[] = [];
749
- const batchOutputs: Array<{ key: string; item: unknown }> = [];
750
- for (let probe = cursor; probe < items.length; probe += 1) {
751
- const outputKey = outputKeyOf(items[probe]);
752
- if (outputKey !== null && batchKeys.includes(outputKey)) {
753
- batchOutputs.push({ key: outputKey, item: items[probe] });
754
- } else {
755
- remainder.push(items[probe]);
874
+ const batchOutputs: unknown[] = [];
875
+ for (const batchKey of batchKeys) {
876
+ const bucket = outputIndexesByKey.get(batchKey);
877
+ if (!bucket) continue;
878
+ while (bucket.offset < bucket.indexes.length && bucket.indexes[bucket.offset]! < cursor) {
879
+ bucket.offset += 1;
880
+ }
881
+ while (bucket.offset < bucket.indexes.length) {
882
+ const outputIndex = bucket.indexes[bucket.offset]!;
883
+ bucket.offset += 1;
884
+ if (claimedOutputIndexes.has(outputIndex)) continue;
885
+ claimedOutputIndexes.add(outputIndex);
886
+ batchOutputs.push(items[outputIndex]);
887
+ break;
756
888
  }
757
889
  }
758
- batchOutputs.sort((left, right) => batchKeys.indexOf(left.key) - batchKeys.indexOf(right.key));
759
- ordered.push(...batch, ...batchOutputs.map(output => output.item));
760
- ordered.push(...reorderBatchOutputs(remainder));
761
- return ordered;
890
+ ordered.push(...batch, ...batchOutputs);
891
+ index = cursor;
762
892
  }
763
893
  return ordered;
764
894
  };
@@ -1362,6 +1492,30 @@ function stripUnsupportedHostedTools(body: unknown): unknown {
1362
1492
  return tools.length === body.tools.length ? body : { ...body, tools };
1363
1493
  }
1364
1494
 
1495
+ /**
1496
+ * OpenAI hosted web_search config fields that a capability-classified Responses
1497
+ * upstream may reject wholesale. xAI's /v1/responses 400s the entire request on
1498
+ * `external_web_access` and `search_context_size` ("Argument not supported"),
1499
+ * which killed every routed Grok turn whose client (Codex) attaches its
1500
+ * default web_search tool config (probe 2026-08-21: both fields 400
1501
+ * individually; `user_location` and `filters` are accepted and kept).
1502
+ * The caller decides whether to apply this compatibility transform from explicit
1503
+ * provider capability metadata; an unclassified upstream keeps the fields.
1504
+ */
1505
+ const OPENAI_ONLY_WEB_SEARCH_FIELDS = ["external_web_access", "search_context_size"] as const;
1506
+ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown {
1507
+ if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
1508
+ let changed = false;
1509
+ const tools = body.tools.map(t => {
1510
+ if (!isPlainObject(t) || (t.type !== "web_search" && t.type !== "web_search_preview")) return t;
1511
+ if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(t, field))) return t;
1512
+ const { external_web_access: _access, search_context_size: _size, ...rest } = t;
1513
+ changed = true;
1514
+ return rest;
1515
+ });
1516
+ return changed ? { ...body, tools } : body;
1517
+ }
1518
+
1365
1519
  /** Replace every `input_image` part under a routed-compaction body with a short marker. */
1366
1520
  function stripInputImagesDeep(value: unknown): unknown {
1367
1521
  if (Array.isArray(value)) return value.map(stripInputImagesDeep);
@@ -1492,6 +1646,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1492
1646
  const forward = provider.authMode === "forward";
1493
1647
  let convertedRoutedCustomToolNames: Set<string> | undefined;
1494
1648
  let convertedRoutedToolSearchNames: Set<string> | undefined;
1649
+ let convertedRoutedNamespaceToolAliases: Map<string, { namespace: string; name: string }> | undefined;
1495
1650
  const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true;
1496
1651
  let outBody = stripPreviousResponseId(
1497
1652
  parsed._rawBody,
@@ -1546,7 +1701,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1546
1701
  if (!isCanonicalOpenAiForwardProvider(provider)) {
1547
1702
  outBody = promoteClientLoadedTools(outBody);
1548
1703
  }
1549
- if (provider.authMode !== "forward") {
1704
+ if (!isCanonicalOpenAiForwardProvider(provider)) {
1550
1705
  const rewritten = rewriteRoutedCustomToolsForUpstream(outBody);
1551
1706
  outBody = rewritten.body;
1552
1707
  convertedRoutedCustomToolNames = rewritten.names;
@@ -1557,8 +1712,33 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1557
1712
  const rewritten = rewriteRoutedToolSearchForUpstream(outBody);
1558
1713
  outBody = rewritten.body;
1559
1714
  convertedRoutedToolSearchNames = rewritten.names;
1715
+ // xAI rejects these OpenAI web_search extensions with HTTP 400. Keep them
1716
+ // for OpenAI API-key traffic and unclassified gateways; only an explicit
1717
+ // provider capability denial activates the compatibility transform.
1718
+ if (provider.supportsOpenAiWebSearchToolFields === false) {
1719
+ outBody = stripOpenAiOnlyWebSearchFields(outBody);
1720
+ }
1721
+ }
1722
+ if (!isCanonicalOpenAiForwardProvider(provider)) {
1723
+ // Codex 0.147 emits private namespace tool groups, while public/third-party Responses
1724
+ // gateways accept only flat tool variants. Run after custom/tool-search lowering so
1725
+ // namespace children already carry their final public kind before they are promoted.
1726
+ const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody);
1727
+ outBody = rewritten.body;
1728
+ convertedRoutedNamespaceToolAliases = rewritten.aliases;
1729
+ // Last, so promoted namespace children are also cleared of Codex-private fields.
1730
+ outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false);
1560
1731
  }
1561
- const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true })))))));
1732
+ const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true;
1733
+ const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(
1734
+ outBody,
1735
+ destinationDecodesNativeCompactionBlob(provider),
1736
+ threadServingIdentityChanged,
1737
+ ), {
1738
+ preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true,
1739
+ dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider),
1740
+ stripEncryptedContent: threadServingIdentityChanged,
1741
+ })))))));
1562
1742
  const finalBody = stripDisabledReasoningSummaries(
1563
1743
  normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
1564
1744
  provider,
@@ -1586,6 +1766,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1586
1766
  releaseBodyObservation,
1587
1767
  ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
1588
1768
  ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}),
1769
+ ...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}),
1589
1770
  ...(tierLog ? { tierLog } : {}),
1590
1771
  };
1591
1772
  },
package/src/bridge.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "./responses/thought-signature-replay";
20
20
  import { resolveStallTimeoutSec } from "./stall-timeout";
21
21
  import { usageDisplayTotalTokens } from "./usage/totals";
22
+ import { appendSafeWebSearchSource, safeWebSearchSources } from "./web-search/sources";
22
23
  import {
23
24
  isTranslatorBudgetExceededError,
24
25
  releaseTranslatedEvent,
@@ -526,17 +527,22 @@ export function bridgeToResponsesSSE(
526
527
  // url_citation annotations on that message (the desktop app's Sources chip), then cleared so
527
528
  // they bind to exactly one message. Deduped by URL across multiple searches in the turn.
528
529
  let pendingWebSources: { url: string; title?: string }[] = [];
530
+ let pendingWebSourceBytes = 0;
531
+ const releasePendingWebSources = () => {
532
+ if (pendingWebSources.length === 0) return;
533
+ pendingWebSources = [];
534
+ budget?.releaseRetained(pendingWebSourceBytes, { kind: "tool_search_sources" });
535
+ pendingWebSourceBytes = 0;
536
+ };
529
537
  const takeWebAnnotations = (): { type: string; url: string; title?: string; start_index: number; end_index: number }[] => {
530
538
  if (pendingWebSources.length === 0) return [];
531
539
  const anns = pendingWebSources.map(s => ({
532
540
  type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0,
533
541
  }));
534
- const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0);
535
542
  const annotationBytes = bytesOf(JSON.stringify(anns));
536
543
  const reservation = budget?.reserveTransient(annotationBytes, { kind: "retained_collectors" });
537
- pendingWebSources = [];
538
544
  reservation?.commitRetained();
539
- budget?.releaseRetained(sourceBytes, { kind: "tool_search_sources" });
545
+ releasePendingWebSources();
540
546
  return anns;
541
547
  };
542
548
 
@@ -792,6 +798,7 @@ export function bridgeToResponsesSSE(
792
798
  handlingTranslatorOverflow = true;
793
799
  abortCurrentToolCallForTranslatorOverflow();
794
800
  currentWebSearch = null;
801
+ releasePendingWebSources();
795
802
  const failure = adapterFailureFromEvent({
796
803
  type: "error",
797
804
  status: 502,
@@ -1157,15 +1164,13 @@ export function bridgeToResponsesSSE(
1157
1164
  });
1158
1165
  currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex };
1159
1166
  }
1160
- closeCurrentWebSearch(event.status ?? "completed", event.queries, event.sources);
1167
+ const safeSources = safeWebSearchSources(event.sources);
1168
+ closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources);
1161
1169
  // Queue this search's sources for the next assistant message (dedup by URL).
1162
- if (event.sources) {
1163
- const seen = new Set(pendingWebSources.map(s => s.url));
1164
- for (const s of event.sources) {
1165
- if (!seen.has(s.url)) {
1166
- seen.add(s.url);
1167
- chargeValue(s, "tool_search_sources");
1168
- pendingWebSources.push(s);
1170
+ if (safeSources.length > 0) {
1171
+ for (const source of safeSources) {
1172
+ if (appendSafeWebSearchSource(pendingWebSources, source)) {
1173
+ pendingWebSourceBytes += chargeValue(source, "tool_search_sources");
1169
1174
  }
1170
1175
  }
1171
1176
  }
@@ -1178,6 +1183,7 @@ export function bridgeToResponsesSSE(
1178
1183
  flushHiddenRawReasoning();
1179
1184
  if (currentToolCall) closeCurrentToolCall();
1180
1185
  if (currentWebSearch) closeCurrentWebSearch("completed", []);
1186
+ releasePendingWebSources();
1181
1187
  // Redacted-only turns (or hidden thinking without a trailing signature event) still
1182
1188
  // need their envelope-only reasoning item so the blocks replay next turn.
1183
1189
  flushHiddenReasoningEnvelope();
@@ -1241,6 +1247,7 @@ export function bridgeToResponsesSSE(
1241
1247
  flushHiddenRawReasoning();
1242
1248
  if (currentToolCall) failCurrentToolCall();
1243
1249
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
1250
+ releasePendingWebSources();
1244
1251
  flushHiddenReasoningEnvelope();
1245
1252
  options?.onUsage?.(event.usage);
1246
1253
  await awaitThoughtSignatureDurability();
@@ -1270,6 +1277,7 @@ export function bridgeToResponsesSSE(
1270
1277
  flushHiddenRawReasoning();
1271
1278
  if (currentToolCall) failCurrentToolCall();
1272
1279
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
1280
+ releasePendingWebSources();
1273
1281
  const failure = adapterFailureFromEvent(event);
1274
1282
  if (event.usage) options?.onUsage?.(event.usage);
1275
1283
  await awaitThoughtSignatureDurability();
@@ -1304,6 +1312,7 @@ export function bridgeToResponsesSSE(
1304
1312
  flushHiddenRawReasoning();
1305
1313
  if (currentToolCall) failCurrentToolCall();
1306
1314
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
1315
+ releasePendingWebSources();
1307
1316
  emit("response.failed", {
1308
1317
  response: {
1309
1318
  ...responseSnapshot("failed", finishedItems),
@@ -1333,6 +1342,7 @@ export function bridgeToResponsesSSE(
1333
1342
  flushHiddenRawReasoning();
1334
1343
  if (currentToolCall) failCurrentToolCall();
1335
1344
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
1345
+ releasePendingWebSources();
1336
1346
  options?.onUsage?.(undefined);
1337
1347
  await awaitThoughtSignatureDurability();
1338
1348
  emit("response.incomplete", {
@@ -1375,6 +1385,7 @@ export function bridgeToResponsesSSE(
1375
1385
  flushHiddenRawReasoning();
1376
1386
  if (currentToolCall) failCurrentToolCall();
1377
1387
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
1388
+ releasePendingWebSources();
1378
1389
  // #1926 gap 2 residual: this beat callback is synchronous, so the durability
1379
1390
  // barrier is not awaited on the stall-timeout kill path. The in-memory store is
1380
1391
  // already updated; only a crash between here and the queued write loses it,
@@ -1427,6 +1438,7 @@ export function bridgeToResponsesSSE(
1427
1438
  clearOwnedWatchdog();
1428
1439
  if (beat !== undefined) clearBeatInterval(beat);
1429
1440
  cancelUpstreamOnce();
1441
+ releasePendingWebSources();
1430
1442
  disposeOwnedBudget();
1431
1443
  },
1432
1444
  });
@@ -1566,6 +1578,7 @@ function buildResponseJSONWithBudget(
1566
1578
  const flushText = (inferredPhase?: OcxMessagePhase) => {
1567
1579
  if (!currentText) return;
1568
1580
  const phase = currentTextPhase ?? inferredPhase;
1581
+ const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0);
1569
1582
  const annotations = pendingWebSources.map(s => ({
1570
1583
  type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0,
1571
1584
  }));
@@ -1576,6 +1589,7 @@ function buildResponseJSONWithBudget(
1576
1589
  ...(phase ? { phase } : {}),
1577
1590
  } as OutputItem;
1578
1591
  pushOutput(item, currentTextBytes);
1592
+ budget?.releaseRetained(sourceBytes, { kind: "tool_search_sources" });
1579
1593
  currentText = "";
1580
1594
  currentTextBytes = 0;
1581
1595
  currentTextPhase = undefined;
@@ -1820,27 +1834,26 @@ function buildResponseJSONWithBudget(
1820
1834
  // Batch/non-streaming output has no in_progress phase to animate — the search cell is a
1821
1835
  // single finalized item, emitted on `end`. Begin is a no-op here.
1822
1836
  break;
1823
- case "web_search_call_end":
1837
+ case "web_search_call_end": {
1824
1838
  if (currentText) flushText("commentary");
1825
1839
  if (currentSummaryReasoning) flushSummaryReasoning();
1826
1840
  if (currentRawReasoning) flushRawReasoning();
1827
1841
  flushToolCall();
1842
+ const safeSources = safeWebSearchSources(e.sources);
1828
1843
  pushOutput({
1829
1844
  type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed",
1830
1845
  action: webSearchAction(e.queries),
1831
- ...(e.sources && e.sources.length > 0 ? { sources: e.sources } : {}),
1846
+ ...(safeSources.length > 0 ? { sources: safeSources } : {}),
1832
1847
  });
1833
- if (e.sources) {
1834
- const seen = new Set(pendingWebSources.map(s => s.url));
1835
- for (const s of e.sources) {
1836
- if (!seen.has(s.url)) {
1837
- seen.add(s.url);
1838
- budget?.chargeRetained(bytesOf(JSON.stringify(s)), { kind: "tool_search_sources" });
1839
- pendingWebSources.push(s);
1848
+ if (safeSources.length > 0) {
1849
+ for (const source of safeSources) {
1850
+ if (appendSafeWebSearchSource(pendingWebSources, source)) {
1851
+ budget?.chargeRetained(bytesOf(JSON.stringify(source)), { kind: "tool_search_sources" });
1840
1852
  }
1841
1853
  }
1842
1854
  }
1843
1855
  break;
1856
+ }
1844
1857
  case "error":
1845
1858
  errorEvent = e;
1846
1859
  sawTerminal = true;
@@ -1872,6 +1885,11 @@ function buildResponseJSONWithBudget(
1872
1885
  if (budget) releaseTranslatedEvent(e, budget);
1873
1886
  }
1874
1887
  flushText(cleanDone && !errorEvent && !incompleteEvent ? "final_answer" : undefined);
1888
+ if (pendingWebSources.length > 0) {
1889
+ const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0);
1890
+ pendingWebSources = [];
1891
+ budget?.releaseRetained(sourceBytes, { kind: "tool_search_sources" });
1892
+ }
1875
1893
  flushSummaryReasoning();
1876
1894
  flushRawReasoning();
1877
1895
  // Open tool call on a failed/incomplete turn must not land as status:"completed" — and neither
@@ -474,6 +474,12 @@ export function inspectDesktop3pConfigLibrary(
474
474
  * Select a credential-free standard profile before deleting an owned gateway.
475
475
  * The old metadata row remains as a retry locator only until both its profile
476
476
  * and backup are absent; successful cleanup removes it in the same operation.
477
+ *
478
+ * `gateway_drifted` is still an owned opencodex gateway (name + valid shape); the
479
+ * fingerprint only says on-disk bytes differ from the last saved marker. Refusing
480
+ * OFF for drift left users unable to disable after a lost `appliedFingerprint`
481
+ * (or any other benign mismatch), while the Integrations card still showed the
482
+ * leftover profile as applied/stale.
477
483
  */
478
484
  export function removeDesktop3pStandardPivot(
479
485
  options: Desktop3pConfigLibraryOptions & {
@@ -485,7 +491,7 @@ export function removeDesktop3pStandardPivot(
485
491
  if (inspected.kind === "not_installed" || inspected.kind === "no_owned_state") {
486
492
  return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath };
487
493
  }
488
- if (inspected.kind === "broken" || inspected.kind === "unsafe" || inspected.kind === "gateway_drifted") {
494
+ if (inspected.kind === "broken" || inspected.kind === "unsafe") {
489
495
  return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: inspected.reason };
490
496
  }
491
497
  if (!inspected.appliedId || !SAFE_DESKTOP_PROFILE_ID.test(inspected.appliedId)) {
@@ -496,10 +502,13 @@ export function removeDesktop3pStandardPivot(
496
502
  try {
497
503
  const metadata = parseMetadata(metadataPath);
498
504
  const selectedId = inspected.appliedId;
499
- // When Desktop is actively using our gateway, pivot only that selected row
500
- // first. Any second owned row is residue for a later standard-mode retry;
501
- // this preserves the selected-row preference after an interrupted cleanup.
502
- const targetIds = inspected.kind === "gateway_ours"
505
+ // When Desktop is actively using our gateway (current or drifted), pivot only
506
+ // that selected row first. Any second owned row is residue for a later
507
+ // standard-mode retry; this preserves the selected-row preference after an
508
+ // interrupted cleanup.
509
+ const selectedOwnedGatewayActive =
510
+ inspected.kind === "gateway_ours" || inspected.kind === "gateway_drifted";
511
+ const targetIds = selectedOwnedGatewayActive
503
512
  ? [selectedId]
504
513
  : metadata.entries
505
514
  .filter(isOwnedDesktopGatewayEntry)
@@ -508,7 +517,7 @@ export function removeDesktop3pStandardPivot(
508
517
  if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath };
509
518
 
510
519
  let metadataAfterPivot = metadata;
511
- if (inspected.kind === "gateway_ours") {
520
+ if (selectedOwnedGatewayActive) {
512
521
  const standardId = randomUUID();
513
522
  const standardPath = profilePath(inspected.libraryPath, standardId);
514
523
  atomicWriteFile(standardPath, "{}\n");