@oh-my-pi/pi-ai 16.0.2 → 16.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.0.4] - 2026-06-17
6
+
7
+ ### Fixed
8
+
9
+ - Fixed tool argument coercion to parse double-encoded JSON strings, including quoted values like `"300"`, when schema expects a number
10
+ - Fixed object-array coercion to parse JSON object and array strings into proper array arguments instead of wrapping raw strings
11
+ - Fixed handling of malformed JSON container strings for array schema fields so validation now surfaces a top-level `expected array, received string` error rather than nested element errors
12
+ - Fixed ChatGPT/Codex browser login missing connector OAuth scopes and rendering object-shaped token endpoint errors as `[object Object]`. ([#2825](https://github.com/can1357/oh-my-pi/issues/2825))
13
+ - Fixed Zhipu/BigModel GLM-5.2 chat-completions requests so internal `xhigh` effort serializes as provider-native `reasoning_effort: "max"` and tool calls opt into `tool_stream`. ([#2833](https://github.com/can1357/oh-my-pi/issues/2833))
14
+ - Fixed Google Gemini CLI and Antigravity tool calls with `toolChoice: "auto"` serializing an explicit `toolConfig` AUTO mode, which can cause Gemini-3 models to leak raw planning JSON instead of executing tools. ([#2830](https://github.com/can1357/oh-my-pi/issues/2830))
15
+
16
+ ## [16.0.3] - 2026-06-16
17
+
18
+ ### Added
19
+
20
+ - Exported `renderDelimitedThinking` from the `@oh-my-pi/pi-ai/dialect` barrel so consumers can reuse the dialect's `<thinking>` envelope unwrap-and-rewrap logic (the only `./dialect/rendering` primitive re-exported; the rest stay dialect-internal).
21
+
22
+ ### Fixed
23
+
24
+ - Fixed OpenAI Responses/Codex tool schema normalization stripping provider-rejected regex lookaround patterns from MCP tool parameter schemas. ([#2784](https://github.com/can1357/oh-my-pi/issues/2784))
25
+ - Fixed OpenAI Responses parallel tool-call routing so late keyed argument deltas for a closed call are dropped instead of being appended to another open call.
26
+
5
27
  ## [16.0.2] - 2026-06-16
6
28
 
7
29
  ### Added
@@ -5,4 +5,5 @@ export * from "./factory";
5
5
  export * from "./history";
6
6
  export * from "./inventory";
7
7
  export * from "./owned-stream";
8
+ export { renderDelimitedThinking } from "./rendering";
8
9
  export * from "./types";
@@ -3,6 +3,8 @@
3
3
  */
4
4
  import type { OAuthController, OAuthCredentials } from "./types";
5
5
  export declare function decodeJwt<T = Record<string, unknown>>(token: string): T | null;
6
+ /** Formats OpenAI Codex OAuth token endpoint errors for login and refresh failures. */
7
+ export declare function formatOpenAICodexTokenEndpointError(status: number, bodyText: string): string;
6
8
  /** Builds the Codex browser OAuth URL used by browser login; exported for auth regression tests. */
7
9
  export declare function createOpenAICodexAuthorizationUrl(args: {
8
10
  state: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.0.2",
4
+ "version": "16.0.4",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,8 +38,8 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.0.2",
42
- "@oh-my-pi/pi-utils": "16.0.2",
41
+ "@oh-my-pi/pi-catalog": "16.0.4",
42
+ "@oh-my-pi/pi-utils": "16.0.4",
43
43
  "partial-json": "^0.1.7",
44
44
  "zod": "^4"
45
45
  },
@@ -5,4 +5,9 @@ export * from "./factory";
5
5
  export * from "./history";
6
6
  export * from "./inventory";
7
7
  export * from "./owned-stream";
8
+ // `./rendering` is a dialect-internal primitives module deliberately excluded
9
+ // from the barrel. `renderDelimitedThinking` is the one helper an external
10
+ // consumer needs (the legacy markdown `/dump` reuses its `<thinking>` envelope
11
+ // unwrap), so re-export only that symbol rather than `export *`-ing the rest.
12
+ export { renderDelimitedThinking } from "./rendering";
8
13
  export * from "./types";
@@ -849,9 +849,12 @@ export function buildRequest(
849
849
  if (options.toolChoice) {
850
850
  const choice = options.toolChoice;
851
851
  if (typeof choice === "string") {
852
- request.toolConfig = {
853
- functionCallingConfig: { mode: mapToolChoice(choice) },
854
- };
852
+ const mode = mapToolChoice(choice);
853
+ if (mode !== "AUTO") {
854
+ request.toolConfig = {
855
+ functionCallingConfig: { mode },
856
+ };
857
+ }
855
858
  } else {
856
859
  request.toolConfig = {
857
860
  functionCallingConfig: {
@@ -1,6 +1,6 @@
1
1
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
2
2
  import { toFirepassWireModelId, toFireworksWireModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
3
- import { isDeepseekModelIdOrName } from "@oh-my-pi/pi-catalog/identity";
3
+ import { isDeepseekModelIdOrName, isGlm52ReasoningEffortModelId } from "@oh-my-pi/pi-catalog/identity";
4
4
  import { getSupportedEfforts, resolveWireModelId } from "@oh-my-pi/pi-catalog/model-thinking";
5
5
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
6
6
  import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-catalog/types";
@@ -367,7 +367,7 @@ export interface OpenAICompletionsOptions extends StreamOptions {
367
367
  openrouterVariant?: string;
368
368
  }
369
369
 
370
- type OpenAICompletionsParams = ChatCompletionCreateParamsStreaming & {
370
+ type OpenAICompletionsParams = Omit<ChatCompletionCreateParamsStreaming, "reasoning_effort"> & {
371
371
  top_k?: number;
372
372
  min_p?: number;
373
373
  repetition_penalty?: number;
@@ -375,6 +375,8 @@ type OpenAICompletionsParams = ChatCompletionCreateParamsStreaming & {
375
375
  enable_thinking?: boolean;
376
376
  chat_template_kwargs?: { enable_thinking: boolean };
377
377
  reasoning?: { effort?: string } | { enabled: false };
378
+ reasoning_effort?: string | null;
379
+ tool_stream?: boolean;
378
380
  provider?: OpenAICompat["openRouterRouting"];
379
381
  providerOptions?: { gateway?: { only?: string[]; order?: string[] } };
380
382
  };
@@ -1338,6 +1340,10 @@ function buildParams(
1338
1340
  // `compat.alwaysSendMaxTokens` carries that detection.
1339
1341
  const requestedMaxTokens =
1340
1342
  options?.maxTokens ?? (compat.alwaysSendMaxTokens ? (model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS) : undefined);
1343
+ const providerOutputClamp =
1344
+ compat.thinkingFormat === "zai" && isGlm52ReasoningEffortModelId(model.id)
1345
+ ? (model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS)
1346
+ : OPENAI_MAX_OUTPUT_TOKENS;
1341
1347
  // OpenRouter fans out to upstreams whose output caps differ from the catalog
1342
1348
  // value (which tracks the highest-cap provider). A max_tokens above the routed
1343
1349
  // upstream's cap makes OpenRouter silently skip that provider (e.g. Cerebras
@@ -1348,7 +1354,7 @@ function buildParams(
1348
1354
  const effectiveMaxTokens =
1349
1355
  requestedMaxTokens === undefined || omitMaxTokensForRouting
1350
1356
  ? undefined
1351
- : Math.min(requestedMaxTokens, model.maxTokens ?? Number.POSITIVE_INFINITY, OPENAI_MAX_OUTPUT_TOKENS);
1357
+ : Math.min(requestedMaxTokens, model.maxTokens ?? Number.POSITIVE_INFINITY, providerOutputClamp);
1352
1358
 
1353
1359
  const requestModelId = resolveOpenAICompletionsModelId(model, options);
1354
1360
  const params: OpenAICompletionsParams = {
@@ -1422,6 +1428,15 @@ function buildParams(
1422
1428
  // so LiteLLM → Bedrock never sees an empty `toolConfig` block.
1423
1429
  params.tools = [];
1424
1430
  }
1431
+ if (
1432
+ compat.thinkingFormat === "zai" &&
1433
+ compat.supportsReasoningEffort &&
1434
+ isGlm52ReasoningEffortModelId(model.id) &&
1435
+ Array.isArray(params.tools) &&
1436
+ params.tools.length > 0
1437
+ ) {
1438
+ params.tool_stream = true;
1439
+ }
1425
1440
 
1426
1441
  if (options?.toolChoice && compat.supportsToolChoice) {
1427
1442
  params.tool_choice = mapToOpenAICompletionsToolChoice(options.toolChoice);
@@ -1459,13 +1474,24 @@ function buildParams(
1459
1474
  }
1460
1475
 
1461
1476
  if (supportsReasoningParams && compat.thinkingFormat === "zai" && model.reasoning) {
1462
- // Z.ai uses binary thinking: { type: "enabled" | "disabled" }
1463
- // Must explicitly disable since z.ai defaults to thinking enabled.
1464
- const enabled = options?.reasoning && !options?.disableReasoning;
1477
+ // Z.AI-style hosts use binary thinking, while GLM-5.2+ also accepts
1478
+ // `reasoning_effort` when thinking is enabled. `minimal` maps to the
1479
+ // provider's skip-thinking path, so keep the effort field absent there.
1480
+ const requestedEffort = options?.reasoning;
1481
+ const mappedEffort =
1482
+ requestedEffort === undefined
1483
+ ? undefined
1484
+ : (compat.reasoningEffortMap?.[requestedEffort] ??
1485
+ model.thinking?.effortMap?.[requestedEffort] ??
1486
+ requestedEffort);
1487
+ const enabled = mappedEffort !== undefined && mappedEffort !== "none" && !options?.disableReasoning;
1465
1488
  params.thinking = { type: enabled ? "enabled" : "disabled" };
1466
1489
  if (enabled && compat.thinkingKeep) {
1467
1490
  params.thinking.keep = compat.thinkingKeep;
1468
1491
  }
1492
+ if (enabled && compat.supportsReasoningEffort) {
1493
+ params.reasoning_effort = mappedEffort;
1494
+ }
1469
1495
  } else if (supportsReasoningParams && compat.thinkingFormat === "qwen" && model.reasoning) {
1470
1496
  // Qwen uses top-level enable_thinking: boolean
1471
1497
  params.enable_thinking = !!options?.reasoning && !options?.disableReasoning;
@@ -525,6 +525,7 @@ export async function processResponsesStream<TApi extends Api>(
525
525
  lastOpenItem = entry;
526
526
  };
527
527
  const lookupOpenItem = (event: { output_index?: number; item_id?: string }): StreamingItem | undefined => {
528
+ const hasKey = typeof event.output_index === "number" || event.item_id !== undefined;
528
529
  if (typeof event.output_index === "number") {
529
530
  const found = openItemsByOutputIndex.get(event.output_index);
530
531
  if (found) return found;
@@ -533,8 +534,10 @@ export async function processResponsesStream<TApi extends Api>(
533
534
  const found = openItemsByItemId.get(event.item_id);
534
535
  if (found) return found;
535
536
  }
536
- // Fallback for tests / mock providers that omit identifiers on stream events.
537
- return lastOpenItem ?? undefined;
537
+ // Keyed events whose item already closed are stale; drop them instead of
538
+ // routing to a sibling. Only fully identifierless mock/proxy events use the
539
+ // legacy singleton fallback.
540
+ return hasKey ? undefined : (lastOpenItem ?? undefined);
538
541
  };
539
542
  const hasOpenItemKey = (event: { output_index?: number; item_id?: string }): boolean =>
540
543
  typeof event.output_index === "number" || event.item_id !== undefined;
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  import { OPENAI_HEADER_VALUES } from "@oh-my-pi/pi-catalog/wire/codex";
6
+ import type { FetchImpl } from "../../types";
7
+ import { isRecord } from "../../utils";
6
8
  import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
7
9
  import { generatePKCE } from "./pkce";
8
10
  import type { OAuthController, OAuthCredentials } from "./types";
@@ -12,7 +14,7 @@ const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
12
14
  const TOKEN_URL = "https://auth.openai.com/oauth/token";
13
15
  const CALLBACK_PORT = 1455;
14
16
  const CALLBACK_PATH = "/auth/callback";
15
- const SCOPE = "openid profile email offline_access";
17
+ const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
16
18
  const JWT_CLAIM_PATH = "https://api.openai.com/auth";
17
19
  const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
18
20
  const TOKEN_REQUEST_TIMEOUT_MS = 15_000;
@@ -62,6 +64,37 @@ interface PKCE {
62
64
  verifier: string;
63
65
  challenge: string;
64
66
  }
67
+ function describeTokenEndpointValue(value: unknown): string | undefined {
68
+ if (typeof value === "string") {
69
+ const trimmed = value.trim();
70
+ return trimmed.length > 0 ? trimmed : undefined;
71
+ }
72
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
73
+ if (!isRecord(value)) return undefined;
74
+
75
+ const code = describeTokenEndpointValue(value.code ?? value.error);
76
+ const message = describeTokenEndpointValue(value.message ?? value.error_description ?? value.description);
77
+ if (code && message && code !== message) return `${code}: ${message}`;
78
+ return code ?? message ?? JSON.stringify(value);
79
+ }
80
+
81
+ /** Formats OpenAI Codex OAuth token endpoint errors for login and refresh failures. */
82
+ export function formatOpenAICodexTokenEndpointError(status: number, bodyText: string): string {
83
+ const trimmed = bodyText.trim();
84
+ if (trimmed.length === 0) return `${status}`;
85
+
86
+ try {
87
+ const body: unknown = JSON.parse(trimmed);
88
+ if (!isRecord(body)) return `${status} ${trimmed}`;
89
+
90
+ const error = describeTokenEndpointValue(body.error);
91
+ const description = describeTokenEndpointValue(body.error_description);
92
+ if (error && description && error !== description) return `${status} ${error}: ${description}`;
93
+ return `${status} ${error ?? description ?? describeTokenEndpointValue(body.message) ?? trimmed}`;
94
+ } catch {
95
+ return `${status} ${trimmed}`;
96
+ }
97
+ }
65
98
  /** Builds the Codex browser OAuth URL used by browser login; exported for auth regression tests. */
66
99
  export function createOpenAICodexAuthorizationUrl(args: {
67
100
  state: string;
@@ -87,11 +120,11 @@ export function createOpenAICodexAuthorizationUrl(args: {
87
120
  }
88
121
 
89
122
  class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
90
- constructor(
91
- ctrl: OAuthController,
92
- private readonly pkce: PKCE,
93
- private readonly originator: string,
94
- ) {
123
+ #pkce: PKCE;
124
+ #originator: string;
125
+ #fetch: FetchImpl;
126
+
127
+ constructor(ctrl: OAuthController, pkce: PKCE, originator: string, fetchImpl: FetchImpl) {
95
128
  super(ctrl, {
96
129
  preferredPort: CALLBACK_PORT,
97
130
  callbackPath: CALLBACK_PATH,
@@ -101,25 +134,33 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
101
134
  // registered allowlist entry.
102
135
  redirectUri: `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`,
103
136
  } satisfies OAuthCallbackFlowOptions);
137
+ this.#pkce = pkce;
138
+ this.#originator = originator;
139
+ this.#fetch = fetchImpl;
104
140
  }
105
141
 
106
142
  async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
107
143
  const url = createOpenAICodexAuthorizationUrl({
108
144
  state,
109
145
  redirectUri,
110
- challenge: this.pkce.challenge,
111
- originator: this.originator,
146
+ challenge: this.#pkce.challenge,
147
+ originator: this.#originator,
112
148
  });
113
149
  return { url, instructions: "A browser window should open. Complete login to finish." };
114
150
  }
115
151
 
116
152
  async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
117
- return exchangeCodeForToken(code, this.pkce.verifier, redirectUri);
153
+ return exchangeCodeForToken(code, this.#pkce.verifier, redirectUri, this.#fetch);
118
154
  }
119
155
  }
120
156
 
121
- async function exchangeCodeForToken(code: string, verifier: string, redirectUri: string): Promise<OAuthCredentials> {
122
- const tokenResponse = await fetch(TOKEN_URL, {
157
+ async function exchangeCodeForToken(
158
+ code: string,
159
+ verifier: string,
160
+ redirectUri: string,
161
+ fetchImpl: FetchImpl = fetch,
162
+ ): Promise<OAuthCredentials> {
163
+ const tokenResponse = await fetchImpl(TOKEN_URL, {
123
164
  method: "POST",
124
165
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
125
166
  body: new URLSearchParams({
@@ -133,13 +174,8 @@ async function exchangeCodeForToken(code: string, verifier: string, redirectUri:
133
174
  });
134
175
 
135
176
  if (!tokenResponse.ok) {
136
- let detail = `${tokenResponse.status}`;
137
- try {
138
- const body = (await tokenResponse.json()) as { error?: string; error_description?: string };
139
- if (body.error)
140
- detail = `${tokenResponse.status} ${body.error}${body.error_description ? `: ${body.error_description}` : ""}`;
141
- } catch {}
142
- throw new Error(`Token exchange failed: ${detail}`);
177
+ const bodyText = await tokenResponse.text();
178
+ throw new Error(`Token exchange failed: ${formatOpenAICodexTokenEndpointError(tokenResponse.status, bodyText)}`);
143
179
  }
144
180
 
145
181
  const tokenData = (await tokenResponse.json()) as {
@@ -177,7 +213,7 @@ export type OpenAICodexLoginOptions = OAuthController & {
177
213
  export async function loginOpenAICodex(options: OpenAICodexLoginOptions): Promise<OAuthCredentials> {
178
214
  const pkce = await generatePKCE();
179
215
  const originator = options.originator?.trim() || OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
180
- const flow = new OpenAICodexOAuthFlow(options, pkce, originator);
216
+ const flow = new OpenAICodexOAuthFlow(options, pkce, originator, options.fetch ?? fetch);
181
217
 
182
218
  return flow.login();
183
219
  }
@@ -285,13 +321,10 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
285
321
  });
286
322
 
287
323
  if (!response.ok) {
288
- let detail = `${response.status}`;
289
- try {
290
- const body = (await response.json()) as { error?: string; error_description?: string };
291
- if (body.error)
292
- detail = `${response.status} ${body.error}${body.error_description ? `: ${body.error_description}` : ""}`;
293
- } catch {}
294
- throw new Error(`OpenAI Codex token refresh failed: ${detail}`);
324
+ const bodyText = await response.text();
325
+ throw new Error(
326
+ `OpenAI Codex token refresh failed: ${formatOpenAICodexTokenEndpointError(response.status, bodyText)}`,
327
+ );
295
328
  }
296
329
 
297
330
  const tokenData = (await response.json()) as {
@@ -936,8 +936,25 @@ export function sanitizeSchemaForOpenAIResponses(schema: JsonObject): JsonObject
936
936
  * `normalizeSchemaFor*` dispatcher naming used elsewhere in this module.
937
937
  */
938
938
  export const normalizeSchemaForOpenAIResponses: (schema: JsonObject) => JsonObject = sanitizeSchemaForOpenAIResponses;
939
+ const OPENAI_UNSUPPORTED_REGEX_LOOKAROUNDS = new Set(["=", "!", "<=", "<!"]);
940
+ const OPENAI_RESPONSES_PATTERN_PROPERTIES_FALLBACK = ".*";
939
941
 
940
- function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonObject, JsonObject>): unknown {
942
+ function hasOpenAIUnsupportedRegexLookaround(pattern: string): boolean {
943
+ let groupStart = pattern.indexOf("(?");
944
+ while (groupStart !== -1) {
945
+ let escapes = 0;
946
+ for (let i = groupStart - 1; i >= 0 && pattern[i] === "\\"; i--) escapes++;
947
+ if (escapes % 2 === 0) {
948
+ const operator =
949
+ pattern[groupStart + 2] === "<" ? pattern.slice(groupStart + 2, groupStart + 4) : pattern[groupStart + 2];
950
+ if (OPENAI_UNSUPPORTED_REGEX_LOOKAROUNDS.has(operator)) return true;
951
+ }
952
+ groupStart = pattern.indexOf("(?", groupStart + 2);
953
+ }
954
+ return false;
955
+ }
956
+
957
+ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonObject, unknown>): unknown {
941
958
  if (!isJsonObject(value)) return value;
942
959
 
943
960
  // `{}` (empty JSON Schema) ≡ `true` (JSON Schema draft 2020-12 §4.3.1).
@@ -973,11 +990,21 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonO
973
990
  changed = true;
974
991
  continue;
975
992
  }
993
+ if (
994
+ key === "pattern" &&
995
+ typeof value.pattern === "string" &&
996
+ hasOpenAIUnsupportedRegexLookaround(value.pattern)
997
+ ) {
998
+ changed = true;
999
+ continue;
1000
+ }
976
1001
 
977
1002
  const child = value[key];
978
1003
  let next: unknown = child;
979
- if (OPENAI_RESPONSES_SCHEMA_MAP_KEYS.has(key) && isJsonObject(child)) {
980
- next = normalizeOpenAIResponsesSchemaMap(child, cache);
1004
+ if (key === "patternProperties" && isJsonObject(child)) {
1005
+ next = normalizeOpenAIResponsesSchemaMap(child, cache, true);
1006
+ } else if (OPENAI_RESPONSES_SCHEMA_MAP_KEYS.has(key) && isJsonObject(child)) {
1007
+ next = normalizeOpenAIResponsesSchemaMap(child, cache, false);
981
1008
  } else if (OPENAI_RESPONSES_SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(child)) {
982
1009
  next = normalizeOpenAIResponsesSchemaArray(child, cache);
983
1010
  } else if (OPENAI_RESPONSES_SCHEMA_VALUE_KEYS.has(key) && isJsonObject(child)) {
@@ -1008,7 +1035,7 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonO
1008
1035
  // the seeded partial and set `changed = true` for that node, so a node
1009
1036
  // that finishes with `changed === false` is provably non-cyclic and
1010
1037
  // referentially equal to its input.
1011
- const result = changed ? output : value;
1038
+ const result = changed ? (isJsonObjectEmpty(output) ? true : output) : value;
1012
1039
  cache.set(value, result);
1013
1040
  return result;
1014
1041
  }
@@ -1022,7 +1049,7 @@ function declaresObjectType(type: unknown): boolean {
1022
1049
  return false;
1023
1050
  }
1024
1051
 
1025
- function normalizeOpenAIResponsesSchemaArray(value: unknown[], cache: WeakMap<JsonObject, JsonObject>): unknown[] {
1052
+ function normalizeOpenAIResponsesSchemaArray(value: unknown[], cache: WeakMap<JsonObject, unknown>): unknown[] {
1026
1053
  let changed = false;
1027
1054
  const output = value.map(item => {
1028
1055
  const next = normalizeOpenAIResponsesSchemaNode(item, cache);
@@ -1032,7 +1059,11 @@ function normalizeOpenAIResponsesSchemaArray(value: unknown[], cache: WeakMap<Js
1032
1059
  return changed ? output : value;
1033
1060
  }
1034
1061
 
1035
- function normalizeOpenAIResponsesSchemaMap(schemaMap: JsonObject, cache: WeakMap<JsonObject, JsonObject>): JsonObject {
1062
+ function normalizeOpenAIResponsesSchemaMap(
1063
+ schemaMap: JsonObject,
1064
+ cache: WeakMap<JsonObject, unknown>,
1065
+ stripUnsupportedRegexKeys: boolean,
1066
+ ): JsonObject {
1036
1067
  let changed = false;
1037
1068
  const output: JsonObject = {};
1038
1069
  for (const key in schemaMap) {
@@ -1040,11 +1071,29 @@ function normalizeOpenAIResponsesSchemaMap(schemaMap: JsonObject, cache: WeakMap
1040
1071
  const child = schemaMap[key];
1041
1072
  const next = normalizeOpenAIResponsesSchemaNode(child, cache);
1042
1073
  if (next !== child) changed = true;
1074
+ if (stripUnsupportedRegexKeys && hasOpenAIUnsupportedRegexLookaround(key)) {
1075
+ changed = true;
1076
+ appendOpenAIResponsesFallbackPatternProperty(output, next);
1077
+ continue;
1078
+ }
1043
1079
  output[key] = next;
1044
1080
  }
1045
1081
  return changed ? output : schemaMap;
1046
1082
  }
1047
1083
 
1084
+ function appendOpenAIResponsesFallbackPatternProperty(output: JsonObject, schema: unknown): void {
1085
+ const existing = output[OPENAI_RESPONSES_PATTERN_PROPERTIES_FALLBACK];
1086
+ if (existing === undefined) {
1087
+ output[OPENAI_RESPONSES_PATTERN_PROPERTIES_FALLBACK] = schema;
1088
+ return;
1089
+ }
1090
+ if (isJsonObject(existing) && Array.isArray(existing.anyOf) && Object.keys(existing).length === 1) {
1091
+ existing.anyOf = [...existing.anyOf, schema];
1092
+ return;
1093
+ }
1094
+ output[OPENAI_RESPONSES_PATTERN_PROPERTIES_FALLBACK] = { anyOf: [existing, schema] };
1095
+ }
1096
+
1048
1097
  // ---------------------------------------------------------------------------
1049
1098
  // OpenAI strict mode — sanitize + enforce
1050
1099
  // ---------------------------------------------------------------------------
@@ -411,6 +411,47 @@ function tryHealMalformedJson(value: string): unknown | undefined {
411
411
  return undefined;
412
412
  }
413
413
 
414
+ const MAX_NESTED_JSON_STRING_PARSE_DEPTH = 3;
415
+
416
+ function acceptParsedJsonForTypes(
417
+ parsed: unknown,
418
+ source: string,
419
+ expectedTypes: string[],
420
+ depth: number,
421
+ ): { value: unknown; changed: boolean } {
422
+ if (parsed === null && source.trim() === "null") {
423
+ return { value: null, changed: true };
424
+ }
425
+ if (matchesExpectedType(parsed, expectedTypes)) {
426
+ return { value: parsed, changed: true };
427
+ }
428
+ if (typeof parsed === "string" && !expectedTypes.includes("string") && depth < MAX_NESTED_JSON_STRING_PARSE_DEPTH) {
429
+ return tryParseJsonForTypes(parsed, expectedTypes, depth + 1);
430
+ }
431
+ return { value: source, changed: false };
432
+ }
433
+
434
+ function looksLikeJsonContainerString(value: unknown): boolean {
435
+ if (typeof value !== "string") return false;
436
+ const trimmed = value.trimStart();
437
+ if (trimmed.startsWith("{")) {
438
+ const body = trimmed.slice(1);
439
+ return body.trimStart().startsWith('"') || body.includes(":") || body.trimStart().startsWith("}");
440
+ }
441
+ if (!trimmed.startsWith("[")) return false;
442
+ const firstItem = trimmed.slice(1).trimStart();
443
+ return (
444
+ firstItem.startsWith("{") ||
445
+ firstItem.startsWith("[") ||
446
+ firstItem.startsWith('"') ||
447
+ firstItem.startsWith("]") ||
448
+ firstItem.startsWith("true") ||
449
+ firstItem.startsWith("false") ||
450
+ firstItem.startsWith("null") ||
451
+ /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?(?:\s*(?:,|\]|$))/.test(firstItem)
452
+ );
453
+ }
454
+
414
455
  /**
415
456
  * Attempts to parse a string as JSON if it looks like a JSON literal and
416
457
  * the parsed result matches one of the expected types.
@@ -424,7 +465,7 @@ function tryHealMalformedJson(value: string): unknown | undefined {
424
465
  * matches an expected type. This prevents false positives like parsing
425
466
  * the string `"123"` when the schema actually wants a string.
426
467
  */
427
- function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value: unknown; changed: boolean } {
468
+ function tryParseJsonForTypes(value: string, expectedTypes: string[], depth = 0): { value: unknown; changed: boolean } {
428
469
  const trimmed = value.trim();
429
470
  if (!trimmed) return { value, changed: false };
430
471
 
@@ -434,28 +475,20 @@ function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value:
434
475
  }
435
476
 
436
477
  // Quick syntactic checks to avoid unnecessary parse attempts
437
- const looksJsonObject = trimmed.startsWith("{");
438
- const looksJsonArray = trimmed.startsWith("[");
478
+ const looksJsonObject = trimmed.startsWith("{") && looksLikeJsonContainerString(trimmed);
479
+ const looksJsonArray = trimmed.startsWith("[") && looksLikeJsonContainerString(trimmed);
480
+ const looksJsonString = trimmed.startsWith('"') && !expectedTypes.includes("string");
439
481
  const looksJsonLiteral =
440
482
  trimmed === "true" || trimmed === "false" || trimmed === "null" || JSON_NUMBER_PATTERN.test(trimmed);
441
483
 
442
- if (!looksJsonObject && !looksJsonArray && !looksJsonLiteral) {
484
+ if (!looksJsonObject && !looksJsonArray && !looksJsonString && !looksJsonLiteral) {
443
485
  return { value, changed: false };
444
486
  }
445
487
 
446
488
  try {
447
489
  const parsed = JSON.parse(trimmed) as unknown;
448
- // If the string was "null", we parsed it to actual null.
449
- // Accept this even if null isn't in expectedTypes — the LLM meant "no value".
450
- // normalizeOptionalNullsForSchema will strip it from optional fields, and
451
- // the validator will correctly error on required fields.
452
- if (parsed === null && trimmed === "null") {
453
- return { value: null, changed: true };
454
- }
455
- // For non-null values, only accept if the parsed type matches what the schema expects
456
- if (matchesExpectedType(parsed, expectedTypes)) {
457
- return { value: parsed, changed: true };
458
- }
490
+ const accepted = acceptParsedJsonForTypes(parsed, trimmed, expectedTypes, depth);
491
+ if (accepted.changed) return accepted;
459
492
  } catch {
460
493
  if (looksJsonObject || looksJsonArray) {
461
494
  // Try escaping raw control chars inside string literals (LLMs sometimes
@@ -464,20 +497,21 @@ function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value:
464
497
  if (escapedControls !== trimmed) {
465
498
  try {
466
499
  const parsed = JSON.parse(escapedControls) as unknown;
467
- if (matchesExpectedType(parsed, expectedTypes)) {
468
- return { value: parsed, changed: true };
469
- }
500
+ const accepted = acceptParsedJsonForTypes(parsed, escapedControls, expectedTypes, depth);
501
+ if (accepted.changed) return accepted;
470
502
  } catch {}
471
503
  }
472
504
  // Try extracting a valid JSON prefix (handles trailing junk after balanced container)
473
505
  const leading = tryParseLeadingJsonContainer(trimmed);
474
- if (leading !== undefined && matchesExpectedType(leading, expectedTypes)) {
475
- return { value: leading, changed: true };
506
+ if (leading !== undefined) {
507
+ const accepted = acceptParsedJsonForTypes(leading, trimmed, expectedTypes, depth);
508
+ if (accepted.changed) return accepted;
476
509
  }
477
510
  // Try healing single-character bracket errors near the end of the string
478
511
  const healed = tryHealMalformedJson(trimmed);
479
- if (healed !== undefined && matchesExpectedType(healed, expectedTypes)) {
480
- return { value: healed, changed: true };
512
+ if (healed !== undefined) {
513
+ const accepted = acceptParsedJsonForTypes(healed, trimmed, expectedTypes, depth);
514
+ if (accepted.changed) return accepted;
481
515
  }
482
516
  }
483
517
  return { value, changed: false };
@@ -1065,14 +1099,22 @@ function coerceArgsFromIssues(args: unknown, issues: FlatIssue[]): { value: unkn
1065
1099
 
1066
1100
  const currentValue = getValueAtPointer(nextArgs, issue.instancePath);
1067
1101
  const result = tryCoerceForExpectedTypes(currentValue, issue.expectedTypes);
1068
- const coercedValue = result.changed
1069
- ? result.value
1070
- : issue.expectedTypes.includes("array") &&
1071
- !issue.unionBranch &&
1072
- currentValue !== undefined &&
1073
- !Array.isArray(currentValue)
1074
- ? [currentValue]
1075
- : undefined;
1102
+ let coercedValue = result.changed ? result.value : undefined;
1103
+ if (
1104
+ coercedValue === undefined &&
1105
+ issue.expectedTypes.includes("array") &&
1106
+ !issue.unionBranch &&
1107
+ currentValue !== undefined &&
1108
+ !Array.isArray(currentValue)
1109
+ ) {
1110
+ const objectCoercion =
1111
+ typeof currentValue === "string"
1112
+ ? tryParseJsonForTypes(currentValue, ["object"])
1113
+ : { value: currentValue, changed: false };
1114
+ if (objectCoercion.changed || !looksLikeJsonContainerString(currentValue)) {
1115
+ coercedValue = [objectCoercion.changed ? objectCoercion.value : currentValue];
1116
+ }
1117
+ }
1076
1118
  if (coercedValue === undefined) continue;
1077
1119
 
1078
1120
  if (!owned) {