From 9fff16136a45c101db3e7abbaaa7e9f7dafa91cf Mon Sep 17 00:00:00 2001 From: Krivoblotsky Date: Wed, 29 Apr 2026 17:40:15 +0300 Subject: [PATCH] feat(chat): add completion_tokens_details to CompletionUsage OpenAI's chat completion `usage` payload includes a `completion_tokens_details` object for reasoning models, audio outputs, and Predicted Outputs. The library was silently dropping this on decode, making fields like reasoning_tokens unreachable. Mirror the existing `PromptTokensDetails` shape with a new `CompletionTokensDetails` struct exposing accepted_prediction_tokens, audio_tokens, reasoning_tokens, and rejected_prediction_tokens. Fields are `Int?` because each only appears under specific conditions (reasoning_tokens for o-series, audio_tokens for audio output, the prediction fields for Predicted Outputs). The new init parameter has a default value, so existing call sites in tests continue to compile unchanged. Closes #258 Co-Authored-By: Claude Opus 4.7 --- Sources/OpenAI/Public/Models/ChatResult.swift | 54 +++++++++++++++++-- Tests/OpenAITests/OpenAITestsDecoder.swift | 49 ++++++++++++++++- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAI/Public/Models/ChatResult.swift b/Sources/OpenAI/Public/Models/ChatResult.swift index 52b5773f..518334d3 100644 --- a/Sources/OpenAI/Public/Models/ChatResult.swift +++ b/Sources/OpenAI/Public/Models/ChatResult.swift @@ -269,24 +269,72 @@ public struct ChatResult: Codable, Equatable, Sendable { public let totalTokens: Int /// Breakdown of tokens used in the prompt. public let promptTokensDetails: PromptTokensDetails? - + /// Breakdown of tokens used in the completion. + public let completionTokensDetails: CompletionTokensDetails? + + public init( + completionTokens: Int, + promptTokens: Int, + totalTokens: Int, + promptTokensDetails: PromptTokensDetails? = nil, + completionTokensDetails: CompletionTokensDetails? = nil + ) { + self.completionTokens = completionTokens + self.promptTokens = promptTokens + self.totalTokens = totalTokens + self.promptTokensDetails = promptTokensDetails + self.completionTokensDetails = completionTokensDetails + } + public struct PromptTokensDetails: Codable, Equatable, Sendable { /// Audio input tokens present in the prompt. public let audioTokens: Int /// Cached tokens present in the prompt. public let cachedTokens: Int - + enum CodingKeys: String, CodingKey { case audioTokens = "audio_tokens" case cachedTokens = "cached_tokens" } } - + + public struct CompletionTokensDetails: Codable, Equatable, Sendable { + /// When using Predicted Outputs, the number of tokens in the prediction that appeared in the completion. + public let acceptedPredictionTokens: Int? + /// Audio output tokens generated by the model. + public let audioTokens: Int? + /// Tokens generated by the model for reasoning. Only present for reasoning models. + public let reasoningTokens: Int? + /// When using Predicted Outputs, the number of tokens in the prediction that did not appear in the completion. + /// However, like reasoning tokens, these tokens are still counted in the total completion tokens for purposes of billing, output, and context window limits. + public let rejectedPredictionTokens: Int? + + public init( + acceptedPredictionTokens: Int? = nil, + audioTokens: Int? = nil, + reasoningTokens: Int? = nil, + rejectedPredictionTokens: Int? = nil + ) { + self.acceptedPredictionTokens = acceptedPredictionTokens + self.audioTokens = audioTokens + self.reasoningTokens = reasoningTokens + self.rejectedPredictionTokens = rejectedPredictionTokens + } + + enum CodingKeys: String, CodingKey { + case acceptedPredictionTokens = "accepted_prediction_tokens" + case audioTokens = "audio_tokens" + case reasoningTokens = "reasoning_tokens" + case rejectedPredictionTokens = "rejected_prediction_tokens" + } + } + enum CodingKeys: String, CodingKey { case completionTokens = "completion_tokens" case promptTokens = "prompt_tokens" case totalTokens = "total_tokens" case promptTokensDetails = "prompt_tokens_details" + case completionTokensDetails = "completion_tokens_details" } } } diff --git a/Tests/OpenAITests/OpenAITestsDecoder.swift b/Tests/OpenAITests/OpenAITestsDecoder.swift index bab1ffe7..a6908f14 100644 --- a/Tests/OpenAITests/OpenAITestsDecoder.swift +++ b/Tests/OpenAITests/OpenAITestsDecoder.swift @@ -75,7 +75,54 @@ class OpenAITestsDecoder: XCTestCase { ) try decode(data, expectedValue) } - + + func testCompletionUsageWithCompletionTokensDetails() async throws { + let data = """ + { + "completion_tokens": 320, + "prompt_tokens": 80, + "total_tokens": 400, + "completion_tokens_details": { + "accepted_prediction_tokens": 12, + "audio_tokens": 0, + "reasoning_tokens": 256, + "rejected_prediction_tokens": 4 + } + } + """ + + let expectedValue = ChatResult.CompletionUsage( + completionTokens: 320, + promptTokens: 80, + totalTokens: 400, + promptTokensDetails: nil, + completionTokensDetails: .init( + acceptedPredictionTokens: 12, + audioTokens: 0, + reasoningTokens: 256, + rejectedPredictionTokens: 4 + ) + ) + try decode(data, expectedValue) + } + + func testCompletionUsageDecodesWithoutCompletionTokensDetails() async throws { + let data = """ + { + "completion_tokens": 12, + "prompt_tokens": 9, + "total_tokens": 21 + } + """ + + let expectedValue = ChatResult.CompletionUsage( + completionTokens: 12, + promptTokens: 9, + totalTokens: 21 + ) + try decode(data, expectedValue) + } + func testImageQuery() async throws { let imageQuery = ImagesQuery( prompt: "test",