@gajae-code/ai 0.18.6 → 0.18.7

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.18.7] - 2026-10-04
6
+
7
+ ### Fixed
8
+
9
+ - Explicitly emit `strict: false` for non-strict OpenAI Responses and Codex Responses function tools.
10
+
11
+ - Salvage finalized Codex tool calls when an empty assistant message item opens before a transient stream close, while still failing closed on non-empty text.
12
+
13
+ - Preserve finalized Codex tool calls when the stream ends without a terminal event, including an empty trailing message; unfinished calls and visible text still fail closed.
14
+
15
+ - Add Codex request-timeout salvage refusal diagnostics and replay coverage for complete `todo_write` calls.
16
+
17
+ - Salvage complete Codex function calls when the SSE or WebSocket idle watchdog ends a stalled stream.
18
+
19
+ - **Kiro OAuth CodeWhisperer wire protocol**: Corrected critical wire protocol issues in the streaming transport:
20
+ - Fixed typo in request header `amzn-X-amz-target` → `x-amz-target`
21
+ - Changed streaming service target from `AmazonCodeWhispererService.GenerateAssistantResponse` to `AmazonCodeWhispererStreamingService.GenerateAssistantResponse`
22
+ - Fixed content-type from `application/json` to `application/x-amz-json-1.0`
23
+ - Moved `profileArn` from header to request body `conversationState`
24
+ - Flattened `userInputMessageContext.tools` from nested `{tools: [...]}` to direct array
25
+ - Fixed event payload handling for flat `{content}` structure (not nested `{assistantResponseEvent: {content}}`)
26
+ - Implemented accumulation of streaming tool input fragments per `toolUseId` until `stop` signal
27
+ - Cross-verified against kiro-api-key.ts headers implementation
28
+ - Reported by: nomo via wire protocol validation failures
29
+
5
30
  ## [0.18.6] - 2026-10-03
6
31
 
7
32
  ### Added
@@ -77,3 +77,4 @@ type CodexToolPayload = {
77
77
  /** @internal Exported for tests. */
78
78
  export declare function convertOpenAICodexResponsesTools(tools: Tool[], model: Model<"openai-codex-responses">): CodexToolPayload[];
79
79
  declare function isRetryableCodexFailureEvent(rawEvent: Record<string, unknown>): boolean;
80
+ export declare function isExplicitCodexTerminalVeto(code?: string, providerMessage?: string): boolean;
@@ -640,6 +640,8 @@ export interface AssistantMessage {
640
640
  * supplied no trustworthy structured evidence.
641
641
  */
642
642
  providerDiagnostic?: ProviderDiagnostic;
643
+ /** Redacted first-event transport timing and chunk-shape diagnostics for Google Gemini CLI. */
644
+ googleGeminiCliDiagnostics?: GoogleGeminiCliDiagnostics;
643
645
  /** Typed upstream failure facts retained for retry classification without parsing errorMessage. */
644
646
  transportFailure?: TransportFailureFacts;
645
647
  /**
@@ -658,6 +660,19 @@ export interface AssistantMessage {
658
660
  /** Prompt-prefix fingerprint of the request that produced this message. */
659
661
  promptPrefix?: PromptPrefixTelemetry;
660
662
  }
663
+ export interface GoogleGeminiCliDiagnostics {
664
+ responseAtMs?: number;
665
+ firstRawSseAtMs?: number;
666
+ chunkCounts: {
667
+ content: number;
668
+ thinking: number;
669
+ functionCall: number;
670
+ usageOnly: number;
671
+ other: number;
672
+ };
673
+ firstEventTimeoutMs?: number;
674
+ firstEventTimeoutSource?: "stream-option" | "env" | "idle-timeout" | "provider-fallback" | "default";
675
+ }
661
676
  export interface ToolResultMessage<TDetails = any> {
662
677
  role: "toolResult";
663
678
  toolCallId: string;
@@ -72,6 +72,7 @@ export interface FirstEventTimeoutFacts {
72
72
  requestBytes?: number;
73
73
  firstEventElapsedMs?: number;
74
74
  firstEventTimeoutMs?: number;
75
+ firstEventTimeoutSource?: "stream-option" | "env" | "idle-timeout" | "provider-fallback" | "default";
75
76
  endpointClass?: "canonical" | "custom";
76
77
  retryMaxAttempts?: number;
77
78
  }
@@ -80,6 +81,7 @@ export declare class FirstEventTimeoutError extends Error {
80
81
  readonly requestBytes?: number;
81
82
  readonly firstEventElapsedMs?: number;
82
83
  readonly firstEventTimeoutMs?: number;
84
+ readonly firstEventTimeoutSource?: FirstEventTimeoutFacts["firstEventTimeoutSource"];
83
85
  readonly endpointClass?: "canonical" | "custom";
84
86
  readonly retryMaxAttempts?: number;
85
87
  constructor(message: string, facts?: FirstEventTimeoutFacts);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.18.6",
4
+ "version": "0.18.7",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -41,8 +41,8 @@
41
41
  "@agentclientprotocol/sdk": "1.3.0",
42
42
  "@anthropic-ai/sdk": "^0.94.0",
43
43
  "@bufbuild/protobuf": "^2.12.0",
44
- "@gajae-code/natives": "0.18.6",
45
- "@gajae-code/utils": "0.18.6",
44
+ "@gajae-code/natives": "0.18.7",
45
+ "@gajae-code/utils": "0.18.7",
46
46
  "openai": "^6.36.0",
47
47
  "partial-json": "^0.1.7",
48
48
  "zod": "4.4.3"
@@ -16,6 +16,7 @@ import type {
16
16
  Api,
17
17
  AssistantMessage,
18
18
  Context,
19
+ GoogleGeminiCliDiagnostics,
19
20
  Model,
20
21
  StreamFunction,
21
22
  StreamOptions,
@@ -27,6 +28,7 @@ import { normalizeSystemPrompts } from "../utils";
27
28
  import { AssistantMessageEventStream } from "../utils/event-stream";
28
29
  import { transportFailureFacts } from "../utils/fallback-transport";
29
30
  import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
31
+ import { FirstEventTimeoutError } from "../utils/idle-iterator";
30
32
  import { resolveRetryBudget } from "../utils/retry-budget";
31
33
  // Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted);
32
34
  // the stream provider trusts the access token threaded through `options.apiKey`.
@@ -325,6 +327,29 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
325
327
  timestamp: Date.now(),
326
328
  };
327
329
  let rawRequestDump: RawHttpRequestDump | undefined;
330
+ const googleGeminiCliDiagnostics: GoogleGeminiCliDiagnostics = {
331
+ chunkCounts: { content: 0, thinking: 0, functionCall: 0, usageOnly: 0, other: 0 },
332
+ };
333
+ (
334
+ stream as AssistantMessageEventStream & { googleGeminiCliDiagnostics?: GoogleGeminiCliDiagnostics }
335
+ ).googleGeminiCliDiagnostics = googleGeminiCliDiagnostics;
336
+ const recordTimeoutDiagnostics = (): void => {
337
+ const reason = options?.signal?.reason;
338
+ if (!(reason instanceof FirstEventTimeoutError)) return;
339
+ googleGeminiCliDiagnostics.firstEventTimeoutMs = reason.firstEventTimeoutMs;
340
+ googleGeminiCliDiagnostics.firstEventTimeoutSource = reason.firstEventTimeoutSource;
341
+ (
342
+ reason as FirstEventTimeoutError & {
343
+ googleGeminiCliDiagnostics?: GoogleGeminiCliDiagnostics;
344
+ }
345
+ ).googleGeminiCliDiagnostics = googleGeminiCliDiagnostics;
346
+ };
347
+ options?.signal?.addEventListener("abort", recordTimeoutDiagnostics, { once: true });
348
+ const recordResponse = (): void => {
349
+ if (googleGeminiCliDiagnostics.responseAtMs === undefined) {
350
+ googleGeminiCliDiagnostics.responseAtMs = Date.now() - startTime;
351
+ }
352
+ };
328
353
 
329
354
  try {
330
355
  const apiKeyRaw = options?.apiKey;
@@ -405,6 +430,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
405
430
  fetch: options?.fetch,
406
431
  },
407
432
  );
433
+ recordResponse();
408
434
  if (!response.ok && sentForcedToolChoice) {
409
435
  const errorText = await response.text();
410
436
  const error = createGeminiCliHttpError(response, errorText);
@@ -447,6 +473,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
447
473
  fetch: options?.fetch,
448
474
  },
449
475
  );
476
+ recordResponse();
450
477
  } else {
451
478
  throw error;
452
479
  }
