From 8d499d0d7a38acf4926833da1a17f80619d934e8 Mon Sep 17 00:00:00 2001 From: james-333i Date: Tue, 25 Aug 2026 10:56:44 -0700 Subject: [PATCH] Route think and keep_alive to the top level of Ollama chat requests Ollama's /api/chat endpoint reads think and keep_alive as top-level siblings of model and messages, so passing them through the custom options dictionary buried them inside options where the server ignores them. Route those keys to the top level of the request body and keep every other custom key in options. Reserved request keys cannot be overridden. Adds request-encoding tests that run without a live Ollama server. Fixes #152 --- .../Models/OllamaLanguageModel.swift | 38 +++++++++++++--- .../OllamaLanguageModelTests.swift | 45 +++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index 580c177d..859c1700 100644 --- a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift @@ -26,11 +26,16 @@ public struct OllamaLanguageModel: LanguageModel { /// Available options are model-specific and defined in the model's Modelfile. /// Common options include `seed`, `repeat_penalty`, `stop`, and others. /// + /// Keys that Ollama defines as top-level chat request parameters + /// (`think` and `keep_alive`) are sent at the top level of the request + /// body instead of inside `options`. + /// /// ```swift /// var options = GenerationOptions(temperature: 0.7) /// options[custom: OllamaLanguageModel.self] = [ /// "seed": 42, - /// "repeat_penalty": 1.2 + /// "repeat_penalty": 1.2, + /// "think": true /// ] /// ``` /// @@ -103,7 +108,8 @@ public struct OllamaLanguageModel: LanguageModel { tools: ollamaTools.isEmpty ? nil : ollamaTools, options: ollamaOptions, stream: false, - format: ollamaFormat + format: ollamaFormat, + parameters: extractTopLevelChatParameters(options) ) let url = baseURL.appendingPathComponent("api/chat") @@ -197,7 +203,8 @@ public struct OllamaLanguageModel: LanguageModel { tools: ollamaTools.isEmpty ? nil : ollamaTools, options: ollamaOptions, stream: true, - format: ollamaFormat + format: ollamaFormat, + parameters: extractTopLevelChatParameters(options) ) let body = try JSONEncoder().encode(params) @@ -381,7 +388,7 @@ private func resolveToolCalls( // MARK: - Conversions -private func convertOptions(_ options: GenerationOptions) -> [String: JSONValue]? { +func convertOptions(_ options: GenerationOptions) -> [String: JSONValue]? { var ollamaOptions: [String: JSONValue] = [:] // Handle temperature @@ -421,7 +428,7 @@ private func convertOptions(_ options: GenerationOptions) -> [String: JSONValue] // Merge custom Ollama options if let customOptions: [String: JSONValue] = options[custom: OllamaLanguageModel.self] { - for (key, value) in customOptions { + for (key, value) in customOptions where !topLevelChatParameterKeys.contains(key) { ollamaOptions[key] = value } } @@ -429,6 +436,18 @@ private func convertOptions(_ options: GenerationOptions) -> [String: JSONValue] return ollamaOptions.isEmpty ? nil : ollamaOptions } +/// Custom option keys that Ollama's `/api/chat` endpoint reads from the top level +/// of the request body rather than from `options`. +private let topLevelChatParameterKeys: Set = ["think", "keep_alive"] + +func extractTopLevelChatParameters(_ options: GenerationOptions) -> [String: JSONValue]? { + guard let customOptions: [String: JSONValue] = options[custom: OllamaLanguageModel.self] else { + return nil + } + let parameters = customOptions.filter { topLevelChatParameterKeys.contains($0.key) } + return parameters.isEmpty ? nil : parameters +} + private func convertToolToOllamaFormat(_ tool: any Tool) throws -> [String: JSONValue] { let resolvedSchema = tool.parameters.withResolvedRoot() ?? tool.parameters return [ @@ -460,7 +479,8 @@ func createChatParams( tools: [[String: JSONValue]]?, options: [String: JSONValue]?, stream: Bool, - format: JSONValue? + format: JSONValue?, + parameters: [String: JSONValue]? = nil ) throws -> [String: JSONValue] { var params: [String: JSONValue] = [ "model": .string(model), @@ -480,6 +500,12 @@ func createChatParams( params["format"] = format } + if let parameters { + for (key, value) in parameters where params[key] == nil { + params[key] = value + } + } + return params } diff --git a/Tests/AnyLanguageModelTests/OllamaLanguageModelTests.swift b/Tests/AnyLanguageModelTests/OllamaLanguageModelTests.swift index 937d4b79..45bd9b63 100644 --- a/Tests/AnyLanguageModelTests/OllamaLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/OllamaLanguageModelTests.swift @@ -226,3 +226,48 @@ struct OllamaChatRequestEncodingTests { #expect(message["images"] == nil) } } + +@Suite("Ollama top-level chat parameters") +struct OllamaTopLevelChatParametersTests { + @Test func routesThinkToTheTopLevelOfTheRequest() throws { + var options = GenerationOptions() + options[custom: OllamaLanguageModel.self] = [ + "think": .bool(true), + "repeat_penalty": .double(1.2), + ] + + let params = try createChatParams( + model: "qwen3:8b", + messages: [OllamaMessage(role: .user, content: "Hello")], + tools: nil, + options: convertOptions(options), + stream: false, + format: nil, + parameters: extractTopLevelChatParameters(options) + ) + + #expect(params["think"] == .bool(true)) + + guard case .object(let requestOptions)? = params["options"] else { + Issue.record("Expected options to encode as an object") + return + } + #expect(requestOptions["think"] == nil) + #expect(requestOptions["repeat_penalty"] == .double(1.2)) + } + + @Test func topLevelParametersDoNotOverrideReservedKeys() throws { + let params = try createChatParams( + model: "gpt-oss:20b", + messages: [OllamaMessage(role: .user, content: "Hello")], + tools: nil, + options: nil, + stream: false, + format: nil, + parameters: ["model": .string("injected"), "think": .string("high")] + ) + + #expect(params["model"] == .string("gpt-oss:20b")) + #expect(params["think"] == .string("high")) + } +}