From e46c4855e4fa59f3bd16bc52e7c431aff34b585b Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Mon, 2 Nov 2026 22:14:42 -0500 Subject: [PATCH 1/3] fix(chat): tolerate missing fields in prompt_tokens_details OpenAI-compatible providers (e.g. DeepSeek, Gemini) send only cached_tokens or only a subset of the fields. Decoding then failed and the whole usage was silently dropped by the try? in ChatResult/ChatStreamResult. Default the missing counters to 0. Co-Authored-By: Claude Sonnet 5.5 --- Sources/OpenAI/Public/Models/ChatResult.swift | 7 +++++ Tests/OpenAITests/ChatResultTests.swift | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/Sources/OpenAI/Public/Models/ChatResult.swift b/Sources/OpenAI/Public/Models/ChatResult.swift index 4cb563a9..41a1564c 100644 --- a/Sources/OpenAI/Public/Models/ChatResult.swift +++ b/Sources/OpenAI/Public/Models/ChatResult.swift @@ -268,6 +268,13 @@ public struct ChatResult: Codable, Equatable, Sendable { case audioTokens = "audio_tokens" case cachedTokens = "cached_tokens" } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // OpenAI-compatible providers (e.g. DeepSeek, Gemini) often send only one of these fields. + self.audioTokens = try container.decodeIfPresent(Int.self, forKey: .audioTokens) ?? 0 + self.cachedTokens = try container.decodeIfPresent(Int.self, forKey: .cachedTokens) ?? 0 + } } public struct CompletionTokensDetails: Codable, Equatable, Sendable { diff --git a/Tests/OpenAITests/ChatResultTests.swift b/Tests/OpenAITests/ChatResultTests.swift index 11f91393..7fd3e3de 100644 --- a/Tests/OpenAITests/ChatResultTests.swift +++ b/Tests/OpenAITests/ChatResultTests.swift @@ -216,6 +216,32 @@ final class ChatResultTests: XCTestCase { XCTAssertEqual(usage.completionTokensDetails?.audioTokens, 35) } + func testUsageDecodesWhenPromptTokensDetailsOmitsAudioTokens() throws { + // Some OpenAI-compatible providers (e.g. DeepSeek, Gemini) only send `cached_tokens`. + let jsonString = """ + { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1677652288, + "model": "deepseek-chat", + "choices": [], + "usage": { + "prompt_tokens": 60, + "completion_tokens": 40, + "total_tokens": 100, + "prompt_tokens_details": { + "cached_tokens": 12 + } + } + } + """ + + let usage = try XCTUnwrap(decode(jsonString).usage) + XCTAssertEqual(usage.totalTokens, 100) + XCTAssertEqual(usage.promptTokensDetails?.cachedTokens, 12) + XCTAssertEqual(usage.promptTokensDetails?.audioTokens, 0) + } + private func decode(_ jsonString: String) throws -> ChatResult { let jsonData = jsonString.data(using: .utf8)! return try decoder.decode(ChatResult.self, from: jsonData) From e8a8edaa43fb8f572f2701537c09e19ca2804cee Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Tue, 3 Nov 2026 23:23:01 -0500 Subject: [PATCH 2/3] fix(chat): honor ParsingOptions when decoding prompt_tokens_details Co-Authored-By: Claude Sonnet 5.5 --- Sources/OpenAI/Public/Models/ChatResult.swift | 9 +++++---- Tests/OpenAITests/ChatResultTests.swift | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAI/Public/Models/ChatResult.swift b/Sources/OpenAI/Public/Models/ChatResult.swift index 41a1564c..99bba13d 100644 --- a/Sources/OpenAI/Public/Models/ChatResult.swift +++ b/Sources/OpenAI/Public/Models/ChatResult.swift @@ -268,12 +268,13 @@ public struct ChatResult: Codable, Equatable, Sendable { case audioTokens = "audio_tokens" case cachedTokens = "cached_tokens" } - - public init(from decoder: Decoder) throws { + + public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + let parsingOptions = decoder.userInfo[.parsingOptions] as? ParsingOptions ?? [] // OpenAI-compatible providers (e.g. DeepSeek, Gemini) often send only one of these fields. - self.audioTokens = try container.decodeIfPresent(Int.self, forKey: .audioTokens) ?? 0 - self.cachedTokens = try container.decodeIfPresent(Int.self, forKey: .cachedTokens) ?? 0 + self.audioTokens = try container.decodeInt(forKey: .audioTokens, parsingOptions: parsingOptions) + self.cachedTokens = try container.decodeInt(forKey: .cachedTokens, parsingOptions: parsingOptions) } } diff --git a/Tests/OpenAITests/ChatResultTests.swift b/Tests/OpenAITests/ChatResultTests.swift index 7fd3e3de..3793d55f 100644 --- a/Tests/OpenAITests/ChatResultTests.swift +++ b/Tests/OpenAITests/ChatResultTests.swift @@ -236,6 +236,7 @@ final class ChatResultTests: XCTestCase { } """ + decoder.userInfo = [.parsingOptions: ParsingOptions.relaxed] let usage = try XCTUnwrap(decode(jsonString).usage) XCTAssertEqual(usage.totalTokens, 100) XCTAssertEqual(usage.promptTokensDetails?.cachedTokens, 12) From be5486978fb43e773b30d31bfde834fec0cab55c Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Fri, 6 Nov 2026 01:15:12 -0400 Subject: [PATCH 3/3] fix(chat): add public memberwise init to PromptTokensDetails Declaring init(from:) in the struct body suppressed the synthesized memberwise initializer; restore it as a public init, matching CompletionTokensDetails. --- Sources/OpenAI/Public/Models/ChatResult.swift | 5 +++++ Tests/OpenAITests/ChatResultTests.swift | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/Sources/OpenAI/Public/Models/ChatResult.swift b/Sources/OpenAI/Public/Models/ChatResult.swift index 99bba13d..9e20319f 100644 --- a/Sources/OpenAI/Public/Models/ChatResult.swift +++ b/Sources/OpenAI/Public/Models/ChatResult.swift @@ -269,6 +269,11 @@ public struct ChatResult: Codable, Equatable, Sendable { case cachedTokens = "cached_tokens" } + public init(audioTokens: Int, cachedTokens: Int) { + self.audioTokens = audioTokens + self.cachedTokens = cachedTokens + } + public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let parsingOptions = decoder.userInfo[.parsingOptions] as? ParsingOptions ?? [] diff --git a/Tests/OpenAITests/ChatResultTests.swift b/Tests/OpenAITests/ChatResultTests.swift index 3793d55f..e28a1301 100644 --- a/Tests/OpenAITests/ChatResultTests.swift +++ b/Tests/OpenAITests/ChatResultTests.swift @@ -216,6 +216,15 @@ final class ChatResultTests: XCTestCase { XCTAssertEqual(usage.completionTokensDetails?.audioTokens, 35) } + func testPromptTokensDetailsMemberwiseInit() { + let details = ChatResult.CompletionUsage.PromptTokensDetails(audioTokens: 3, cachedTokens: 7) + let usage = ChatResult.CompletionUsage( + completionTokens: 1, promptTokens: 2, totalTokens: 3, promptTokensDetails: details + ) + XCTAssertEqual(usage.promptTokensDetails?.audioTokens, 3) + XCTAssertEqual(usage.promptTokensDetails?.cachedTokens, 7) + } + func testUsageDecodesWhenPromptTokensDetailsOmitsAudioTokens() throws { // Some OpenAI-compatible providers (e.g. DeepSeek, Gemini) only send `cached_tokens`. let jsonString = """