@oh-my-pi/pi-ai 18.2.0 → 18.2.2

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 (75) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +2 -0
  3. package/dist/types/auth/sqlite-credential-store.d.ts +2 -1
  4. package/dist/types/auth-broker/remote-store.d.ts +17 -0
  5. package/dist/types/auth-gateway/index.d.ts +1 -0
  6. package/dist/types/auth-gateway/session-state.d.ts +118 -0
  7. package/dist/types/auth-storage.d.ts +17 -0
  8. package/dist/types/error/body-error.d.ts +15 -0
  9. package/dist/types/error/flags.d.ts +16 -0
  10. package/dist/types/error/index.d.ts +1 -0
  11. package/dist/types/index.d.ts +1 -0
  12. package/dist/types/oneshot-retry.d.ts +6 -0
  13. package/dist/types/provider-session-state.d.ts +46 -0
  14. package/dist/types/providers/amazon-bedrock.d.ts +3 -0
  15. package/dist/types/providers/aws-sigv4.d.ts +12 -0
  16. package/dist/types/providers/openai-codex/request-transformer.d.ts +27 -0
  17. package/dist/types/providers/openai-responses.d.ts +15 -0
  18. package/dist/types/providers/openai-shared.d.ts +20 -3
  19. package/dist/types/registry/oauth/perplexity.d.ts +1 -7
  20. package/dist/types/registry/oauth/types.d.ts +8 -0
  21. package/dist/types/stream.d.ts +2 -0
  22. package/dist/types/types.d.ts +3 -1
  23. package/dist/types/usage/openai-codex.d.ts +3 -1
  24. package/dist/types/usage.d.ts +11 -1
  25. package/dist/types/utils/block-symbols.d.ts +36 -0
  26. package/dist/types/utils/openai-http.d.ts +2 -0
  27. package/dist/types/utils/retry-after.d.ts +2 -0
  28. package/dist/types/utils/schema/wire.d.ts +4 -5
  29. package/dist/types/utils.d.ts +9 -0
  30. package/package.json +6 -6
  31. package/src/auth/sqlite-credential-store.ts +8 -33
  32. package/src/auth-broker/remote-store.ts +73 -8
  33. package/src/auth-broker/wire-schemas.ts +1 -0
  34. package/src/auth-gateway/index.ts +1 -0
  35. package/src/auth-gateway/server.ts +186 -74
  36. package/src/auth-gateway/session-state.ts +312 -0
  37. package/src/auth-storage.ts +146 -15
  38. package/src/error/body-error.ts +310 -0
  39. package/src/error/flags.ts +63 -13
  40. package/src/error/index.ts +1 -0
  41. package/src/error/retryable.ts +2 -0
  42. package/src/index.ts +1 -0
  43. package/src/oneshot-retry.ts +13 -3
  44. package/src/provider-session-state.ts +56 -0
  45. package/src/providers/amazon-bedrock.ts +20 -3
  46. package/src/providers/anthropic-messages-server.ts +104 -23
  47. package/src/providers/anthropic-signature.ts +5 -2
  48. package/src/providers/anthropic.ts +101 -15
  49. package/src/providers/aws-sigv4.ts +16 -5
  50. package/src/providers/cursor.ts +60 -10
  51. package/src/providers/devin.ts +82 -28
  52. package/src/providers/openai-chat-server.ts +4 -0
  53. package/src/providers/openai-codex/request-transformer.ts +36 -0
  54. package/src/providers/openai-codex-responses.ts +35 -12
  55. package/src/providers/openai-completions.ts +49 -12
  56. package/src/providers/openai-reasoning-fallback.ts +6 -6
  57. package/src/providers/openai-responses-server.ts +2 -1
  58. package/src/providers/openai-responses.ts +52 -4
  59. package/src/providers/openai-shared.ts +199 -51
  60. package/src/registry/oauth/perplexity.ts +94 -28
  61. package/src/registry/oauth/types.ts +9 -0
  62. package/src/stream.ts +23 -2
  63. package/src/types.ts +3 -0
  64. package/src/usage/claude.ts +33 -0
  65. package/src/usage/google-antigravity.ts +8 -2
  66. package/src/usage/openai-codex.ts +94 -11
  67. package/src/usage.ts +8 -1
  68. package/src/utils/block-symbols.ts +57 -0
  69. package/src/utils/http-inspector.ts +20 -0
  70. package/src/utils/openai-http.ts +39 -3
  71. package/src/utils/retry-after.ts +12 -0
  72. package/src/utils/schema/normalize.ts +3 -3
  73. package/src/utils/schema/stamps.ts +33 -45
  74. package/src/utils/schema/wire.ts +9 -7
  75. package/src/utils.ts +67 -22
