Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 13 additions & 2 deletions Sources/OpenAI/Public/ResponseModels/Response/OutputItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,16 @@ public enum OutputItem: Decodable {
public struct WebSearchToolCall: Decodable {
/// Action taken in web search
public struct Action: Decodable {
// Action properties will use AnyCodable for flexibility
// as different action types (search, open_page, find) have different structures
/// The action performed: search, open_page, or find_in_page.
public let type: String?
/// The search query returned by older responses.
public let query: String?
/// The search queries performed by the tool.
public let queries: [String]?
/// The URL opened or searched within.
public let url: String?
/// The pattern searched for within a page.
public let pattern: String?
}

/// An object describing the specific action taken in this web search call
Expand Down Expand Up @@ -487,9 +495,12 @@ public enum OutputItem: Decodable {
public let status: String?
/// The type of the image generation call. Always "image_generation_call"
public let type: String
/// The output format used for generation: png, webp, or jpeg.
public let outputFormat: String?

enum CodingKeys: String, CodingKey {
case id, result, status, type
case outputFormat = "output_format"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public struct ResponseModel: Decodable {
/// Set of 16 key-value pairs that can be attached to an object.
/// This can be useful for storing additional information about the object in a structured format, and querying for objects via API or the dashboard.
/// Keys are strings with a maximum length of 64 characters. Values are strings with a maximum length of 512 characters.
public let metadata: [String: String]
public let metadata: [String: String]?

/// Model ID used to generate the response, like gpt-4o or o1.
/// OpenAI offers a wide range of models with different capabilities, performance characteristics, and price points.
Expand Down
2 changes: 1 addition & 1 deletion Tests/OpenAITests/ResponseModelValidationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ final class ResponseModelValidationTests: XCTestCase {
XCTAssertEqual(responseModel.topP, 1.0)
XCTAssertEqual(responseModel.truncation, "disabled")
XCTAssertNil(responseModel.user)
XCTAssertTrue(responseModel.metadata.isEmpty)
XCTAssertEqual(responseModel.metadata, [:])

// Validate usage
XCTAssertNotNil(responseModel.usage)
Expand Down
116 changes: 116 additions & 0 deletions Tests/OpenAITests/ResponseSchemaRegressionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#if compiler(>=6.0)
import Foundation
import SwiftOpenAI
import Testing

@Suite("Responses schema regressions")
struct ResponseSchemaRegressionTests {
@Test("A response accepts explicitly null metadata")
func nullMetadata() throws {
let data = Data(
#"{"id":"r","object":"response","created_at":1,"model":"test","output":[],"parallel_tool_calls":true,"metadata":null}"#
.utf8)
let response = try JSONDecoder().decode(ResponseModel.self, from: data)
#expect(response.metadata == nil)
}

@Test("Documented web-search fields survive decoding")
func searchFields() throws {
let data = Data(
#"{"id":"w","type":"web_search_call","status":"completed","action":{"type":"search","query":"weather","queries":["weather","forecast"]}}"#
.utf8)
let item = try JSONDecoder().decode(OutputItem.self, from: data)
guard case .webSearchCall(let search) = item else {
Issue.record("Expected web search")
return
}
let action = try #require(search.action)
#expect(action.type == "search")
#expect(action.queries == ["weather", "forecast"])
#expect(action.query == "weather")
let event = try JSONDecoder().decode(ResponseStreamEvent.self, from: Data(
(#"{"type":"response.output_item.done","output_index":0,"item":"# + String(decoding: data, as: UTF8.self) + "}").utf8))
guard case .outputItemDone(let done) = event, case .webSearchCall(let streamed) = done.item else {
Issue.record("Expected streamed web search")
return
}
#expect(streamed.action?.queries == ["weather", "forecast"])
}

@Test("Documented generated-image format survives decoding", arguments: ["png", "jpeg", "webp"])
func imageFormat(format: String) throws {
let data = Data(
#"{"id":"i","type":"image_generation_call","status":"completed","result":"aW1hZ2U=","output_format":"\#(format)"}"#
.utf8)
let item = try JSONDecoder().decode(OutputItem.self, from: data)
guard case .imageGenerationCall(let image) = item else {
Issue.record("Expected generated image")
return
}
#expect(image.outputFormat == format)
#expect(image.result == "aW1hZ2U=")
let streamed = try JSONDecoder().decode(StreamOutputItem.self, from: data)
guard case .imageGenerationCall(let streamImage) = streamed else {
Issue.record("Expected streamed generated image")
return
}
#expect(streamImage.outputFormat == format)
}

@Test("Web search preserves page and find actions")
func pageActions() throws {
for type in ["open_page", "find_in_page"] {
let data = Data(#"{"type":"\#(type)","url":"https://example.com/","pattern":"forecast"}"#.utf8)
let action = try JSONDecoder().decode(OutputItem.WebSearchToolCall.Action.self, from: data)
#expect(action.type == type)
#expect(action.url == "https://example.com/")
#expect(action.pattern == "forecast")
#expect(action.queries == nil)
}
}

@Test("Optional tool fields may be absent or null")
func optionalToolFields() throws {
for body in ["{}", #"{"type":null,"query":null,"queries":null,"url":null,"pattern":null}"#] {
let action = try JSONDecoder().decode(OutputItem.WebSearchToolCall.Action.self, from: Data(body.utf8))
#expect(action.query == nil)
#expect(action.queries == nil)
}
for extra in ["", #", "output_format":null"#] {
let body = #"{"id":"i","type":"image_generation_call"\#(extra)}"#
let image = try JSONDecoder().decode(OutputItem.ImageGenerationCall.self, from: Data(body.utf8))
#expect(image.outputFormat == nil)
}
}

@Test("Wrong field types remain decoding errors")
func invalidFieldTypes() {
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(OutputItem.WebSearchToolCall.Action.self, from: Data(#"{"queries":17}"#.utf8))
}
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(
OutputItem.ImageGenerationCall.self,
from: Data(#"{"id":"i","type":"image_generation_call","output_format":17}"#.utf8))
}
}

@Test(
"Accepting null metadata does not relax other required response fields",
arguments: ["id", "object", "created_at", "model", "output", "parallel_tool_calls"])
func requiredResponseFields(field: String) throws {
var json: [String: Any] = [
"id": "r",
"object": "response",
"created_at": 1,
"model": "test",
"output": [],
"parallel_tool_calls": true,
"metadata": NSNull(),
]
json.removeValue(forKey: field)
let data = try JSONSerialization.data(withJSONObject: json)
#expect(throws: DecodingError.self) { try JSONDecoder().decode(ResponseModel.self, from: data) }
}
}
#endif