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:
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
Reference in New Issue
Block a user