fix(ai): sanitized ollama tool schemas

- Added an Ollama-specific tool schema sanitizer for boolean subschemas, boolean additionalProperties/unevaluatedProperties, and nullable type arrays.

- Applied the sanitizer in the native Ollama chat tool serializer and covered the provider payload contract with a regression test.

Fixes #4488
This commit is contained in:
roboomp
2026-07-04 04:19:03 +00:00
parent fef9b62334
commit 676fefbfce
4 changed files with 180 additions and 5 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Ollama/Ollama Cloud tool requests failing with HTTP 400 by normalizing boolean subschemas, boolean `additionalProperties`/`unevaluatedProperties`, and nullable `type` arrays before serializing tool parameters. ([#4488](https://github.com/can1357/oh-my-pi/issues/4488))
## [16.3.5] - 2026-07-04
### Added
+2 -2
View File
@@ -23,7 +23,7 @@ import {
getOpenAIStreamFirstEventTimeoutMs,
getOpenAIStreamIdleTimeoutMs,
} from "../utils/idle-iterator";
import { toolWireSchema } from "../utils/schema/wire";
import { sanitizeSchemaForOllama, toolWireSchema } from "../utils/schema";
import {
getStreamMarkupHealingPattern,
type HealedToolCall,
@@ -280,7 +280,7 @@ function convertTools(tools: Tool[] | undefined): OllamaFunctionTool[] | undefin
function: {
name: tool.name,
description: tool.description,
parameters: toolWireSchema(tool),
parameters: sanitizeSchemaForOllama(toolWireSchema(tool)),
},
}));
}
+105
View File
@@ -1014,6 +1014,111 @@ export function normalizeSchemaForMoonshot(value: unknown): unknown {
});
}
// ---------------------------------------------------------------------------
// Ollama — Go schema parser compatibility
// ---------------------------------------------------------------------------
const OLLAMA_SCHEMA_ARRAY_KEYS = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]);
const OLLAMA_SCHEMA_MAP_KEYS = new Set([
"properties",
"patternProperties",
"dependencies",
"dependentSchemas",
"$defs",
"definitions",
]);
const OLLAMA_SCHEMA_VALUE_KEYS = new Set([
"items",
"additionalItems",
"contains",
"contentSchema",
"propertyNames",
"if",
"then",
"else",
"not",
"additionalProperties",
"unevaluatedItems",
"unevaluatedProperties",
]);
/**
* Rewrites standard JSON Schema forms that Ollama's Go `/api/chat` tool parser
* cannot unmarshal into its object-shaped `Schema` struct.
*/
export function sanitizeSchemaForOllama(schema: JsonObject): JsonObject {
const cache = new WeakMap<JsonObject, unknown>();
const normalizeNode = (value: unknown): unknown => {
if (typeof value === "boolean") {
return value ? {} : { type: "object", properties: {} };
}
if (!isJsonObject(value)) {
if (!Array.isArray(value)) return value;
let changed = false;
const output = value.map(item => {
const next = normalizeNode(item);
if (next !== item) changed = true;
return next;
});
return changed ? output : value;
}
const cached = cache.get(value);
if (cached) return cached;
const output: JsonObject = {};
cache.set(value, output);
let changed = false;
for (const key in value) {
if (!Object.hasOwn(value, key)) continue;
const child = value[key];
if ((key === "additionalProperties" || key === "unevaluatedProperties") && typeof child === "boolean") {
changed = true;
continue;
}
if (key === "type" && Array.isArray(child)) {
const variants = child.filter((entry): entry is string => typeof entry === "string");
const nonNull = variants.filter(entry => entry !== "null");
output.type = nonNull[0] ?? variants[0] ?? child[0];
changed = true;
continue;
}
let next = child;
if (OLLAMA_SCHEMA_MAP_KEYS.has(key) && isJsonObject(child)) {
let mapChanged = false;
const mapOutput: JsonObject = {};
for (const childKey in child) {
if (!Object.hasOwn(child, childKey)) continue;
const mapChild = child[childKey];
const normalizedChild = normalizeNode(mapChild);
if (normalizedChild !== mapChild) mapChanged = true;
mapOutput[childKey] = normalizedChild;
}
next = mapChanged ? mapOutput : child;
} else if (OLLAMA_SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(child)) {
let arrayChanged = false;
const arrayOutput = child.map(item => {
const normalizedItem = normalizeNode(item);
if (normalizedItem !== item) arrayChanged = true;
return normalizedItem;
});
next = arrayChanged ? arrayOutput : child;
} else if (OLLAMA_SCHEMA_VALUE_KEYS.has(key)) {
next = normalizeNode(child);
}
if (next !== child) changed = true;
output[key] = next;
}
const result = changed ? output : value;
cache.set(value, result);
return result;
};
return normalizeNode(schema) as JsonObject;
}
// ---------------------------------------------------------------------------
// OpenAI Responses — schema-valued normalization
// ---------------------------------------------------------------------------
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import type { AssistantMessage, Context, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai";
import type { AssistantMessage, Context, Tool, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai";
import { streamOllama } from "@oh-my-pi/pi-ai/providers/ollama";
import { NON_VISION_IMAGE_PLACEHOLDER } from "@oh-my-pi/pi-ai/providers/vision-guard";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
@@ -10,15 +10,25 @@ interface OllamaChatMessagePayload {
images?: unknown;
}
interface OllamaToolPayload {
function?: {
parameters?: Record<string, unknown>;
};
}
interface OllamaChatRequestPayload {
think?: unknown;
messages?: OllamaChatMessagePayload[];
tools?: OllamaToolPayload[];
}
function isOllamaChatRequestPayload(value: unknown): value is OllamaChatRequestPayload {
if (value === null || typeof value !== "object") return false;
const payload = value as { messages?: unknown };
return payload.messages === undefined || Array.isArray(payload.messages);
const payload = value as { messages?: unknown; tools?: unknown };
return (
(payload.messages === undefined || Array.isArray(payload.messages)) &&
(payload.tools === undefined || Array.isArray(payload.tools))
);
}
const emptyUsage: Usage = {
@@ -70,6 +80,62 @@ describe("Ollama chat thinking controls", () => {
expect(payload?.think).toBe(false);
});
it("normalizes tool schemas for Ollama's Go parser", async () => {
let payload: OllamaChatRequestPayload | undefined;
const fetchMock = async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const parsed: unknown = JSON.parse(String(init?.body));
if (!isOllamaChatRequestPayload(parsed)) {
throw new Error("Expected Ollama payload object");
}
payload = parsed;
return new Response('{"message":{"content":"ok"},"done":true,"prompt_eval_count":1,"eval_count":1}\n', {
status: 200,
});
};
const tool: Tool = {
name: "schema_probe",
description: "probe schema normalization",
parameters: {
type: "object",
properties: {
anything: {},
nullableName: { type: ["string", "null"] },
list: { type: "array", items: {} },
union: { anyOf: [{}, { type: "string" }] },
nested: {
type: "object",
properties: { value: { type: "string" } },
additionalProperties: false,
},
},
required: ["anything", "nullableName", "list", "union", "nested"],
additionalProperties: false,
},
};
const context: Context = {
messages: [{ role: "user", content: "hola", timestamp: 0 }],
tools: [tool],
};
await streamOllama(createReasoningOllamaModel(), context, {
apiKey: "test-key",
fetch: fetchMock,
}).result();
const parameters = payload?.tools?.[0]?.function?.parameters;
if (!parameters || typeof parameters.properties !== "object" || parameters.properties === null) {
throw new Error("Expected Ollama tool parameters with properties");
}
const properties = parameters.properties as Record<string, Record<string, unknown>>;
expect(Object.hasOwn(parameters, "additionalProperties")).toBe(false);
expect(properties.anything).toEqual({});
expect(properties.nullableName?.type).toBe("string");
expect(properties.list?.items).toEqual({});
expect(properties.union?.anyOf).toEqual([{}, { type: "string" }]);
expect(Object.hasOwn(properties.nested, "additionalProperties")).toBe(false);
});
it("sends mid-conversation developer messages as user turns for llama.cpp cache reuse", async () => {
let payload: OllamaChatRequestPayload | undefined;
const fetchMock = async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {