@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 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.3",
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.3",
42
- "@oh-my-pi/pi-utils": "16.0.3",
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
- 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;
@@ -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 {
@@ -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) {