@bitkyc08/opencodex 2.13.0 → 2.14.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 (55) hide show
  1. package/gui/dist/assets/index-Co12XTT-.js +76 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/cursor/discovery.ts +4 -1
  5. package/src/adapters/cursor/effort-map.ts +5 -1
  6. package/src/adapters/cursor/request-builder.ts +3 -3
  7. package/src/adapters/google.ts +25 -5
  8. package/src/adapters/openai-chat.ts +182 -6
  9. package/src/adapters/openai-responses.ts +17 -7
  10. package/src/codex/catalog/bundled.ts +16 -0
  11. package/src/codex/catalog/metadata.ts +180 -5
  12. package/src/codex/catalog/parsing.ts +7 -6
  13. package/src/codex/catalog/sync.ts +73 -6
  14. package/src/codex/catalog.ts +1 -1
  15. package/src/codex/convergence.ts +20 -0
  16. package/src/codex/prompt-journal.ts +50 -13
  17. package/src/codex/prompt-layers.ts +1 -1
  18. package/src/config.ts +57 -0
  19. package/src/generated/compatibility-version.json +64 -48
  20. package/src/generated/model-metadata.ts +3 -3
  21. package/src/lib/local-provider-reload-contract.ts +100 -0
  22. package/src/oauth/login-cli.ts +52 -26
  23. package/src/providers/derive.ts +2 -0
  24. package/src/providers/openai-sidecar.ts +9 -2
  25. package/src/providers/quota.ts +57 -0
  26. package/src/providers/registry.ts +53 -25
  27. package/src/responses/state.ts +22 -0
  28. package/src/router.ts +1 -0
  29. package/src/server/claude-messages.ts +57 -11
  30. package/src/server/direct-local-http.ts +7 -3
  31. package/src/server/images.ts +6 -0
  32. package/src/server/index.ts +51 -11
  33. package/src/server/live.ts +117 -13
  34. package/src/server/local-provider-reload-client.ts +137 -0
  35. package/src/server/management/config-routes.ts +20 -3
  36. package/src/server/management/logs-usage-routes.ts +28 -0
  37. package/src/server/management/model-routes.ts +11 -3
  38. package/src/server/management/model-rows.ts +18 -3
  39. package/src/server/management/provider-routes.ts +107 -3
  40. package/src/server/management-auth.ts +65 -1
  41. package/src/server/proxy-liveness.ts +1 -0
  42. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  43. package/src/server/responses/agent-task-recovery.ts +460 -0
  44. package/src/server/responses/compact.ts +4 -2
  45. package/src/server/responses/core.ts +142 -6
  46. package/src/server/responses/encrypted-payload.ts +4 -1
  47. package/src/server/search.ts +4 -0
  48. package/src/types.ts +27 -0
  49. package/src/usage/expected-prices.ts +11 -0
  50. package/src/vision/describe.ts +4 -0
  51. package/src/web-search/anthropic-executor.ts +5 -1
  52. package/src/web-search/executor.ts +9 -1
  53. package/src/web-search/index.ts +5 -0
  54. package/src/web-search/loop.ts +42 -5
  55. package/gui/dist/assets/index-BHldBl6_.js +0 -76
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BHldBl6_.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-Co12XTT-.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-BNVYzdn0.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -29,7 +29,7 @@ export function inferCursorContextWindow(modelId: string): number {
29
29
  if (id === "glm-5.2") return CONTEXT_1M;
30
30
  if (id.startsWith("gpt-5.6-")) return CONTEXT_1M;
31
31
  if (id.startsWith("gpt-5") || id === "gpt-5-codex") return CONTEXT_272K;
32
- if (id.startsWith("grok-4.5")) return 500_000;
32
+ if (id.startsWith("grok-4.5") || id.startsWith("grok-4.6")) return 500_000;
33
33
  if (id.startsWith("grok-")) return CONTEXT_256K;
34
34
  if (id.includes("claude")) return CONTEXT_200K;
35
35
  return CURSOR_DEFAULT_CONTEXT_WINDOW;
@@ -240,6 +240,9 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
240
240
 
241
241
  { id: "grok-4.5", contextWindow: 500_000, supportsReasoningEffort: true },
242
242
  { id: "grok-4.5-fast", contextWindow: 500_000, supportsReasoningEffort: true },
243
+ // 260813 preemptive: grok-4.6 seeded ahead of Cursor's lineup update (mirrors grok-4.5).
244
+ { id: "grok-4.6", contextWindow: 500_000, supportsReasoningEffort: true },
245
+ { id: "grok-4.6-fast", contextWindow: 500_000, supportsReasoningEffort: true },
243
246
  ]);
