MusePi

AI tool-schema normalization

@musepi/pi-ai exposes one unified schema normalizer that providers consume before tools are sent on the wire. All walkers live in packages/ai/src/utils/schema/normalize.ts; the operational contract is packages/ai/src/utils/schema/CONSTRAINTS.md.

There is no separate strict-mode.ts module any more — OpenAI strict-mode sanitization, OpenAI Responses oneOf rewriting, Google/Vertex/Gemini-CLI sanitization, Cloud Code Assist Claude sanitization, and MCP sanitization all share the same option-driven walk.

Entry points

All exports live under @musepi/pi-ai/utils/schema:

Removed in the unified-flow refactor:

Dispatcher mapping

Provider transport(s) Dispatcher
openai-completions, openai-responses, openai-codex-responses adaptSchemaForStrict (sanitize + enforce)
openai-responses family (oneOfanyOf only) normalizeSchemaForOpenAIResponses
google-generative-ai, google-vertex, Gemini CLI normalizeSchemaForGoogle
Cloud Code Assist Claude (Antigravity + GCA, claude-* model ids) normalizeSchemaForCCA
MCP inputSchema ingestion normalizeSchemaForMCP
anthropic-messages (native, not CCA) per-provider whitelist in anthropic.ts

Gemini CLI / Antigravity CCA MUST run the full normalizeSchemaForCCA pipeline (not just the first keyword-stripping pass) to keep parity with the shared Google Claude path.

Walk semantics

normalizeSchema upgrades inputs to JSON Schema 2020-12, dereferences the tree, then walks it with the option set pinned by the dispatcher. Each node:

  1. Renames snake_case combinator/property keys to camelCase (any_ofanyOf, etc.; collisions follow python-genai pop(from)/set(to) semantics — snake_case wins).
  2. Applies the handle_null_fields collapse for nullable unions before recursing into children.
  3. Strips keys the target provider does not support, optionally lifting human-meaningful keys (pattern, format, min/max, default, examples, …) into the sibling description via the spill formatter (spill.ts). Structural/meta keys ($ref, $defs, additionalProperties) are not spilled.
  4. Normalizes type unions (type: ["T", "null"]type: "T" + nullable marker on Google, plain type: "T" on CCA).
  5. Collapses object-only / same-type combiners, optionally lossy-collapses mixed-type combiners (CCA only), and runs the residual-combiner fixpoint.
  6. Validates with the in-house structural validator (isValidJsonSchema from meta-validator.ts) when validateAndFallback is set (CCA path) and emits the per-tool fallback { "type": "object", "properties": {} } on residual incompatibility — type array, type: "null", nullable key, or any remaining anyOf/oneOf/allOf.

OpenAI strict-mode pipeline

adaptSchemaForStrict(schema, strict) runs tryEnforceStrictSchema, which composes:

  1. Sanitize (sanitizeSchemaForStrictMode): strips non-structural keywords (format, pattern, min/max, examples, default, if/then/else, not, unevaluated*, patternProperties, dependent*, content*, min/maxProperties, $dynamicRef, etc.). The default value is inlined into the sibling description as ` (default: X) before being dropped, unless description already contains (default: or no description` exists.
  2. Enforce (enforceStrictSchema): every object node gets additionalProperties: false, every property goes into required, and optional properties become nullable unions (anyOf: [<original>, { "type": "null" }]). Tuple prefixItems are strictified recursively.

The two passes use cache/cycle guards, so refs, allOf, and nullable wrapping stay deterministic without recursing forever. tryEnforceStrictSchema is fail-open: if anything throws, it returns { strict: false, schema: upgraded } so callers MUST emit strict: true only when enforcement actually succeeded.

Edge cases the strict-mode normalizer handles

Performance: static fingerprint cache

resolveProviderModels in packages/catalog/src/model-manager.ts and readModelCache/writeModelCache in packages/catalog/src/model-cache.ts cooperate via a static_fingerprint column on the model_cache SQLite table (current cache schema version 6).

Cache rows written before the current schema version are dropped by the cache-version check; the column defaults to '' for any row that survives a version upgrade so the fingerprint-equality check naturally fails closed and the full merge re-runs.