@@ -14,7 +14,7 @@
14
14
  * captured response body for the strict-tools fallback and the responses
15
15
  * chain-state detectors, which regex over `error.message`.
16
16
  */
17
- import { fetchWithRetry, readSseJson, type SseEventObserver } from "@oh-my-pi/pi-utils";
17
+ import { fetchWithRetry, readSseJsonOrText, type SseEventObserver } from "@oh-my-pi/pi-utils";
18
18
  import * as AIError from "../error";
19
19
  import { OpenAIHttpError } from "../error";
20
20
 
@@ -68,6 +68,8 @@ export interface OpenAIStreamRequestInit {
68
68
  body: unknown;
69
69
  signal: AbortSignal;
70
70
  fetch?: FetchImpl;
71
+ /** Optional caller-specific gate composed with shared transport retry exclusions. */
72
+ shouldRetryResponse?: (response: Response, bodyText: string) => boolean | Promise<boolean>;
71
73
  /** Raw wire-frame observer (`onSseEvent` debug pipeline). */
72
74
  onSseEvent?: SseEventObserver;
73
75
  }
@@ -98,7 +100,9 @@ export async function postOpenAIStream<TEvent>(init: OpenAIStreamRequestInit): P
98
100
  // A proxy concurrency-admission 429 (`rate_limit_type: max_parallel_requests`)
99
101
  // surfaces immediately instead of being slept-and-retried here; session
100
102
  // recovery owns its backoff/fallback (issue #8854).
101
- shouldRetryResponse: (response, bodyText) => !isConcurrencyAdmissionRejection(response, bodyText),
103
+ shouldRetryResponse: async (response, bodyText) =>
104
+ !isConcurrencyAdmissionRejection(response, bodyText) &&
105
+ (init.shouldRetryResponse === undefined || (await init.shouldRetryResponse(response, bodyText))),
102
106
  // Bun's native fetch enforces a hard ~300s pre-response timeout (issue #2422).
103
107
  // Cold large-context streams legitimately exceed it; the caller's
104
108
  // `firstEventTimeoutMs`/`AbortSignal` already govern stuck requests.
@@ -113,12 +117,44 @@ export async function postOpenAIStream<TEvent>(init: OpenAIStreamRequestInit): P
113
117
  });
114
118
  }
115
119
  return {
116
- events: readSseJson<TEvent>(response.body, init.signal, init.onSseEvent),
120
+ events: decodeStream<TEvent>(response.body, init.signal, init.onSseEvent),
117
121
  response,
118
122
  requestId: response.headers.get("x-request-id"),
119
123
  };
120
124
  }
121
125
 