244
247
 
245
248
  export function cursorModelIds(models: readonly CursorModelInfo[] = CURSOR_STATIC_MODELS): string[] {
@@ -35,6 +35,10 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
35
35
  // cursor-grok-4.5-{low,medium,high}-fast. The bare Fast id returns not_found.
36
36
  "grok-4.5": ["low", "medium", "high"],
37
37
  "grok-4.5-fast": ["low", "medium", "high"],
38
+ // 260813 preemptive: grok-4.6 tiers mirrored from grok-4.5 ahead of Cursor's lineup update,
39
+ // so the suffix/wire handling is already correct the day the slugs appear.
40
+ "grok-4.6": ["low", "medium", "high"],
41
+ "grok-4.6-fast": ["low", "medium", "high"],
38
42
  "gpt-5.1": ["low", "high"],
39
43
  "gpt-5.1-codex-max": ["low", "medium", "high", "xhigh"],
40
44
  "gpt-5.1-codex-mini": ["low", "high"],
@@ -135,5 +139,5 @@ export function cursorWireModelIdWithEffort(baseModelId: string, effortSuffix: s
135
139
  */
136
140
  export function cursorRequestWireModelIdWithEffort(baseModelId: string, effortSuffix: string): string {
137
141
  const flattened = cursorWireModelIdWithEffort(baseModelId, effortSuffix);
138
- return baseModelId === "grok-4.5" ? `cursor-${flattened}` : flattened;
142
+ return baseModelId === "grok-4.5" || baseModelId === "grok-4.6" ? `cursor-${flattened}` : flattened;
139
143
  }
@@ -129,7 +129,7 @@ function catalogLimitNote(kept: readonly OcxTool[], omitted: readonly OcxTool[])
129
129
 
130
130
  /**
131
131
  * Resolve a `cursor/<model>` selection + Codex reasoning effort to Cursor's requested model shape.
132
- * Most models encode effort in a flat id (`claude-4.6-opus-high`). Grok 4.5 Fast is parameterized
132
+ * Most models encode effort in a flat id (`claude-4.6-opus-high`). Grok Fast is parameterized
133
133
  * instead: current Cursor clients send the `grok-4.5` base id plus `effort` and `fast` parameters.
134
134
  * A fully-qualified id (one that is not a known effort base) passes through unchanged.
135
135
  */
@@ -141,10 +141,10 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): {
141
141
  const selection = cursorWireModelSelection(modelId);
142
142
  const id = selection.modelId;
143
143
  const suffix = cursorEffortSuffix(id, reasoning);
144
- if (id === "grok-4.5-fast" && suffix) {
144
+ if ((id === "grok-4.5-fast" || id === "grok-4.6-fast") && suffix) {
145
145
  return {
146
146
  ...selection,
147
- modelId: "grok-4.5",
147
+ modelId: id.slice(0, -"-fast".length),
148
148
  requestedModelParameters: [
149
149
  { id: "effort", value: suffix },
150
150
  { id: "fast", value: "true" },
@@ -304,6 +304,24 @@ function artifactMarkdownUrl(filePath: string): string {
304
304
  return artifactHttpUrl(filePath).replace(/([()])/g, "\\$1");
305
305
  }
306
306
 
307
+ interface GoogleResponsePart {
308
+ text?: string;
309
+ thought?: boolean;
310
+ functionCall?: { name: string; args: unknown };
311
+ }
312
+
313
+ /**
314
+ * Google marks model-internal reasoning as a normal text-bearing part plus `thought: true`.
315
+ * Keep that provider visibility bit authoritative here so the streaming and buffered parsers
316
+ * cannot accidentally expose the same hidden reasoning through different event types.
317
+ */
318
+ function googlePartTextEvent(part: GoogleResponsePart): AdapterEvent | undefined {
319
+ if (!part.text) return undefined;
320
+ return part.thought === true
321
+ ? { type: "reasoning_raw_delta", text: part.text }
322
+ : { type: "text_delta", text: part.text };
323
+ }
324
+
307
325
  export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter {
308
326
  // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest
309
327
  // can stash the CCA model/session for parseStream's reasoning-replay observation.
@@ -602,7 +620,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
602
620
  sawTerminalSignal = true;
603
621
  }
604
622
 
605
- const parts = candidate.content?.parts as { text?: string; functionCall?: { name: string; args: unknown } }[] | undefined;
623
+ const parts = candidate.content?.parts as GoogleResponsePart[] | undefined;
606
624
  // Record Gemini thought signatures for the next stateless tool-result turn. Vertex and
607
625
  // Antigravity use separate model namespaces so opaque provider state cannot cross routes.
608
626
  const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
@@ -613,9 +631,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
613
631
  }
614
632
  if (parts) {
615
633
  for (const part of parts) {
616
- if (part.text) {
634
+ const textEvent = googlePartTextEvent(part);
635
+ if (textEvent) {
617
636
  emittedContentEvent = true;
618
- yield { type: "text_delta", text: part.text };
637
+ yield textEvent;
619
638
  }
620
639
  const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData;
621
640
  if (inline && typeof inline.data === "string") {
@@ -817,7 +836,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
817
836
  }
818
837
  const events: AdapterEvent[] = [];
819
838
 
820
- const candidates = json.candidates as { content?: { parts?: { text?: string; functionCall?: { name: string; args: unknown } }[] }; finishReason?: string }[] | undefined;
839
+ const candidates = json.candidates as { content?: { parts?: GoogleResponsePart[] }; finishReason?: string }[] | undefined;
821
840
  if (!candidates?.length) {
822
841
  return finish([{ type: "error", message: "google response contained no candidates" }]);
823
842
  }
@@ -833,7 +852,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
833
852
  observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]);
834
853
  }
835
854
  for (const part of candidates[0].content.parts) {
836
- if (part.text) events.push({ type: "text_delta", text: part.text });
855
+ const textEvent = googlePartTextEvent(part);
856
+ if (textEvent) events.push(textEvent);
837
857
  const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData;
838
858
  if (inline && typeof inline.data === "string") {
839
859
  if (inline.data.length > MAX_ENCODED_BYTES_PER_IMAGE) {
@@ -178,10 +178,125 @@ function invalidToolCallsEvent(usage?: OcxUsage): Extract<AdapterEvent, { type:
178
178
  };
179
179
  }
180
180
 
181
+ /**
182
+ * A streamed tool call is only dispatchable once the upstream has named the function.
183
+ *
184
+ * The OpenAI streaming convention puts `function.name` in the first chunk for a tool-call
185
+ * index and leaves later chunks carrying only `arguments` deltas, so a stream that never
186
+ * sends a name is non-conforming for every provider rather than quirky for one. The
187
+ * reference implementations accumulate such a call with an empty name and let the caller
188
+ * fail; we sit at the boundary where it would become a Codex tool-call contract event, so
189
+ * the equivalent is to refuse to emit it.
190
+ *
191
+ * Failing closed rather than dropping is deliberate, and matches #1325: a claimed tool call
192
+ * that silently disappears can leave the matching result orphaned on the next turn. Naming
193
+ * it ourselves is worse still — the id is synthesizable because it is an opaque correlation
194
+ * handle, but a function name is a guess at intent.
195
+ */
196
+ function unnamedToolCallEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
197
+ return {
198
+ type: "error",
199
+ message: "upstream streamed a tool call without a function name — cannot dispatch",
200
+ ...(usage !== undefined ? { usage } : {}),
201
+ };
202
+ }
203
+
181
204
  function isRecord(value: unknown): value is Record<string, unknown> {
182
205
  return value !== null && typeof value === "object" && !Array.isArray(value);
183
206
  }
184
207
 
208
+ type InvalidToolCallReason =
209
+ | "tool_calls_not_array"
210
+ | "tool_call_not_object"
211
+ | "tool_call_id_invalid"
212
+ | "tool_call_function_not_object"
213
+ | "tool_call_function_name_invalid"
214
+ | "tool_call_function_name_blank"
215
+ | "tool_call_function_arguments_invalid";
216
+
217
+ /**
218
+ * Explain only the rejected wire shape, never its values. This diagnostic exists so provider
219
+ * compatibility can be tightened from evidence without retaining tool arguments or credentials.
220
+ */
221
+ function diagnoseInvalidToolCalls(
222
+ rawToolCalls: unknown,
223
+ mode: "stream" | "response",
224
+ ): { reason: InvalidToolCallReason; callIndex?: number; valueType: string } | undefined {
225
+ if (!Array.isArray(rawToolCalls)) {
226
+ return { reason: "tool_calls_not_array", valueType: rawToolCalls === null ? "null" : typeof rawToolCalls };
227
+ }
228
+ for (let callIndex = 0; callIndex < rawToolCalls.length; callIndex++) {
229
+ const rawToolCall = rawToolCalls[callIndex];
230
+ if (!isRecord(rawToolCall)) {
231
+ return {
232
+ reason: "tool_call_not_object",
233
+ callIndex,
234
+ valueType: rawToolCall === null ? "null" : Array.isArray(rawToolCall) ? "array" : typeof rawToolCall,
235
+ };
236
+ }
237
+ if (mode === "stream") {
238
+ // The streamed path validates the pieces it is about to store (#1531): a present
239
+ // `function` must be a record, and a present `name`/`arguments`/`id` must be a string.
240
+ // Blank names are caught later at flush, not here, so they are not diagnosed on this
241
+ // branch. Describe exactly that boundary rather than tightening compatibility in a
242
+ // diagnostic change.
243
+ const streamFunction = (rawToolCall as { function?: unknown }).function;
244
+ if (streamFunction !== undefined && streamFunction !== null) {
245
+ if (!isRecord(streamFunction)) {
246
+ return {
247
+ reason: "tool_call_function_not_object",
248
+ callIndex,
249
+ valueType: Array.isArray(streamFunction) ? "array" : typeof streamFunction,
250
+ };
251
+ }
252
+ if (streamFunction.name !== undefined && typeof streamFunction.name !== "string") {
253
+ return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof streamFunction.name };
254
+ }
255
+ if (streamFunction.arguments !== undefined && typeof streamFunction.arguments !== "string") {
256
+ return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof streamFunction.arguments };
257
+ }
258
+ }
259
+ if (rawToolCall.id !== undefined && typeof rawToolCall.id !== "string") {
260
+ return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id };
261
+ }
262
+ continue;
263
+ }
264
+ // Precedence must mirror the buffered validator below, or a payload with more than one
265
+ // problem is reported under the wrong reason and sends compatibility work after the wrong
266
+ // shape. That validator checks the `function` container first (`!isRecord(rawToolCall) ||
267
+ // !isRecord(rawToolCall.function)`), then id/name/arguments types together, and only then
268
+ // the blank name.
269
+ if (!isRecord(rawToolCall.function)) {
270
+ return {
271
+ reason: "tool_call_function_not_object",
272
+ callIndex,
273
+ valueType: rawToolCall.function === null ? "null" : Array.isArray(rawToolCall.function) ? "array" : typeof rawToolCall.function,
274
+ };
275
+ }
276
+ if (typeof rawToolCall.id !== "string") {
277
+ return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id };
278
+ }
279
+ if (typeof rawToolCall.function.name !== "string") {
280
+ return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof rawToolCall.function.name };
281
+ }
282
+ if (typeof rawToolCall.function.arguments !== "string") {
283
+ return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof rawToolCall.function.arguments };
284
+ }
285
+ // Last, matching the validator: #1531 also rejects a blank or whitespace-only name here,
286
+ // because such a call cannot select a dispatch target. Reporting it as `name_invalid`
287
+ // would claim a type problem for a correctly-typed value, so it gets its own code.
288
+ if (rawToolCall.function.name.trim().length === 0) {
289
+ return { reason: "tool_call_function_name_blank", callIndex, valueType: "string" };
290
+ }
291
+ }
292
+ return undefined;
293
+ }
294
+
295
+ function logInvalidToolCalls(mode: "stream" | "response", rawToolCalls: unknown): void {
296
+ const diagnostic = diagnoseInvalidToolCalls(rawToolCalls, mode);
297
+ if (diagnostic) debugProviderDiagnostic("openai-chat", "invalid-tool-calls", { mode, ...diagnostic });
298
+ }
299
+
185
300
  function developerSystemText(message: OcxMessage): string | undefined {
186
301
  if (message.role !== "developer") return undefined;
187
302
  if (typeof message.content === "string") return message.content;
@@ -721,6 +836,18 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
721
836
  messages,
722
837
  stream: parsed.stream,
723
838
  };