@@ -496,20 +523,29 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
496
523
  for await (const chunk of readSseJson<CloudCodeAssistResponseChunk>(
497
524
  activeResponse.body!,
498
525
  options?.signal,
499
- event =>
526
+ event => {
527
+ if (googleGeminiCliDiagnostics.firstRawSseAtMs === undefined) {
528
+ googleGeminiCliDiagnostics.firstRawSseAtMs = Date.now() - startTime;
529
+ }
500
530
  options?.onSseEvent?.(
501
531
  { event: event.event, data: event.data, raw: [...event.raw] },
502
532
  model,
503
533
  options?.attemptScope,
504
- ),
534
+ );
535
+ },
505
536
  )) {
506
537
  const responseData = chunk.response;
507
- if (!responseData) continue;
538
+ if (!responseData) {
539
+ googleGeminiCliDiagnostics.chunkCounts.other += 1;
540
+ continue;
541
+ }
508
542
 
509
543
  const candidate = responseData.candidates?.[0];
544
+ let chunkKind: keyof GoogleGeminiCliDiagnostics["chunkCounts"] = "other";
510
545
  if (candidate?.content?.parts) {
511
546
  for (const part of candidate.content.parts) {
512
547
  if (part.text !== undefined) {
548
+ chunkKind = isThinkingPart(part) ? "thinking" : "content";
513
549
  hasContent = true;
514
550
  const isThinking = isThinkingPart(part);
515
551
  if (
@@ -550,6 +586,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
550
586
  }
551
587
 
552
588
  if (part.functionCall) {
589
+ chunkKind = "functionCall";
553
590
  hasContent = true;
554
591
  if (currentBlock) {
555
592
  pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
@@ -618,6 +655,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
618
655
  }
619
656
 
620
657
  if (responseData.usageMetadata) {
658
+ if (chunkKind === "other") chunkKind = "usageOnly";
621
659
  // promptTokenCount includes cachedContentTokenCount, so subtract to get fresh input
622
660
  const promptTokens = responseData.usageMetadata.promptTokenCount || 0;
623
661
  const cacheReadTokens = responseData.usageMetadata.cachedContentTokenCount || 0;
@@ -639,6 +677,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
639
677
  };
640
678
  calculateCost(model, output.usage);
641
679
  }
680
+ googleGeminiCliDiagnostics.chunkCounts[chunkKind] += 1;
642
681
  }
643
682
 
644
683
  if (currentBlock) {
@@ -676,6 +715,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
676
715
  body: requestBodyJson,
677
716
  signal: options?.signal,
678
717
  });
718
+ recordResponse();
679
719
 
680
720
  if (!currentResponse.ok) {
681
721
  const retryErrorText = await currentResponse.text();
@@ -711,6 +751,11 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
711
751
  stream.push({ type: "done", reason: output.stopReason, message: output });
712
752
  stream.end();
713
753
  } catch (error) {
754
+ const timeoutError = options?.signal?.reason;
755
+ if (timeoutError instanceof FirstEventTimeoutError) {
756
+ googleGeminiCliDiagnostics.firstEventTimeoutMs = timeoutError.firstEventTimeoutMs;
757
+ googleGeminiCliDiagnostics.firstEventTimeoutSource = timeoutError.firstEventTimeoutSource;
758
+ }
714
759
  for (const block of output.content) {
715
760
  if ("index" in block) {
716
761
  delete (block as { index?: number }).index;
@@ -719,6 +764,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
719
764
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
720
765
  output.errorStatus = extractHttpStatusFromError(error);
721
766
  output.transportFailure = transportFailureFacts(error);
767
+ output.googleGeminiCliDiagnostics = googleGeminiCliDiagnostics;
722
768
  output.errorMessage = await appendRawHttpRequestDumpFor400(
723
769
  error instanceof Error ? error.message : JSON.stringify(error),
724
770
  error,
@@ -74,7 +74,7 @@ interface WireUserMessage {
74
74
  content: string;
75
75
  modelId?: string;
76
76
  userInputMessageContext?: {
77
- tools?: { tools: WireToolSpec[] };
77
+ tools?: WireToolSpec[];
78
78
  toolResults?: WireToolResult[];
79
79
  editorStateContext?: Record<string, unknown>;
80
80
  };
@@ -108,25 +108,19 @@ interface GenerateAssistantResponseRequest {
108
108
  // ─────────────────────────────────────────────────────────────────────────────
109
109
 
110
110
  interface AssistantResponseEvent {
111
- assistantResponseEvent?: {
112
- content?: string;
113
- };
111
+ content?: string;
114
112
  }
115
113
 
116
114
  interface ToolUseEventPayload {
117
- toolUseEvent?: {
118
- toolUseId?: string;
119
- name?: string;
120
- input?: unknown;
121
- stop?: { stopReason?: string };
122
- };
115
+ toolUseId?: string;
116
+ name?: string;
117
+ input?: string;
118
+ stop?: boolean;
123
119
  }
124
120
 
125
121
  interface MessageMetadataEvent {
126
- messageMetadataEvent?: {
127
- conversationId?: string;
128
- utteranceId?: string;
129
- };
122
+ conversationId?: string;
123
+ utteranceId?: string;
130
124
  }
131
125
 
132
126
  interface ErrorPayload {
@@ -169,6 +163,8 @@ export const streamKiroCodeWhisperer: StreamFunction<"kiro-codewhisperer-stream"
169
163
  (async () => {
170
164
  const startTime = Date.now();
171
165
  let firstTokenTime: number | undefined;
166
+ // Accumulator for streaming tool input fragments, keyed by toolUseId
167
+ const toolInputAccumulator = new Map<string, { name: string; input: string }>();
172
168
 
173
169
  const output: AssistantMessage = {
174
170
  role: "assistant",
@@ -190,6 +186,7 @@ export const streamKiroCodeWhisperer: StreamFunction<"kiro-codewhisperer-stream"
190
186
 
191
187
  const blocks = output.content as Block[];
192
188
  const region = options.region ?? $env.KIRO_REGION ?? $env.AWS_REGION ?? $env.AWS_DEFAULT_REGION ?? DEFAULT_REGION;
189
+ let started = false; // Track whether start event has been emitted
193
190
 
194
191
  try {
195
192
  assertAwsRegionLabel(region);
@@ -222,24 +219,24 @@ export const streamKiroCodeWhisperer: StreamFunction<"kiro-codewhisperer-stream"
222
219
  const bodyText = JSON.stringify(requestBody);
223
220
  const body = new TextEncoder().encode(bodyText);
224
221
  const requestHeaders: Record<string, string> = {
225
- "content-type": "application/json",
226
- accept: "application/vnd.amazon.eventstream",
227
- authorization: `Bearer ${bearerToken}`,
228
- "amzn-X-amz-target": "AmazonCodeWhispererService.GenerateAssistantResponse",
222
+ "Content-Type": "application/x-amz-json-1.0",
223
+ Accept: "application/vnd.amazon.eventstream",
224
+ Authorization: `Bearer ${bearerToken}`,
225
+ "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse",
229
226
  };
230
227
 
231
- if (options.profileArn) {
232
- requestHeaders["x-amzn-codewhisperer-proflearn"] = options.profileArn;
233
- }
234
-
235
- // Merge user-provided headers
228
+ // Merge user-provided headers (case-insensitively)
229
+ const headersList = new Headers(requestHeaders);
236
230
  if (options.headers) {
237
- Object.assign(requestHeaders, options.headers);
231
+ const userHeaders = new Headers(options.headers);
232
+ userHeaders.forEach((value, key) => {
233
+ headersList.set(key, value);
234
+ });
238
235
  }
239
236
 
240
237
  const response = await fetch(url, {
241
238
  method: "POST",
242
- headers: requestHeaders,
239
+ headers: headersList,
243
240
  body,
244
241
  redirect: "error",
245
242
  signal: options.signal,
@@ -300,11 +297,12 @@ export const streamKiroCodeWhisperer: StreamFunction<"kiro-codewhisperer-stream"
300
297
  switch (eventType) {
301
298
  case "assistantResponseEvent": {
302
299
  const ev = payload as AssistantResponseEvent;
303
- const content = ev.assistantResponseEvent?.content;
300
+ const content = ev.content;
304
301
  if (content) {
305
302
  if (!firstTokenTime) firstTokenTime = Date.now();
306
- if (blocks.length === 0) {
303
+ if (!started) {
307
304
  stream.push({ type: "start", partial: output });
305
+ started = true;
308
306
  }
309
307
  handleTextDelta(content, blocks, output, stream);
310
308
  }
@@ -313,16 +311,21 @@ export const streamKiroCodeWhisperer: StreamFunction<"kiro-codewhisperer-stream"
313
311
  case "toolUseEvent": {
314
312
  const ev = payload as ToolUseEventPayload;
315
313
  if (!firstTokenTime) firstTokenTime = Date.now();
316
- if (blocks.length === 0) {
314
+ if (!started) {
317
315
  stream.push({ type: "start", partial: output });
316
+ started = true;
317
+ }
318
+ handleToolUseEvent(ev, blocks, output, stream, toolInputAccumulator);
319
+ // Clear accumulator for completed tool
320
+ if (ev.stop) {
321
+ toolInputAccumulator.delete(ev.toolUseId ?? "");
318
322
  }
319
- handleToolUseEvent(ev, blocks, output, stream);
320
323
  break;
321
324
  }
322
325
  case "messageMetadataEvent": {
323
326
  const ev = payload as MessageMetadataEvent;
324
- if (ev.messageMetadataEvent?.conversationId) {
325
- output.responseId = ev.messageMetadataEvent.conversationId;
327
+ if (ev.conversationId) {
328
+ output.responseId = ev.conversationId;
326
329
  }
327
330
  break;
328
331
  }
@@ -349,6 +352,12 @@ export const streamKiroCodeWhisperer: StreamFunction<"kiro-codewhisperer-stream"
349
352
 
350
353
  if (options.signal?.aborted) throw new Error("Request was aborted");
351
354
 
355
+ // Reject EOF while tool input is still being accumulated
356
+ if (toolInputAccumulator.size > 0) {
357
+ const unfinishedIds = Array.from(toolInputAccumulator.keys()).join(", ");
358
+ throw new Error(`Kiro CodeWhisperer stream ended with incomplete tool calls: ${unfinishedIds}`);
359
+ }
360
+
352
361
  // Finalize blocks
353
362
  for (const block of blocks) {
354
363
  delete block.index;
@@ -456,9 +465,7 @@ function buildConversationState(
456
465
  if (!currentMessage.userInputMessage.userInputMessageContext) {
457
466
  currentMessage.userInputMessage.userInputMessageContext = {};
458
467
  }
459
- currentMessage.userInputMessage.userInputMessageContext.tools = {
460
- tools: convertTools(context.tools),
461
- };
468
+ currentMessage.userInputMessage.userInputMessageContext.tools = convertTools(context.tools);
462
469
  }
463
470
 
464
471
  return {
@@ -601,44 +608,55 @@ function handleToolUseEvent(
601
608
  blocks: Block[],
602
609
  output: AssistantMessage,
603
610
  stream: AssistantMessageEventStream,
611
+ accumulator: Map<string, { name: string; input: string }>,
604
612
  ): void {
605
- const toolEvent = ev.toolUseEvent;
606
- if (!toolEvent) return;
613
+ const name = ev.name ?? "";
614
+ const input = ev.input ?? "";
615
+
616
+ // Determine the tool ID: use provided ID, or find the currently active tool call
617
+ let toolUseId = ev.toolUseId;
618
+ if (!toolUseId) {
619
+ // ID-less fragment: associate with the currently active tool call
620
+ // Find the last tool call being accumulated (the most recently started tool)
621
+ const keys = Array.from(accumulator.keys());
622
+ if (keys.length > 0) {
623
+ toolUseId = keys[keys.length - 1];
624
+ } else {
625
+ // No active tool calls; create a placeholder entry (should rarely happen)
626
+ toolUseId = "";
627
+ }
628
+ }
629
+
630
+ // Accumulate input fragments per toolUseId
631
+ let accumulated = accumulator.get(toolUseId);
632
+ if (!accumulated) {
633
+ accumulated = { name, input: "" };
634
+ accumulator.set(toolUseId, accumulated);
635
+ }
636
+ accumulated.input += input;
607
637
 
608
- const toolUseId = toolEvent.toolUseId ?? "";
609
- const name = toolEvent.name ?? "";
638
+ // Update name if provided and not yet set
639
+ if (name && !accumulated.name) {
640
+ accumulated.name = name;
641
+ }
610
642
 
611
- // If input is provided as a complete object, emit toolcall_end
612
- if (toolEvent.input !== undefined && toolEvent.input !== null) {
613
- const inputStr = typeof toolEvent.input === "string" ? toolEvent.input : JSON.stringify(toolEvent.input);
643
+ // Emit toolcall_end only when we have a stop signal
644
+ if (ev.stop) {
645
+ const inputStr = accumulated.input;
614
646
  const toolCall: ToolCall = {
615
647
  type: "toolCall",
616
648
  id: toolUseId,
617
- name,
649
+ name: accumulated.name,
618
650
  arguments: safeParseJson(inputStr) as Record<string, any>,
619
651
  };
620
- if (typeof toolEvent.input === "string") captureUnicodeEscapeEvidence(toolCall, inputStr);
652
+ captureUnicodeEscapeEvidence(toolCall, inputStr);
621
653
 
622
654
  const newBlock: Block = { ...toolCall, index: blocks.length };
623
- if (typeof toolEvent.input === "string") captureUnicodeEscapeEvidence(newBlock, inputStr);
655
+ captureUnicodeEscapeEvidence(newBlock, inputStr);
624
656
  blocks.push(newBlock);
625
657
  stream.push({ type: "toolcall_end", contentIndex: newBlock.index!, toolCall, partial: output });
626
- return;
658
+ accumulator.delete(toolUseId);
627
659
  }
628
-
629
- // Otherwise, accumulate partial input (if the service streams it in chunks)
630
- // The published model does not document chunked tool input for CodeWhisperer,
631
- // so this path handles the case defensively but expects complete input per event.
632
- const toolCall: ToolCall = {
633
- type: "toolCall",
634
- id: toolUseId,
635
- name,
636
- arguments: {},
637
- };
638
-
639
- const newBlock: Block = { ...toolCall, index: blocks.length };
640
- blocks.push(newBlock);
641
- stream.push({ type: "toolcall_end", contentIndex: newBlock.index!, toolCall, partial: output });
642
660
  }
643
661
 
644
662
  // ─────────────────────────────────────────────────────────────────────────────
@@ -77,3 +77,4 @@ type CodexToolPayload = {
77
77
  /** @internal Exported for tests. */
78
78
  export declare function convertOpenAICodexResponsesTools(tools: Tool[], model: Model<"openai-codex-responses">): CodexToolPayload[];
79
79
  declare function isRetryableCodexFailureEvent(rawEvent: Record<string, unknown>): boolean;
80
+ export declare function isExplicitCodexTerminalVeto(code?: string, providerMessage?: string): boolean;
@@ -190,6 +190,12 @@ const CODEX_NON_RETRYABLE_EVENT_CODES = new Set([
190
190
  ]);
191
191
  const CODEX_NON_RETRYABLE_EVENT_MESSAGE =
192
192
  /invalid[_ -]function[_ -]parameters|invalid schema for function|invalid[_ -]tool[_ -]schema|schema must have type ["']?object["']?|request blocked[^\n]*invalid[_ -]prompt|code=invalid[_ -]prompt/i;
193
+ const CODEX_EXPLICIT_TERMINAL_VETO_CODES = new Set([
194
+ "invalid_function_parameters",
195
+ "invalid_schema",
196
+ "invalid_tool_schema",
197
+ "invalid_prompt",
198
+ ]);
193
199
  const CODEX_RETRYABLE_EVENT_MESSAGE =
194
200
  /processing your request|retry your request|temporar(?:y|ily)|overloaded|service.?unavailable|internal error|server error/i;
195
201
  const CODEX_ACCOUNT_MODEL_UNAVAILABLE_MESSAGE = /\bnot supported when using codex with a chatgpt account\b/i;
@@ -371,6 +377,10 @@ type CodexOutputBlock =
371
377
  doneInput?: string;
372
378
  argumentsComplete?: boolean;
373
379
  argumentsAuthoritative?: boolean;
380
+ sourceItemId?: string;
381
+ sourceCallId?: string;
382
+ sourceName?: string;
383
+ sourceOutputIndex?: number;
374
384
  });
375
385
  export interface OpenAICodexWebSocketDebugStats {
376
386
  fullContextRequests: number;
@@ -470,6 +480,7 @@ interface CodexStreamRuntime {
470
480
  currentItemOutputIndex?: number;
471
481
  currentBlock: CodexOutputBlock | null;
472
482
  nativeOutputItems: Array<Record<string, unknown>>;
483
+ nativeOutputItemOutputIndexes: Array<number | undefined>;
473
484
  websocketStreamRetries: number;
474
485
  providerRetryAttempt: number;
475
486
  toolChoiceFallbackAttempted: boolean;
@@ -818,6 +829,10 @@ function removeTransientBlockIndices(output: AssistantMessage): void {
818
829
  delete (block as { doneInput?: string }).doneInput;
819
830
  delete (block as { argumentsComplete?: boolean }).argumentsComplete;
820
831
  delete (block as { argumentsAuthoritative?: boolean }).argumentsAuthoritative;
832
+ delete (block as { sourceItemId?: string }).sourceItemId;
833
+ delete (block as { sourceCallId?: string }).sourceCallId;
834
+ delete (block as { sourceName?: string }).sourceName;
835
+ delete (block as { sourceOutputIndex?: number }).sourceOutputIndex;
821
836
  }
822
837
  }
823
838
  }
@@ -1220,6 +1235,7 @@ function createCodexStreamRuntime(initial: {
1220
1235
  currentItemOutputIndex: undefined,
1221
1236
  currentBlock: null,
1222
1237
  nativeOutputItems: [],
1238
+ nativeOutputItemOutputIndexes: [],
1223
1239
  websocketStreamRetries: 0,
1224
1240
  providerRetryAttempt: 0,
1225
1241
  toolChoiceFallbackAttempted: initial.toolChoiceFallbackApplied === true,
@@ -1255,6 +1271,13 @@ async function processCodexResponseStream(
1255
1271
  });
1256
1272
  if (runtime.sawTerminalEvent) break;
1257
1273
  }
1274
+ if (!runtime.sawTerminalEvent) {
1275
+ trySalvageCodexFinalizedToolCalls(
1276
+ context,
1277
+ runtime,
1278
+ new Error("Codex stream ended before terminal completion event"),
1279
+ );
1280
+ }
1258
1281
  return { firstTokenTime };
1259
1282
  } catch (error) {
1260
1283
  if (trySalvageCodexFinalizedToolCalls(context, runtime, error)) {
@@ -1274,38 +1297,82 @@ function trySalvageCodexFinalizedToolCalls(
1274
1297
  error: unknown,
1275
1298
  ): boolean {
1276
1299
  const toolCalls = context.output.content.filter((block): block is ToolCall => block.type === "toolCall");
1277
- const activeToolCall =
1278
- runtime.currentItem?.type === "function_call" && runtime.currentBlock?.type === "toolCall"
1279
- ? runtime.currentBlock
1280
- : undefined;
1281
- const completeActiveToolCall =
1282
- activeToolCall?.argumentsComplete === true &&
1283
- (activeToolCall.argumentsAuthoritative === true || Object.keys(activeToolCall.arguments).length > 0);
1300
+ const completeToolCalls = toolCalls.filter(toolCall => {
1301
+ const block = toolCall as ToolCall & {
1302
+ argumentsComplete?: boolean;
1303
+ argumentsAuthoritative?: boolean;
1304
+ sourceItemId?: string;
1305
+ sourceCallId?: string;
1306
+ };
1307
+ return (
1308
+ !runtime.finalizedToolCallIds.has(toolCall.id) &&
1309
+ block.argumentsComplete === true &&
1310
+ typeof block.sourceItemId === "string" &&
1311
+ typeof block.sourceCallId === "string" &&
1312
+ (block.argumentsAuthoritative === true || Object.keys(block.arguments).length > 0)
1313
+ );
1314
+ });
1284
1315
  const hasCompleteArguments = toolCalls.every(
1285
- toolCall =>
1286
- runtime.finalizedToolCallIds.has(toolCall.id) || (toolCall === activeToolCall && completeActiveToolCall),
1316
+ toolCall => runtime.finalizedToolCallIds.has(toolCall.id) || completeToolCalls.includes(toolCall),
1287
1317
  );
1288
- const isIdleStall =
1289
- error instanceof Error && error.message === "OpenAI Codex SSE stream stalled while waiting for the next event";
1318
+ const isIdleStall = isCodexIdleStall(error);
1319
+ const hasEmptyActiveMessage =
1320
+ runtime.currentItem?.type === "message" &&
1321
+ runtime.currentBlock?.type === "text" &&
1322
+ runtime.currentItem.content.every(content =>
1323
+ content.type === "output_text" ? content.text === "" : content.refusal === "",
1324
+ );
1325
+ // EOF proves a closed stream, but only finalized calls are safe without a timeout event.
1326
+ const isUnexpectedStreamEnd =
1327
+ error instanceof Error && error.message === "Codex stream ended before terminal completion event";
1290
1328
  const canSalvageFinalizedCall =
1291
- isCodexTransientStreamClose(error) &&
1329
+ (isCodexTransientStreamClose(error) || isUnexpectedStreamEnd) &&
1292
1330
  !runtime.toolArgumentCorrelationFailed &&
1293
1331
  toolCalls.length > 0 &&
1294
1332
  (runtime.currentBlock === null ||
1295
- (runtime.currentItem?.type === "reasoning" && runtime.currentBlock?.type === "thinking")) &&
1296
- context.output.content.every(block => block.type === "thinking" || block.type === "toolCall") &&
1333
+ runtime.currentBlock?.type === "toolCall" ||
1334
+ (runtime.currentItem?.type === "reasoning" && runtime.currentBlock?.type === "thinking") ||
1335
+ hasEmptyActiveMessage) &&
1336
+ context.output.content.every(
1337
+ block =>
1338
+ block.type === "thinking" || block.type === "toolCall" || (block.type === "text" && block.text === ""),
1339
+ ) &&
1297
1340
  toolCalls.every(toolCall => runtime.finalizedToolCallIds.has(toolCall.id));
1298
1341
  const canSalvageCompleteArguments =
1299
1342
  (isCodexTransientStreamClose(error) || isIdleStall) &&
1300
1343
  !runtime.toolArgumentCorrelationFailed &&
1301
1344
  toolCalls.length > 0 &&
1302
- context.output.content.every(block => block.type === "thinking" || block.type === "toolCall") &&
1345
+ (runtime.currentBlock === null ||
1346
+ runtime.currentBlock?.type === "toolCall" ||
1347
+ (runtime.currentItem?.type === "reasoning" && runtime.currentBlock?.type === "thinking") ||
1348
+ hasEmptyActiveMessage) &&
1349
+ context.output.content.every(
1350
+ block =>
1351
+ block.type === "thinking" || block.type === "toolCall" || (block.type === "text" && block.text === ""),
1352
+ ) &&
1303
1353
  hasCompleteArguments;
1304
1354
  if (!canSalvageFinalizedCall && !canSalvageCompleteArguments) {
1355
+ logCodexDebug("codex stream close salvage refused", {
1356
+ toolArgumentCorrelationFailed: runtime.toolArgumentCorrelationFailed,
1357
+ argumentsComplete: toolCalls.map(toolCall => {
1358
+ const block = toolCall as ToolCall & { argumentsComplete?: boolean };
1359
+ return block.argumentsComplete === true;
1360
+ }),
1361
+ sourceItemIdPresent: toolCalls.map(toolCall => {
1362
+ const block = toolCall as ToolCall & { sourceItemId?: string };
1363
+ return typeof block.sourceItemId === "string";
1364
+ }),
1365
+ sourceCallIdPresent: toolCalls.map(toolCall => {
1366
+ const block = toolCall as ToolCall & { sourceCallId?: string };
1367
+ return typeof block.sourceCallId === "string";
1368
+ }),
1369
+ currentItemType: runtime.currentItem?.type ?? null,
1370
+ currentBlockType: runtime.currentBlock?.type ?? null,
1371
+ });
1305
1372
  return false;
1306
1373
  }
1307
1374
 
1308
- if (canSalvageFinalizedCall && runtime.currentBlock?.type === "thinking") {
1375
+ if ((canSalvageFinalizedCall || canSalvageCompleteArguments) && runtime.currentBlock?.type === "thinking") {
1309
1376
  context.stream.push({
1310
1377
  type: "thinking_end",
1311
1378
  contentIndex: context.output.content.length - 1,
@@ -1317,33 +1384,72 @@ function trySalvageCodexFinalizedToolCalls(
1317
1384
  runtime.currentBlock = null;
1318
1385
  }
1319
1386
 
1320
- if (canSalvageCompleteArguments && activeToolCall && !runtime.finalizedToolCallIds.has(activeToolCall.id)) {
1321
- const item = runtime.currentItem;
1322
- const toolCall: ToolCall = {
1323
- type: "toolCall",
1324
- id: activeToolCall.id,
1325
- name: activeToolCall.name,
1326
- arguments: activeToolCall.arguments,
1327
- };
1328
- const rawPartialJson = activeToolCall.partialJson;
1329
- captureUnicodeEscapeEvidence(toolCall, rawPartialJson);
1330
- Object.assign(activeToolCall, toolCall);
1331
- captureUnicodeEscapeEvidence(activeToolCall, rawPartialJson);
1332
- delete (activeToolCall as { partialJson?: string }).partialJson;
1333
- delete (activeToolCall as { argumentsComplete?: boolean }).argumentsComplete;
1334
- delete (activeToolCall as { argumentsAuthoritative?: boolean }).argumentsAuthoritative;
1335
- runtime.finalizedToolCallIds.add(toolCall.id);
1336
- runtime.nativeOutputItems.push({
1337
- ...item,
1338
- arguments: JSON.stringify(toolCall.arguments),
1339
- status: "completed",
1340
- } as Record<string, unknown>);
1341
- context.stream.push({
1342
- type: "toolcall_end",
1343
- contentIndex: context.output.content.length - 1,
1344
- toolCall,
1345
- partial: context.output,
1346
- });
1387
+ if (canSalvageCompleteArguments) {
1388
+ for (const toolCallBlock of completeToolCalls) {
1389
+ const activeToolCall = toolCallBlock as ToolCall & {
1390
+ partialJson: string;
1391
+ argumentsComplete?: boolean;
1392
+ argumentsAuthoritative?: boolean;
1393
+ sourceItemId?: string;
1394
+ sourceCallId?: string;
1395
+ sourceName?: string;
1396
+ sourceOutputIndex?: number;
1397
+ };
1398
+ const item =
1399
+ runtime.currentBlock === activeToolCall && runtime.currentItem?.type === "function_call"
1400
+ ? runtime.currentItem
1401
+ : {
1402
+ type: "function_call",
1403
+ id: activeToolCall.sourceItemId,
1404
+ call_id: activeToolCall.sourceCallId,
1405
+ name: activeToolCall.sourceName ?? codexToolWireName(activeToolCall.name),
1406
+ arguments: activeToolCall.partialJson,
1407
+ };
1408
+ if (typeof item.id !== "string" || typeof item.call_id !== "string") continue;
1409
+ const toolCall: ToolCall = {
1410
+ type: "toolCall",
1411
+ id: activeToolCall.id,
1412
+ name: activeToolCall.name,
1413
+ arguments: activeToolCall.arguments,
1414
+ };
1415
+ const rawPartialJson = activeToolCall.partialJson;
1416
+ const sourceOutputIndex = activeToolCall.sourceOutputIndex;
1417
+ captureUnicodeEscapeEvidence(toolCall, rawPartialJson);
1418
+ Object.assign(activeToolCall, toolCall);
1419
+ captureUnicodeEscapeEvidence(activeToolCall, rawPartialJson);
1420
+ delete (activeToolCall as { partialJson?: string }).partialJson;
1421
+ delete (activeToolCall as { argumentsComplete?: boolean }).argumentsComplete;
1422
+ delete (activeToolCall as { argumentsAuthoritative?: boolean }).argumentsAuthoritative;
1423
+ delete (activeToolCall as { sourceItemId?: string }).sourceItemId;
1424
+ delete (activeToolCall as { sourceCallId?: string }).sourceCallId;
1425
+ delete (activeToolCall as { sourceName?: string }).sourceName;
1426
+ delete (activeToolCall as { sourceOutputIndex?: number }).sourceOutputIndex;
1427
+ runtime.finalizedToolCallIds.add(toolCall.id);
1428
+ const nativeOutputItem = {
1429
+ ...item,
1430
+ name:
1431
+ typeof item.name === "string"
1432
+ ? item.name
1433
+ : (activeToolCall.sourceName ?? codexToolWireName(activeToolCall.name)),
1434
+ arguments: JSON.stringify(toolCall.arguments),
1435
+ status: "completed",
1436
+ } as Record<string, unknown>;
1437
+ const insertionIndex =
1438
+ typeof sourceOutputIndex === "number"
1439
+ ? runtime.nativeOutputItemOutputIndexes.findIndex(
1440
+ outputIndex => typeof outputIndex === "number" && outputIndex > sourceOutputIndex,
1441
+ )
1442
+ : -1;
1443
+ const resolvedInsertionIndex = insertionIndex === -1 ? runtime.nativeOutputItems.length : insertionIndex;
1444
+ runtime.nativeOutputItems.splice(resolvedInsertionIndex, 0, nativeOutputItem);
1445
+ runtime.nativeOutputItemOutputIndexes.splice(resolvedInsertionIndex, 0, sourceOutputIndex);
1446
+ context.stream.push({
1447
+ type: "toolcall_end",
1448
+ contentIndex: context.output.content.indexOf(activeToolCall),
1449
+ toolCall,
1450
+ partial: context.output,
1451
+ });
1452
+ }
1347
1453
  runtime.currentItem = null;
1348
1454
  runtime.currentBlock = null;
1349
1455
  }
@@ -1377,7 +1483,15 @@ function isCodexTransientStreamClose(error: unknown): boolean {
1377
1483
  message.includes("stream disconnected before completion") ||
1378
1484
  message.includes("stream closed before response.completed") ||
1379
1485
  message.includes("websocket closed before response completion");
1380
- return hasRequestTimeout && hasClosedStreamMessage;
1486
+ return (hasRequestTimeout && hasClosedStreamMessage) || isCodexIdleStall(error);
1487
+ }
1488
+
1489
+ function isCodexIdleStall(error: unknown): boolean {
1490
+ if (!(error instanceof Error)) return false;
1491
+ return (
1492
+ error.message === "OpenAI Codex SSE stream stalled while waiting for the next event" ||
1493
+ error.message === `${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: idle timeout waiting for websocket`
1494
+ );
1381
1495
  }
1382
1496
 
1383
1497
  function handleCodexStreamEvent(args: {
@@ -1403,6 +1517,9 @@ function handleCodexStreamEvent(args: {
1403
1517
  runtime.currentItemOutputIndex = typeof rawEvent.output_index === "number" ? rawEvent.output_index : undefined;
1404
1518
  runtime.currentBlock = createOutputBlockForItem(item);
1405
1519
  if (!runtime.currentBlock) return firstTokenTime;
1520
+ if (runtime.currentBlock.type === "toolCall" && typeof runtime.currentItemOutputIndex === "number") {
1521
+ runtime.currentBlock.sourceOutputIndex = runtime.currentItemOutputIndex;
1522
+ }
1406
1523
  const currentBlock = runtime.currentBlock;
1407
1524
  if (
1408
1525
  currentBlock.type === "toolCall" &&
@@ -1558,7 +1675,11 @@ function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null
1558
1675
  const block: ToolCall & {
1559
1676
  partialJson: string;
1560
1677
  argumentsComplete: boolean;
1561
- argumentsAuthoritative: boolean;
1678
+ argumentsAuthoritative?: boolean;
1679
+ sourceItemId?: string;
1680
+ sourceCallId?: string;
1681
+ sourceName?: string;
1682
+ sourceOutputIndex?: number;
1562
1683
  } = {
1563
1684
  type: "toolCall",
1564
1685
  id: encodeResponsesToolCallId(item.call_id, item.id),
@@ -1567,6 +1688,9 @@ function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null
1567
1688
  partialJson: initialArguments,
1568
1689
  argumentsComplete: hasValidInitialArguments && Object.keys(parsedArguments).length > 0,
1569
1690
  argumentsAuthoritative: false,
1691
+ sourceItemId: item.id,
1692
+ sourceCallId: item.call_id,
1693
+ sourceName: item.name,
1570
1694
  };
1571
1695
  captureUnicodeEscapeEvidence(block, initialArguments);
1572
1696
  return block;
@@ -1821,6 +1945,9 @@ function handleOutputItemDone(
1821
1945
  ): void {
1822
1946
  const item = structuredCloneJSON(rawEvent.item) as CodexEventItem;
1823
1947
  runtime.nativeOutputItems.push(item as unknown as Record<string, unknown>);
1948
+ runtime.nativeOutputItemOutputIndexes.push(
1949
+ typeof rawEvent.output_index === "number" ? rawEvent.output_index : undefined,
1950
+ );
1824
1951
 
1825
1952
  if (item.type === "reasoning" && runtime.currentBlock?.type === "thinking") {
1826
1953
  const block = runtime.currentBlock;
@@ -1920,6 +2047,10 @@ function handleOutputItemDone(
1920
2047
  delete (runtime.currentBlock as { argumentsComplete?: boolean }).argumentsComplete;
1921
2048
  delete (runtime.currentBlock as { argumentsAuthoritative?: boolean }).argumentsAuthoritative;
1922
2049
  delete (runtime.currentBlock as { doneInput?: string }).doneInput;
2050
+ delete (runtime.currentBlock as { sourceItemId?: string }).sourceItemId;
2051
+ delete (runtime.currentBlock as { sourceCallId?: string }).sourceCallId;
2052
+ delete (runtime.currentBlock as { sourceName?: string }).sourceName;
2053
+ delete (runtime.currentBlock as { sourceOutputIndex?: number }).sourceOutputIndex;
1923
2054
  runtime.canSafelyReplayWebsocketOverSse = false;
1924
2055
  stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
1925
2056
  runtime.currentItem = null;
@@ -2126,6 +2257,7 @@ async function tryRetryWithoutForcedToolChoice(
2126
2257
  runtime.currentBlock = null;
2127
2258
  runtime.sawTerminalEvent = false;
2128
2259
  runtime.nativeOutputItems.length = 0;
2260
+ runtime.nativeOutputItemOutputIndexes.length = 0;
2129
2261
  runtime.finalizedToolCallIds.clear();
2130
2262
  runtime.toolArgumentCorrelationFailed = false;
2131
2263
  resetOutputState(context.output);
@@ -2225,6 +2357,7 @@ async function tryReconnectCodexWebSocketOnConnectionLimit(
2225
2357
  runtime.currentItem = null;
2226
2358
  runtime.currentBlock = null;
2227
2359
  runtime.nativeOutputItems.length = 0;
2360
+ runtime.nativeOutputItemOutputIndexes.length = 0;
2228
2361
  runtime.finalizedToolCallIds.clear();
2229
2362
  runtime.toolArgumentCorrelationFailed = false;
2230
2363
  resetOutputState(context.output);
@@ -2284,6 +2417,7 @@ async function tryRecoverCodexPreviousResponseNotFound(
2284
2417
  runtime.currentBlock = null;
2285
2418
  runtime.sawTerminalEvent = false;
2286
2419
  runtime.nativeOutputItems.length = 0;
2420
+ runtime.nativeOutputItemOutputIndexes.length = 0;
2287
2421
  runtime.finalizedToolCallIds.clear();
2288
2422
  runtime.toolArgumentCorrelationFailed = false;
2289
2423
  resetOutputState(context.output);
@@ -2345,6 +2479,7 @@ async function tryReplayWebsocketFailureOverSse(
2345
2479
  runtime.currentItem = null;
2346
2480
  runtime.currentBlock = null;
2347
2481
  runtime.nativeOutputItems.length = 0;
2482
+ runtime.nativeOutputItemOutputIndexes.length = 0;
2348
2483
  runtime.finalizedToolCallIds.clear();
2349
2484
  runtime.toolArgumentCorrelationFailed = false;
2350
2485
  resetOutputState(context.output);
@@ -2389,6 +2524,7 @@ async function tryRetryCodexProviderError(
2389
2524
  runtime.currentBlock = null;
2390
2525
  runtime.sawTerminalEvent = false;
2391
2526
  runtime.nativeOutputItems.length = 0;
2527
+ runtime.nativeOutputItemOutputIndexes.length = 0;
2392
2528
  runtime.finalizedToolCallIds.clear();
2393
2529
  runtime.toolArgumentCorrelationFailed = false;
2394
2530
  resetOutputState(context.output);
@@ -2455,17 +2591,24 @@ async function handleCodexStreamFailure(
2455
2591
  output.stopReason = context.options?.signal?.aborted ? "aborted" : "error";
2456
2592
  output.errorStatus = extractHttpStatusFromError(error);
2457
2593
  const transportFailure = transportFailureFacts(error);
2594
+ const codexError = error instanceof CodexProviderStreamError ? error : undefined;
2595
+ const explicitTerminalVeto =
2596
+ codexError !== undefined && isExplicitCodexTerminalVeto(codexError.code, codexError.providerMessage);
2458
2597
  const typedProviderCode =
2459
- error instanceof CodexProviderStreamError && CODEX_TYPED_TRANSPORT_PROVIDER_CODES.has(error.code ?? "")
2460
- ? error.code
2598
+ codexError !== undefined && CODEX_TYPED_TRANSPORT_PROVIDER_CODES.has(codexError.code ?? "")
2599
+ ? codexError.code
2461
2600
  : undefined;
2462
2601
  output.transportFailure = typedProviderCode
2463
- ? {
2464
- ...(transportFailure ?? { kind: "transport" as const }),
2465
- providerCode: typedProviderCode,
2466
- ...(error instanceof CodexProviderStreamError && error.deterministicVeto ? { retryMaxAttempts: 1 } : {}),
2467
- }
2468
- : transportFailure;
2602
+ ? explicitTerminalVeto
2603
+ ? undefined
2604
+ : {
2605
+ ...(transportFailure ?? { kind: "transport" as const }),
2606
+ providerCode: typedProviderCode,
2607
+ ...(codexError?.deterministicVeto ? { retryMaxAttempts: 1 } : {}),
2608
+ }
2609
+ : explicitTerminalVeto
2610
+ ? undefined
2611
+ : transportFailure;
2469
2612
  output.errorMessage = await finalizeErrorMessage(error, context.requestContext.rawRequestDump);
2470
2613
  output.duration = Date.now() - context.startTime;
2471
2614
  if (context.firstTokenTime) {
@@ -3594,7 +3737,7 @@ export function convertOpenAICodexResponsesTools(
3594
3737
  name: codexToolWireName(tool.name),
3595
3738
  description: tool.description || "",
3596
3739
  parameters,
3597
- ...(effectiveStrict && { strict: true }),
3740
+ strict: effectiveStrict,
3598
3741
  };
3599
3742
  });
3600
3743
  // Tool definitions bypass the `input`/`instructions` sanitizers, so a
@@ -3673,6 +3816,13 @@ function isCodexDeterministicVeto(code: string, message: string): boolean {
3673
3816
  );
3674
3817
  }
3675
3818
 
3819
+ export function isExplicitCodexTerminalVeto(code?: string, providerMessage?: string): boolean {
3820
+ return (
3821
+ CODEX_EXPLICIT_TERMINAL_VETO_CODES.has(code?.toLowerCase() ?? "") ||
3822
+ CODEX_NON_RETRYABLE_EVENT_MESSAGE.test(providerMessage ?? "")
3823
+ );
3824
+ }
3825
+
3676
3826
  function createCodexProviderStreamError(rawEvent: Record<string, unknown>): CodexProviderStreamError {
3677
3827
  const code = getCodexEventErrorCode(rawEvent);
3678
3828
  const message = getCodexEventErrorMessage(rawEvent);
@@ -19,6 +19,8 @@ interface HealthPayload {
19
19
  ok?: unknown;
20
20
  pid?: unknown;
21
21
  port?: unknown;
22
+ status?: unknown;
23
+ service?: unknown;
22
24
  version?: unknown;
23
25
  }
24
26
 
@@ -103,7 +105,10 @@ async function fetchJson(url: string, signal?: AbortSignal): Promise<unknown> {
103
105
  function isOpenCodexHealth(payload: unknown, expectedPort: number): boolean {
104
106
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
105
107
  const health = payload as HealthPayload;
106
- return health.ok === true && health.version === "opencodex" && health.port === expectedPort;
108
+ const identityOk =
109
+ (health.ok === true && health.version === "opencodex") ||
110
+ (health.status === "ok" && health.service === "opencodex");
111
+ return identityOk && health.port === expectedPort;
107
112
  }
108
113
 
109
114
  export async function resolveOpenCodexEndpoint(signal?: AbortSignal): Promise<OpenCodexEndpoint | undefined> {
@@ -1006,7 +1006,7 @@ export function convertTools(tools: Tool[], strictMode: boolean, model: Model<"o
1006
1006
  name: tool.name,
1007
1007
  description: tool.description || "",
1008
1008
  parameters,
1009
- ...(effectiveStrict && { strict: true }),
1009
+ strict: effectiveStrict,
1010
1010
  } as OpenAITool;
1011
1011
  });
1012
1012
  // Tool definitions bypass the `input`/`instructions` sanitizers, so a
@@ -11,6 +11,7 @@
11
11
  * loader: heavy SDKs stay out of the CLI startup parse graph.
12
12
  */
13
13
 
14
+ import { $env } from "@gajae-code/utils";
14
15
  import type {
15
16
  Api,
16
17
  AssistantMessage,
@@ -280,16 +281,33 @@ function forwardStream<TApi extends Api>(
280
281
  model.provider,
281
282
  limits?.defaultFirstEventTimeoutMs,
282
283
  );
284
+ const firstEventTimeoutMs =
285
+ options.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
286
+ const firstEventTimeoutSource =
287
+ options.streamFirstEventTimeoutMs !== undefined
288
+ ? "stream-option"
289
+ : $env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS !== undefined &&
290
+ Number.isFinite(Number($env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS))
291
+ ? "env"
292
+ : idleTimeoutMs !== undefined &&
293
+ (firstEventFallbackMs === undefined || idleTimeoutMs >= firstEventFallbackMs)
294
+ ? "idle-timeout"
295
+ : firstEventFallbackMs !== undefined
296
+ ? "provider-fallback"
297
+ : "default";
283
298
  watchedSource = iterateWithIdleTimeout(source, {
284
299
  idleTimeoutMs,
285
- firstItemTimeoutMs:
286
- options.streamFirstEventTimeoutMs ??
287
- getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs),
300
+ firstItemTimeoutMs: firstEventTimeoutMs,
288
301
  errorMessage: LAZY_STREAM_IDLE_TIMEOUT_ERROR,
289
302
  firstItemErrorMessage: LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR,
290
303
  onIdle: () => abortTracker.abortLocally(new Error(LAZY_STREAM_IDLE_TIMEOUT_ERROR)),
291
304
  onFirstItemTimeout: () =>
292
- abortTracker.abortLocally(new FirstEventTimeoutError(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
305
+ abortTracker.abortLocally(
306
+ new FirstEventTimeoutError(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR, {
307
+ firstEventTimeoutMs,
308
+ firstEventTimeoutSource,
309
+ }),
310
+ ),
293
311
  abortSignal: options.signal,
294
312
  // Synthetic starts and tool-capability negotiation are control-plane events,
295
313
  // not model progress. Keep the first-event window active until assistant output
@@ -313,7 +331,7 @@ function forwardStream<TApi extends Api>(
313
331
  }
314
332
  } catch (error) {
315
333
  const stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
316
- const message = createLazyLoadErrorMessage(model, error, stopReason);
334
+ const message = createLazyLoadErrorMessage(model, error, stopReason, source);
317
335
  target.push({ type: "error", reason: stopReason, error: message });
318
336
  target.end(message);
319
337
  }
@@ -324,8 +342,37 @@ function createLazyLoadErrorMessage<TApi extends Api>(
324
342
  model: Model<TApi>,
325
343
  error: unknown,
326
344
  stopReason: Extract<AssistantMessage["stopReason"], "aborted" | "error"> = "error",
345
+ source?: AsyncIterable<AssistantMessageEvent>,
327
346
  ): AssistantMessage {
328
347
  const transportFailure = transportFailureFacts(error);
348
+ const carriedDiagnostics =
349
+ source && typeof source === "object"
350
+ ? (source as { googleGeminiCliDiagnostics?: AssistantMessage["googleGeminiCliDiagnostics"] })
351
+ .googleGeminiCliDiagnostics
352
+ : undefined;
353
+ const diagnostics =
354
+ carriedDiagnostics ??
355
+ (error instanceof FirstEventTimeoutError
356
+ ? (
357
+ error as FirstEventTimeoutError & {
358
+ googleGeminiCliDiagnostics?: AssistantMessage["googleGeminiCliDiagnostics"];
359
+ }
360
+ ).googleGeminiCliDiagnostics
361
+ : undefined);
362
+ if (diagnostics && typeof error === "object" && error !== null) {
363
+ const timeoutFacts = error as {
364
+ firstEventTimeoutMs?: unknown;
365
+ firstEventTimeoutSource?: unknown;
366
+ };
367
+ if (typeof timeoutFacts.firstEventTimeoutMs === "number") {
368
+ diagnostics.firstEventTimeoutMs = timeoutFacts.firstEventTimeoutMs;
369
+ }
370
+ if (typeof timeoutFacts.firstEventTimeoutSource === "string") {
371
+ diagnostics.firstEventTimeoutSource = timeoutFacts.firstEventTimeoutSource as NonNullable<
372
+ AssistantMessage["googleGeminiCliDiagnostics"]
373
+ >["firstEventTimeoutSource"];
374
+ }
375
+ }
329
376
  return {
330
377
  role: "assistant",
331
378
  content: [],
@@ -344,6 +391,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
344
391
  errorMessage:
345
392
  stopReason === "aborted" ? "Request was aborted" : error instanceof Error ? error.message : String(error),
346
393
  ...(transportFailure ? { transportFailure } : {}),
394
+ ...(diagnostics ? { googleGeminiCliDiagnostics: diagnostics } : {}),
347
395
  timestamp: Date.now(),
348
396
  };
349
397
  }
package/src/types.d.ts CHANGED
@@ -640,6 +640,8 @@ export interface AssistantMessage {
640
640
  * supplied no trustworthy structured evidence.
641
641
  */
642
642
  providerDiagnostic?: ProviderDiagnostic;
643
+ /** Redacted first-event transport timing and chunk-shape diagnostics for Google Gemini CLI. */
644
+ googleGeminiCliDiagnostics?: GoogleGeminiCliDiagnostics;
643
645
  /** Typed upstream failure facts retained for retry classification without parsing errorMessage. */
644
646
  transportFailure?: TransportFailureFacts;
645
647
  /**
@@ -658,6 +660,19 @@ export interface AssistantMessage {
658
660
  /** Prompt-prefix fingerprint of the request that produced this message. */
659
661
  promptPrefix?: PromptPrefixTelemetry;
660
662
  }
663
+ export interface GoogleGeminiCliDiagnostics {
664
+ responseAtMs?: number;
665
+ firstRawSseAtMs?: number;
666
+ chunkCounts: {
667
+ content: number;
668
+ thinking: number;
669
+ functionCall: number;
670
+ usageOnly: number;
671
+ other: number;
672
+ };
673
+ firstEventTimeoutMs?: number;
674
+ firstEventTimeoutSource?: "stream-option" | "env" | "idle-timeout" | "provider-fallback" | "default";
675
+ }
661
676
  export interface ToolResultMessage<TDetails = any> {
662
677
  role: "toolResult";
663
678
  toolCallId: string;
package/src/types.ts CHANGED
@@ -881,6 +881,8 @@ export interface AssistantMessage {
881
881
  * supplied no trustworthy structured evidence.
882
882
  */
883
883
  providerDiagnostic?: ProviderDiagnostic;
884
+ /** Redacted first-event transport timing and chunk-shape diagnostics for Google Gemini CLI. */
885
+ googleGeminiCliDiagnostics?: GoogleGeminiCliDiagnostics;
884
886
  /** Typed upstream failure facts retained for retry classification without parsing errorMessage. */
885
887
  transportFailure?: TransportFailureFacts;
886
888
  /**
@@ -900,6 +902,20 @@ export interface AssistantMessage {
900
902
  promptPrefix?: PromptPrefixTelemetry;
901
903
  }
902
904
 
905
+ export interface GoogleGeminiCliDiagnostics {
906
+ responseAtMs?: number;
907
+ firstRawSseAtMs?: number;
908
+ chunkCounts: {
909
+ content: number;
910
+ thinking: number;
911
+ functionCall: number;
912
+ usageOnly: number;
913
+ other: number;
914
+ };
915
+ firstEventTimeoutMs?: number;
916
+ firstEventTimeoutSource?: "stream-option" | "env" | "idle-timeout" | "provider-fallback" | "default";
917
+ }
918
+
903
919
  export interface ToolResultMessage<TDetails = any> {
904
920
  role: "toolResult";
905
921
  toolCallId: string;
@@ -72,6 +72,7 @@ export interface FirstEventTimeoutFacts {
72
72
  requestBytes?: number;
73
73
  firstEventElapsedMs?: number;
74
74
  firstEventTimeoutMs?: number;
75
+ firstEventTimeoutSource?: "stream-option" | "env" | "idle-timeout" | "provider-fallback" | "default";
75
76
  endpointClass?: "canonical" | "custom";
76
77
  retryMaxAttempts?: number;
77
78
  }
@@ -80,6 +81,7 @@ export declare class FirstEventTimeoutError extends Error {
80
81
  readonly requestBytes?: number;
81
82
  readonly firstEventElapsedMs?: number;
82
83
  readonly firstEventTimeoutMs?: number;
84
+ readonly firstEventTimeoutSource?: FirstEventTimeoutFacts["firstEventTimeoutSource"];
83
85
  readonly endpointClass?: "canonical" | "custom";
84
86
  readonly retryMaxAttempts?: number;
85
87
  constructor(message: string, facts?: FirstEventTimeoutFacts);
@@ -179,6 +179,7 @@ export interface FirstEventTimeoutFacts {
179
179
  requestBytes?: number;
180
180
  firstEventElapsedMs?: number;
181
181
  firstEventTimeoutMs?: number;
182
+ firstEventTimeoutSource?: "stream-option" | "env" | "idle-timeout" | "provider-fallback" | "default";
182
183
  endpointClass?: "canonical" | "custom";
183
184
  retryMaxAttempts?: number;
184
185
  }
@@ -188,6 +189,7 @@ export class FirstEventTimeoutError extends Error {
188
189
  readonly requestBytes?: number;
189
190
  readonly firstEventElapsedMs?: number;
190
191
  readonly firstEventTimeoutMs?: number;
192
+ readonly firstEventTimeoutSource?: FirstEventTimeoutFacts["firstEventTimeoutSource"];
191
193
  readonly endpointClass?: "canonical" | "custom";
192
194
  readonly retryMaxAttempts?: number;
193
195
 
@@ -197,6 +199,7 @@ export class FirstEventTimeoutError extends Error {
197
199
  this.requestBytes = facts.requestBytes;
198
200
  this.firstEventElapsedMs = facts.firstEventElapsedMs;
199
201
  this.firstEventTimeoutMs = facts.firstEventTimeoutMs;
202
+ this.firstEventTimeoutSource = facts.firstEventTimeoutSource;
200
203
  this.endpointClass = facts.endpointClass;
201
204
  this.retryMaxAttempts = facts.retryMaxAttempts;
202
205
  }