Skip to content

[Bug]: Gemini models fail every turn (HTTP 400) because nullable tool parameters lack a top-level schema type #544

Open

Description

@qiurungeng

Product or interface

CLI - interactive TUI

Version

0.6.13 (also present on main @ da22f11)

Platform

macOS

OS version and architecture

macOS 26.4, arm64

Issue area

Tool call / permission / sandbox

Steps to reproduce

  1. Configure an OpenAI-compatible BYOK custom_provider that serves a Gemini model (e.g. gemini-4-argon, api: openai-completions).
  2. Select it as the model and send any prompt (e.g. "hi").
  3. The turn fails immediately; /retry fails the same way.

Expected and actual behavior

Expected: Gemini answers like other models.
Actual: every turn fails with Error The model provider is temporarily unavailable. Code: 50113. The upstream response is HTTP 400. The same key, endpoint and model work with plain curl.

Redacted error summary

400 Unable to submit request because `update_goal` functionDeclaration `parameters.token_budget` schema didn't specify the schema type field.
400 Unable to submit request because `mavis` functionDeclaration `***.args.timeout_ms` schema didn't specify the schema type field.

Summary

With a Gemini model (gemini-4-argon via an OpenAI-compatible BYOK custom_provider), every turn fails with 50113 The model provider is temporarily unavailable. The real upstream response is HTTP 400 because two built-in tool schemas contain a nullable union without a top-level type, which Gemini function declarations reject. The same key/endpoint/model work with plain curl and other clients.

Root cause

Tool Parameter Source
update_goal token_budget packages/agent-modules/goal/src/tool-defs.ts — Type.Union([Type.Integer(...), Type.Null(...)])
mavis args.timeout_ms packages/agent-tools/src/desktop/local-mavis.ts — z.union([z.number().int().positive(), z.null()])

Both serialize to {"anyOf":[{"type":"integer","minimum":1},{"type":"null"}]}. Gemini rejects this (also when a top-level type is added next to anyOf), but accepts the equivalent {"type":["integer","null"],"minimum":1} or {"type":"integer","nullable":true}. Gemini reports only one offending property per request and not always the same one, so fixing a single definition is insufficient.

Changing the definitions to Type.Integer({ nullable: true }) would break runtime validation (Value.Check rejects null, so update_goal could no longer clear a budget), and Type.Unsafe throws Unknown type in Value.Check.

Proposed fix (patch ready, verified)

Normalize only the provider-facing schema: add toProviderToolParameters() in packages/agent-core/src/pi-turn-runner/tools.ts, which recursively rewrites anyOf: [<single-typed schema>, {type:"null"}] → {...schema, type:[<type>,"null"]} (other anyOf shapes untouched). Apply it in newTool() (main agent-loop path → pi-ai convertTools) and the two auxiliary request builders in llm.ts / turn.ts. Hook-input revalidation keeps the original TypeBox schema. Type arrays are standard JSON Schema, so other providers get an equivalent schema.

Validation done on main @ da22f11:

  • New unit test provider-tool-parameters.test.ts (5 cases; incl. UpdateGoalToolDef.schema not mutated and Value.Check still accepting null).
  • pi-ai validateToolArguments on the normalized schema: null accepted, "5" coerced, 0 rejected.
  • agent-core + goal vitest: 11 files / 259 tests pass; tsc, eslint --quiet, check:source pass.
  • Live replay of a real 47 KB MiniMax Code tool list against gemini-4-argon: original → HTTP 400, normalized → HTTP 200.
  • Not run: full pnpm verify, native Google/Vertex providers, Windows.

Since external PRs are not accepted, I can share the patch (135 lines incl. test). Happy to adjust if you prefer fixing this elsewhere (e.g. in pi-ai convertTools for Gemini-compatible endpoints).

Side note

The 400 surfaces in the TUI as a generic, retryable 50113 provider temporarily unavailable, hiding a non-retryable schema error. Surfacing the upstream message for 4xx would make this much easier to diagnose.

