From df2f88431cc7158999ce1497da426f5e3b0b352f Mon Sep 17 00:00:00 2001 From: Neel Virdy Date: Sun, 14 Dec 2025 20:58:56 -0500 Subject: [PATCH] Support allowed_tools for tool_choice --- Sources/OpenAI/Public/Models/ChatQuery.swift | 165 +++++++++++++++++- .../Public/Schemas/Generated/Components.swift | 133 ++++++++++++++ Tests/OpenAITests/OpenAITestsDecoder.swift | 122 +++++++++++++ 3 files changed, 413 insertions(+), 7 deletions(-) diff --git a/Sources/OpenAI/Public/Models/ChatQuery.swift b/Sources/OpenAI/Public/Models/ChatQuery.swift index 4ada9282..12a3f243 100644 --- a/Sources/OpenAI/Public/Models/ChatQuery.swift +++ b/Sources/OpenAI/Public/Models/ChatQuery.swift @@ -1082,6 +1082,28 @@ public struct ChatQuery: Equatable, Codable, Streamable, Sendable { case auto case function(String) case required + /// Let the model choose between a restricted set of function tools. + case allowedTools(AllowedTools) + + public enum AllowedToolsMode: String, Codable, Equatable, Sendable { + case auto + case required + } + + public struct AllowedTools: Codable, Equatable, Sendable { + public let mode: AllowedToolsMode? + public let tools: [String] + + public init(mode: AllowedToolsMode? = nil, tools: [String]) { + self.mode = mode + self.tools = tools + } + } + + /// Convenience overload that preserves `.allowedTools(...)` call site while adding optional `mode`. + public static func allowedTools(_ tools: [String], mode: AllowedToolsMode? = nil) -> Self { + .allowedTools(.init(mode: mode, tools: tools)) + } public func encode(to encoder: Encoder) throws { switch self { @@ -1092,12 +1114,83 @@ public struct ChatQuery: Equatable, Codable, Streamable, Sendable { var container = encoder.singleValueContainer() try container.encode(CodingKeys.auto.rawValue) case .function(let name): - var container = encoder.container(keyedBy: Self.ChatCompletionFunctionCallNameParam.CodingKeys.self) - try container.encode("function", forKey: .type) - try container.encode(["name": name], forKey: .function) + try ToolChoice( + type: "function", + function: FunctionNameParam(name: name), + mode: nil, + tools: nil, + legacyAllowedTools: nil + ).encode(to: encoder) case .required: var container = encoder.singleValueContainer() try container.encode(CodingKeys.required.rawValue) + case .allowedTools(let config): + try ToolChoice( + type: "allowed_tools", + function: nil, + mode: config.mode?.rawValue, + tools: config.tools.map { .functionTool(.init(name: $0)) }, + legacyAllowedTools: nil + ).encode(to: encoder) + } + } + + public init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer(), + let stringValue = try? container.decode(String.self) { + switch stringValue { + case CodingKeys.none.rawValue: + self = .none + case CodingKeys.auto.rawValue: + self = .auto + case CodingKeys.required.rawValue: + self = .required + default: + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unknown tool_choice value: \(stringValue)" + ) + } + return + } + + let object = try ToolChoice(from: decoder) + switch object.type { + case "function": + guard let function = object.function else { + throw DecodingError.keyNotFound( + ToolChoice.CodingKeys.function, + .init( + codingPath: decoder.codingPath, + debugDescription: "Expected `function` for tool_choice.type == \"function\"." + ) + ) + } + self = .function(function.name) + case "allowed_tools": + let allowedTools: [AllowedToolNamePayload] + if let tools = object.tools { + allowedTools = tools + } else if let legacyAllowedTools = object.legacyAllowedTools { + allowedTools = legacyAllowedTools + } else { + throw DecodingError.keyNotFound( + ToolChoice.CodingKeys.tools, + .init( + codingPath: decoder.codingPath, + debugDescription: "Expected `tools` (or legacy `allowed_tools`) in allowed_tools tool_choice." + ) + ) + } + let names = allowedTools.map(\.name) + let parsedMode = object.mode.flatMap(AllowedToolsMode.init(rawValue:)) + self = .allowedTools(.init(mode: parsedMode, tools: names)) + default: + throw DecodingError.dataCorruptedError( + forKey: ToolChoice.CodingKeys.type, + in: try decoder.container(keyedBy: ToolChoice.CodingKeys.self), + debugDescription: "Unknown tool_choice.type: \(object.type)" + ) } } @@ -1105,6 +1198,10 @@ public struct ChatQuery: Equatable, Codable, Streamable, Sendable { self = .function(function) } + public init(allowedTools: [String]) { + self = .allowedTools(.init(mode: nil, tools: allowedTools)) + } + enum CodingKeys: String, CodingKey { case none = "none" case auto = "auto" @@ -1112,13 +1209,67 @@ public struct ChatQuery: Equatable, Codable, Streamable, Sendable { case required = "required" } - private enum ChatCompletionFunctionCallNameParam: Codable, Equatable { - case type - case function + private struct FunctionNameParam: Codable, Equatable { + let name: String + } - enum CodingKeys: CodingKey { + private enum AllowedToolNamePayload: Codable, Equatable { + case name(String) + case functionTool(AllowedToolParam) + + init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer(), + let name = try? container.decode(String.self) { + self = .name(name) + return + } + + self = .functionTool(try AllowedToolParam(from: decoder)) + } + + func encode(to encoder: Encoder) throws { + switch self { + case .name(let name): + var container = encoder.singleValueContainer() + try container.encode(name) + case .functionTool(let tool): + try tool.encode(to: encoder) + } + } + + var name: String { + switch self { + case .name(let name): + return name + case .functionTool(let tool): + return tool.function.name + } + } + } + + private struct AllowedToolParam: Codable, Equatable { + let type: String + let function: FunctionNameParam + + init(type: String = "function", name: String) { + self.type = type + self.function = .init(name: name) + } + } + + private struct ToolChoice: Codable, Equatable { + let type: String + let function: FunctionNameParam? + let mode: String? + let tools: [AllowedToolNamePayload]? + let legacyAllowedTools: [AllowedToolNamePayload]? + + enum CodingKeys: String, CodingKey { case type case function + case mode + case tools + case legacyAllowedTools = "allowed_tools" } } } diff --git a/Sources/OpenAI/Public/Schemas/Generated/Components.swift b/Sources/OpenAI/Public/Schemas/Generated/Components.swift index 8f7dce6d..dbe0a8b1 100644 --- a/Sources/OpenAI/Public/Schemas/Generated/Components.swift +++ b/Sources/OpenAI/Public/Schemas/Generated/Components.swift @@ -7493,6 +7493,8 @@ public enum Components { case ToolChoiceTypes(Components.Schemas.ToolChoiceTypes) /// - Remark: Generated from `#/components/schemas/ResponseProperties/tool_choice/case3`. case ToolChoiceFunction(Components.Schemas.ToolChoiceFunction) + /// - Remark: Generated from `#/components/schemas/ResponseProperties/tool_choice/case4`. + case ToolChoiceAllowedTools(Components.Schemas.ToolChoiceAllowedTools) public init(from decoder: any Decoder) throws { var errors: [any Error] = [] do { @@ -7513,6 +7515,12 @@ public enum Components { } catch { errors.append(error) } + do { + self = .ToolChoiceAllowedTools(try .init(from: decoder)) + return + } catch { + errors.append(error) + } throw Swift.DecodingError.failedToDecodeOneOfSchema( type: Self.self, codingPath: decoder.codingPath, @@ -7527,6 +7535,8 @@ public enum Components { try value.encode(to: encoder) case let .ToolChoiceFunction(value): try value.encode(to: encoder) + case let .ToolChoiceAllowedTools(value): + try value.encode(to: encoder) } } } @@ -9931,6 +9941,129 @@ public enum Components { case _type = "type" } } + /// Use this option to let the model pick from a restricted set of tools. + /// + /// - Remark: Matches the `allowed_tools` tool choice shape. + public struct ToolChoiceAllowedTools: Codable, Hashable, Sendable { + /// For allowed tools selection, the type is always `allowed_tools`. + @frozen public enum _TypePayload: String, Codable, Hashable, Sendable, CaseIterable { + case allowedTools = "allowed_tools" + } + /// Controls how the model chooses among the allowed tools. + @frozen public enum ModePayload: String, Codable, Hashable, Sendable, CaseIterable { + case auto = "auto" + case required = "required" + } + /// For allowed tools selection, the type is always `allowed_tools`. + public var _type: Components.Schemas.ToolChoiceAllowedTools._TypePayload + /// Controls how the model chooses among the allowed tools. + public var mode: Components.Schemas.ToolChoiceAllowedTools.ModePayload? + /// The list of tools the model is allowed to call. + @frozen public enum AllowedToolPayload: Codable, Hashable, Sendable { + case toolName(Swift.String) + case ToolChoiceTypes(Components.Schemas.ToolChoiceTypes) + case ToolChoiceFunction(Components.Schemas.ToolChoiceFunction) + public init(from decoder: any Decoder) throws { + var errors: [any Error] = [] + do { + self = .toolName(try decoder.decodeFromSingleValueContainer()) + return + } catch { + errors.append(error) + } + do { + self = .ToolChoiceTypes(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .ToolChoiceFunction(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + throw Swift.DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case let .toolName(value): + try encoder.encodeToSingleValueContainer(value) + case let .ToolChoiceTypes(value): + try value.encode(to: encoder) + case let .ToolChoiceFunction(value): + try value.encode(to: encoder) + } + } + } + public var allowedTools: [Components.Schemas.ToolChoiceAllowedTools.AllowedToolPayload] + public init( + _type: Components.Schemas.ToolChoiceAllowedTools._TypePayload = .allowedTools, + mode: Components.Schemas.ToolChoiceAllowedTools.ModePayload? = nil, + allowedTools: [Components.Schemas.ToolChoiceAllowedTools.AllowedToolPayload] + ) { + self._type = _type + self.mode = mode + self.allowedTools = allowedTools + } + public init(allowedToolNames: [Swift.String]) { + self._type = .allowedTools + self.mode = nil + self.allowedTools = allowedToolNames.map { .toolName($0) } + } + public enum CodingKeys: String, CodingKey { + case _type = "type" + case mode + case tools + case allowedTools = "allowed_tools" + } + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self._type = try container.decode( + Components.Schemas.ToolChoiceAllowedTools._TypePayload.self, + forKey: ._type + ) + self.mode = try container.decodeIfPresent( + Components.Schemas.ToolChoiceAllowedTools.ModePayload.self, + forKey: .mode + ) + if let tools = try container.decodeIfPresent( + [Components.Schemas.ToolChoiceAllowedTools.AllowedToolPayload].self, + forKey: .tools + ) { + self.allowedTools = tools + } else if let allowedTools = try container.decodeIfPresent( + [Components.Schemas.ToolChoiceAllowedTools.AllowedToolPayload].self, + forKey: .allowedTools + ) { + self.allowedTools = allowedTools + } else { + throw Swift.DecodingError.keyNotFound( + CodingKeys.tools, + .init( + codingPath: decoder.codingPath, + debugDescription: "Expected `tools` (or legacy `allowed_tools`) in allowed_tools tool_choice." + ) + ) + } + try decoder.ensureNoAdditionalProperties(knownKeys: [ + "type", + "mode", + "tools", + "allowed_tools" + ]) + } + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self._type, forKey: ._type) + try container.encodeIfPresent(self.mode, forKey: .mode) + try container.encode(self.allowedTools, forKey: .tools) + } + } /// Emitted when there is an additional text delta. This is also the first event emitted when the transcription starts. Only emitted when you [create a transcription](/docs/api-reference/audio/create-transcription) with the `Stream` parameter set to `true`. /// /// - Remark: Generated from `#/components/schemas/TranscriptTextDeltaEvent`. diff --git a/Tests/OpenAITests/OpenAITestsDecoder.swift b/Tests/OpenAITests/OpenAITestsDecoder.swift index bab1ffe7..bfce5202 100644 --- a/Tests/OpenAITests/OpenAITestsDecoder.swift +++ b/Tests/OpenAITests/OpenAITestsDecoder.swift @@ -318,6 +318,128 @@ class OpenAITestsDecoder: XCTestCase { ) try decode(data, expectedValue) } + + func testChatQueryToolChoiceAllowedTools() async throws { + let tool1 = ChatQuery.ChatCompletionToolParam.makeWeatherMock() + let tool2 = ChatQuery.ChatCompletionToolParam( + function: .init( + name: "get_forecast", + description: "Get the forecast in a given location", + parameters: nil, + strict: nil + ) + ) + + let chatQuery = ChatQuery( + messages: [ + .user(.init(content: .string("What's the weather like in Boston?"))) + ], + model: .gpt5, + toolChoice: .allowedTools(["get_current_weather", "get_forecast"], mode: .auto), + tools: [ + tool1, + tool2 + ] + ) + + let expectedValue = """ + { + "model": "gpt-5", + "messages": [ + { + "role": "user", + "content": "What's the weather like in Boston?" + } + ], + "tools": [ + { + "function": { + "name": "get_current_weather", + "description": "Get the current weather in a given location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + }, + "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] } + }, + "required": ["location"] + } + }, + "type": "function" + }, + { + "function": { + "name": "get_forecast", + "description": "Get the forecast in a given location" + }, + "type": "function" + } + ], + "tool_choice": { + "type": "allowed_tools", + "mode": "auto", + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_weather" + } + }, + { + "type": "function", + "function": { + "name": "get_forecast" + } + } + ] + }, + "stream": false + } + """ + + try encode(chatQuery, expectedValue) + } + + func testCreateModelResponseQueryToolChoiceAllowedTools() async throws { + let allowedTools = Components.Schemas.ToolChoiceAllowedTools( + mode: .auto, + allowedTools: [ + .ToolChoiceFunction(.init(_type: .function, name: "my_function")), + .ToolChoiceTypes(.init(_type: .fileSearch)) + ] + ) + + let query = CreateModelResponseQuery( + input: .textInput("Hello"), + model: "test-model", + toolChoice: .ToolChoiceAllowedTools(allowedTools) + ) + + let expectedValue = """ + { + "input": "Hello", + "model": "test-model", + "tool_choice": { + "type": "allowed_tools", + "mode": "auto", + "tools": [ + { + "type": "function", + "name": "my_function" + }, + { + "type": "file_search" + } + ] + } + } + """ + + try encode(query, expectedValue) + } func testChatQueryWithReasoningEffort() throws { let chatQuery = ChatQuery(