Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// ]
/// ```
///
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -415,14 +422,26 @@ 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
}
}

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<String> = ["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 [
Expand All @@ -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),
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions Tests/AnyLanguageModelTests/OllamaLanguageModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}