diff --git a/README.md b/README.md index 99a0569..fd87c16 100644 --- a/README.md +++ b/README.md @@ -471,6 +471,7 @@ options[custom: OpenAILanguageModel.self] = .init( frequencyPenalty: 0.5, presencePenalty: 0.3, stopSequences: ["END"], + stopAfterToolCalls: true, reasoningEffort: .high, // For reasoning models (o3, o4-mini) serviceTier: .priority, extraBody: [ // Vendor-specific parameters @@ -478,6 +479,9 @@ options[custom: OpenAILanguageModel.self] = .init( ] ) ``` +`stopAfterToolCalls` lets you return tool calls immediately without executing +tools, which is useful when you want to delegate tool execution to your own +orchestrator. ### Anthropic diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index 6299ce0..018b5f2 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -145,6 +145,12 @@ public struct OpenAILanguageModel: LanguageModel { /// Defaults to `true`. public var parallelToolCalls: Bool? + /// Whether to stop generation immediately after tool calls are generated. + /// + /// When enabled, the response returns the tool call transcript entries + /// without executing tools, and the content is empty. + public var stopAfterToolCalls: Bool? + /// The maximum number of total calls to built-in tools that can be processed /// in a response. /// @@ -323,6 +329,7 @@ public struct OpenAILanguageModel: LanguageModel { /// - reasoningEffort: Reasoning effort for reasoning models. /// - reasoning: Reasoning configuration (Responses API). /// - parallelToolCalls: Whether to allow parallel tool calls. + /// - stopAfterToolCalls: Whether to return tool calls without executing them. /// - maxToolCalls: Maximum number of tool calls (Responses API). /// - serviceTier: Service tier for request processing. /// - store: Whether to store the response. @@ -346,6 +353,7 @@ public struct OpenAILanguageModel: LanguageModel { reasoningEffort: ReasoningEffort? = nil, reasoning: ReasoningConfiguration? = nil, parallelToolCalls: Bool? = nil, + stopAfterToolCalls: Bool? = nil, maxToolCalls: Int? = nil, serviceTier: ServiceTier? = nil, store: Bool? = nil, @@ -369,6 +377,7 @@ public struct OpenAILanguageModel: LanguageModel { self.reasoningEffort = reasoningEffort self.reasoning = reasoning self.parallelToolCalls = parallelToolCalls + self.stopAfterToolCalls = stopAfterToolCalls self.maxToolCalls = maxToolCalls self.serviceTier = serviceTier self.store = store @@ -508,6 +517,24 @@ public struct OpenAILanguageModel: LanguageModel { if let value = try? JSONValue(toolCallMessage) { messages.append(OpenAIMessage(role: .raw(rawContent: value), content: .text(""))) } + + if let stop = options[custom: OpenAILanguageModel.self]?.stopAfterToolCalls, stop { + let calls = toolCalls.map { tc in + Transcript.ToolCall( + id: tc.id ?? UUID().uuidString, + toolName: tc.function?.name ?? "", + arguments: (try? GeneratedContent(json: tc.function?.arguments ?? "{}")) + ?? GeneratedContent(tc.function?.arguments ?? "") + ) + } + entries.append(.toolCalls(Transcript.ToolCalls(calls))) + return LanguageModelSession.Response( + content: "" as! Content, + rawContent: GeneratedContent(""), + transcriptEntries: ArraySlice(entries) + ) + } + let invocations = try await resolveToolCalls(toolCalls, session: session) if !invocations.isEmpty { entries.append(.toolCalls(Transcript.ToolCalls(invocations.map { $0.call }))) diff --git a/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift b/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift index 3486fe8..c37a19c 100644 --- a/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift +++ b/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift @@ -334,6 +334,7 @@ struct OpenAICustomOptionsTests { reasoningEffort: .high, reasoning: .init(effort: .medium, summary: "concise"), parallelToolCalls: false, + stopAfterToolCalls: true, maxToolCalls: 10, serviceTier: .priority, store: true, @@ -359,6 +360,7 @@ struct OpenAICustomOptionsTests { #expect(options.reasoning?.effort == .medium) #expect(options.reasoning?.summary == "concise") #expect(options.parallelToolCalls == false) + #expect(options.stopAfterToolCalls == true) #expect(options.maxToolCalls == 10) #expect(options.serviceTier == .priority) #expect(options.store == true) @@ -416,7 +418,8 @@ struct OpenAICustomOptionsTests { frequencyPenalty: 0.5, topLogprobs: 5, reasoningEffort: .medium, - parallelToolCalls: true + parallelToolCalls: true, + stopAfterToolCalls: true ) let encoder = JSONEncoder() @@ -429,6 +432,7 @@ struct OpenAICustomOptionsTests { #expect(json.contains("\"top_logprobs\"")) #expect(json.contains("\"reasoning_effort\"")) #expect(json.contains("\"parallel_tool_calls\"")) + #expect(!json.contains("stop_after_tool_calls")) } @Test func nilProperties() { @@ -446,6 +450,7 @@ struct OpenAICustomOptionsTests { #expect(options.reasoningEffort == nil) #expect(options.reasoning == nil) #expect(options.parallelToolCalls == nil) + #expect(options.stopAfterToolCalls == nil) #expect(options.maxToolCalls == nil) #expect(options.serviceTier == nil) #expect(options.store == nil)