diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9cbda6ba9..5198df3ca 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/providers/ollama.ts b/packages/ai/src/providers/ollama.ts index d2b0b67df..abede61fb 100644 --- a/packages/ai/src/providers/ollama.ts +++ b/packages/ai/src/providers/ollama.ts @@ -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)), }, })); } diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 493c06ecd..549d2802b 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -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(); + 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 // --------------------------------------------------------------------------- diff --git a/packages/ai/test/ollama-thinking-disable.test.ts b/packages/ai/test/ollama-thinking-disable.test.ts index bbdd92dd6..0c2791531 100644 --- a/packages/ai/test/ollama-thinking-disable.test.ts +++ b/packages/ai/test/ollama-thinking-disable.test.ts @@ -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; + }; +} + 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 => { + 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>; + + 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 => {