From ad9c1cc770cfd5f5943bfeac506f7131ea5924ea Mon Sep 17 00:00:00 2001 From: Naved Date: Mon, 6 Jul 2026 10:05:09 -0700 Subject: [PATCH 1/2] ollama fixes --- .../providers/__tests__/native-ollama.spec.ts | 165 ++++++++++++++++++ src/api/providers/fetchers/ollama.ts | 5 +- src/api/providers/native-ollama.ts | 52 ++++-- 3 files changed, 206 insertions(+), 16 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 8a6b025c8f..2ad765c589 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -697,5 +697,170 @@ describe("NativeOllamaHandler", () => { const firstEndIndex = results.findIndex((r) => r.type === "tool_call_end") expect(firstEndIndex).toBeGreaterThan(lastPartialIndex) }) + + it("should send tool results with role 'tool' and tool_name when preceded by a tool_use", async () => { + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok" } } + }) + + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-abc", + name: "apply_diff", + input: { + path: "foo.ts", + diff: "SEARCH_REPLACE_DIFF_CONTENT", + }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-abc", + content: "Diff applied successfully", + }, + ], + }, + ] + + const stream = handler.createMessage("System", messages) + for await (const _ of stream) { + // consume stream + } + + // The tool result should use Ollama's native "tool" role with tool_name + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: "tool", + tool_name: "apply_diff", + content: "Diff applied successfully", + }), + ]), + }), + ) + }) + + it("should fall back to role 'user' for tool results when no matching tool_use is found", async () => { + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok" } } + }) + + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "unknown-id", + content: "orphan result", + }, + ], + }, + ] + + const stream = handler.createMessage("System", messages) + for await (const _ of stream) { + // consume stream + } + + // No preceding tool_use -> fall back to "user" role + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: "user", + content: "orphan result", + }), + ]), + }), + ) + }) + + it("should strip additionalProperties from tool schema parameters", async () => { + mockGetOllamaModels.mockResolvedValue({ + "llama3.2": { + contextWindow: 128000, + maxTokens: 4096, + supportsImages: true, + supportsPromptCache: false, + }, + }) + + const options: ApiHandlerOptions = { + apiModelId: "llama3.2", + ollamaModelId: "llama3.2", + ollamaBaseUrl: "http://localhost:11434", + } + + handler = new NativeOllamaHandler(options) + + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok" } } + }) + + const tools = [ + { + type: "function" as const, + function: { + name: "apply_diff", + description: "Apply a diff", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + diff: { type: "string", description: "Diff content" }, + }, + required: ["path", "diff"], + additionalProperties: false, + }, + }, + }, + ] + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Edit the file" }], { + taskId: "test", + tools, + }) + + for await (const _ of stream) { + // consume stream + } + + // additionalProperties should be stripped from the parameters + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + tools: [ + { + type: "function", + function: { + name: "apply_diff", + description: "Apply a diff", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + diff: { type: "string", description: "Diff content" }, + }, + required: ["path", "diff"], + }, + }, + }, + ], + }), + ) + + // Explicitly verify additionalProperties is not present + const callArgs = mockChat.mock.calls[0][0] as any + expect(callArgs.tools[0].function.parameters).not.toHaveProperty("additionalProperties") + }) }) }) diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index a81a1e2896..872d687db4 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -53,7 +53,10 @@ export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo | contextWindow: contextWindow || ollamaDefaultModelInfo.contextWindow, supportsPromptCache: true, supportsImages: rawModel.capabilities?.includes("vision"), - maxTokens: contextWindow || ollamaDefaultModelInfo.contextWindow, + // maxTokens represents max OUTPUT tokens, not the context window. + // Setting it to the full contextWindow causes getModelMaxOutputTokens to + // reserve 20% of the window for output, triggering premature condensing. + // Inherit the sane default (4096) from ollamaDefaultModelInfo instead. }) return modelInfo diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 4574c184f2..fd36f97fdf 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -16,6 +16,9 @@ interface OllamaChatOptions { function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] { const ollamaMessages: Message[] = [] + // Track tool use IDs to tool names so tool results can be sent with + // Ollama's native "tool" role and tool_name field instead of "user". + const toolUseIdToName = new Map() for (const anthropicMessage of anthropicMessages) { if (typeof anthropicMessage.content === "string") { @@ -67,8 +70,13 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa }) .join("\n") ?? "" } + // Look up the tool name from the corresponding tool_use block. + // When found, use Ollama's native "tool" role with tool_name so the + // model can distinguish tool results from user messages. + const toolName = toolUseIdToName.get(toolMessage.tool_use_id) ollamaMessages.push({ - role: "user", + role: toolName ? "tool" : "user", + tool_name: toolName, images: toolResultImages.length > 0 ? toolResultImages : undefined, content: content, }) @@ -128,12 +136,16 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa // Convert tool_use blocks to Ollama tool_calls format const toolCalls = toolMessages.length > 0 - ? toolMessages.map((tool) => ({ - function: { - name: tool.name, - arguments: tool.input as Record, - }, - })) + ? toolMessages.map((tool) => { + // Track tool use ID → name so tool results can use the "tool" role + toolUseIdToName.set(tool.id, tool.name) + return { + function: { + name: tool.name, + arguments: tool.input as Record, + }, + } + }) : undefined ollamaMessages.push({ @@ -193,14 +205,24 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio return tools .filter((tool): tool is OpenAI.Chat.ChatCompletionTool & { type: "function" } => tool.type === "function") - .map((tool) => ({ - type: tool.type, - function: { - name: tool.function.name, - description: tool.function.description, - parameters: tool.function.parameters as OllamaTool["function"]["parameters"], - }, - })) + .map((tool) => { + // Strip additionalProperties from the parameters schema. + // This field is not part of Ollama's tool schema definition and can + // cause issues with some models' tool-calling templates. + const rawParams = tool.function.parameters as Record | undefined + const parameters = rawParams ? { ...rawParams } : undefined + if (parameters) { + delete parameters.additionalProperties + } + return { + type: tool.type, + function: { + name: tool.function.name, + description: tool.function.description, + parameters: parameters as OllamaTool["function"]["parameters"], + }, + } + }) } override async *createMessage( From 5ec15c77ff2b6e93814186959420563cadd0031b Mon Sep 17 00:00:00 2001 From: Naved Date: Mon, 6 Jul 2026 21:51:58 -0700 Subject: [PATCH 2/2] fix(ollama): isolate failing model fetches and address PR #848 review - getOllamaModels: append per-request .catch() so a single failing /api/show no longer rejects the whole Promise.all and wipes out all healthy models (issue #851) - native-ollama: keep tool results text-only; move images extracted from tool results onto the adjacent user message and reset the per-result image accumulator to prevent leakage between tool results - native-ollama: recursively strip additionalProperties from tool schemas (top-level and nested properties/items) instead of only the top level - tests: update ollama fetcher maxTokens expectations to the inherited default (4096), add regression test for individual model fetch failure, and add coverage for text-only tool results, image relocation, image leakage prevention, and nested additionalProperties stripping --- .../providers/__tests__/native-ollama.spec.ts | 201 +++++++++++++++++- .../fetchers/__tests__/ollama.test.ts | 91 +++++++- src/api/providers/fetchers/ollama.ts | 7 + src/api/providers/native-ollama.ts | 63 +++++- 4 files changed, 347 insertions(+), 15 deletions(-) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 2ad765c589..989c0a196d 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -117,19 +117,31 @@ describe("NativeOllamaHandler", () => { // consume stream } - // Text blocks are joined with "\n"; the image emits a placeholder and is - // flushed separately via the `images` field rather than inlined. + // Text blocks are joined with "\n"; the image emits a placeholder. + // Tool results are text-only in Ollama, so the image is flushed onto a + // separate adjacent user message via the `images` field rather than + // inlined into the tool result. expect(mockChat).toHaveBeenCalledWith( expect.objectContaining({ messages: expect.arrayContaining([ expect.objectContaining({ role: "user", content: "line one\n(see following user message for image)\nline two", + }), + expect.objectContaining({ + role: "user", images: ["imgdata"], }), ]), }), ) + + // The tool result message itself must not carry an images field. + const callArgs = mockChat.mock.calls[0][0] as any + const toolResultMessage = callArgs.messages.find( + (m: any) => typeof m.content === "string" && m.content.includes("line one"), + ) + expect(toolResultMessage.images).toBeUndefined() }) it("should drop unknown block types in tool_result content (empty string contribution)", async () => { @@ -862,5 +874,190 @@ describe("NativeOllamaHandler", () => { const callArgs = mockChat.mock.calls[0][0] as any expect(callArgs.tools[0].function.parameters).not.toHaveProperty("additionalProperties") }) + + it("should recursively strip additionalProperties from nested tool schema parameters", async () => { + mockGetOllamaModels.mockResolvedValue({ + "llama3.2": { + contextWindow: 128000, + maxTokens: 4096, + supportsImages: true, + supportsPromptCache: false, + }, + }) + + const options: ApiHandlerOptions = { + apiModelId: "llama3.2", + ollamaModelId: "llama3.2", + ollamaBaseUrl: "http://localhost:11434", + } + + handler = new NativeOllamaHandler(options) + + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok" } } + }) + + const tools = [ + { + type: "function" as const, + function: { + name: "apply_diff", + description: "Apply a diff", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + options: { + type: "object", + properties: { + dry_run: { type: "boolean" }, + backup: { type: "boolean" }, + }, + required: ["dry_run"], + additionalProperties: false, + }, + }, + required: ["path", "options"], + additionalProperties: false, + }, + }, + }, + ] + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Edit the file" }], { + taskId: "test", + tools, + }) + + for await (const _ of stream) { + // consume stream + } + + const callArgs = mockChat.mock.calls[0][0] as any + const params = callArgs.tools[0].function.parameters + + // Top-level additionalProperties stripped + expect(params).not.toHaveProperty("additionalProperties") + + // Nested additionalProperties also stripped + expect(params.properties.options).not.toHaveProperty("additionalProperties") + expect(params.properties.options.properties.dry_run).toEqual({ type: "boolean" }) + }) + + it("should keep tool results text-only and move images onto the adjacent user message", async () => { + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok" } } + }) + + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-img", + name: "read_file", + input: { path: "foo.ts" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-img", + content: [ + { type: "text", text: "screenshot" }, + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "imgdata", + }, + }, + ], + }, + { type: "text", text: "please continue" }, + ], + }, + ] + + const stream = handler.createMessage("System", messages) + for await (const _ of stream) { + // consume stream + } + + const callArgs = mockChat.mock.calls[0][0] as any + + // The tool result uses the native "tool" role and is text-only. + const toolMessage = callArgs.messages.find((m: any) => m.role === "tool") + expect(toolMessage).toBeDefined() + expect(toolMessage.tool_name).toBe("read_file") + expect(toolMessage.images).toBeUndefined() + + // The image is carried by the adjacent user message. + const userMessage = callArgs.messages.find( + (m: any) => m.role === "user" && Array.isArray(m.images) && m.images.includes("imgdata"), + ) + expect(userMessage).toBeDefined() + }) + + it("should not leak images from one tool result into another", async () => { + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok" } } + }) + + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "tool_use", id: "tool-a", name: "read_file", input: { path: "a.ts" } }, + { type: "tool_use", id: "tool-b", name: "read_file", input: { path: "b.ts" } }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-a", + content: [ + { type: "text", text: "a" }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "img-a" }, + }, + ], + }, + { + type: "tool_result", + tool_use_id: "tool-b", + content: [{ type: "text", text: "b" }], + }, + ], + }, + ] + + const stream = handler.createMessage("System", messages) + for await (const _ of stream) { + // consume stream + } + + const callArgs = mockChat.mock.calls[0][0] as any + const toolMessages = callArgs.messages.filter((m: any) => m.role === "tool") + + // Neither tool result should carry an images field. + expect(toolMessages).toHaveLength(2) + for (const m of toolMessages) { + expect(m.images).toBeUndefined() + } + + // The single image is delivered once via the adjacent user message. + const userImageMessages = callArgs.messages.filter((m: any) => m.role === "user" && Array.isArray(m.images)) + expect(userImageMessages).toHaveLength(1) + expect(userImageMessages[0].images).toEqual(["img-a"]) + }) }) }) diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index d9d1832175..9c0b547e88 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -18,7 +18,7 @@ describe("Ollama Fetcher", () => { const parsedModel = parseOllamaModel(modelData) expect(parsedModel).toEqual({ - maxTokens: 40960, + maxTokens: 4096, contextWindow: 40960, supportsImages: false, supportsPromptCache: true, @@ -42,7 +42,7 @@ describe("Ollama Fetcher", () => { const parsedModel = parseOllamaModel(modelDataWithNullFamilies as any) expect(parsedModel).toEqual({ - maxTokens: 40960, + maxTokens: 4096, contextWindow: 40960, supportsImages: false, supportsPromptCache: true, @@ -398,5 +398,92 @@ describe("Ollama Fetcher", () => { expect(Object.keys(result).length).toBe(1) expect(result[modelName]).toBeDefined() }) + + it("should still list healthy models when a single /api/show request fails", async () => { + const baseUrl = "http://localhost:11434" + const healthyModel = "healthy-model:latest" + const failingModel = "failing-model:latest" + + const mockApiTagsResponse = { + models: [ + { + name: healthyModel, + model: healthyModel, + modified_at: "2025-06-03T09:23:22.610222878-04:00", + size: 14333928010, + digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5", + details: { + family: "llama", + families: ["llama"], + format: "gguf", + parameter_size: "23.6B", + parent_model: "", + quantization_level: "Q4_K_M", + }, + }, + { + name: failingModel, + model: failingModel, + modified_at: "2025-06-03T09:23:22.610222878-04:00", + size: 14333928010, + digest: "6a5f0c01d2c96c687d79e32fdd25b87087feb376bf9838f854d10be8cf3c10a5", + details: { + family: "llama", + families: ["llama"], + format: "gguf", + parameter_size: "23.6B", + parent_model: "", + quantization_level: "Q4_K_M", + }, + }, + ], + } + const mockApiShowResponse = { + license: "Mock License", + modelfile: "FROM /path/to/blob\nTEMPLATE {{ .Prompt }}", + parameters: "num_ctx 4096\nstop_token ", + template: "{{ .System }}USER: {{ .Prompt }}ASSISTANT:", + modified_at: "2025-06-03T09:23:22.610222878-04:00", + details: { + parent_model: "", + format: "gguf", + family: "llama", + families: ["llama"], + parameter_size: "23.6B", + quantization_level: "Q4_K_M", + }, + model_info: { + "ollama.context_length": 4096, + "some.other.info": "value", + }, + capabilities: ["completion", "tools"], // Has tools capability + } + + mockedAxios.get.mockResolvedValueOnce({ data: mockApiTagsResponse }) + // First /api/show succeeds (healthy model), second rejects (failing model) + mockedAxios.post + .mockResolvedValueOnce({ data: mockApiShowResponse }) + .mockRejectedValueOnce(new Error("model not found")) + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(function () {}) + + const result = await getOllamaModels(baseUrl) + + expect(mockedAxios.get).toHaveBeenCalledTimes(1) + expect(mockedAxios.post).toHaveBeenCalledTimes(2) + + // The healthy model should still be present despite the sibling failure + expect(Object.keys(result).length).toBe(1) + expect(result[healthyModel]).toBeDefined() + expect(result[failingModel]).toBeUndefined() + + // The individual failure should be logged, not swallowed silently + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Error fetching details for model ${failingModel}:`, + expect.any(Error), + ) + + consoleErrorSpy.mockRestore() + }) }) }) diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 872d687db4..9f88d75327 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -103,6 +103,13 @@ export async function getOllamaModels( if (modelInfo) { models[ollamaModel.name] = modelInfo } + }) + // A single failing /api/show request (corrupt model, timeout, + // server overload, etc.) must not reject the whole Promise.all + // and wipe out all otherwise healthy models. Log and swallow + // the individual failure so the remaining models still load. + .catch((error) => { + console.error(`Error fetching details for model ${ollamaModel.model}:`, error) }), ) } diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index fd36f97fdf..5dfda425cf 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -43,7 +43,11 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa { nonToolMessages: [], toolMessages: [] }, ) - // Process tool result messages FIRST since they must follow the tool use messages + // Process tool result messages FIRST since they must follow the tool use messages. + // Images extracted from tool results are collected here and attached to the + // adjacent user message, because Ollama's "tool" role only supports text + // content (content + tool_name); attaching images there can invalidate the + // request. const toolResultImages: string[] = [] toolMessages.forEach((toolMessage) => { // The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility. @@ -52,6 +56,10 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa if (typeof toolMessage.content === "string") { content = toolMessage.content } else { + // Collect this result's images in a local accumulator so they + // cannot leak into sibling tool results, then fold them into + // the shared accumulator for the adjacent user message. + const resultImages: string[] = [] content = toolMessage.content ?.map((part) => { @@ -59,7 +67,7 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa // Handle base64 images only (Anthropic SDK uses base64) // Ollama expects raw base64 strings, not data URLs if ("source" in part && part.source.type === "base64") { - toolResultImages.push(part.source.data) + resultImages.push(part.source.data) } return "(see following user message for image)" } @@ -69,15 +77,16 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa return "" }) .join("\n") ?? "" + toolResultImages.push(...resultImages) } // Look up the tool name from the corresponding tool_use block. // When found, use Ollama's native "tool" role with tool_name so the - // model can distinguish tool results from user messages. + // model can distinguish tool results from user messages. Tool messages + // stay text-only; images are delivered via the adjacent user message. const toolName = toolUseIdToName.get(toolMessage.tool_use_id) ollamaMessages.push({ role: toolName ? "tool" : "user", tool_name: toolName, - images: toolResultImages.length > 0 ? toolResultImages : undefined, content: content, }) }) @@ -97,12 +106,23 @@ function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessagePa imageData.push(part.source.data) } }) + // Attach images extracted from tool results to the adjacent user + // message, the only role that supports images in Ollama's chat API. + imageData.push(...toolResultImages) ollamaMessages.push({ role: "user", content: textContent, images: imageData.length > 0 ? imageData : undefined, }) + } else if (toolResultImages.length > 0) { + // No adjacent user message exists to carry tool-result images, so + // emit a dedicated user message to ensure they still reach the model. + ollamaMessages.push({ + role: "user", + content: "", + images: toolResultImages, + }) } } else if (anthropicMessage.role === "assistant") { const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ @@ -193,6 +213,29 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio return this.client } + /** + * Recursively strips `additionalProperties` from a JSON schema (including + * nested `properties` objects and `items` arrays). `additionalProperties` + * is not part of Ollama's tool schema definition and can break tool-calling + * templates on some models, so it must be removed at every nesting level. + */ + private stripAdditionalProperties(schema: unknown): unknown { + if (!schema || typeof schema !== "object") { + return schema + } + if (Array.isArray(schema)) { + return schema.map((item) => this.stripAdditionalProperties(item)) + } + const result: Record = {} + for (const [key, value] of Object.entries(schema)) { + if (key === "additionalProperties") { + continue + } + result[key] = this.stripAdditionalProperties(value) + } + return result + } + /** * Converts OpenAI-format tools to Ollama's native tool format. * This allows NativeOllamaHandler to use the same tool definitions @@ -206,14 +249,12 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio return tools .filter((tool): tool is OpenAI.Chat.ChatCompletionTool & { type: "function" } => tool.type === "function") .map((tool) => { - // Strip additionalProperties from the parameters schema. - // This field is not part of Ollama's tool schema definition and can - // cause issues with some models' tool-calling templates. + // Recursively strip additionalProperties from the parameters schema + // (top-level and nested). This field is not part of Ollama's tool + // schema definition and can cause issues with some models' + // tool-calling templates. const rawParams = tool.function.parameters as Record | undefined - const parameters = rawParams ? { ...rawParams } : undefined - if (parameters) { - delete parameters.additionalProperties - } + const parameters = rawParams ? this.stripAdditionalProperties(rawParams) : undefined return { type: tool.type, function: {