Skip to content
Merged
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
49 changes: 45 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,12 +537,53 @@ let context = ContextOptions(
)
```

Only the models that run through Foundation Models use `toolCallingMode`.
`SystemLanguageModel`, `PrivateCloudComputeLanguageModel`, and `FoundationLanguageModel`
pass it to Foundation Models
A `nil` tool-calling mode, the default, leaves each model's behavior unchanged.
Each provider handles the three modes as follows:

| Provider | `allowed` | `disallowed` | `required` |
| --- | --- | --- | --- |
| `SystemLanguageModel`, `PrivateCloudComputeLanguageModel`, `FoundationLanguageModel` | Passed to Foundation Models | Passed to Foundation Models | Passed to Foundation Models |
| `OpenAILanguageModel` (both APIs), `OpenResponsesLanguageModel` | `"tool_choice": "auto"` | `"tool_choice": "none"` | `"tool_choice": "required"` |
| `AnthropicLanguageModel` | `"tool_choice": {"type": "auto"}` | `"tool_choice": {"type": "none"}` | `"tool_choice": {"type": "any"}` |
| `GeminiLanguageModel` | `functionCallingConfig` mode `AUTO` | `functionCallingConfig` mode `NONE` | `functionCallingConfig` mode `ANY` |
| `OllamaLanguageModel`, `MLXLanguageModel`, `LlamaLanguageModel`, `CoreMLLanguageModel` | Tools offered as usual | No tools offered | Not supported: tools offered as usual |

The models that run through Foundation Models pass the mode on
in apps built with Swift 6.4 or later that run on OS 27 or later,
and not on tvOS.
Other providers ignore it.
Otherwise, they ignore it.

The remote providers send the mode only with a request that has tools,
because a tool choice applies to the tools in the request.
For Gemini, server tools such as Google Search don't count:
the mode applies to the session's tools.
A provider's own tool-choice option takes precedence over `toolCallingMode`:
`toolChoice` in the custom options of `AnthropicLanguageModel`
and `OpenResponsesLanguageModel`,
and a `tool_choice` key in `extraBody`
for `OpenAILanguageModel`, `OpenResponsesLanguageModel`, and `AnthropicLanguageModel`.

Ollama, MLX, llama.cpp, and Core ML have no tool-choice setting.
They honor `disallowed` by leaving the tools out of the prompt,
so no tool runs.
They can't make a model call a tool,
so they treat `required` like `allowed`.

As in Foundation Models, the mode applies to every request in a response,
including each request that continues the response after tool calls.
With `required`, the model keeps calling tools
until a tool throws an error,
a `ToolExecutionDelegate` stops the response,
dynamic instructions remove the tools,
or a round of tool calls past `maximumToolRounds`
ends the response with `LanguageModelSession.ToolRoundLimitExceeded`.

> [!NOTE]
> Anthropic rejects `{"type": "any"}`
> with manual extended thinking (`thinking: .enabled(budgetTokens:)`)
> and on models that don't support forced tool use.
> See [Forcing tool use](https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools#forcing-tool-use).

A transcript keeps the tool-calling mode of each prompt.

No AnyLanguageModel API accepts `ContextOptions` yet,
Expand Down
30 changes: 26 additions & 4 deletions Sources/AnyLanguageModel/GenerationOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,32 @@ public struct GenerationOptions: Sendable, Equatable {

/// The tool calling requirements.
///
/// ``SystemLanguageModel``, ``PrivateCloudComputeLanguageModel``, and ``FoundationLanguageModel``
/// pass it to Foundation Models
/// when built with Swift 6.4 or later and run on OS 27 or later, except on tvOS.
/// Other models ignore it.
/// Leave it `nil` to use the model's default.
/// Models handle it as follows:
///
/// - ``SystemLanguageModel``, ``PrivateCloudComputeLanguageModel``, and ``FoundationLanguageModel``
/// pass it to Foundation Models
/// when built with Swift 6.4 or later and run on OS 27 or later, except on tvOS.
/// Otherwise, they ignore it.
/// - ``OpenAILanguageModel``, ``OpenResponsesLanguageModel``, ``AnthropicLanguageModel``,
/// and ``GeminiLanguageModel`` send it as the API's tool-choice setting
/// with each request that has tools.
/// The `toolChoice` custom option
/// of ``AnthropicLanguageModel`` and ``OpenResponsesLanguageModel`` overrides it,
/// as does a `tool_choice` key in `extraBody`
/// for ``OpenAILanguageModel``, ``OpenResponsesLanguageModel``, and ``AnthropicLanguageModel``.
/// - ``OllamaLanguageModel``, `MLXLanguageModel`, `LlamaLanguageModel`, and `CoreMLLanguageModel`
/// have no tool-choice setting.
/// For ``ToolCallingMode/disallowed``, they offer the model no tools.
/// They can't make the model call a tool,
/// so they offer the tools as usual for ``ToolCallingMode/required``.
///
/// As in Foundation Models, the mode applies to every request in a response,
/// including each request that continues the response after tool calls.
/// With ``ToolCallingMode/required``, the model calls tools until a tool throws,
/// a ``ToolExecutionDelegate`` stops the response,
/// dynamic instructions remove the tools,
/// or a round of tool calls past ``maximumToolRounds`` ends the response.
///
/// - Note: This API is exclusive to AnyLanguageModel on OS 26.
/// It follows the Foundation Models 27 `GenerationOptions.toolCallingMode` API,
Expand Down
11 changes: 11 additions & 0 deletions Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public struct AnthropicLanguageModel: LanguageModel {
/// How the model should use the provided tools.
///
/// Use this to control whether the model can use tools and which tools it prefers.
/// When set, it takes precedence over ``GenerationOptions/toolCallingMode``.
public var toolChoice: ToolChoice?

/// Configuration for extended thinking.
Expand Down Expand Up @@ -773,6 +774,16 @@ private func createMessageParams(
}
if let tools, !tools.isEmpty {
params["tools"] = try JSONValue(tools)
// Sent only with tools. A custom `toolChoice` or `extraBody` overrides it below.
if let toolCallingMode = options.toolCallingMode {
let type =
switch toolCallingMode.kind {
case .allowed: "auto"
case .required: "any"
case .disallowed: "none"
}
params["tool_choice"] = .object(["type": .string(type)])
}
}
if let responseSchema {
// Structured outputs: https://platform.claude.com/docs/en/build-with-claude/structured-outputs
Expand Down
10 changes: 7 additions & 3 deletions Sources/AnyLanguageModel/Models/CoreMLLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
if let chatTemplateHandler = chatTemplateHandler {
// Use chat template handler with optional tools
let messages = chatTemplateHandler(requestContext.instructions, prompt)
let toolSpecs: [ToolSpec]? = toolsHandler?(requestContext.tools)
let toolSpecs: [ToolSpec]? = toolsHandler?(options.toolsOffered(from: requestContext.tools))
tokens = try tokenizer.applyChatTemplate(messages: messages, tools: toolSpecs)
} else {
// Fall back to direct tokenizer encoding
Expand Down Expand Up @@ -242,7 +242,9 @@
if let chatTemplateHandler = chatTemplateHandler {
// Use chat template handler with optional tools
let messages = chatTemplateHandler(requestContext.instructions, prompt)
let toolSpecs: [ToolSpec]? = toolsHandler?(requestContext.tools)
let toolSpecs: [ToolSpec]? = toolsHandler?(
options.toolsOffered(from: requestContext.tools)
)
tokens = try tokenizer.applyChatTemplate(messages: messages, tools: toolSpecs)
} else {
// Fall back to direct tokenizer encoding
Expand Down Expand Up @@ -413,6 +415,7 @@

let promptTokens = try structuredPromptTokens(
requestContext: requestContext,
tools: options.toolsOffered(from: requestContext.tools),
prompt: prompt,
schema: schema,
includeSchemaInPrompt: includeSchemaInPrompt
Expand Down Expand Up @@ -450,6 +453,7 @@

private func structuredPromptTokens(
requestContext: LanguageModelSession.RequestContext,
tools: [any Tool],
prompt: Prompt,
schema: GenerationSchema,
includeSchemaInPrompt: Bool
Expand All @@ -462,7 +466,7 @@
messages.insert(["role": "system", "content": schemaPrompt], at: 0)
}
}
let toolSpecs: [ToolSpec]? = toolsHandler?(requestContext.tools)
let toolSpecs: [ToolSpec]? = toolsHandler?(tools)
return try tokenizer.applyChatTemplate(messages: messages, tools: toolSpecs)
}

Expand Down
20 changes: 20 additions & 0 deletions Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,20 @@ private func createGenerateContentParams<Content: Generable>(
break
}
}

// The function-calling mode applies to function declarations, not to server tools,
// so it's sent only with the session's tools.
if let toolCallingMode = options.toolCallingMode, tools.contains(where: \.isFunctionDeclarations) {
let mode =
switch toolCallingMode.kind {
case .allowed: "AUTO"
case .required: "ANY"
case .disallowed: "NONE"
}
var toolConfig = params["toolConfig"]?.objectValue ?? [:]
toolConfig["functionCallingConfig"] = .object(["mode": .string(mode)])
params["toolConfig"] = .object(toolConfig)
}
}

var generationConfig: [String: JSONValue] = [:]
Expand Down Expand Up @@ -889,6 +903,12 @@ private enum GeminiTool: Sendable {
}
}

/// Whether this tool holds the session's function declarations, not a server tool.
var isFunctionDeclarations: Bool {
if case .functionDeclarations = self { return true }
return false
}

var toolConfigValue: JSONValue? {
switch self {
case .googleMaps(let latitude, let longitude):
Expand Down
12 changes: 8 additions & 4 deletions Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -861,8 +861,10 @@ import Foundation
if mmprojPath == nil {
try validateNoImageSegments(in: requestContext.transcript)
}
// llama.cpp has no tool-choice setting.
let tools = options.toolsOffered(from: requestContext.tools)
let toolContext = try makeToolPromptContext(
tools: requestContext.tools,
tools: tools,
pendingEntries: pendingEntries
)
var promptImages: [Data] = []
Expand Down Expand Up @@ -932,7 +934,7 @@ import Foundation

let resolution = try await resolveToolCalls(
parsedCalls,
tools: requestContext.tools,
tools: tools,
session: session
)
switch resolution {
Expand Down Expand Up @@ -1108,8 +1110,10 @@ import Foundation
if self.mmprojPath == nil {
try self.validateNoImageSegments(in: requestContext.transcript)
}
// llama.cpp has no tool-choice setting.
let tools = options.toolsOffered(from: requestContext.tools)
let toolContext = try self.makeToolPromptContext(
tools: requestContext.tools,
tools: tools,
pendingEntries: pendingEntries
)
var promptImages: [Data] = []
Expand Down Expand Up @@ -1194,7 +1198,7 @@ import Foundation

let resolution = try await self.resolveToolCalls(
parsedCalls,
tools: requestContext.tools,
tools: tools,
session: session
)
switch resolution {
Expand Down
12 changes: 8 additions & 4 deletions Sources/AnyLanguageModel/Models/MLXLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1133,7 +1133,9 @@ import Foundation
// Loop until no more tool calls
while true {
let requestContext = session.resolvedRequestContext()
let toolSpecs = mlxToolSpecs(for: requestContext.tools)
// MLX has no tool-choice setting.
let tools = options.toolsOffered(from: requestContext.tools)
let toolSpecs = mlxToolSpecs(for: tools)
let chat =
convertTranscriptToMLXChat(
requestContext: requestContext,
Expand Down Expand Up @@ -1199,7 +1201,7 @@ import Foundation

let resolution = try await resolveToolCalls(
collectedToolCalls,
tools: requestContext.tools,
tools: tools,
session: session
)
switch resolution {
Expand Down Expand Up @@ -1374,7 +1376,9 @@ import Foundation
// Loop until the model stops without pending tool calls (mirrors `respond()`).
toolLoop: while true {
let requestContext = session.resolvedRequestContext()
let toolSpecs = mlxToolSpecs(for: requestContext.tools)
// MLX has no tool-choice setting.
let tools = options.toolsOffered(from: requestContext.tools)
let toolSpecs = mlxToolSpecs(for: tools)
let chat =
convertTranscriptToMLXChat(
requestContext: requestContext,
Expand Down Expand Up @@ -1440,7 +1444,7 @@ import Foundation

let resolution = try await resolveToolCalls(
collectedToolCalls,
tools: requestContext.tools,
tools: tools,
session: session
)
switch resolution {
Expand Down
12 changes: 8 additions & 4 deletions Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ public struct OllamaLanguageModel: LanguageModel {
}

let requestContext = session.resolvedRequestContext()
let ollamaTools = try requestContext.tools.map(convertToolToOllamaFormat)
// Ollama has no tool-choice setting.
let tools = options.toolsOffered(from: requestContext.tools)
let ollamaTools = try tools.map(convertToolToOllamaFormat)
var messages = try requestContext.transcript.toOllamaMessages()
if messages.isEmpty {
messages.append(.init(role: .user, content: prompt.description))
Expand Down Expand Up @@ -160,7 +162,7 @@ public struct OllamaLanguageModel: LanguageModel {
if let toolCalls = chatResponse.message.toolCalls, !toolCalls.isEmpty {
let resolution = try await resolveToolCalls(
toolCalls,
tools: requestContext.tools,
tools: tools,
session: session
)
switch resolution {
Expand Down Expand Up @@ -258,7 +260,9 @@ public struct OllamaLanguageModel: LanguageModel {
while true {
try Task.checkCancellation()
let requestContext = session.resolvedRequestContext()
let tools = try requestContext.tools.map(convertToolToOllamaFormat)
// Ollama has no tool-choice setting.
let offeredTools = options.toolsOffered(from: requestContext.tools)
let tools = try offeredTools.map(convertToolToOllamaFormat)
var messages = try requestContext.transcript.toOllamaMessages()
if messages.isEmpty {
messages.append(.init(role: .user, content: prompt.description))
Expand Down Expand Up @@ -296,7 +300,7 @@ public struct OllamaLanguageModel: LanguageModel {
try toolRounds.record(completed: state.entries)
switch try await resolveToolCalls(
toolCalls,
tools: requestContext.tools,
tools: offeredTools,
session: session
) {
case .stop(let calls):
Expand Down
8 changes: 8 additions & 0 deletions Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,10 @@ private enum ChatCompletions {

if let tools {
body["tools"] = .array(tools.map { $0.jsonValue(for: .chatCompletions) })
// Sent only with tools. `extraBody` can override it.
if let toolCallingMode = options.toolCallingMode {
body["tool_choice"] = .string(toolCallingMode.kind.openAIToolChoice)
}
}

if type != String.self {
Expand Down Expand Up @@ -1246,6 +1250,10 @@ private enum Responses {

if let tools {
body["tools"] = .array(tools.map { $0.jsonValue(for: .responses) })
// Sent only with tools. `extraBody` can override it.
if let toolCallingMode = options.toolCallingMode {
body["tool_choice"] = .string(toolCallingMode.kind.openAIToolChoice)
}
}

if type != String.self {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public struct OpenResponsesLanguageModel: LanguageModel {
/// reasoning/text options. Use ``extraBody`` for parameters not yet modeled.
public struct CustomGenerationOptions: AnyLanguageModel.CustomGenerationOptions, Codable, Sendable {
/// Controls which tool the model should use, if any.
///
/// When set, it takes precedence over ``GenerationOptions/toolCallingMode``.
public var toolChoice: ToolChoice?

/// The list of tools that are permitted for this request.
Expand Down Expand Up @@ -808,6 +810,10 @@ private enum OpenResponsesAPI {

if let tools {
body["tools"] = .array(tools.map { $0.jsonValue })
// Sent only with tools. A custom `toolChoice` or `extraBody` overrides it below.
if let toolCallingMode = options.toolCallingMode {
body["tool_choice"] = .string(toolCallingMode.kind.openAIToolChoice)
}
}

if type != String.self {
Expand Down
25 changes: 25 additions & 0 deletions Sources/AnyLanguageModel/Shared/ToolCallingModeRequests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
extension GenerationOptions.ToolCallingMode.Kind {
/// The `tool_choice` value for this kind
/// in the OpenAI Chat Completions, OpenAI Responses, and Open Responses APIs.
var openAIToolChoice: String {
switch self {
case .allowed: "auto"
case .required: "required"
case .disallowed: "none"
}
}
}

extension GenerationOptions {
/// The tools to offer the model in one request,
/// for a model that has no tool-choice setting.
///
/// Such a model honors ``ToolCallingMode/disallowed`` by being offered no tools,
/// so no tool runs.
/// It can't be made to call a tool,
/// so it's offered the tools for ``ToolCallingMode/required``
/// as it is for ``ToolCallingMode/allowed``.
func toolsOffered(from tools: [any Tool]) -> [any Tool] {
toolCallingMode?.kind == .disallowed ? [] : tools
}
}
Loading
Loading