@oh-my-pi/pi-ai 16.0.3 → 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 +11 -0
- package/dist/types/registry/oauth/openai-codex.d.ts +2 -0
- package/package.json +3 -3
- package/src/providers/google-gemini-cli.ts +6 -3
- package/src/providers/openai-completions.ts +32 -6
- package/src/registry/oauth/openai-codex.ts +59 -26
- package/src/utils/validation.ts +72 -30
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
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
|
+
|
|
5
16
|
## [16.0.3] - 2026-06-16
|
|
6
17
|
|
|
7
18
|
### Added
|
|
@@ -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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.0.
|
|
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
|
},
|
|
@@ -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
|
-
|
|
853
|
-
|
|
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,
|
|
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.
|
|
1463
|
-
//
|
|
1464
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
111
|
-
originator: this
|
|
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
|
|
153
|
+
return exchangeCodeForToken(code, this.#pkce.verifier, redirectUri, this.#fetch);
|
|
118
154
|
}
|
|
119
155
|
}
|
|
120
156
|
|
|
121
|
-
async function exchangeCodeForToken(
|
|
122
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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 {
|
package/src/utils/validation.ts
CHANGED
|
@@ -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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
|
475
|
-
|
|
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
|
|
480
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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) {
|