Skip to content
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

Feat: Structured Outputs #225

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
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 @@ -19,6 +19,7 @@ This repository contains Swift community-maintained implementation over [OpenAI]
- [Completions Streaming](#completions-streaming)
- [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 @@ -381,9 +382,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 = try 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