fix(ai): always send a root type:"object" on tool parameter schemas (#3245)

Merged
CO
10d ago
+30-6

Summary

DeepSeek's official API (api.deepseek.com) rejects every tool call whose root parameters JSON Schema does not declare type: "object":

HTTP 400  Invalid schema for function '<name>': schema must be a JSON Schema of 'type: "object"', got 'type: null'

senpi already merges object-only root unions into a single object schema (#710), but ensureRootObjectSchema skipped the root type-fill whenever a root combiner (anyOf/oneOf/allOf) existed. A root union that cannot merge — e.g. branches mixing object and scalar shapes — therefore went out with a typeless root and 400'd on every tool call. Reported by two users (omo and open-codex); fails with tools present, succeeds when re-sent without tools.

The OpenGateway DeepSeek route surfaces the same 400 with the same rejected field (verified: identical error text) — the gateway forwards faithfully, so this is a senpi request-shaping bug, not a gateway issue.

Root cause

packages/ai/src/utils/tool-schema-compat.ts — ensureRootObjectSchema:

const hasCombiner = COMBINER_KEYS.some((key) => Array.isArray(schema[key]));
if (hasCombiner) return schema;          // root stays typeless -> DeepSeek 400
return { ...schema, type: "object" };

When mergeRootObjectUnion bails on an unmergeable union, hasCombiner is true and the schema is returned with the combiner intact and no root type.

Fix

Fill the root type: "object" whenever it is missing, including when a combiner exists but could not merge. The combiner is kept — DeepSeek accepts { type: "object", anyOf: [...] } (live-verified). Object-only unions still merge exactly as before.

One block:

function ensureRootObjectSchema(schema) {
	const merged = mergeRootObjectUnion(schema);
	if (merged) return merged;
	if (schema.type !== undefined) return schema;
	return { ...schema, type: "object" };
}

Evidence

  • Bisect (live, 30+ request shapes against the DeepSeek route): 400 only for root schemas without type: "object" (unmergeable root union, root $ref without sibling type, empty parameters: {}). 200 for plain tools, strict: true, tool_choice, assistant replay with content: ""/null, reasoning_content: ""/text, streaming, format/patternProperties/additionalProperties/nested anyOf. DeepSeek's only hard constraint in this space is the root type: "object".
  • Verified the fixed shape is accepted: { type: "object", anyOf: [object, scalar] } -> 200.
  • Official-API note: the direct-probe key was out of credits (DeepSeek returns 402 Insufficient Balance before schema validation), so the discriminating bisect ran against the OpenGateway DeepSeek route, which surfaces DeepSeek's own validator response verbatim (identical error text and request-shape discrimination).

Tests

  • RED first: two provider-compat tests pin the DeepSeek contract — a root union of non-object branches and a typeless mixed object|scalar root union must both normalize to a root type: "object". (One existing test pinned the buggy behavior — expect(type).toBeUndefined() — and is updated to the correct contract.)
  • packages/ai/test/openai-completions-tool-schema-compat.test.ts: 18/18 pass.
  • Schema-adjacent suites (anthropic root-union / strict, bai, mistral, cursor, codex apply-patch): all pass.
  • Full packages/ai suite: 3767 passed; the only 3 failures (cursor-agent heartbeat, models-entry) reproduce identically on pristine main and are pre-existing, unrelated to this change — tracked in #3252.

Compatibility

  • No behavior change for schemas that already worked: object-only unions still merge, $ref roots and empty parameters still get their type-fill.
  • The one contract flip is the previously-pinned bug: an unmergeable root union now claims type: "object" (with the combiner kept) instead of going out typeless. Validators that accepted the typeless root accept the object-typed root too; DeepSeek (which rejected the typeless root) now accepts it.

Tracker / changelog

  • tool-schema-compat.ts is fork-only (not in upstream badlogic/pi-mono at the pin), so it is changes.md-exempt; scripts/audit-changes-md.mjs reports 0 uncovered.
  • packages/ai/CHANGELOG.md ## [Unreleased] > ### Fixed entry added.

Fixes #3245