126
+ /**
127
+ * Consume `readSseJsonOrText` and turn a non-JSON `data:` frame into a
128
+ * classified in-band error. A reverse proxy that already committed to an HTTP
129
+ * 200 stream (so the status line can no longer carry the failure) answers with
130
+ * plain text — `data: 429 Too Many Requests`, an nginx throttle page — and
131
+ * that has to advance the fallback chain like a real 429 (body-error.ts).
132
+ * Frames that are not recognisable throttles rethrow the original parse error,
133
+ * preserving the pre-existing loud failure for genuinely malformed payloads.
134
+ * `readSseJsonOrText` also yields a frame that was a JSON-encoded *string* on the
135
+ * wire (a double-encoded proxy error page); it is not a usable event either, so
136
+ * it is classified the same way and then dropped — every consumer here already
137
+ * ignored a string chunk, the completions loop by its `typeof !== "object"` test.
138
+ */
139
+ async function* decodeStream<TEvent>(
140
+ body: ReadableStream<Uint8Array>,
141
+ signal: AbortSignal | undefined,
142
+ onSseEvent: SseEventObserver | undefined,
143
+ ): AsyncGenerator<TEvent> {
144
+ for await (const frame of readSseJsonOrText<TEvent>(body, signal, onSseEvent)) {
145
+ if (typeof frame === "string") {
146
+ const inBand = AIError.createInBandProviderErrorFromText(frame);
147
+ if (inBand) throw inBand;
148
+ // Not a recognisable throttle: reproduce the exact strict-parse failure the
149
+ // previous reader raised, so genuinely malformed payloads stay equally
150
+ // loud. A frame that parses again was a JSON string, not a malformed one.
151
+ JSON.parse(frame);
152
+ continue;
153
+ }
154
+ yield frame;
155
+ }
156
+ }
157
+
122
158
  /** Decode a non-2xx response into an {@link OpenAIHttpError} without consuming it twice. */
