@oh-my-pi/pi-ai 17.2.3 → 17.2.5

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 (35) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/types/auth-storage.d.ts +8 -0
  3. package/dist/types/dialect/examples.d.ts +10 -2
  4. package/dist/types/dialect/inventory.d.ts +6 -9
  5. package/dist/types/dialect/rendering.d.ts +9 -0
  6. package/dist/types/dialect/types.d.ts +0 -1
  7. package/dist/types/error/flags.d.ts +5 -0
  8. package/dist/types/providers/openai-codex-responses.d.ts +15 -1
  9. package/dist/types/providers/openai-shared.d.ts +24 -6
  10. package/dist/types/providers/transform-messages.d.ts +1 -1
  11. package/dist/types/types.d.ts +6 -4
  12. package/dist/types/utils/harmony-leak.d.ts +9 -0
  13. package/dist/types/utils/schema/typescript.d.ts +8 -2
  14. package/package.json +4 -4
  15. package/src/auth-broker/discover.ts +2 -1
  16. package/src/auth-storage.ts +149 -15
  17. package/src/dialect/examples.ts +24 -12
  18. package/src/dialect/gemini.ts +17 -31
  19. package/src/dialect/harmony.ts +1 -2
  20. package/src/dialect/inventory.ts +20 -63
  21. package/src/dialect/rendering.ts +54 -0
  22. package/src/dialect/types.ts +0 -1
  23. package/src/error/flags.ts +8 -0
  24. package/src/providers/anthropic.ts +78 -33
  25. package/src/providers/cursor.ts +6 -3
  26. package/src/providers/openai-codex-responses.ts +191 -36
  27. package/src/providers/openai-completions.ts +7 -1
  28. package/src/providers/openai-responses.ts +5 -6
  29. package/src/providers/openai-shared.ts +114 -40
  30. package/src/providers/transform-messages.ts +1 -1
  31. package/src/stream.ts +2 -2
  32. package/src/types.ts +9 -8
  33. package/src/utils/harmony-leak.ts +12 -0
  34. package/src/utils/schema/typescript.ts +21 -7
  35. package/src/utils.ts +1 -1
@@ -69,6 +69,7 @@ import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, tool
69
69
  import { notifyRawSseEvent } from "../utils/sse-debug";
70
70
  import { compactGrammarDefinition } from "./grammar";