Activity

  1. qiurungeng commented on Oct 26, 2026

    @qiurungeng
    Author

    Proposed patch against main @ da22f11 (release/public-source.json inventory line omitted; regenerate with node scripts/source-inventory.mjs --write).

    Diff (agent-core + test registration)
    diff --git a/packages/agent-core/src/pi-turn-runner/llm.ts b/packages/agent-core/src/pi-turn-runner/llm.ts
    index d9bcfcb..6205522 100644
    --- a/packages/agent-core/src/pi-turn-runner/llm.ts
    +++ b/packages/agent-core/src/pi-turn-runner/llm.ts
    @@ -10,6 +10,7 @@ import type {
     import type { LLMModelConfig } from './types.js';
     import type { turnHistory } from './history.js';
     import type { turnState } from './turn.js';
    +import { toProviderToolParameters } from './tools.js';
     
     export function composeStreamFn(resolved: LLMModelConfig): StreamFn {
       // Order matters: each wrapper only fills the option it owns, while
    @@ -202,7 +203,7 @@ async function runBeforeLLM(
             tools: turn.tools.map((tool) => ({
               name: tool.name,
               description: tool.description,
    -          parameters: tool.parameters,
    +          parameters: toProviderToolParameters(tool.parameters),
             })),
             thinkingLevel: turn.thinkingLevel,
             signal,
    diff --git a/packages/agent-core/src/pi-turn-runner/tools.ts b/packages/agent-core/src/pi-turn-runner/tools.ts
    index 0ce5734..f6ce730 100644
    --- a/packages/agent-core/src/pi-turn-runner/tools.ts
    +++ b/packages/agent-core/src/pi-turn-runner/tools.ts
    @@ -19,6 +19,51 @@ import type { RuntimeToolSource } from '../tools/types.js';
     import type { turnState } from './turn.js';
     
     type SourcedAgentTool = AgentTool & { source?: RuntimeToolSource };
    +
    +type JsonSchemaRecord = Record<string, unknown>;
    +
    +function isJsonSchemaRecord(value: unknown): value is JsonSchemaRecord {
    +  return typeof value === 'object' && value !== null && !Array.isArray(value);
    +}
    +
    +function isNullOnlySchema(schema: unknown): boolean {
    +  return isJsonSchemaRecord(schema) && schema.type === 'null';
    +}
    +
    +/**
    + * Rewrites `anyOf: [<typed schema>, { type: 'null' }]` into the equivalent
    + * `{ ...<typed schema>, type: [<type>, 'null'] }` for the wire payload.
    + *
    + * TypeBox emits `Type.Union([T, Type.Null()])` as an `anyOf` without a
    + * top-level `type`. Gemini function declarations reject such properties
    + * ("schema didn't specify the schema type field"), which fails every request
    + * that carries the tool. A type array is standard JSON Schema with identical
    + * semantics and is accepted by Gemini, OpenAI and Anthropic. Runtime argument
    + * validation keeps using the original TypeBox schema; only the provider-facing
    + * copy is rewritten.
    + */
    +export function toProviderToolParameters<T>(schema: T): T {
    +  return normalizeNullableUnions(schema) as T;
    +}
    +
    +function normalizeNullableUnions(schema: unknown): unknown {
    +  if (Array.isArray(schema)) return schema.map(normalizeNullableUnions);
    +  if (!isJsonSchemaRecord(schema)) return schema;
    +  const out: JsonSchemaRecord = {};
    +  for (const [key, value] of Object.entries(schema)) {
    +    out[key] = normalizeNullableUnions(value);
    +  }
    +  const variants = out.anyOf;
    +  if (out.type !== undefined || !Array.isArray(variants) || variants.length !== 2) return out;
    +  const nullIndex = variants.findIndex(isNullOnlySchema);
    +  if (nullIndex === -1) return out;
    +  const typed = variants[1 - nullIndex];
    +  if (!isJsonSchemaRecord(typed) || typeof typed.type !== 'string' || typed.type === 'null') {
    +    return out;
    +  }
    +  const { anyOf: _anyOf, ...rest } = out;
    +  return { ...typed, ...rest, type: [typed.type, 'null'] };
    +}
     type ToolCallContextWithSource<TContext extends { toolCall: { name: string } }> = TContext & {
       toolCall: TContext['toolCall'] & { source?: RuntimeToolSource };
     };
    @@ -133,7 +178,9 @@ function newTool<TCtx extends ToolExecutionContext>(
         name: b.def.name,
         label: b.def.label ?? b.def.name,
         description: b.def.description,
    -    parameters: b.def.schema as AgentTool['parameters'],
    +    // Provider-facing schema; see toProviderToolParameters. Hook-rewritten
    +    // input is still revalidated against the original `b.def.schema`.
    +    parameters: toProviderToolParameters(b.def.schema) as AgentTool['parameters'],
         ...(b.def.prepareArguments
           ? { prepareArguments: b.def.prepareArguments as AgentTool['prepareArguments'] }
           : {}),
    diff --git a/packages/agent-core/src/pi-turn-runner/turn.ts b/packages/agent-core/src/pi-turn-runner/turn.ts
    index 95fe531..1903176 100644
    --- a/packages/agent-core/src/pi-turn-runner/turn.ts
    +++ b/packages/agent-core/src/pi-turn-runner/turn.ts
    @@ -22,7 +22,7 @@ import type {
     import { composeStreamFn } from './llm.js';
     import { withLLMRetry, type LLMCallScope, type LLMCallSettledEvent } from './llm-retry.js';
     import type { TurnMetricsRecorder } from './metrics.js';
    -import { newTools } from './tools.js';
    +import { newTools, toProviderToolParameters } from './tools.js';
     import type {
       LLMModelConfig,
       PiEventWriter,
    @@ -309,7 +309,7 @@ function withLlmCallPreparedObservers(
               tools: options.tools.map((tool) => ({
                 name: tool.name,
                 description: tool.description,
    -            parameters: tool.parameters,
    +            parameters: toProviderToolParameters(tool.parameters),
               })),
               thinkingLevel: options.thinkingLevel,
               ...(streamOptions?.signal ? { signal: streamOptions.signal } : {}),
    diff --git a/packages/agent-core/test/unit/pi-turn-runner/provider-tool-parameters.test.ts b/packages/agent-core/test/unit/pi-turn-runner/provider-tool-parameters.test.ts
    new file mode 100644
    index 0000000..9c7dcaa
    --- /dev/null
    +++ b/packages/agent-core/test/unit/pi-turn-runner/provider-tool-parameters.test.ts
    @@ -0,0 +1,81 @@
    +import { Type } from "@sinclair/typebox";
    +import { Value } from "@sinclair/typebox/value";
    +import { describe, expect, it } from "vitest";
    +
    +import { UpdateGoalToolDef } from "../../../../agent-modules/goal/src/tool-defs.js";
    +import { toProviderToolParameters } from "../../../src/pi-turn-runner/tools.js";
    +
    +function wire(schema: unknown): Record<string, unknown> {
    +  return JSON.parse(JSON.stringify(toProviderToolParameters(schema))) as Record<string, unknown>;
    +}
    +
    +function findTypelessAnyOf(schema: unknown, path = "$"): string[] {
    +  if (Array.isArray(schema)) return schema.flatMap((item, i) => findTypelessAnyOf(item, `${path}[${i}]`));
    +  if (typeof schema !== "object" || schema === null) return [];
    +  const record = schema as Record<string, unknown>;
    +  const own =
    +    Array.isArray(record.anyOf) &&
    +    record.type === undefined &&
    +    record.anyOf.some((v) => (v as { type?: unknown }).type === "null")
    +      ? [path]
    +      : [];
    +  return [
    +    ...own,
    +    ...Object.entries(record).flatMap(([key, value]) => findTypelessAnyOf(value, `${path}.${key}`)),
    +  ];
    +}
    +
    +describe("toProviderToolParameters", () => {
    +  it("collapses a nullable union into a typed schema with a type array", () => {
    +    const schema = Type.Object({
    +      budget: Type.Optional(
    +        Type.Union([
    +          Type.Integer({ minimum: 1, description: "positive cap" }),
    +          Type.Null({ description: "clear" }),
    +        ]),
    +      ),
    +    });
    +
    +    expect(wire(schema).properties).toEqual({
    +      budget: { type: ["integer", "null"], minimum: 1, description: "positive cap" },
    +    });
    +  });
    +
    +  it("keeps an outer description over the member description", () => {
    +    const schema = Type.Union([Type.String({ description: "inner" }), Type.Null()], {
    +      description: "outer",
    +    });
    +
    +    expect(wire(schema)).toEqual({ type: ["string", "null"], description: "outer" });
    +  });
    +
    +  it("leaves non-nullable unions and already typed schemas untouched", () => {
    +    const literals = Type.Union([Type.Literal("a"), Type.Literal("b")]);
    +    const multi = Type.Union([Type.String(), Type.Integer(), Type.Null()]);
    +
    +    expect(wire(literals)).toEqual(JSON.parse(JSON.stringify(literals)));
    +    expect(wire(multi)).toEqual(JSON.parse(JSON.stringify(multi)));
    +  });
    +
    +  it("does not mutate the runtime schema used for argument validation", () => {
    +    const schema = UpdateGoalToolDef.schema;
    +    const before = JSON.stringify(schema);
    +
    +    toProviderToolParameters(schema);
    +
    +    expect(JSON.stringify(schema)).toBe(before);
    +    expect(Value.Check(schema, { mode: "token_budget", token_budget: null })).toBe(true);
    +    expect(Value.Check(schema, { mode: "token_budget", token_budget: 1000 })).toBe(true);
    +    expect(Value.Check(schema, { mode: "token_budget", token_budget: 0 })).toBe(false);
    +  });
    +
    +  it("emits an update_goal wire schema without typeless nullable unions", () => {
    +    const out = wire(UpdateGoalToolDef.schema);
    +
    +    expect(findTypelessAnyOf(out)).toEqual([]);
    +    expect((out.properties as Record<string, unknown>).token_budget).toMatchObject({
    +      type: ["integer", "null"],
    +      minimum: 1,
    +    });
    +  });
    +});
    diff --git a/test/vitest-suites.json b/test/vitest-suites.json
    index 157c815..0f12a4d 100644
    --- a/test/vitest-suites.json
    +++ b/test/vitest-suites.json
    @@ -115,6 +115,7 @@
           "packages/tui/test/unit/tui/widgets/editor/editor-behavior.test.ts",
           "packages/agent-core/test/unit/pi-turn-runner/llm-retry.test.ts",
           "packages/agent-core/test/unit/pi-turn-runner/llm-stream-timeout.test.ts",
    +      "packages/agent-core/test/unit/pi-turn-runner/provider-tool-parameters.test.ts",
           "packages/agent-core/test/unit/pi-turn-runner/request-image-limit.test.ts",
           "packages/tui/test/unit/runtime-error-presentation-rejection.test.ts",
           "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.test.ts",
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    tuiInteractive terminal UI (TUI)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions