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")) + } +}