diff --git a/FirebaseVertexAI/CHANGELOG.md b/FirebaseVertexAI/CHANGELOG.md index beb2ef14e32..aab6e6c9417 100644 --- a/FirebaseVertexAI/CHANGELOG.md +++ b/FirebaseVertexAI/CHANGELOG.md @@ -1,3 +1,7 @@ +# Unreleased +- [fixed] Fixed `ModalityTokenCount` decoding when the `tokenCount` field is + omitted; this occurs when the count is 0. (#14745) + # 11.12.0 - [added] **Public Preview**: Added support for specifying response modalities in `GenerationConfig`. This includes **public experimental** support for image diff --git a/FirebaseVertexAI/Sources/ModalityTokenCount.swift b/FirebaseVertexAI/Sources/ModalityTokenCount.swift index 457e31d4109..8b75242d349 100644 --- a/FirebaseVertexAI/Sources/ModalityTokenCount.swift +++ b/FirebaseVertexAI/Sources/ModalityTokenCount.swift @@ -57,5 +57,18 @@ public struct ContentModality: DecodableProtoEnum, Hashable, Sendable { VertexLog.MessageCode.generateContentResponseUnrecognizedContentModality } +// MARK: Codable Conformances + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension ModalityTokenCount: Decodable {} +extension ModalityTokenCount: Decodable { + enum CodingKeys: CodingKey { + case modality + case tokenCount + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + modality = try container.decode(ContentModality.self, forKey: .modality) + tokenCount = try container.decodeIfPresent(Int.self, forKey: .tokenCount) ?? 0 + } +} diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 5c55685a085..ca0d5bd7100 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -116,9 +116,10 @@ struct GenerateContentIntegrationTests { @Test(arguments: [ InstanceConfig.vertexV1Beta, - // TODO(andrewheard): Prod config temporarily disabled due to backend issue. + // TODO(andrewheard): Configs temporarily disabled due to backend issue. // InstanceConfig.developerV1Beta, - InstanceConfig.developerV1BetaStaging, // Remove after re-enabling `developerV1Beta` config. + // InstanceConfig.developerV1BetaStaging + InstanceConfig.developerV1BetaSpark, ]) func generateImage(_ config: InstanceConfig) async throws { let generationConfig = GenerationConfig( diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift index 1130597fead..3db2ac60371 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift @@ -51,9 +51,9 @@ struct InstanceConfig { vertexV1Staging, vertexV1Beta, vertexV1BetaStaging, - // TODO(andrewheard): Prod config temporarily disabled due to backend issue: + // TODO(andrewheard): Configs temporarily disabled due to backend issue: // developerV1Beta, - developerV1BetaStaging, + // developerV1BetaStaging, developerV1Spark, developerV1BetaSpark, ] @@ -63,9 +63,9 @@ struct InstanceConfig { vertexV1Staging, vertexV1Beta, vertexV1BetaStaging, - // TODO(andrewheard): Prod config temporarily disabled due to backend issue: + // TODO(andrewheard): Configs temporarily disabled due to backend issue: // developerV1Beta, - developerV1BetaStaging, + // developerV1BetaStaging, developerV1BetaSpark, ] diff --git a/FirebaseVertexAI/Tests/Unit/Types/ModalityTokenCountTests.swift b/FirebaseVertexAI/Tests/Unit/Types/ModalityTokenCountTests.swift new file mode 100644 index 00000000000..ffdb36938fb --- /dev/null +++ b/FirebaseVertexAI/Tests/Unit/Types/ModalityTokenCountTests.swift @@ -0,0 +1,88 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseVertexAI +import XCTest + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class ModalityTokenCountTests: XCTestCase { + let decoder = JSONDecoder() + + // MARK: - Decoding Tests + + func testDecodeModalityTokenCount_valid() throws { + let json = """ + { + "modality": "TEXT", + "tokenCount": 123 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let tokenCount = try decoder.decode(ModalityTokenCount.self, from: jsonData) + + XCTAssertEqual(tokenCount.modality, .text) + XCTAssertEqual(tokenCount.modality.rawValue, "TEXT") + XCTAssertEqual(tokenCount.tokenCount, 123) + } + + func testDecodeModalityTokenCount_missingTokenCount_defaultsToZero() throws { + let json = """ + { + "modality": "AUDIO" + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let tokenCount = try decoder.decode(ModalityTokenCount.self, from: jsonData) + + XCTAssertEqual(tokenCount.modality, .audio) + XCTAssertEqual(tokenCount.modality.rawValue, "AUDIO") + XCTAssertEqual(tokenCount.tokenCount, 0) + } + + func testDecodeModalityTokenCount_unrecognizedModalityString_succeeds() throws { + let newModality = "NEW_MODALITY_NAME" + let json = """ + { + "modality": "\(newModality)", + "tokenCount": 50 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let tokenCount = try decoder.decode(ModalityTokenCount.self, from: jsonData) + + XCTAssertEqual(tokenCount.tokenCount, 50) + XCTAssertEqual(tokenCount.modality.rawValue, newModality) + } + + func testDecodeModalityTokenCount_missingModalityKey_throws() throws { + let json = """ + { + "tokenCount": 50 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + do { + _ = try decoder.decode(ModalityTokenCount.self, from: jsonData) + XCTFail("Expected a DecodingError, but decoding succeeded.") + } catch let DecodingError.keyNotFound(key, _) { + XCTAssertEqual(key.stringValue, "modality") + } catch { + XCTFail("Expected a DecodingError.keyNotFound, but received \(error)") + } + } +}