Skip to content

Commit dbaf347

Browse files
authored
Merge pull request #225 from andgordio/main
Feat: Structured Outputs
2 parents 22bb780 + 74c8929 commit dbaf347

File tree

5 files changed

+370
-8
lines changed

5 files changed

+370
-8
lines changed

README.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ This repository contains Swift community-maintained implementation over [OpenAI]
1717
- [Initialization](#initialization)
1818
- [Chats](#chats)
1919
- [Chats Streaming](#chats-streaming)
20+
- [Structured Output](#structured-output)
2021
- [Images](#images)
2122
- [Create Image](#create-image)
2223
- [Create Image Edit](#create-image-edit)
@@ -295,9 +296,57 @@ Result will be (serialized as JSON here for readability):
295296

296297
```
297298

298-
299299
Review [Chat Documentation](https://platform.openai.com/docs/guides/chat) for more info.
300300

301+
#### Structured Output
302+
303+
JSON is one of the most widely used formats in the world for applications to exchange data.
304+
305+
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.
306+
307+
**Example**
308+
309+
```swift
310+
struct MovieInfo: StructuredOutput {
311+
312+
let title: String
313+
let director: String
314+
let release: Date
315+
let genres: [MovieGenre]
316+
let cast: [String]
317+
318+
static let example: Self = {
319+
.init(
320+
title: "Earth",
321+
director: "Alexander Dovzhenko",
322+
release: Calendar.current.date(from: DateComponents(year: 1930, month: 4, day: 1))!,
323+
genres: [.drama],
324+
cast: ["Stepan Shkurat", "Semyon Svashenko", "Yuliya Solntseva"]
325+
)
326+
}()
327+
}
328+
329+
enum MovieGenre: String, Codable, StructuredOutputEnum {
330+
case action, drama, comedy, scifi
331+
332+
var caseNames: [String] { Self.allCases.map { $0.rawValue } }
333+
}
334+
335+
let query = ChatQuery(
336+
messages: [.system(.init(content: "Best Picture winner at the 2011 Oscars"))],
337+
model: .gpt4_o,
338+
responseFormat: .jsonSchema(name: "movie-info", type: MovieInfo.self)
339+
)
340+
let result = try await openAI.chats(query: query)
341+
```
342+
343+
- Use the `jsonSchema(name:type:)` response format when creating a `ChatQuery`
344+
- Provide a schema name and a type that conforms to `ChatQuery.StructuredOutput` and generates an instance as an example
345+
- Make sure all enum types within the provided type conform to `ChatQuery.StructuredOutputEnum` and generate an array of names for all cases
346+
347+
348+
Review [Structured Output Documentation](https://platform.openai.com/docs/guides/structured-outputs) for more info.
349+
301350
### Images
302351

303352
Given a prompt and/or an input image, the model will generate a new image.

Sources/OpenAI/Public/Models/ChatQuery.swift

Lines changed: 256 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -599,14 +599,263 @@ public struct ChatQuery: Equatable, Codable, Streamable {
599599
}
600600
}
601601

602-
// See more https://platform.openai.com/docs/guides/text-generation/json-mode
603-
public enum ResponseFormat: String, Codable, Equatable {
604-
case jsonObject = "json_object"
602+
// See more https://platform.openai.com/docs/guides/structured-outputs/introduction
603+
public enum ResponseFormat: Codable, Equatable {
604+
605605
case text
606-
607-
public func encode(to encoder: Encoder) throws {
608-
var container = encoder.singleValueContainer()
609-
try container.encode(["type": self.rawValue])
606+
case jsonObject
607+
case jsonSchema(name: String, type: StructuredOutput.Type)
608+
609+
enum CodingKeys: String, CodingKey {
610+
case type
611+
case jsonSchema = "json_schema"
612+
}
613+
614+
public func encode(to encoder: any Encoder) throws {
615+
var container = encoder.container(keyedBy: CodingKeys.self)
616+
switch self {
617+
case .text:
618+
try container.encode("text", forKey: .type)
619+
case .jsonObject:
620+
try container.encode("json_object", forKey: .type)
621+
case .jsonSchema(let name, let type):
622+
try container.encode("json_schema", forKey: .type)
623+
let schema = JSONSchema(name: name, schema: type.example)
624+
try container.encode(schema, forKey: .jsonSchema)
625+
}
626+
}
627+
628+
public static func == (lhs: ResponseFormat, rhs: ResponseFormat) -> Bool {
629+
switch (lhs, rhs) {
630+
case (.text, .text): return true
631+
case (.jsonObject, .jsonObject): return true
632+
case (.jsonSchema(let lhsName, let lhsType), .jsonSchema(let rhsName, let rhsType)):
633+
return lhsName == rhsName && lhsType == rhsType
634+
default:
635+
return false
636+
}
637+
}
638+
639+
/// A formal initializer reqluired for the inherited Decodable conformance.
640+
/// This type is never returned from the server and is never decoded into.
641+
public init(from decoder: any Decoder) throws {
642+
self = .text
643+
}
644+
}
645+
646+
private struct JSONSchema: Encodable {
647+
648+
let name: String
649+
let schema: StructuredOutput
650+
651+
enum CodingKeys: String, CodingKey {
652+
case name
653+
case schema
654+
case strict
655+
}
656+
657+
init(name: String, schema: StructuredOutput) {
658+
659+
func format(_ name: String) -> String {
660+
var formattedName = name.replacingOccurrences(of: " ", with: "_")
661+
let regex = try! NSRegularExpression(pattern: "[^a-zA-Z0-9_-]", options: [])
662+
let range = NSRange(location: 0, length: formattedName.utf16.count)
663+
formattedName = regex.stringByReplacingMatches(in: formattedName, options: [], range: range, withTemplate: "")
664+
formattedName = formattedName.isEmpty ? "sample" : formattedName
665+
formattedName = String(formattedName.prefix(64))
666+
return formattedName
667+
}
668+
669+
self.name = format(name)
670+
self.schema = schema
671+
672+
if self.name != name {
673+
print("The name was changed to \(self.name) to satisfy the API requirements. See more: https://platform.openai.com/docs/api-reference/chat/create")
674+
}
675+
}
676+
677+
public func encode(to encoder: any Encoder) throws {
678+
var container = encoder.container(keyedBy: CodingKeys.self)
679+
try container.encode(name, forKey: .name)
680+
try container.encode(true, forKey: .strict)
681+
try container.encode(try PropertyValue(from: schema), forKey: .schema)
682+
}
683+
}
684+
685+
private indirect enum PropertyValue: Codable {
686+
687+
enum SimpleType: String, Codable {
688+
case string, integer, number, boolean
689+
}
690+
691+
enum ComplexType: String, Codable {
692+
case object, array, date
693+
}
694+
695+
enum SpecialType: String, Codable {
696+
case null
697+
}
698+
699+
case simple(SimpleType, isOptional: Bool)
700+
case date(isOptional: Bool)
701+
case `enum`(cases: [String], isOptional: Bool)
702+
case object([String: PropertyValue], isOptional: Bool)
703+
case array(PropertyValue, isOptional: Bool)
704+
705+
enum CodingKeys: String, CodingKey {
706+
case type
707+
case description
708+
case properties
709+
case items
710+
case additionalProperties
711+
case required
712+
case `enum`
713+
}
714+
715+
enum ValueType: String, Codable {
716+
case string
717+
case date
718+
case integer
719+
case number
720+
case boolean
721+
case object
722+
case array
723+
}
724+
725+
func encode(to encoder: Encoder) throws {
726+
var container = encoder.container(keyedBy: CodingKeys.self)
727+
728+
switch self {
729+
case .simple(let type, let isOptional):
730+
if isOptional {
731+
try container.encode([type.rawValue, SpecialType.null.rawValue], forKey: .type)
732+
} else {
733+
try container.encode(type.rawValue, forKey: .type)
734+
}
735+
case .date(let isOptional):
736+
if isOptional {
737+
try container.encode([SimpleType.string.rawValue, SpecialType.null.rawValue], forKey: .type)
738+
} else {
739+
try container.encode(SimpleType.string.rawValue, forKey: .type)
740+
}
741+
try container.encode("String that represents a date formatted in iso8601", forKey: .description)
742+
case .enum(let cases, let isOptional):
743+
if isOptional {
744+
try container.encode([SimpleType.string.rawValue, SpecialType.null.rawValue], forKey: .type)
745+
} else {
746+
try container.encode(SimpleType.string.rawValue, forKey: .type)
747+
}
748+
try container.encode(cases, forKey: .enum)
749+
case .object(let object, let isOptional):
750+
if isOptional {
751+
try container.encode([ComplexType.object.rawValue, SpecialType.null.rawValue], forKey: .type)
752+
} else {
753+
try container.encode(ComplexType.object.rawValue, forKey: .type)
754+
}
755+
try container.encode(false, forKey: .additionalProperties)
756+
try container.encode(object, forKey: .properties)
757+
let fields = object.map { key, value in key }
758+
try container.encode(fields, forKey: .required)
759+
case .array(let items, let isOptional):
760+
if isOptional {
761+
try container.encode([ComplexType.array.rawValue, SpecialType.null.rawValue], forKey: .type)
762+
} else {
763+
try container.encode(ComplexType.array.rawValue, forKey: .type)
764+
}
765+
try container.encode(items, forKey: .items)
766+
}
767+
}
768+
769+
init<T: Any>(from value: T) throws {
770+
let mirror = Mirror(reflecting: value)
771+
let isOptional = mirror.displayStyle == .optional
772+
773+
switch value {
774+
case _ as String:
775+
self = .simple(.string, isOptional: isOptional)
776+
return
777+
case _ as Bool:
778+
self = .simple(.boolean, isOptional: isOptional)
779+
return
780+
case _ as Int, _ as Int8, _ as Int16, _ as Int32, _ as Int64, _ as UInt, _ as UInt8, _ as UInt16, _ as UInt32, _ as UInt64:
781+
self = .simple(.integer, isOptional: isOptional)
782+
return
783+
case _ as Double, _ as Float, _ as CGFloat:
784+
self = .simple(.number, isOptional: isOptional)
785+
return
786+
case _ as Date:
787+
self = .date(isOptional: isOptional)
788+
return
789+
default:
790+
791+
var unwrappedMirror: Mirror!
792+
if isOptional {
793+
guard let child = mirror.children.first else {
794+
throw StructuredOutputError.nilFoundInExample
795+
}
796+
unwrappedMirror = Mirror(reflecting: child.value)
797+
} else {
798+
unwrappedMirror = mirror
799+
}
800+
801+
if let displayStyle = unwrappedMirror.displayStyle {
802+
803+
switch displayStyle {
804+
805+
case .struct, .class:
806+
var dict = [String: PropertyValue]()
807+
for child in unwrappedMirror.children {
808+
dict[child.label!] = try Self(from: child.value)
809+
}
810+
self = .object(dict, isOptional: isOptional)
811+
return
812+
813+
case .collection:
814+
if let child = unwrappedMirror.children.first {
815+
self = .array(try Self(from: child.value), isOptional: isOptional)
816+
return
817+
} else {
818+
throw StructuredOutputError.typeUnsupported
819+
}
820+
821+
case .enum:
822+
if let structuredEnum = value as? any StructuredOutputEnum {
823+
self = .enum(cases: structuredEnum.caseNames, isOptional: isOptional)
824+
return
825+
} else {
826+
throw StructuredOutputError.enumsConformance
827+
}
828+
829+
default:
830+
throw StructuredOutputError.typeUnsupported
831+
}
832+
}
833+
throw StructuredOutputError.typeUnsupported
834+
}
835+
}
836+
837+
838+
/// A formal initializer reqluired for the inherited Decodable conformance.
839+
/// This type is never returned from the server and is never decoded into.
840+
init(from decoder: Decoder) throws {
841+
self = .simple(.boolean, isOptional: false)
842+
}
843+
}
844+
845+
public enum StructuredOutputError: LocalizedError {
846+
case enumsConformance
847+
case typeUnsupported
848+
case nilFoundInExample
849+
850+
public var errorDescription: String? {
851+
switch self {
852+
case .enumsConformance:
853+
return "Conform the enum types to StructuredOutputEnum and provide the `caseNames` property with a list of available cases."
854+
case .typeUnsupported:
855+
return "Unsupported type. Supported types: String, Bool, Int, Double, Array, and Codable struct/class instances."
856+
case .nilFoundInExample:
857+
return "Found nils when serializing the StructuredOutput‘s example. Provide values for all optional properties in the example."
858+
}
610859
}
611860
}
612861

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// StructuredOutput.swift
3+
//
4+
//
5+
// Created by Andriy Gordiyenko on 8/28/24.
6+
//
7+
8+
import Foundation
9+
10+
public protocol StructuredOutput: Codable {
11+
static var example: Self { get }
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// StructuredOutputEnum.swift
3+
//
4+
//
5+
// Created by Andriy Gordiyenko on 8/29/24.
6+
//
7+
8+
import Foundation
9+
10+
public protocol StructuredOutputEnum: CaseIterable {
11+
var caseNames: [String] { get }
12+
}

0 commit comments

Comments
 (0)