@oh-my-pi/pi-ai 15.10.12 → 15.11.0

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,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.11.0] - 2026-06-10
6
+
7
+ ### Added
8
+
9
+ - Added optional `ImageContent.detail` (`"auto" | "low" | "high" | "original"`): an OpenAI resolution hint forwarded by the `openai-responses` serializers (default stays `auto`) and by `openai-completions` for the values Chat Completions supports. `"original"` preserves native resolution — required for snapcompact frames, whose pixel-font glyphs do not survive the default downscale. Providers without a detail knob ignore the field.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed OpenRouter DeepSeek V4 strict tool schemas nesting `anyOf` inside the nullable wrapper for optional unions, which produced a branch without `type` and triggered OpenRouter's `Invalid tool parameters schema : field anyOf: missing field type` 400. ([#2270](https://github.com/can1357/oh-my-pi/issues/2270))
14
+ - Hardened strict tool-schema handling beyond the optional-union case: `enforceStrictSchema` now splices natively nested pure unions into the parent `anyOf` (only when the inner node carries no constraining siblings, since sibling keywords are conjunctive with `anyOf`), so source schemas with nested unions no longer produce type-less `anyOf` branches that strict upstream validators reject. ([#2270](https://github.com/can1357/oh-my-pi/issues/2270))
15
+ - Made the openai-completions non-strict retry reachable for `"mixed"` strict mode (previously gated to `all_strict`, i.e. Cerebras only) and taught it to recognize upstream tool-schema validation 400s (`Invalid tool parameters schema …`, `Invalid schema for function …`). A matching rejection now retries the request with base (non-strict) schemas and persists `strictToolsDisabled` on the provider session, so later requests skip the doomed strict attempt instead of paying a 400 + retry round-trip each turn. ([#2270](https://github.com/can1357/oh-my-pi/issues/2270))
16
+ - Cross-model `anthropic-messages → anthropic-messages` continuations now preserve prior assistant turns' reasoning chains end-to-end: every prior `thinking`/`redactedThinking` block survives (not just the latest surviving assistant), and third-party ↔ third-party replays keep their signatures intact so the reasoning chain stays signed for the next turn. Signatures are stripped (and any `redacted_thinking` sibling without a native landing spot is dropped) only when an official Anthropic endpoint is on either end of the replay — official Anthropic cryptographically binds reasoning signatures to its key+session+model, while compatible reasoning endpoints (Z.AI, DeepSeek, custom anthropic-messages providers configured via `models.yaml`) treat them as opaque continuation hints. Source-side official detection uses the canonical catalog provider id `"anthropic"` (assistant messages carry no `baseUrl`); target-side detection reuses the baked `compat.officialEndpoint` flag. Latest-turn byte-for-byte behavior (Anthropic's "thinking blocks in the latest assistant message cannot be modified" rule) and existing aborted/errored last-block sanitization are unchanged. ([#2257](https://github.com/can1357/oh-my-pi/issues/2257), [#2265](https://github.com/can1357/oh-my-pi/issues/2265))
17
+
5
18
  ## [15.10.12] - 2026-06-10
6
19
 
7
20
  ### Added
@@ -328,6 +328,12 @@ export interface ImageContent {
328
328
  type: "image";
329
329
  data: string;
330
330
  mimeType: string;
331
+ /**
332
+ * OpenAI-only resolution hint. `"original"` preserves native resolution
333
+ * (required for snapcompact frames, whose glyphs do not survive the
334
+ * default `auto` downscale). Providers without a detail knob ignore it.
335
+ */
336
+ detail?: "auto" | "low" | "high" | "original";
331
337
  }
332
338
  export interface ToolCall {
333
339
  type: "toolCall";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.10.12",
4
+ "version": "15.11.0",
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": "15.10.12",
42
- "@oh-my-pi/pi-utils": "15.10.12",
41
+ "@oh-my-pi/pi-catalog": "15.11.0",
42
+ "@oh-my-pi/pi-utils": "15.11.0",
43
43
  "openai": "^6.39.0",
44
44
  "partial-json": "^0.1.7",
45
45
  "zod": "4.4.3"
@@ -278,6 +278,8 @@ type ToolStrictModeOverride = Exclude<ResolvedOpenAICompat["toolStrictMode"], "m
278
278
  type BuiltOpenAICompletionTools = {
279
279
  tools: OpenAI.Chat.Completions.ChatCompletionTool[];
280
280
  toolStrictMode: AppliedToolStrictMode;
281
+ /** True when at least one wire tool was sent with `strict: true`. */
282
+ strictToolsApplied: boolean;
281
283
  };
282
284
 
283
285
  const OPENAI_COMPLETIONS_PROVIDER_SESSION_STATE_PREFIX = "openai-completions:";
@@ -447,7 +449,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
447
449
  } = await createClient(model, context, apiKey, options?.headers, options?.initiatorOverride, options?.fetch);
448
450
  const premiumRequestsTotal = copilotPremiumRequests;
449
451
  getCapturedErrorResponse = captureErrorResponse;
450
- let appliedToolStrictMode: AppliedToolStrictMode = "mixed";
452
+ let appliedStrictTools = false;
451
453
  const providerSessionState = getOpenAICompletionsProviderSessionState(
452
454
  model,
453
455
  baseUrl,
@@ -458,8 +460,13 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
458
460
  const createCompletionsStream = async (toolStrictModeOverride?: ToolStrictModeOverride) => {
459
461
  clearCapturedErrorResponse();
460
462
  const effectiveToolStrictModeOverride = disableStrictTools ? "none" : toolStrictModeOverride;
461
- const { params, toolStrictMode } = buildParams(model, context, options, effectiveToolStrictModeOverride);
462
- appliedToolStrictMode = toolStrictMode;
463
+ const { params, strictToolsApplied } = buildParams(
464
+ model,
465
+ context,
466
+ options,
467
+ effectiveToolStrictModeOverride,
468
+ );
469
+ appliedStrictTools = strictToolsApplied;
463
470
  options?.onPayload?.(params);
464
471
  rawRequestDump = {
465
472
  provider: model.provider,
@@ -517,9 +524,15 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
517
524
  disableStrictTools = true;
518
525
  openaiStream = await createCompletionsStream("none");
519
526
  } else {
520
- if (!shouldRetryWithoutStrictTools(error, capturedErrorResponse, appliedToolStrictMode, context.tools)) {
527
+ if (!shouldRetryWithoutStrictTools(error, capturedErrorResponse, appliedStrictTools, context.tools)) {
521
528
  throw error;
522
529
  }
530
+ // Remember the rejection for the rest of the session so every
531
+ // subsequent request doesn't pay a strict-400 + retry round-trip.
532
+ if (providerSessionState) {
533
+ providerSessionState.strictToolsDisabled = true;
534
+ }
535
+ disableStrictTools = true;
523
536
  openaiStream = await createCompletionsStream("none");
524
537
  }
525
538
  }
@@ -1174,7 +1187,7 @@ function buildParams(
1174
1187
  context: Context,
1175
1188
  options: OpenAICompletionsOptions | undefined,
1176
1189
  toolStrictModeOverride?: ToolStrictModeOverride,
1177
- ): { params: OpenAICompletionsParams; toolStrictMode: AppliedToolStrictMode } {
1190
+ ): { params: OpenAICompletionsParams; toolStrictMode: AppliedToolStrictMode; strictToolsApplied: boolean } {
1178
1191
  let compat = model.compat;
1179
1192
  const thinkingEnabledForRequest =
1180
1193
  Boolean(options?.reasoning) && !options?.disableReasoning && Boolean(model.reasoning);
@@ -1211,6 +1224,7 @@ function buildParams(
1211
1224
  stream: true,
1212
1225
  };
1213
1226
  let toolStrictMode: AppliedToolStrictMode = "none";
1227
+ let strictToolsApplied = false;
1214
1228
 
1215
1229
  if (compat.supportsUsageInStreaming !== false) {
1216
1230
  params.stream_options = { include_usage: true };
@@ -1264,6 +1278,7 @@ function buildParams(
1264
1278
  const builtTools = convertTools(context.tools, compat, toolStrictModeOverride);
1265
1279
  params.tools = builtTools.tools;
1266
1280
  toolStrictMode = builtTools.toolStrictMode;
1281
+ strictToolsApplied = builtTools.strictToolsApplied;
1267
1282
  } else if (context.tools === undefined && hasToolHistory(context.messages)) {
1268
1283
  // Anthropic (via LiteLLM/proxy) requires the `tools` param when the conversation
1269
1284
  // contains tool_calls/tool_results, even when no tools are offered this turn.
@@ -1391,7 +1406,7 @@ function buildParams(
1391
1406
  }
1392
1407
  }
1393
1408
 
1394
- return { params, toolStrictMode };
1409
+ return { params, toolStrictMode, strictToolsApplied };
1395
1410
  }
1396
1411
 
1397
1412
  function getOptionalNumberProperty(value: object, key: string): number | undefined {
@@ -1651,6 +1666,8 @@ export function convertMessages(
1651
1666
  type: "image_url",
1652
1667
  image_url: {
1653
1668
  url: `data:${item.mimeType};base64,${item.data}`,
1669
+ // Chat Completions has no "original"; omit it (provider default).
1670
+ ...(item.detail && item.detail !== "original" ? { detail: item.detail } : {}),
1654
1671
  },
1655
1672
  } satisfies ChatCompletionContentPartImage);
1656
1673
  } else {
@@ -1999,16 +2016,19 @@ function convertTools(
1999
2016
  };
2000
2017
  }),
2001
2018
  toolStrictMode,
2019
+ strictToolsApplied:
2020
+ tools.length > 0 &&
2021
+ (toolStrictMode === "all_strict" || (toolStrictMode === "mixed" && adaptedTools.some(tool => tool.strict))),
2002
2022
  };
2003
2023
  }
2004
2024
 
2005
2025
  function shouldRetryWithoutStrictTools(
2006
2026
  error: unknown,
2007
2027
  capturedErrorResponse: CapturedHttpErrorResponse | undefined,
2008
- toolStrictMode: AppliedToolStrictMode,
2028
+ strictToolsApplied: boolean,
2009
2029
  tools: Tool[] | undefined,
2010
2030
  ): boolean {
2011
- if (!tools || tools.length === 0 || toolStrictMode !== "all_strict") {
2031
+ if (!tools || tools.length === 0 || !strictToolsApplied) {
2012
2032
  return false;
2013
2033
  }
2014
2034
  const status = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
@@ -2018,7 +2038,14 @@ function shouldRetryWithoutStrictTools(
2018
2038
  const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText]
2019
2039
  .filter((value): value is string => typeof value === "string" && value.trim().length > 0)
2020
2040
  .join("\n");
2021
- return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool/i.test(messageParts);
2041
+ // Last two alternatives catch upstream tool-schema validators rejecting our
2042
+ // strictified schemas outright (e.g. OpenRouter DeepSeek's "Invalid tool
2043
+ // parameters schema : field `anyOf`: missing field `type`", #2270, and
2044
+ // OpenAI's own "Invalid schema for function 'x'"). Retrying non-strict sends
2045
+ // the unmodified base schemas, which those validators accept.
2046
+ return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool|tool parameters? schema|invalid schema for function/i.test(
2047
+ messageParts,
2048
+ );
2022
2049
  }
2023
2050
 
2024
2051
  function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
@@ -289,7 +289,7 @@ export function convertResponsesInputContent(
289
289
  for (const item of imageBlocks) {
290
290
  normalizedContent.push({
291
291
  type: "input_image",
292
- detail: "auto",
292
+ detail: item.detail ?? "auto",
293
293
  image_url: `data:${item.mimeType};base64,${item.data}`,
294
294
  } satisfies ResponseInputImage);
295
295
  }
@@ -448,7 +448,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
448
448
  if (block.type === "image") {
449
449
  contentParts.push({
450
450
  type: "input_image",
451
- detail: "auto",
451
+ detail: block.detail ?? "auto",
452
452
  image_url: `data:${block.mimeType};base64,${block.data}`,
453
453
  } satisfies ResponseInputImage);
454
454
  }
@@ -139,6 +139,10 @@ function getLatestSurvivingAssistantIndex(messages: readonly Message[]): number
139
139
  return -1;
140
140
  }
141
141
 
142
+ function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> {
143
+ return model.api === "anthropic-messages";
144
+ }
145
+
142
146
  /**
143
147
  * Normalize tool call ID for cross-provider compatibility.
144
148
  * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
@@ -184,10 +188,45 @@ export function transformMessages<TApi extends Api>(
184
188
  assistantMsg.api === model.api &&
185
189
  assistantMsg.model === model.id;
186
190
 
187
- const mustPreserveLatestAnthropicThinking =
188
- index === latestSurvivingAssistantIndex &&
189
- model.api === "anthropic-messages" &&
190
- assistantMsg.api === "anthropic-messages";
191
+ const isAnthropicTarget = isAnthropicMessagesModel(model);
192
+ // Anthropic's all-or-none contract on prior-turn thinking blocks
193
+ // applies to every `anthropic-messages → anthropic-messages` replay,
194
+ // not just the latest assistant turn. The legacy
195
+ // `mustPreserveLatestAnthropicThinking` flag only honored it for the
196
+ // latest turn; every prior turn fell through to the cross-API
197
+ // text-demotion path whenever the conversation crossed a model id,
198
+ // silently dropping the reasoning chain on continuation for custom
199
+ // anthropic-messages providers configured via `models.yaml` and
200
+ // session-level model swaps (#2257).
201
+ const isAnthropicReplay = isAnthropicTarget && assistantMsg.api === "anthropic-messages";
202
+ const isLatestSurvivingAssistant = index === latestSurvivingAssistantIndex;
203
+ // Signature policy is a second axis. Anthropic cryptographically
204
+ // binds reasoning signatures to its key+session+model, so cross-model
205
+ // signatures must be stripped whenever official Anthropic is on
206
+ // either end of the replay:
207
+ // * official → 3p: the 3p target can't reverify the signature;
208
+ // keeping it leaks private continuation metadata for no benefit.
209
+ // * 3p → official: official rejects a foreign signature outright.
210
+ // * official → official cross-model: the new model rejects the
211
+ // previous model's signature.
212
+ // 3p ↔ 3p replays preserve signatures because compatible providers
213
+ // (Z.AI, DeepSeek, custom `models.yaml` providers) treat them as
214
+ // opaque continuation hints rather than verified material; stripping
215
+ // degrades the reasoning chain into unsigned/text on the next turn
216
+ // (#2265). Source-side official detection uses the canonical catalog
217
+ // provider id `"anthropic"` because assistant messages carry no
218
+ // `baseUrl` — a user who manually points `provider: "anthropic"` at
219
+ // a custom proxy via `models.yaml` will see signatures stripped, the
220
+ // conservative direction (degraded reasoning, not broken requests).
221
+ const isOfficialAnthropicSource = isAnthropicReplay && assistantMsg.provider === "anthropic";
222
+ const isOfficialAnthropicTarget = isAnthropicTarget && model.compat.officialEndpoint;
223
+ const officialAnthropicInvolved = isOfficialAnthropicSource || isOfficialAnthropicTarget;
224
+ // Compatible Anthropic-messages reasoning targets that accept
225
+ // unsigned thinking natively (Z.AI, DeepSeek, the generic
226
+ // `reasoning && !official` case in the compat builder). Used to keep
227
+ // `redacted_thinking` siblings beside unsigned visible thinking on
228
+ // targets that won't text-demote it.
229
+ const replaysUnsignedAnthropicThinking = isAnthropicTarget && model.compat.replayUnsignedThinking;
191
230
  // Thinking signatures can be untrustworthy for two distinct reasons with very
192
231
  // different blast radii:
193
232
  //
@@ -226,11 +265,37 @@ export function transformMessages<TApi extends Api>(
226
265
  // untrustworthy signature so the encoder can downgrade the block to text.
227
266
  const signatureUntrustworthy =
228
267
  abandonedToolUse || (invalidStopReason && blockIndex === lastBlockIndex);
229
- const sanitized =
268
+ let sanitized: typeof block =
230
269
  signatureUntrustworthy && block.thinkingSignature
231
270
  ? { ...block, thinkingSignature: undefined }
232
271
  : block;
233
- if (mustPreserveLatestAnthropicThinking) return abandonedToolUse ? block : sanitized;
272
+ if (isAnthropicReplay) {
273
+ // Latest abandoned turn: Anthropic's byte-for-byte rule forbids
274
+ // even stripping a signature on the latest message.
275
+ if (isLatestSurvivingAssistant && abandonedToolUse) return block;
276
+ // Cross-model prior turns crossing an official Anthropic endpoint
277
+ // must strip the source signature so the downstream encoder
278
+ // applies its `replayUnsignedThinking` policy (unsigned thinking
279
+ // is emitted natively on Anthropic-compatible reasoning endpoints
280
+ // and demoted to text on official Anthropic). 3p ↔ 3p replays
281
+ // keep the signature so the reasoning chain stays signed on
282
+ // continuation (#2265).
283
+ if (
284
+ !isLatestSurvivingAssistant &&
285
+ !isSameModel &&
286
+ officialAnthropicInvolved &&
287
+ sanitized.thinkingSignature
288
+ ) {
289
+ sanitized = { ...sanitized, thinkingSignature: undefined };
290
+ }
291
+ // Drop blocks with neither a signature anchor nor any text —
292
+ // nothing for the next turn to replay.
293
+ if (!sanitized.thinkingSignature && (!sanitized.thinking || sanitized.thinking.trim() === "")) {
294
+ return [];
295
+ }
296
+ return sanitized;
297
+ }
298
+ // Cross-API target: keep the existing text-demotion fallback.
234
299
  // For same model: keep thinking blocks with signatures (needed for replay)
235
300
  // even if the thinking text is empty (OpenAI encrypted reasoning)
236
301
  if (isSameModel && sanitized.thinkingSignature) return sanitized;
@@ -244,7 +309,15 @@ export function transformMessages<TApi extends Api>(
244
309
  }
245
310
 
246
311
  if (block.type === "redactedThinking") {
247
- if (mustPreserveLatestAnthropicThinking) return block;
312
+ // Redacted thinking is native-only. Keep it for same-model
313
+ // signed replay, the latest byte-for-byte Anthropic turn, or
314
+ // compatible targets that will also emit sibling unsigned
315
+ // thinking natively. Drop it when the visible thinking was
316
+ // cross-model stripped and will be demoted to text.
317
+ if (isAnthropicReplay) {
318
+ if (isSameModel || isLatestSurvivingAssistant || replaysUnsignedAnthropicThinking) return block;
319
+ return [];
320
+ }
248
321
  if (isSameModel) return block;
249
322
  return [];
250
323
  }
package/src/types.ts CHANGED
@@ -409,6 +409,12 @@ export interface ImageContent {
409
409
  type: "image";
410
410
  data: string; // base64 encoded image data
411
411
  mimeType: string; // e.g., "image/jpeg", "image/png"
412
+ /**
413
+ * OpenAI-only resolution hint. `"original"` preserves native resolution
414
+ * (required for snapcompact frames, whose glyphs do not survive the
415
+ * default `auto` downscale). Providers without a detail knob ignore it.
416
+ */
417
+ detail?: "auto" | "low" | "high" | "original";
412
418
  }
413
419
 
414
420
  export interface ToolCall {
@@ -37,8 +37,10 @@ When strict mode is requested (`strict=true` at call site), the schema MUST sati
37
37
  3. **Object and tuple strictness is enforced recursively**
38
38
  - Every object node gets `additionalProperties: false`.
39
39
  - Every property key is included in `required`.
40
- - Optional properties are wrapped as nullable unions:
41
- - `anyOf: [<original schema>, { "type": "null" }]`.
40
+ - Optional properties are made nullable:
41
+ - Pure union nodes (only `anyOf` plus optional `description`) get a `{ "type": "null" }` branch appended in place — never a nested wrapper.
42
+ - All other nodes are wrapped as `anyOf: [<original schema>, { "type": "null" }]`. Nodes with constraining siblings next to `anyOf` MUST keep the wrapper: sibling keywords are conjunctive with `anyOf`, so appending a null branch would not make the node nullable.
43
+ - Nested pure unions are spliced into the parent `anyOf` (`(A ∨ B) ∨ C` → `A ∨ B ∨ C`); an inner `description` is hoisted to the parent when the parent has none. Strict output MUST NOT contain an `anyOf` branch that is itself a pure union — some upstream validators (OpenRouter DeepSeek) reject branches without `type`.
42
44
  - Tuple entries in `prefixItems` are strictified recursively.
43
45
 
44
46
  4. **Schema nodes must be representable in strict mode**
@@ -1437,6 +1437,21 @@ export function sanitizeSchemaForStrictMode(
1437
1437
  return sanitized;
1438
1438
  }
1439
1439
 
1440
+ /**
1441
+ * A node whose only constraining keyword is `anyOf` (annotations like
1442
+ * `description` aside). Only such nodes can be merged into an enclosing
1443
+ * union without changing semantics: sibling keywords (`type`, `enum`,
1444
+ * `properties`, …) apply conjunctively with `anyOf`, so spreading the
1445
+ * branches of a non-pure node would drop those constraints.
1446
+ */
1447
+ function isPureAnyOfNode(value: unknown): value is Record<string, unknown> & { anyOf: unknown[] } {
1448
+ if (!isJsonObject(value) || !Array.isArray(value.anyOf)) return false;
1449
+ for (const key in value) {
1450
+ if (key !== "anyOf" && key !== "description") return false;
1451
+ }
1452
+ return true;
1453
+ }
1454
+
1440
1455
  /**
1441
1456
  * Recursively enforces JSON Schema constraints required by OpenAI/Codex strict mode:
1442
1457
  * - `additionalProperties: false` on every object node
@@ -1505,6 +1520,10 @@ function enforceStrictSchemaBody(
1505
1520
  strictProperties[key] = processed;
1506
1521
  continue;
1507
1522
  }
1523
+ if (isPureAnyOfNode(processed)) {
1524
+ strictProperties[key] = { ...processed, anyOf: [...processed.anyOf, { type: "null" }] };
1525
+ continue;
1526
+ }
1508
1527
  if (isJsonObject(processed) && typeof processed.description === "string") {
1509
1528
  const { description, ...withoutDescription } = processed;
1510
1529
  strictProperties[key] = { anyOf: [withoutDescription, { type: "null" }], description };
@@ -1545,6 +1564,26 @@ function enforceStrictSchemaBody(
1545
1564
  );
1546
1565
  }
1547
1566
  }
1567
+ // Splice nested pure unions into the parent `anyOf`: `(A ∨ B) ∨ C` ≡ `A ∨ B ∨ C`.
1568
+ // Some strict-mode validators (e.g. DeepSeek behind OpenRouter) reject anyOf
1569
+ // branches that carry no `type`, which is exactly what a nested combinator
1570
+ // node looks like (#2270). Branch recursion above already flattened deeper
1571
+ // levels bottom-up, so a single pass suffices.
1572
+ if (Array.isArray(result.anyOf) && result.anyOf.some(isPureAnyOfNode)) {
1573
+ const flattened: unknown[] = [];
1574
+ for (const branch of result.anyOf) {
1575
+ if (!isPureAnyOfNode(branch)) {
1576
+ flattened.push(branch);
1577
+ continue;
1578
+ }
1579
+ flattened.push(...branch.anyOf);
1580
+ // Keep the inner annotation when the parent has none.
1581
+ if (typeof branch.description === "string" && result.description === undefined) {
1582
+ result.description = branch.description;
1583
+ }
1584
+ }
1585
+ result.anyOf = flattened;
1586
+ }
1548
1587
  for (const defsKey of ["$defs", "definitions"] as const) {
1549
1588
  if (result[defsKey] != null && typeof result[defsKey] === "object" && !Array.isArray(result[defsKey])) {
1550
1589
  const defs = result[defsKey] as Record<string, unknown>;