diff --git a/README.md b/README.md index b272beea..d4254ab1 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ This repository contains Swift community-maintained implementation over [OpenAI] - [Initialization](#initialization) - [Chats](#chats) - [Chats Streaming](#chats-streaming) + - [Structured Output](#structured-output) - [Images](#images) - [Create Image](#create-image) - [Create Image Edit](#create-image-edit) @@ -295,9 +296,57 @@ Result will be (serialized as JSON here for readability): ``` - Review [Chat Documentation](https://platform.openai.com/docs/guides/chat) for more info. +#### Structured Output + +JSON is one of the most widely used formats in the world for applications to exchange data. + +Structured Outputs is a feature that ensures the model will always generate responses that adhere to your supplied JSON Schema, so you don't need to worry about the model omitting a required key, or hallucinating an invalid enum value. + +**Example** + +```swift +struct MovieInfo: StructuredOutput { + + let title: String + let director: String + let release: Date + let genres: [MovieGenre] + let cast: [String] + + static let example: Self = { + .init( + title: "Earth", + director: "Alexander Dovzhenko", + release: Calendar.current.date(from: DateComponents(year: 1930, month: 4, day: 1))!, + genres: [.drama], + cast: ["Stepan Shkurat", "Semyon Svashenko", "Yuliya Solntseva"] + ) + }() +} + +enum MovieGenre: String, Codable, StructuredOutputEnum { + case action, drama, comedy, scifi + + var caseNames: [String] { Self.allCases.map { $0.rawValue } } +} + +let query = ChatQuery( + messages: [.system(.init(content: "Best Picture winner at the 2011 Oscars"))], + model: .gpt4_o, + responseFormat: .jsonSchema(name: "movie-info", type: MovieInfo.self) +) +let result = try await openAI.chats(query: query) +``` + +- Use the `jsonSchema(name:type:)` response format when creating a `ChatQuery` +- Provide a schema name and a type that conforms to `ChatQuery.StructuredOutput` and generates an instance as an example +- Make sure all enum types within the provided type conform to `ChatQuery.StructuredOutputEnum` and generate an array of names for all cases + + +Review [Structured Output Documentation](https://platform.openai.com/docs/guides/structured-outputs) for more info. + ### Images Given a prompt and/or an input image, the model will generate a new image. diff --git a/Sources/OpenAI/Public/Models/ChatQuery.swift b/Sources/OpenAI/Public/Models/ChatQuery.swift index 2e22d4a0..c635ad3c 100644 --- a/Sources/OpenAI/Public/Models/ChatQuery.swift +++ b/Sources/OpenAI/Public/Models/ChatQuery.swift @@ -599,14 +599,263 @@ public struct ChatQuery: Equatable, Codable, Streamable { } } - // See more https://platform.openai.com/docs/guides/text-generation/json-mode - public enum ResponseFormat: String, Codable, Equatable { - case jsonObject = "json_object" + // See more https://platform.openai.com/docs/guides/structured-outputs/introduction + public enum ResponseFormat: Codable, Equatable { + case text - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(["type": self.rawValue]) + case jsonObject + case jsonSchema(name: String, type: StructuredOutput.Type) + + enum CodingKeys: String, CodingKey { + case type + case jsonSchema = "json_schema" + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .text: + try container.encode("text", forKey: .type) + case .jsonObject: + try container.encode("json_object", forKey: .type) + case .jsonSchema(let name, let type): + try container.encode("json_schema", forKey: .type) + let schema = JSONSchema(name: name, schema: type.example) + try container.encode(schema, forKey: .jsonSchema) + } + } + + public static func == (lhs: ResponseFormat, rhs: ResponseFormat) -> Bool { + switch (lhs, rhs) { + case (.text, .text): return true + case (.jsonObject, .jsonObject): return true + case (.jsonSchema(let lhsName, let lhsType), .jsonSchema(let rhsName, let rhsType)): + return lhsName == rhsName && lhsType == rhsType + default: + return false + } + } + + /// A formal initializer reqluired for the inherited Decodable conformance. + /// This type is never returned from the server and is never decoded into. + public init(from decoder: any Decoder) throws { + self = .text + } + } + + private struct JSONSchema: Encodable { + + let name: String + let schema: StructuredOutput + + enum CodingKeys: String, CodingKey { + case name + case schema + case strict + } + + init(name: String, schema: StructuredOutput) { + + func format(_ name: String) -> String { + var formattedName = name.replacingOccurrences(of: " ", with: "_") + let regex = try! NSRegularExpression(pattern: "[^a-zA-Z0-9_-]", options: []) + let range = NSRange(location: 0, length: formattedName.utf16.count) + formattedName = regex.stringByReplacingMatches(in: formattedName, options: [], range: range, withTemplate: "") + formattedName = formattedName.isEmpty ? "sample" : formattedName + formattedName = String(formattedName.prefix(64)) + return formattedName + } + + self.name = format(name) + self.schema = schema + + if self.name != name { + print("The name was changed to \(self.name) to satisfy the API requirements. See more: https://platform.openai.com/docs/api-reference/chat/create") + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(true, forKey: .strict) + try container.encode(try PropertyValue(from: schema), forKey: .schema) + } + } + + private indirect enum PropertyValue: Codable { + + enum SimpleType: String, Codable { + case string, integer, number, boolean + } + + enum ComplexType: String, Codable { + case object, array, date + } + + enum SpecialType: String, Codable { + case null + } + + case simple(SimpleType, isOptional: Bool) + case date(isOptional: Bool) + case `enum`(cases: [String], isOptional: Bool) + case object([String: PropertyValue], isOptional: Bool) + case array(PropertyValue, isOptional: Bool) + + enum CodingKeys: String, CodingKey { + case type + case description + case properties + case items + case additionalProperties + case required + case `enum` + } + + enum ValueType: String, Codable { + case string + case date + case integer + case number + case boolean + case object + case array + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .simple(let type, let isOptional): + if isOptional { + try container.encode([type.rawValue, SpecialType.null.rawValue], forKey: .type) + } else { + try container.encode(type.rawValue, forKey: .type) + } + case .date(let isOptional): + if isOptional { + try container.encode([SimpleType.string.rawValue, SpecialType.null.rawValue], forKey: .type) + } else { + try container.encode(SimpleType.string.rawValue, forKey: .type) + } + try container.encode("String that represents a date formatted in iso8601", forKey: .description) + case .enum(let cases, let isOptional): + if isOptional { + try container.encode([SimpleType.string.rawValue, SpecialType.null.rawValue], forKey: .type) + } else { + try container.encode(SimpleType.string.rawValue, forKey: .type) + } + try container.encode(cases, forKey: .enum) + case .object(let object, let isOptional): + if isOptional { + try container.encode([ComplexType.object.rawValue, SpecialType.null.rawValue], forKey: .type) + } else { + try container.encode(ComplexType.object.rawValue, forKey: .type) + } + try container.encode(false, forKey: .additionalProperties) + try container.encode(object, forKey: .properties) + let fields = object.map { key, value in key } + try container.encode(fields, forKey: .required) + case .array(let items, let isOptional): + if isOptional { + try container.encode([ComplexType.array.rawValue, SpecialType.null.rawValue], forKey: .type) + } else { + try container.encode(ComplexType.array.rawValue, forKey: .type) + } + try container.encode(items, forKey: .items) + } + } + + init(from value: T) throws { + let mirror = Mirror(reflecting: value) + let isOptional = mirror.displayStyle == .optional + + switch value { + case _ as String: + self = .simple(.string, isOptional: isOptional) + return + case _ as Bool: + self = .simple(.boolean, isOptional: isOptional) + return + case _ as Int, _ as Int8, _ as Int16, _ as Int32, _ as Int64, _ as UInt, _ as UInt8, _ as UInt16, _ as UInt32, _ as UInt64: + self = .simple(.integer, isOptional: isOptional) + return + case _ as Double, _ as Float, _ as CGFloat: + self = .simple(.number, isOptional: isOptional) + return + case _ as Date: + self = .date(isOptional: isOptional) + return + default: + + var unwrappedMirror: Mirror! + if isOptional { + guard let child = mirror.children.first else { + throw StructuredOutputError.nilFoundInExample + } + unwrappedMirror = Mirror(reflecting: child.value) + } else { + unwrappedMirror = mirror + } + + if let displayStyle = unwrappedMirror.displayStyle { + + switch displayStyle { + + case .struct, .class: + var dict = [String: PropertyValue]() + for child in unwrappedMirror.children { + dict[child.label!] = try Self(from: child.value) + } + self = .object(dict, isOptional: isOptional) + return + + case .collection: + if let child = unwrappedMirror.children.first { + self = .array(try Self(from: child.value), isOptional: isOptional) + return + } else { + throw StructuredOutputError.typeUnsupported + } + + case .enum: + if let structuredEnum = value as? any StructuredOutputEnum { + self = .enum(cases: structuredEnum.caseNames, isOptional: isOptional) + return + } else { + throw StructuredOutputError.enumsConformance + } + + default: + throw StructuredOutputError.typeUnsupported + } + } + throw StructuredOutputError.typeUnsupported + } + } + + + /// A formal initializer reqluired for the inherited Decodable conformance. + /// This type is never returned from the server and is never decoded into. + init(from decoder: Decoder) throws { + self = .simple(.boolean, isOptional: false) + } + } + + public enum StructuredOutputError: LocalizedError { + case enumsConformance + case typeUnsupported + case nilFoundInExample + + public var errorDescription: String? { + switch self { + case .enumsConformance: + return "Conform the enum types to StructuredOutputEnum and provide the `caseNames` property with a list of available cases." + case .typeUnsupported: + return "Unsupported type. Supported types: String, Bool, Int, Double, Array, and Codable struct/class instances." + case .nilFoundInExample: + return "Found nils when serializing the StructuredOutput‘s example. Provide values for all optional properties in the example." + } } } diff --git a/Sources/OpenAI/Public/Models/StructuredOutput.swift b/Sources/OpenAI/Public/Models/StructuredOutput.swift new file mode 100644 index 00000000..36142bcb --- /dev/null +++ b/Sources/OpenAI/Public/Models/StructuredOutput.swift @@ -0,0 +1,12 @@ +// +// StructuredOutput.swift +// +// +// Created by Andriy Gordiyenko on 8/28/24. +// + +import Foundation + +public protocol StructuredOutput: Codable { + static var example: Self { get } +} diff --git a/Sources/OpenAI/Public/Models/StructuredOutputEnum.swift b/Sources/OpenAI/Public/Models/StructuredOutputEnum.swift new file mode 100644 index 00000000..fc0aee4c --- /dev/null +++ b/Sources/OpenAI/Public/Models/StructuredOutputEnum.swift @@ -0,0 +1,12 @@ +// +// StructuredOutputEnum.swift +// +// +// Created by Andriy Gordiyenko on 8/29/24. +// + +import Foundation + +public protocol StructuredOutputEnum: CaseIterable { + var caseNames: [String] { get } +} diff --git a/Tests/OpenAITests/OpenAITests.swift b/Tests/OpenAITests/OpenAITests.swift index dc7b3d70..a5102f9c 100644 --- a/Tests/OpenAITests/OpenAITests.swift +++ b/Tests/OpenAITests/OpenAITests.swift @@ -95,6 +95,46 @@ class OpenAITests: XCTestCase { let result = try await openAI.chats(query: query) XCTAssertEqual(result, chatResult) } + + func testChatQueryWithStructuredOutput() async throws { + + let chatResult = ChatResult(id: "id-12312", object: "foo", created: 100, model: .gpt3_5Turbo, choices: [ + ], usage: .init(completionTokens: 200, promptTokens: 100, totalTokens: 300), systemFingerprint: nil) + try self.stub(result: chatResult) + + enum MovieGenre: String, Codable, StructuredOutputEnum { + case action, drama, comedy, scifi + var caseNames: [String] { Self.allCases.map { $0.rawValue } } + } + + struct MovieInfo: StructuredOutput { + + let title: String + let director: String + let release: Date + let genres: [MovieGenre] + let cast: [String] + + static let example: Self = { + .init( + title: "Earth", + director: "Alexander Dovzhenko", + release: Calendar.current.date(from: DateComponents(year: 1930, month: 4, day: 1))!, + genres: [.drama], + cast: ["Stepan Shkurat", "Semyon Svashenko", "Yuliya Solntseva"] + ) + }() + } + + let query = ChatQuery( + messages: [.system(.init(content: "Return a structured response."))], + model: .gpt4_o, + responseFormat: .jsonSchema(name: "movie-info", type: MovieInfo.self) + ) + + let result = try await openAI.chats(query: query) + XCTAssertEqual(result, chatResult) + } func testChatsFunction() async throws { let query = ChatQuery(messages: [