From a491cb25d76752c5d21bbf5ea7af8c114fe0c2b9 Mon Sep 17 00:00:00 2001 From: James Cahall 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 | 42 +++++++++++++---- .../OllamaLanguageModelTests.swift | 47 +++++++++++++++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index 82be5cc7..b7a11635 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 /// ] /// ``` /// @@ -100,7 +105,8 @@ public struct OllamaLanguageModel: LanguageModel { options: ollamaOptions, stream: false, images: ollamaImages.isEmpty ? nil : ollamaImages, - format: ollamaFormat + format: ollamaFormat, + parameters: extractTopLevelChatParameters(options) ) let url = baseURL.appendingPathComponent("api/chat") @@ -191,7 +197,8 @@ public struct OllamaLanguageModel: LanguageModel { options: ollamaOptions, stream: true, images: (ollamaImages.isEmpty ? nil : ollamaImages), - format: ollamaFormat + format: ollamaFormat, + parameters: extractTopLevelChatParameters(options) ) let body = try JSONEncoder().encode(params) @@ -375,7 +382,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 @@ -415,7 +422,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 } } @@ -423,6 +430,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 [ @@ -448,14 +467,15 @@ private func toGeneratedContent(_ value: JSONValue?) throws -> GeneratedContent return try GeneratedContent(json: json) } -private func createChatParams( +func createChatParams( model: String, messages: [OllamaMessage], tools: [[String: JSONValue]]?, options: [String: JSONValue]?, stream: Bool, images: [String]?, - format: JSONValue? + format: JSONValue?, + parameters: [String: JSONValue]? = nil ) throws -> [String: JSONValue] { var params: [String: JSONValue] = [ "model": .string(model), @@ -479,12 +499,18 @@ private func createChatParams( params["format"] = format } + if let parameters { + for (key, value) in parameters where params[key] == nil { + params[key] = value + } + } + return params } // MARK: - Supporting Types -private struct OllamaMessage: Hashable, Codable, Sendable { +struct OllamaMessage: Hashable, Codable, Sendable { enum Role: String, Hashable, Codable, Sendable { case system case user diff --git a/Tests/AnyLanguageModelTests/OllamaLanguageModelTests.swift b/Tests/AnyLanguageModelTests/OllamaLanguageModelTests.swift index 5a8911f0..357dcf5c 100644 --- a/Tests/AnyLanguageModelTests/OllamaLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/OllamaLanguageModelTests.swift @@ -174,3 +174,50 @@ struct OllamaLanguageModelTests { #expect(!response.content.isEmpty) } } + +@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, + images: nil, + 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, + images: nil, + format: nil, + parameters: ["model": .string("injected"), "think": .string("high")] + ) + + #expect(params["model"] == .string("gpt-oss:20b")) + #expect(params["think"] == .string("high")) + } +}