From 276fa481c7b6ed4b9212f03c7dd5d10b730fc496 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Feb 2025 19:00:01 -0500 Subject: [PATCH 1/7] [Vertex AI] Add Developer API encoding `CountTokensRequest` # Conflicts: # FirebaseVertexAI/Sources/CountTokensRequest.swift # FirebaseVertexAI/Sources/GenerativeModel.swift --- .../Sources/CountTokensRequest.swift | 33 +++++++++++++++---- FirebaseVertexAI/Sources/FirebaseInfo.swift | 5 ++- .../Sources/GenerateContentRequest.swift | 1 + .../Sources/GenerativeAIService.swift | 1 + .../Sources/GenerativeModel.swift | 12 ++++--- .../Sources/Types/Internal/BackendAPI.swift | 18 ++++++++++ FirebaseVertexAI/Sources/VertexAI.swift | 3 +- 7 files changed, 60 insertions(+), 13 deletions(-) create mode 100644 FirebaseVertexAI/Sources/Types/Internal/BackendAPI.swift diff --git a/FirebaseVertexAI/Sources/CountTokensRequest.swift b/FirebaseVertexAI/Sources/CountTokensRequest.swift index 1a0866b2f35..7789bceee5a 100644 --- a/FirebaseVertexAI/Sources/CountTokensRequest.swift +++ b/FirebaseVertexAI/Sources/CountTokensRequest.swift @@ -16,12 +16,7 @@ import Foundation @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) struct CountTokensRequest { - let model: String - - let contents: [ModelContent] - let systemInstruction: ModelContent? - let tools: [Tool]? - let generationConfig: GenerationConfig? + let generateContentRequest: GenerateContentRequest let apiConfig: APIConfig let options: RequestOptions @@ -57,12 +52,36 @@ public struct CountTokensResponse { @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension CountTokensRequest: Encodable { - enum CodingKeys: CodingKey { + enum VertexCodingKeys: CodingKey { case contents case systemInstruction case tools case generationConfig } + + enum DeveloperCodingKeys: CodingKey { + case generateContentRequest + } + + func encode(to encoder: any Encoder) throws { + let backendAPI = encoder.userInfo[CodingUserInfoKey(rawValue: "BackendAPI")!] as! BackendAPI + + switch backendAPI { + case .vertexAI: + var container = encoder.container(keyedBy: VertexCodingKeys.self) + try container.encode(generateContentRequest.contents, forKey: .contents) + try container.encodeIfPresent( + generateContentRequest.systemInstruction, forKey: .systemInstruction + ) + try container.encodeIfPresent(generateContentRequest.tools, forKey: .tools) + try container.encodeIfPresent( + generateContentRequest.generationConfig, forKey: .generationConfig + ) + case .developer: + var container = encoder.container(keyedBy: DeveloperCodingKeys.self) + try container.encode(generateContentRequest, forKey: .generateContentRequest) + } + } } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/FirebaseVertexAI/Sources/FirebaseInfo.swift b/FirebaseVertexAI/Sources/FirebaseInfo.swift index 8bd705e4b29..a5082a4242f 100644 --- a/FirebaseVertexAI/Sources/FirebaseInfo.swift +++ b/FirebaseVertexAI/Sources/FirebaseInfo.swift @@ -28,18 +28,21 @@ struct FirebaseInfo: Sendable { let apiKey: String let googleAppID: String let app: FirebaseApp + let backendAPI: BackendAPI init(appCheck: AppCheckInterop? = nil, auth: AuthInterop? = nil, projectID: String, apiKey: String, googleAppID: String, - firebaseApp: FirebaseApp) { + firebaseApp: FirebaseApp, + backendAPI: BackendAPI) { self.appCheck = appCheck self.auth = auth self.projectID = projectID self.apiKey = apiKey self.googleAppID = googleAppID app = firebaseApp + self.backendAPI = backendAPI } } diff --git a/FirebaseVertexAI/Sources/GenerateContentRequest.swift b/FirebaseVertexAI/Sources/GenerateContentRequest.swift index 97e7248fc7c..5852a73fd73 100644 --- a/FirebaseVertexAI/Sources/GenerateContentRequest.swift +++ b/FirebaseVertexAI/Sources/GenerateContentRequest.swift @@ -32,6 +32,7 @@ struct GenerateContentRequest: Sendable { @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension GenerateContentRequest: Encodable { enum CodingKeys: String, CodingKey { + case model case contents case generationConfig case safetySettings diff --git a/FirebaseVertexAI/Sources/GenerativeAIService.swift b/FirebaseVertexAI/Sources/GenerativeAIService.swift index de8a18ee333..26de2285b31 100644 --- a/FirebaseVertexAI/Sources/GenerativeAIService.swift +++ b/FirebaseVertexAI/Sources/GenerativeAIService.swift @@ -199,6 +199,7 @@ struct GenerativeAIService { // } let encoder = JSONEncoder() + encoder.userInfo[CodingUserInfoKey(rawValue: "BackendAPI")!] = firebaseInfo.backendAPI urlRequest.httpBody = try encoder.encode(request) urlRequest.timeoutInterval = request.options.timeout diff --git a/FirebaseVertexAI/Sources/GenerativeModel.swift b/FirebaseVertexAI/Sources/GenerativeModel.swift index 3f57c3ed80d..d5f2410b1af 100644 --- a/FirebaseVertexAI/Sources/GenerativeModel.swift +++ b/FirebaseVertexAI/Sources/GenerativeModel.swift @@ -260,15 +260,19 @@ public final class GenerativeModel: Sendable { /// - Returns: The results of running the model's tokenizer on the input; contains /// ``CountTokensResponse/totalTokens``. public func countTokens(_ content: [ModelContent]) async throws -> CountTokensResponse { - let countTokensRequest = CountTokensRequest( + let generateContentRequest = GenerateContentRequest( model: modelResourceName, contents: content, - systemInstruction: systemInstruction, - tools: tools, generationConfig: generationConfig, - apiConfig: apiConfig, + safetySettings: safetySettings, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + isStreaming: false, options: requestOptions ) + let countTokensRequest = CountTokensRequest(generateContentRequest: generateContentRequest) + return try await generativeAIService.loadRequest(request: countTokensRequest) } diff --git a/FirebaseVertexAI/Sources/Types/Internal/BackendAPI.swift b/FirebaseVertexAI/Sources/Types/Internal/BackendAPI.swift new file mode 100644 index 00000000000..3cc529b0e9b --- /dev/null +++ b/FirebaseVertexAI/Sources/Types/Internal/BackendAPI.swift @@ -0,0 +1,18 @@ +// 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. + +enum BackendAPI { + case vertexAI + case developer +} diff --git a/FirebaseVertexAI/Sources/VertexAI.swift b/FirebaseVertexAI/Sources/VertexAI.swift index f0b427af59a..0cd529f3e98 100644 --- a/FirebaseVertexAI/Sources/VertexAI.swift +++ b/FirebaseVertexAI/Sources/VertexAI.swift @@ -178,7 +178,8 @@ public class VertexAI { projectID: projectID, apiKey: apiKey, googleAppID: app.options.googleAppID, - firebaseApp: app + firebaseApp: app, + backendAPI: .vertexAI ) self.apiConfig = apiConfig self.location = location From 5ad783d3b2863948afa92427058b96c506ce61a3 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 27 Feb 2025 19:14:57 -0500 Subject: [PATCH 2/7] Fix tests --- FirebaseVertexAI/Tests/Unit/ChatTests.swift | 3 ++- FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/FirebaseVertexAI/Tests/Unit/ChatTests.swift b/FirebaseVertexAI/Tests/Unit/ChatTests.swift index 4e8a1ae0f73..aa95106c83f 100644 --- a/FirebaseVertexAI/Tests/Unit/ChatTests.swift +++ b/FirebaseVertexAI/Tests/Unit/ChatTests.swift @@ -63,7 +63,8 @@ final class ChatTests: XCTestCase { projectID: "my-project-id", apiKey: "API_KEY", googleAppID: "My app ID", - firebaseApp: app + firebaseApp: app, + backendAPI: .vertexAI ), apiConfig: APIConfig(service: .vertexAI, version: .v1beta), tools: nil, diff --git a/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift index 891e4bc359e..cad51c1f6dc 100644 --- a/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift +++ b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift @@ -1479,7 +1479,8 @@ final class GenerativeModelTests: XCTestCase { projectID: "my-project-id", apiKey: "API_KEY", googleAppID: "My app ID", - firebaseApp: app + firebaseApp: app, + backendAPI: .vertexAI ) } From ff59d95955be6925eb39b3c89cf2c0d387fd8250 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 10 Mar 2025 20:29:46 -0400 Subject: [PATCH 3/7] Add `CountTokensIntegrationTests` --- .../Sources/CountTokensRequest.swift | 16 ++--- FirebaseVertexAI/Sources/FirebaseInfo.swift | 5 +- .../Sources/GenerateContentRequest.swift | 15 +++++ .../Sources/GenerativeModel.swift | 16 ++++- FirebaseVertexAI/Sources/VertexAI.swift | 3 +- .../Tests/TestApp/Sources/Constants.swift | 1 + .../CountTokensIntegrationTests.swift | 67 +++++++++++++++++++ .../GenerateContentIntegrationTests.swift | 29 ++------ ...AITestUtils.swift => InstanceConfig.swift} | 42 ++++++++++++ .../VertexAITestApp.xcodeproj/project.pbxproj | 12 ++-- 10 files changed, 161 insertions(+), 45 deletions(-) create mode 100644 FirebaseVertexAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift rename FirebaseVertexAI/Tests/TestApp/Tests/Utilities/{VertexAITestUtils.swift => InstanceConfig.swift} (57%) diff --git a/FirebaseVertexAI/Sources/CountTokensRequest.swift b/FirebaseVertexAI/Sources/CountTokensRequest.swift index 7789bceee5a..de053a20d7d 100644 --- a/FirebaseVertexAI/Sources/CountTokensRequest.swift +++ b/FirebaseVertexAI/Sources/CountTokensRequest.swift @@ -17,18 +17,20 @@ import Foundation @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) struct CountTokensRequest { let generateContentRequest: GenerateContentRequest - - let apiConfig: APIConfig - let options: RequestOptions } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension CountTokensRequest: GenerativeAIRequest { typealias Response = CountTokensResponse + var options: RequestOptions { generateContentRequest.options } + + var apiConfig: APIConfig { generateContentRequest.apiConfig } + var url: URL { - URL(string: - "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(model):countTokens")! + let version = apiConfig.version.rawValue + let endpoint = apiConfig.service.endpoint.rawValue + return URL(string: "\(endpoint)/\(version)/\(generateContentRequest.model):countTokens")! } } @@ -64,9 +66,7 @@ extension CountTokensRequest: Encodable { } func encode(to encoder: any Encoder) throws { - let backendAPI = encoder.userInfo[CodingUserInfoKey(rawValue: "BackendAPI")!] as! BackendAPI - - switch backendAPI { + switch apiConfig.service { case .vertexAI: var container = encoder.container(keyedBy: VertexCodingKeys.self) try container.encode(generateContentRequest.contents, forKey: .contents) diff --git a/FirebaseVertexAI/Sources/FirebaseInfo.swift b/FirebaseVertexAI/Sources/FirebaseInfo.swift index a5082a4242f..8bd705e4b29 100644 --- a/FirebaseVertexAI/Sources/FirebaseInfo.swift +++ b/FirebaseVertexAI/Sources/FirebaseInfo.swift @@ -28,21 +28,18 @@ struct FirebaseInfo: Sendable { let apiKey: String let googleAppID: String let app: FirebaseApp - let backendAPI: BackendAPI init(appCheck: AppCheckInterop? = nil, auth: AuthInterop? = nil, projectID: String, apiKey: String, googleAppID: String, - firebaseApp: FirebaseApp, - backendAPI: BackendAPI) { + firebaseApp: FirebaseApp) { self.appCheck = appCheck self.auth = auth self.projectID = projectID self.apiKey = apiKey self.googleAppID = googleAppID app = firebaseApp - self.backendAPI = backendAPI } } diff --git a/FirebaseVertexAI/Sources/GenerateContentRequest.swift b/FirebaseVertexAI/Sources/GenerateContentRequest.swift index 5852a73fd73..8821db411d2 100644 --- a/FirebaseVertexAI/Sources/GenerateContentRequest.swift +++ b/FirebaseVertexAI/Sources/GenerateContentRequest.swift @@ -18,12 +18,14 @@ import Foundation struct GenerateContentRequest: Sendable { /// Model name. let model: String + let contents: [ModelContent] let generationConfig: GenerationConfig? let safetySettings: [SafetySetting]? let tools: [Tool]? let toolConfig: ToolConfig? let systemInstruction: ModelContent? + let apiConfig: APIConfig let apiMethod: APIMethod let options: RequestOptions @@ -40,6 +42,19 @@ extension GenerateContentRequest: Encodable { case toolConfig case systemInstruction } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if apiMethod == .countTokens { + try container.encode(model, forKey: .model) + } + try container.encode(contents, forKey: .contents) + try container.encodeIfPresent(generationConfig, forKey: .generationConfig) + try container.encodeIfPresent(safetySettings, forKey: .safetySettings) + try container.encodeIfPresent(tools, forKey: .tools) + try container.encodeIfPresent(toolConfig, forKey: .toolConfig) + try container.encodeIfPresent(systemInstruction, forKey: .systemInstruction) + } } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/FirebaseVertexAI/Sources/GenerativeModel.swift b/FirebaseVertexAI/Sources/GenerativeModel.swift index d5f2410b1af..3d37be52061 100644 --- a/FirebaseVertexAI/Sources/GenerativeModel.swift +++ b/FirebaseVertexAI/Sources/GenerativeModel.swift @@ -260,15 +260,27 @@ public final class GenerativeModel: Sendable { /// - Returns: The results of running the model's tokenizer on the input; contains /// ``CountTokensResponse/totalTokens``. public func countTokens(_ content: [ModelContent]) async throws -> CountTokensResponse { + let requestContent = switch apiConfig.service { + case .vertexAI: + content + case .developer: + // The `role` defaults to "user" but is ignored in `countTokens`. However, it is erroneously + // erroneously counted towards the prompt and total token count when using the Developer API + // backend; set to `nil` to avoid token count discrepancies between `countTokens` and + // `generateContent` and the two backend APIs. + content.map { ModelContent(role: nil, parts: $0.parts) } + } + let generateContentRequest = GenerateContentRequest( model: modelResourceName, - contents: content, + contents: requestContent, generationConfig: generationConfig, safetySettings: safetySettings, tools: tools, toolConfig: toolConfig, systemInstruction: systemInstruction, - isStreaming: false, + apiConfig: apiConfig, + apiMethod: .countTokens, options: requestOptions ) let countTokensRequest = CountTokensRequest(generateContentRequest: generateContentRequest) diff --git a/FirebaseVertexAI/Sources/VertexAI.swift b/FirebaseVertexAI/Sources/VertexAI.swift index 0cd529f3e98..f0b427af59a 100644 --- a/FirebaseVertexAI/Sources/VertexAI.swift +++ b/FirebaseVertexAI/Sources/VertexAI.swift @@ -178,8 +178,7 @@ public class VertexAI { projectID: projectID, apiKey: apiKey, googleAppID: app.options.googleAppID, - firebaseApp: app, - backendAPI: .vertexAI + firebaseApp: app ) self.apiConfig = apiConfig self.location = location diff --git a/FirebaseVertexAI/Tests/TestApp/Sources/Constants.swift b/FirebaseVertexAI/Tests/TestApp/Sources/Constants.swift index 7e2f450597a..f26fec45fb3 100644 --- a/FirebaseVertexAI/Tests/TestApp/Sources/Constants.swift +++ b/FirebaseVertexAI/Tests/TestApp/Sources/Constants.swift @@ -21,5 +21,6 @@ public enum FirebaseAppNames { } public enum ModelNames { + public static let gemini2Flash = "gemini-2.0-flash-001" public static let gemini2FlashLite = "gemini-2.0-flash-lite-001" } diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift new file mode 100644 index 00000000000..e590b96964f --- /dev/null +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift @@ -0,0 +1,67 @@ +// 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 FirebaseAuth +import FirebaseCore +import FirebaseStorage +import FirebaseVertexAI +import Testing +import VertexAITestApp + +@testable import struct FirebaseVertexAI.APIConfig + +@Suite(.serialized) +struct CountTokensIntegrationTests { + let generationConfig = GenerationConfig( + temperature: 1.2, + topP: 0.95, + topK: 32, + candidateCount: 1, + maxOutputTokens: 8192, + presencePenalty: 1.5, + frequencyPenalty: 1.75, + stopSequences: ["cat", "dog", "bird"] + ) + let safetySettings = [ + SafetySetting(harmCategory: .harassment, threshold: .blockLowAndAbove), + SafetySetting(harmCategory: .hateSpeech, threshold: .blockLowAndAbove), + SafetySetting(harmCategory: .sexuallyExplicit, threshold: .blockLowAndAbove), + SafetySetting(harmCategory: .dangerousContent, threshold: .blockLowAndAbove), + SafetySetting(harmCategory: .civicIntegrity, threshold: .blockLowAndAbove), + ] + + @Test(arguments: InstanceConfig.allConfigs) + func countTokens_text(_ config: InstanceConfig) async throws { + let prompt = "Why is the sky blue?" + let model = VertexAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2Flash, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + + let response = try await model.countTokens(prompt) + + #expect(response.totalTokens == 6) + switch config.apiConfig.service { + case .vertexAI: + #expect(response.totalBillableCharacters == 16) + case .developer: + #expect(response.totalBillableCharacters == nil) + } + #expect(response.promptTokensDetails.count == 1) + let promptTokensDetails = try #require(response.promptTokensDetails.first) + #expect(promptTokensDetails.modality == .text) + #expect(promptTokensDetails.tokenCount == response.totalTokens) + } +} diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index de0b2e76556..92ebe09d159 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -19,29 +19,8 @@ import FirebaseVertexAI import Testing import VertexAITestApp -@testable import struct FirebaseVertexAI.APIConfig - @Suite(.serialized) struct GenerateContentIntegrationTests { - static let vertexV1Config = - InstanceConfig(apiConfig: APIConfig(service: .vertexAI, version: .v1)) - static let vertexV1BetaConfig = - InstanceConfig(apiConfig: APIConfig(service: .vertexAI, version: .v1beta)) - static let developerV1Config = InstanceConfig( - appName: FirebaseAppNames.spark, - apiConfig: APIConfig( - service: .developer(endpoint: .generativeLanguage), version: .v1 - ) - ) - static let developerV1BetaConfig = InstanceConfig( - appName: FirebaseAppNames.spark, - apiConfig: APIConfig( - service: .developer(endpoint: .generativeLanguage), version: .v1beta - ) - ) - static let allConfigs = - [vertexV1Config, vertexV1BetaConfig, developerV1Config, developerV1BetaConfig] - // Set temperature, topP and topK to lowest allowed values to make responses more deterministic. let generationConfig = GenerationConfig(temperature: 0.0, topP: 0.0, topK: 1) let safetySettings = [ @@ -67,7 +46,7 @@ struct GenerateContentIntegrationTests { storage = Storage.storage() } - @Test(arguments: allConfigs) + @Test(arguments: InstanceConfig.allConfigs) func generateContent(_ config: InstanceConfig) async throws { let model = VertexAI.componentInstance(config).generativeModel( modelName: ModelNames.gemini2FlashLite, @@ -98,10 +77,10 @@ struct GenerateContentIntegrationTests { @Test( "Generate an enum and provide a system instruction", arguments: [ - vertexV1Config, - vertexV1BetaConfig, + InstanceConfig.vertexV1, + InstanceConfig.vertexV1Beta, /* System instructions are not supported on the v1 Developer API. */ - developerV1BetaConfig, + InstanceConfig.developerV1Beta, ] ) func generateContentEnum(_ config: InstanceConfig) async throws { diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/VertexAITestUtils.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift similarity index 57% rename from FirebaseVertexAI/Tests/TestApp/Tests/Utilities/VertexAITestUtils.swift rename to FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift index f76bd8ff148..44b0c1c7685 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/VertexAITestUtils.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift @@ -13,12 +13,25 @@ // limitations under the License. import FirebaseCore +import Testing import VertexAITestApp @testable import struct FirebaseVertexAI.APIConfig @testable import class FirebaseVertexAI.VertexAI struct InstanceConfig { + static let vertexV1 = InstanceConfig(apiConfig: APIConfig(service: .vertexAI, version: .v1)) + static let vertexV1Beta = InstanceConfig(apiConfig: APIConfig(service: .vertexAI, version: .v1beta)) + static let developerV1 = InstanceConfig( + appName: FirebaseAppNames.spark, + apiConfig: APIConfig(service: .developer(endpoint: .generativeLanguage), version: .v1) + ) + static let developerV1Beta = InstanceConfig( + appName: FirebaseAppNames.spark, + apiConfig: APIConfig(service: .developer(endpoint: .generativeLanguage), version: .v1beta) + ) + static let allConfigs = [vertexV1, vertexV1Beta, developerV1, developerV1Beta] + let appName: String? let location: String? let apiConfig: APIConfig @@ -32,6 +45,35 @@ struct InstanceConfig { var app: FirebaseApp? { return appName.map { FirebaseApp.app(name: $0) } ?? FirebaseApp.app() } + + var serviceName: String { + switch apiConfig.service { + case .vertexAI: + return "Vertex AI" + case .developer: + return "Developer" + } + } + + var versionName: String { + return apiConfig.version.rawValue + } +} + +extension InstanceConfig: CustomTestStringConvertible { + var testDescription: String { + let endpointSuffix = switch apiConfig.service.endpoint { + case .firebaseVertexAIProd: + "" + case .firebaseVertexAIStaging: + " - Staging" + case .generativeLanguage: + " - Generative Language" + } + let locationSuffix = location.map { " - \($0)" } ?? "" + + return "\(serviceName) (\(versionName))\(endpointSuffix)\(locationSuffix)" + } } extension VertexAI { diff --git a/FirebaseVertexAI/Tests/TestApp/VertexAITestApp.xcodeproj/project.pbxproj b/FirebaseVertexAI/Tests/TestApp/VertexAITestApp.xcodeproj/project.pbxproj index 29f333bf127..6f13a62472e 100644 --- a/FirebaseVertexAI/Tests/TestApp/VertexAITestApp.xcodeproj/project.pbxproj +++ b/FirebaseVertexAI/Tests/TestApp/VertexAITestApp.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 8661385C2CC943DD00F4B78E /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8661385B2CC943DD00F4B78E /* TestApp.swift */; }; 8661385E2CC943DD00F4B78E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8661385D2CC943DD00F4B78E /* ContentView.swift */; }; 8661386E2CC943DE00F4B78E /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8661386D2CC943DE00F4B78E /* IntegrationTests.swift */; }; + 8689CDCC2D7F8BD700BF426B /* CountTokensIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8689CDCB2D7F8BCF00BF426B /* CountTokensIntegrationTests.swift */; }; 868A7C482CCA931B00E449DD /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 868A7C462CCA931B00E449DD /* GoogleService-Info.plist */; }; 868A7C4F2CCC229F00E449DD /* Credentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868A7C4D2CCC1F4700E449DD /* Credentials.swift */; }; 868A7C522CCC263300E449DD /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 868A7C502CCC263300E449DD /* Preview Assets.xcassets */; }; @@ -25,7 +26,7 @@ 86D77DFC2D7A5340003D155D /* GenerateContentIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86D77DFB2D7A5340003D155D /* GenerateContentIntegrationTests.swift */; }; 86D77DFE2D7B5C86003D155D /* GoogleService-Info-Spark.plist in Resources */ = {isa = PBXBuildFile; fileRef = 86D77DFD2D7B5C86003D155D /* GoogleService-Info-Spark.plist */; }; 86D77E022D7B63AF003D155D /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86D77E012D7B63AC003D155D /* Constants.swift */; }; - 86D77E042D7B6C9D003D155D /* VertexAITestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86D77E032D7B6C95003D155D /* VertexAITestUtils.swift */; }; + 86D77E042D7B6C9D003D155D /* InstanceConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86D77E032D7B6C95003D155D /* InstanceConfig.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -46,6 +47,7 @@ 8661385D2CC943DD00F4B78E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 866138692CC943DE00F4B78E /* IntegrationTests-SPM.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "IntegrationTests-SPM.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 8661386D2CC943DE00F4B78E /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; + 8689CDCB2D7F8BCF00BF426B /* CountTokensIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountTokensIntegrationTests.swift; sourceTree = ""; }; 868A7C462CCA931B00E449DD /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 868A7C4D2CCC1F4700E449DD /* Credentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Credentials.swift; sourceTree = ""; }; 868A7C502CCC263300E449DD /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -56,7 +58,7 @@ 86D77DFB2D7A5340003D155D /* GenerateContentIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateContentIntegrationTests.swift; sourceTree = ""; }; 86D77DFD2D7B5C86003D155D /* GoogleService-Info-Spark.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info-Spark.plist"; sourceTree = ""; }; 86D77E012D7B63AC003D155D /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; - 86D77E032D7B6C95003D155D /* VertexAITestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VertexAITestUtils.swift; sourceTree = ""; }; + 86D77E032D7B6C95003D155D /* InstanceConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceConfig.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -134,6 +136,7 @@ 868A7C572CCC27AF00E449DD /* Integration */ = { isa = PBXGroup; children = ( + 8689CDCB2D7F8BCF00BF426B /* CountTokensIntegrationTests.swift */, 868A7C4D2CCC1F4700E449DD /* Credentials.swift */, 8661386D2CC943DE00F4B78E /* IntegrationTests.swift */, 86D77DFB2D7A5340003D155D /* GenerateContentIntegrationTests.swift */, @@ -154,7 +157,7 @@ 8698D7442CD3CEF700ABA833 /* Utilities */ = { isa = PBXGroup; children = ( - 86D77E032D7B6C95003D155D /* VertexAITestUtils.swift */, + 86D77E032D7B6C95003D155D /* InstanceConfig.swift */, 8698D7452CD3CF2F00ABA833 /* FirebaseAppTestUtils.swift */, 862218802D04E08D007ED2D4 /* IntegrationTestUtils.swift */, ); @@ -283,7 +286,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 86D77E042D7B6C9D003D155D /* VertexAITestUtils.swift in Sources */, + 8689CDCC2D7F8BD700BF426B /* CountTokensIntegrationTests.swift in Sources */, + 86D77E042D7B6C9D003D155D /* InstanceConfig.swift in Sources */, 8698D7462CD3CF3600ABA833 /* FirebaseAppTestUtils.swift in Sources */, 868A7C4F2CCC229F00E449DD /* Credentials.swift in Sources */, 864F8F712D4980DD0002EA7E /* ImagenIntegrationTests.swift in Sources */, From 6202ea23ecc9a3415c9c2109e07bf284190eba92 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 11 Mar 2025 21:18:33 -0400 Subject: [PATCH 4/7] Fix rebase issues --- .../Sources/GenerativeAIService.swift | 1 - .../Sources/Types/Internal/BackendAPI.swift | 18 ------------------ FirebaseVertexAI/Tests/Unit/ChatTests.swift | 3 +-- .../Tests/Unit/GenerativeModelTests.swift | 3 +-- 4 files changed, 2 insertions(+), 23 deletions(-) delete mode 100644 FirebaseVertexAI/Sources/Types/Internal/BackendAPI.swift diff --git a/FirebaseVertexAI/Sources/GenerativeAIService.swift b/FirebaseVertexAI/Sources/GenerativeAIService.swift index 26de2285b31..de8a18ee333 100644 --- a/FirebaseVertexAI/Sources/GenerativeAIService.swift +++ b/FirebaseVertexAI/Sources/GenerativeAIService.swift @@ -199,7 +199,6 @@ struct GenerativeAIService { // } let encoder = JSONEncoder() - encoder.userInfo[CodingUserInfoKey(rawValue: "BackendAPI")!] = firebaseInfo.backendAPI urlRequest.httpBody = try encoder.encode(request) urlRequest.timeoutInterval = request.options.timeout diff --git a/FirebaseVertexAI/Sources/Types/Internal/BackendAPI.swift b/FirebaseVertexAI/Sources/Types/Internal/BackendAPI.swift deleted file mode 100644 index 3cc529b0e9b..00000000000 --- a/FirebaseVertexAI/Sources/Types/Internal/BackendAPI.swift +++ /dev/null @@ -1,18 +0,0 @@ -// 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. - -enum BackendAPI { - case vertexAI - case developer -} diff --git a/FirebaseVertexAI/Tests/Unit/ChatTests.swift b/FirebaseVertexAI/Tests/Unit/ChatTests.swift index aa95106c83f..4e8a1ae0f73 100644 --- a/FirebaseVertexAI/Tests/Unit/ChatTests.swift +++ b/FirebaseVertexAI/Tests/Unit/ChatTests.swift @@ -63,8 +63,7 @@ final class ChatTests: XCTestCase { projectID: "my-project-id", apiKey: "API_KEY", googleAppID: "My app ID", - firebaseApp: app, - backendAPI: .vertexAI + firebaseApp: app ), apiConfig: APIConfig(service: .vertexAI, version: .v1beta), tools: nil, diff --git a/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift index cad51c1f6dc..891e4bc359e 100644 --- a/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift +++ b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift @@ -1479,8 +1479,7 @@ final class GenerativeModelTests: XCTestCase { projectID: "my-project-id", apiKey: "API_KEY", googleAppID: "My app ID", - firebaseApp: app, - backendAPI: .vertexAI + firebaseApp: app ) } From 83d8d3afd577bf9743f6ae20617630baffbe1a0f Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 11 Mar 2025 22:32:10 -0400 Subject: [PATCH 5/7] Address Gemini review comments --- .../Sources/CountTokensRequest.swift | 30 ++++++++++++------- .../Sources/GenerateContentRequest.swift | 3 ++ .../Tests/Utilities/InstanceConfig.swift | 4 ++- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/FirebaseVertexAI/Sources/CountTokensRequest.swift b/FirebaseVertexAI/Sources/CountTokensRequest.swift index de053a20d7d..8a49adcab3f 100644 --- a/FirebaseVertexAI/Sources/CountTokensRequest.swift +++ b/FirebaseVertexAI/Sources/CountTokensRequest.swift @@ -68,20 +68,28 @@ extension CountTokensRequest: Encodable { func encode(to encoder: any Encoder) throws { switch apiConfig.service { case .vertexAI: - var container = encoder.container(keyedBy: VertexCodingKeys.self) - try container.encode(generateContentRequest.contents, forKey: .contents) - try container.encodeIfPresent( - generateContentRequest.systemInstruction, forKey: .systemInstruction - ) - try container.encodeIfPresent(generateContentRequest.tools, forKey: .tools) - try container.encodeIfPresent( - generateContentRequest.generationConfig, forKey: .generationConfig - ) + try encodeForVertexAI(to: encoder) case .developer: - var container = encoder.container(keyedBy: DeveloperCodingKeys.self) - try container.encode(generateContentRequest, forKey: .generateContentRequest) + try encodeForDeveloper(to: encoder) } } + + private func encodeForVertexAI(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: VertexCodingKeys.self) + try container.encode(generateContentRequest.contents, forKey: .contents) + try container.encodeIfPresent( + generateContentRequest.systemInstruction, forKey: .systemInstruction + ) + try container.encodeIfPresent(generateContentRequest.tools, forKey: .tools) + try container.encodeIfPresent( + generateContentRequest.generationConfig, forKey: .generationConfig + ) + } + + private func encodeForDeveloper(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: DeveloperCodingKeys.self) + try container.encode(generateContentRequest, forKey: .generateContentRequest) + } } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/FirebaseVertexAI/Sources/GenerateContentRequest.swift b/FirebaseVertexAI/Sources/GenerateContentRequest.swift index 8821db411d2..21acd502a75 100644 --- a/FirebaseVertexAI/Sources/GenerateContentRequest.swift +++ b/FirebaseVertexAI/Sources/GenerateContentRequest.swift @@ -45,6 +45,9 @@ extension GenerateContentRequest: Encodable { func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + // The model name only needs to be encoded when this `GenerateContentRequest` instance is used + // in a `CountTokensRequest` (calling `countTokens`). When calling `generateContent` or + // `generateContentStream`, the `model` field is populated in the backend from the `url`. if apiMethod == .countTokens { try container.encode(model, forKey: .model) } diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift index 44b0c1c7685..7c233e94f7a 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift @@ -21,7 +21,9 @@ import VertexAITestApp struct InstanceConfig { static let vertexV1 = InstanceConfig(apiConfig: APIConfig(service: .vertexAI, version: .v1)) - static let vertexV1Beta = InstanceConfig(apiConfig: APIConfig(service: .vertexAI, version: .v1beta)) + static let vertexV1Beta = InstanceConfig( + apiConfig: APIConfig(service: .vertexAI, version: .v1beta) + ) static let developerV1 = InstanceConfig( appName: FirebaseAppNames.spark, apiConfig: APIConfig(service: .developer(endpoint: .generativeLanguage), version: .v1) From 041b9d16d7c78ec8bfe57dee50301f7daddf3d03 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 12 Mar 2025 11:44:55 -0400 Subject: [PATCH 6/7] Add system instruction integration tests --- .../CountTokensIntegrationTests.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift index e590b96964f..bd1a5b9d935 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift @@ -40,6 +40,10 @@ struct CountTokensIntegrationTests { SafetySetting(harmCategory: .dangerousContent, threshold: .blockLowAndAbove), SafetySetting(harmCategory: .civicIntegrity, threshold: .blockLowAndAbove), ] + let systemInstruction = ModelContent( + role: "system", + parts: "You are a friendly and helpful assistant." + ) @Test(arguments: InstanceConfig.allConfigs) func countTokens_text(_ config: InstanceConfig) async throws { @@ -64,4 +68,55 @@ struct CountTokensIntegrationTests { #expect(promptTokensDetails.modality == .text) #expect(promptTokensDetails.tokenCount == response.totalTokens) } + + @Test(arguments: [ + InstanceConfig.vertexV1, + InstanceConfig.vertexV1Beta, + /* System instructions are not supported on the v1 Developer API. */ + InstanceConfig.developerV1Beta, + ]) + func countTokens_text_systemInstruction(_ config: InstanceConfig) async throws { + let model = VertexAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2Flash, + generationConfig: generationConfig, + safetySettings: safetySettings, + systemInstruction: systemInstruction // Not supported on the v1 Developer API + ) + + let response = try await model.countTokens("What is your favourite colour?") + + #expect(response.totalTokens == 14) + switch config.apiConfig.service { + case .vertexAI: + #expect(response.totalBillableCharacters == 61) + case .developer: + #expect(response.totalBillableCharacters == nil) + } + #expect(response.promptTokensDetails.count == 1) + let promptTokensDetails = try #require(response.promptTokensDetails.first) + #expect(promptTokensDetails.modality == .text) + #expect(promptTokensDetails.tokenCount == response.totalTokens) + } + + @Test(arguments: [ + /* System instructions are not supported on the v1 Developer API. */ + InstanceConfig.developerV1, + ]) + func countTokens_text_systemInstruction_unsupported(_ config: InstanceConfig) async throws { + let model = VertexAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2Flash, + systemInstruction: systemInstruction // Not supported on the v1 Developer API + ) + + try await #require( + throws: BackendError.self, + """ + If this test fails (i.e., `countTokens` succeeds), remove \(config) from this test and add it + to `countTokens_text_systemInstruction`. + """, + performing: { + try await model.countTokens("What is your favourite colour?") + } + ) + } } From dece0adef8ed7644c23b15fa5f25ca9dc1b25ee3 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 13 Mar 2025 18:26:26 -0400 Subject: [PATCH 7/7] Add minimal encoding unit tests for `CountTokensRequest` --- .../Requests}/CountTokensRequest.swift | 0 .../Requests/CountTokensRequestTests.swift | 111 ++++++++++++++++++ 2 files changed, 111 insertions(+) rename FirebaseVertexAI/Sources/{ => Types/Internal/Requests}/CountTokensRequest.swift (100%) create mode 100644 FirebaseVertexAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift diff --git a/FirebaseVertexAI/Sources/CountTokensRequest.swift b/FirebaseVertexAI/Sources/Types/Internal/Requests/CountTokensRequest.swift similarity index 100% rename from FirebaseVertexAI/Sources/CountTokensRequest.swift rename to FirebaseVertexAI/Sources/Types/Internal/Requests/CountTokensRequest.swift diff --git a/FirebaseVertexAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift b/FirebaseVertexAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift new file mode 100644 index 00000000000..fd03f0be5a1 --- /dev/null +++ b/FirebaseVertexAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift @@ -0,0 +1,111 @@ +// 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 Foundation +import XCTest + +@testable import FirebaseVertexAI + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class CountTokensRequestTests: XCTestCase { + let encoder = JSONEncoder() + + let modelResourceName = "models/test-model-name" + let textPart = TextPart("test-prompt") + let vertexAPIConfig = APIConfig(service: .vertexAI, version: .v1beta) + let developerAPIConfig = APIConfig( + service: .developer(endpoint: .firebaseVertexAIProd), + version: .v1beta + ) + let requestOptions = RequestOptions() + + override func setUp() { + encoder.outputFormatting = .init( + arrayLiteral: .prettyPrinted, .sortedKeys, .withoutEscapingSlashes + ) + } + + // MARK: CountTokensRequest Encoding + + func testEncodeCountTokensRequest_vertexAI_minimal() throws { + let content = ModelContent(role: nil, parts: [textPart]) + let generateContentRequest = GenerateContentRequest( + model: modelResourceName, + contents: [content], + generationConfig: nil, + safetySettings: nil, + tools: nil, + toolConfig: nil, + systemInstruction: nil, + apiConfig: vertexAPIConfig, + apiMethod: .countTokens, + options: requestOptions + ) + let request = CountTokensRequest(generateContentRequest: generateContentRequest) + + let jsonData = try encoder.encode(request) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "contents" : [ + { + "parts" : [ + { + "text" : "\(textPart.text)" + } + ] + } + ] + } + """) + } + + func testEncodeCountTokensRequest_developerAPI_minimal() throws { + let content = ModelContent(role: nil, parts: [textPart]) + let generateContentRequest = GenerateContentRequest( + model: modelResourceName, + contents: [content], + generationConfig: nil, + safetySettings: nil, + tools: nil, + toolConfig: nil, + systemInstruction: nil, + apiConfig: developerAPIConfig, + apiMethod: .countTokens, + options: requestOptions + ) + let request = CountTokensRequest(generateContentRequest: generateContentRequest) + + let jsonData = try encoder.encode(request) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "generateContentRequest" : { + "contents" : [ + { + "parts" : [ + { + "text" : "\(textPart.text)" + } + ] + } + ], + "model" : "\(modelResourceName)" + } + } + """) + } +}