839
+ // Preserve a caller-selected service tier for OpenAI-compatible chat gateways. The
840
+ // request pipeline deliberately does not inject fast mode for this adapter, but dropping
841
+ // an explicit value here makes the Responses parser's serviceTier projection ineffective.
842
+ //
843
+ // Opt-in, like `prompt_cache_key` directly below: `service_tier` is an OpenAI-specific
844
+ // extension and 66 registry providers share this adapter, several of which reject
845
+ // unknown body fields. Forwarding unconditionally would turn a caller-supplied
846
+ // `service_tier` into an upstream 400 on those routes. `supportsServiceTier` is the
847
+ // Responses-wire flag (applyServiceTierGate) and deliberately does not gate this path.
848
+ if (provider.chatServiceTier && parsed.options.serviceTier !== undefined) {
849
+ body.service_tier = parsed.options.serviceTier;
850
+ }
724
851
  if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true;
725
852
  const maxTokens = resolveMaxTokens(provider, parsed);
726
853
  const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId);
@@ -897,13 +1024,27 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
897
1024
  pendingToolCalls.length = 0;
898
1025
  return calls;
899
1026
  };
900
- const flushToolCalls = function* (): Generator<AdapterEvent> {
1027
+ // Returns "terminate" when a pending call cannot be dispatched, so every flush site
1028
+ // stops the turn instead of emitting an unusable call. `closeToolCalls()` runs first,
1029
+ // so budget reservations are released for every pending call even on the early return.
1030
+ const flushToolCalls = function* (): Generator<AdapterEvent, "continue" | "terminate"> {
901
1031
  for (const call of closeToolCalls()) {
1032
+ // Ingest already proved `name` is a string; the typeof guard keeps this branch
1033
+ // total so a future ingest change cannot turn a malformed name into a throw.
1034
+ if (typeof call.name !== "string" || call.name.trim().length === 0) {
1035
+ debugProviderDiagnostic("openai-chat", "tool-call-unnamed", {
1036
+ hadId: call.id.length > 0,
1037
+ argsBytes: call.argsBytes,
1038
+ });
1039
+ yield unnamedToolCallEvent(pendingUsage);
1040
+ return "terminate";
1041
+ }
902
1042
  if (!call.id) call.id = `call_${++toolCallSeq}`;
903
1043
  yield { type: "tool_call_start", id: call.id, name: call.name };
904
1044
  if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
905
1045
  yield { type: "tool_call_end" };
906
1046
  }
1047
+ return "continue";
907
1048
  };
908
1049
  const terminateWithError = function* (
909
1050
  event: Extract<AdapterEvent, { type: "error" }>,
@@ -922,7 +1063,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
922
1063
  const payload = rawPayload.trim();
923
1064
  if (payload.length === 0) return "continue";
924
1065
  if (payload === "[DONE]") {
925
- yield* flushToolCalls();
1066
+ if ((yield* flushToolCalls()) === "terminate") return "terminate";
926
1067
  const stopReason = stopReasonFor(finishReason);
927
1068
  yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
928
1069
  return "terminate";
@@ -981,10 +1122,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
981
1122
  // through the adapter error channel instead of escaping as TypeError (#1325). Null is
982
1123
  // tolerated as absent because OpenAI-compatible providers may emit it as stream padding.
983
1124
  if (!Array.isArray(rawToolCalls)) {
1125
+ logInvalidToolCalls("stream", rawToolCalls);
984
1126
  return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
985
1127
  }
986
1128
  for (const rawToolCall of rawToolCalls) {
987
1129
  if (!isRecord(rawToolCall)) {
1130
+ logInvalidToolCalls("stream", rawToolCalls);
988
1131
  return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
989
1132
  }
990
1133
  const tc = rawToolCall as {
@@ -992,6 +1135,28 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
992
1135
  id?: string;
993
1136
  function?: { name?: string; arguments?: string };
994
1137
  };
1138
+ // That cast is a TypeScript convenience, not a runtime guarantee: this is
1139
+ // upstream JSON. Validate the fields before they are stored, so a non-string
1140
+ // name or arguments value fails closed through the #1325 channel here rather
1141
+ // than escaping later as a TypeError from string handling at flush time.
1142
+ const rawFunction = (rawToolCall as { function?: unknown }).function;
1143
+ if (rawFunction !== undefined && rawFunction !== null) {
1144
+ if (!isRecord(rawFunction)) {
1145
+ logInvalidToolCalls("stream", rawToolCalls);
1146
+ return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
1147
+ }
1148
+ const rawName = rawFunction.name;
1149
+ const rawArguments = rawFunction.arguments;
1150
+ if ((rawName !== undefined && typeof rawName !== "string")
1151
+ || (rawArguments !== undefined && typeof rawArguments !== "string")) {
1152
+ logInvalidToolCalls("stream", rawToolCalls);
1153
+ return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
1154
+ }
1155
+ }
1156
+ if (tc.id !== undefined && typeof tc.id !== "string") {
1157
+ logInvalidToolCalls("stream", rawToolCalls);
1158
+ return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
1159
+ }
995
1160
  const key = typeof tc.index === "number"
996
1161
  ? `i:${tc.index}`
997
1162
  : tc.id
@@ -1025,7 +1190,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1025
1190
  }
1026
1191
  }
1027
1192
 
1028
- if (typeof choice.finish_reason === "string" && choice.finish_reason) yield* flushToolCalls();
1193
+ if (typeof choice.finish_reason === "string" && choice.finish_reason) {
1194
+ if ((yield* flushToolCalls()) === "terminate") return "terminate";
1195
+ }
1029
1196
  return "continue";
1030
1197
  };
1031
1198
 
@@ -1085,7 +1252,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1085
1252
  yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
1086
1253
  return;
1087
1254
  }
