@oh-my-pi/pi-ai 18.8.5 → 18.8.6

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,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.6] - 2026-10-08
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Google Gemini and Cloud Code Assist (Antigravity) requests failing when tool schemas contain unsupported JSON Schema keywords or fields that allow multiple types.
10
+ - Fixed auth broker account selection and usage-limit enforcement to consistently use the selected account’s quota when multiple accounts are present.
11
+ - Fixed Anthropic-family model streaming so encoded marker tokens are decoded correctly in text, tool-call updates, partial messages, and completed tool calls.
12
+
5
13
  ## [18.8.5] - 2026-10-08
6
14
 
7
15
  ### Added
@@ -2333,19 +2341,4 @@
2333
2341
  - Fixed OpenAI-compatible Ollama completions that return empty `finish_reason:length` after filling `num_ctx` so they surface an actionable context-window error instead of an empty length stop. ([#2774](https://github.com/can1357/oh-my-pi/issues/2774))
2334
2342
  - Fixed Codex browser login issuing credentials for the `opencode` OAuth originator while OMP requests identify as `pi`, which could make the first authenticated Codex request return 401 ([#2696](https://github.com/can1357/oh-my-pi/issues/2696)).
2335
2343
 
2336
- ## [16.0.1] - 2026-06-15
2337
-
2338
- ### Added
2339
-
2340
- - Added Umans AI Coding Plan API-key login support and `UMANS_AI_CODING_PLAN_API_KEY` environment fallback ([#2636](https://github.com/can1357/oh-my-pi/pull/2636) by [@oldschoola](https://github.com/oldschoola)).
2341
-
2342
- ### Fixed
2343
-
2344
- - Fixed OpenAI Responses, Azure OpenAI Responses, and Codex Responses providers ignoring async `onPayload` replacement bodies. Provider payload hooks can now transform the actual request body sent upstream, matching the Anthropic/Gemini replacement contract.
2345
- - Fixed OpenAI-compatible chat-completions streams that send object-shaped tool arguments in fragments by deep-merging nested objects and task arrays instead of replacing earlier chunks. ([#2617](https://github.com/can1357/oh-my-pi/issues/2617))
2346
- - Fixed OpenAI Responses strict-mode tool schema normalization for nullable enum MCP parameters so enum constraints are distributed to matching `anyOf` branches instead of being copied onto the `null` branch. ([#1835](https://github.com/can1357/oh-my-pi/issues/1835))
2347
- - Fixed Cursor provider formatting tool errors with the same `[Tool Result]` prefix as successful results, causing Composer models to misinterpret error messages (e.g. "Pattern must not be empty") as directives over long conversations. Errors now use a `[Tool Error]` prefix so the model can distinguish failures from successes in the prompt history. ([#1853](https://github.com/can1357/oh-my-pi/pull/1853))
2348
- - Fixed `validateToolArguments` silently accepting JSON-encoded array strings (e.g. `'["a","b"]'`) against `union(string, array<string>)` schemas — providers that double-serialize tool-call arguments (Z.AI / GLM) caused tools like `search` to receive the literal `["a","b"]` as a single path, producing zero matches (single element) or glob parse errors (multi-element). A new pre-validation pass parses JSON-array-shaped strings when the schema explicitly accepts both shapes. ([#1788](https://github.com/can1357/oh-my-pi/issues/1788))
2349
- - Fixed Anthropic thinking summaries that arrive wrapped in literal `<thinking>` tags so advisor/raw transcript dumps do not render nested thinking tags ([#2695](https://github.com/can1357/oh-my-pi/issues/2695)).
2350
-
2351
- Older entries are archived in [packages/ai/CHANGELOG.md@d22e34333695](https://github.com/can1357/oh-my-pi/blob/d22e34333695d3dd9de7741ff45903681dba4606/packages/ai/CHANGELOG.md).
2344
+ Older entries are archived in [packages/ai/CHANGELOG.md@0dd6aff5f282](https://github.com/can1357/oh-my-pi/blob/0dd6aff5f2821aec1e5b54c6a458e323667a949b/packages/ai/CHANGELOG.md).
@@ -4,7 +4,7 @@ import { AssistantMessageEventStream } from "./event-stream.js";
4
4
  export interface GlyphCodec {
5
5
  /** Context with model-bound glyphs encoded and one conditional convention notice. */
6
6
  context: Context;
7
- /** Decodes glyph tokens in the stream's terminal assistant message. */
7
+ /** Decodes glyph tokens live: text and tool-call deltas, partial snapshots, completed tool calls, and the terminal message. */
8
8
  wrap(inner: AssistantMessageEventStream): AssistantMessageEventStream;
9
9
  /** Decodes Cursor Pi calls and encodes their provider-bound results. */
10
10
  wrapCursorExecHandlers(handlers: CursorExecHandlers): CursorExecHandlers;
@@ -10,7 +10,11 @@ export interface NormalizeSchemaOptions {
10
10
  unsupportedFields: (key: string) => boolean;
11
11
  normalizeFieldNames: boolean;
12
12
  collapseNullFields: boolean;
13
- normalizeTypeArrayToNullable: boolean;
13
+ /**
14
+ * Target takes a scalar `type` only: `["T", "null"]` becomes `T` (+ `nullable`)
15
+ * and a multi-type array becomes one `anyOf` branch per type.
16
+ */
17
+ scalarizeTypeArrays: boolean;
14
18
  stripNullableKeyword: boolean;
15
19
  autoPropertyOrdering: boolean;
16
20
  ensureObjectProperties: boolean;
@@ -66,8 +70,9 @@ export declare function normalizeSchemaForMCP(value: unknown): unknown;
66
70
  * rejected; collapse to `enum` with an inferred scalar `type`.
67
71
  * - `oneOf` is not an MFJS combinator (only `anyOf` is); residual `oneOf` is
68
72
  * folded into `anyOf`.
69
- * - `type` must be a scalar string; `type: [...]` arrays are reduced to a
70
- * single scalar (the `null` branch is dropped — `nullable` is unsupported).
73
+ * - `type` must be a scalar string; `["T", "null"]` reduces to `T` (the
74
+ * `null` member is dropped — `nullable` is unsupported) and a multi-type
75
+ * array becomes one typed `anyOf` branch per type.
71
76
  * - Enum-bearing nodes get an inferred `type` (the idiomatic MFJS form; a bare
72
77
  * `enum` is valid too) so `anyOf` branches always carry a `type`.
73
78
  * - Validation/decorative keywords (`minItems`, `maxItems`, `maxLength`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.8.5",
3
+ "version": "18.8.6",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.8.5",
159
- "@oh-my-pi/pi-catalog": "18.8.5",
160
- "@oh-my-pi/pi-natives": "18.8.5",
161
- "@oh-my-pi/pi-utils": "18.8.5",
162
- "@oh-my-pi/pi-wire": "18.8.5"
158
+ "@oh-my-pi/omptype": "18.8.6",
159
+ "@oh-my-pi/pi-catalog": "18.8.6",
160
+ "@oh-my-pi/pi-natives": "18.8.6",
161
+ "@oh-my-pi/pi-utils": "18.8.6",
162
+ "@oh-my-pi/pi-wire": "18.8.6"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -1582,9 +1582,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1582
1582
  * pick the one whose identity (accountId / email / projectId) lines up with
1583
1583
  * the credential the caller is asking about.
1584
1584
  *
1585
- * Falls back to the lone candidate when only one matches the provider; falls
1586
- * through to `null` when nothing matches, which `AuthStorage` treats as "no
1587
- * usage data" (ranking proceeds without a usage signal for this credential).
1585
+ * Falls back to the provider's lone report when it shares no identity field
1586
+ * with the credential; falls through to `null` when nothing matches, which
1587
+ * `AuthStorage` treats as "no usage data" (ranking proceeds without a usage
1588
+ * signal for this credential).
1588
1589
  */
1589
1590
  function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null {
1590
1591
  const all = reports.filter(report => report.provider === provider);
@@ -1635,7 +1636,17 @@ function matchUsageReport(reports: UsageReport[], provider: Provider, credential
1635
1636
  report => !readMetadataString((report.metadata ?? {}) as Record<string, unknown>, "orgId"),
1636
1637
  );
1637
1638
  if (candidates.length === 0) return null;
1638
- if (all.length === 1 && candidates.length === 1) return candidates[0];
1639
+ // The sole report stands in for a credential it shares no identity field
1640
+ // with; otherwise identity decides, because a report naming another email,
1641
+ // account or project is a sibling's pool whose fetch succeeded where this
1642
+ // credential's failed.
1643
+ if (
1644
+ all.length === 1 &&
1645
+ candidates.length === 1 &&
1646
+ !reportHasComparableIdentity(candidates[0], accountId, email, projectId)
1647
+ ) {
1648
+ return candidates[0];
1649
+ }
1639
1650
  for (const report of candidates) {
1640
1651
  if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
1641
1652
  }
@@ -1699,7 +1710,13 @@ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport):
1699
1710
  candidate => !readMetadataString((candidate.report.metadata ?? {}) as Record<string, unknown>, "orgId"),
1700
1711
  );
1701
1712
  if (candidates.length === 0) return -1;
1702
- if (all.length === 1 && candidates.length === 1) return candidates[0]!.index;
1713
+ if (
1714
+ all.length === 1 &&
1715
+ candidates.length === 1 &&
1716
+ !reportHasComparableIdentity(candidates[0]!.report, accountId, email, projectId)
1717
+ ) {
1718
+ return candidates[0]!.index;
1719
+ }
1703
1720
  for (const candidate of candidates) {
1704
1721
  if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
1705
1722
  }
@@ -1721,19 +1738,39 @@ function reportMatchesIdentity(
1721
1738
  const metaAccount = readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id");
1722
1739
  if (metaAccount && metaAccount.toLowerCase() === accountId) return true;
1723
1740
  for (const limit of report.limits) {
1724
- if (limit.scope.accountId?.toLowerCase() === accountId) return true;
1741
+ if (limit.scope.accountId?.trim().toLowerCase() === accountId) return true;
1725
1742
  }
1726
1743
  }
1727
1744
  if (projectId) {
1728
1745
  const metaProject = readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id");
1729
1746
  if (metaProject && metaProject.toLowerCase() === projectId) return true;
1730
1747
  for (const limit of report.limits) {
1731
- if (limit.scope.projectId?.toLowerCase() === projectId) return true;
1748
+ if (limit.scope.projectId?.trim().toLowerCase() === projectId) return true;
1732
1749
  }
1733
1750
  }
1734
1751
  return false;
1735
1752
  }
1736
1753
 
1754
+ /** Whether the report names an email, account or project the caller can compare against. */
1755
+ function reportHasComparableIdentity(
1756
+ report: UsageReport,
1757
+ accountId: string | undefined,
1758
+ email: string | undefined,
1759
+ projectId: string | undefined,
1760
+ ): boolean {
1761
+ const metadata = report.metadata ?? {};
1762
+ if (email && readMetadataString(metadata, "email")) return true;
1763
+ if (accountId) {
1764
+ if (readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id")) return true;
1765
+ if (report.limits.some(limit => limit.scope.accountId?.trim())) return true;
1766
+ }
1767
+ if (projectId) {
1768
+ if (readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id")) return true;
1769
+ if (report.limits.some(limit => limit.scope.projectId?.trim())) return true;
1770
+ }
1771
+ return false;
1772
+ }
1773
+
1737
1774
  function readMetadataString(metadata: Record<string, unknown>, key: string): string | undefined {
1738
1775
  const value = metadata[key];
1739
1776
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
@@ -1,4 +1,13 @@
1
- import type { AssistantMessage, Context, CursorExecHandlers, ImageContent, Message, TextContent } from "../types";
1
+ import type {
2
+ AssistantMessage,
3
+ AssistantMessageEvent,
4
+ Context,
5
+ CursorExecHandlers,
6
+ ImageContent,
7
+ Message,
8
+ TextContent,
9
+ ToolCall,
10
+ } from "../types";
2
11
  import { getStreamingPartialJson, setStreamingPartialJson } from "./block-symbols";
3
12
  import { AssistantMessageEventStream } from "./event-stream";
4
13
  import glyphNotice from "./glyph-notice.md" with { type: "text" };
@@ -10,6 +19,9 @@ const GLYPH_ENCODE_PATTERN =
10
19
  /([\uE000-\uF8FF\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}])|⟦(?=(?:U[0-9a-fA-F]{4,6}|E)⟧)/gu;
11
20
  const GLYPH_DECODE_PATTERN = /⟦E⟧|⟦U([0-9a-fA-F]{4,6})⟧/gu;
12
21
 
22
+ /** A trailing token or escape prefix that a later delta may still complete; sticky, tested at the last opener. */
23
+ const GLYPH_PENDING_TAIL = /⟦(?:U[0-9a-fA-F]{0,6}|E)?$/uy;
24
+
13
25
  const kGlyphEncoded = Symbol("provider.context.glyphEncoded");
14
26
 
15
27
  interface GlyphEncodedContext extends Context {
@@ -20,7 +32,7 @@ interface GlyphEncodedContext extends Context {
20
32
  export interface GlyphCodec {
21
33
  /** Context with model-bound glyphs encoded and one conditional convention notice. */
22
34
  context: Context;
23
- /** Decodes glyph tokens in the stream's terminal assistant message. */
35
+ /** Decodes glyph tokens live: text and tool-call deltas, partial snapshots, completed tool calls, and the terminal message. */
24
36
  wrap(inner: AssistantMessageEventStream): AssistantMessageEventStream;
25
37
  /** Decodes Cursor Pi calls and encodes their provider-bound results. */
26
38
  wrapCursorExecHandlers(handlers: CursorExecHandlers): CursorExecHandlers;
@@ -287,15 +299,196 @@ function decodeAssistantMessageInPlace(message: AssistantMessage): void {
287
299
  }
288
300
  }
289
301
 
302
+ /** Decoded copy of a tool call, or the block itself when it carries no tokens. */
303
+ function decodeToolCall(block: ToolCall): ToolCall {
304
+ const args = transformRecord(block.arguments, decodeGlyphText);
305
+ const partialJson = getStreamingPartialJson(block);
306
+ const json = partialJson === undefined ? undefined : decodeGlyphText(partialJson);
307
+ if (args === block.arguments && json === partialJson) return block;
308
+ const decoded: ToolCall = { ...block, arguments: args };
309
+ if (json !== undefined) setStreamingPartialJson(decoded, json);
310
+ return decoded;
311
+ }
312
+
313
+ /** Decodes one streamed block incrementally, holding back a token split across deltas. */
314
+ class GlyphDeltaDecoder {
315
+ #pending = "";
316
+
317
+ /** Decodes everything in the held-back tail plus `delta` that no later delta can still change. */
318
+ push(delta: string): string {
319
+ const text = this.#pending + delta;
320
+ const open = text.lastIndexOf(GLYPH_OPEN);
321
+ let cut = text.length;
322
+ if (open >= 0) {
323
+ GLYPH_PENDING_TAIL.lastIndex = open;
324
+ if (GLYPH_PENDING_TAIL.test(text)) cut = open;
325
+ }
326
+ this.#pending = text.slice(cut);
327
+ return decodeGlyphText(cut === text.length ? text : text.slice(0, cut));
328
+ }
329
+
330
+ /** Releases the held-back tail verbatim: an unfinished token is literal text. */
331
+ flush(): string {
332
+ const tail = this.#pending;
333
+ this.#pending = "";
334
+ return tail;
335
+ }
336
+ }
337
+
338
+ /** Decode state for one streamed text or tool-call block. */
339
+ interface GlyphChannel {
340
+ kind: "text" | "toolCall";
341
+ decoder: GlyphDeltaDecoder;
342
+ /** Decoded text forwarded so far; text blocks only. */
343
+ text: string;
344
+ /** Whether a delta carried a token opener; tool-call stand-ins are only rebuilt then. */
345
+ tokens: boolean;
346
+ /** Decoded stand-in for the provider's block in forwarded partials. */
347
+ view?: AssistantMessage["content"][number];
348
+ }
349
+
350
+ /**
351
+ * Re-projects a provider stream with glyph tokens decoded as they arrive, so
352
+ * delta consumers (side-channel replies, argument streams) and partial
353
+ * snapshots (live rendering, speculation) never see wire tokens.
354
+ *
355
+ * Provider-owned blocks are never mutated mid-stream — the provider keeps
356
+ * appending to them — so decoded stand-ins are swapped into each forwarded
357
+ * partial instead. The terminal message is decoded in place once. Thinking
358
+ * stays encoded, matching {@link decodeAssistantMessageInPlace}.
359
+ */
360
+ class GlyphStreamProjector {
361
+ readonly #out: AssistantMessageEventStream;
362
+ readonly #channels = new Map<number, GlyphChannel>();
363
+ /** Set once any block has a stand-in; until then events pass through untouched. */
364
+ #projected = false;
365
+
366
+ constructor(out: AssistantMessageEventStream) {
367
+ this.#out = out;
368
+ }
369
+
370
+ push(event: AssistantMessageEvent): void {
371
+ switch (event.type) {
372
+ case "start":
373
+ this.#channels.clear();
374
+ this.#projected = false;
375
+ this.#out.push(event);
376
+ return;
377
+ case "text_delta": {
378
+ const channel = this.#channel(event.contentIndex, "text");
379
+ const delta = channel.decoder.push(event.delta);
380
+ channel.text += delta;
381
+ const block = event.partial.content[event.contentIndex];
382
+ if (block?.type === "text" && (channel.view !== undefined || delta !== event.delta)) {
383
+ this.#setView(channel, { ...block, text: channel.text });
384
+ }
385
+ if (!delta) return;
386
+ this.#out.push(
387
+ delta === event.delta && !this.#projected
388
+ ? event
389
+ : { ...event, delta, partial: this.#partial(event.partial) },
390
+ );
391
+ return;
392
+ }
393
+ case "text_end": {
394
+ const channel = this.#channel(event.contentIndex, "text");
395
+ this.#flush(event.contentIndex, channel, event.partial);
396
+ const content = decodeGlyphText(event.content);
397
+ const block = event.partial.content[event.contentIndex];
398
+ if (block?.type === "text" && (channel.view !== undefined || content !== event.content)) {
399
+ this.#setView(channel, { ...block, text: content });
400
+ }
401
+ this.#out.push(
402
+ content === event.content && !this.#projected
403
+ ? event
404
+ : { ...event, content, partial: this.#partial(event.partial) },
405
+ );
406
+ return;
407
+ }
408
+ case "toolcall_delta": {
409
+ const channel = this.#channel(event.contentIndex, "toolCall");
410
+ const delta = channel.decoder.push(event.delta);
411
+ channel.tokens ||= event.delta.includes(GLYPH_OPEN);
412
+ const block = event.partial.content[event.contentIndex];
413
+ if (channel.tokens && block?.type === "toolCall") {
414
+ const view = decodeToolCall(block);
415
+ if (view === block) channel.view = undefined;
416
+ else this.#setView(channel, view);
417
+ }
418
+ if (!delta) return;
419
+ this.#out.push(
420
+ delta === event.delta && !this.#projected
421
+ ? event
422
+ : { ...event, delta, partial: this.#partial(event.partial) },
423
+ );
424
+ return;
425
+ }
426
+ case "toolcall_end": {
427
+ const channel = this.#channel(event.contentIndex, "toolCall");
428
+ this.#flush(event.contentIndex, channel, event.partial);
429
+ const toolCall = decodeToolCall(event.toolCall);
430
+ if (toolCall === event.toolCall) channel.view = undefined;
431
+ else this.#setView(channel, toolCall);
432
+ this.#out.push(
433
+ toolCall === event.toolCall && !this.#projected
434
+ ? event
435
+ : { ...event, toolCall, partial: this.#partial(event.partial) },
436
+ );
437
+ return;
438
+ }
439
+ case "done":
440
+ case "error": {
441
+ const message = event.type === "done" ? event.message : event.error;
442
+ for (const [index, channel] of this.#channels) this.#flush(index, channel, message);
443
+ decodeAssistantMessageInPlace(message);
444
+ this.#out.push(event);
445
+ return;
446
+ }
447
+ default:
448
+ this.#out.push(this.#projected ? { ...event, partial: this.#partial(event.partial) } : event);
449
+ }
450
+ }
451
+
452
+ #channel(index: number, kind: GlyphChannel["kind"]): GlyphChannel {
453
+ let channel = this.#channels.get(index);
454
+ if (channel === undefined) {
455
+ channel = { kind, decoder: new GlyphDeltaDecoder(), text: "", tokens: false };
456
+ this.#channels.set(index, channel);
457
+ }
458
+ return channel;
459
+ }
460
+
461
+ #setView(channel: GlyphChannel, view: AssistantMessage["content"][number]): void {
462
+ channel.view = view;
463
+ this.#projected = true;
464
+ }
465
+
466
+ /** Forwards a block's held-back tail before the block ends or the stream settles. */
467
+ #flush(index: number, channel: GlyphChannel, partial: AssistantMessage): void {
468
+ const tail = channel.decoder.flush();
469
+ if (!tail) return;
470
+ if (channel.kind === "toolCall") {
471
+ this.#out.push({ type: "toolcall_delta", contentIndex: index, delta: tail, partial: this.#partial(partial) });
472
+ return;
473
+ }
474
+ channel.text += tail;
475
+ const block = partial.content[index];
476
+ if (block?.type === "text") this.#setView(channel, { ...block, text: channel.text });
477
+ this.#out.push({ type: "text_delta", contentIndex: index, delta: tail, partial: this.#partial(partial) });
478
+ }
479
+
480
+ #partial(partial: AssistantMessage): AssistantMessage {
481
+ if (!this.#projected) return partial;
482
+ return { ...partial, content: partial.content.map((block, index) => this.#channels.get(index)?.view ?? block) };
483
+ }
484
+ }
485
+
290
486
  function wrapGlyphStream(inner: AssistantMessageEventStream): AssistantMessageEventStream {
291
487
  const out = new AssistantMessageEventStream();
488
+ const projector = new GlyphStreamProjector(out);
292
489
  void (async () => {
293
490
  try {
294
- for await (const event of inner) {
295
- if (event.type === "done") decodeAssistantMessageInPlace(event.message);
296
- else if (event.type === "error") decodeAssistantMessageInPlace(event.error);
297
- out.push(event);
298
- }
491
+ for await (const event of inner) projector.push(event);
299
492
  if (!out.done) {
300
493
  const result = await inner.result();
301
494
  decodeAssistantMessageInPlace(result);
@@ -64,19 +64,21 @@ Schemas sent on the Google JSON Schema path MUST follow:
64
64
 
65
65
  1. **Unsupported JSON Schema keywords are stripped (except property names under `properties`)**
66
66
  - Unsupported keys (`UNSUPPORTED_SCHEMA_FIELDS`):
67
- - `$schema`, `$ref`, `$defs`, `$dynamicRef`, `$dynamicAnchor`
67
+ - `$schema`, `$id`, `$anchor`, `$ref`, `$defs`, `$dynamicRef`, `$dynamicAnchor`
68
68
  - `examples`, `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`
69
- - `patternProperties`, `additionalProperties`
70
- - `minItems`, `maxItems`, `minLength`, `maxLength`
71
- - `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`
72
- - `pattern`, `format`
69
+ - `patternProperties`, `additionalProperties`, `propertyNames`
70
+ - `minItems`, `maxItems`, `uniqueItems`, `contains`, `minContains`, `maxContains`, `additionalItems`
71
+ - `minLength`, `maxLength`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`
72
+ - `pattern`, `format`, `contentEncoding`, `contentMediaType`, `contentSchema`
73
73
  - `dependencies`, `dependentSchemas`, `dependentRequired`
74
- - `deprecated`, `readOnly`, `writeOnly`, `$comment`
74
+ - `x-mcp-header`, `deprecated`, `readOnly`, `writeOnly`, `$comment`
75
75
  - Important: keys inside a `properties` object are treated as property names and MUST NOT be stripped by keyword match.
76
76
  - Human-meaningful stripped keys (`pattern`, `format`, min/max constraints, `default`, `examples`, etc.) are appended to the sibling `description` as an Anthropic-style spill block: `{pattern: "^foo$", minimum: 0}`. Structural/meta keys such as `$ref`, `$defs`, and `additionalProperties` are not spilled.
77
77
 
78
78
  2. **`type` arrays are normalized to scalar type + nullable marker**
79
79
  - `type: ["T", "null"]` becomes `type: "T"` and `nullable: true`.
80
+ - `type: ["A", "B", …]` becomes `anyOf: [{ type: "A", …A keywords }, { type: "B", …B keywords }]`; type-specific keywords (`items`, `properties`, `minimum`, …) move into the branches whose type they constrain, and a `null` member becomes `nullable: true`.
81
+ - A node left with a scalar `type` MUST NOT keep another type's keywords: Gemini rejects `items` beside `type: "string"` with HTTP 400.
80
82
  - Google expects scalar type, not `type[]`.
81
83
 
82
84
  3. **`const` is converted to `enum`**
@@ -115,7 +117,7 @@ For Cloud Code Assist Claude tool declarations, schema MUST satisfy stricter con
115
117
 
116
118
  1. Start with Google unsupported-key stripping behavior.
117
119
  2. **`nullable` keyword MUST be stripped** in CCA Claude path.
118
- 3. `type: ["T", "null"]` becomes `type: "T"` with no `nullable` marker.
120
+ 3. `type: ["T", "null"]` becomes `type: "T"` with no `nullable` marker; a multi-type array becomes `anyOf` branches that the combiner rules below collapse.
119
121
  4. Human-meaningful stripped keys are appended to `description` with the same spill format used by the Google dispatcher.
120
122
 
121
123
  ### 3.3 Combiner/union normalization contract
@@ -15,6 +15,8 @@
15
15
  */
16
16
  export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
17
17
  $schema: true,
18
+ $id: true,
19
+ $anchor: true,
18
20
  $ref: true,
19
21
  $defs: true,
20
22
  $dynamicRef: true,
@@ -28,6 +30,11 @@ export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
28
30
  propertyNames: true,
29
31
  minItems: true,
30
32
  maxItems: true,
33
+ uniqueItems: true,
34
+ contains: true,
35
+ minContains: true,
36
+ maxContains: true,
37
+ additionalItems: true,
31
38
  minLength: true,
32
39
  maxLength: true,
33
40
  minimum: true,
@@ -37,6 +44,9 @@ export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
37
44
  multipleOf: true,
38
45
  pattern: true,
39
46
  format: true,
47
+ contentEncoding: true,
48
+ contentMediaType: true,
49
+ contentSchema: true,
40
50
  dependencies: true,
41
51
  dependentSchemas: true,
42
52
  dependentRequired: true,
@@ -212,6 +222,8 @@ export const COMBINATOR_KEYS = ["anyOf", "allOf", "oneOf"] as const;
212
222
  */
213
223
  export const CCA_UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
214
224
  $schema: true,
225
+ $id: true,
226
+ $anchor: true,
215
227
  $ref: true,
216
228
  $defs: true,
217
229
  $dynamicRef: true,
@@ -223,4 +235,12 @@ export const CCA_UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
223
235
  readOnly: true,
224
236
  writeOnly: true,
225
237
  $comment: true,
238
+ uniqueItems: true,
239
+ contains: true,
240
+ minContains: true,
241
+ maxContains: true,
242
+ additionalItems: true,
243
+ contentEncoding: true,
244
+ contentMediaType: true,
245
+ contentSchema: true,
226
246
  };
@@ -36,7 +36,11 @@ export interface NormalizeSchemaOptions {
36
36
  unsupportedFields: (key: string) => boolean;
37
37
  normalizeFieldNames: boolean;
38
38
  collapseNullFields: boolean;
39
- normalizeTypeArrayToNullable: boolean;
39
+ /**
40
+ * Target takes a scalar `type` only: `["T", "null"]` becomes `T` (+ `nullable`)
41
+ * and a multi-type array becomes one `anyOf` branch per type.
42
+ */
43
+ scalarizeTypeArrays: boolean;
40
44
  stripNullableKeyword: boolean;
41
45
  autoPropertyOrdering: boolean;
42
46
  ensureObjectProperties: boolean;
@@ -290,6 +294,51 @@ function preHandleNullFields(obj: JsonObject): JsonObject {
290
294
  return out;
291
295
  }
292
296
 
297
+ /**
298
+ * Rewrites a multi-type `type` array (`["string", "array"]`) as one `anyOf`
299
+ * branch per non-null type, each carrying only the keywords that constrain its
300
+ * type. Collapsing to the first type instead narrows what the tool accepts and
301
+ * strands the other types' keywords: Gemini rejects `items` beside
302
+ * `type: "string"` with HTTP 400. A `null` member becomes `nullable: true`, as
303
+ * in the scalar collapse. Returns `obj` itself when it has fewer than two
304
+ * non-null types or already carries `anyOf`.
305
+ */
306
+ function splitTypeArrayIntoAnyOf(obj: JsonObject): JsonObject {
307
+ if (!Array.isArray(obj.type) || Array.isArray(obj.anyOf)) return obj;
308
+ const types: string[] = [];
309
+ for (const type of obj.type) {
310
+ if (typeof type === "string" && type !== "null" && !types.includes(type)) types.push(type);
311
+ }
312
+ if (types.length < 2) return obj;
313
+ const branches = types.map(type => {
314
+ const schema: JsonObject = { type };
315
+ return { schema, keys: CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[type] ?? {} };
316
+ });
317
+ const out: JsonObject = {};
318
+ for (const key in obj) {
319
+ if (!Object.hasOwn(obj, key) || key === "type") continue;
320
+ const value = obj[key];
321
+ if (!Object.hasOwn(ALL_CCA_TYPE_SPECIFIC_KEYS, key)) {
322
+ out[key] = value;
323
+ continue;
324
+ }
325
+ for (const branch of branches) {
326
+ if (Object.hasOwn(branch.keys, key)) branch.schema[key] = value;
327
+ }
328
+ }
329
+ if (obj.type.includes("null")) out.nullable = true;
330
+ out.anyOf = branches.map(branch => branch.schema);
331
+ return out;
332
+ }
333
+
334
+ /** Deletes keywords that constrain a JSON type other than `type`, e.g. `items` on a string node. */
335
+ function dropForeignTypeKeywords(schema: JsonObject, type: string): void {
336
+ const allowed = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[type] ?? {};
337
+ for (const key in schema) {
338
+ if (Object.hasOwn(ALL_CCA_TYPE_SPECIFIC_KEYS, key) && !Object.hasOwn(allowed, key)) delete schema[key];
339
+ }
340
+ }
341
+
293
342
  function outHasOwn(obj: JsonObject, key: string): boolean {
294
343
  return Object.hasOwn(obj, key);
295
344
  }
@@ -375,6 +424,9 @@ function normalizeSchemaNode(value: unknown, options: NormalizeSchemaWalkOptions
375
424
 
376
425
  function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWalkOptions): unknown {
377
426
  let obj = options.normalizeFieldNames && !options.insideSchemaMap ? applySnakeCaseRenames(value) : value;
427
+ if (options.scalarizeTypeArrays && !options.insideSchemaMap) {
428
+ obj = splitTypeArrayIntoAnyOf(obj);
429
+ }
378
430
  if (options.collapseNullFields && !options.insideSchemaMap) {
379
431
  obj = preHandleNullFields(obj);
380
432
  }
@@ -479,13 +531,14 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa
479
531
  : entry;
480
532
  }
481
533
 
482
- if (options.normalizeTypeArrayToNullable && Array.isArray(result.type)) {
534
+ if (options.scalarizeTypeArrays && Array.isArray(result.type)) {
483
535
  const types = (result.type as unknown[]).filter((t): t is string => typeof t === "string");
484
536
  const nonNull = types.filter(t => t !== "null");
485
537
  if (types.includes("null") && !options.stripNullableKeyword) {
486
538
  result.nullable = true;
487
539
  }
488
540
  result.type = nonNull[0] ?? types[0];
541
+ if (typeof result.type === "string") dropForeignTypeKeywords(result, result.type);
489
542
  }
490
543
  if (constValue !== undefined) {
491
544
  const existingEnum = Array.isArray(result.enum) ? result.enum : [];
@@ -738,20 +791,8 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
738
791
  const chosenType: string = nonNullTypes[0] ?? variantTypes[0];
739
792
  nextSchema.type = chosenType;
740
793
  const chosenTypeAllowedKeys = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[chosenType] ?? {};
741
-
742
- // Strip sibling keys that were copied from the parent and belong to a
743
- // different type (e.g. `items` sibling on a now-string-typed schema).
744
- for (const key in nextSchema) {
745
- if (!Object.hasOwn(nextSchema, key)) continue;
746
- if (key === "type") continue;
747
- if (
748
- Object.hasOwn(ALL_CCA_TYPE_SPECIFIC_KEYS, key) &&
749
- !Object.hasOwn(chosenTypeAllowedKeys, key) &&
750
- !Object.hasOwn(CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, key)
751
- ) {
752
- delete nextSchema[key];
753
- }
754
- }
794
+ // Sibling keys copied from the parent may belong to a different type.
795
+ dropForeignTypeKeywords(nextSchema, chosenType);
755
796
 
756
797
  for (const key in mergedVariantFields) {
757
798
  if (!Object.hasOwn(mergedVariantFields, key)) continue;
@@ -1256,7 +1297,7 @@ export function normalizeSchemaForGoogle(value: unknown): unknown {
1256
1297
  unsupportedFields: isGoogleUnsupportedSchemaField,
1257
1298
  normalizeFieldNames: true,
1258
1299
  collapseNullFields: true,
1259
- normalizeTypeArrayToNullable: true,
1300
+ scalarizeTypeArrays: true,
1260
1301
  stripNullableKeyword: false,
1261
1302
  autoPropertyOrdering: true,
1262
1303
  ensureObjectProperties: true,
@@ -1279,7 +1320,7 @@ export function normalizeSchemaForCCA(value: unknown): unknown {
1279
1320
  unsupportedFields: isGoogleUnsupportedSchemaField,
1280
1321
  normalizeFieldNames: true,
1281
1322
  collapseNullFields: false,
1282
- normalizeTypeArrayToNullable: true,
1323
+ scalarizeTypeArrays: true,
1283
1324
  stripNullableKeyword: true,
1284
1325
  autoPropertyOrdering: false,
1285
1326
  ensureObjectProperties: true,
@@ -1302,7 +1343,7 @@ export function normalizeSchemaForMCP(value: unknown): unknown {
1302
1343
  unsupportedFields: isMcpUnsupportedSchemaField,
1303
1344
  normalizeFieldNames: false,
1304
1345
  collapseNullFields: false,
1305
- normalizeTypeArrayToNullable: false,
1346
+ scalarizeTypeArrays: false,
1306
1347
  foldOneOfIntoAnyOf: false,
1307
1348
  stripNullableKeyword: true,
1308
1349
  autoPropertyOrdering: false,
@@ -1330,8 +1371,9 @@ export function normalizeSchemaForMCP(value: unknown): unknown {
1330
1371
  * rejected; collapse to `enum` with an inferred scalar `type`.
1331
1372
  * - `oneOf` is not an MFJS combinator (only `anyOf` is); residual `oneOf` is
1332
1373
  * folded into `anyOf`.
1333
- * - `type` must be a scalar string; `type: [...]` arrays are reduced to a
1334
- * single scalar (the `null` branch is dropped — `nullable` is unsupported).
1374
+ * - `type` must be a scalar string; `["T", "null"]` reduces to `T` (the
1375
+ * `null` member is dropped — `nullable` is unsupported) and a multi-type
1376
+ * array becomes one typed `anyOf` branch per type.
1335
1377
  * - Enum-bearing nodes get an inferred `type` (the idiomatic MFJS form; a bare
1336
1378
  * `enum` is valid too) so `anyOf` branches always carry a `type`.
1337
1379
  * - Validation/decorative keywords (`minItems`, `maxItems`, `maxLength`,
@@ -1354,7 +1396,7 @@ export function normalizeSchemaForMoonshot(value: unknown): unknown {
1354
1396
  unsupportedFields: isMoonshotUnsupportedSchemaField,
1355
1397
  normalizeFieldNames: false,
1356
1398
  collapseNullFields: false,
1357
- normalizeTypeArrayToNullable: true,
1399
+ scalarizeTypeArrays: true,
1358
1400
  stripNullableKeyword: true,
1359
1401
  autoPropertyOrdering: false,
1360
1402
  ensureObjectProperties: false,