123
159
  export async function captureOpenAIHttpError(response: Response): Promise<AIError.OpenAIHttpError> {
124
160
  let bodyText: string | undefined;
@@ -1,5 +1,17 @@
1
+ import { retryResetTimezoneOffsetFor } from "@oh-my-pi/pi-catalog/compat/behavior";
2
+ import { extractRetryHint } from "@oh-my-pi/pi-utils";
3
+
1
4
  export type HeadersLike = Headers | Record<string, string | undefined> | undefined | null;
2
5
 
6
+ /** Extracts retry timing using the provider's catalog-declared timestamp timezone. */
7
+ export function extractProviderRetryHint(
8
+ provider: string | undefined,
9
+ message: string | undefined,
10
+ ): number | undefined {
11
+ const naiveResetTimezoneOffset = provider === undefined ? undefined : retryResetTimezoneOffsetFor(provider);
12
+ return extractRetryHint(undefined, message, { naiveResetTimezoneOffset });
13
+ }
14
+
3
15
  const RETRY_AFTER_HINT = "retry-after-ms=";
4
16
 
5
17
  export function formatErrorMessageWithRetryAfter(error: unknown, headers?: HeadersLike): string {
@@ -1877,9 +1877,9 @@ function inferStrictPrimitiveTypeFromEnumOrConst(node: Record<string, unknown>):
1877
1877
  }
1878
1878
 
1879
1879
  /**
1880
- * Per-schema-object memoization slot. The result of `tryEnforceStrictSchema`
1881
- * is stamped directly onto the input via `stamp(target, kStrictSchema, …)`
1882
- * so repeated calls (different providers, retries, batching) reuse the same
1880
+ * Per-schema-object memoization key. The result of `tryEnforceStrictSchema`
1881
+ * is memoized against the input via `stamp(target, kStrictSchema, …)` so
1882
+ * repeated calls (different providers, retries, batching) reuse the same
1883
1883
  * computed pair without re-walking the tree.
1884
1884
  */
1885
1885
  const kStrictSchema = Symbol("pi.schema.strict");
@@ -1,38 +1,39 @@
1
1
  /**
2
- * Symbol-keyed lazy memoization stamped directly onto the host object.
2
+ * Lazy memoization keyed by host object identity, held in a module-level
3
+ * weak side table.
3
4
  *
4
- * Faster than a module-level `WeakMap` in V8/JSC because the symbol slot is
5
- * resolved through the object's hidden class instead of a side-table hash
6
- * lookup. The slot is defined as a non-enumerable property so the stamp
7
- * does not leak through `{...spread}`, `Object.keys`, `JSON.stringify`, or
8
- * `toEqual`-style deep equality.
5
+ * The bookkeeping deliberately lives outside the host rather than in a
6
+ * non-enumerable symbol property: schema objects are caller-owned and may be
7
+ * sealed, frozen, or deep-frozen after their first traversal. A recursive
8
+ * freeze that enumerates with `Reflect.ownKeys` reaches symbol slots, so an
9
+ * on-host slot would be frozen along with the schema and every later write
10
+ * would throw. A side table is also invisible to `{...spread}`,
11
+ * `Object.keys`, `JSON.stringify`, and `toEqual`-style deep equality.
9
12
  *
10
- * Caveats: the stamp lives as long as the host object, even after callers
13
+ * Caveats: an entry lives as long as the host object, even after callers
11
14
  * release their references to the cached value — only use this for caches
12
- * whose lifetime should match the host. Frozen hosts cannot be stamped;
13
- * `define` silently skips them, so memoization/visit-tracking degrades to
14
- * best-effort (recompute on every call, no cycle protection) instead of
15
- * throwing.
15
+ * whose lifetime should match the host.
16
16
  */
17
- function define<T extends object>(target: T, key: symbol, value: unknown): void {
18
- if (Object.isFrozen(target)) return;
19
- Object.defineProperty(target, key, { value, writable: true, configurable: true });
20
- }
17
+ const memos = new WeakMap<object, Map<symbol, unknown>>();
21
18
 
22
19
  export function stamp<T extends object, V>(target: T, key: symbol, compute: (target: T) => V): V {
23
- const slot = target as Record<symbol, V | undefined>;
24
- const existing = slot[key];
20
+ let slots = memos.get(target);
21
+ if (!slots) {
22
+ slots = new Map();
23
+ memos.set(target, slots);
24
+ }
25
+ const existing = slots.get(key) as V | undefined;
25
26
  if (existing !== undefined) return existing;
26
27
  const value = compute(target);
27
- define(target, key, value);
28
+ slots.set(key, value);
28
29
  return value;
29
30
  }
30
31
 
31
32
  /**
32
- * Epoch-keyed cycle guard. Cheaper than `WeakSet` for recursive traversal
33
- * because the marker is a single property slot on the host object, written
34
- * once and overwritten in place on every subsequent traversal — the hidden
35
- * class transitions once per object lifetime, not per traversal.
33
+ * Epoch-keyed cycle guard. Cheaper than a per-call `WeakSet` for recursive
34
+ * traversal because the marker is a single side-table entry per host object,
35
+ * written once and overwritten in place on every subsequent traversal — no
36
+ * per-walk allocation.
36
37
  *
37
38
  * Usage:
38
39
  * function walk(node, epoch = epochNext()) {
@@ -40,7 +41,7 @@ export function stamp<T extends object, V>(target: T, key: symbol, compute: (tar
40
41
  * for (const child of node.children) walk(child, epoch);
41
42
  * }
42
43
  */
43
- const kEpoch = Symbol("pi.schema.epoch");
44
+ const epochs = new WeakMap<object, number>();
44
45
  let __epoch = 0;
45
46
 
46
47
  export function epochNext(): number {
@@ -53,11 +54,9 @@ export function epochNext(): number {
53
54
  * subsequent call within the same epoch.
54
55
  */
55
56
  export function once<T extends object>(target: T, epoch: number): boolean {
56
- const slot = target as Record<symbol, number | undefined>;
57
- const cur = slot[kEpoch];
57
+ const cur = epochs.get(target);
58
58
  if (cur !== undefined && cur >= epoch) return false;
59
- if (cur === undefined) define(target, kEpoch, epoch);
60
- else slot[kEpoch] = epoch;
59
+ epochs.set(target, epoch);
61
60
  return true;
62
61
  }
63
62
 
@@ -65,13 +64,9 @@ export function once<T extends object>(target: T, epoch: number): boolean {
65
64
  * Counter-based path tracker. Use when a traversal needs to distinguish
66
65
  * "currently on the recursion path" from "previously visited" — i.e. cycle
67
66
  * detection that throws while still allowing DAG sharing. Increment on
68
- * entry, decrement on exit; the slot returns to 0 after a balanced walk so
67
+ * entry, decrement on exit; the counter returns to 0 after a balanced walk so
69
68
  * subsequent top-level calls see a fresh state without any reset.
70
69
  *
71
- * Unlike a `WeakSet` with `seen.delete(...)`, the property is never deleted
72
- * — only incremented and decremented — so the host object's hidden class
73
- * is never invalidated.
74
- *
75
70
  * Usage:
76
71
  * function walk(node) {
77
72
  * if (!enter(node)) throw new Error("cycle");
@@ -79,7 +74,7 @@ export function once<T extends object>(target: T, epoch: number): boolean {
79
74
  * finally { exit(node); }
80
75
  * }
81
76
  */
82
- const kDepth = Symbol("pi.schema.depth");
77
+ const depths = new WeakMap<object, number>();
83
78
 
84
79
  /**
85
80
  * Returns `true` on first entry, `false` if `target` is already on the
@@ -89,21 +84,14 @@ const kDepth = Symbol("pi.schema.depth");
89
84
  * make every later top-level walk of the same object misreport a cycle.
90
85
  */
91
86
  export function enter<T extends object>(target: T): boolean {
92
- const slot = target as Record<symbol, number | undefined>;
93
- const cur = slot[kDepth];
94
- if (cur === undefined) {
95
- define(target, kDepth, 1);
96
- return true;
97
- }
98
- if (cur !== 0) return false;
99
- slot[kDepth] = 1;
87
+ const cur = depths.get(target);
88
+ if (cur !== undefined && cur !== 0) return false;
89
+ depths.set(target, 1);
100
90
  return true;
101
91
  }
102
92
 
103
93
  export function exit<T extends object>(target: T): void {
104
- const slot = target as Record<symbol, number | undefined>;
105
- const cur = slot[kDepth];
106
- // Frozen targets never received the kDepth stamp in `enter` — nothing to unwind.
94
+ const cur = depths.get(target);
107
95
  if (cur === undefined) return;
108
- slot[kDepth] = cur - 1;
96
+ depths.set(target, cur - 1);
109
97
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Type } from "@oh-my-pi/omptype";
10
+ import { structuredCloneJSON } from "@oh-my-pi/pi-utils";
10
11
  import type { Tool, TSchema } from "../../types";
11
12
  import { upgradeJsonSchemaTo202012 } from "./draft";
12
13
  import { stamp } from "./stamps";
@@ -107,7 +108,7 @@ function arkJsonAstToWire(value: unknown): unknown {
107
108
  return {};
108
109
  }
109
110
 
110
- /** Symbol-stamped caches keyed by schema object identity. */
111
+ /** `stamp` cache keys; the entries themselves live in a weak side table keyed by schema identity. */
111
112
  const kJsonWireSchema = Symbol("pi.schema.json.wire");
112
113
  const kArkWireSchema = Symbol("pi.schema.ark.wire");
113
114
  const kStrippedSchema = Symbol("pi.schema.descriptions.stripped");
@@ -602,7 +603,9 @@ export function toolWireSchema(tool: Tool): Record<string, unknown> {
602
603
  const params: TSchema = tool.parameters;
603
604
  if (isArkSchema(params)) return arkToWireSchema(params);
604
605
  return stamp(params as Record<string, unknown>, kJsonWireSchema, p => {
605
- const raw = isArkJsonAst(p) ? arkJsonAstToWire(p) : p;
606
+ // Legacy schemas may carry non-cloneable metadata (helper functions); the JSON
607
+ // fallback drops it the same way the wire serializer always has.
608
+ const raw = isArkJsonAst(p) ? arkJsonAstToWire(p) : structuredCloneJSON(p);
606
609
  const upgraded = upgradeJsonSchemaTo202012(raw) as Record<string, unknown>;
607
610
  return postProcessJsonSchema(upgraded);
608
611
  });
@@ -663,10 +666,9 @@ function stripSchemaDescriptionsInPlace(node: unknown): void {
663
666
 
664
667
  /**
665
668
  * Return a deep clone of `schema` with every `description` annotation removed.
666
- * The result is memoized on the input via a non-enumerable symbol (`stamp`) so
667
- * repeated provider requests reuse the same stripped object; the input is never
668
- * mutated, so the stamped `toolWireSchema` cache stays intact for
669
- * system-prompt/UI rendering.
669
+ * The result is memoized against the input (`stamp`) so repeated provider
670
+ * requests reuse the same stripped object; the input is never mutated, so the
671
+ * memoized `toolWireSchema` result stays intact for system-prompt/UI rendering.
670
672
  */
671
673
  export function stripSchemaDescriptions(schema: Record<string, unknown>): Record<string, unknown> {
672
674
  return stamp(schema, kStrippedSchema, source => {
@@ -682,7 +684,7 @@ export function stripSchemaDescriptions(schema: Record<string, unknown>): Record
682
684
  * Used when the full tool catalog is rendered into the system prompt instead, so
683
685
  * the descriptions ride the wire once (in the prompt) rather than duplicated on
684
686
  * every tool definition. Parameters are resolved to wire JSON Schema and cloned,
685
- * leaving the original tool objects and the stamped schema cache untouched.
687
+ * leaving the original tool objects and the memoized schema cache untouched.
686
688
  */
687
689
  export function stripToolDescriptions(tools: readonly Tool[]): Tool[] {
688
690
  return tools.map(tool => ({
package/src/utils.ts CHANGED
@@ -45,8 +45,10 @@ export function normalizeResponsesToolCallId(
45
45
  id: string,
46
46
  itemPrefix: ResponsesToolItemIdPrefix = "fc",
47
47
  ): { callId: string; itemId: string } {
48
- const [callId, itemId] = id.split("|");
49
- if (callId && itemId) {
48
+ const sep = id.search(/[\n|]/);
49
+ if (sep > 0) {
50
+ const callId = id.slice(0, sep);
51
+ const itemId = id.slice(sep + 1);
50
52
  return { callId, itemId: normalizeResponsesItemId(itemId, itemPrefix) };
51
53
  }
52
54
  const hash = Bun.hash(id).toString(36);
@@ -324,6 +326,15 @@ export function stripUnpairedOpenAIResponsesComputerReasoningIdsForReplay(items:
324
326
  * Returns `undefined` for hidden-empty turns that only contain reasoning and an
325
327
  * empty assistant message, allowing callers to rebuild visible transcript
326
328
  * history instead of replaying stale native state.
329
+ *
330
+ * When the turn does carry replayable output, whitespace-only assistant
331
+ * messages are dropped rather than replayed. gpt-5.6 Codex ends a turn whose
332
+ * text all landed in the `commentary` phase with an empty `final_answer`
333
+ * message; replaying that item verbatim seeds the next turn with an empty slot
334
+ * the model then fills - `""` → `"\n\n"` → stray words → non-Latin residue -
335
+ * and each contaminated item is replayed in turn, so the drift compounds until
336
+ * the visible answer collapses. Codex CLI replays the empty item too
337
+ * (openai/codex#32389); this is the layer where omp can refuse to.
327
338
  */
328
339
  export function sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
329
340
  items: Array<Record<string, unknown>>,
@@ -331,34 +342,68 @@ export function sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
331
342
  ): ResponseInput | undefined {
332
343
  const sanitized = sanitizeOpenAIResponsesHistoryItemsForReplay(items, options);
333
344
  let hasReplayableAssistantOutput = false;
345
+ let hasEmptyAssistantMessage = false;
334
346
 
335
347
  for (const item of sanitized) {
336
348
  if (item.type === "reasoning") continue;
337
- if (item.type !== "message" || item.role !== "assistant") {
338
- hasReplayableAssistantOutput = true;
339
- break;
349
+ if (isEmptyAssistantMessage(item)) {
350
+ hasEmptyAssistantMessage = true;
351
+ continue;
340
352
  }
341
- if (typeof item.content === "string") {
342
- if (NON_WHITESPACE_RE.test(item.content)) {
343
- hasReplayableAssistantOutput = true;
344
- break;
345
- }
353
+ hasReplayableAssistantOutput = true;
354
+ }
355
+
356
+ if (!hasReplayableAssistantOutput) return undefined;
357
+ if (!hasEmptyAssistantMessage) return sanitized;
358
+ return dropEmptyAssistantMessagesForReplay(sanitized);
359
+ }
360
+
361
+ function isEmptyAssistantMessage(item: OpenAIResponsesReplayItem): boolean {
362
+ if (item.type !== "message" || item.role !== "assistant") return false;
363
+ if (typeof item.content === "string") return !NON_WHITESPACE_RE.test(item.content);
364
+ for (const part of item.content) {
365
+ if (part.type === "output_text" && NON_WHITESPACE_RE.test(part.text)) return false;
366
+ if (part.type === "refusal" && NON_WHITESPACE_RE.test(part.refusal)) return false;
367
+ }
368
+ return true;
369
+ }
370
+
371
+ /**
372
+ * Remove whitespace-only assistant messages plus any reasoning item left with
373
+ * no output to introduce: a reasoning item is only meaningful ahead of the
374
+ * output it produced, and a dangling one is rejected by the Responses API when
375
+ * it carries an id.
376
+ */
377
+ function dropEmptyAssistantMessagesForReplay(items: ResponseInput): ResponseInput {
378
+ const kept: ResponseInput = [];
379
+ let pendingReasoning: ResponseInput = [];
380
+ let removedAssistantMessage = false;
381
+ const flushReasoning = (): void => {
382
+ kept.push(...pendingReasoning);
383
+ pendingReasoning = [];
384
+ };
385
+ for (const item of items) {
386
+ if (isOpenAIResponsesClientInputBoundary(item as unknown as Record<string, unknown>)) {
387
+ pendingReasoning = [];
388
+ removedAssistantMessage = false;
389
+ kept.push(item);
346
390
  continue;
347
391
  }
348
- for (const part of item.content) {
349
- if (part.type === "output_text" && NON_WHITESPACE_RE.test(part.text)) {
350
- hasReplayableAssistantOutput = true;
351
- break;
352
- }
353
- if (part.type === "refusal" && NON_WHITESPACE_RE.test(part.refusal)) {
354
- hasReplayableAssistantOutput = true;
355
- break;
356
- }
392
+ if (item.type === "reasoning") {
393
+ if (removedAssistantMessage) pendingReasoning = [];
394
+ removedAssistantMessage = false;
395
+ pendingReasoning.push(item);
396
+ continue;
357
397
  }
358
- if (hasReplayableAssistantOutput) break;
398
+ if (isEmptyAssistantMessage(item)) {
399
+ removedAssistantMessage = true;
400
+ continue;
401
+ }
402
+ flushReasoning();
403
+ removedAssistantMessage = false;
404
+ kept.push(item);
359
405
  }
360
-
361
- return hasReplayableAssistantOutput ? sanitized : undefined;
406
+ return kept;
362
407
  }
363
408
 
364
409
  /**