@oh-my-pi/pi-ai 16.3.9 → 16.3.11

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,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.11] - 2026-07-06
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `openai-codex-responses` fresh plan execution requests that contained only system/developer guidance by mirroring the final instruction as user input so Codex accepts the first turn. ([#4714](https://github.com/can1357/oh-my-pi/issues/4714))
10
+ - Fixed Codex WebSocket compact/resume delta diagnostics to record request shape and raw-vs-displayed usage buckets, so persistent server-reported uncached suffixes without `orchestration_*` fields are visible in debug stats. ([#4707](https://github.com/can1357/oh-my-pi/issues/4707))
11
+
12
+ ## [16.3.10] - 2026-07-06
13
+
14
+ ### Fixed
15
+
16
+ - Fixed Ollama/Ollama Cloud EOS-only completions to retry empty stops with a single output token before the agent loop can halt silently. ([#4659](https://github.com/can1357/oh-my-pi/issues/4659))
17
+ - Fixed Claude Sonnet 5 failing every request on feature-gated gateways (Azure Foundry, OpenAI-compatible relays) that reject strict tools with "structured_outputs not supported" — the rejection is now classified as a strict-tool rejection, so the request retries without strict tools and the session remembers the downgrade.
18
+
5
19
  ## [16.3.7] - 2026-07-05
6
20
 
7
21
  ### Fixed
@@ -13,7 +13,7 @@ export declare const Flag: {
13
13
  readonly SilentAbort: 33554432;
14
14
  readonly UserInterrupt: 67108864;
15
15
  readonly Abort: 134217728;
16
- /** Anthropic strict-tool grammar too large / schema too complex to compile (400). */
16
+ /** Strict-tool rejection (400): grammar too large, schema too complex, or structured outputs unsupported by the model/endpoint. */
17
17
  readonly Grammar: 268435456;
18
18
  /** Anthropic model/account does not support fast mode / the `speed` parameter. */
19
19
  readonly FastModeUnsupported: 536870912;
@@ -48,7 +48,8 @@ export declare function classify(error: unknown, api?: Api): number;
48
48
  */
49
49
  export declare function isUsageLimit(error: unknown, api?: Api): boolean;
50
50
  /**
51
- * Anthropic strict-tool grammar too large / schema too complex to compile.
51
+ * Strict-tool rejection: grammar too large, schema too complex, or structured
52
+ * outputs unsupported by the model/endpoint.
52
53
  * Accessor for {@link Flag.Grammar}.
53
54
  */
54
55
  export declare function isGrammarError(error: unknown): boolean;
@@ -4,4 +4,5 @@ export interface OllamaChatOptions extends StreamOptions {
4
4
  disableReasoning?: boolean;
5
5
  toolChoice?: ToolChoice;
6
6
  }
7
+ /** Retry EOS-only Ollama completions before the agent loop sees an empty stop. */
7
8
  export declare const streamOllama: StreamFunction<"ollama-chat">;
@@ -35,10 +35,46 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
35
35
  onModerationMetadata?: (metadata: unknown) => void;
36
36
  }
37
37
  type CodexTransport = "sse" | "websocket";
38
+ /** Shape of the Codex request sent on the latest provider turn. */
39
+ export interface OpenAICodexTurnRequestDiagnostics {
40
+ transport: "sse" | "websocket";
41
+ previousResponseIdPresent: boolean;
42
+ inputItemCount: number;
43
+ inputItemTypes: string[];
44
+ firstInputItemType?: string;
45
+ inputJsonBytes: number;
46
+ promptCacheKey?: string;
47
+ toolsHash?: string;
48
+ optionsHash: string;
49
+ canAppendBeforeRequest: boolean;
50
+ }
51
+ /** Raw provider usage plus the normalized buckets OMP displays for the latest Codex turn. */
52
+ export interface OpenAICodexTurnUsageDiagnostics {
53
+ rawInputTokens: number;
54
+ rawCachedTokens: number;
55
+ rawUncachedTokens: number;
56
+ rawOutputTokens: number;
57
+ rawTotalTokens?: number;
58
+ rawOrchestrationInputTokens?: number;
59
+ rawOrchestrationCachedTokens?: number;
60
+ rawOrchestrationOutputTokens?: number;
61
+ displayedInputTokens: number;
62
+ displayedOutputTokens: number;
63
+ displayedCacheReadTokens: number;
64
+ displayedCacheWriteTokens: number;
65
+ displayedTotalTokens: number;
66
+ displayedOrchestrationInputTokens: number;
67
+ displayedOrchestrationCacheReadTokens: number;
68
+ displayedOrchestrationOutputTokens: number;
69
+ }
70
+ /** Latest Codex turn request/usage diagnostics exposed to debug UIs and tests. */
71
+ export interface OpenAICodexTurnDiagnostics {
72
+ request: OpenAICodexTurnRequestDiagnostics;
73
+ usage?: OpenAICodexTurnUsageDiagnostics;
74
+ }
38
75
  /**
39
- * Per-session request-shape counters. Despite the name, these cover both
40
- * transports: once stateful SSE chaining is enabled, SSE requests are counted
41
- * too (the shared chained-request builder records every request it shapes).
76
+ * Per-session request-shape counters and latest turn diagnostics. Despite the
77
+ * name, these cover both transports.
42
78
  */
43
79
  export interface OpenAICodexWebSocketDebugStats {
44
80
  fullContextRequests: number;
@@ -46,6 +82,7 @@ export interface OpenAICodexWebSocketDebugStats {
46
82
  lastInputItems: number;
47
83
  lastDeltaInputItems?: number;
48
84
  lastPreviousResponseId?: string;
85
+ lastTurn?: OpenAICodexTurnDiagnostics;
49
86
  }
50
87
  /** @internal Exported for tests. */
51
88
  export declare function normalizeCodexToolChoice(choice: ToolChoice | undefined, tools?: Tool[], model?: Model<"openai-codex-responses">): string | Record<string, unknown> | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.3.9",
4
+ "version": "16.3.11",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.3.9",
42
- "@oh-my-pi/pi-utils": "16.3.9",
43
- "@oh-my-pi/pi-wire": "16.3.9",
41
+ "@oh-my-pi/pi-catalog": "16.3.11",
42
+ "@oh-my-pi/pi-utils": "16.3.11",
43
+ "@oh-my-pi/pi-wire": "16.3.11",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -22,7 +22,7 @@ export const Flag = {
22
22
  SilentAbort: 0x0200_0000,
23
23
  UserInterrupt: 0x0400_0000,
24
24
  Abort: 0x0800_0000,
25
- /** Anthropic strict-tool grammar too large / schema too complex to compile (400). */
25
+ /** Strict-tool rejection (400): grammar too large, schema too complex, or structured outputs unsupported by the model/endpoint. */
26
26
  Grammar: 0x1000_0000,
27
27
  /** Anthropic model/account does not support fast mode / the `speed` parameter. */
28
28
  FastModeUnsupported: 0x2000_0000,
@@ -108,12 +108,17 @@ export const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN =
108
108
  // on a backend that has the model.
109
109
  const COPILOT_MODEL_NOT_SUPPORTED_PATTERN = /model_not_supported/i;
110
110
  // Anthropic strict-tool grammar too large / schema too complex (400 invalid_request_error).
111
+ // Feature-gated deployments (Azure Foundry, Baseten, …) reject `strict: true`
112
+ // tools outright when the hosted model lacks structured outputs, e.g.
113
+ // "structured_outputs not supported" — without an invalid_request_error wrapper.
111
114
  const GRAMMAR_TOO_LARGE_PATTERN = /compiled grammar/i;
112
115
  const GRAMMAR_TOO_LARGE_DETAIL_PATTERN = /too large/i;
113
116
  const SCHEMA_TOO_COMPLEX_PATTERN = /schema/i;
114
117
  const SCHEMA_TOO_COMPLEX_DETAIL_PATTERN = /too complex/i;
115
118
  const SCHEMA_COMPILE_PATTERN = /compil/i;
116
119
  const INVALID_REQUEST_PATTERN = /invalid_request_error/i;
120
+ const STRUCTURED_OUTPUTS_PATTERN = /structured[_ -]?outputs?/i;
121
+ const FEATURE_NOT_SUPPORTED_PATTERN = /not (?:supported|available|enabled)|unsupported|does(?: not|n'?t) support/i;
117
122
  // Anthropic fast-mode unsupported: 400 rejecting `speed`, or 429 rate_limit_error
118
123
  // because the account lacks the extra-usage entitlement fast mode requires.
119
124
  const FAST_MODE_SPEED_PARAM_PATTERN = /\bspeed\b/i;
@@ -127,8 +132,9 @@ const OAUTH_TRANSIENT_FAILURE_PATTERN =
127
132
  /timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i;
128
133
  const OAUTH_HTTP_AUTH_PATTERN = /\b401\b/;
129
134
 
130
- function matchesGrammarTooLarge(message: string, errorStatus: number | undefined): boolean {
135
+ function matchesStrictToolsRejection(message: string, errorStatus: number | undefined): boolean {
131
136
  if (errorStatus !== 400) return false;
137
+ if (STRUCTURED_OUTPUTS_PATTERN.test(message) && FEATURE_NOT_SUPPORTED_PATTERN.test(message)) return true;
132
138
  if (!INVALID_REQUEST_PATTERN.test(message)) return false;
133
139
  const grammarTooLarge = GRAMMAR_TOO_LARGE_PATTERN.test(message) && GRAMMAR_TOO_LARGE_DETAIL_PATTERN.test(message);
134
140
  const schemaTooComplex =
@@ -317,7 +323,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
317
323
 
318
324
  // Copilot per-client routing flap is transient.
319
325
  if (statusClean === 400 && COPILOT_MODEL_NOT_SUPPORTED_PATTERN.test(cleanMessage)) kinds |= Flag.Transient;
320
- if (matchesGrammarTooLarge(cleanMessage, statusClean)) kinds |= Flag.Grammar;
326
+ if (matchesStrictToolsRejection(cleanMessage, statusClean)) kinds |= Flag.Grammar;
321
327
  if (matchesFastModeUnsupported(cleanMessage, statusClean)) kinds |= Flag.FastModeUnsupported;
322
328
  }
323
329
  if (kinds !== 0) return create(kinds);
@@ -411,7 +417,8 @@ export function isUsageLimit(error: unknown, api?: Api): boolean {
411
417
  }
412
418
 
413
419
  /**
414
- * Anthropic strict-tool grammar too large / schema too complex to compile.
420
+ * Strict-tool rejection: grammar too large, schema too complex, or structured
421
+ * outputs unsupported by the model/endpoint.
415
422
  * Accessor for {@link Flag.Grammar}.
416
423
  */
417
424
  export function isGrammarError(error: unknown): boolean {
@@ -2396,7 +2396,7 @@ const streamAnthropicOnce = (
2396
2396
  ) {
2397
2397
  // Log-only: the retried turn must not carry an errorMessage on
2398
2398
  // success (consumers treat its presence as failure).
2399
- logger.warn("anthropic: strict tool grammar rejected, retrying without strict tools", {
2399
+ logger.warn("anthropic: strict tools rejected, retrying without strict tools", {
2400
2400
  model: model.id,
2401
2401
  error: await finalizeErrorMessage(streamFailure, rawRequestDump),
2402
2402
  });
@@ -16,6 +16,7 @@ import type {
16
16
  } from "../types";
17
17
  import { normalizeSystemPrompts } from "../utils";
18
18
  import { clearStreamingPartialJson, kStreamingPartialJson } from "../utils/block-symbols";
19
+ import { withEmptyCompletionRetry } from "../utils/empty-completion-retry";
19
20
  import { AssistantMessageEventStream } from "../utils/event-stream";
20
21
  import type { CapturedHttpErrorResponse, RawHttpRequestDump } from "../utils/http-inspector";
21
22
  import {
@@ -449,10 +450,10 @@ function hasVisibleAssistantContent(output: AssistantMessage): boolean {
449
450
 
450
451
  const OLLAMA_RETRY_DELAYS_MS = [2_000, 5_000, 10_000];
451
452
 
452
- export const streamOllama: StreamFunction<"ollama-chat"> = (
453
+ const streamOllamaOnce = (
453
454
  model: Model<"ollama-chat">,
454
455
  context: Context,
455
- options: OllamaChatOptions,
456
+ options: OllamaChatOptions = {},
456
457
  ): AssistantMessageEventStream => {
457
458
  const stream = new AssistantMessageEventStream();
458
459
  void (async () => {
@@ -771,3 +772,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
771
772
  })();
772
773
  return stream;
773
774
  };
775
+
776
+ /** Retry EOS-only Ollama completions before the agent loop sees an empty stop. */
777
+ export const streamOllama: StreamFunction<"ollama-chat"> = (model, context, options) =>
778
+ withEmptyCompletionRetry(model, context, options, streamOllamaOnce);
@@ -230,16 +230,61 @@ export async function transformRequestBody(
230
230
  }
231
231
  }
232
232
 
233
- if (prompt?.developerMessages && prompt.developerMessages.length > 0 && Array.isArray(body.input)) {
234
- const developerMessages = prompt.developerMessages.map(
235
- text =>
236
- ({
233
+ if (prompt?.developerMessages && prompt.developerMessages.length > 0) {
234
+ const developerMessages: InputItem[] = prompt.developerMessages.map(text => ({
235
+ type: "message",
236
+ role: "developer",
237
+ content: [{ type: "input_text", text }],
238
+ }));
239
+ const input = Array.isArray(body.input) ? body.input : [];
240
+ body.input = [...developerMessages, ...input];
241
+ }
242
+
243
+ let finalInstruction = prompt?.developerMessages.findLast(text => text.trim().length > 0);
244
+ if (finalInstruction === undefined && Array.isArray(body.input)) {
245
+ for (let itemIndex = body.input.length - 1; itemIndex >= 0; itemIndex -= 1) {
246
+ const item = body.input[itemIndex];
247
+ if (item.role !== "developer" || !Array.isArray(item.content)) continue;
248
+ for (let partIndex = item.content.length - 1; partIndex >= 0; partIndex -= 1) {
249
+ const part = item.content[partIndex];
250
+ if (
251
+ part &&
252
+ typeof part === "object" &&
253
+ "type" in part &&
254
+ part.type === "input_text" &&
255
+ "text" in part &&
256
+ typeof part.text === "string" &&
257
+ part.text.trim().length > 0
258
+ ) {
259
+ finalInstruction = part.text;
260
+ break;
261
+ }
262
+ }
263
+ if (finalInstruction !== undefined) break;
264
+ }
265
+ }
266
+ if (finalInstruction === undefined && typeof body.instructions === "string" && body.instructions.trim().length > 0) {
267
+ finalInstruction = body.instructions;
268
+ }
269
+ if (finalInstruction !== undefined) {
270
+ const input = Array.isArray(body.input) ? body.input : [];
271
+ let hasVisibleInput = false;
272
+ for (const item of input) {
273
+ if (item.role !== "developer") {
274
+ hasVisibleInput = true;
275
+ break;
276
+ }
277
+ }
278
+ if (!hasVisibleInput) {
279
+ body.input = [
280
+ ...input,
281
+ {
237
282
  type: "message",
238
- role: "developer",
239
- content: [{ type: "input_text", text }],
240
- }) as InputItem,
241
- );
242
- body.input = [...developerMessages, ...body.input];
283
+ role: "user",
284
+ content: [{ type: "input_text", text: finalInstruction }],
285
+ },
286
+ ];
287
+ }
243
288
  }
244
289
 
245
290
  const responsesLite = shouldUseCodexResponsesLite(body, options.responsesLite);
@@ -37,6 +37,7 @@ import type {
37
37
  Tool,
38
38
  ToolCall,
39
39
  ToolChoice,
40
+ Usage,
40
41
  } from "../types";
41
42
  import {
42
43
  createOpenAIResponsesHistoryPayload,
@@ -246,10 +247,66 @@ type CodexOutputBlock =
246
247
  | TextContent
247
248
  | (ToolCall & { [kStreamingPartialJson]: string; [kStreamingLastParseLen]?: number });
248
249
 
250
+ interface CodexResponseUsage {
251
+ input_tokens?: number;
252
+ output_tokens?: number;
253
+ total_tokens?: number;
254
+ prompt_cache_hit_tokens?: number;
255
+ input_tokens_details?: {
256
+ cached_tokens?: number;
257
+ cache_write_tokens?: number;
258
+ orchestration_input_tokens?: number;
259
+ orchestration_input_cached_tokens?: number;
260
+ };
261
+ output_tokens_details?: {
262
+ reasoning_tokens?: number;
263
+ orchestration_output_tokens?: number;
264
+ };
265
+ }
266
+
267
+ /** Shape of the Codex request sent on the latest provider turn. */
268
+ export interface OpenAICodexTurnRequestDiagnostics {
269
+ transport: "sse" | "websocket";
270
+ previousResponseIdPresent: boolean;
271
+ inputItemCount: number;
272
+ inputItemTypes: string[];
273
+ firstInputItemType?: string;
274
+ inputJsonBytes: number;
275
+ promptCacheKey?: string;
276
+ toolsHash?: string;
277
+ optionsHash: string;
278
+ canAppendBeforeRequest: boolean;
279
+ }
280
+
281
+ /** Raw provider usage plus the normalized buckets OMP displays for the latest Codex turn. */
282
+ export interface OpenAICodexTurnUsageDiagnostics {
283
+ rawInputTokens: number;
284
+ rawCachedTokens: number;
285
+ rawUncachedTokens: number;
286
+ rawOutputTokens: number;
287
+ rawTotalTokens?: number;
288
+ rawOrchestrationInputTokens?: number;
289
+ rawOrchestrationCachedTokens?: number;
290
+ rawOrchestrationOutputTokens?: number;
291
+ displayedInputTokens: number;
292
+ displayedOutputTokens: number;
293
+ displayedCacheReadTokens: number;
294
+ displayedCacheWriteTokens: number;
295
+ displayedTotalTokens: number;
296
+ displayedOrchestrationInputTokens: number;
297
+ displayedOrchestrationCacheReadTokens: number;
298
+ displayedOrchestrationOutputTokens: number;
299
+ }
300
+
301
+ /** Latest Codex turn request/usage diagnostics exposed to debug UIs and tests. */
302
+ export interface OpenAICodexTurnDiagnostics {
303
+ request: OpenAICodexTurnRequestDiagnostics;
304
+ usage?: OpenAICodexTurnUsageDiagnostics;
305
+ }
306
+
249
307
  /**
250
- * Per-session request-shape counters. Despite the name, these cover both
251
- * transports: once stateful SSE chaining is enabled, SSE requests are counted
252
- * too (the shared chained-request builder records every request it shapes).
308
+ * Per-session request-shape counters and latest turn diagnostics. Despite the
309
+ * name, these cover both transports.
253
310
  */
254
311
  export interface OpenAICodexWebSocketDebugStats {
255
312
  fullContextRequests: number;
@@ -257,6 +314,7 @@ export interface OpenAICodexWebSocketDebugStats {
257
314
  lastInputItems: number;
258
315
  lastDeltaInputItems?: number;
259
316
  lastPreviousResponseId?: string;
317
+ lastTurn?: OpenAICodexTurnDiagnostics;
260
318
  }
261
319
 
262
320
  /**
@@ -1002,6 +1060,7 @@ async function openCodexWebSocketTransport(
1002
1060
  requestBodyForState: RequestBody;
1003
1061
  transport: CodexTransport;
1004
1062
  }> {
1063
+ const canAppendBeforeRequest = websocketState.canAppend === true;
1005
1064
  const chainedBody = buildCodexChainedRequestBody(requestContext.transformedBody, websocketState);
1006
1065
  // WebSocket frames cannot carry per-request HTTP headers, so the Responses
1007
1066
  // Lite marker rides in `client_metadata` on every `response.create`.
@@ -1021,6 +1080,7 @@ async function openCodexWebSocketTransport(
1021
1080
  if (replacementWebsocketRequest !== undefined) {
1022
1081
  websocketRequest = replacementWebsocketRequest as typeof websocketRequest;
1023
1082
  }
1083
+ recordCodexTurnRequestDiagnostics(websocketState, websocketRequest, "websocket", canAppendBeforeRequest);
1024
1084
  const websocketHeaders = createCodexHeaders(
1025
1085
  requestContext.requestHeaders,
1026
1086
  requestContext.accountId,
@@ -1113,12 +1173,13 @@ async function openCodexSseTransport(
1113
1173
  ),
1114
1174
  );
1115
1175
  };
1176
+ const canAppendBeforeRequest = state?.canAppend === true;
1116
1177
  let wireBody = body;
1117
1178
  const replacementWireBody = await options?.onPayload?.(wireBody, model);
1118
1179
  if (replacementWireBody !== undefined) {
1119
1180
  wireBody = replacementWireBody as RequestBody;
1120
1181
  }
1121
- recordCodexWebSocketRequestStats(state, wireBody);
1182
+ recordCodexTurnRequestDiagnostics(state, wireBody, "sse", canAppendBeforeRequest);
1122
1183
  return { eventStream: await open(wireBody), requestBodyForState: structuredCloneJSON(wireBody), transport: "sse" };
1123
1184
  }
1124
1185
 
@@ -1525,34 +1586,19 @@ class CodexStreamProcessor {
1525
1586
  #handleResponseCompleted(rawEvent: Record<string, unknown>): void {
1526
1587
  const { runtime, model, output } = this;
1527
1588
  runtime.sawTerminalEvent = true;
1528
- const response = (
1529
- rawEvent as {
1530
- response?: {
1531
- id?: string;
1532
- usage?: {
1533
- input_tokens?: number;
1534
- output_tokens?: number;
1535
- total_tokens?: number;
1536
- input_tokens_details?: {
1537
- cached_tokens?: number;
1538
- orchestration_input_tokens?: number;
1539
- orchestration_input_cached_tokens?: number;
1540
- };
1541
- output_tokens_details?: {
1542
- reasoning_tokens?: number;
1543
- orchestration_output_tokens?: number;
1544
- };
1545
- };
1546
- status?: string;
1547
- service_tier?: ServiceTier | "default";
1548
- end_turn?: boolean;
1549
- };
1550
- }
1551
- ).response;
1589
+ const rawResponse = rawEvent.response;
1590
+ const response = rawResponse && typeof rawResponse === "object" ? rawResponse : undefined;
1591
+ const responseId = response && "id" in response && typeof response.id === "string" ? response.id : undefined;
1592
+ const usage = response && "usage" in response ? parseCodexResponseUsage(response.usage) : undefined;
1593
+ const serviceTier =
1594
+ response && "service_tier" in response ? parseCodexServiceTier(response.service_tier) : undefined;
1595
+ const status = response && "status" in response ? parseCodexResponseStatus(response.status) : undefined;
1596
+ const endTurn = response && "end_turn" in response ? response.end_turn : undefined;
1552
1597
 
1553
- populateResponsesUsageFromResponse(output, response?.usage);
1554
- if (typeof response?.id === "string" && response.id.length > 0) {
1555
- output.responseId = response.id;
1598
+ populateResponsesUsageFromResponse(output, usage);
1599
+ recordCodexTurnUsageDiagnostics(runtime.websocketState, usage, output.usage);
1600
+ if (responseId) {
1601
+ output.responseId = responseId;
1556
1602
  }
1557
1603
 
1558
1604
  const state = runtime.websocketState;
@@ -1564,8 +1610,8 @@ class CodexStreamProcessor {
1564
1610
  resetCodexWebSocketAppendState(state);
1565
1611
  } else {
1566
1612
  state.lastRequest = structuredCloneJSON(runtime.requestBodyForState);
1567
- if (typeof response?.id === "string" && response.id.length > 0) {
1568
- state.lastResponseId = response.id;
1613
+ if (responseId) {
1614
+ state.lastResponseId = responseId;
1569
1615
  state.lastResponseItems = stripInputItemIds(structuredCloneJSON(runtime.nativeOutputItems));
1570
1616
  state.canAppend = rawEvent.type === "response.done" || rawEvent.type === "response.completed";
1571
1617
  } else {
@@ -1578,14 +1624,9 @@ class CodexStreamProcessor {
1578
1624
  finalizePendingResponsesToolCalls(output);
1579
1625
 
1580
1626
  calculateCost(model, output.usage);
1581
- applyCodexServiceTierPricing(
1582
- model,
1583
- output.usage,
1584
- response?.service_tier,
1585
- runtime.requestBodyForState.service_tier,
1586
- );
1587
- output.stopReason = mapOpenAIResponsesStopReason(response?.status as ResponseStatus | undefined);
1588
- promoteResponsesToolUseStopReason(output, response?.end_turn);
1627
+ applyCodexServiceTierPricing(model, output.usage, serviceTier, runtime.requestBodyForState.service_tier);
1628
+ output.stopReason = mapOpenAIResponsesStopReason(status);
1629
+ promoteResponsesToolUseStopReason(output, endTurn === true ? true : endTurn === false ? false : undefined);
1589
1630
  }
1590
1631
 
1591
1632
  async #recoverStreamError(error: unknown): Promise<boolean> {
@@ -2270,22 +2311,230 @@ function stripInputItemIds(items: Array<Record<string, unknown>>): InputItem[] {
2270
2311
  });
2271
2312
  }
2272
2313
 
2273
- function recordCodexWebSocketRequestStats(
2314
+ const codexDiagnosticsTextEncoder = new TextEncoder();
2315
+
2316
+ function jsonByteLength(value: unknown): number {
2317
+ const json = JSON.stringify(value);
2318
+ return codexDiagnosticsTextEncoder.encode(json === undefined ? "undefined" : json).byteLength;
2319
+ }
2320
+
2321
+ function hashJson(value: unknown): string {
2322
+ const json = JSON.stringify(value);
2323
+ return String(Bun.hash(json === undefined ? "undefined" : json));
2324
+ }
2325
+
2326
+ function parseCodexServiceTier(value: unknown): ServiceTier | undefined {
2327
+ switch (value) {
2328
+ case "auto":
2329
+ case "default":
2330
+ case "flex":
2331
+ case "scale":
2332
+ case "priority":
2333
+ return value;
2334
+ default:
2335
+ return undefined;
2336
+ }
2337
+ }
2338
+
2339
+ function parseCodexResponseStatus(value: unknown): ResponseStatus | undefined {
2340
+ switch (value) {
2341
+ case "completed":
2342
+ case "failed":
2343
+ case "in_progress":
2344
+ case "cancelled":
2345
+ case "queued":
2346
+ case "incomplete":
2347
+ return value;
2348
+ default:
2349
+ return undefined;
2350
+ }
2351
+ }
2352
+
2353
+ function parseCodexResponseUsage(value: unknown): CodexResponseUsage | undefined {
2354
+ if (!value || typeof value !== "object") return undefined;
2355
+ const usage: CodexResponseUsage = {};
2356
+ let hasUsage = false;
2357
+ if ("input_tokens" in value && typeof value.input_tokens === "number") {
2358
+ usage.input_tokens = value.input_tokens;
2359
+ hasUsage = true;
2360
+ }
2361
+ if ("output_tokens" in value && typeof value.output_tokens === "number") {
2362
+ usage.output_tokens = value.output_tokens;
2363
+ hasUsage = true;
2364
+ }
2365
+ if ("total_tokens" in value && typeof value.total_tokens === "number") {
2366
+ usage.total_tokens = value.total_tokens;
2367
+ hasUsage = true;
2368
+ }
2369
+ if ("prompt_cache_hit_tokens" in value && typeof value.prompt_cache_hit_tokens === "number") {
2370
+ usage.prompt_cache_hit_tokens = value.prompt_cache_hit_tokens;
2371
+ hasUsage = true;
2372
+ }
2373
+ if (
2374
+ "input_tokens_details" in value &&
2375
+ value.input_tokens_details &&
2376
+ typeof value.input_tokens_details === "object"
2377
+ ) {
2378
+ const details = value.input_tokens_details;
2379
+ const parsedDetails: NonNullable<CodexResponseUsage["input_tokens_details"]> = {};
2380
+ let hasDetails = false;
2381
+ if ("cached_tokens" in details && typeof details.cached_tokens === "number") {
2382
+ parsedDetails.cached_tokens = details.cached_tokens;
2383
+ hasDetails = true;
2384
+ }
2385
+ if ("cache_write_tokens" in details && typeof details.cache_write_tokens === "number") {
2386
+ parsedDetails.cache_write_tokens = details.cache_write_tokens;
2387
+ hasDetails = true;
2388
+ }
2389
+ if ("orchestration_input_tokens" in details && typeof details.orchestration_input_tokens === "number") {
2390
+ parsedDetails.orchestration_input_tokens = details.orchestration_input_tokens;
2391
+ hasDetails = true;
2392
+ }
2393
+ if (
2394
+ "orchestration_input_cached_tokens" in details &&
2395
+ typeof details.orchestration_input_cached_tokens === "number"
2396
+ ) {
2397
+ parsedDetails.orchestration_input_cached_tokens = details.orchestration_input_cached_tokens;
2398
+ hasDetails = true;
2399
+ }
2400
+ if (hasDetails) {
2401
+ usage.input_tokens_details = parsedDetails;
2402
+ hasUsage = true;
2403
+ }
2404
+ }
2405
+ if (
2406
+ "output_tokens_details" in value &&
2407
+ value.output_tokens_details &&
2408
+ typeof value.output_tokens_details === "object"
2409
+ ) {
2410
+ const details = value.output_tokens_details;
2411
+ const parsedDetails: NonNullable<CodexResponseUsage["output_tokens_details"]> = {};
2412
+ let hasDetails = false;
2413
+ if ("reasoning_tokens" in details && typeof details.reasoning_tokens === "number") {
2414
+ parsedDetails.reasoning_tokens = details.reasoning_tokens;
2415
+ hasDetails = true;
2416
+ }
2417
+ if ("orchestration_output_tokens" in details && typeof details.orchestration_output_tokens === "number") {
2418
+ parsedDetails.orchestration_output_tokens = details.orchestration_output_tokens;
2419
+ hasDetails = true;
2420
+ }
2421
+ if (hasDetails) {
2422
+ usage.output_tokens_details = parsedDetails;
2423
+ hasUsage = true;
2424
+ }
2425
+ }
2426
+ return hasUsage ? usage : undefined;
2427
+ }
2428
+
2429
+ function describeCodexInputItemType(item: unknown): string {
2430
+ if (item && typeof item === "object") {
2431
+ if ("type" in item && typeof item.type === "string") return item.type;
2432
+ if ("role" in item && typeof item.role === "string") return item.role;
2433
+ }
2434
+ return typeof item;
2435
+ }
2436
+
2437
+ function createCodexOptionsHash(request: Record<string, unknown>): string {
2438
+ const options: Record<string, unknown> = {};
2439
+ for (const key in request) {
2440
+ if (key === "input" || key === "previous_response_id" || key === "type" || key === "client_metadata") {
2441
+ continue;
2442
+ }
2443
+ options[key] = request[key];
2444
+ }
2445
+ return hashJson(options);
2446
+ }
2447
+
2448
+ function buildCodexTurnRequestDiagnostics(
2449
+ request: Record<string, unknown>,
2450
+ transport: CodexTransport,
2451
+ canAppendBeforeRequest: boolean,
2452
+ ): OpenAICodexTurnRequestDiagnostics {
2453
+ const input = request.input;
2454
+ const inputItems = Array.isArray(input) ? input : [];
2455
+ const inputItemTypes = inputItems.map(describeCodexInputItemType);
2456
+ const promptCacheKey = typeof request.prompt_cache_key === "string" ? request.prompt_cache_key : undefined;
2457
+ const toolsHash = request.tools === undefined ? undefined : hashJson(request.tools);
2458
+ return {
2459
+ transport,
2460
+ previousResponseIdPresent:
2461
+ typeof request.previous_response_id === "string" && request.previous_response_id.length > 0,
2462
+ inputItemCount: inputItems.length,
2463
+ inputItemTypes,
2464
+ ...(inputItemTypes[0] ? { firstInputItemType: inputItemTypes[0] } : {}),
2465
+ inputJsonBytes: jsonByteLength(inputItems),
2466
+ ...(promptCacheKey !== undefined ? { promptCacheKey } : {}),
2467
+ ...(toolsHash !== undefined ? { toolsHash } : {}),
2468
+ optionsHash: createCodexOptionsHash(request),
2469
+ canAppendBeforeRequest,
2470
+ };
2471
+ }
2472
+
2473
+ function recordCodexTurnRequestDiagnostics(
2274
2474
  state: CodexWebSocketSessionState | undefined,
2275
2475
  request: Record<string, unknown>,
2476
+ transport: CodexTransport,
2477
+ canAppendBeforeRequest: boolean,
2276
2478
  ): void {
2277
2479
  if (!state) return;
2278
2480
  const input = request.input;
2279
2481
  state.stats.lastInputItems = Array.isArray(input) ? input.length : 0;
2280
- if (typeof request.previous_response_id === "string" && request.previous_response_id.length > 0) {
2482
+ const previousResponseId =
2483
+ typeof request.previous_response_id === "string" ? request.previous_response_id : undefined;
2484
+ if (previousResponseId && previousResponseId.length > 0) {
2281
2485
  state.stats.deltaRequests += 1;
2282
2486
  state.stats.lastDeltaInputItems = state.stats.lastInputItems;
2283
- state.stats.lastPreviousResponseId = request.previous_response_id;
2284
- return;
2487
+ state.stats.lastPreviousResponseId = previousResponseId;
2488
+ } else {
2489
+ state.stats.fullContextRequests += 1;
2490
+ state.stats.lastDeltaInputItems = undefined;
2491
+ state.stats.lastPreviousResponseId = undefined;
2285
2492
  }
2286
- state.stats.fullContextRequests += 1;
2287
- state.stats.lastDeltaInputItems = undefined;
2288
- state.stats.lastPreviousResponseId = undefined;
2493
+ state.stats.lastTurn = {
2494
+ request: buildCodexTurnRequestDiagnostics(request, transport, canAppendBeforeRequest),
2495
+ };
2496
+ CODEX_DEBUG && logger.debug("[codex] codex turn request diagnostics", { diagnostics: state.stats.lastTurn.request });
2497
+ }
2498
+
2499
+ function recordCodexTurnUsageDiagnostics(
2500
+ state: CodexWebSocketSessionState | undefined,
2501
+ rawUsage: CodexResponseUsage | undefined,
2502
+ displayedUsage: Usage,
2503
+ ): void {
2504
+ if (!state?.stats.lastTurn || !rawUsage) return;
2505
+ const details = rawUsage.input_tokens_details;
2506
+ const outputDetails = rawUsage.output_tokens_details;
2507
+ const rawInputTokens = rawUsage.input_tokens ?? 0;
2508
+ const rawCachedTokens = details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0;
2509
+ const usageDiagnostics: OpenAICodexTurnUsageDiagnostics = {
2510
+ rawInputTokens,
2511
+ rawCachedTokens,
2512
+ rawUncachedTokens: Math.max(0, rawInputTokens - rawCachedTokens),
2513
+ rawOutputTokens: rawUsage.output_tokens ?? 0,
2514
+ ...(typeof rawUsage.total_tokens === "number" ? { rawTotalTokens: rawUsage.total_tokens } : {}),
2515
+ ...(typeof details?.orchestration_input_tokens === "number"
2516
+ ? { rawOrchestrationInputTokens: details.orchestration_input_tokens }
2517
+ : {}),
2518
+ ...(typeof details?.orchestration_input_cached_tokens === "number"
2519
+ ? { rawOrchestrationCachedTokens: details.orchestration_input_cached_tokens }
2520
+ : {}),
2521
+ ...(typeof outputDetails?.orchestration_output_tokens === "number"
2522
+ ? { rawOrchestrationOutputTokens: outputDetails.orchestration_output_tokens }
2523
+ : {}),
2524
+ displayedInputTokens: displayedUsage.input,
2525
+ displayedOutputTokens: displayedUsage.output,
2526
+ displayedCacheReadTokens: displayedUsage.cacheRead,
2527
+ displayedCacheWriteTokens: displayedUsage.cacheWrite,
2528
+ displayedTotalTokens: displayedUsage.totalTokens,
2529
+ displayedOrchestrationInputTokens: displayedUsage.orchestration?.input ?? 0,
2530
+ displayedOrchestrationCacheReadTokens: displayedUsage.orchestration?.cacheRead ?? 0,
2531
+ displayedOrchestrationOutputTokens: displayedUsage.orchestration?.output ?? 0,
2532
+ };
2533
+ state.stats.lastTurn = {
2534
+ ...state.stats.lastTurn,
2535
+ usage: usageDiagnostics,
2536
+ };
2537
+ CODEX_DEBUG && logger.debug("[codex] codex turn diagnostics", { diagnostics: state.stats.lastTurn });
2289
2538
  }
2290
2539
 
2291
2540
  /**
@@ -2306,9 +2555,7 @@ function buildCodexChainedRequestBody(
2306
2555
  ? buildResponsesDeltaInput(state.lastRequest, state.lastResponseItems, requestBody)
2307
2556
  : null;
2308
2557
  if (appendInput && appendInput.length > 0 && state?.lastResponseId) {
2309
- const body: RequestBody = { ...requestBody, previous_response_id: state.lastResponseId, input: appendInput };
2310
- recordCodexWebSocketRequestStats(state, body);
2311
- return body;
2558
+ return { ...requestBody, previous_response_id: state.lastResponseId, input: appendInput };
2312
2559
  }
2313
2560
  if (chainable && state) {
2314
2561
  // Chaining was eligible but the prefix/options check failed: history
@@ -2322,7 +2569,6 @@ function buildCodexChainedRequestBody(
2322
2569
  state.turnState = undefined;
2323
2570
  state.modelsEtag = undefined;
2324
2571
  }
2325
- recordCodexWebSocketRequestStats(state, requestBody);
2326
2572
  return requestBody;
2327
2573
  }
2328
2574
 
@@ -1041,7 +1041,7 @@ export function shouldRetryWithoutStrictTools(
1041
1041
  const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText]
1042
1042
  .filter((value): value is string => typeof value === "string" && value.trim().length > 0)
1043
1043
  .join("\n");
1044
- return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool|tool parameters? schema|invalid schema for function/i.test(
1044
+ return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool|tool parameters? schema|invalid schema for function|structured[_ -]?outputs?\b[^\n]*(?:not (?:supported|available|enabled)|unsupported)|(?:not support|unsupported)[^\n]*structured[_ -]?outputs?\b/i.test(
1045
1045
  messageParts,
1046
1046
  );
1047
1047
  }
@@ -109,17 +109,16 @@ export function withEmptyCompletionRetry<M, O extends EmptyCompletionRetryOption
109
109
  }
110
110
 
111
111
  // Retry only a genuinely degenerate completion: a normal stop that
112
- // produced no visible content AND billed no output tokens (the flaky
113
- // gateway signature charged nothing, returned nothing). A stop that
114
- // reports output tokens spent its budget somewhere (e.g. thinking) and
115
- // is left alone.
112
+ // produced no visible content and reported no generated content tokens.
113
+ // Some providers count the terminal EOS as one output token, so a
114
+ // one-token invisible stop is still the same empty-completion failure.
116
115
  const message = terminal?.type === "done" ? terminal.message : undefined;
117
116
  const isRetryableEmpty =
118
117
  !committed &&
119
118
  message !== undefined &&
120
119
  message.stopReason === "stop" &&
121
120
  !message.errorMessage &&
122
- (message.usage?.output ?? 0) <= 0 &&
121
+ (message.usage?.output ?? 0) <= 1 &&
123
122
  !hasVisibleAssistantContent(message);
124
123
 
125
124
  if (isRetryableEmpty && emptyAttempt < MAX_EMPTY_COMPLETION_RETRIES && !signal?.aborted) {