Skip to content

Feat: Structured Outputs #225

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
34279ce
encoding to a dictionary worked
andgordio Aug 28, 2024
6bc560d
Model description
andgordio Aug 28, 2024
e56a581
Renaming and refactoring
andgordio Aug 28, 2024
feb50bd
More renaming and refactoring
andgordio Aug 28, 2024
aa2d9c2
Schema name
andgordio Aug 28, 2024
5527d94
Cleanup
andgordio Aug 28, 2024
2e155a3
Proper Equatable conformance
andgordio Aug 28, 2024
54e5ed5
Access
andgordio Aug 28, 2024
577eaa3
Renames
andgordio Aug 28, 2024
d78677b
Schema name formatting
andgordio Aug 28, 2024
15c2fb2
Support: additionalProperties
andgordio Aug 28, 2024
56ea693
Support: required
andgordio Aug 28, 2024
54c7a2e
Explicit lack of support for enums
andgordio Aug 28, 2024
d8124ac
Merge pull request #1 from andgordio/encode-to-dictionary
andgordio Aug 28, 2024
3b54013
Date type support
andgordio Aug 29, 2024
5bc3220
Date type support decode fix
andgordio Aug 29, 2024
48b6687
Enum support
andgordio Aug 29, 2024
a76cfcb
Explicit Optionals support
andgordio Aug 29, 2024
39adef8
Quick fix: File meta
andgordio Aug 29, 2024
b956ac4
Merge pull request #2 from andgordio/structured-output-i
andgordio Aug 29, 2024
db14b2e
README + refactoring
andgordio Aug 30, 2024
1495de3
Merge pull request #3 from andgordio/structured-output-ii
andgordio Aug 30, 2024
aed3440
Removes August snapshot
andgordio Oct 8, 2024
279ca37
Test
andgordio Oct 8, 2024
0977263
Merge pull request #4 from andgordio/structured-output-ii
andgordio Oct 8, 2024
56dc6ae
Merge branch 'main' into pr/225
nezhyborets Jan 28, 2025
74c8929
Remove unneeded try to fix warning
nezhyborets Jan 28, 2025
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
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
263 changes: 256 additions & 7 deletions Sources/OpenAI/Public/Models/ChatQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Any>(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."
}
}
}

Expand Down
12 changes: 12 additions & 0 deletions Sources/OpenAI/Public/Models/StructuredOutput.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
12 changes: 12 additions & 0 deletions Sources/OpenAI/Public/Models/StructuredOutputEnum.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// StructuredOutputEnum.swift
//
//
// Created by Andriy Gordiyenko on 8/29/24.
//

import Foundation

public protocol StructuredOutputEnum: CaseIterable {
var caseNames: [String] { get }
}
Loading
Loading