@oh-my-pi/pi-ai 16.0.0 → 16.0.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.
@@ -686,6 +686,14 @@ function resetOutputState(output: AssistantMessage): void {
686
686
  output.stopReason = "stop";
687
687
  output.stopDetails = undefined;
688
688
  }
689
+ async function applyCodexPayloadReplacement<T extends Record<string, unknown>>(
690
+ model: Model<"openai-codex-responses">,
691
+ options: OpenAICodexResponsesOptions | undefined,
692
+ payload: T,
693
+ ): Promise<T> {
694
+ const replacementPayload = await options?.onPayload?.(payload, model);
695
+ return replacementPayload !== undefined ? (replacementPayload as T) : payload;
696
+ }
689
697
 
690
698
  function removeTransientBlockIndices(output: AssistantMessage): void {
691
699
  for (const block of output.content) {
@@ -742,7 +750,6 @@ async function buildCodexRequestContext(
742
750
  const promptCacheKey = resolveCodexPromptCacheKey(options);
743
751
  const transportSessionId = resolveCodexTransportSessionId(options);
744
752
  const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
745
- options?.onPayload?.(transformedBody);
746
753
 
747
754
  const requestHeaders = { ...(model.headers ?? {}), ...(options?.headers ?? {}) };
748
755
  const rawRequestDump: RawHttpRequestDump = {
@@ -878,6 +885,8 @@ async function openInitialCodexEventStream(
878
885
  while (true) {
879
886
  try {
880
887
  return await openCodexWebSocketTransport(
888
+ model,
889
+ options,
881
890
  requestContext,
882
891
  requestSetup,
883
892
  websocketState,
@@ -910,6 +919,8 @@ async function openInitialCodexEventStream(
910
919
  return openCodexSseTransport(model, requestContext, requestSetup, options, websocketState, transformedBody);
911
920
  }
912
921
  async function openCodexWebSocketTransport(
922
+ model: Model<"openai-codex-responses">,
923
+ options: OpenAICodexResponsesOptions | undefined,
913
924
  requestContext: CodexRequestContext,
914
925
  requestSetup: CodexRequestSetup,
915
926
  websocketState: CodexWebSocketSessionState,
@@ -923,7 +934,7 @@ async function openCodexWebSocketTransport(
923
934
  const chainedBody = buildCodexChainedRequestBody(requestContext.transformedBody, websocketState);
924
935
  // WebSocket frames cannot carry per-request HTTP headers, so the Responses
925
936
  // Lite marker rides in `client_metadata` on every `response.create`.
926
- const websocketRequest: Record<string, unknown> = {
937
+ const websocketRequest = await applyCodexPayloadReplacement(model, options, {
927
938
  type: "response.create",
928
939
  ...chainedBody,
929
940
  ...(requestContext.responsesLite
@@ -934,7 +945,7 @@ async function openCodexWebSocketTransport(
934
945
  },
935
946
  }
936
947
  : {}),
937
- };
948
+ });
938
949
  const websocketHeaders = createCodexHeaders(
939
950
  requestContext.requestHeaders,
940
951
  requestContext.accountId,
@@ -945,6 +956,7 @@ async function openCodexWebSocketTransport(
945
956
  requestContext.responsesLite,
946
957
  );
947
958
  const requestBodyForState = structuredCloneJSON(requestContext.transformedBody);
959
+ requestContext.rawRequestDump.body = websocketRequest;
948
960
  logCodexDebug("codex websocket request", {
949
961
  url: toWebSocketUrl(requestContext.url),
950
962
  model: requestContext.transformedBody.model,
@@ -1022,8 +1034,9 @@ async function openCodexSseTransport(
1022
1034
  ),
1023
1035
  );
1024
1036
  };
1025
- recordCodexWebSocketRequestStats(state, body);
1026
- return { eventStream: await open(body), requestBodyForState: structuredCloneJSON(body), transport: "sse" };
1037
+ const wireBody = await applyCodexPayloadReplacement(model, options, body);
1038
+ recordCodexWebSocketRequestStats(state, wireBody);
1039
+ return { eventStream: await open(wireBody), requestBodyForState: structuredCloneJSON(wireBody), transport: "sse" };
1027
1040
  }
1028
1041
 
1029
1042
  async function reopenCodexWebSocketRuntimeStream(
@@ -1033,6 +1046,8 @@ async function reopenCodexWebSocketRuntimeStream(
1033
1046
  ): Promise<void> {
1034
1047
  try {
1035
1048
  const next = await openCodexWebSocketTransport(
1049
+ context.model,
1050
+ context.options,
1036
1051
  context.requestContext,
1037
1052
  context.requestSetup,
1038
1053
  state,
@@ -186,6 +186,105 @@ function serializeToolArguments(value: unknown): string {
186
186
  return "{}";
187
187
  }
188
188
 
189
+ function isUnsafeToolArgumentKey(key: string): boolean {
190
+ return key === "__proto__" || key === "constructor" || key === "prototype";
191
+ }
192
+
193
+ function isStreamingArgumentObject(value: unknown): value is Record<string, unknown> {
194
+ return value !== null && typeof value === "object" && !Array.isArray(value);
195
+ }
196
+
197
+ function cloneStreamingArgumentValue(value: unknown): unknown {
198
+ if (Array.isArray(value)) {
199
+ return value.map(cloneStreamingArgumentValue);
200
+ }
201
+ if (isStreamingArgumentObject(value)) {
202
+ return mergeStreamingArgumentObjects(undefined, value);
203
+ }
204
+ return value;
205
+ }
206
+
207
+ function streamingArgumentValuesEqual(left: unknown, right: unknown): boolean {
208
+ if (left === right) return true;
209
+ if (Array.isArray(left) && Array.isArray(right)) {
210
+ if (left.length !== right.length) return false;
211
+ for (let i = 0; i < left.length; i++) {
212
+ if (!streamingArgumentValuesEqual(left[i], right[i])) return false;
213
+ }
214
+ return true;
215
+ }
216
+ if (isStreamingArgumentObject(left) && isStreamingArgumentObject(right)) {
217
+ let leftKeys = 0;
218
+ for (const key in left) {
219
+ if (!Object.hasOwn(left, key) || isUnsafeToolArgumentKey(key)) continue;
220
+ leftKeys++;
221
+ if (!Object.hasOwn(right, key) || !streamingArgumentValuesEqual(left[key], right[key])) return false;
222
+ }
223
+ let rightKeys = 0;
224
+ for (const key in right) {
225
+ if (!Object.hasOwn(right, key) || isUnsafeToolArgumentKey(key)) continue;
226
+ rightKeys++;
227
+ }
228
+ return leftKeys === rightKeys;
229
+ }
230
+ return false;
231
+ }
232
+
233
+ function streamingArgumentArrayStartsWith(value: unknown[], prefix: unknown[]): boolean {
234
+ if (prefix.length > value.length) return false;
235
+ for (let i = 0; i < prefix.length; i++) {
236
+ if (!streamingArgumentValuesEqual(value[i], prefix[i])) return false;
237
+ }
238
+ return true;
239
+ }
240
+
241
+ function mergeStreamingArgumentArrays(prev: unknown[], fragment: unknown[]): unknown[] {
242
+ if (streamingArgumentArrayStartsWith(fragment, prev)) {
243
+ return fragment.map(cloneStreamingArgumentValue);
244
+ }
245
+ if (streamingArgumentArrayStartsWith(prev, fragment)) {
246
+ return prev.map(cloneStreamingArgumentValue);
247
+ }
248
+ const merged = prev.map(cloneStreamingArgumentValue);
249
+ for (const value of fragment) {
250
+ merged.push(cloneStreamingArgumentValue(value));
251
+ }
252
+ return merged;
253
+ }
254
+
255
+ function mergeStreamingArgumentValues(prev: unknown, fragment: unknown): unknown {
256
+ if (typeof prev === "string" && typeof fragment === "string") {
257
+ return fragment.startsWith(prev) ? fragment : prev + fragment;
258
+ }
259
+ if (Array.isArray(prev) && Array.isArray(fragment)) {
260
+ return mergeStreamingArgumentArrays(prev, fragment);
261
+ }
262
+ if (isStreamingArgumentObject(prev) && isStreamingArgumentObject(fragment)) {
263
+ return mergeStreamingArgumentObjects(prev, fragment);
264
+ }
265
+ return cloneStreamingArgumentValue(fragment);
266
+ }
267
+
268
+ function mergeStreamingArgumentObjects(
269
+ prev: Record<string, unknown> | undefined,
270
+ fragment: Record<string, unknown>,
271
+ ): Record<string, unknown> {
272
+ const merged: Record<string, unknown> = {};
273
+ if (prev) {
274
+ for (const key in prev) {
275
+ if (!Object.hasOwn(prev, key) || isUnsafeToolArgumentKey(key)) continue;
276
+ merged[key] = cloneStreamingArgumentValue(prev[key]);
277
+ }
278
+ }
279
+ for (const key in fragment) {
280
+ if (!Object.hasOwn(fragment, key) || isUnsafeToolArgumentKey(key)) continue;
281
+ merged[key] = Object.hasOwn(merged, key)
282
+ ? mergeStreamingArgumentValues(merged[key], fragment[key])
283
+ : cloneStreamingArgumentValue(fragment[key]);
284
+ }
285
+ return merged;
286
+ }
287
+
189
288
  /**
190
289
  * Check if conversation messages contain tool calls or tool results.
191
290
  * This is needed because Anthropic (via proxy) requires the tools param
@@ -981,31 +1080,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
981
1080
  // OpenAI JSON-string contract. Most chunks carry the complete object in one delta,
982
1081
  // but cannot rely on that: replacing per-chunk drops earlier keys (and earlier
983
1082
  // string content for the same key) when the host fragments the args across deltas.
984
- // Shallow-merge into the accumulated object; for shared string keys, detect
985
- // cumulative-vs-delta semantics with `startsWith` so we neither duplicate cumulative
986
- // payloads nor lose delta fragments. Degenerates to the previous "last wins"
987
- // behaviour for the common single-chunk shape (no prior value to merge with).
1083
+ // Deep-merge into the accumulated object. Strings and arrays detect
1084
+ // cumulative-vs-delta semantics by prefix, nested objects merge by key, and
1085
+ // prototype-polluting keys are ignored before storing or comparing values.
988
1086
  //
989
1087
  // `delta` stays empty here: emitting `JSON.stringify(rawArgs)` per chunk feeds
990
1088
  // downstream concat-based accumulators (proxy.ts, openai-chat-server,
991
1089
  // openai-responses-server, anthropic-messages-server) an invalid sequence like
992
1090
  // `{"input":"a"}{"input":"b"}`. The merged object is flushed as a single
993
1091
  // concat-safe delta in `finishToolCallBlock` before `toolcall_end` instead.
994
- const prev =
995
- block.partialArgs &&
996
- typeof block.partialArgs === "object" &&
997
- !Array.isArray(block.partialArgs)
998
- ? (block.partialArgs as Record<string, unknown>)
999
- : undefined;
1000
- const merged: Record<string, unknown> = prev ? { ...prev } : {};
1001
- for (const [key, value] of Object.entries(rawArgs)) {
1002
- const prevValue = merged[key];
1003
- if (typeof prevValue === "string" && typeof value === "string") {
1004
- merged[key] = value.startsWith(prevValue) ? value : prevValue + value;
1005
- } else {
1006
- merged[key] = value;
1007
- }
1008
- }
1092
+ const prev = isStreamingArgumentObject(block.partialArgs) ? block.partialArgs : undefined;
1093
+ const merged = mergeStreamingArgumentObjects(prev, rawArgs);
1009
1094
  block.partialArgs = merged;
1010
1095
  block.arguments = merged;
1011
1096
  }
@@ -1077,6 +1162,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1077
1162
  output.stopReason = "toolUse";
1078
1163
  }
1079
1164
 
1165
+ if (model.provider === "ollama" && output.stopReason === "length" && !hasVisibleCompletionContent(output)) {
1166
+ output.stopReason = "error";
1167
+ output.errorMessage = EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE;
1168
+ }
1080
1169
  const firstEventTimeoutError = abortTracker.getLocalAbortReason();
1081
1170
  if (firstEventTimeoutError) {
1082
1171
  throw firstEventTimeoutError;
@@ -2111,6 +2200,19 @@ function shouldRetryWithoutStrictTools(
2111
2200
  );
2112
2201
  }
2113
2202
 
2203
+ const NON_WHITESPACE_RE = /\S/;
2204
+
2205
+ function hasVisibleCompletionContent(message: AssistantMessage): boolean {
2206
+ for (const block of message.content) {
2207
+ if (block.type === "toolCall") return true;
2208
+ if (block.type === "text" && NON_WHITESPACE_RE.test(block.text)) return true;
2209
+ }
2210
+ return false;
2211
+ }
2212
+
2213
+ const EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE =
2214
+ "Model returned no content: prompt filled the context window; raise Ollama num_ctx or shorten the prompt.";
2215
+
2114
2216
  function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
2115
2217
  stopReason: StopReason;
2116
2218
  errorMessage?: string;
@@ -490,26 +490,37 @@ export async function processResponsesStream<TApi extends Api>(
490
490
  // function_call deltas interleaved, and a singleton `current` reference would
491
491
  // fold them into the wrong block and drop arguments on every call but the last.
492
492
  //
493
- // llama.cpp's `to_json_oaicompat_resp` (issue #2015) compounds this: `output_item.added`
494
- // for function_call/custom_tool_call carries `item.call_id` but no `item.id` and no
495
- // `output_index`, while the matching `function_call_arguments.delta` carries
496
- // `item_id = "fc_<call_id>"`. Registering function-call items by `call_id` as a
497
- // secondary key lets the delta lookup find the right block on hosts that emit one
498
- // identifier but not the other.
493
+ // OpenAI-compatible hosts can compound this by omitting `item.id` and
494
+ // `output_index` on `output_item.added` while routing later argument deltas to
495
+ // either the bare `call_id` or a synthesized `fc_<call_id>` item id. Register
496
+ // both keys so each delta reaches its own block instead of falling back to the
497
+ // most recently added parallel call.
499
498
  const openItemsByOutputIndex = new Map<number, StreamingItem>();
500
499
  const openItemsByItemId = new Map<string, StreamingItem>();
500
+ const openItemsByPrefixedCallId = new Map<string, StreamingItem>();
501
501
  let lastOpenItem: StreamingItem | null = null;
502
502
  const openItemsInOrder: StreamingItem[] = [];
503
503
 
504
+ const prefixedFunctionCallItemKey = (callId: string | undefined): string | undefined =>
505
+ callId ? `fc_${callId}` : undefined;
506
+
504
507
  const registerOpenItem = (
505
508
  outputIndex: number | undefined,
506
509
  itemId: string | undefined,
507
510
  entry: StreamingItem,
508
511
  alternateItemKey?: string,
512
+ prefixedAlternateItemKey?: string,
509
513
  ): void => {
510
514
  if (typeof outputIndex === "number") openItemsByOutputIndex.set(outputIndex, entry);
511
515
  if (itemId) openItemsByItemId.set(itemId, entry);
512
516
  if (alternateItemKey && alternateItemKey !== itemId) openItemsByItemId.set(alternateItemKey, entry);
517
+ if (
518
+ prefixedAlternateItemKey &&
519
+ prefixedAlternateItemKey !== itemId &&
520
+ prefixedAlternateItemKey !== alternateItemKey
521
+ ) {
522
+ openItemsByPrefixedCallId.set(prefixedAlternateItemKey, entry);
523
+ }
513
524
  openItemsInOrder.push(entry);
514
525
  lastOpenItem = entry;
515
526
  };
@@ -527,11 +538,36 @@ export async function processResponsesStream<TApi extends Api>(
527
538
  };
528
539
  const hasOpenItemKey = (event: { output_index?: number; item_id?: string }): boolean =>
529
540
  typeof event.output_index === "number" || event.item_id !== undefined;
541
+ const lookupOpenToolCallAlias = (
542
+ event: { output_index?: number; item_id?: string },
543
+ type: "function_call" | "custom_tool_call",
544
+ ): StreamingItem | undefined => {
545
+ if (typeof event.output_index === "number") {
546
+ const byOutputIndex = openItemsByOutputIndex.get(event.output_index);
547
+ if (byOutputIndex) return byOutputIndex;
548
+ // A lossy host (llama.cpp/Ollama, issue #2015) can omit `output_index` on
549
+ // `output_item.added` while still stamping the spec-required field on the
550
+ // delta. The index was never registered, so fall through to the prefixed
551
+ // alias / exact item-id maps instead of dropping to `lastOpenItem`.
552
+ }
553
+ if (event.item_id) {
554
+ // Prefixed call-id aliases share the same wire namespace as real call ids.
555
+ // Argument/input events can use the prefixed form, while final
556
+ // output_item.done events below use exact call ids; keep aliases in a
557
+ // separate map so a real `call_id: "fc_x"` cannot overwrite the alias
558
+ // for `call_id: "x"`.
559
+ const alias = openItemsByPrefixedCallId.get(event.item_id);
560
+ if (alias?.item.type === type) return alias;
561
+ const exact = openItemsByItemId.get(event.item_id);
562
+ if (exact) return exact;
563
+ }
564
+ return lookupOpenItem(event);
565
+ };
530
566
  const lookupOpenFunctionCallItem = (event: {
531
567
  output_index?: number;
532
568
  item_id?: string;
533
569
  }): StreamingItem | undefined => {
534
- if (hasOpenItemKey(event)) return lookupOpenItem(event);
570
+ if (hasOpenItemKey(event)) return lookupOpenToolCallAlias(event, "function_call");
535
571
  for (const candidate of openItemsInOrder) {
536
572
  if (
537
573
  candidate.item.type === "function_call" &&
@@ -548,10 +584,19 @@ export async function processResponsesStream<TApi extends Api>(
548
584
  itemId: string | undefined,
549
585
  entry: StreamingItem | undefined,
550
586
  alternateItemKey?: string,
587
+ prefixedAlternateItemKey?: string,
551
588
  ): void => {
552
589
  if (typeof outputIndex === "number") openItemsByOutputIndex.delete(outputIndex);
553
590
  if (itemId) openItemsByItemId.delete(itemId);
554
591
  if (alternateItemKey && alternateItemKey !== itemId) openItemsByItemId.delete(alternateItemKey);
592
+ if (
593
+ prefixedAlternateItemKey &&
594
+ prefixedAlternateItemKey !== itemId &&
595
+ prefixedAlternateItemKey !== alternateItemKey &&
596
+ openItemsByPrefixedCallId.get(prefixedAlternateItemKey) === entry
597
+ ) {
598
+ openItemsByPrefixedCallId.delete(prefixedAlternateItemKey);
599
+ }
555
600
  if (entry) {
556
601
  const index = openItemsInOrder.indexOf(entry);
557
602
  if (index >= 0) openItemsInOrder.splice(index, 1);
@@ -591,7 +636,13 @@ export async function processResponsesStream<TApi extends Api>(
591
636
  partialJson: item.arguments || "",
592
637
  };
593
638
  output.content.push(block);
594
- registerOpenItem(event.output_index, item.id, { item, block }, item.call_id);
639
+ registerOpenItem(
640
+ event.output_index,
641
+ item.id,
642
+ { item, block },
643
+ item.call_id,
644
+ prefixedFunctionCallItemKey(item.call_id),
645
+ );
595
646
  stream.push({ type: "toolcall_start", contentIndex: contentIndexOf(block), partial: output });
596
647
  } else if (item.type === "custom_tool_call") {
597
648
  const block: StreamingToolCallBlock = {
@@ -609,7 +660,13 @@ export async function processResponsesStream<TApi extends Api>(
609
660
  partialJson: item.input ?? "",
610
661
  };
611
662
  output.content.push(block);
612
- registerOpenItem(event.output_index, item.id, { item, block }, item.call_id);
663
+ registerOpenItem(
664
+ event.output_index,
665
+ item.id,
666
+ { item, block },
667
+ item.call_id,
668
+ prefixedFunctionCallItemKey(item.call_id),
669
+ );
613
670
  stream.push({ type: "toolcall_start", contentIndex: contentIndexOf(block), partial: output });
614
671
  }
615
672
  } else if (event.type === "response.reasoning_summary_part.added") {
@@ -739,7 +796,7 @@ export async function processResponsesStream<TApi extends Api>(
739
796
  delete (block as { lastParseLen?: number }).lastParseLen;
740
797
  }
741
798
  } else if (event.type === "response.custom_tool_call_input.delta") {
742
- const entry = lookupOpenItem(event);
799
+ const entry = lookupOpenToolCallAlias(event, "custom_tool_call");
743
800
  if (entry?.item.type === "custom_tool_call" && entry.block.type === "toolCall") {
744
801
  const block = entry.block;
745
802
  block.partialJson += event.delta;
@@ -752,7 +809,7 @@ export async function processResponsesStream<TApi extends Api>(
752
809
  });
753
810
  }
754
811
  } else if (event.type === "response.custom_tool_call_input.done") {
755
- const entry = lookupOpenItem(event);
812
+ const entry = lookupOpenToolCallAlias(event, "custom_tool_call");
756
813
  if (entry?.item.type === "custom_tool_call" && entry.block.type === "toolCall") {
757
814
  entry.block.partialJson = event.input;
758
815
  entry.block.arguments = { input: event.input };
@@ -842,7 +899,7 @@ export async function processResponsesStream<TApi extends Api>(
842
899
  output.content.push(toolCall);
843
900
  contentIndex = output.content.length - 1;
844
901
  }
845
- closeOpenItem(event.output_index, item.id, entry, item.call_id);
902
+ closeOpenItem(event.output_index, item.id, entry, item.call_id, prefixedFunctionCallItemKey(item.call_id));
846
903
  stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
847
904
  } else if (item.type === "custom_tool_call") {
848
905
  const block = entry?.block.type === "toolCall" ? entry.block : undefined;
@@ -866,7 +923,7 @@ export async function processResponsesStream<TApi extends Api>(
866
923
  output.content.push(toolCall);
867
924
  contentIndex = output.content.length - 1;
868
925
  }
869
- closeOpenItem(event.output_index, item.id, entry, item.call_id);
926
+ closeOpenItem(event.output_index, item.id, entry, item.call_id, prefixedFunctionCallItemKey(item.call_id));
870
927
  stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
871
928
  }
872
929
  } else if (event.type === "response.completed" || event.type === "response.incomplete") {
@@ -34,7 +34,13 @@ import {
34
34
  import { postOpenAIStream } from "../utils/openai-http";
35
35
  import { notifyProviderResponse } from "../utils/provider-response";
36
36
  import { callWithCopilotModelRetry } from "../utils/retry";
37
- import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
37
+ import {
38
+ adaptSchemaForStrict,
39
+ findStrictToolSchemaViolation,
40
+ NO_STRICT,
41
+ sanitizeSchemaForOpenAIResponses,
42
+ toolWireSchema,
43
+ } from "../utils/schema";
38
44
  import { mapToOpenAIResponsesToolChoice, type OpenAIResponsesToolChoice } from "../utils/tool-choice";
39
45
  import {
40
46
  buildCopilotDynamicHeaders,
@@ -398,7 +404,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
398
404
  } = createRequestSetup(model, context, apiKey, options?.headers, options?.initiatorOverride, routingSessionId);
399
405
  const premiumRequestsTotal = copilotPremiumRequests;
400
406
  const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
401
- const { params, trailingScaffoldingItems } = buildParams(model, context, options, providerSessionState);
407
+ const builtParams = buildParams(model, context, options, providerSessionState);
408
+ const params = builtParams.params;
409
+ const { trailingScaffoldingItems } = builtParams;
402
410
  if (isOpenAIResponsesStatefulEnabled(options, baseUrl) && routingSessionId && providerSessionState) {
403
411
  chainState = getOpenAIResponsesChainState(providerSessionState, model, routingSessionId);
404
412
  if (!chainState.disabled) {
@@ -406,7 +414,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
406
414
  params.store = true;
407
415
  }
408
416
  }
409
- const chained: OpenAIResponsesChainedParams =
417
+ let chained: OpenAIResponsesChainedParams =
410
418
  chainState && !chainState.disabled
411
419
  ? buildOpenAIResponsesChainedParams(params, trailingScaffoldingItems, chainState)
412
420
  : { params };
@@ -416,8 +424,14 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
416
424
  options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
417
425
  const requestTimeoutMs =
418
426
  firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
419
- options?.onPayload?.(params);
420
427
  const requestUrl = `${(baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "")}/responses`;
428
+ const applyPayloadReplacement = async (requestParams: OpenAIResponsesSamplingParams) => {
429
+ const replacementPayload = await options?.onPayload?.(requestParams, model);
430
+ return replacementPayload !== undefined
431
+ ? (replacementPayload as OpenAIResponsesSamplingParams)
432
+ : requestParams;
433
+ };
434
+ chained = { ...chained, params: await applyPayloadReplacement(chained.params) };
421
435
  rawRequestDump = {
422
436
  provider: model.provider,
423
437
  api: output.api,
@@ -492,8 +506,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
492
506
  registerOpenAIResponsesChainStaleFailure(chainState, error);
493
507
  }
494
508
  sentPreviousResponseId = undefined;
495
- rawRequestDump.body = params;
496
- openaiStream = await openResponsesStream(params);
509
+ const retryParams = await applyPayloadReplacement(params);
510
+ rawRequestDump.body = retryParams;
511
+ openaiStream = await openResponsesStream(retryParams);
497
512
  }
498
513
  if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
499
514
  stream.push({ type: "start", partial: output });
@@ -652,7 +667,8 @@ function getOpenAIResponsesRoutingSessionId(
652
667
  return normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
653
668
  }
654
669
 
655
- function buildParams(
670
+ /** @internal Exported for tests. */
671
+ export function buildParams(
656
672
  model: Model<"openai-responses">,
657
673
  context: Context,
658
674
  options: OpenAIResponsesOptions | undefined,
@@ -705,7 +721,21 @@ function buildParams(
705
721
  if (context.tools) {
706
722
  params.tools = convertTools(context.tools, model.compat.supportsStrictMode, model);
707
723
  if (options?.toolChoice) {
708
- params.tool_choice = mapOpenAIResponsesToolChoiceForTools(options.toolChoice, context.tools, model);
724
+ // Map tool_choice against the tools that survived quarantine, not the
725
+ // original list: a forced choice for a dropped tool — or "required" when
726
+ // every tool was dropped — would otherwise send a tool_choice with no
727
+ // matching tool, which the provider rejects just like the bad schema did (#2652).
728
+ const emittedNames = new Set(
729
+ params.tools.map(t => (t as { name?: string }).name).filter((n): n is string => n !== undefined),
730
+ );
731
+ const survivingTools =
732
+ params.tools.length === context.tools.length
733
+ ? context.tools
734
+ : context.tools.filter(t => emittedNames.has(t.customWireName ?? t.name));
735
+ const toolChoice = mapOpenAIResponsesToolChoiceForTools(options.toolChoice, survivingTools, model);
736
+ if (toolChoice !== undefined && params.tools.length > 0) {
737
+ params.tool_choice = toolChoice;
738
+ }
709
739
  }
710
740
  // The apply_patch spec §1 marks only `apply_patch` itself as
711
741
  // `supports_parallel_tool_calls = false`. OpenAI's Responses API
@@ -852,11 +882,20 @@ export function mapOpenAIResponsesToolChoiceForTools(
852
882
  }
853
883
 
854
884
  /** @internal Exported for tests. */
855
- export function convertTools(tools: Tool[], strictMode: boolean, model: Model<"openai-responses">): OpenAITool[] {
885
+ export function convertTools(
886
+ tools: Tool[],
887
+ strictMode: boolean,
888
+ model: Model<"openai-responses">,
889
+ onQuarantine: (toolName: string, schemaPath: string) => void = (toolName, schemaPath) =>
890
+ logger.warn(
891
+ `Tool "${toolName}" omitted from the openai-responses request: its parameter schema is invalid for this provider at ${schemaPath} (an enum/const value cannot match its declared type). Other tools are unaffected.`,
892
+ ),
893
+ ): OpenAITool[] {
856
894
  const allowFreeform = supportsFreeformApplyPatch(model);
857
- return tools.map(tool => {
895
+ const out: OpenAITool[] = [];
896
+ for (const tool of tools) {
858
897
  if (allowFreeform && tool.customFormat) {
859
- return {
898
+ out.push({
860
899
  type: "custom",
861
900
  // Tool advertises its wire-level name (e.g. `apply_patch`) — the
862
901
  // agent-loop dispatcher will match incoming calls by either the
@@ -868,18 +907,29 @@ export function convertTools(tools: Tool[], strictMode: boolean, model: Model<"o
868
907
  syntax: tool.customFormat.syntax,
869
908
  definition: compactGrammarDefinition(tool.customFormat.syntax, tool.customFormat.definition),
870
909
  },
871
- } as unknown as OpenAITool;
910
+ } as unknown as OpenAITool);
911
+ continue;
872
912
  }
873
913
  const strict = !NO_STRICT && strictMode && tool.strict !== false;
874
914
  const baseParameters = toolWireSchema(tool);
875
915
  const responseParameters = sanitizeSchemaForOpenAIResponses(baseParameters);
876
916
  const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(responseParameters, strict);
877
- return {
917
+ // Quarantine a tool whose emitted schema carries a provider-rejecting
918
+ // enum/const-vs-type contradiction: dropping just that tool keeps the rest
919
+ // of the request valid instead of letting one bad MCP schema 400 the whole
920
+ // turn (#2652). Other tools and built-ins are unaffected.
921
+ const violation = findStrictToolSchemaViolation(parameters);
922
+ if (violation) {
923
+ onQuarantine(tool.name, violation);
924
+ continue;
925
+ }
926
+ out.push({
878
927
  type: "function",
879
928
  name: tool.name,
880
929
  description: tool.description || "",
881
930
  parameters,
882
931
  ...(effectiveStrict && { strict: true }),
883
- } as OpenAITool;
884
- });
932
+ } as OpenAITool);
933
+ }
934
+ return out;
885
935
  }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * OpenAI Codex (ChatGPT OAuth) flow — browser and device-code flows.
3
3
  */
4
+
5
+ import { OPENAI_HEADER_VALUES } from "@oh-my-pi/pi-catalog/wire/codex";
4
6
  import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
5
7
  import { generatePKCE } from "./pkce";
6
8
  import type { OAuthController, OAuthCredentials } from "./types";
@@ -60,6 +62,29 @@ interface PKCE {
60
62
  verifier: string;
61
63
  challenge: string;
62
64
  }
65
+ /** Builds the Codex browser OAuth URL used by browser login; exported for auth regression tests. */
66
+ export function createOpenAICodexAuthorizationUrl(args: {
67
+ state: string;
68
+ redirectUri: string;
69
+ challenge: string;
70
+ originator?: string;
71
+ }): string {
72
+ const originator = args.originator?.trim() || OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
73
+ const searchParams = new URLSearchParams({
74
+ response_type: "code",
75
+ client_id: CLIENT_ID,
76
+ redirect_uri: args.redirectUri,
77
+ scope: SCOPE,
78
+ code_challenge: args.challenge,
79
+ code_challenge_method: "S256",
80
+ state: args.state,
81
+ id_token_add_organizations: "true",
82
+ codex_cli_simplified_flow: "true",
83
+ originator,
84
+ });
85
+
86
+ return `${AUTHORIZE_URL}?${searchParams.toString()}`;
87
+ }
63
88
 
64
89
  class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
65
90
  constructor(
@@ -79,20 +104,12 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
79
104
  }
80
105
 
81
106
  async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
82
- const searchParams = new URLSearchParams({
83
- response_type: "code",
84
- client_id: CLIENT_ID,
85
- redirect_uri: redirectUri,
86
- scope: SCOPE,
87
- code_challenge: this.pkce.challenge,
88
- code_challenge_method: "S256",
107
+ const url = createOpenAICodexAuthorizationUrl({
89
108
  state,
90
- id_token_add_organizations: "true",
91
- codex_cli_simplified_flow: "true",
109
+ redirectUri,
110
+ challenge: this.pkce.challenge,
92
111
  originator: this.originator,
93
112
  });
94
-
95
- const url = `${AUTHORIZE_URL}?${searchParams.toString()}`;
96
113
  return { url, instructions: "A browser window should open. Complete login to finish." };
97
114
  }
98
115
 
@@ -153,13 +170,13 @@ async function exchangeCodeForToken(code: string, verifier: string, redirectUri:
153
170
  * Login with OpenAI Codex OAuth
154
171
  */
155
172
  export type OpenAICodexLoginOptions = OAuthController & {
156
- /** Optional originator value for OpenAI Codex OAuth. Default: "opencode". */
173
+ /** Optional originator value for OpenAI Codex OAuth. Default matches OMP Codex request headers. */
157
174
  originator?: string;
158
175
  };
159
176
 
160
177
  export async function loginOpenAICodex(options: OpenAICodexLoginOptions): Promise<OAuthCredentials> {
161
178
  const pkce = await generatePKCE();
162
- const originator = options.originator?.trim() || "opencode";
179
+ const originator = options.originator?.trim() || OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
163
180
  const flow = new OpenAICodexOAuthFlow(options, pkce, originator);
164
181
 
165
182
  return flow.login();
@@ -46,6 +46,7 @@ import { syntheticProvider } from "./synthetic";
46
46
  import { tavilyProvider } from "./tavily";
47
47
  import { togetherProvider } from "./together";
48
48
  import type { ProviderDefinition } from "./types";
49
+ import { umansProvider } from "./umans";
49
50
  import { veniceProvider } from "./venice";
50
51
  import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
51
52
  import { vllmProvider } from "./vllm";
@@ -85,6 +86,7 @@ const ALL = [
85
86
  alibabaCodingPlanProvider,
86
87
  aimlApiProvider,
87
88
  zhipuCodingPlanProvider,
89
+ umansProvider,
88
90
  qwenPortalProvider,
89
91
  minimaxCodeProvider,
90
92
  minimaxCodeCnProvider,