@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
@@ -24,6 +24,7 @@ import {
24
24
  sseDataPayload,
25
25
  type SseBlockRewrite,
26
26
  } from "../sse-payload-rewrite";
27
+ import { isCompactionItemType } from "../../responses/compaction";
27
28
 
28
29
  function isPlainObject(value: unknown): value is Record<string, unknown> {
29
30
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -119,16 +120,6 @@ function backfillContentArray(content: unknown): unknown {
119
120
  return changed ? repaired : content;
120
121
  }
121
122
 
122
- /**
123
- * Item types that are NOT Responses output items and must be returned byte-for-byte.
124
- *
125
- * `compaction` is the `/v1/responses/compact` wire format, not a Responses output item. It has
126
- * no `id` in that contract, so synthesizing one changes a response body the client compares
127
- * exactly. The backfill exists to satisfy strict Responses decoders; a shape those decoders
128
- * never see is outside its remit.
129
- */
130
- const NON_RESPONSES_ITEM_TYPES: ReadonlySet<string> = new Set(["compaction"]);
131
-
132
123
  /**
133
124
  * Walk an output item and backfill output_text parts in its content.
134
125
  * Also backfills a missing required id on the item itself.
@@ -136,7 +127,12 @@ const NON_RESPONSES_ITEM_TYPES: ReadonlySet<string> = new Set(["compaction"]);
136
127
  */
137
128
  function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown {
138
129
  if (!isPlainObject(item)) return item;
139
- if (typeof item.type === "string" && NON_RESPONSES_ITEM_TYPES.has(item.type)) return item;
130
+ // The compact wire family is the `/v1/responses/compact` format, not a Responses output item.
131
+ // Those items have no `id` in that contract, so synthesizing one changes a response body the
132
+ // client compares exactly — and the client replays the item on every later turn, where the
133
+ // minting backend rejects it as modified. The backfill exists to satisfy strict Responses
134
+ // decoders; a shape those decoders never see is outside its remit.
135
+ if (isCompactionItemType(item.type)) return item;
140
136
  const content = item.content;
141
137
  const repaired = backfillContentArray(content);
142
138
  const withId = backfillItemId(item, slot);
@@ -117,7 +117,7 @@ export function analyzeTerminalTurn(parsed: OcxParsedRequest, events: readonly A
117
117
  return { decision: "continue", reason: "suspicious_no_tool", assistantText: text, userText, hasToolCall };
118
118
  }
119
119
 
120
- function assistantMessageFromEvents(events: readonly AdapterEvent[]): OcxAssistantMessage | undefined {
120
+ function assistantMessageFromEvents(events: readonly AdapterEvent[], timestamp: number): OcxAssistantMessage | undefined {
121
121
  let text = "";
122
122
  let thinking = "";
123
123
  let signature: string | undefined;
@@ -134,14 +134,18 @@ function assistantMessageFromEvents(events: readonly AdapterEvent[]): OcxAssista
134
134
  }
135
135
  if (text) content.push({ type: "text", text });
136
136
  if (content.length === 0) return undefined;
137
- return { role: "assistant", content, timestamp: Date.now() };
137
+ return { role: "assistant", content, timestamp };
138
138
  }
139
139
 
140
140
  export function buildContinuationRequest(parsed: OcxParsedRequest, events: readonly AdapterEvent[]): OcxParsedRequest {
141
141
  const messages = [...parsed.context.messages];
142
- const assistant = assistantMessageFromEvents(events);
142
+ // One clock read for the whole rebuild. Two Date.now() calls could straddle a millisecond
143
+ // boundary, which made the retained-heartbeat contract test fail intermittently in CI on a
144
+ // 1ms skew between the assistant message and the nudge that follows it.
145
+ const timestamp = Date.now();
146
+ const assistant = assistantMessageFromEvents(events, timestamp);
143
147
  if (assistant) messages.push(assistant);
144
- messages.push({ role: "developer", content: TERMINAL_GUARD_NUDGE, timestamp: Date.now() });
148
+ messages.push({ role: "developer", content: TERMINAL_GUARD_NUDGE, timestamp });
145
149
  return { ...parsed, context: { ...parsed.context, messages } };
146
150
  }
147
151
 
@@ -153,6 +157,16 @@ export interface GuardedEventStreamOptions {
153
157
  maxAutoContinuations?: number;
154
158
  }
155
159
 
160
+ /**
161
+ * Events that are useful to the downstream stream consumer but carry no state used by the
162
+ * terminal-continuation decision or its rebuilt request. Keep them out of the retained event
163
+ * list so adapter liveness markers and arbitrarily large tool-argument fragments cannot make
164
+ * the guard's per-turn memory grow without adding any continuation semantics.
165
+ */
166
+ export function isTerminalGuardPassthroughOnly(event: AdapterEvent): boolean {
167
+ return event.type === "heartbeat" || event.type === "tool_call_delta";
168
+ }
169
+
156
170
  function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): OcxUsage | undefined {
157
171
  if (!first) return second;
158
172
  if (!second) return first;
@@ -193,13 +207,10 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
193
207
  const seen: AdapterEvent[] = [];
194
208
  let terminalSeen = false;
195
209
  for await (const event of source) {
196
- // A heartbeat is adapter liveness, not turn content: it exists so the bridge watchdog
197
- // can tell a buffering adapter from a hung one. Retaining it here would put an
198
- // unbounded number of empty markers into `seen`, which feeds both the continuation
199
- // analysis and the rebuilt request — and the openai-chat adapter now emits one per
200
- // tool-call delta, so a long argument payload alone could grow this array without
201
- // limit. The empty-completion guard already passes them through unretained; match it.
202
- if (event.type === "heartbeat") {
210
+ // Liveness markers and tool argument fragments are passed through to the bridge, but
211
+ // neither analyzeTerminalTurn nor buildContinuationRequest consumes them. Retaining the
212
+ // fragments would duplicate arbitrarily large argument payloads in `seen` for no effect.
213
+ if (isTerminalGuardPassthroughOnly(event)) {
203
214
  yield event;
204
215
  continue;
205
216
  }
@@ -2,6 +2,7 @@ import type { TranslatorBudget } from "../lib/translator-budget";
2
2
  import {
3
3
  customToolItemId,
4
4
  restoreRoutedCustomCalls,
5
+ routedCustomToolWireName,
5
6
  unwrapRoutedCustomToolArguments,
6
7
  } from "../responses/custom-tool-compat";
7
8
  import {
@@ -181,7 +182,8 @@ export function createRoutedCustomToolRestoreBlockRewrite(
181
182
  && typeof parsed.item.name === "string"
182
183
  ) {
183
184
  const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined;
184
- const routed = names.has(parsed.item.name);
185
+ const wireName = routedCustomToolWireName(parsed.item);
186
+ const routed = wireName !== undefined && names.has(wireName);
185
187
  if (upstreamItemId) {
186
188
  if (routed) {
187
189
  itemNames.set(upstreamItemId, parsed.item.name);
@@ -34,6 +34,13 @@ function reasoningTextOf(item: Record<string, unknown>): string {
34
34
  /** Move a reasoning item's content channel into the summary channel. */
35
35
  function reasoningItemToSummaryShape(item: Record<string, unknown>): Record<string, unknown> {
36
36
  if (item.type !== "reasoning") return item;
37
+ // `encrypted_content` is opaque, state-bearing provider data, so the entire item must retain its
38
+ // upstream shape unless that backend has an explicit replay contract permitting a rewrite. This
39
+ // defensively protects content-channel backends that do issue blobs when the client replays the
40
+ // stored item. The delta rewrite can still provide the expandable trace for the live turn.
41
+ // DeepSeek — the provider this rewrite was verified against — is `statelessResponses` and issues
42
+ // no blob, so it is unaffected.
43
+ if (typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) return item;
37
44
  const text = reasoningTextOf(item);
38
45
  // Items that already use the summary channel (or carry no content text at
39
46
  // all) are left untouched: rewriting them could clear a valid summary.
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  isTranslatorBudgetExceededError,
3
+ TranslatorBudgetExceededError,
3
4
  type TranslatorBudget,
4
5
  } from "../lib/translator-budget";
5
6
  import {
@@ -24,6 +25,19 @@ type PendingArgumentBlock = {
24
25
 
25
26
  const MAX_PENDING_ARGUMENT_FRAMES = 256;
26
27
  const MAX_PENDING_ARGUMENT_BYTES = 1024 * 1024;
28
+ const MAX_CLASSIFIED_ITEM_IDS = 256;
29
+ const MAX_CLASSIFIED_ITEM_ID_BYTES = 256 * 1024;
30
+
31
+ class ClassifiedItemIdCountExceededError extends Error {
32
+ readonly code = "translation_buffer_limit";
33
+ readonly kind = "item_ids";
34
+ readonly limitItems = MAX_CLASSIFIED_ITEM_IDS;
35
+
36
+ constructor() {
37
+ super(`translator item_ids count exceeded ${MAX_CLASSIFIED_ITEM_IDS} items`);
38
+ this.name = "ClassifiedItemIdCountExceededError";
39
+ }
40
+ }
27
41
 
28
42
  /**
29
43
  * Public Responses gateways stream a lowered search as a normal function lifecycle. Codex expects
@@ -36,6 +50,7 @@ export function createRoutedToolSearchRestoreBlockRewrite(
36
50
  ): SseBlockRewrite {
37
51
  const routedItemIds = new Set<string>();
38
52
  const ordinaryItemIds = new Set<string>();
53
+ let classifiedItemIdBytes = 0;
39
54
  let pendingArguments: PendingArgumentBlock[] = [];
40
55
  let pendingArgumentBytes = 0;
41
56
  let passthrough = false;
@@ -49,10 +64,45 @@ export function createRoutedToolSearchRestoreBlockRewrite(
49
64
  }
50
65
  pendingArguments = [];
51
66
  pendingArgumentBytes = 0;
67
+ if (classifiedItemIdBytes > 0) {
68
+ budget?.releaseRetained(classifiedItemIdBytes, { kind: "item_ids" });
69
+ }
70
+ classifiedItemIdBytes = 0;
52
71
  routedItemIds.clear();
53
72
  ordinaryItemIds.clear();
54
73
  };
55
74
 
75
+ const clearOrdinaryItemIds = (): void => {
76
+ let releasedBytes = 0;
77
+ for (const itemId of ordinaryItemIds) {
78
+ releasedBytes += Buffer.byteLength(JSON.stringify(itemId), "utf8");
79
+ }
80
+ ordinaryItemIds.clear();
81
+ classifiedItemIdBytes = Math.max(0, classifiedItemIdBytes - releasedBytes);
82
+ if (releasedBytes > 0) budget?.releaseRetained(releasedBytes, { kind: "item_ids" });
83
+ };
84
+
85
+ const classifyItemId = (itemId: string, routed: boolean): void => {
86
+ const target = routed ? routedItemIds : ordinaryItemIds;
87
+ const previous = routed ? ordinaryItemIds : routedItemIds;
88
+ if (target.has(itemId)) return;
89
+ if (previous.delete(itemId)) {
90
+ target.add(itemId);
91
+ return;
92
+ }
93
+
94
+ if (routedItemIds.size + ordinaryItemIds.size >= MAX_CLASSIFIED_ITEM_IDS) {
95
+ throw new ClassifiedItemIdCountExceededError();
96
+ }
97
+ const retainedBytes = Buffer.byteLength(JSON.stringify(itemId), "utf8");
98
+ if (classifiedItemIdBytes + retainedBytes > MAX_CLASSIFIED_ITEM_ID_BYTES) {
99
+ throw new TranslatorBudgetExceededError("item_ids", MAX_CLASSIFIED_ITEM_ID_BYTES);
100
+ }
101
+ budget?.chargeRetained(retainedBytes, { kind: "item_ids" });
102
+ target.add(itemId);
103
+ classifiedItemIdBytes += retainedBytes;
104
+ };
105
+
56
106
  const retainPending = (
57
107
  block: string,
58
108
  itemId: string | undefined,
@@ -73,7 +123,7 @@ export function createRoutedToolSearchRestoreBlockRewrite(
73
123
  // that we forget what we already classified: an item restored to `tool_search_call`
74
124
  // upstream of here would otherwise start emitting `function_call_arguments.*` again and
75
125
  // the client would see a mixed private/public lifecycle for one call.
76
- ordinaryItemIds.clear();
126
+ clearOrdinaryItemIds();
77
127
  return flushed;
78
128
  }
79
129
  if (retainedBytes > 0) {
@@ -90,7 +140,7 @@ export function createRoutedToolSearchRestoreBlockRewrite(
90
140
  passthrough = true;
91
141
  // Same reasoning as the frame/byte overflow above: an already-restored routed item
92
142
  // must keep its frames suppressed even once buffering stops.
93
- ordinaryItemIds.clear();
143
+ clearOrdinaryItemIds();
94
144
  return flushed;
95
145
  }
96
146
  }
@@ -135,7 +185,11 @@ export function createRoutedToolSearchRestoreBlockRewrite(
135
185
  const rewrite: SseBlockRewrite = (block: string): readonly string[] => {
136
186
  if (disposed) return [block];
137
187
  const payload = sseDataPayload(block);
138
- if (payload === null || payload === "[DONE]") return [block];
188
+ if (payload === null) return [block];
189
+ if (payload === "[DONE]") {
190
+ releaseAll();
191
+ return [block];
192
+ }
139
193
  let parsed: unknown;
140
194
  try {
141
195
  parsed = JSON.parse(payload);
@@ -145,11 +199,16 @@ export function createRoutedToolSearchRestoreBlockRewrite(
145
199
  if (!isPlainObject(parsed)) return [block];
146
200
 
147
201
  const type = typeof parsed.type === "string" ? parsed.type : "";
202
+ const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete";
148
203
  // After overflow we stop BUFFERING unknown frames, but an item already restored to
149
204
  // `tool_search_call` must keep its public argument frames suppressed — otherwise the client
150
205
  // receives a private item followed by `function_call_arguments.*` for the same id, which is
151
206
  // exactly the mixed lifecycle this rewrite exists to prevent. Everything else passes through.
152
207
  if (passthrough) {
208
+ if (terminal) {
209
+ releaseAll();
210
+ return [block];
211
+ }
153
212
  const passthroughItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined;
154
213
  const isArgumentEvent = type === "response.function_call_arguments.delta"
155
214
  || type === "response.function_call_arguments.done";
@@ -169,22 +228,14 @@ export function createRoutedToolSearchRestoreBlockRewrite(
169
228
  ) {
170
229
  const itemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined;
171
230
  const routed = names.has(parsed.item.name);
172
- if (itemId) {
173
- if (routed) {
174
- routedItemIds.add(itemId);
175
- ordinaryItemIds.delete(itemId);
176
- } else {
177
- ordinaryItemIds.add(itemId);
178
- routedItemIds.delete(itemId);
179
- }
180
- }
231
+ if (itemId) classifyItemId(itemId, routed);
181
232
  const pending = takePending(itemId, outputIndex);
182
233
  const restored = routed ? restoreRoutedToolSearchCalls(parsed, names) : { value: parsed, changed: false };
183
234
  const restoredBlock = restored.changed
184
235
  ? replaceSseDataPayload(block, JSON.stringify(restored.value))
185
236
  : block;
186
237
  // Classification is retained past `output_item.done` for BOTH kinds, until the terminal
187
- // event releases everything.
238
+ // event releases the bounded, budgeted state.
188
239
  //
189
240
  // `done` ends the item, not the id's relevance. Forgetting a ROUTED id let a trailing
190
241
  // `function_call_arguments.*` — which some upstreams emit after done — fall through to
@@ -204,7 +255,6 @@ export function createRoutedToolSearchRestoreBlockRewrite(
204
255
  }
205
256
  if (argumentEvent && itemId && routedItemIds.has(itemId)) return [];
206
257
 
207
- const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete";
208
258
  if (!terminal) return [block];
209
259
  const restored = restoreRoutedToolSearchCalls(parsed, names);
210
260
  releaseAll();
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The one place that decides whether a sidecar may treat ChatGPT or Anthropic
3
+ * auth as PRESENT (#2188). Web-search and vision consumed two hand-rolled
4
+ * copies of the Anthropic predicate and no Codex-login predicate at all —
5
+ * provider presence was standing in for "logged in", which let a fresh install
6
+ * with the built-in forward provider but no credential offer Luna as a
7
+ * describer it could never run.
8
+ *
9
+ * Both flags are request-context-free: they read config plus the stored
10
+ * account state, never headers. Per-request usability (exact accounts,
11
+ * generation fences) stays in resolveFirstUsableOpenAiSidecar and the
12
+ * executors; this module only answers "is this side worth offering at all?".
13
+ */
14
+ import type { OcxConfig, OcxProviderConfig } from "../types";
15
+ import { listOpenAiForwardSidecarCandidates } from "../providers/openai-sidecar";
16
+ import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
17
+ import { isCodexAccountUsable } from "../codex/account-usability";
18
+ import { MAIN_CODEX_ACCOUNT_ID, isSelectableCodexPoolAccount } from "../codex/account-id";
19
+ import { getAccountSet } from "../oauth/store";
20
+
21
+ export interface SidecarAuthState {
22
+ /** ChatGPT login usable: canonical forward provider AND a live stored credential. */
23
+ isCodexAuth: boolean;
24
+ /** Enabled anthropic-adapter OAuth provider whose active account is not marked for reauth. */
25
+ isAnthropicAuth: boolean;
26
+ /** The provider an Anthropic-side executor would dispatch through, when isAnthropicAuth. */
27
+ anthropicProviderName?: string;
28
+ anthropicProvider?: OcxProviderConfig;
29
+ }
30
+
31
+ /**
32
+ * Fixed auth-slot models (#2188): logged-in sides keep these candidates even
33
+ * when the picker hides or disables them. The slot is the LOGIN's entitlement,
34
+ * not the catalog's.
35
+ */
36
+ export const AUTH_SLOT_MODELS = {
37
+ codex: "gpt-5.6-luna",
38
+ anthropic: "claude-haiku-4-5",
39
+ } as const;
40
+
41
+ export interface SidecarAuthSlot {
42
+ provider: string;
43
+ id: string;
44
+ slot: keyof typeof AUTH_SLOT_MODELS;
45
+ }
46
+
47
+ /** Login-shaped, not provider-shaped: a forward provider with no credential is NOT Codex auth. */
48
+ function hasUsableCodexLogin(config: OcxConfig): boolean {
49
+ if (listOpenAiForwardSidecarCandidates(config).length === 0) return false;
50
+ if (isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID)) return true;
51
+ return (config.codexAccounts ?? []).some(account =>
52
+ isSelectableCodexPoolAccount(account) && isCodexAccountUsable(config, account.id));
53
+ }
54
+
55
+ /**
56
+ * The predicate previously duplicated as findAnthropicSidecarProvider
57
+ * (web-search) and findAnthropicVisionProvider (vision): first enabled
58
+ * anthropic-adapter OAuth provider whose ACTIVE stored account holds a usable
59
+ * credential. getAccountSet + needsReauth, not getCredential — a terminally
60
+ * invalid account must not present as auth (audit F1).
61
+ */
62
+ function findAnthropicAuthProvider(
63
+ config: OcxConfig,
64
+ ): { providerName: string; provider: OcxProviderConfig } | undefined {
65
+ for (const [providerName, provider] of Object.entries(config.providers)) {
66
+ if (provider.disabled === true) continue;
67
+ if (provider.adapter !== "anthropic" || provider.authMode !== "oauth") continue;
68
+ const set = getAccountSet(providerName);
69
+ const active = set?.accounts.find(account => account.id === set.activeAccountId);
70
+ if (active && active.needsReauth !== true) return { providerName, provider };
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ export function resolveSidecarAuth(config: OcxConfig): SidecarAuthState {
76
+ const anthropic = findAnthropicAuthProvider(config);
77
+ return {
78
+ isCodexAuth: hasUsableCodexLogin(config),
79
+ isAnthropicAuth: anthropic !== undefined,
80
+ ...(anthropic ? { anthropicProviderName: anthropic.providerName, anthropicProvider: anthropic.provider } : {}),
81
+ };
82
+ }
83
+
84
+ /** The auth-entitled fixed candidates. Emitted regardless of picker visibility. */
85
+ export function sidecarAuthSlots(auth: SidecarAuthState): SidecarAuthSlot[] {
86
+ const slots: SidecarAuthSlot[] = [];
87
+ if (auth.isCodexAuth) slots.push({ provider: OPENAI_CODEX_PROVIDER_ID, id: AUTH_SLOT_MODELS.codex, slot: "codex" });
88
+ if (auth.isAnthropicAuth && auth.anthropicProviderName) {
89
+ slots.push({ provider: auth.anthropicProviderName, id: AUTH_SLOT_MODELS.anthropic, slot: "anthropic" });
90
+ }
91
+ return slots;
92
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The unified picker-visibility candidate set both sidecars consume (#2188).
3
+ *
4
+ * Rule 1 (picker): a model may be OFFERED as a sidecar backend only if the
5
+ * management picker would show it — the same `listManagementModelRows` rows
6
+ * the GUI Models tab renders, minus disabled rows. Rule 1's only exception is
7
+ * the auth slots: a logged-in side keeps its fixed slot model (Luna/Haiku)
8
+ * even when the picker hides it, because the slot is the login's entitlement.
9
+ *
10
+ * Vision additionally applies rule 2 (− provably text-only); web-search
11
+ * applies its own rule 2 (∩ probed backend with an executor) in
12
+ * src/web-search/backends.ts. Both start from THIS set so the two sidecars
13
+ * cannot diverge on what "visible" means.
14
+ */
15
+ import type { OcxConfig } from "../types";
16
+ import { listManagementModelRows } from "../server/management/model-rows";
17
+ import { modelAcceptsImageInput, type VisionCandidateModel } from "../vision/eligibility";
18
+ import { sidecarAuthSlots, type SidecarAuthState } from "./auth";
19
+
20
+ export interface SidecarCandidate {
21
+ provider: string;
22
+ id: string;
23
+ native?: boolean;
24
+ inputModalities?: string[];
25
+ /** True when this row is an auth-slot entitlement rather than a picker row. */
26
+ authSlot?: boolean;
27
+ }
28
+
29
+ /**
30
+ * (picker-visible rows) ∪ (auth slots). De-duplicated by provider+id with the
31
+ * auth-slot flag winning, so a slot model that is ALSO picker-visible still
32
+ * reads as slot-backed. A catalog outage degrades to slots only — the settings
33
+ * routes must not 500 and must keep the logged-in floor populated.
34
+ */
35
+ export async function pickerVisibleSidecarCandidates(
36
+ config: OcxConfig,
37
+ auth: SidecarAuthState,
38
+ ): Promise<SidecarCandidate[]> {
39
+ let rows: Awaited<ReturnType<typeof listManagementModelRows>> = [];
40
+ try { rows = await listManagementModelRows(config); } catch { rows = []; }
41
+ const byKey = new Map<string, SidecarCandidate>();
42
+ for (const row of rows) {
43
+ if (row.disabled === true) continue;
44
+ byKey.set(`${row.provider}/${row.id}`, {
45
+ provider: row.provider,
46
+ id: row.id,
47
+ ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}),
48
+ ...(row.native ? { native: true } : {}),
49
+ });
50
+ }
51
+ for (const slot of sidecarAuthSlots(auth)) {
52
+ byKey.set(`${slot.provider}/${slot.id}`, {
53
+ provider: slot.provider,
54
+ id: slot.id,
55
+ // Slot models are known image-capable; their only exclusion path is the
56
+ // provider's explicit consumer list (same stance as baselineCandidate).
57
+ inputModalities: ["text", "image"],
58
+ authSlot: true,
59
+ });
60
+ }
61
+ return [...byKey.values()];
62
+ }
63
+
64
+ /**
65
+ * Rule 2 for vision: drop rows PROVABLY text-only. Unknown stays eligible —
66
+ * the picker filter (rule 1) already bounded the set, so permissive-unknown
67
+ * no longer expands to the whole catalog.
68
+ */
69
+ export function visionSidecarCandidates(
70
+ config: Pick<OcxConfig, "providers">,
71
+ all: readonly SidecarCandidate[],
72
+ ): SidecarCandidate[] {
73
+ return all.filter(candidate => modelAcceptsImageInput(config, toVisionCandidate(candidate)) !== false);
74
+ }
75
+
76
+ function toVisionCandidate(candidate: SidecarCandidate): VisionCandidateModel {
77
+ return {
78
+ provider: candidate.provider,
79
+ id: candidate.id,
80
+ ...(candidate.inputModalities ? { inputModalities: candidate.inputModalities } : {}),
81
+ ...(candidate.native ? { native: true } : {}),
82
+ };
83
+ }
@@ -128,7 +128,7 @@ export interface OcxClaudeCodeConfig {
128
128
  */
129
129
  subagentEffort?: "low" | "medium" | "high" | "xhigh" | "max";
130
130
  /** Claude-originated web-search override. Unset fields inherit the global sidecar settings. */
131
- webSearchSidecar?: { backend?: "openai" | "anthropic"; model?: string };
131
+ webSearchSidecar?: { backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa"; model?: string };
132
132
  /** Claude-originated vision override. Unset fields inherit the global sidecar settings. */
133
133
  visionSidecar?: { backend?: "openai" | "anthropic"; model?: string };
134
134
  /** Persisted Claude Desktop four-family routing profile. */
@@ -790,12 +790,33 @@ export interface OcxWebSearchSidecarConfig {
790
790
  /**
791
791
  * Which backend actually runs the server-side search. "openai" replays the hosted web_search via
792
792
  * the ChatGPT forward provider (gpt-mini sidecar); "anthropic" runs web_search_20250305 on a Claude
793
- * model authenticated by the STORED anthropic OAuth credential. Unset resolves to "anthropic" when a
794
- * usable anthropic OAuth credential exists, else "openai".
795
- */
796
- backend?: "openai" | "anthropic";
793
+ * model authenticated by the STORED anthropic OAuth credential. "xai" runs Grok hosted web_search
794
+ * and optional x_search through stored Grok OAuth. "gemini" (google_search grounding via the
795
+ * Antigravity CCA transport) and "exa" (non-LLM search JSON via an operator key) are explicit-only
796
+ * and stay inactive until their executors ship. Unset ALWAYS resolves to "openai"; no backend is ever
797
+ * auto-selected from credential availability (that once sent incompatible models to the
798
+ * Anthropic API — see resolveSidecarBackend).
799
+ */
800
+ backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa";
797
801
  /** Sidecar model that runs the real server-side web_search (must be a native ChatGPT model). */
798
802
  model?: string;
803
+ /**
804
+ * Operator-supplied Exa API key for the "exa" backend. Management GET responses never echo it,
805
+ * and src/lib/redact.ts strips it from any logged structure or error string.
806
+ */
807
+ exaApiKey?: string;
808
+ /**
809
+ * Opt-in X (Twitter) search for the xai backend: adds the hosted x_search tool next to
810
+ * web_search. Limits are doc-validated at the management layer AND in the executor:
811
+ * handles <=20 per list, allow XOR exclude, ISO-8601 dates.
812
+ */
813
+ xSearch?: {
814
+ enabled?: boolean;
815
+ allowedXHandles?: string[];
816
+ excludedXHandles?: string[];
817
+ fromDate?: string;
818
+ toDate?: string;
819
+ };
799
820
  /** Reasoning effort for the sidecar — "minimal" (non-thinking) keeps it fast/cheap. */
800
821
  reasoning?: string;
801
822
  /** Max searches executed per main-model turn (loop guard). */
@@ -204,6 +204,11 @@ export interface OcxProviderConfig {
204
204
  * `ocxr1` envelopes are still stripped because no upstream can decrypt them.
205
205
  */
206
206
  preserveResponsesReasoningContent?: boolean;
207
+ /**
208
+ * Explicit opt-in for a relay that genuinely fronts OpenAI and can decode native
209
+ * compaction blobs. Absent or false degrades foreign blobs to an opaque note.
210
+ */
211
+ decodesNativeCompactionBlobs?: boolean;
207
212
  /**
208
213
  * Explicit opt-in for non-registry private-network destinations such as localhost, RFC1918,
209
214
  * link-local, or unique-local upstreams. Metadata endpoints remain blocked.
@@ -341,6 +346,12 @@ export interface OcxProviderConfig {
341
346
  * Use for non-forward Responses gateways that reserve a hosted tool namespace server-side.
342
347
  */
343
348
  modelPreferHostedTools?: Record<string, string[]>;
349
+ /**
350
+ * Whether the Responses upstream accepts OpenAI's extended hosted web_search fields.
351
+ * Set false only for a provider whose native contract rejects them; absence preserves
352
+ * passthrough compatibility for OpenAI and unclassified gateways.
353
+ */
354
+ supportsOpenAiWebSearchToolFields?: boolean;
344
355
  /**
345
356
  * Provider-local repair for Responses gateways whose lifecycle snapshots omit canonical
346
357
  * fields or closing events (#893). Disabled by default and applied only to client-facing
@@ -400,6 +411,13 @@ export interface OcxProviderConfig {
400
411
  * mid-work; non-`openai-chat` adapters ignore this flag.
401
412
  */
402
413
  terminalContinuationGuard?: boolean;
414
+ /**
415
+ * Opt-in for OpenAI-compatible chat gateways that may close after emitting a complete
416
+ * tool-call delta without `finish_reason` or `[DONE]`. The adapter accepts that EOF only
417
+ * when every pending call has a non-empty name and complete JSON-object arguments;
418
+ * incomplete JSON, missing arguments, and empty streams remain truncation errors.
419
+ */
420
+ openaiChatEofTolerance?: boolean;
403
421
  /**
404
422
  * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body.
405
423
  * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown
@@ -65,6 +65,11 @@ export interface OcxParsedRequest {
65
65
  _clientThreadId?: string;
66
66
  /** Provider/account/model-bound namespace for process-local raw-reasoning replay. */
67
67
  _reasoningReplayScope?: OcxReasoningReplayScopeRef;
68
+ /**
69
+ * Set by bindRouteReasoningReplayScope after a proven serving-identity change, or by
70
+ * prepareOpaqueBlobRecovery after an authoritative rejection; consumers strip replayed blobs.
71
+ */
72
+ _stripReasoningEncryptedContent?: boolean;
68
73
  /**
69
74
  * Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation.
70
75
  * When absent (single-operator local proxy), derivation stays local-scoped.
@@ -79,6 +84,10 @@ export interface OcxParsedRequest {
79
84
  _kiroAuthContext?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">;
80
85
  /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */
81
86
  _providerContinuation?: OcxProviderContinuationState;
87
+ /** Persisted continuation considered only after the final physical route is known. */
88
+ _providerContinuationCandidate?: OcxProviderContinuationState;
89
+ /** Exact process-local route owner attached to newly persisted provider state. */
90
+ _providerContinuationOwner?: OcxProviderContinuationOwner;
82
91
  /**
83
92
  * The hosted `{type:"web_search", ...}` tool config, stashed when Codex enables web search. Routed
84
93
  * (non-OpenAI) providers can't run it server-side, so the proxy re-exposes it as a function tool and
@@ -251,16 +260,33 @@ export interface OcxRequestOptions {
251
260
 
252
261
  export type OcxMessagePhase = "commentary" | "final_answer";
253
262
 
263
+ /** Non-secret, process-local owner fence for provider-private continuation state. */
264
+ export interface OcxProviderContinuationOwner {
265
+ [field: string]: string | number;
266
+ version: 1;
267
+ providerName: string;
268
+ providerDestinationIdentity: string;
269
+ adapterName: string;
270
+ modelId: string;
271
+ credentialIdentity: string;
272
+ }
273
+
254
274
  /**
255
275
  * Provider-private state that must follow a locally expanded `previous_response_id` chain.
256
276
  * Kept out of public Responses output and persisted only in the bounded local continuation cache.
257
277
  */
258
278
  export interface OcxProviderContinuationState {
279
+ /** Proxy-authored owner metadata; stripped before provider adapters receive the state. */
280
+ __ocxOwner?: OcxProviderContinuationOwner;
259
281
  cursor?: {
282
+ [field: string]: unknown;
260
283
  conversationId?: string;
261
284
  checkpointUsable?: boolean;
285
+ /** Opaque process-local Cursor ConversationStateStructure snapshot ref. Never raw protobuf. */
286
+ checkpointRef?: string;
262
287
  };
263
288
  kiro?: {
289
+ [field: string]: unknown;
264
290
  conversationId?: string;
265
291
  };
266
292
  [provider: string]: Record<string, unknown> | undefined;
package/src/types.ts CHANGED
@@ -43,6 +43,7 @@ export type {
43
43
  OcxAssistantContentPart,
44
44
  OcxRequestOptions,
45
45
  OcxMessagePhase,
46
+ OcxProviderContinuationOwner,
46
47
  OcxProviderContinuationState,
47
48
  AdapterEvent,
48
49
  OcxUrlCitation,
package/src/usage/log.ts CHANGED
@@ -29,6 +29,7 @@ export type AttemptRecoveryKind =
29
29
  | "rate-limit-429"
30
30
  | "anthropic-oauth-429"
31
31
  | "image-413"
32
+ | "opaque-blob-rejection"
32
33
  | "empty-completion";
33
34
 
34
35
  export interface PersistedUsageAttempt {
@@ -218,6 +219,7 @@ const ATTEMPT_RECOVERY_KINDS = new Set<AttemptRecoveryKind>([
218
219
  "rate-limit-429",
219
220
  "anthropic-oauth-429",
220
221
  "image-413",
222
+ "opaque-blob-rejection",
221
223
  "empty-completion",
222
224
  ]);
223
225
  const USAGE_STATUSES = new Set<UsageStatus>([