71
71
  import {
72
+ type CodexLiteShapedBody,
72
73
  type CodexReasoningContext,
73
74
  type CodexRequestOptions,
74
75
  type InputItem,
@@ -83,6 +84,7 @@ import type {
83
84
  ResponseFunctionToolCall,
84
85
  ResponseInput,
85
86
  ResponseInputContent,
87
+ ResponseOutputItem,
86
88
  ResponseOutputMessage,
87
89
  ResponseReasoningItem,
88
90
  ResponseStatus,
@@ -95,6 +97,7 @@ import {
95
97
  appendReasoningSummaryPart,
96
98
  appendReasoningSummaryPartDone,
97
99
  appendReasoningSummaryTextDelta,
100
+ appendResponsesImageResult,
98
101
  appendResponsesToolResultMessages,
99
102
  applyOpenAIServiceTier,
100
103
  applyReasoningSummaryDone,
@@ -105,7 +108,7 @@ import {
105
108
  createSequentialCutoffSummaryState,
106
109
  encodeResponsesToolCallId,
107
110
  encodeTextSignatureV1,
108
- escapeReplayedClientText,
111
+ escapeReplayedControlTokens,
109
112
  finalizeCustomToolCallInputDone,
110
113
  finalizeMessageText,
111
114
  finalizePendingResponsesToolCalls,
@@ -165,6 +168,17 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
165
168
  onModerationMetadata?: (metadata: unknown) => void;
166
169
  }
167
170
 
171
+ /** Raw V2 compaction body accepted by the Codex transport selector. */
172
+ export interface OpenAICodexCompactionBody extends CodexLiteShapedBody {
173
+ model: string;
174
+ [key: string]: unknown;
175
+ }
176
+
177
+ /** Transport controls for a provider-native Codex V2 compaction stream. */
178
+ export interface OpenAICodexCompactionStreamOptions extends OpenAICodexResponsesOptions {
179
+ apiKey: string;
180
+ }
181
+
168
182
  /** Inputs for synthesizing Codex request identity outside the normal stream path. */
169
183
  export interface OpenAICodexCompatibilityMetadataOptions {
170
184
  sessionId?: string;
@@ -359,7 +373,8 @@ type CodexEventItem =
359
373
  | ResponseOutputMessage
360
374
  | ResponseFunctionToolCall
361
375
  | ResponseCustomToolCall
362
- | ResponseComputerToolCall;
376
+ | ResponseComputerToolCall
377
+ | ResponseOutputItem.ImageGenerationCall;
363
378
  type CodexOutputBlock =
364
379
  | ThinkingContent
365
380
  | TextContent
@@ -1360,12 +1375,16 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
1360
1375
  };
1361
1376
  }
1362
1377
 
1363
- async function buildCodexRequestContext(
1378
+ function createCodexRequestContext(
1364
1379
  model: Model<"openai-codex-responses">,
1365
- context: Context,
1380
+ transformedBody: RequestBody,
1366
1381
  options: OpenAICodexResponsesOptions | undefined,
1367
- output: AssistantMessage,
1368
- ): Promise<CodexRequestContext> {
1382
+ contextOptions: {
1383
+ isolateCompactionTransport: boolean;
1384
+ startNewTurn?: boolean;
1385
+ turnStartedAtUnixMs?: number;
1386
+ },
1387
+ ): CodexRequestContext {
1369
1388
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
1370
1389
  if (!apiKey) {
1371
1390
  throw new AIError.MissingApiKeyError(model.provider);
@@ -1374,15 +1393,13 @@ async function buildCodexRequestContext(
1374
1393
  const accountId = getCodexAccountId(apiKey);
1375
1394
  const baseUrl = model.baseUrl || CODEX_BASE_URL;
1376
1395
  const url = resolveCodexResponsesUrl(baseUrl);
1377
- const promptCacheKey = getOpenAIPromptCacheKey(options);
1396
+
1378
1397
  const transportSessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
1379
1398
  const codexClientVersion = CODEX_CLIENT_VERSION;
1380
- const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
1381
-
1382
1399
  const requestHeaders = { ...(model.headers ?? {}), ...(options?.headers ?? {}) };
1383
1400
  const rawRequestDump: RawHttpRequestDump = {
1384
1401
  provider: model.provider,
1385
- api: output.api,
1402
+ api: model.api,
1386
1403
  model: model.id,
1387
1404
  method: "POST",
1388
1405
  url,
@@ -1390,7 +1407,10 @@ async function buildCodexRequestContext(
1390
1407
  };
1391
1408
 
1392
1409
  const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
1393
- const isolatedTransportState = options?.codexCompaction ? createCodexProviderSessionState() : undefined;
1410
+ const isolatedTransportState =
1411
+ contextOptions.isolateCompactionTransport && options?.codexCompaction
1412
+ ? createCodexProviderSessionState()
1413
+ : undefined;
1394
1414
  const transportProviderSessionState = isolatedTransportState ?? providerSessionState;
1395
1415
  const responsesLite = resolveCodexResponsesLite(model, options?.responsesLite);
1396
1416
  const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, apiKey, baseUrl, responsesLite);
@@ -1413,20 +1433,14 @@ async function buildCodexRequestContext(
1413
1433
  websocketState.turnState = sharedWebsocketState.turnState;
1414
1434
  websocketState.modelsEtag = sharedWebsocketState.modelsEtag;
1415
1435
  }
1416
- const withinTurnContinuation = isCodexWithinTurnContinuation(context);
1417
1436
  const metadataSessionId = transportSessionId ?? crypto.randomUUID();
1418
1437
  const metadataSession = getOrCreateCodexMetadataSessionState(metadataSessionId, providerSessionState);
1419
1438
  const compaction = options?.codexCompaction;
1420
1439
  const requestKind: OpenAICodexRequestKind = compaction ? "compaction" : "turn";
1421
- const startNewTurn = resolveCodexStartNewTurn(
1422
- metadataSession,
1423
- requestKind,
1424
- compaction,
1425
- compaction ? undefined : !withinTurnContinuation,
1426
- );
1440
+ const startNewTurn = resolveCodexStartNewTurn(metadataSession, requestKind, compaction, contextOptions.startNewTurn);
1427
1441
  if (websocketState && startNewTurn) {
1428
- // Codex scopes turn-state to one turn. Mid-turn compaction and tool-loop
1429
- // follow-ups preserve it; new user or compaction turns start without it.
1442
+ // Codex scopes turn-state to one turn. Mid-turn compaction preserves it;
1443
+ // a pre-turn or standalone compaction starts without it.
1430
1444
  websocketState.turnState = undefined;
1431
1445
  }
1432
1446
  const requestMetadata = createCodexRequestMetadata(metadataSession, requestKind, {
@@ -1435,7 +1449,7 @@ async function buildCodexRequestContext(
1435
1449
  ? startNewTurn || !metadataSession.turnId
1436
1450
  ? Date.now()
1437
1451
  : undefined
1438
- : getCodexTurnStartedAtUnixMs(context),
1452
+ : contextOptions.turnStartedAtUnixMs,
1439
1453
  clientMetadata: transformedBody.client_metadata,
1440
1454
  parentTurnId: options?.parentTurnId,
1441
1455
  compaction,
@@ -1459,6 +1473,20 @@ async function buildCodexRequestContext(
1459
1473
  };
1460
1474
  }
1461
1475
 
1476
+ async function buildCodexRequestContext(
1477
+ model: Model<"openai-codex-responses">,
1478
+ context: Context,
1479
+ options: OpenAICodexResponsesOptions | undefined,
1480
+ ): Promise<CodexRequestContext> {
1481
+ const promptCacheKey = getOpenAIPromptCacheKey(options);
1482
+ const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
1483
+ return createCodexRequestContext(model, transformedBody, options, {
1484
+ isolateCompactionTransport: true,
1485
+ startNewTurn: options?.codexCompaction ? undefined : !isCodexWithinTurnContinuation(context),
1486
+ turnStartedAtUnixMs: options?.codexCompaction ? undefined : getCodexTurnStartedAtUnixMs(context),
1487
+ });
1488
+ }
1489
+
1462
1490
  /** @internal Exported for tests. */
1463
1491
  export async function buildTransformedCodexRequestBody(
1464
1492
  model: Model<"openai-codex-responses">,
@@ -1566,6 +1594,127 @@ async function openInitialCodexEventStream(
1566
1594
  }
1567
1595
  return openCodexSseTransport(model, requestContext, requestSetup, options, websocketState, transformedBody);
1568
1596
  }
1597
+
1598
+ function toCodexRequestBody(body: OpenAICodexCompactionBody): RequestBody {
1599
+ const request: RequestBody = { model: body.model };
1600
+ for (const key in body) {
1601
+ if (key !== "model") request[key] = body[key];
1602
+ }
1603
+ return request;
1604
+ }
1605
+
1606
+ /**
1607
+ * Open a provider-native V2 compaction stream through Codex's WebSocket-first
1608
+ * transport, replaying WebSocket transport failures over SSE.
1609
+ */
1610
+ export async function openCodexCompactionEventStream(
1611
+ model: Model<"openai-codex-responses">,
1612
+ body: OpenAICodexCompactionBody,
1613
+ options: OpenAICodexCompactionStreamOptions,
1614
+ ): Promise<AsyncGenerator<Record<string, unknown>>> {
1615
+ const requestSetup = createRequestSetup(options);
1616
+ let requestContext: CodexRequestContext;
1617
+ let initial: {
1618
+ eventStream: AsyncGenerator<Record<string, unknown>>;
1619
+ requestBodyForState: RequestBody;
1620
+ transport: CodexTransport;
1621
+ };
1622
+ try {
1623
+ requestContext = createCodexRequestContext(model, toCodexRequestBody(body), options, {
1624
+ isolateCompactionTransport: false,
1625
+ });
1626
+ initial = await openInitialCodexEventStream(model, options, requestSetup, requestContext);
1627
+ } catch (error) {
1628
+ requestSetup.requestAbortController.abort();
1629
+ throw error;
1630
+ }
1631
+
1632
+ if (requestContext.websocketState) {
1633
+ requestContext.websocketState.lastTransport = initial.transport;
1634
+ // The compaction request may use the existing append baseline, but the
1635
+ // replacement history makes that baseline stale for the next normal turn.
1636
+ resetCodexWebSocketAppendState(requestContext.websocketState);
1637
+ }
1638
+ return streamCodexCompactionEvents(model, options, requestSetup, requestContext, initial);
1639
+ }
1640
+
1641
+ async function* streamCodexCompactionEvents(
1642
+ model: Model<"openai-codex-responses">,
1643
+ options: OpenAICodexCompactionStreamOptions,
1644
+ requestSetup: CodexRequestSetup,
1645
+ requestContext: CodexRequestContext,
1646
+ initial: {
1647
+ eventStream: AsyncGenerator<Record<string, unknown>>;
1648
+ requestBodyForState: RequestBody;
1649
+ transport: CodexTransport;
1650
+ },
1651
+ ): AsyncGenerator<Record<string, unknown>> {
1652
+ let completed = false;
1653
+ const websocketState = requestContext.websocketState;
1654
+ const previousTurnState = websocketState?.turnState;
1655
+ const previousModelsEtag = websocketState?.modelsEtag;
1656
+ try {
1657
+ if (initial.transport === "websocket") {
1658
+ // Do not expose a WebSocket attempt until it finishes: an SSE replay
1659
+ // must replace, not extend, any partial compaction output.
1660
+ const bufferedEvents: Array<Record<string, unknown>> = [];
1661
+ try {
1662
+ for await (const event of initial.eventStream) bufferedEvents.push(event);
1663
+ } catch (error) {
1664
+ if (options.signal?.aborted || !(error instanceof CodexWebSocketTransportError)) {
1665
+ throw error;
1666
+ }
1667
+ const state = requestContext.websocketState;
1668
+ if (state) recordCodexWebSocketFailure(state, true);
1669
+ const fallback = await openCodexSseTransport(model, requestContext, requestSetup, options, state);
1670
+ if (state) state.lastTransport = fallback.transport;
1671
+ yield* drainCodexCompactionEvents(fallback.eventStream, requestContext.websocketState);
1672
+ completed = true;
1673
+ return;
1674
+ }
1675
+ // Apply metadata only once the WebSocket attempt succeeded: a discarded
1676
+ // attempt must not leak its `x-codex-turn-state` into the session.
1677
+ for (const event of bufferedEvents) {
1678
+ applyCodexCompactionResponseMetadata(requestContext.websocketState, event);
1679
+ yield event;
1680
+ }
1681
+ } else {
1682
+ yield* drainCodexCompactionEvents(initial.eventStream, requestContext.websocketState);
1683
+ }
1684
+ completed = true;
1685
+ } finally {
1686
+ if (!completed) {
1687
+ requestSetup.requestAbortController.abort();
1688
+ if (websocketState) {
1689
+ websocketState.turnState = previousTurnState;
1690
+ websocketState.modelsEtag = previousModelsEtag;
1691
+ }
1692
+ }
1693
+ }
1694
+ }
1695
+
1696
+ /**
1697
+ * Capture `x-codex-turn-state`/`x-models-etag` refreshes carried by a
1698
+ * `response.metadata` frame so a mid-turn compaction leaves the live session on
1699
+ * the latest turn state, matching the normal Codex stream processor.
1700
+ */
1701
+ function applyCodexCompactionResponseMetadata(
1702
+ state: CodexWebSocketSessionState | undefined,
1703
+ event: Record<string, unknown>,
1704
+ ): void {
1705
+ if (!state || event.type !== "response.metadata") return;
1706
+ updateCodexSessionMetadataFromHeaders(state, toCodexHeaders(event.headers));
1707
+ }
1708
+
1709
+ async function* drainCodexCompactionEvents(
1710
+ events: AsyncGenerator<Record<string, unknown>>,
1711
+ state: CodexWebSocketSessionState | undefined,
1712
+ ): AsyncGenerator<Record<string, unknown>> {
1713
+ for await (const event of events) {
1714
+ applyCodexCompactionResponseMetadata(state, event);
1715
+ yield event;
1716
+ }
1717
+ }
1569
1718
  async function openCodexWebSocketTransport(
1570
1719
  model: Model<"openai-codex-responses">,
1571
1720
  options: OpenAICodexResponsesOptions | undefined,
@@ -2143,6 +2292,7 @@ class CodexStreamProcessor {
2143
2292
  const rawItem = rawEvent.item;
2144
2293
  if (!rawItem || typeof rawItem !== "object") return;
2145
2294
  const item = structuredCloneJSON(rawItem) as CodexEventItem;
2295
+ if (item.type === "image_generation_call" && item.result) item.status = "completed";
2146
2296
  runtime.nativeOutputItems.push(item as unknown as Record<string, unknown>);
2147
2297
 
2148
2298
  // Match the finalization to the OPEN ITEM that started this block, not the
@@ -2155,6 +2305,12 @@ class CodexStreamProcessor {
2155
2305
  const block = entry?.block ?? null;
2156
2306
  const contentIndex = entry?.contentIndex ?? output.content.length - 1;
2157
2307
 
2308
+ if (item.type === "image_generation_call" && item.result) {
2309
+ appendResponsesImageResult(output, stream, item.result);
2310
+ runtime.closeOpenItem(entry);
2311
+ return;
2312
+ }
2313
+
2158
2314
  if (item.type === "reasoning" && block?.type === "thinking") {
2159
2315
  this.#flushSummaryDeltas(entry);
2160
2316
  block.thinking = finalizeReasoningThinking(
@@ -2279,12 +2435,15 @@ class CodexStreamProcessor {
2279
2435
  resetCodexWebSocketAppendState(state);
2280
2436
  } else {
2281
2437
  state.lastRequest = structuredCloneJSON(runtime.requestBodyForState);
2282
- if (responseId) {
2438
+ const replayableResponseItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
2439
+ structuredCloneJSON(runtime.nativeOutputItems),
2440
+ );
2441
+ if (responseId && replayableResponseItems) {
2283
2442
  state.lastResponseId = responseId;
2284
- state.lastResponseItems = stripInputItemIds(structuredCloneJSON(runtime.nativeOutputItems));
2443
+ state.lastResponseItems = replayableResponseItems;
2285
2444
  state.canAppend = rawEvent.type === "response.done" || rawEvent.type === "response.completed";
2286
2445
  } else {
2287
- // Without a response id the append baseline cannot be trusted.
2446
+ // Without both a response id and replayable output, the append baseline cannot be trusted.
2288
2447
  state.canAppend = false;
2289
2448
  }
2290
2449
  }
@@ -2710,7 +2869,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
2710
2869
  let requestContext: CodexRequestContext | undefined;
2711
2870
 
2712
2871
  try {
2713
- requestContext = await buildCodexRequestContext(model, context, options, output);
2872
+ requestContext = await buildCodexRequestContext(model, context, options);
2714
2873
  const initialTransport = await openInitialCodexEventStream(model, options, requestSetup, requestContext);
2715
2874
  const runtime = new CodexStreamRuntime({
2716
2875
  ...initialTransport,
@@ -3005,14 +3164,6 @@ export function getOpenAICodexTransportDetails(
3005
3164
  };
3006
3165
  }
3007
3166
 
3008
- function stripInputItemIds(items: Array<Record<string, unknown>>): InputItem[] {
3009
- return items.map(item => {
3010
- if (item.id == null) return item as InputItem;
3011
- const { id: _id, ...rest } = item;
3012
- return rest as InputItem;
3013
- });
3014
- }
3015
-
3016
3167
  const codexDiagnosticsTextEncoder = new TextEncoder();
3017
3168
 
3018
3169
  function jsonByteLength(value: unknown): number {
@@ -4198,6 +4349,9 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
4198
4349
  };
4199
4350
 
4200
4351
  const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
4352
+ // gpt-5.x reject raw Harmony control-token spellings anywhere in replayed
4353
+ // input, including the model's own tool-call arguments (#6913).
4354
+ const escapeControlTokens = isHarmonyDialectModel(model);
4201
4355
  let msgIndex = 0;
4202
4356
  // Track call_ids that originated as custom tool calls so paired tool-result
4203
4357
  // messages can be replayed as `custom_tool_call_output` rather than
@@ -4227,7 +4381,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
4227
4381
  knownCallIds.add(item.call_id);
4228
4382
  }
4229
4383
  }
4230
- messages.push(...(isHarmonyDialectModel(model) ? escapeReplayedClientText(replayItems) : replayItems));
4384
+ messages.push(...(escapeControlTokens ? escapeReplayedControlTokens(replayItems) : replayItems));
4231
4385
  msgIndex += 1;
4232
4386
  continue;
4233
4387
  }
@@ -4253,10 +4407,11 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
4253
4407
  if (historyItems) {
4254
4408
  const sanitizedHistoryItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(historyItems);
4255
4409
  if (sanitizedHistoryItems) {
4256
- const replayItems =
4410
+ const rawReplayItems =
4257
4411
  model.supportsComputerUse === true
4258
4412
  ? sanitizedHistoryItems
4259
4413
  : unrollCodexComputerItems(sanitizedHistoryItems, model.compat.supportsImageDetailOriginal);
4414
+ const replayItems = escapeControlTokens ? escapeReplayedControlTokens(rawReplayItems) : rawReplayItems;
4260
4415
  for (const item of replayItems) {
4261
4416
  if (item.type === "custom_tool_call") {
4262
4417
  customCallIds.add(item.call_id);
@@ -4294,7 +4449,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
4294
4449
  ? sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(convertedOutputItems)
4295
4450
  : convertedOutputItems;
4296
4451
  if (outputItems.length > 0) {
4297
- messages.push(...outputItems);
4452
+ messages.push(...(escapeControlTokens ? escapeReplayedControlTokens(outputItems) : outputItems));
4298
4453
  }
4299
4454
  msgIndex += 1;
4300
4455
  continue;
@@ -757,7 +757,13 @@ const streamOpenAICompletionsOnce = (
757
757
  disableStrictTools = true;
758
758
  openaiStream = await createCompletionsStream("none");
759
759
  } else {
760
- if (!shouldRetryWithoutStrictTools(error, capturedErrorResponse, appliedStrictTools, context.tools)) {
760
+ if (
761
+ !shouldRetryWithoutStrictTools(error, capturedErrorResponse, {
762
+ model,
763
+ strictToolsApplied: appliedStrictTools,
764
+ tools: context.tools,
765
+ })
766
+ ) {
761
767
  throw error;
762
768
  }
763
769
  // Remember the rejection for the rest of the session so every
@@ -610,12 +610,11 @@ const streamOpenAIResponsesOnce = (
610
610
  strictRetryAvailable &&
611
611
  !requestSignal.aborted &&
612
612
  (compiledGrammarTooLarge ||
613
- shouldRetryWithoutStrictTools(
614
- error,
615
- capturedErrorResponse,
616
- activeStrictToolsApplied,
617
- context.tools,
618
- ));
613
+ shouldRetryWithoutStrictTools(error, capturedErrorResponse, {
614
+ model,
615
+ strictToolsApplied: activeStrictToolsApplied,
616
+ tools: context.tools,
617
+ }));
619
618
  if (canRetryWithoutStrictTools) {
620
619
  strictRetryAvailable = false;
621
620
  forceDisableStrictTools = true;
@@ -78,7 +78,11 @@ import {
78
78
  kStreamingPartialJson,
79
79
  } from "../utils/block-symbols";
80
80
  import type { AssistantMessageEventStream } from "../utils/event-stream";
81
- import { escapeHarmonyControlTokens, isHarmonyDialectModel } from "../utils/harmony-leak";
81
+ import {
82
+ escapeHarmonyControlTokens,
83
+ escapeHarmonyControlTokensInJson,
84
+ isHarmonyDialectModel,
85
+ } from "../utils/harmony-leak";
82
86
  import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
83
87
  import { getOpenRouterHeaders } from "../utils/openrouter-headers";
84
88
  import { isForcedToolChoice } from "../utils/tool-choice";
@@ -329,9 +333,7 @@ export function applyOpenAIServiceTier(
329
333
  model: Pick<Model, "provider" | "api" | "id">,
330
334
  ): void {
331
335
  if (!shouldSendServiceTier(serviceTier, model)) return;
332
- if (serviceTier === "flex" || serviceTier === "scale" || serviceTier === "priority") {
333
- params.service_tier = serviceTier;
334
- }
336
+ params.service_tier = serviceTier;
335
337
  }
336
338
 
337
339
  /**
@@ -1181,21 +1183,46 @@ export function isCompiledGrammarTooLargeStrictError(
1181
1183
  );
1182
1184
  }
1183
1185
 
1186
+ interface StrictToolsRetryContext {
1187
+ model: OpenAIModelIdentity;
1188
+ strictToolsApplied: boolean;
1189
+ tools: Tool[] | undefined;
1190
+ }
1191
+
1192
+ /** Decide whether an OpenAI-family request should retry once with non-strict tools. */
1184
1193
  export function shouldRetryWithoutStrictTools(
1185
1194
  error: unknown,
1186
1195
  capturedErrorResponse: CapturedHttpErrorResponse | undefined,
1187
- strictToolsApplied: boolean,
1188
- tools: Tool[] | undefined,
1196
+ context: StrictToolsRetryContext,
1189
1197
  ): boolean {
1198
+ const { model, strictToolsApplied, tools } = context;
1190
1199
  if (!tools || tools.length === 0 || !strictToolsApplied) return false;
1191
1200
  const status = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
1192
1201
  if (status !== 400 && status !== 422) return false;
1202
+ const errorMessage = error instanceof Error ? error.message.trim() : "";
1193
1203
  const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText]
1194
1204
  .filter((value): value is string => typeof value === "string" && value.trim().length > 0)
1195
1205
  .join("\n");
1196
- 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(
1197
- messageParts,
1198
- );
1206
+ if (
1207
+ /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(
1208
+ messageParts,
1209
+ )
1210
+ ) {
1211
+ return true;
1212
+ }
1213
+ if (model.provider !== "openrouter" || !/^(?:400\s+)?Provider returned error$/i.test(errorMessage)) return false;
1214
+ const body = capturedErrorResponse?.bodyJson;
1215
+ if (body && typeof body === "object" && "error" in body) {
1216
+ const errorBody = body.error;
1217
+ if (errorBody && typeof errorBody === "object" && "metadata" in errorBody) {
1218
+ const metadata = errorBody.metadata;
1219
+ if (metadata && typeof metadata === "object" && "raw" in metadata) {
1220
+ const raw = metadata.raw;
1221
+ if (typeof raw === "string" ? raw.trim().length > 0 : raw != null) return false;
1222
+ }
1223
+ }
1224
+ }
1225
+ return true;
1199
1226
  }
1200
1227
 
1201
1228
  function normalizeOpenAIStableId(value: string | undefined, maxLength: number, hashPrefix: string): string | undefined {
@@ -1611,39 +1638,71 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
1611
1638
  }
1612
1639
 
1613
1640
  /**
1614
- * Escape reserved Harmony control tokens in the client-boundary text of
1615
- * replayed Responses input items — user/developer/system message text and
1616
- * tool-result output. Model-owned items (assistant output, reasoning, tool-call
1617
- * arguments) carry no client data and are returned untouched.
1641
+ * Escape reserved Harmony control tokens in the free-text fields of replayed
1642
+ * Responses input items: user/developer/system text, tool-result output,
1643
+ * assistant message text, and tool-call payloads.
1644
+ *
1645
+ * Tool-call items are covered deliberately. The original #6913 fix skipped
1646
+ * model-owned items on the theory that they carry no client data — but a model
1647
+ * legitimately writing *about* Harmony samples `<|channel|>` etc. into its own
1648
+ * `function_call.arguments`, and a full-transcript replay (stale or blocked
1649
+ * previous_response_id, provider fallback) feeds those bytes back as input,
1650
+ * which gpt-5.x reject with invalid_prompt / "Request blocked", permanently
1651
+ * poisoning the session. `arguments` is a JSON document, so it uses
1652
+ * {@link escapeHarmonyControlTokensInJson} to stay parseable. Reasoning items
1653
+ * are left untouched: `encrypted_content` is opaque and plaintext summaries
1654
+ * are never rendered back into the prompt.
1618
1655
  *
1619
1656
  * Native history replay pushes stored `providerPayload` items straight onto the
1620
1657
  * wire, bypassing {@link convertResponsesInputContent}; without this a stored
1621
1658
  * `input_text` carrying `<|channel|>analysis` still reaches gpt-5.x raw (#6913).
1622
1659
  * Callers gate on {@link isHarmonyDialectModel}. Items are copied, not mutated.
1623
1660
  */
1624
- export function escapeReplayedClientText(items: ResponseInput): ResponseInput {
1661
+ export function escapeReplayedControlTokens(items: ResponseInput): ResponseInput {
1625
1662
  return items.map(item => {
1626
1663
  if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
1627
1664
  return typeof item.output === "string" ? { ...item, output: escapeHarmonyControlTokens(item.output) } : item;
1628
1665
  }
1666
+ if (item.type === "function_call") {
1667
+ return typeof item.arguments === "string"
1668
+ ? { ...item, arguments: escapeHarmonyControlTokensInJson(item.arguments) }
1669
+ : item;
1670
+ }
1671
+ if (item.type === "custom_tool_call") {
1672
+ return typeof item.input === "string" ? { ...item, input: escapeHarmonyControlTokens(item.input) } : item;
1673
+ }
1629
1674
  // EasyInputMessage may omit `type` (`{ role, content }`); the responses
1630
1675
  // server persists it verbatim, so treat missing type as a message too.
1631
1676
  const isTypedMessage = item.type === "message" || item.type === undefined;
1632
- if (isTypedMessage && "role" in item && "content" in item) {
1633
- const role = item.role;
1634
- if (role !== "user" && role !== "developer" && role !== "system") return item;
1635
- const content = item.content;
1636
- if (typeof content === "string") {
1637
- return { ...item, content: escapeHarmonyControlTokens(content) };
1638
- }
1639
- if (Array.isArray(content)) {
1677
+ if (!isTypedMessage || !("role" in item) || !("content" in item)) return item;
1678
+ if (item.role === "assistant") {
1679
+ // Assistant output text is model-owned but equally capable of carrying
1680
+ // control tokens as data. `status` discriminates ResponseOutputMessage.
1681
+ if ("status" in item && Array.isArray(item.content)) {
1640
1682
  return {
1641
1683
  ...item,
1642
- content: content.map(part =>
1643
- part.type === "input_text" ? { ...part, text: escapeHarmonyControlTokens(part.text) } : part,
1684
+ content: item.content.map(part =>
1685
+ part.type === "output_text"
1686
+ ? { ...part, text: escapeHarmonyControlTokens(part.text) }
1687
+ : part.type === "refusal"
1688
+ ? { ...part, refusal: escapeHarmonyControlTokens(part.refusal) }
1689
+ : part,
1644
1690
  ),
1645
1691
  };
1646
1692
  }
1693
+ return item;
1694
+ }
1695
+ const content = item.content;
1696
+ if (typeof content === "string") {
1697
+ return { ...item, content: escapeHarmonyControlTokens(content) };
1698
+ }
1699
+ if (Array.isArray(content)) {
1700
+ return {
1701
+ ...item,
1702
+ content: content.map(part =>
1703
+ part.type === "input_text" ? { ...part, text: escapeHarmonyControlTokens(part.text) } : part,
1704
+ ),
1705
+ };
1647
1706
  }
1648
1707
  return item;
1649
1708
  });
@@ -1708,7 +1767,7 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1708
1767
  customToolWireNameMap,
1709
1768
  options.model.supportsComputerUse === true,
1710
1769
  );
1711
- messages.push(...(escapeControlTokens ? escapeReplayedClientText(replayItems) : replayItems));
1770
+ messages.push(...(escapeControlTokens ? escapeReplayedControlTokens(replayItems) : replayItems));
1712
1771
  knownCallIds = collectKnownCallIds(messages);
1713
1772
  for (const id of collectCustomCallIds(messages)) customCallIds.add(id);
1714
1773
  for (const id of collectComputerCallIds(messages)) computerCallIds.add(id);
@@ -1769,10 +1828,16 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1769
1828
  )
1770
1829
  : undefined;
1771
1830
  if (nativeReplayEnabled && sanitizedHistoryItems) {
1831
+ // Model-owned replay items can carry reserved control-token
1832
+ // spellings as data (the model writing *about* Harmony); escape the
1833
+ // transport copy just like client turns.
1834
+ const wireItems = escapeControlTokens
1835
+ ? escapeReplayedControlTokens(sanitizedHistoryItems)
1836
+ : sanitizedHistoryItems;
1772
1837
  if (providerPayload?.dt) {
1773
- messages.push(...sanitizedHistoryItems);
1838
+ messages.push(...wireItems);
1774
1839
  } else {
1775
- messages.splice(0, messages.length, ...sanitizedHistoryItems);
1840
+ messages.splice(0, messages.length, ...wireItems);
1776
1841
  customCallIds.clear();
1777
1842
  computerCallIds.clear();
1778
1843
  }
@@ -1801,7 +1866,7 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1801
1866
  ? sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(convertedOutputItems)
1802
1867
  : convertedOutputItems;
1803
1868
  if (outputItems.length === 0) continue;
1804
- messages.push(...outputItems);
1869
+ messages.push(...(escapeControlTokens ? escapeReplayedControlTokens(outputItems) : outputItems));
1805
1870
  } else if (msg.role === "toolResult") {
1806
1871
  appendResponsesToolResultMessages(
1807
1872
  messages,
@@ -2394,6 +2459,26 @@ export function computerCallMetadata(item: ResponseComputerToolCall): ComputerTo
2394
2459
  };
2395
2460
  }
2396
2461
 
2462
+ /** Append a native Responses image result and emit its completion event. */
2463
+ export function appendResponsesImageResult(
2464
+ output: AssistantMessage,
2465
+ stream: AssistantMessageEventStream,
2466
+ result: string,
2467
+ ): void {
2468
+ const image: ImageContent = {
2469
+ type: "image",
2470
+ data: result,
2471
+ mimeType: parseImageMetadata(Buffer.from(result, "base64"))?.mimeType ?? "image/png",
2472
+ };
2473
+ output.content.push(image);
2474
+ stream.push({
2475
+ type: "image_end",
2476
+ contentIndex: output.content.length - 1,
2477
+ content: image,
2478
+ partial: output,
2479
+ });
2480
+ }
2481
+
2397
2482
  export async function processResponsesStream<TApi extends Api>(
2398
2483
  openaiStream: AsyncIterable<ResponseStreamEvent>,
2399
2484
  output: AssistantMessage,
@@ -2907,18 +2992,7 @@ export async function processResponsesStream<TApi extends Api>(
2907
2992
  closeOpenItem(event.output_index, item.id, entry, item.call_id, prefixedFunctionCallItemKey(item.call_id));
2908
2993
  stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
2909
2994
  } else if (item.type === "image_generation_call" && item.status === "completed" && item.result) {
2910
- const image: ImageContent = {
2911
- type: "image",
2912
- data: item.result,
2913
- mimeType: parseImageMetadata(Buffer.from(item.result, "base64"))?.mimeType ?? "image/png",
2914
- };
2915
- output.content.push(image);
2916
- stream.push({
2917
- type: "image_end",
2918
- contentIndex: output.content.length - 1,
2919
- content: image,
2920
- partial: output,
2921
- });
2995
+ appendResponsesImageResult(output, stream, item.result);
2922
2996
  }
2923
2997
  } else if (terminalEvent) {
2924
2998
  const response = terminalEvent.response;