1088
- yield* flushToolCalls();
1255
+ if ((yield* flushToolCalls()) === "terminate") return;
1089
1256
  const stopReason = stopReasonFor(finishReason);
1090
1257
  yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
1091
1258
  } catch (error) {
@@ -1148,15 +1315,24 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1148
1315
  if (typeof msg.content === "string") events.push({ type: "text_delta", text: msg.content });
1149
1316
  const rawToolCalls = msg.tool_calls;
1150
1317
  if (rawToolCalls !== undefined && rawToolCalls !== null) {
1151
- if (!Array.isArray(rawToolCalls)) return [invalidToolCallsEvent(usage)];
1318
+ if (!Array.isArray(rawToolCalls)) {
1319
+ logInvalidToolCalls("response", rawToolCalls);
1320
+ return [invalidToolCallsEvent(usage)];
1321
+ }
1152
1322
  for (const rawToolCall of rawToolCalls) {
1153
1323
  if (!isRecord(rawToolCall) || !isRecord(rawToolCall.function)) {
1324
+ logInvalidToolCalls("response", rawToolCalls);
1154
1325
  return [invalidToolCallsEvent(usage)];
1155
1326
  }
1156
1327
  const id = rawToolCall.id;
1157
1328
  const name = rawToolCall.function.name;
1158
1329
  const args = rawToolCall.function.arguments;
1159
- if (typeof id !== "string" || typeof name !== "string" || typeof args !== "string") {
1330
+ // A blank name is as undispatchable as a missing one, so it fails closed here
1331
+ // for the same reason the streamed path refuses it. Trimmed length, not `!name`:
1332
+ // a whitespace-only function name is not a legitimate tool-call shape either.
1333
+ if (typeof id !== "string" || typeof name !== "string" || typeof args !== "string"
1334
+ || name.trim().length === 0) {
1335
+ logInvalidToolCalls("response", rawToolCalls);
1160
1336
  return [invalidToolCallsEvent(usage)];
1161
1337
  }
1162
1338
  events.push({ type: "tool_call_start", id, name });
@@ -6,7 +6,7 @@ import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../resp
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 { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
9
+ import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
10
10
  import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
11
11
  import { modelRecordValue } from "../reasoning-effort";
12
12
  import type { TranslatorBudget } from "../lib/translator-budget";
@@ -1213,22 +1213,32 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1213
1213
  let url: string;
1214
1214
 
1215
1215
  if (provider.authMode === "forward") {
1216
+ const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider);
1216
1217
  // OAuth passthrough: ChatGPT backend path is `${baseUrl}/responses` (no /v1).
1217
- url = `${provider.baseUrl}/responses`;
1218
+ const baseUrl = mayForwardCallerCredentials
1219
+ ? CODEX_FORWARD_BASE_URL
1220
+ : provider.baseUrl.replace(/\/+$/, "");
1221
+ url = `${baseUrl}/responses`;
1218
1222
  if (provider.headers) Object.assign(headers, provider.headers); // static headers first…
1219
1223
  const runtimeProvider = provider as {
1220
1224
  _codexAccountOverride?: { accessToken: string; chatgptAccountId: string };
1221
1225
  _codexAccountRequired?: boolean;
1222
1226
  };
1223
- if (runtimeProvider._codexAccountRequired && !runtimeProvider._codexAccountOverride) {
1227
+ if (
1228
+ mayForwardCallerCredentials
1229
+ && runtimeProvider._codexAccountRequired
1230
+ && !runtimeProvider._codexAccountOverride
1231
+ ) {
1224
1232
  throw new Error("Codex pool account auth is required but unavailable");
1225
1233
  }
1226
- for (const h of FORWARD_HEADERS) {
1227
- const v = incoming?.headers.get(h);
1228
- if (v) headers[h] = v; // …so forwarded auth always wins.
1234
+ if (mayForwardCallerCredentials) {
1235
+ for (const h of FORWARD_HEADERS) {
1236
+ const v = incoming?.headers.get(h);
1237
+ if (v) headers[h] = v; // …so forwarded auth always wins.
1238
+ }
1229
1239
  }
1230
1240
  const override = runtimeProvider._codexAccountOverride;
1231
- if (override) {
1241
+ if (override && mayForwardCallerCredentials) {
1232
1242
  headers["authorization"] = `Bearer ${override.accessToken}`;
1233
1243
  headers["chatgpt-account-id"] = override.chatgptAccountId;
1234
1244
  }
@@ -522,6 +522,22 @@ export function readCurrentCatalogOrCache(): RawCatalog | null {
522
522
  return readCatalog(path) ?? readCatalog(activeCodexModelsCachePath());
523
523
  }
524
524
 
525
+ /**
526
+ * Read the user-owned Codex catalog surfaces without substituting the bundled catalog.
527
+ *
528
+ * The bundled catalog is intentionally the authority for static native metadata on the default
529
+ * path. Account-qualified discovery needs the opposite view: an exact model id that Codex has
530
+ * observed in the user's catalog/cache may be account-scoped even when this release does not know
531
+ * it statically yet.
532
+ */
533
+ export function readCurrentCodexCatalog(): RawCatalog | null {
534
+ return readCatalog(readCodexCatalogPath());
535
+ }
536
+
537
+ export function readCurrentCodexModelsCache(): RawCatalog | null {
538
+ return readCatalog(activeCodexModelsCachePath());
539
+ }
540
+
525
541
  export function loadCatalogTemplate(): RawEntry | null {
526
542
  const catalogPath = readCodexCatalogPath();
527
543
  const bundled = loadBundledCodexCatalog();