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
38 changes: 32 additions & 6 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 @@ -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")
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -421,14 +428,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 Down Expand Up @@ -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),
Expand All @@ -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
}

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