From 32065d6fd4d01275b180147ae7de9d989dd7881b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:46:47 -0700 Subject: [PATCH 01/54] chore(deps): bump rexml from 3.3.9 to 3.4.2 (#15337) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index aa840195966..c4e166dd671 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -150,7 +150,7 @@ GEM open4 (1.3.4) public_suffix (4.0.7) rchardet (1.8.0) - rexml (3.3.9) + rexml (3.4.2) ruby-macho (2.5.1) ruby2_keywords (0.0.5) sawyer (0.9.2) From a5bc774de7b37d90fee73ab39ed5649cf0118571 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:52:34 -0700 Subject: [PATCH 02/54] chore(deps): bump rexml from 3.4.1 to 3.4.2 in /.github/actions/notices_generation (#15342) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/notices_generation/Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/notices_generation/Gemfile.lock b/.github/actions/notices_generation/Gemfile.lock index 78d9a193cfb..3d9d31a5ff3 100644 --- a/.github/actions/notices_generation/Gemfile.lock +++ b/.github/actions/notices_generation/Gemfile.lock @@ -98,7 +98,7 @@ GEM sawyer (~> 0.8.0, >= 0.5.3) plist (3.6.0) public_suffix (4.0.6) - rexml (3.4.1) + rexml (3.4.2) ruby-macho (2.5.1) ruby2_keywords (0.0.2) sawyer (0.8.2) From 218716f556173eb39b79842c2f5df79fdb45d178 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 23 Sep 2025 19:24:56 -0400 Subject: [PATCH 03/54] [Firebase AI] Add `URLContext` tool (#15221) Co-authored-by: Paul Beusterien Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- FirebaseAI/CHANGELOG.md | 2 + FirebaseAI/Sources/AILog.swift | 1 + .../Sources/GenerateContentResponse.swift | 32 ++++++- FirebaseAI/Sources/Tool.swift | 12 +++ .../Types/Internal/Tools/URLContext.swift | 18 ++++ .../Types/Public/URLContextMetadata.swift | 34 +++++++ .../Sources/Types/Public/URLMetadata.swift | 85 ++++++++++++++++++ .../GenerateContentIntegrationTests.swift | 27 ++++++ .../Unit/GenerativeModelGoogleAITests.swift | 72 +++++++++++++++ .../Unit/GenerativeModelVertexAITests.swift | 90 +++++++++++++++++++ FirebaseAI/Tests/Unit/MockURLProtocol.swift | 8 +- .../Types/GenerateContentResponseTests.swift | 51 +++++++++++ 12 files changed, 427 insertions(+), 5 deletions(-) create mode 100644 FirebaseAI/Sources/Types/Internal/Tools/URLContext.swift create mode 100644 FirebaseAI/Sources/Types/Public/URLContextMetadata.swift create mode 100644 FirebaseAI/Sources/Types/Public/URLMetadata.swift diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index d1a00c824c3..8c0a0068fde 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,4 +1,6 @@ # Unreleased +- [feature] Added support for the URL context tool, which allows the model to access content + from provided public web URLs to inform and enhance its responses. (#15221) - [changed] Using Firebase AI Logic with the Gemini Developer API is now Generally Available (GA). - [changed] Using Firebase AI Logic with the Imagen generation APIs is now Generally Available (GA). diff --git a/FirebaseAI/Sources/AILog.swift b/FirebaseAI/Sources/AILog.swift index fe04716384a..03232ff23df 100644 --- a/FirebaseAI/Sources/AILog.swift +++ b/FirebaseAI/Sources/AILog.swift @@ -66,6 +66,7 @@ enum AILog { case codeExecutionResultUnrecognizedOutcome = 3015 case executableCodeUnrecognizedLanguage = 3016 case fallbackValueUsed = 3017 + case urlMetadataUnrecognizedURLRetrievalStatus = 3018 // SDK State Errors case generateContentResponseNoCandidates = 4000 diff --git a/FirebaseAI/Sources/GenerateContentResponse.swift b/FirebaseAI/Sources/GenerateContentResponse.swift index 015d5dae56c..c13d3d53c8a 100644 --- a/FirebaseAI/Sources/GenerateContentResponse.swift +++ b/FirebaseAI/Sources/GenerateContentResponse.swift @@ -26,6 +26,9 @@ public struct GenerateContentResponse: Sendable { /// The total number of tokens across the generated response candidates. public let candidatesTokenCount: Int + /// The number of tokens used by tools. + public let toolUsePromptTokenCount: Int + /// The number of tokens used by the model's internal "thinking" process. /// /// For models that support thinking (like Gemini 2.5 Pro and Flash), this represents the actual @@ -39,11 +42,15 @@ public struct GenerateContentResponse: Sendable { /// The total number of tokens in both the request and response. public let totalTokenCount: Int - /// The breakdown, by modality, of how many tokens are consumed by the prompt + /// The breakdown, by modality, of how many tokens are consumed by the prompt. public let promptTokensDetails: [ModalityTokenCount] /// The breakdown, by modality, of how many tokens are consumed by the candidates public let candidatesTokensDetails: [ModalityTokenCount] + + /// The breakdown, by modality, of how many tokens were consumed by the tools used to process + /// the request. + public let toolUsePromptTokensDetails: [ModalityTokenCount] } /// A list of candidate response content, ordered from best to worst. @@ -154,14 +161,19 @@ public struct Candidate: Sendable { public let groundingMetadata: GroundingMetadata? + /// Metadata related to the ``URLContext`` tool. + public let urlContextMetadata: URLContextMetadata? + /// Initializer for SwiftUI previews or tests. public init(content: ModelContent, safetyRatings: [SafetyRating], finishReason: FinishReason?, - citationMetadata: CitationMetadata?, groundingMetadata: GroundingMetadata? = nil) { + citationMetadata: CitationMetadata?, groundingMetadata: GroundingMetadata? = nil, + urlContextMetadata: URLContextMetadata? = nil) { self.content = content self.safetyRatings = safetyRatings self.finishReason = finishReason self.citationMetadata = citationMetadata self.groundingMetadata = groundingMetadata + self.urlContextMetadata = urlContextMetadata } // Returns `true` if the candidate contains no information that a developer could use. @@ -469,10 +481,12 @@ extension GenerateContentResponse.UsageMetadata: Decodable { enum CodingKeys: CodingKey { case promptTokenCount case candidatesTokenCount + case toolUsePromptTokenCount case thoughtsTokenCount case totalTokenCount case promptTokensDetails case candidatesTokensDetails + case toolUsePromptTokensDetails } public init(from decoder: any Decoder) throws { @@ -480,6 +494,8 @@ extension GenerateContentResponse.UsageMetadata: Decodable { promptTokenCount = try container.decodeIfPresent(Int.self, forKey: .promptTokenCount) ?? 0 candidatesTokenCount = try container.decodeIfPresent(Int.self, forKey: .candidatesTokenCount) ?? 0 + toolUsePromptTokenCount = + try container.decodeIfPresent(Int.self, forKey: .toolUsePromptTokenCount) ?? 0 thoughtsTokenCount = try container.decodeIfPresent(Int.self, forKey: .thoughtsTokenCount) ?? 0 totalTokenCount = try container.decodeIfPresent(Int.self, forKey: .totalTokenCount) ?? 0 promptTokensDetails = @@ -488,6 +504,9 @@ extension GenerateContentResponse.UsageMetadata: Decodable { [ModalityTokenCount].self, forKey: .candidatesTokensDetails ) ?? [] + toolUsePromptTokensDetails = try container.decodeIfPresent( + [ModalityTokenCount].self, forKey: .toolUsePromptTokensDetails + ) ?? [] } } @@ -499,6 +518,7 @@ extension Candidate: Decodable { case finishReason case citationMetadata case groundingMetadata + case urlContextMetadata } /// Initializes a response from a decoder. Used for decoding server responses; not for public @@ -540,6 +560,14 @@ extension Candidate: Decodable { GroundingMetadata.self, forKey: .groundingMetadata ) + + if let urlContextMetadata = + try container.decodeIfPresent(URLContextMetadata.self, forKey: .urlContextMetadata), + !urlContextMetadata.urlMetadata.isEmpty { + self.urlContextMetadata = urlContextMetadata + } else { + urlContextMetadata = nil + } } } diff --git a/FirebaseAI/Sources/Tool.swift b/FirebaseAI/Sources/Tool.swift index 78dc8ef9443..53e0ee8b49e 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -76,12 +76,15 @@ public struct Tool: Sendable { let googleSearch: GoogleSearch? let codeExecution: CodeExecution? + let urlContext: URLContext? init(functionDeclarations: [FunctionDeclaration]? = nil, googleSearch: GoogleSearch? = nil, + urlContext: URLContext? = nil, codeExecution: CodeExecution? = nil) { self.functionDeclarations = functionDeclarations self.googleSearch = googleSearch + self.urlContext = urlContext self.codeExecution = codeExecution } @@ -128,6 +131,15 @@ public struct Tool: Sendable { return self.init(googleSearch: googleSearch) } + /// Creates a tool that allows you to provide additional context to the models in the form of + /// public web URLs. + /// + /// By including URLs in your request, the Gemini model will access the content from those pages + /// to inform and enhance its response. + public static func urlContext() -> Tool { + return self.init(urlContext: URLContext()) + } + /// Creates a tool that allows the model to execute code. /// /// For more details, see ``CodeExecution``. diff --git a/FirebaseAI/Sources/Types/Internal/Tools/URLContext.swift b/FirebaseAI/Sources/Types/Internal/Tools/URLContext.swift new file mode 100644 index 00000000000..2033bb940f1 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Tools/URLContext.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. + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct URLContext: Sendable, Encodable { + init() {} +} diff --git a/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift new file mode 100644 index 00000000000..5ff671f68eb --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift @@ -0,0 +1,34 @@ +// 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. + +/// Metadata related to the ``Tool/urlContext()`` tool. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct URLContextMetadata: Sendable, Hashable { + /// List of URL metadata used to provide context to the Gemini model. + public let urlMetadata: [URLMetadata] +} + +// MARK: - Codable Conformances + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension URLContextMetadata: Decodable { + enum CodingKeys: CodingKey { + case urlMetadata + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + urlMetadata = try container.decodeIfPresent([URLMetadata].self, forKey: .urlMetadata) ?? [] + } +} diff --git a/FirebaseAI/Sources/Types/Public/URLMetadata.swift b/FirebaseAI/Sources/Types/Public/URLMetadata.swift new file mode 100644 index 00000000000..50ee16a7e86 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/URLMetadata.swift @@ -0,0 +1,85 @@ +// 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 + +/// Metadata for a single URL retrieved by the ``Tool/urlContext()`` tool. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct URLMetadata: Sendable, Hashable { + /// Status of the URL retrieval. + public struct URLRetrievalStatus: DecodableProtoEnum, Hashable { + enum Kind: String { + case unspecified = "URL_RETRIEVAL_STATUS_UNSPECIFIED" + case success = "URL_RETRIEVAL_STATUS_SUCCESS" + case error = "URL_RETRIEVAL_STATUS_ERROR" + case paywall = "URL_RETRIEVAL_STATUS_PAYWALL" + case unsafe = "URL_RETRIEVAL_STATUS_UNSAFE" + } + + /// Internal only - Unspecified retrieval status. + static let unspecified = URLRetrievalStatus(kind: .unspecified) + + /// The URL retrieval was successful. + public static let success = URLRetrievalStatus(kind: .success) + + /// The URL retrieval failed. + public static let error = URLRetrievalStatus(kind: .error) + + /// The URL retrieval failed because the content is behind a paywall. + public static let paywall = URLRetrievalStatus(kind: .paywall) + + /// The URL retrieval failed because the content is unsafe. + public static let unsafe = URLRetrievalStatus(kind: .unsafe) + + /// Returns the raw string representation of the `URLRetrievalStatus` value. + public let rawValue: String + + static let unrecognizedValueMessageCode = + AILog.MessageCode.urlMetadataUnrecognizedURLRetrievalStatus + } + + /// The retrieved URL. + public let retrievedURL: URL? + + /// The status of the URL retrieval. + public let retrievalStatus: URLRetrievalStatus +} + +// MARK: - Codable Conformances + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension URLMetadata: Decodable { + enum CodingKeys: String, CodingKey { + case retrievedURL = "retrievedUrl" + case retrievalStatus = "urlRetrievalStatus" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let retrievedURLString = try container.decodeIfPresent(String.self, forKey: .retrievedURL), + let retrievedURL = URL(string: retrievedURLString) { + self.retrievedURL = retrievedURL + } else { + retrievedURL = nil + } + let retrievalStatus = try container.decodeIfPresent( + URLMetadata.URLRetrievalStatus.self, forKey: .retrievalStatus + ) + + self.retrievalStatus = AILog.safeUnwrap( + retrievalStatus, fallback: URLMetadata.URLRetrievalStatus(kind: .unspecified) + ) + } +} diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index d2fb589a432..747b1dc5bea 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -424,6 +424,33 @@ struct GenerateContentIntegrationTests { } } + @Test( + "generateContent with URL Context", + arguments: InstanceConfig.allConfigs + ) + func generateContent_withURLContext_succeeds(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_Flash, + tools: [.urlContext()] + ) + let prompt = """ + Write a one paragraph summary of this blog post: \ + https://developers.googleblog.com/en/introducing-gemma-3-270m/ + """ + + let response = try await model.generateContent(prompt) + + let candidate = try #require(response.candidates.first) + let urlContextMetadata = try #require(candidate.urlContextMetadata) + #expect(urlContextMetadata.urlMetadata.count == 1) + let urlMetadata = try #require(urlContextMetadata.urlMetadata.first) + let retrievedURL = try #require(urlMetadata.retrievedURL) + #expect( + retrievedURL == URL(string: "https://developers.googleblog.com/en/introducing-gemma-3-270m/") + ) + #expect(urlMetadata.retrievalStatus == .success) + } + @Test(arguments: InstanceConfig.allConfigs) func generateContent_codeExecution_succeeds(_ config: InstanceConfig) async throws { let model = FirebaseAI.componentInstance(config).generativeModel( diff --git a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift index c6335142959..59e1581a638 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift @@ -333,6 +333,55 @@ final class GenerativeModelGoogleAITests: XCTestCase { let textPart = try XCTUnwrap(parts[2] as? TextPart) XCTAssertFalse(textPart.isThought) XCTAssertTrue(textPart.text.hasPrefix("The first 5 prime numbers are 2, 3, 5, 7, and 11.")) + let usageMetadata = try XCTUnwrap(response.usageMetadata) + XCTAssertEqual(usageMetadata.toolUsePromptTokenCount, 160) + } + + func testGenerateContent_success_urlContext() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-url-context", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 1) + let urlMetadata = try XCTUnwrap(urlContextMetadata.urlMetadata.first) + let retrievedURL = try XCTUnwrap(urlMetadata.retrievedURL) + XCTAssertEqual( + retrievedURL, + URL(string: "https://berkshirehathaway.com") + ) + XCTAssertEqual(urlMetadata.retrievalStatus, .success) + let usageMetadata = try XCTUnwrap(response.usageMetadata) + XCTAssertEqual(usageMetadata.toolUsePromptTokenCount, 424) + } + + func testGenerateContent_success_urlContext_mixedValidity() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-url-context-mixed-validity", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + let candidate = try XCTUnwrap(response.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 3) + + let paywallURLMetadata = urlContextMetadata.urlMetadata[0] + XCTAssertEqual(paywallURLMetadata.retrievalStatus, .error) + + let successURLMetadata = urlContextMetadata.urlMetadata[1] + XCTAssertEqual(successURLMetadata.retrievalStatus, .success) + + let errorURLMetadata = urlContextMetadata.urlMetadata[2] + XCTAssertEqual(errorURLMetadata.retrievalStatus, .error) } func testGenerateContent_failure_invalidAPIKey() async throws { @@ -642,4 +691,27 @@ final class GenerativeModelGoogleAITests: XCTestCase { let lastResponse = try XCTUnwrap(responses.last) XCTAssertEqual(lastResponse.text, "text8") } + + func testGenerateContentStream_success_urlContext() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-url-context", + withExtension: "txt", + subdirectory: googleAISubdirectory + ) + + var responses = [GenerateContentResponse]() + let stream = try model.generateContentStream(testPrompt) + for try await response in stream { + responses.append(response) + } + + let firstResponse = try XCTUnwrap(responses.first) + let candidate = try XCTUnwrap(firstResponse.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 1) + let urlMetadata = try XCTUnwrap(urlContextMetadata.urlMetadata.first) + let retrievedURL = try XCTUnwrap(urlMetadata.retrievedURL) + XCTAssertEqual(retrievedURL, URL(string: "https://google.com")) + XCTAssertEqual(urlMetadata.retrievalStatus, .success) + } } diff --git a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift index 847f5a8e643..1d2498f07e5 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift @@ -487,6 +487,73 @@ final class GenerativeModelVertexAITests: XCTestCase { XCTAssertEqual( textPart2.text, "The sum of the first 5 prime numbers (2, 3, 5, 7, and 11) is 28." ) + let usageMetadata = try XCTUnwrap(response.usageMetadata) + XCTAssertEqual(usageMetadata.toolUsePromptTokenCount, 371) + } + + func testGenerateContent_success_urlContext() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-url-context", + withExtension: "json", + subdirectory: vertexSubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 1) + let urlMetadata = try XCTUnwrap(urlContextMetadata.urlMetadata.first) + let retrievedURL = try XCTUnwrap(urlMetadata.retrievedURL) + XCTAssertEqual( + retrievedURL, + URL(string: "https://berkshirehathaway.com") + ) + XCTAssertEqual(urlMetadata.retrievalStatus, .success) + let usageMetadata = try XCTUnwrap(response.usageMetadata) + XCTAssertEqual(usageMetadata.toolUsePromptTokenCount, 34) + XCTAssertEqual(usageMetadata.thoughtsTokenCount, 36) + } + + func testGenerateContent_success_urlContext_mixedValidity() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-url-context-mixed-validity", + withExtension: "json", + subdirectory: vertexSubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + let candidate = try XCTUnwrap(response.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 3) + + let paywallURLMetadata = urlContextMetadata.urlMetadata[0] + XCTAssertEqual(paywallURLMetadata.retrievalStatus, .error) + + let successURLMetadata = urlContextMetadata.urlMetadata[1] + XCTAssertEqual(successURLMetadata.retrievalStatus, .success) + + let errorURLMetadata = urlContextMetadata.urlMetadata[2] + XCTAssertEqual(errorURLMetadata.retrievalStatus, .error) + } + + func testGenerateContent_success_urlContext_retrievedURLPresentOnErrorStatus() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-url-context-missing-retrievedurl", + withExtension: "json", + subdirectory: vertexSubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + let candidate = try XCTUnwrap(response.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + let urlMetadata = try XCTUnwrap(urlContextMetadata.urlMetadata.first) + let retrievedURL = try XCTUnwrap(urlMetadata.retrievedURL) + XCTAssertEqual(retrievedURL.absoluteString, "https://example.com/8") + XCTAssertEqual(urlMetadata.retrievalStatus, .error) } func testGenerateContent_success_image_invalidSafetyRatingsIgnored() async throws { @@ -1718,6 +1785,29 @@ final class GenerativeModelVertexAITests: XCTestCase { XCTAssertEqual(responses, 1) } + func testGenerateContentStream_success_urlContext() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-url-context", + withExtension: "txt", + subdirectory: vertexSubdirectory + ) + + var responses = [GenerateContentResponse]() + let stream = try model.generateContentStream(testPrompt) + for try await response in stream { + responses.append(response) + } + + let firstResponse = try XCTUnwrap(responses.first) + let candidate = try XCTUnwrap(firstResponse.candidates.first) + let urlContextMetadata = try XCTUnwrap(candidate.urlContextMetadata) + XCTAssertEqual(urlContextMetadata.urlMetadata.count, 1) + let urlMetadata = try XCTUnwrap(urlContextMetadata.urlMetadata.first) + let retrievedURL = try XCTUnwrap(urlMetadata.retrievedURL) + XCTAssertEqual(retrievedURL, URL(string: "https://google.com")) + XCTAssertEqual(urlMetadata.retrievalStatus, .success) + } + // MARK: - Count Tokens func testCountTokens_succeeds() async throws { diff --git a/FirebaseAI/Tests/Unit/MockURLProtocol.swift b/FirebaseAI/Tests/Unit/MockURLProtocol.swift index 5385b164015..6db227d5cfb 100644 --- a/FirebaseAI/Tests/Unit/MockURLProtocol.swift +++ b/FirebaseAI/Tests/Unit/MockURLProtocol.swift @@ -21,6 +21,7 @@ class MockURLProtocol: URLProtocol, @unchecked Sendable { URLResponse, AsyncLineSequence? ))? + override class func canInit(with request: URLRequest) -> Bool { #if os(watchOS) print("MockURLProtocol cannot be used on watchOS.") @@ -33,13 +34,14 @@ class MockURLProtocol: URLProtocol, @unchecked Sendable { override class func canonicalRequest(for request: URLRequest) -> URLRequest { return request } override func startLoading() { - guard let requestHandler = MockURLProtocol.requestHandler else { - fatalError("`requestHandler` is nil.") - } guard let client = client else { fatalError("`client` is nil.") } + guard let requestHandler = MockURLProtocol.requestHandler else { + fatalError("No request handler set.") + } + Task { let (response, stream) = try requestHandler(self.request) client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) diff --git a/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift b/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift index a53d215359f..276308f63aa 100644 --- a/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift +++ b/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift @@ -17,6 +17,8 @@ import XCTest @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class GenerateContentResponseTests: XCTestCase { + let jsonDecoder = JSONDecoder() + // MARK: - GenerateContentResponse Computed Properties func testGenerateContentResponse_inlineDataParts_success() throws { @@ -106,4 +108,53 @@ final class GenerateContentResponseTests: XCTestCase { "functionCalls should be empty when there are no candidates." ) } + + // MARK: - Decoding Tests + + func testDecodeCandidate_emptyURLMetadata_urlContextMetadataIsNil() throws { + let json = """ + { + "content": { "role": "model", "parts": [ { "text": "Some text." } ] }, + "finishReason": "STOP", + "urlContextMetadata": { "urlMetadata": [] } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let candidate = try jsonDecoder.decode(Candidate.self, from: jsonData) + + XCTAssertNil( + candidate.urlContextMetadata, + "urlContextMetadata should be nil if the `urlMetadata` array is empty in the candidate." + ) + XCTAssertEqual(candidate.content.role, "model") + let part = try XCTUnwrap(candidate.content.parts.first) + let textPart = try XCTUnwrap(part as? TextPart) + XCTAssertEqual(textPart.text, "Some text.") + XCTAssertFalse(textPart.isThought) + XCTAssertEqual(candidate.finishReason, .stop) + } + + func testDecodeCandidate_missingURLMetadata_urlContextMetadataIsNil() throws { + let json = """ + { + "content": { "role": "model", "parts": [ { "text": "Some text." } ] }, + "finishReason": "STOP" + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let candidate = try jsonDecoder.decode(Candidate.self, from: jsonData) + + XCTAssertNil( + candidate.urlContextMetadata, + "urlContextMetadata should be nil if `urlMetadata` is not provided in the candidate." + ) + XCTAssertEqual(candidate.content.role, "model") + let part = try XCTUnwrap(candidate.content.parts.first) + let textPart = try XCTUnwrap(part as? TextPart) + XCTAssertEqual(textPart.text, "Some text.") + XCTAssertFalse(textPart.isThought) + XCTAssertEqual(candidate.finishReason, .stop) + } } From 03cffc3e0e3ca63b7ebf5b5f950323fc59c4acb7 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Wed, 24 Sep 2025 13:06:49 -0400 Subject: [PATCH 04/54] [AI] Add Public Preview annotations to URL context APIs (#15354) Co-authored-by: Andrew Heard --- FirebaseAI/Sources/Tool.swift | 3 +++ FirebaseAI/Sources/Types/Public/URLContextMetadata.swift | 3 +++ FirebaseAI/Sources/Types/Public/URLMetadata.swift | 3 +++ 3 files changed, 9 insertions(+) diff --git a/FirebaseAI/Sources/Tool.swift b/FirebaseAI/Sources/Tool.swift index 53e0ee8b49e..e051b3b5ea4 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -136,6 +136,9 @@ public struct Tool: Sendable { /// /// By including URLs in your request, the Gemini model will access the content from those pages /// to inform and enhance its response. + /// + /// > Warning: URL context is a **Public Preview** feature, which means that it is not subject to + /// > any SLA or deprecation policy and could change in backwards-incompatible ways. public static func urlContext() -> Tool { return self.init(urlContext: URLContext()) } diff --git a/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift index 5ff671f68eb..5689c7610f7 100644 --- a/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift +++ b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift @@ -13,6 +13,9 @@ // limitations under the License. /// Metadata related to the ``Tool/urlContext()`` tool. +/// +/// > Warning: URL context is a **Public Preview** feature, which means that it is not subject to +/// > any SLA or deprecation policy and could change in backwards-incompatible ways. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct URLContextMetadata: Sendable, Hashable { /// List of URL metadata used to provide context to the Gemini model. diff --git a/FirebaseAI/Sources/Types/Public/URLMetadata.swift b/FirebaseAI/Sources/Types/Public/URLMetadata.swift index 50ee16a7e86..3833d71accf 100644 --- a/FirebaseAI/Sources/Types/Public/URLMetadata.swift +++ b/FirebaseAI/Sources/Types/Public/URLMetadata.swift @@ -15,6 +15,9 @@ import Foundation /// Metadata for a single URL retrieved by the ``Tool/urlContext()`` tool. +/// +/// > Warning: URL context is a **Public Preview** feature, which means that it is not subject to +/// > any SLA or deprecation policy and could change in backwards-incompatible ways. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct URLMetadata: Sendable, Hashable { /// Status of the URL retrieval. From 63083d850d0d94e9c300c304b47708ebfe62dace Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Thu, 25 Sep 2025 08:03:26 -0700 Subject: [PATCH 05/54] [AI] Update empty parts check for urlContextMetadata (#15355) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../Sources/GenerateContentResponse.swift | 3 +- .../Types/GenerateContentResponseTests.swift | 38 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/FirebaseAI/Sources/GenerateContentResponse.swift b/FirebaseAI/Sources/GenerateContentResponse.swift index c13d3d53c8a..a7d7da85d67 100644 --- a/FirebaseAI/Sources/GenerateContentResponse.swift +++ b/FirebaseAI/Sources/GenerateContentResponse.swift @@ -179,7 +179,8 @@ public struct Candidate: Sendable { // Returns `true` if the candidate contains no information that a developer could use. var isEmpty: Bool { content.parts - .isEmpty && finishReason == nil && citationMetadata == nil && groundingMetadata == nil + .isEmpty && finishReason == nil && citationMetadata == nil && groundingMetadata == nil && + urlContextMetadata == nil } } diff --git a/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift b/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift index 276308f63aa..dfc393e2d29 100644 --- a/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift +++ b/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +@testable import FirebaseAI import XCTest @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) @@ -157,4 +157,40 @@ final class GenerateContentResponseTests: XCTestCase { XCTAssertFalse(textPart.isThought) XCTAssertEqual(candidate.finishReason, .stop) } + + // MARK: - Candidate.isEmpty + + func testCandidateIsEmpty_allEmpty_isTrue() throws { + let candidate = Candidate( + content: ModelContent(parts: []), + safetyRatings: [], + finishReason: nil, + citationMetadata: nil, + groundingMetadata: nil, + urlContextMetadata: nil + ) + + XCTAssertTrue(candidate.isEmpty, "A candidate with no content should be empty.") + } + + func testCandidateIsEmpty_withURLContextMetadata_isFalse() throws { + let urlMetadata = try URLMetadata( + retrievedURL: XCTUnwrap(URL(string: "https://google.com")), + retrievalStatus: .success + ) + let urlContextMetadata = URLContextMetadata(urlMetadata: [urlMetadata]) + let candidate = Candidate( + content: ModelContent(parts: []), + safetyRatings: [], + finishReason: nil, + citationMetadata: nil, + groundingMetadata: nil, + urlContextMetadata: urlContextMetadata + ) + + XCTAssertFalse( + candidate.isEmpty, + "A candidate with only `urlContextMetadata` should not be empty." + ) + } } From 6bcf9786ec1851b9d272a4e86a3d2349ae869130 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Sat, 27 Sep 2025 16:10:50 +0200 Subject: [PATCH 06/54] Add design document for Swift AsyncSequence / AsyncStream support for Firebase's realtime APIs (#15350) --- .../swift-async-sequence-api-design.md | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 docs/AsyncStreams/swift-async-sequence-api-design.md diff --git a/docs/AsyncStreams/swift-async-sequence-api-design.md b/docs/AsyncStreams/swift-async-sequence-api-design.md new file mode 100644 index 00000000000..07645f30d3e --- /dev/null +++ b/docs/AsyncStreams/swift-async-sequence-api-design.md @@ -0,0 +1,301 @@ +# API Design for Firebase `AsyncSequence` Event Streams + +* **Authors** + * Peter Friese +* **Contributors** + * Nick Cooke + * Paul Beusterien +* **Status**: `In Review` +* **Last Updated**: 2025-09-25 + +## 1. Abstract + +This proposal outlines the integration of Swift's `AsyncStream` and `AsyncSequence` APIs into the Firebase Apple SDK. The goal is to provide a modern, developer-friendly way to consume real-time data streams from Firebase APIs, aligning the SDK with Swift's structured concurrency model and improving the overall developer experience. + +## 2. Background + +Many Firebase APIs produce a sequence of asynchronous events, such as authentication state changes, document and collection updates, and remote configuration updates. Currently, the SDK exposes these through completion-handler-based APIs (listeners). + +```swift +// Current listener-based approach +db.collection("cities").document("SF") + .addSnapshotListener { documentSnapshot, error in + guard let document = documentSnapshot else { /* ... */ } + guard let data = document.data() else { /* ... */ } + print("Current data: \(data)") + } +``` + +This approach breaks the otherwise linear control flow, requires manual management of listener lifecycles, and complicates error handling. Swift's `AsyncSequence` provides a modern, type-safe alternative that integrates seamlessly with structured concurrency, offering automatic resource management, simplified error handling, and a more intuitive, linear control flow. + +## 3. Motivation + +Adopting `AsyncSequence` will: + +* **Modernize the SDK:** Align with Swift's modern concurrency approach, making Firebase feel more native to Swift developers. +* **Simplify Development:** Eliminate the need for manual listener management and reduce boilerplate code, especially when integrating with SwiftUI. +* **Improve Code Quality:** Provide official, high-quality implementations for streaming APIs, reducing ecosystem fragmentation caused by unofficial solutions. +* **Enhance Readability:** Leverage structured error handling (`throws`) and a linear `for try await` syntax to make asynchronous code easier to read and maintain. +* **Enable Composition:** Allow developers to use a rich set of sequence operators (like `map`, `filter`, `prefix`) to transform and combine streams declaratively. + +## 4. Goals + +* To design and implement an idiomatic, `AsyncSequence`-based API surface for all relevant event-streaming Firebase APIs. +* To provide a clear and consistent naming convention that aligns with Apple's own Swift APIs. +* To ensure the new APIs automatically manage the lifecycle of underlying listeners, removing this burden from the developer. +* To improve the testability of asynchronous Firebase interactions. + +## 5. Non-Goals + +* To deprecate or remove the existing listener-based APIs in the immediate future. The new APIs will be additive. +* To introduce `AsyncSequence` wrappers for one-shot asynchronous calls (which are better served by `async/await` functions). This proposal is focused exclusively on event streams. +* To provide a custom `AsyncSequence` implementation. We will use Swift's standard `Async(Throwing)Stream` types. + +## 6. API Naming Convention + +The guiding principle is to establish a clear, concise, and idiomatic naming convention that aligns with modern Swift practices and mirrors Apple's own frameworks. + +### Recommended Approach: Name the sequence based on its conceptual model. + +1. **For sequences of discrete items, use a plural noun.** + * This applies when the stream represents a series of distinct objects, like data snapshots. + * **Guidance:** Use a computed property for parameter-less access and a method for cases that require parameters. + * **Examples:** `url.lines`, `db.collection("users").snapshots`. + +2. **For sequences observing a single entity, describe the event with a suffix.** + * This applies when the stream represents the changing value of a single property or entity over time. + * **Guidance:** Use the entity's name combined with a suffix like `Changes`, `Updates`, or `Events`. + * **Example:** `auth.authStateChanges`. + +This approach was chosen over verb-based (`.streamSnapshots()`) or suffix-based (`.snapshotStream`) alternatives because it aligns most closely with Apple's API design guidelines, leading to a more idiomatic and less verbose call site. + +## 7. Proposed API Design + +### 7.1. Cloud Firestore + +Provides an async alternative to `addSnapshotListener`. + +#### API Design + +```swift +// Collection snapshots +extension CollectionReference { + var snapshots: AsyncThrowingStream { get } + func snapshots(includeMetadataChanges: Bool = false) -> AsyncThrowingStream +} + +// Query snapshots +extension Query { + var snapshots: AsyncThrowingStream { get } + func snapshots(includeMetadataChanges: Bool = false) -> AsyncThrowingStream +} + +// Document snapshots +extension DocumentReference { + var snapshots: AsyncThrowingStream { get } + func snapshots(includeMetadataChanges: Bool = false) -> AsyncThrowingStream +} +``` + +#### Usage + +```swift +// Streaming updates on a collection +func observeUsers() async throws { + for try await snapshot in db.collection("users").snapshots { + // ... + } +} +``` + +### 7.2. Realtime Database + +Provides an async alternative to the `observe(_:with:)` method. + +#### API Design + +```swift +/// An enumeration of granular child-level events. +public enum DatabaseEvent { + case childAdded(DataSnapshot, previousSiblingKey: String?) + case childChanged(DataSnapshot, previousSiblingKey: String?) + case childRemoved(DataSnapshot) + case childMoved(DataSnapshot, previousSiblingKey: String?) +} + +extension DatabaseQuery { + /// An asynchronous stream of the entire contents at a location. + /// This stream emits a new `DataSnapshot` every time the data changes. + var value: AsyncThrowingStream { get } + + /// An asynchronous stream of child-level events at a location. + func events() -> AsyncThrowingStream +} +``` + +#### Usage + +```swift +// Streaming a single value +let scoreRef = Database.database().reference(withPath: "game/score") +for try await snapshot in scoreRef.value { + // ... +} + +// Streaming child events +let messagesRef = Database.database().reference(withPath: "chats/123/messages") +for try await event in messagesRef.events() { + switch event { + case .childAdded(let snapshot, _): + // ... + // ... + } +} +``` + +### 7.3. Authentication + +Provides an async alternative to `addStateDidChangeListener`. + +#### API Design + +```swift +extension Auth { + /// An asynchronous stream of authentication state changes. + var authStateChanges: AsyncStream { get } +} +``` + +#### Usage + +```swift +// Monitoring authentication state +for await user in Auth.auth().authStateChanges { + if let user = user { + // User is signed in + } else { + // User is signed out + } +} +``` + +### 7.4. Cloud Storage + +Provides an async alternative to `observe(.progress, ...)`. + +#### API Design + +```swift +extension StorageTask { + /// An asynchronous stream of progress updates for an ongoing task. + var progressUpdates: AsyncThrowingStream { get } +} +``` + +#### Usage + +```swift +// Monitoring an upload task +let uploadTask = ref.putData(data, metadata: nil) +do { + for try await progress in uploadTask.progress { + // Update progress bar + } + print("Upload complete") +} catch { + // Handle error +} +``` + +### 7.5. Remote Config + +Provides an async alternative to `addOnConfigUpdateListener`. + +#### API Design + +```swift +extension RemoteConfig { + /// An asynchronous stream of configuration updates. + var updates: AsyncThrowingStream { get } +} +``` + +#### Usage + +```swift +// Listening for real-time config updates +for try await update in RemoteConfig.remoteConfig().updates { + // Activate new config +} +``` + +### 7.6. Cloud Messaging (FCM) + +Provides an async alternative to the delegate-based approach for token updates and foreground messages. + +#### API Design + +```swift +extension Messaging { + /// An asynchronous stream of FCM registration token updates. + var tokenUpdates: AsyncStream { get } + + /// An asynchronous stream of remote messages received while the app is in the foreground. + var foregroundMessages: AsyncStream { get } +} +``` + +#### Usage + +```swift +// Monitoring FCM token updates +for await token in Messaging.messaging().tokenUpdates { + // Send token to server +} +``` + +## 8. Testing Plan + +The quality and reliability of this new API surface will be ensured through a multi-layered testing strategy, covering unit, integration, and cancellation scenarios. + +### 8.1. Unit Tests + +The primary goal of unit tests is to verify the correctness of the `AsyncStream` wrapping logic in isolation from the network and backend services. + +* **Mocking:** Each product's stream implementation will be tested against a mocked version of its underlying service (e.g., a mock `Firestore` client). +* **Behavior Verification:** + * Tests will confirm that initiating a stream correctly registers a listener with the underlying service. + * We will use the mock listeners to simulate events (e.g., new snapshots, auth state changes) and assert that the `AsyncStream` yields the corresponding values correctly. + * Error conditions will be simulated to ensure that the stream correctly throws errors. +* **Teardown Logic:** We will verify that the underlying listener is removed when the stream is either cancelled or finishes naturally. + +### 8.2. Integration Tests + +Integration tests will validate the end-to-end functionality of the async sequences against a live backend environment using the **Firebase Emulator Suite**. + +* **Environment:** A new integration test suite will be created that configures the Firebase SDK to connect to the local emulators (Firestore, Database, Auth, etc.). +* **Validation:** These tests will perform real operations (e.g., writing a document and then listening to its `snapshots` stream) to verify that real-time updates are correctly received and propagated through the `AsyncSequence` API. +* **Cross-Product Scenarios:** We will test scenarios that involve multiple Firebase products where applicable. + +### 8.3. Cancellation Behavior Tests + +A specific set of tests will be dedicated to ensuring that resource cleanup (i.e., listener removal) happens correctly and promptly when the consuming task is cancelled. + +* **Test Scenario:** + 1. A stream will be consumed within a Swift `Task`. + 2. The `Task` will be cancelled immediately after the stream is initiated. + 3. Using a mock or a spy object, we will assert that the `remove()` method on the underlying listener registration is called. +* **Importance:** This is critical for preventing resource leaks and ensuring the new API behaves predictably within the Swift structured concurrency model, especially in SwiftUI contexts where tasks are automatically managed. + +## 9. Implementation Plan + +The implementation will be phased, with each product's API being added in a separate Pull Request to facilitate focused reviews. + +* **Firestore:** [PR #14924: Support AsyncStream in realtime query](https://github.com/firebase/firebase-ios-sdk/pull/14924) +* **Authentication:** [Link to PR when available] +* **Realtime Database:** [Link to PR when available] +* ...and so on. + +## 10. Open Questions & Future Work + +* Should we provide convenience wrappers for common `AsyncSequence` operators? (e.g., a method to directly stream decoded objects instead of snapshots). For now, this is considered a **Non-Goal** but could be revisited. From 61ca043c06609a33ed2d570f40aa4eac23d42db5 Mon Sep 17 00:00:00 2001 From: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:10:16 -0400 Subject: [PATCH 07/54] fix(ci): Change how FIREBASE_CI is enabled (#15364) --- .github/workflows/auth.yml | 1 + .github/workflows/common_cocoapods_cron.yml | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auth.yml b/.github/workflows/auth.yml index 18fd32f04e9..59626a1a4b8 100644 --- a/.github/workflows/auth.yml +++ b/.github/workflows/auth.yml @@ -141,5 +141,6 @@ jobs: platforms: '[ "ios", "tvos --skip-tests", "macos --skip-tests", "watchos --skip-tests" ]' flags: '[ "--use-static-frameworks" ]' setup_command: scripts/configure_test_keychain.sh + ignore_deprecation_warnings: true secrets: plist_secret: ${{ secrets.GHASecretsGPGPassphrase1 }} diff --git a/.github/workflows/common_cocoapods_cron.yml b/.github/workflows/common_cocoapods_cron.yml index 3fbebc89924..ade13db13b5 100644 --- a/.github/workflows/common_cocoapods_cron.yml +++ b/.github/workflows/common_cocoapods_cron.yml @@ -46,8 +46,11 @@ on: required: false default: "macos-15" -env: - FIREBASE_CI: true + # Whether to ignore deprecation warnings by setting FIREBASE_CI. + ignore_deprecation_warnings: + type: boolean + required: false + default: false jobs: cron-job: @@ -67,6 +70,9 @@ jobs: run: scripts/setup_bundler.sh - name: Xcode run: sudo xcode-select -s /Applications/${{ inputs.xcode }}.app/Contents/Developer + - name: Set FIREBASE_CI, if needed. + if: inputs.ignore_deprecation_warnings == true + run: echo "FIREBASE_CI=true" >> $GITHUB_ENV - name: Run setup command, if needed. if: inputs.setup_command != '' env: From ba8674d887cac17fc1a0cb1a6fe7e882869f153a Mon Sep 17 00:00:00 2001 From: themiswang Date: Tue, 30 Sep 2025 14:27:53 -0400 Subject: [PATCH 08/54] Adding development platform setter APIs to context init promise chain (#15356) --- Crashlytics/CHANGELOG.md | 3 ++ Crashlytics/Crashlytics/FIRCrashlytics.m | 45 ++++++++++++++----- .../UnitTests/FIRCLSContextManagerTests.m | 23 ++++++++++ 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/Crashlytics/CHANGELOG.md b/Crashlytics/CHANGELOG.md index d7fb8b796f2..64195694926 100644 --- a/Crashlytics/CHANGELOG.md +++ b/Crashlytics/CHANGELOG.md @@ -1,3 +1,6 @@ +# Unreleased +- [fixed] Make set development platform APIs to chain on Crashlytics context init promise. + # 12.3.0 - [fixed] Add missing nanopb dependency to fix SwiftPM builds when building dynamically linked libraries. (#15276) diff --git a/Crashlytics/Crashlytics/FIRCrashlytics.m b/Crashlytics/Crashlytics/FIRCrashlytics.m index 171999717ac..09e733996eb 100644 --- a/Crashlytics/Crashlytics/FIRCrashlytics.m +++ b/Crashlytics/Crashlytics/FIRCrashlytics.m @@ -111,6 +111,8 @@ @interface FIRCrashlytics () *result = @[].mutableCopy; + NSMutableArray *expectation = @[].mutableCopy; + + for (int j = 0; j < 100; j++) { + [expectation addObject:[NSString stringWithFormat:@"%d", j]]; + } + + FBLPromise *promise = [self.contextManager setupContextWithReport:self.report + settings:self.mockSettings + fileManager:self.fileManager]; + + for (int i = 0; i < 100; i++) { + [promise then:^id _Nullable(id _Nullable value) { + [result addObject:[NSString stringWithFormat:@"%d", i]]; + if (i == 99) { + XCTAssertTrue([result isEqualToArray:expectation]); + } + return nil; + }]; + } +} @end From 3098853dfabb30e4bf7fc8e6a6d653d2fc1b7923 Mon Sep 17 00:00:00 2001 From: pcfba <111909874+pcfba@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:00:52 -0700 Subject: [PATCH 09/54] Analytics 12.4.0 (#15367) --- FirebaseAnalytics.podspec | 2 +- GoogleAppMeasurement.podspec | 4 ++-- Package.swift | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/FirebaseAnalytics.podspec b/FirebaseAnalytics.podspec index ddfd6bfefcf..356649ad3c7 100644 --- a/FirebaseAnalytics.podspec +++ b/FirebaseAnalytics.podspec @@ -13,7 +13,7 @@ Pod::Spec.new do |s| s.authors = 'Google, Inc.' s.source = { - :http => 'https://dl.google.com/firebase/ios/analytics/7f774173bfc50ea8/FirebaseAnalytics-12.3.0.tar.gz' + :http => 'https://dl.google.com/firebase/ios/analytics/8ca614257fd008b0/FirebaseAnalytics-12.4.0.tar.gz' } s.cocoapods_version = '>= 1.12.0' diff --git a/GoogleAppMeasurement.podspec b/GoogleAppMeasurement.podspec index c1ee6cfb4e3..15a0e5bb817 100644 --- a/GoogleAppMeasurement.podspec +++ b/GoogleAppMeasurement.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.authors = 'Google, Inc.' s.source = { - :http => 'https://dl.google.com/firebase/ios/analytics/1c0181b69fa16f29/GoogleAppMeasurement-12.3.0.tar.gz' + :http => 'https://dl.google.com/firebase/ios/analytics/2eb2929f64cc5fb8/GoogleAppMeasurement-12.4.0.tar.gz' } s.cocoapods_version = '>= 1.12.0' @@ -39,7 +39,7 @@ Pod::Spec.new do |s| s.subspec 'Default' do |ss| ss.dependency 'GoogleAppMeasurement/Core', '12.4.0' ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.4.0' - ss.ios.dependency 'GoogleAdsOnDeviceConversion', '~> 3.0.0' + ss.ios.dependency 'GoogleAdsOnDeviceConversion', '~> 3.1.0' end s.subspec 'Core' do |ss| diff --git a/Package.swift b/Package.swift index 3bcbca83686..eee9fe9e212 100644 --- a/Package.swift +++ b/Package.swift @@ -329,8 +329,8 @@ let package = Package( ), .binaryTarget( name: "FirebaseAnalytics", - url: "https://dl.google.com/firebase/ios/swiftpm/12.3.0/FirebaseAnalytics.zip", - checksum: "a7fcb34227d6cc0b2db9b1d3f9dd844801e5a28217f20f1daae6c3d2b7d1e8e1" + url: "https://dl.google.com/firebase/ios/swiftpm/12.4.0/FirebaseAnalytics.zip", + checksum: "625b4853a02b312eeb857cb6578b109d42459c65021115f864414141ff32a117" ), .testTarget( name: "AnalyticsSwiftUnit", @@ -1392,7 +1392,7 @@ func googleAppMeasurementDependency() -> Package.Dependency { return .package(url: appMeasurementURL, branch: "main") } - return .package(url: appMeasurementURL, exact: "12.3.0") + return .package(url: appMeasurementURL, exact: "12.4.0") } func abseilDependency() -> Package.Dependency { From ead5eb40a0183ab6727e7338c9ee27c9216c4400 Mon Sep 17 00:00:00 2001 From: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Date: Wed, 1 Oct 2025 17:48:35 -0400 Subject: [PATCH 10/54] chore(m171): Version changelogs (#15369) --- Crashlytics/CHANGELOG.md | 2 +- FirebaseAI/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Crashlytics/CHANGELOG.md b/Crashlytics/CHANGELOG.md index 64195694926..5f1d6c961df 100644 --- a/Crashlytics/CHANGELOG.md +++ b/Crashlytics/CHANGELOG.md @@ -1,4 +1,4 @@ -# Unreleased +# 12.4.0 - [fixed] Make set development platform APIs to chain on Crashlytics context init promise. # 12.3.0 diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index 8c0a0068fde..ce7d6468db2 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,4 +1,4 @@ -# Unreleased +# 12.4.0 - [feature] Added support for the URL context tool, which allows the model to access content from provided public web URLs to inform and enhance its responses. (#15221) - [changed] Using Firebase AI Logic with the Gemini Developer API is now Generally Available (GA). From 22e527bc22832d2f2cb20e088fd5ab55d3f3b885 Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Wed, 1 Oct 2025 17:36:44 -0500 Subject: [PATCH 11/54] feat(ai): Live API (#15309) Co-authored-by: Andrew Heard Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- FirebaseAI/CHANGELOG.md | 6 + FirebaseAI/Sources/AILog.swift | 13 + FirebaseAI/Sources/FirebaseAI.swift | 40 ++ FirebaseAI/Sources/GenerativeAIService.swift | 52 +-- .../Sources/Types/Internal/APIConfig.swift | 1 + .../Sources/Types/Internal/AppCheck.swift | 74 ++++ .../Sources/Types/Internal/InternalPart.swift | 9 +- .../Types/Internal/Live/AsyncWebSocket.swift | 149 +++++++ .../BidiGenerateContentClientContent.swift | 36 ++ .../BidiGenerateContentClientMessage.swift | 57 +++ .../BidiGenerateContentRealtimeInput.swift | 76 ++++ .../BidiGenerateContentServerContent.swift | 58 +++ .../BidiGenerateContentServerMessage.swift | 105 +++++ .../Live/BidiGenerateContentSetup.swift | 78 ++++ .../BidiGenerateContentSetupComplete.swift | 20 + .../Live/BidiGenerateContentToolCall.swift | 24 ++ ...iGenerateContentToolCallCancellation.swift | 27 ++ .../BidiGenerateContentToolResponse.swift | 30 ++ .../BidiGenerateContentTranscription.swift | 19 + .../Internal/Live/BidiGenerationConfig.swift | 46 ++ .../Internal/Live/BidiSpeechConfig.swift | 31 ++ .../Sources/Types/Internal/Live/GoAway.swift | 25 ++ .../Internal/Live/LiveSessionService.swift | 395 ++++++++++++++++++ .../Types/Internal/Live/VoiceConfig.swift | 74 ++++ .../Types/Internal/ProtoDuration.swift | 112 +++++ .../Live/AudioTranscriptionConfig.swift | 33 ++ .../Public/Live/LiveAudioTranscription.swift | 26 ++ .../Public/Live/LiveGenerationConfig.swift | 152 +++++++ .../Public/Live/LiveGenerativeModel.swift | 74 ++++ .../Types/Public/Live/LiveServerContent.swift | 82 ++++ .../Live/LiveServerGoingAwayNotice.swift | 33 ++ .../Types/Public/Live/LiveServerMessage.swift | 77 ++++ .../Public/Live/LiveServerToolCall.swift | 32 ++ .../Live/LiveServerToolCallCancellation.swift | 30 ++ .../Types/Public/Live/LiveSession.swift | 140 +++++++ .../Types/Public/Live/LiveSessionErrors.swift | 102 +++++ .../Types/Public/Live/SpeechConfig.swift | 47 +++ FirebaseAI/Sources/Types/Public/Part.swift | 40 +- .../Types/Public/ResponseModality.swift | 14 + 39 files changed, 2387 insertions(+), 52 deletions(-) create mode 100644 FirebaseAI/Sources/Types/Internal/AppCheck.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/AsyncWebSocket.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientContent.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientMessage.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentRealtimeInput.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerContent.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerMessage.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetup.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetupComplete.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCall.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCallCancellation.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolResponse.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentTranscription.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiGenerationConfig.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/BidiSpeechConfig.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/GoAway.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift create mode 100644 FirebaseAI/Sources/Types/Internal/Live/VoiceConfig.swift create mode 100644 FirebaseAI/Sources/Types/Internal/ProtoDuration.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/AudioTranscriptionConfig.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/LiveAudioTranscription.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/LiveGenerationConfig.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/LiveGenerativeModel.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/LiveServerContent.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/LiveServerGoingAwayNotice.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/LiveServerToolCall.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/LiveServerToolCallCancellation.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/LiveSession.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/LiveSessionErrors.swift create mode 100644 FirebaseAI/Sources/Types/Public/Live/SpeechConfig.swift diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index ce7d6468db2..f3208df09ea 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -11,6 +11,12 @@ - [fixed] Fixed a decoding error when generating images with the `gemini-2.5-flash-image-preview` model using `generateContentStream` or `sendMessageStream` with the Gemini Developer API. (#15262) +- [feature] Added support for the Live API, which allows bidirectional + communication with the model in realtime. + + To get started with the Live API, see the Firebase docs on + [Bidirectional streaming using the Gemini Live API](https://firebase.google.com/docs/ai-logic/live-api). + (#15309) # 12.2.0 - [feature] Added support for returning thought summaries, which are synthesized diff --git a/FirebaseAI/Sources/AILog.swift b/FirebaseAI/Sources/AILog.swift index 03232ff23df..460f1f3aaa8 100644 --- a/FirebaseAI/Sources/AILog.swift +++ b/FirebaseAI/Sources/AILog.swift @@ -67,12 +67,25 @@ enum AILog { case executableCodeUnrecognizedLanguage = 3016 case fallbackValueUsed = 3017 case urlMetadataUnrecognizedURLRetrievalStatus = 3018 + case liveSessionUnsupportedMessage = 3019 + case liveSessionUnsupportedMessagePayload = 3020 + case liveSessionFailedToEncodeClientMessage = 3021 + case liveSessionFailedToEncodeClientMessagePayload = 3022 + case liveSessionFailedToSendClientMessage = 3023 + case liveSessionUnexpectedResponse = 3024 + case liveSessionGoingAwaySoon = 3025 + case decodedMissingProtoDurationSuffix = 3026 + case decodedInvalidProtoDurationString = 3027 + case decodedInvalidProtoDurationSeconds = 3028 + case decodedInvalidProtoDurationNanoseconds = 3029 // SDK State Errors case generateContentResponseNoCandidates = 4000 case generateContentResponseNoText = 4001 case appCheckTokenFetchFailed = 4002 case generateContentResponseEmptyCandidates = 4003 + case invalidWebsocketURL = 4004 + case duplicateLiveSessionSetupComplete = 4005 // SDK Debugging case loadRequestStreamResponseLine = 5000 diff --git a/FirebaseAI/Sources/FirebaseAI.swift b/FirebaseAI/Sources/FirebaseAI.swift index ecd9a92077e..fdd870ecfcf 100644 --- a/FirebaseAI/Sources/FirebaseAI.swift +++ b/FirebaseAI/Sources/FirebaseAI.swift @@ -137,6 +137,46 @@ public final class FirebaseAI: Sendable { ) } + /// **[Public Preview]** Initializes a ``LiveGenerativeModel`` with the given parameters. + /// + /// > Warning: Using the Firebase AI Logic SDKs with the Gemini Live API is in Public + /// Preview, which means that the feature is not subject to any SLA or deprecation policy and + /// could change in backwards-incompatible ways. + /// + /// > Important: Only models that support the Gemini Live API (typically containing `live-*` in + /// the name) are supported. + /// + /// - Parameters: + /// - modelName: The name of the model to use, for example + /// `"gemini-live-2.5-flash-preview"`; + /// see [model versions](https://firebase.google.com/docs/ai-logic/live-api?api=dev#models-that-support-capability) + /// for a list of supported models. + /// - generationConfig: The content generation parameters your model should use. + /// - tools: A list of ``Tool`` objects that the model may use to generate the next response. + /// - toolConfig: Tool configuration for any ``Tool`` specified in the request. + /// - systemInstruction: Instructions that direct the model to behave a certain way; currently + /// only text content is supported. + /// - requestOptions: Configuration parameters for sending requests to the backend. + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) + @available(watchOS, unavailable) + public func liveModel(modelName: String, + generationConfig: LiveGenerationConfig? = nil, + tools: [Tool]? = nil, + toolConfig: ToolConfig? = nil, + systemInstruction: ModelContent? = nil, + requestOptions: RequestOptions = RequestOptions()) -> LiveGenerativeModel { + return LiveGenerativeModel( + modelResourceName: modelResourceName(modelName: modelName), + firebaseInfo: firebaseInfo, + apiConfig: apiConfig, + generationConfig: generationConfig, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + requestOptions: requestOptions + ) + } + /// Class to enable FirebaseAI to register via the Objective-C based Firebase component system /// to include FirebaseAI in the userAgent. @objc(FIRVertexAIComponent) class FirebaseVertexAIComponent: NSObject {} diff --git a/FirebaseAI/Sources/GenerativeAIService.swift b/FirebaseAI/Sources/GenerativeAIService.swift index 8056d4172b8..a17364f8cb6 100644 --- a/FirebaseAI/Sources/GenerativeAIService.swift +++ b/FirebaseAI/Sources/GenerativeAIService.swift @@ -177,7 +177,10 @@ struct GenerativeAIService { urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") if let appCheck = firebaseInfo.appCheck { - let tokenResult = try await fetchAppCheckToken(appCheck: appCheck) + let tokenResult = try await appCheck.fetchAppCheckToken( + limitedUse: firebaseInfo.useLimitedUseAppCheckTokens, + domain: "GenerativeAIService" + ) urlRequest.setValue(tokenResult.token, forHTTPHeaderField: "X-Firebase-AppCheck") if let error = tokenResult.error { AILog.error( @@ -207,53 +210,6 @@ struct GenerativeAIService { return urlRequest } - private func fetchAppCheckToken(appCheck: AppCheckInterop) async throws - -> FIRAppCheckTokenResultInterop { - if firebaseInfo.useLimitedUseAppCheckTokens { - if let token = await getLimitedUseAppCheckToken(appCheck: appCheck) { - return token - } - - let errorMessage = - "The provided App Check token provider doesn't implement getLimitedUseToken(), but requireLimitedUseTokens was enabled." - - #if Debug - fatalError(errorMessage) - #else - throw NSError( - domain: "\(Constants.baseErrorDomain).\(Self.self)", - code: AILog.MessageCode.appCheckTokenFetchFailed.rawValue, - userInfo: [NSLocalizedDescriptionKey: errorMessage] - ) - #endif - } - - return await appCheck.getToken(forcingRefresh: false) - } - - private func getLimitedUseAppCheckToken(appCheck: AppCheckInterop) async - -> FIRAppCheckTokenResultInterop? { - // At the moment, `await` doesn’t get along with Objective-C’s optional protocol methods. - await withCheckedContinuation { (continuation: CheckedContinuation< - FIRAppCheckTokenResultInterop?, - Never - >) in - guard - firebaseInfo.useLimitedUseAppCheckTokens, - // `getLimitedUseToken(completion:)` is an optional protocol method. Optional binding - // is performed to make sure `continuation` is called even if the method’s not implemented. - let limitedUseTokenClosure = appCheck.getLimitedUseToken - else { - return continuation.resume(returning: nil) - } - - limitedUseTokenClosure { tokenResult in - // The placeholder token should be used in the case of App Check error. - continuation.resume(returning: tokenResult) - } - } - } - private func httpResponse(urlResponse: URLResponse) throws -> HTTPURLResponse { // The following condition should always be true: "Whenever you make HTTP URL load requests, any // response objects you get back from the URLSession, NSURLConnection, or NSURLDownload class diff --git a/FirebaseAI/Sources/Types/Internal/APIConfig.swift b/FirebaseAI/Sources/Types/Internal/APIConfig.swift index f9c5d32c779..e854db25c8c 100644 --- a/FirebaseAI/Sources/Types/Internal/APIConfig.swift +++ b/FirebaseAI/Sources/Types/Internal/APIConfig.swift @@ -68,6 +68,7 @@ extension APIConfig { extension APIConfig.Service { /// Network addresses for generative AI API services. + // TODO: maybe remove the https:// prefix and just add it as needed? websockets use these too. enum Endpoint: String, Encodable { /// The Firebase proxy production endpoint. /// diff --git a/FirebaseAI/Sources/Types/Internal/AppCheck.swift b/FirebaseAI/Sources/Types/Internal/AppCheck.swift new file mode 100644 index 00000000000..3b6d784f636 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/AppCheck.swift @@ -0,0 +1,74 @@ +// 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 FirebaseAppCheckInterop + +/// Internal helper extension for fetching app check tokens. +/// +/// Provides a common means for fetching limited use tokens, and falling back to standard tokens +/// when it's disabled (or in debug mode). This also centrializes the error, since this method is +/// used in multiple places. +extension AppCheckInterop { + /// Fetch the appcheck token. + /// + /// - Parameters: + /// - limitedUse: Should the token be a limited-use token, or a standard token. + /// - domain: A string dictating where this method is being called from. Used in any thrown + /// errors, to avoid hard-to-parse traces. + func fetchAppCheckToken(limitedUse: Bool, + domain: String) async throws -> FIRAppCheckTokenResultInterop { + if limitedUse { + if let token = await getLimitedUseTokenAsync() { + return token + } + + let errorMessage = + "The provided App Check token provider doesn't implement getLimitedUseToken(), but requireLimitedUseTokens was enabled." + + #if Debug + fatalError(errorMessage) + #else + throw NSError( + domain: "\(Constants.baseErrorDomain).\(domain)", + code: AILog.MessageCode.appCheckTokenFetchFailed.rawValue, + userInfo: [NSLocalizedDescriptionKey: errorMessage] + ) + #endif + } + + return await getToken(forcingRefresh: false) + } + + private func getLimitedUseTokenAsync() async + -> FIRAppCheckTokenResultInterop? { + // At the moment, `await` doesn’t get along with Objective-C’s optional protocol methods. + await withCheckedContinuation { (continuation: CheckedContinuation< + FIRAppCheckTokenResultInterop?, + Never + >) in + guard + // `getLimitedUseToken(completion:)` is an optional protocol method. Optional binding + // is performed to make sure `continuation` is called even if the method’s not implemented. + let limitedUseTokenClosure = getLimitedUseToken + else { + return continuation.resume(returning: nil) + } + + limitedUseTokenClosure { tokenResult in + // The placeholder token should be used in the case of App Check error. + continuation.resume(returning: tokenResult) + } + } + } +} diff --git a/FirebaseAI/Sources/Types/Internal/InternalPart.swift b/FirebaseAI/Sources/Types/Internal/InternalPart.swift index a8afe4439c3..a9d5a2eb810 100644 --- a/FirebaseAI/Sources/Types/Internal/InternalPart.swift +++ b/FirebaseAI/Sources/Types/Internal/InternalPart.swift @@ -45,10 +45,12 @@ struct FileData: Codable, Equatable, Sendable { struct FunctionCall: Equatable, Sendable { let name: String let args: JSONObject + let id: String? - init(name: String, args: JSONObject) { + init(name: String, args: JSONObject, id: String?) { self.name = name self.args = args + self.id = id } } @@ -56,10 +58,12 @@ struct FunctionCall: Equatable, Sendable { struct FunctionResponse: Codable, Equatable, Sendable { let name: String let response: JSONObject + let id: String? - init(name: String, response: JSONObject) { + init(name: String, response: JSONObject, id: String? = nil) { self.name = name self.response = response + self.id = id } } @@ -135,6 +139,7 @@ extension FunctionCall: Codable { } else { args = JSONObject() } + id = try container.decodeIfPresent(String.self, forKey: .id) } } diff --git a/FirebaseAI/Sources/Types/Internal/Live/AsyncWebSocket.swift b/FirebaseAI/Sources/Types/Internal/Live/AsyncWebSocket.swift new file mode 100644 index 00000000000..81c1c337258 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/AsyncWebSocket.swift @@ -0,0 +1,149 @@ +// 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 +private import FirebaseCoreInternal + +/// Async API for interacting with web sockets. +/// +/// Internally, this just wraps around a `URLSessionWebSocketTask`, and provides a more async +/// friendly interface for sending and consuming data from it. +/// +/// Also surfaces a more fine-grained ``WebSocketClosedError`` for when the web socket is closed. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +final class AsyncWebSocket: Sendable { + private let webSocketTask: URLSessionWebSocketTask + private let stream: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + private let continuationFinished = UnfairLock(false) + private let closeError: UnfairLock + + init(urlSession: URLSession = GenAIURLSession.default, urlRequest: URLRequest) { + webSocketTask = urlSession.webSocketTask(with: urlRequest) + (stream, continuation) = AsyncThrowingStream + .makeStream() + closeError = UnfairLock(nil) + } + + deinit { + disconnect() + } + + /// Starts a connection to the backend, returning a stream for the websocket responses. + func connect() -> AsyncThrowingStream { + webSocketTask.resume() + closeError.withLock { $0 = nil } + startReceiving() + return stream + } + + /// Closes the websocket, if it's not already closed. + func disconnect() { + guard closeError.value() == nil else { return } + + close(code: .goingAway, reason: nil) + } + + /// Sends a message to the server, through the websocket. + /// + /// If the web socket is closed, this method will throw the error it was closed with. + func send(_ message: URLSessionWebSocketTask.Message) async throws { + if let closeError = closeError.value() { + throw closeError + } + try await webSocketTask.send(message) + } + + private func startReceiving() { + Task { + while !Task.isCancelled && self.webSocketTask.isOpen && self.closeError.value() == nil { + do { + let message = try await webSocketTask.receive() + continuation.yield(message) + } catch { + if let error = webSocketTask.error as? NSError { + close( + code: webSocketTask.closeCode, + reason: webSocketTask.closeReason, + underlyingError: error + ) + } else { + close(code: webSocketTask.closeCode, reason: webSocketTask.closeReason) + } + } + } + } + } + + private func close(code: URLSessionWebSocketTask.CloseCode, + reason: Data?, + underlyingError: Error? = nil) { + let error = WebSocketClosedError( + closeCode: code, + closeReason: reason, + underlyingError: underlyingError + ) + closeError.withLock { + $0 = error + } + + webSocketTask.cancel(with: code, reason: reason) + + continuationFinished.withLock { isFinished in + guard !isFinished else { return } + self.continuation.finish(throwing: error) + isFinished = true + } + } +} + +private extension URLSessionWebSocketTask { + var isOpen: Bool { + return closeCode == .invalid + } +} + +/// The websocket was closed. +/// +/// See the `closeReason` for why, or the `errorCode` for the corresponding +/// `URLSessionWebSocketTask.CloseCode`. +/// +/// In some cases, the `NSUnderlyingErrorKey` key may be populated with an +/// error for additional context. +struct WebSocketClosedError: Error, Sendable, CustomNSError { + let closeCode: URLSessionWebSocketTask.CloseCode + let closeReason: String + let underlyingError: Error? + + init(closeCode: URLSessionWebSocketTask.CloseCode, closeReason: Data?, + underlyingError: Error? = nil) { + self.closeCode = closeCode + self.closeReason = closeReason + .flatMap { String(data: $0, encoding: .utf8) } ?? "Unknown reason." + self.underlyingError = underlyingError + } + + var errorCode: Int { closeCode.rawValue } + + var errorUserInfo: [String: Any] { + var userInfo: [String: Any] = [ + NSLocalizedDescriptionKey: "WebSocket closed with code \(closeCode.rawValue). Reason: \(closeReason)", + ] + if let underlyingError { + userInfo[NSUnderlyingErrorKey] = underlyingError + } + return userInfo + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientContent.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientContent.swift new file mode 100644 index 00000000000..459aa258cc3 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientContent.swift @@ -0,0 +1,36 @@ +// 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 + +/// Incremental update of the current conversation delivered from the client. +/// All the content here is unconditionally appended to the conversation +/// history and used as part of the prompt to the model to generate content. +/// +/// A message here will interrupt any current model generation. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentClientContent: Encodable { + /// The content appended to the current conversation with the model. + /// + /// For single-turn queries, this is a single instance. For multi-turn + /// queries, this is a repeated field that contains conversation history and + /// latest request. + let turns: [ModelContent]? + + /// If true, indicates that the server content generation should start with + /// the currently accumulated prompt. Otherwise, the server will await + /// additional messages before starting generation. + let turnComplete: Bool? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientMessage.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientMessage.swift new file mode 100644 index 00000000000..758d75e2cc7 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentClientMessage.swift @@ -0,0 +1,57 @@ +// 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 + +/// Messages sent by the client in the BidiGenerateContent RPC call. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +enum BidiGenerateContentClientMessage { + /// Message to be sent in the first and only first client message. + case setup(BidiGenerateContentSetup) + + /// Incremental update of the current conversation delivered from the client. + case clientContent(BidiGenerateContentClientContent) + + /// User input that is sent in real time. + case realtimeInput(BidiGenerateContentRealtimeInput) + + /// Response to a `ToolCallMessage` received from the server. + case toolResponse(BidiGenerateContentToolResponse) +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +extension BidiGenerateContentClientMessage: Encodable { + enum CodingKeys: CodingKey { + case setup + case clientContent + case realtimeInput + case toolResponse + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .setup(setup): + try container.encode(setup, forKey: .setup) + case let .clientContent(clientContent): + try container.encode(clientContent, forKey: .clientContent) + case let .realtimeInput(realtimeInput): + try container.encode(realtimeInput, forKey: .realtimeInput) + case let .toolResponse(toolResponse): + try container.encode(toolResponse, forKey: .toolResponse) + } + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentRealtimeInput.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentRealtimeInput.swift new file mode 100644 index 00000000000..753a9a3fb15 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentRealtimeInput.swift @@ -0,0 +1,76 @@ +// 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 + +/// User input that is sent in real time. +/// +/// This is different from `ClientContentUpdate` in a few ways: +/// +/// - Can be sent continuously without interruption to model generation. +/// - If there is a need to mix data interleaved across the +/// `ClientContentUpdate` and the `RealtimeUpdate`, server attempts to +/// optimize for best response, but there are no guarantees. +/// - End of turn is not explicitly specified, but is rather derived from user +/// activity (for example, end of speech). +/// - Even before the end of turn, the data is processed incrementally +/// to optimize for a fast start of the response from the model. +/// - Is always assumed to be the user's input (cannot be used to populate +/// conversation history). +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentRealtimeInput: Encodable { + /// These form the realtime audio input stream. + let audio: InlineData? + + /// Indicates that the audio stream has ended, e.g. because the microphone was + /// turned off. + /// + /// This should only be sent when automatic activity detection is enabled + /// (which is the default). + /// + /// The client can reopen the stream by sending an audio message. + let audioStreamEnd: Bool? + + /// These form the realtime video input stream. + let video: InlineData? + + /// These form the realtime text input stream. + let text: String? + + /// Marks the start of user activity. + struct ActivityStart: Encodable {} + + /// Marks the start of user activity. This can only be sent if automatic + /// (i.e. server-side) activity detection is disabled. + let activityStart: ActivityStart? + + /// Marks the end of user activity. + struct ActivityEnd: Encodable {} + + /// Marks the end of user activity. This can only be sent if automatic (i.e. + /// server-side) activity detection is disabled. + let activityEnd: ActivityEnd? + + init(audio: InlineData? = nil, video: InlineData? = nil, text: String? = nil, + activityStart: ActivityStart? = nil, activityEnd: ActivityEnd? = nil, + audioStreamEnd: Bool? = nil) { + self.audio = audio + self.video = video + self.text = text + self.activityStart = activityStart + self.activityEnd = activityEnd + self.audioStreamEnd = audioStreamEnd + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerContent.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerContent.swift new file mode 100644 index 00000000000..648d7a09ed8 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerContent.swift @@ -0,0 +1,58 @@ +// 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 + +/// Incremental server update generated by the model in response to client +/// messages. +/// +/// Content is generated as quickly as possible, and not in realtime. Clients +/// may choose to buffer and play it out in realtime. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentServerContent: Decodable, Sendable { + /// The content that the model has generated as part of the current + /// conversation with the user. + let modelTurn: ModelContent? + + /// If true, indicates that the model is done generating. Generation will only + /// start in response to additional client messages. Can be set alongside + /// `content`, indicating that the `content` is the last in the turn. + let turnComplete: Bool? + + /// If true, indicates that a client message has interrupted current model + /// generation. If the client is playing out the content in realtime, this is a + /// good signal to stop and empty the current queue. If the client is playing + /// out the content in realtime, this is a good signal to stop and empty the + /// current playback queue. + let interrupted: Bool? + + /// If true, indicates that the model is done generating. + /// + /// When model is interrupted while generating there will be no + /// 'generation_complete' message in interrupted turn, it will go through + /// 'interrupted > turn_complete'. + /// + /// When model assumes realtime playback there will be delay between + /// generation_complete and turn_complete that is caused by model waiting for + /// playback to finish. + let generationComplete: Bool? + + /// Metadata specifies sources used to ground generated content. + let groundingMetadata: GroundingMetadata? + + let inputTranscription: BidiGenerateContentTranscription? + + let outputTranscription: BidiGenerateContentTranscription? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerMessage.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerMessage.swift new file mode 100644 index 00000000000..8c7c628ebdb --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentServerMessage.swift @@ -0,0 +1,105 @@ +// 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 + +/// Response message for BidiGenerateContent RPC call. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentServerMessage: Sendable { + /// The type of the message. + enum MessageType: Sendable { + /// Sent in response to a `BidiGenerateContentSetup` message from the client. + case setupComplete(BidiGenerateContentSetupComplete) + + /// Content generated by the model in response to client messages. + case serverContent(BidiGenerateContentServerContent) + + /// Request for the client to execute the `function_calls` and return the + /// responses with the matching `id`s. + case toolCall(BidiGenerateContentToolCall) + + /// Notification for the client that a previously issued + /// `ToolCallMessage` with the specified `id`s should have been not executed + /// and should be cancelled. + case toolCallCancellation(BidiGenerateContentToolCallCancellation) + + /// Server will disconnect soon. + case goAway(GoAway) + } + + /// The message type. + let messageType: MessageType + + /// Usage metadata about the response(s). + let usageMetadata: GenerateContentResponse.UsageMetadata? +} + +// MARK: - Decodable + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +extension BidiGenerateContentServerMessage: Decodable { + enum CodingKeys: String, CodingKey { + case setupComplete + case serverContent + case toolCall + case toolCallCancellation + case goAway + case usageMetadata + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let setupComplete = try container.decodeIfPresent( + BidiGenerateContentSetupComplete.self, + forKey: .setupComplete + ) { + messageType = .setupComplete(setupComplete) + } else if let serverContent = try container.decodeIfPresent( + BidiGenerateContentServerContent.self, + forKey: .serverContent + ) { + messageType = .serverContent(serverContent) + } else if let toolCall = try container.decodeIfPresent( + BidiGenerateContentToolCall.self, + forKey: .toolCall + ) { + messageType = .toolCall(toolCall) + } else if let toolCallCancellation = try container.decodeIfPresent( + BidiGenerateContentToolCallCancellation.self, + forKey: .toolCallCancellation + ) { + messageType = .toolCallCancellation(toolCallCancellation) + } else if let goAway = try container.decodeIfPresent(GoAway.self, forKey: .goAway) { + messageType = .goAway(goAway) + } else { + throw InvalidMessageTypeError() + } + + usageMetadata = try container.decodeIfPresent( + GenerateContentResponse.UsageMetadata.self, + forKey: .usageMetadata + ) + } +} + +struct InvalidMessageTypeError: Error, Sendable, CustomNSError { + public var errorUserInfo: [String: Any] { + [ + NSLocalizedDescriptionKey: "Missing server message type.", + ] + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetup.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetup.swift new file mode 100644 index 00000000000..15dc8889a0b --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetup.swift @@ -0,0 +1,78 @@ +// 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 + +/// Message to be sent in the first and only first +/// `BidiGenerateContentClientMessage`. Contains configuration that will apply +/// for the duration of the streaming RPC. +/// +/// Clients should wait for a `BidiGenerateContentSetupComplete` message before +/// sending any additional messages. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentSetup: Encodable { + /// The fully qualified name of the publisher model. + /// + /// Publisher model format: + /// `projects/{project}/locations/{location}/publishers/*/models/*` + let model: String + + /// Generation config. + let generationConfig: BidiGenerationConfig? + + /// The user provided system instructions for the model. + /// Note: only text should be used in parts and content in each part will be + /// in a separate paragraph. + let systemInstruction: ModelContent? + + /// A list of `Tools` the model may use to generate the next response. + /// + /// A `Tool` is a piece of code that enables the system to interact with + /// external systems to perform an action, or set of actions, outside of + /// knowledge and scope of the model. + let tools: [Tool]? + + let toolConfig: ToolConfig? + + /// Input transcription. The transcription is independent to the model turn + /// which means it doesn't imply any ordering between transcription and model + /// turn. + let inputAudioTranscription: BidiAudioTranscriptionConfig? + + /// Output transcription. The transcription is independent to the model turn + /// which means it doesn't imply any ordering between transcription and model + /// turn. + let outputAudioTranscription: BidiAudioTranscriptionConfig? + + init(model: String, + generationConfig: BidiGenerationConfig? = nil, + systemInstruction: ModelContent? = nil, + tools: [Tool]? = nil, + toolConfig: ToolConfig? = nil, + inputAudioTranscription: BidiAudioTranscriptionConfig? = nil, + outputAudioTranscription: BidiAudioTranscriptionConfig? = nil) { + self.model = model + self.generationConfig = generationConfig + self.systemInstruction = systemInstruction + self.tools = tools + self.toolConfig = toolConfig + self.inputAudioTranscription = inputAudioTranscription + self.outputAudioTranscription = outputAudioTranscription + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiAudioTranscriptionConfig: Encodable {} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetupComplete.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetupComplete.swift new file mode 100644 index 00000000000..54449782060 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentSetupComplete.swift @@ -0,0 +1,20 @@ +// 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 + +/// Sent in response to a `BidiGenerateContentSetup` message from the client. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentSetupComplete: Decodable, Sendable {} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCall.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCall.swift new file mode 100644 index 00000000000..4c34e6367e9 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCall.swift @@ -0,0 +1,24 @@ +// 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 + +/// Request for the client to execute the `function_calls` and return the +/// responses with the matching `id`s. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentToolCall: Decodable, Sendable { + /// The function call to be executed. + let functionCalls: [FunctionCall]? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCallCancellation.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCallCancellation.swift new file mode 100644 index 00000000000..48bc991c1fa --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolCallCancellation.swift @@ -0,0 +1,27 @@ +// 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 + +/// Notification for the client that a previously issued `ToolCallMessage` +/// with the specified `id`s should have been not executed and should be +/// cancelled. If there were side-effects to those tool calls, clients may +/// attempt to undo the tool calls. This message occurs only in cases where the +/// clients interrupt server turns. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentToolCallCancellation: Decodable, Sendable { + /// The ids of the tool calls to be cancelled. + let ids: [String]? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolResponse.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolResponse.swift new file mode 100644 index 00000000000..c9d2506895b --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentToolResponse.swift @@ -0,0 +1,30 @@ +// 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 + +/// Client generated response to a `ToolCall` received from the server. +/// Individual `FunctionResponse` objects are matched to the respective +/// `FunctionCall` objects by the `id` field. +/// +/// Note that in the unary and server-streaming GenerateContent APIs function +/// calling happens by exchanging the `Content` parts, while in the bidi +/// GenerateContent APIs function calling happens over these dedicated set of +/// messages. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentToolResponse: Encodable { + /// The response to the function calls. + let functionResponses: [FunctionResponse]? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentTranscription.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentTranscription.swift new file mode 100644 index 00000000000..652799edf9d --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerateContentTranscription.swift @@ -0,0 +1,19 @@ +// 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. + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerateContentTranscription: Decodable, Sendable { + let text: String? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiGenerationConfig.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerationConfig.swift new file mode 100644 index 00000000000..a3a3e8a9f99 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiGenerationConfig.swift @@ -0,0 +1,46 @@ +// 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 + +/// Configuration options for live content generation. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiGenerationConfig: Encodable, Sendable { + let temperature: Float? + let topP: Float? + let topK: Int? + let candidateCount: Int? + let maxOutputTokens: Int? + let presencePenalty: Float? + let frequencyPenalty: Float? + let responseModalities: [ResponseModality]? + let speechConfig: BidiSpeechConfig? + + init(temperature: Float? = nil, topP: Float? = nil, topK: Int? = nil, + candidateCount: Int? = nil, maxOutputTokens: Int? = nil, + presencePenalty: Float? = nil, frequencyPenalty: Float? = nil, + responseModalities: [ResponseModality]? = nil, + speechConfig: BidiSpeechConfig? = nil) { + self.temperature = temperature + self.topP = topP + self.topK = topK + self.candidateCount = candidateCount + self.maxOutputTokens = maxOutputTokens + self.presencePenalty = presencePenalty + self.frequencyPenalty = frequencyPenalty + self.responseModalities = responseModalities + self.speechConfig = speechConfig + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/BidiSpeechConfig.swift b/FirebaseAI/Sources/Types/Internal/Live/BidiSpeechConfig.swift new file mode 100644 index 00000000000..80e7d341ef7 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/BidiSpeechConfig.swift @@ -0,0 +1,31 @@ +// 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 + +/// Speech generation config. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct BidiSpeechConfig: Encodable, Sendable { + /// The configuration for the speaker to use. + let voiceConfig: VoiceConfig + + /// Language code (ISO 639. e.g. en-US) for the speech synthesization. + let languageCode: String? + + init(voiceConfig: VoiceConfig, languageCode: String?) { + self.voiceConfig = voiceConfig + self.languageCode = languageCode + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/GoAway.swift b/FirebaseAI/Sources/Types/Internal/Live/GoAway.swift new file mode 100644 index 00000000000..f5c858b8b45 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/GoAway.swift @@ -0,0 +1,25 @@ +// 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 + +/// Server will not be able to service client soon. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct GoAway: Decodable, Sendable { + /// The remaining time before the connection will be terminated as ABORTED. + /// The minimal time returned here is specified differently together with + /// the rate limits for a given model. + let timeLeft: ProtoDuration? +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift new file mode 100644 index 00000000000..42f8364b90f --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift @@ -0,0 +1,395 @@ +// 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 + +// TODO: remove @preconcurrency when we update to Swift 6 +// for context, see +// https://forums.swift.org/t/why-does-sending-a-sendable-value-risk-causing-data-races/73074 +@preconcurrency import FirebaseAppCheckInterop +@preconcurrency import FirebaseAuthInterop + +/// Facilitates communication with the backend for a ``LiveSession``. +/// +/// Using an actor will make it easier to adopt session resumption, as we have an isolated place for +/// mainting mutablity, which is backed by Swift concurrency implicity; allowing us to avoid various +/// edge-case issues with dead-locks and data races. +/// +/// This mainly comes into play when we don't want to block developers from sending messages while a +/// session is being reloaded. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +actor LiveSessionService { + let responses: AsyncThrowingStream + private let responseContinuation: AsyncThrowingStream + .Continuation + + // to ensure messages are sent in order, since swift actors are reentrant + private let messageQueue: AsyncStream + private let messageQueueContinuation: AsyncStream.Continuation + + let modelResourceName: String + let generationConfig: LiveGenerationConfig? + let urlSession: URLSession + let apiConfig: APIConfig + let firebaseInfo: FirebaseInfo + let requestOptions: RequestOptions + let tools: [Tool]? + let toolConfig: ToolConfig? + let systemInstruction: ModelContent? + + var webSocket: AsyncWebSocket? + + private let jsonEncoder = JSONEncoder() + private let jsonDecoder = JSONDecoder() + + /// Task that doesn't complete until the server sends a setupComplete message. + /// + /// Used to hold off on sending messages until the server is ready. + private var setupTask: Task + + /// Long running task that that wraps around the websocket, propogating messages through the + /// public stream. + private var responsesTask: Task? + + /// Long running task that consumes user messages from the ``messageQueue`` and sends them through + /// the websocket. + private var messageQueueTask: Task? + + init(modelResourceName: String, + generationConfig: LiveGenerationConfig?, + urlSession: URLSession, + apiConfig: APIConfig, + firebaseInfo: FirebaseInfo, + tools: [Tool]?, + toolConfig: ToolConfig?, + systemInstruction: ModelContent?, + requestOptions: RequestOptions) { + (responses, responseContinuation) = AsyncThrowingStream.makeStream() + (messageQueue, messageQueueContinuation) = AsyncStream.makeStream() + self.modelResourceName = modelResourceName + self.generationConfig = generationConfig + self.urlSession = urlSession + self.apiConfig = apiConfig + self.firebaseInfo = firebaseInfo + self.tools = tools + self.toolConfig = toolConfig + self.systemInstruction = systemInstruction + self.requestOptions = requestOptions + setupTask = Task {} + } + + deinit { + setupTask.cancel() + responsesTask?.cancel() + messageQueueTask?.cancel() + webSocket?.disconnect() + + webSocket = nil + responsesTask = nil + messageQueueTask = nil + } + + /// Queue a message to be sent to the model. + /// + /// If there's any issues while sending the message, details about the issue will be logged. + /// + /// Since messages are queued synchronously, they are sent in-order. + func send(_ message: BidiGenerateContentClientMessage) { + messageQueueContinuation.yield(message) + } + + /// Start a new connection to the backend. + /// + /// Seperated into its own function to make it easier to surface a way to call it seperately when + /// resuming the same session. + func connect() async throws { + close() + // we launch the setup task in a seperate task to allow us to cancel it via close + setupTask = Task { [weak self] in + // we need a continuation to surface that the setup is complete, while still allowing us to + // listen to the server + try await withCheckedThrowingContinuation { setupContinuation in + // nested task so we can use await + Task { [weak self] in + guard let self else { return } + await self.listenToServer(setupContinuation) + } + } + } + + try await setupTask.value + } + + /// Cancel any running tasks and close the websocket. + /// + /// This method is idempotent; if it's already ran once, it will effectively be a no-op. + func close() { + setupTask.cancel() + responsesTask?.cancel() + messageQueueTask?.cancel() + webSocket?.disconnect() + + webSocket = nil + responsesTask = nil + messageQueueTask = nil + } + + /// Start a fresh websocket to the backend, and listen for responses. + /// + /// Will hold off on sending any messages until the server sends a setupComplete message. + /// + /// Will also close out the old websocket and the previous long running tasks. + private func listenToServer(_ setupComplete: CheckedContinuation) async { + do { + webSocket = try await createWebsocket() + } catch { + let error = LiveSessionSetupError(underlyingError: error) + close() + setupComplete.resume(throwing: error) + return + } + + guard let webSocket else { return } + let stream = webSocket.connect() + + var resumed = false + + // remove the uncommon (and unexpected) responses from the stream, to make normal path cleaner + let dataStream = stream.compactMap { (message: URLSessionWebSocketTask.Message) -> Data? in + switch message { + case let .string(string): + AILog.error(code: .liveSessionUnexpectedResponse, "Unexpected string response: \(string)") + case let .data(data): + return data + @unknown default: + AILog.error(code: .liveSessionUnexpectedResponse, "Unknown message received: \(message)") + } + return nil + } + + do { + let setup = BidiGenerateContentSetup( + model: modelResourceName, + generationConfig: generationConfig?.bidiGenerationConfig, + systemInstruction: systemInstruction, + tools: tools, + toolConfig: toolConfig, + inputAudioTranscription: generationConfig?.inputAudioTranscription, + outputAudioTranscription: generationConfig?.outputAudioTranscription + ) + let data = try jsonEncoder.encode(BidiGenerateContentClientMessage.setup(setup)) + try await webSocket.send(.data(data)) + } catch { + let error = LiveSessionSetupError(underlyingError: error) + close() + setupComplete.resume(throwing: error) + return + } + + responsesTask = Task { + do { + for try await message in dataStream { + let response: BidiGenerateContentServerMessage + do { + response = try jsonDecoder.decode( + BidiGenerateContentServerMessage.self, + from: message + ) + } catch { + // only log the json if it wasn't a decoding error, but an unsupported message type + if error is InvalidMessageTypeError { + AILog.error( + code: .liveSessionUnsupportedMessage, + "The server sent a message that we don't currently have a mapping for." + ) + + AILog.debug( + code: .liveSessionUnsupportedMessagePayload, + message.encodeToJsonString() ?? "\(message)" + ) + } + + let error = LiveSessionUnsupportedMessageError(underlyingError: error) + // if we've already finished setting up, then only surface the error through responses + // otherwise, make the setup task error as well + if !resumed { + setupComplete.resume(throwing: error) + } + throw error + } + + if case .setupComplete = response.messageType { + if resumed { + AILog.debug( + code: .duplicateLiveSessionSetupComplete, + "Setup complete was received multiple times; this may be a bug in the model." + ) + } else { + // calling resume multiple times is an error in swift, so we catch multiple calls + // to avoid causing any issues due to model quirks + resumed = true + setupComplete.resume() + } + } else if let liveMessage = LiveServerMessage(from: response) { + if case let .goingAwayNotice(message) = liveMessage.payload { + // TODO: (b/444045023) When auto session resumption is enabled, call `connect` again + AILog.debug( + code: .liveSessionGoingAwaySoon, + "Session expires in: \(message.goAway.timeLeft?.timeInterval ?? 0)" + ) + } + + responseContinuation.yield(liveMessage) + } + } + } catch { + if let error = error as? WebSocketClosedError { + // only raise an error if the session didn't close normally (ie; the user calling close) + if error.closeCode != .goingAway { + let closureError: Error + if let error = error.underlyingError as? NSError, error.domain == NSURLErrorDomain, + error.code == NSURLErrorNetworkConnectionLost { + closureError = LiveSessionLostConnectionError(underlyingError: error) + } else { + closureError = LiveSessionUnexpectedClosureError(underlyingError: error) + } + close() + responseContinuation.finish(throwing: closureError) + } + } else { + // an error occurred outside the websocket, so it's likely not closed + close() + responseContinuation.finish(throwing: error) + } + } + } + + messageQueueTask = Task { + for await message in messageQueue { + // we don't propogate errors, since those are surfaced in the responses stream + guard let _ = try? await setupTask.value else { + break + } + + let data: Data + do { + data = try jsonEncoder.encode(message) + } catch { + AILog.error(code: .liveSessionFailedToEncodeClientMessage, error.localizedDescription) + AILog.debug( + code: .liveSessionFailedToEncodeClientMessagePayload, + String(describing: message) + ) + continue + } + + do { + try await webSocket.send(.data(data)) + } catch { + AILog.error(code: .liveSessionFailedToSendClientMessage, error.localizedDescription) + } + } + } + } + + /// Creates a websocket pointing to the backend. + /// + /// Will apply the required app check and auth headers, as the backend expects them. + private nonisolated func createWebsocket() async throws -> AsyncWebSocket { + let host = apiConfig.service.endpoint.rawValue.withoutPrefix("https://") + // TODO: (b/448722577) Set a location based on the api config + let urlString = switch apiConfig.service { + case .vertexAI: + "wss://\(host)/ws/google.firebase.vertexai.v1beta.LlmBidiService/BidiGenerateContent/locations/us-central1" + case .googleAI: + "wss://\(host)/ws/google.firebase.vertexai.v1beta.GenerativeService/BidiGenerateContent" + } + guard let url = URL(string: urlString) else { + throw NSError( + domain: "\(Constants.baseErrorDomain).\(Self.self)", + code: AILog.MessageCode.invalidWebsocketURL.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: "The live API websocket URL is not a valid URL", + ] + ) + } + var urlRequest = URLRequest(url: url) + urlRequest.timeoutInterval = requestOptions.timeout + urlRequest.setValue(firebaseInfo.apiKey, forHTTPHeaderField: "x-goog-api-key") + urlRequest.setValue( + "\(GenerativeAIService.languageTag) \(GenerativeAIService.firebaseVersionTag)", + forHTTPHeaderField: "x-goog-api-client" + ) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let appCheck = firebaseInfo.appCheck { + let tokenResult = try await appCheck.fetchAppCheckToken( + limitedUse: firebaseInfo.useLimitedUseAppCheckTokens, + domain: "LiveSessionService" + ) + urlRequest.setValue(tokenResult.token, forHTTPHeaderField: "X-Firebase-AppCheck") + if let error = tokenResult.error { + AILog.error( + code: .appCheckTokenFetchFailed, + "Failed to fetch AppCheck token. Error: \(error)" + ) + } + } + + if let auth = firebaseInfo.auth, let authToken = try await auth.getToken( + forcingRefresh: false + ) { + urlRequest.setValue("Firebase \(authToken)", forHTTPHeaderField: "Authorization") + } + + if firebaseInfo.app.isDataCollectionDefaultEnabled { + urlRequest.setValue(firebaseInfo.firebaseAppID, forHTTPHeaderField: "X-Firebase-AppId") + if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + urlRequest.setValue(appVersion, forHTTPHeaderField: "X-Firebase-AppVersion") + } + } + + return AsyncWebSocket(urlSession: urlSession, urlRequest: urlRequest) + } +} + +private extension Data { + /// Encodes this into a raw json string, with no regard to specific keys. + /// + /// Will return `nil` if this data doesn't represent a valid json object. + func encodeToJsonString() -> String? { + do { + let object = try JSONSerialization.jsonObject(with: self) + let data = try JSONSerialization.data(withJSONObject: object) + + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } +} + +private extension String { + /// Create a new string with the given prefix removed, if it's present. + /// + /// If the prefix isn't present, this string will be returned instead. + func withoutPrefix(_ prefix: String) -> String { + if let index = range(of: prefix, options: .anchored) { + return String(self[index.upperBound...]) + } else { + return self + } + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Live/VoiceConfig.swift b/FirebaseAI/Sources/Types/Internal/Live/VoiceConfig.swift new file mode 100644 index 00000000000..0e6790c03f2 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/Live/VoiceConfig.swift @@ -0,0 +1,74 @@ +// 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 + +/// Configuration for the speaker to use. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +enum VoiceConfig { + /// Configuration for the prebuilt voice to use. + case prebuiltVoiceConfig(PrebuiltVoiceConfig) + + /// Configuration for the custom voice to use. + case customVoiceConfig(CustomVoiceConfig) +} + +/// The configuration for the prebuilt speaker to use. +/// +/// Not just a string on the parent proto, because there'll likely be a lot +/// more options here. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct PrebuiltVoiceConfig: Encodable, Sendable { + /// The name of the preset voice to use. + let voiceName: String + + init(voiceName: String) { + self.voiceName = voiceName + } +} + +/// The configuration for the custom voice to use. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +struct CustomVoiceConfig: Encodable, Sendable { + /// The sample of the custom voice, in pcm16 s16e format. + let customVoiceSample: Data + + init(customVoiceSample: Data) { + self.customVoiceSample = customVoiceSample + } +} + +// MARK: - Encodable conformance + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +extension VoiceConfig: Encodable { + enum CodingKeys: CodingKey { + case prebuiltVoiceConfig + case customVoiceConfig + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .prebuiltVoiceConfig(setup): + try container.encode(setup, forKey: .prebuiltVoiceConfig) + case let .customVoiceConfig(clientContent): + try container.encode(clientContent, forKey: .customVoiceConfig) + } + } +} diff --git a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift new file mode 100644 index 00000000000..1dac21d6429 --- /dev/null +++ b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift @@ -0,0 +1,112 @@ +// Copyright 2024 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 + +/// Represents a signed, fixed-length span of time represented +/// as a count of seconds and fractions of seconds at nanosecond +/// resolution. +/// +/// This represents a +/// [`google.protobuf.duration`](https://protobuf.dev/reference/protobuf/google.protobuf/#duration). +struct ProtoDuration { + /// Signed seconds of the span of time. + /// + /// Must be from -315,576,000,000 to +315,576,000,000 inclusive. + /// + /// Note: these bounds are computed from: + /// 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years + let seconds: Int64 + + /// Signed fractions of a second at nanosecond resolution of the span of time. + /// + /// Durations less than one second are represented with a 0 + /// `seconds` field and a positive or negative `nanos` field. + /// + /// For durations of one second or more, a non-zero value for the `nanos` field must be + /// of the same sign as the `seconds` field. Must be from -999,999,999 + /// to +999,999,999 inclusive. + let nanos: Int32 + + /// Returns a `TimeInterval` representation of the `ProtoDuration`. + var timeInterval: TimeInterval { + return TimeInterval(seconds) + TimeInterval(nanos) / 1_000_000_000 + } +} + +// MARK: - Codable Conformance + +extension ProtoDuration: Decodable { + init(from decoder: any Decoder) throws { + var text = try decoder.singleValueContainer().decode(String.self) + if text.last != "s" { + AILog.warning( + code: .decodedMissingProtoDurationSuffix, + "Missing 's' at end of proto duration: \(text)." + ) + } else { + text.removeLast() + } + + let seconds: String + let nanoseconds: String + + let maybeSplit = text.split(separator: ".") + if maybeSplit.count > 2 { + AILog.warning( + code: .decodedInvalidProtoDurationString, + "Too many decimal places in proto duration (expected only 1): \(maybeSplit)." + ) + throw DecodingError.dataCorrupted(.init( + codingPath: [], + debugDescription: "Invalid proto duration string: \(text)" + )) + } + + if maybeSplit.count == 2 { + seconds = String(maybeSplit[0]) + nanoseconds = String(maybeSplit[1]) + } else { + seconds = text + nanoseconds = "0" + } + + guard let secs = Int64(seconds) else { + AILog.warning( + code: .decodedInvalidProtoDurationSeconds, + "Failed to parse the seconds to an Int64: \(seconds)." + ) + + throw DecodingError.dataCorrupted(.init( + codingPath: [], + debugDescription: "Invalid proto duration seconds: \(text)" + )) + } + + guard let nanos = Int32(nanoseconds) else { + AILog.warning( + code: .decodedInvalidProtoDurationNanoseconds, + "Failed to parse the nanoseconds to an Int32: \(nanoseconds)." + ) + + throw DecodingError.dataCorrupted(.init( + codingPath: [], + debugDescription: "Invalid proto duration nanoseconds: \(text)" + )) + } + + self.seconds = secs + self.nanos = nanos + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/AudioTranscriptionConfig.swift b/FirebaseAI/Sources/Types/Public/Live/AudioTranscriptionConfig.swift new file mode 100644 index 00000000000..365afebc5da --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/AudioTranscriptionConfig.swift @@ -0,0 +1,33 @@ +// 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. + +/// Configuration options for audio transcriptions when communicating with a model that supports the +/// Gemini Live API. +/// +/// While there are not currently any options, this will likely change in the future. For now, just +/// providing an instance of this struct will enable audio transcriptions for the corresponding +/// input or output fields. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct AudioTranscriptionConfig: Sendable { + let audioTranscriptionConfig: BidiAudioTranscriptionConfig + + init(_ audioTranscriptionConfig: BidiAudioTranscriptionConfig) { + self.audioTranscriptionConfig = audioTranscriptionConfig + } + + public init() { + self.init(BidiAudioTranscriptionConfig()) + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveAudioTranscription.swift b/FirebaseAI/Sources/Types/Public/Live/LiveAudioTranscription.swift new file mode 100644 index 00000000000..76dc112ee03 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveAudioTranscription.swift @@ -0,0 +1,26 @@ +// 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. + +/// Text transcription of some audio form during a live interaction with the model. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveAudioTranscription: Sendable { + let transcript: BidiGenerateContentTranscription + /// Text representing the model's interpretation of what the audio said. + public var text: String? { transcript.text } + + init(_ transcript: BidiGenerateContentTranscription) { + self.transcript = transcript + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveGenerationConfig.swift b/FirebaseAI/Sources/Types/Public/Live/LiveGenerationConfig.swift new file mode 100644 index 00000000000..21692f27eed --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveGenerationConfig.swift @@ -0,0 +1,152 @@ +// 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 + +/// Configuration options for live content generation. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveGenerationConfig: Sendable { + let bidiGenerationConfig: BidiGenerationConfig + let inputAudioTranscription: BidiAudioTranscriptionConfig? + let outputAudioTranscription: BidiAudioTranscriptionConfig? + + /// Creates a new ``LiveGenerationConfig`` value. + /// + /// See the + /// [Configure model parameters](https://firebase.google.com/docs/vertex-ai/model-parameters) + /// guide and the + /// [Cloud documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#generationconfig) + /// for more details. + /// + /// - Parameters: + /// - temperature:Controls the randomness of the language model's output. Higher values (for + /// example, 1.0) make the text more random and creative, while lower values (for example, + /// 0.1) make it more focused and deterministic. + /// + /// > Note: A temperature of 0 means that the highest probability tokens are always selected. + /// > In this case, responses for a given prompt are mostly deterministic, but a small amount + /// > of variation is still possible. + /// + /// > Important: The range of supported temperature values depends on the model; see the + /// > [documentation](https://firebase.google.com/docs/vertex-ai/model-parameters?platform=ios#temperature) + /// > for more details. + /// - topP: Controls diversity of generated text. Higher values (e.g., 0.9) produce more diverse + /// text, while lower values (e.g., 0.5) make the output more focused. + /// + /// The supported range is 0.0 to 1.0. + /// + /// > Important: The default `topP` value depends on the model; see the + /// > [documentation](https://firebase.google.com/docs/vertex-ai/model-parameters?platform=ios#top-p) + /// > for more details. + /// - topK: Limits the number of highest probability words the model considers when generating + /// text. For example, a topK of 40 means only the 40 most likely words are considered for the + /// next token. A higher value increases diversity, while a lower value makes the output more + /// deterministic. + /// + /// The supported range is 1 to 40. + /// + /// > Important: Support for `topK` and the default value depends on the model; see the + /// [documentation](https://firebase.google.com/docs/vertex-ai/model-parameters?platform=ios#top-k) + /// for more details. + /// - candidateCount: The number of response variations to return; defaults to 1 if not set. + /// Support for multiple candidates depends on the model; see the + /// [Cloud documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#generationconfig) + /// for more details. + /// - maxOutputTokens: Maximum number of tokens that can be generated in the response. + /// See the configure model parameters [documentation](https://firebase.google.com/docs/vertex-ai/model-parameters?platform=ios#max-output-tokens) + /// for more details. + /// - presencePenalty: Controls the likelihood of repeating the same words or phrases already + /// generated in the text. Higher values increase the penalty of repetition, resulting in more + /// diverse output. + /// + /// > Note: While both `presencePenalty` and `frequencyPenalty` discourage repetition, + /// > `presencePenalty` applies the same penalty regardless of how many times the word/phrase + /// > has already appeared, whereas `frequencyPenalty` increases the penalty for *each* + /// > repetition of a word/phrase. + /// + /// > Important: The range of supported `presencePenalty` values depends on the model; see the + /// > [Cloud documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#generationconfig) + /// > for more details + /// - frequencyPenalty: Controls the likelihood of repeating words or phrases, with the penalty + /// increasing for each repetition. Higher values increase the penalty of repetition, + /// resulting in more diverse output. + /// + /// > Note: While both `frequencyPenalty` and `presencePenalty` discourage repetition, + /// > `frequencyPenalty` increases the penalty for *each* repetition of a word/phrase, whereas + /// > `presencePenalty` applies the same penalty regardless of how many times the word/phrase + /// > has already appeared. + /// + /// > Important: The range of supported `frequencyPenalty` values depends on the model; see + /// > the + /// > [Cloud documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#generationconfig) + /// > for more details + /// - responseModalities: The data types (modalities) that may be returned in model responses. + /// + /// See the [multimodal + /// responses](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal-response-generation) + /// documentation for more details. + /// + /// > Warning: Specifying response modalities is a **Public Preview** feature, which means + /// > that it is not subject to any SLA or deprecation policy and could change in + /// > backwards-incompatible ways. + /// - speech: Controls the voice of the model, when streaming `audio` via + /// ``ResponseModality``. + /// - inputAudioTranscription: Configures (and enables) input transcriptions when streaming to + /// the model. + /// + /// Input transcripts are the model's interpretation of audio data sent to it, and they are + /// populated in model responses via ``LiveServerContent``. When this field is set to `nil`, + /// input transcripts are not populated in model responses. + /// - outputAudioTranscription: Configures (and enables) output transcriptions when streaming to + /// the model. + /// + /// Output transcripts are text representations of the audio the model is sending to the + /// client, and they are populated in model responses via ``LiveServerContent``. When this + /// field is set to `nil`, output transcripts are not populated in model responses. + /// + /// > Important: Transcripts are independent to the model turn. This means transcripts may + /// > come earlier or later than when the model sends the corresponding audio responses. + public init(temperature: Float? = nil, topP: Float? = nil, topK: Int? = nil, + candidateCount: Int? = nil, maxOutputTokens: Int? = nil, + presencePenalty: Float? = nil, frequencyPenalty: Float? = nil, + responseModalities: [ResponseModality]? = nil, + speech: SpeechConfig? = nil, + inputAudioTranscription: AudioTranscriptionConfig? = nil, + outputAudioTranscription: AudioTranscriptionConfig? = nil) { + self.init( + BidiGenerationConfig( + temperature: temperature, + topP: topP, + topK: topK, + candidateCount: candidateCount, + maxOutputTokens: maxOutputTokens, + presencePenalty: presencePenalty, + frequencyPenalty: frequencyPenalty, + responseModalities: responseModalities, + speechConfig: speech?.speechConfig + ), + inputAudioTranscription: inputAudioTranscription?.audioTranscriptionConfig, + outputAudioTranscription: outputAudioTranscription?.audioTranscriptionConfig + ) + } + + init(_ bidiGenerationConfig: BidiGenerationConfig, + inputAudioTranscription: BidiAudioTranscriptionConfig? = nil, + outputAudioTranscription: BidiAudioTranscriptionConfig? = nil) { + self.bidiGenerationConfig = bidiGenerationConfig + self.inputAudioTranscription = inputAudioTranscription + self.outputAudioTranscription = outputAudioTranscription + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveGenerativeModel.swift b/FirebaseAI/Sources/Types/Public/Live/LiveGenerativeModel.swift new file mode 100644 index 00000000000..a9168789ff3 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveGenerativeModel.swift @@ -0,0 +1,74 @@ +// 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 + +/// A multimodal model (like Gemini) capable of real-time content generation based on +/// various input types, supporting bidirectional streaming. +/// +/// You can create a new session via ``connect()``. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public final class LiveGenerativeModel { + let modelResourceName: String + let firebaseInfo: FirebaseInfo + let apiConfig: APIConfig + let generationConfig: LiveGenerationConfig? + let tools: [Tool]? + let toolConfig: ToolConfig? + let systemInstruction: ModelContent? + let urlSession: URLSession + let requestOptions: RequestOptions + + init(modelResourceName: String, + firebaseInfo: FirebaseInfo, + apiConfig: APIConfig, + generationConfig: LiveGenerationConfig? = nil, + tools: [Tool]? = nil, + toolConfig: ToolConfig? = nil, + systemInstruction: ModelContent? = nil, + urlSession: URLSession = GenAIURLSession.default, + requestOptions: RequestOptions) { + self.modelResourceName = modelResourceName + self.firebaseInfo = firebaseInfo + self.apiConfig = apiConfig + self.generationConfig = generationConfig + self.tools = tools + self.toolConfig = toolConfig + self.systemInstruction = systemInstruction + self.urlSession = urlSession + self.requestOptions = requestOptions + } + + /// Start a ``LiveSession`` with the server for bidirectional streaming. + /// + /// - Returns: A new ``LiveSession`` that you can use to stream messages to and from the server. + public func connect() async throws -> LiveSession { + let service = LiveSessionService( + modelResourceName: modelResourceName, + generationConfig: generationConfig, + urlSession: urlSession, + apiConfig: apiConfig, + firebaseInfo: firebaseInfo, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + requestOptions: requestOptions + ) + + try await service.connect() + + return LiveSession(service: service) + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerContent.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerContent.swift new file mode 100644 index 00000000000..25e29e4b891 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerContent.swift @@ -0,0 +1,82 @@ +// 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. + +/// Incremental server update generated by the model in response to client +/// messages. +/// +/// Content is generated as quickly as possible, and not in realtime. Clients +/// may choose to buffer and play it out in realtime. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveServerContent: Sendable { + let serverContent: BidiGenerateContentServerContent + + /// The content that the model has generated as part of the current + /// conversation with the user. + /// + /// This can be `nil` if the message signifies something else (such + /// as the turn ending). + public var modelTurn: ModelContent? { serverContent.modelTurn } + + /// The model has finished sending data in the current turn. + /// + /// Generation will only start in response to additional client messages. + /// + /// Can be set alongside ``modelTurn``, indicating that the content is the last in the turn. + public var isTurnComplete: Bool { serverContent.turnComplete ?? false } + + /// The model was interrupted by a client message while generating data. + /// + /// If the client is playing out the content in realtime, this is a + /// good signal to stop and empty the current queue. + public var wasInterrupted: Bool { serverContent.interrupted ?? false } + + /// The model has finished _generating_ data for the current turn. + /// + /// For realtime playback, there will be a delay between when the model finishes generating + /// content and the client has finished playing back the generated content. `generationComplete` + /// indicates that the model is done generating data, while `isTurnComplete` indicates the model + /// is waiting for additional client messages. Sending a message during this delay may cause a + /// `wasInterrupted` message to be sent. + /// + /// Note that if the model `wasInterrupted`, this will not be set. The model will go from + /// `wasInterrupted` -> `turnComplete`. + public var isGenerationComplete: Bool { serverContent.generationComplete ?? false } + + /// Metadata specifying the sources used to ground generated content. + public var groundingMetadata: GroundingMetadata? { serverContent.groundingMetadata } + + /// The model's interpretation of what the client said in an audio message. + /// + /// This field is only populated when an ``AudioTranscriptionConfig`` is provided to + /// ``LiveGenerationConfig``. + public var inputAudioTranscription: LiveAudioTranscription? { + serverContent.inputTranscription.map { LiveAudioTranscription($0) } + } + + /// Transcription matching the model's audio response. + /// + /// This field is only populated when an ``AudioTranscriptionConfig`` is provided to + /// ``LiveGenerationConfig``. + /// + /// > Important: Transcripts are independent to the model turn. This means transcripts may + /// > come earlier or later than when the model sends the corresponding audio responses. + public var outputAudioTranscription: LiveAudioTranscription? { + serverContent.outputTranscription.map { LiveAudioTranscription($0) } + } + + init(_ serverContent: BidiGenerateContentServerContent) { + self.serverContent = serverContent + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerGoingAwayNotice.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerGoingAwayNotice.swift new file mode 100644 index 00000000000..981ddf0c251 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerGoingAwayNotice.swift @@ -0,0 +1,33 @@ +// 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 + +/// Server will not be able to service client soon. +/// +/// To learn more about session limits, see the docs on [Maximum session duration](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/multimodal-live#maximum-session-duration)\. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveServerGoingAwayNotice: Sendable { + let goAway: GoAway + /// The remaining time before the connection will be terminated as ABORTED. + /// + /// The minimal time returned here is specified differently together with + /// the rate limits for a given model. + public var timeLeft: TimeInterval? { goAway.timeLeft?.timeInterval } + + init(_ goAway: GoAway) { + self.goAway = goAway + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift new file mode 100644 index 00000000000..5868efca07f --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift @@ -0,0 +1,77 @@ +// 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. + +/// Update from the server, generated from the model in response to client messages. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveServerMessage: Sendable { + let serverMessage: BidiGenerateContentServerMessage + + /// The type of message sent from the server. + public enum Payload: Sendable { + /// Content generated by the model in response to client messages. + case content(LiveServerContent) + + /// Request for the client to execute the provided functions. + case toolCall(LiveServerToolCall) + + /// Notification for the client that a previously issued ``LiveServerToolCall`` should be + /// cancelled. + case toolCallCancellation(LiveServerToolCallCancellation) + + /// Server will disconnect soon. + case goingAwayNotice(LiveServerGoingAwayNotice) + } + + /// The message sent from the server. + public let payload: Payload + + /// Metadata on the usage of the cached content. + public var usageMetadata: GenerateContentResponse.UsageMetadata? { serverMessage.usageMetadata } +} + +// MARK: - Internal parsing + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +extension LiveServerMessage { + init?(from serverMessage: BidiGenerateContentServerMessage) { + guard let payload = LiveServerMessage.Payload(from: serverMessage.messageType) else { + return nil + } + + self.serverMessage = serverMessage + self.payload = payload + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +extension LiveServerMessage.Payload { + init?(from serverMessage: BidiGenerateContentServerMessage.MessageType) { + switch serverMessage { + case .setupComplete: + // this is handled internally, and should not be surfaced to users + return nil + case let .serverContent(msg): + self = .content(LiveServerContent(msg)) + case let .toolCall(msg): + self = .toolCall(LiveServerToolCall(msg)) + case let .toolCallCancellation(msg): + self = .toolCallCancellation(LiveServerToolCallCancellation(msg)) + case let .goAway(msg): + self = .goingAwayNotice(LiveServerGoingAwayNotice(msg)) + } + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCall.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCall.swift new file mode 100644 index 00000000000..7209e312c76 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCall.swift @@ -0,0 +1,32 @@ +// 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. + +/// Request for the client to execute the provided ``functionCalls``. +/// +/// The client should return matching ``FunctionResponsePart``, where the `functionId` fields +/// correspond to individual ``FunctionCallPart``s. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +public struct LiveServerToolCall: Sendable { + let serverToolCall: BidiGenerateContentToolCall + + /// A list of ``FunctionCallPart`` to run and return responses for. + public var functionCalls: [FunctionCallPart]? { + serverToolCall.functionCalls?.map { FunctionCallPart($0) } + } + + init(_ serverToolCall: BidiGenerateContentToolCall) { + self.serverToolCall = serverToolCall + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCallCancellation.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCallCancellation.swift new file mode 100644 index 00000000000..ca7973c64b7 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCallCancellation.swift @@ -0,0 +1,30 @@ +// 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. + +/// Notification for the client to cancel a previous function call from ``LiveServerToolCall``. +/// +/// The client does not need to send ``FunctionResponsePart``s for the cancelled +/// ``FunctionCallPart``s. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveServerToolCallCancellation: Sendable { + let serverToolCallCancellation: BidiGenerateContentToolCallCancellation + /// A list of `functionId`s matching the `functionId` provided in a previous + /// ``LiveServerToolCall``, where only the provided `functionId`s should be cancelled. + public var ids: [String]? { serverToolCallCancellation.ids } + + init(_ serverToolCallCancellation: BidiGenerateContentToolCallCancellation) { + self.serverToolCallCancellation = serverToolCallCancellation + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift b/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift new file mode 100644 index 00000000000..3e5e6923a59 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift @@ -0,0 +1,140 @@ +// 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 + +/// A live WebSocket session, capable of streaming content to and from the model. +/// +/// Messages are streamed through ``responses``, and can be sent through either the dedicated +/// realtime API function (such as ``sendAudioRealtime(audio:)`` or ``sendTextRealtime(text:)``), or +/// through the incremental API (such as ``sendContent(_:turnComplete:)``). +/// +/// To create an instance of this class, see ``LiveGenerativeModel``. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +public final class LiveSession: Sendable { + private let service: LiveSessionService + + /// An asyncronous stream of messages from the server. + /// + /// These messages from the incremental updates from the model, for the current conversation. + public var responses: AsyncThrowingStream { service.responses } + + init(service: LiveSessionService) { + self.service = service + } + + /// Response to a ``LiveServerToolCall`` received from the server. + /// + /// This method is used both for the realtime API and the incremental API. + /// + /// - Parameters: + /// - responses: Client generated function results, matched to their respective + /// ``FunctionCallPart`` by the `functionId` field. + public func sendFunctionResponses(_ responses: [FunctionResponsePart]) async { + let message = BidiGenerateContentToolResponse( + functionResponses: responses.map { $0.functionResponse } + ) + await service.send(.toolResponse(message)) + } + + /// Sends an audio input stream to the model, using the realtime API. + /// + /// To learn more about audio formats, and the required state they should be provided in, see the + /// docs on + /// [Supported audio formats](https://cloud.google.com/vertex-ai/generative-ai/docs/live-api#supported-audio-formats). + /// + /// - Parameters: + /// - audio: Raw 16-bit PCM audio at 16Hz, used to update the model on the client's + /// conversation. + public func sendAudioRealtime(_ audio: Data) async { + // TODO: (b/443984790) address when we add RealtimeInputConfig support + let message = BidiGenerateContentRealtimeInput( + audio: InlineData(data: audio, mimeType: "audio/pcm") + ) + await service.send(.realtimeInput(message)) + } + + /// Sends a video input stream to the model, using the realtime API. + /// + /// - Parameters: + /// - video: Encoded video data, used to update the model on the client's conversation. + /// - format: The format that the video was encoded in (eg; `mp4`, `webm`, `wmv`, etc.,). + // TODO: (b/448671945) Make public after testing and next release + func sendVideoRealtime(_ video: Data, format: String) async { + let message = BidiGenerateContentRealtimeInput( + video: InlineData(data: video, mimeType: "video/\(format)") + ) + await service.send(.realtimeInput(message)) + } + + /// Sends a text input stream to the model, using the realtime API. + /// + /// - Parameters: + /// - text: Text content to append to the current client's conversation. + public func sendTextRealtime(_ text: String) async { + let message = BidiGenerateContentRealtimeInput(text: text) + await service.send(.realtimeInput(message)) + } + + /// Incremental update of the current conversation. + /// + /// The content is unconditionally appended to the conversation history and used as part of the + /// prompt to the model to generate content. + /// + /// Sending this message will also cause an interruption, if the server is actively generating + /// content. + /// + /// - Parameters: + /// - content: Content to append to the current conversation with the model. + /// - turnComplete: Whether the server should start generating content with the currently + /// accumulated prompt, or await additional messages before starting generation. By default, + /// the server will await additional messages. + public func sendContent(_ content: [ModelContent], turnComplete: Bool = false) async { + let message = BidiGenerateContentClientContent(turns: content, turnComplete: turnComplete) + await service.send(.clientContent(message)) + } + + /// Incremental update of the current conversation. + /// + /// The content is unconditionally appended to the conversation history and used as part of the + /// prompt to the model to generate content. + /// + /// Sending this message will also cause an interruption, if the server is actively generating + /// content. + /// + /// - Parameters: + /// - content: Content to append to the current conversation with the model (see + /// ``PartsRepresentable`` for conforming types). + /// - turnComplete: Whether the server should start generating content with the currently + /// accumulated prompt, or await additional messages before starting generation. By default, + /// the server will await additional messages. + public func sendContent(_ parts: any PartsRepresentable..., + turnComplete: Bool = false) async { + await sendContent([ModelContent(parts: parts)], turnComplete: turnComplete) + } + + /// Permanently stop the conversation with the model, and close the connection to the server + /// + /// This method will be called automatically when the ``LiveSession`` is deinitialized, but this + /// method can be called manually to explicitly end the session. + /// + /// Attempting to receive content from a closed session will cause a + /// ``LiveSessionUnexpectedClosureError`` error to be thrown. + public func close() async { + await service.close() + } + + // TODO: b(445716402) Add a start method when we support session resumption +} diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveSessionErrors.swift b/FirebaseAI/Sources/Types/Public/Live/LiveSessionErrors.swift new file mode 100644 index 00000000000..90b7ab84476 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/LiveSessionErrors.swift @@ -0,0 +1,102 @@ +// 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 + +/// The model sent a message that the SDK failed to parse. +/// +/// This may indicate that the SDK version needs updating, a model is too old for the current SDK +/// version, or that the model is just +/// not supported. +/// +/// Check the `NSUnderlyingErrorKey` entry in ``errorUserInfo`` for the error that caused this. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveSessionUnsupportedMessageError: Error, Sendable, CustomNSError { + let underlyingError: Error + + init(underlyingError: Error) { + self.underlyingError = underlyingError + } + + public var errorUserInfo: [String: Any] { + [ + NSLocalizedDescriptionKey: "Failed to parse a live message from the model. Cause: \(underlyingError.localizedDescription)", + NSUnderlyingErrorKey: underlyingError, + ] + } +} + +/// The live session was closed, because the network connection was lost. +/// +/// Check the `NSUnderlyingErrorKey` entry in ``errorUserInfo`` for the error that caused this. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveSessionLostConnectionError: Error, Sendable, CustomNSError { + let underlyingError: Error + + init(underlyingError: Error) { + self.underlyingError = underlyingError + } + + public var errorUserInfo: [String: Any] { + [ + NSLocalizedDescriptionKey: "The live session lost connection to the server. Cause: \(underlyingError.localizedDescription)", + NSUnderlyingErrorKey: underlyingError, + ] + } +} + +/// The live session was closed, but not for a reason the SDK expected. +/// +/// Check the `NSUnderlyingErrorKey` entry in ``errorUserInfo`` for the error that caused this. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveSessionUnexpectedClosureError: Error, Sendable, CustomNSError { + let underlyingError: WebSocketClosedError + + init(underlyingError: WebSocketClosedError) { + self.underlyingError = underlyingError + } + + public var errorUserInfo: [String: Any] { + [ + NSLocalizedDescriptionKey: "The live session was closed for some unexpected reason. Cause: \(underlyingError.localizedDescription)", + NSUnderlyingErrorKey: underlyingError, + ] + } +} + +/// The model refused our request to setup a live session. +/// +/// This can occur due to the model not supporting the requested response modalities, the project +/// not having access to the model, the model being invalid, or some internal error. +/// +/// Check the `NSUnderlyingErrorKey` entry in ``errorUserInfo`` for the error that caused this. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct LiveSessionSetupError: Error, Sendable, CustomNSError { + let underlyingError: Error + + init(underlyingError: Error) { + self.underlyingError = underlyingError + } + + public var errorUserInfo: [String: Any] { + [ + NSLocalizedDescriptionKey: "The model did not accept the live session request. Reason: \(underlyingError.localizedDescription)", + NSUnderlyingErrorKey: underlyingError, + ] + } +} diff --git a/FirebaseAI/Sources/Types/Public/Live/SpeechConfig.swift b/FirebaseAI/Sources/Types/Public/Live/SpeechConfig.swift new file mode 100644 index 00000000000..67f4799f6e4 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Live/SpeechConfig.swift @@ -0,0 +1,47 @@ +// 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 + +/// Configuration for controlling the voice of the model during conversation. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) +@available(watchOS, unavailable) +public struct SpeechConfig: Sendable { + let speechConfig: BidiSpeechConfig + + init(_ speechConfig: BidiSpeechConfig) { + self.speechConfig = speechConfig + } + + /// Creates a new `LiveSpeechConfig` value. + /// + /// - Parameters: + /// - voiceName: The name of the prebuilt voice to be used for the model's speech response. + /// + /// To learn more about the available voices, see the docs on + /// [Voice options](https://ai.google.dev/gemini-api/docs/speech-generation#voices)\. + /// - languageCode: ISO-639 language code to use when parsing text sent from the client, instead + /// of audio. By default, the model will attempt to detect the input language automatically. + /// + /// To learn which codes are supported, see the docs on + /// [Supported languages](https://ai.google.dev/gemini-api/docs/speech-generation#languages)\. + public init(voiceName: String, languageCode: String? = nil) { + self.init( + BidiSpeechConfig( + voiceConfig: .prebuiltVoiceConfig(.init(voiceName: voiceName)), + languageCode: languageCode + ) + ) + } +} diff --git a/FirebaseAI/Sources/Types/Public/Part.swift b/FirebaseAI/Sources/Types/Public/Part.swift index e0015901d61..8acf7b12e9a 100644 --- a/FirebaseAI/Sources/Types/Public/Part.swift +++ b/FirebaseAI/Sources/Types/Public/Part.swift @@ -147,6 +147,11 @@ public struct FunctionCallPart: Part { public var isThought: Bool { _isThought ?? false } + /// Unique id of the function call. + /// + /// If present, the returned ``FunctionResponsePart`` should have a matching `functionId` field. + public var functionId: String? { functionCall.id } + /// Constructs a new function call part. /// /// > Note: A `FunctionCallPart` is typically received from the model, rather than created @@ -156,10 +161,24 @@ public struct FunctionCallPart: Part { /// - name: The name of the function to call. /// - args: The function parameters and values. public init(name: String, args: JSONObject) { - self.init(FunctionCall(name: name, args: args), isThought: nil, thoughtSignature: nil) + self.init(FunctionCall(name: name, args: args, id: nil), isThought: nil, thoughtSignature: nil) + } + + /// Constructs a new function call part. + /// + /// > Note: A `FunctionCallPart` is typically received from the model, rather than created + /// manually. + /// + /// - Parameters: + /// - name: The name of the function to call. + /// - args: The function parameters and values. + /// - id: Unique id of the function call. If present, the returned ``FunctionResponsePart`` + /// should have a matching `id` field. + public init(name: String, args: JSONObject, id: String? = nil) { + self.init(FunctionCall(name: name, args: args, id: id), isThought: nil, thoughtSignature: nil) } - init(_ functionCall: FunctionCall, isThought: Bool?, thoughtSignature: String?) { + init(_ functionCall: FunctionCall, isThought: Bool? = nil, thoughtSignature: String? = nil) { self.functionCall = functionCall _isThought = isThought self.thoughtSignature = thoughtSignature @@ -177,6 +196,9 @@ public struct FunctionResponsePart: Part { let _isThought: Bool? let thoughtSignature: String? + /// Matching `id` for a ``FunctionCallPart``, if one was provided. + public var functionId: String? { functionResponse.id } + /// The name of the function that was called. public var name: String { functionResponse.name } @@ -196,6 +218,20 @@ public struct FunctionResponsePart: Part { ) } + /// Constructs a new `FunctionResponse`. + /// + /// - Parameters: + /// - name: The name of the function that was called. + /// - response: The function's response. + /// - functionId: Matching `functionId` for a ``FunctionCallPart``, if one was provided. + public init(name: String, response: JSONObject, functionId: String? = nil) { + self.init( + FunctionResponse(name: name, response: response, id: functionId), + isThought: nil, + thoughtSignature: nil + ) + } + init(_ functionResponse: FunctionResponse, isThought: Bool?, thoughtSignature: String?) { self.functionResponse = functionResponse _isThought = isThought diff --git a/FirebaseAI/Sources/Types/Public/ResponseModality.swift b/FirebaseAI/Sources/Types/Public/ResponseModality.swift index 442fed5f434..576046aa834 100644 --- a/FirebaseAI/Sources/Types/Public/ResponseModality.swift +++ b/FirebaseAI/Sources/Types/Public/ResponseModality.swift @@ -28,6 +28,7 @@ public struct ResponseModality: EncodableProtoEnum, Sendable { enum Kind: String { case text = "TEXT" case image = "IMAGE" + case audio = "AUDIO" } /// Specifies that the model should generate textual content. @@ -48,5 +49,18 @@ public struct ResponseModality: EncodableProtoEnum, Sendable { /// > backwards-incompatible ways. public static let image = ResponseModality(kind: .image) + /// **Public Preview**: Specifies that the model should generate audio content. + /// + /// Use this modality when you need the model to produce (spoken) audio responses based on the + /// provided input or prompts. + /// + /// > Warning: This is currently **only** supported via the + /// > [live api](https://firebase.google.com/docs/ai-logic/live-api)\. + /// > + /// > Furthermore, using the Firebase AI Logic SDKs with the Gemini Live API is in Public Preview, + /// > which means that the feature is not subject to any SLA or deprecation policy and could + /// > change in backwards-incompatible ways. + public static let audio = ResponseModality(kind: .audio) + let rawValue: String } From a935ee39ae875c89c29f1a74c81224a20612093b Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Wed, 1 Oct 2025 17:53:22 -0500 Subject: [PATCH 12/54] chore(m172): Update changelogs (#15370) --- FirebaseAI/CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index f3208df09ea..06f5f30908d 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -3,6 +3,12 @@ from provided public web URLs to inform and enhance its responses. (#15221) - [changed] Using Firebase AI Logic with the Gemini Developer API is now Generally Available (GA). - [changed] Using Firebase AI Logic with the Imagen generation APIs is now Generally Available (GA). +- [feature] Added support for the Live API, which allows bidirectional + communication with the model in realtime. + + To get started with the Live API, see the Firebase docs on + [Bidirectional streaming using the Gemini Live API](https://firebase.google.com/docs/ai-logic/live-api). + (#15309) # 12.3.0 - [feature] Added support for the Code Execution tool, which enables the model @@ -11,12 +17,6 @@ - [fixed] Fixed a decoding error when generating images with the `gemini-2.5-flash-image-preview` model using `generateContentStream` or `sendMessageStream` with the Gemini Developer API. (#15262) -- [feature] Added support for the Live API, which allows bidirectional - communication with the model in realtime. - - To get started with the Live API, see the Firebase docs on - [Bidirectional streaming using the Gemini Live API](https://firebase.google.com/docs/ai-logic/live-api). - (#15309) # 12.2.0 - [feature] Added support for returning thought summaries, which are synthesized From 7575b7774c84806c8b31121c0a3d98dc6fde552b Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:40:11 -0500 Subject: [PATCH 13/54] chore(m172): Update firestore target for release (#15374) Co-authored-by: Nick Cooke --- .github/workflows/spm.yml | 11 +++++++++++ Firestore/CHANGELOG.md | 4 ++++ Package.swift | 4 ++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spm.yml b/.github/workflows/spm.yml index f4f8c05a539..e23f4703bfd 100644 --- a/.github/workflows/spm.yml +++ b/.github/workflows/spm.yml @@ -113,6 +113,11 @@ jobs: run: scripts/setup_spm_tests.sh - name: iOS Device and Test Build run: scripts/third_party/travis/retry.sh ./scripts/build.sh Firebase-Package iOS-device spmbuildonly + - uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: spm-ios-device-${{ matrix.os }}-${{ matrix.xcode }}-xcodebuild-build.log + path: xcodebuild-build.log platforms: # Don't run on private repo unless it is a PR. @@ -148,3 +153,9 @@ jobs: run: scripts/third_party/travis/retry.sh ./scripts/build.sh version-test ${{ matrix.target }} spm - name: Analytics Build Tests run: scripts/third_party/travis/retry.sh ./scripts/build.sh analytics-import-test ${{ matrix.target }} spm + - uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: spm-platforms-${{ matrix.target }}-${{ matrix.os }}-${{ matrix.xcode }}-logs + path: xcodebuild-*.log + diff --git a/Firestore/CHANGELOG.md b/Firestore/CHANGELOG.md index 628e3afd826..573f1822684 100644 --- a/Firestore/CHANGELOG.md +++ b/Firestore/CHANGELOG.md @@ -1,3 +1,7 @@ +# 12.4.0 +- [fixed] Implemented an internal workaround to fix + [CVE-2025-0838](https://nvd.nist.gov/vuln/detail/CVE-2025-0838). (#15300) + # 12.1.0 - [fixed] Fixed accidental removal of `pod "Firebase/Firestore"` for tvOS in 12.0.0. diff --git a/Package.swift b/Package.swift index eee9fe9e212..103d380af97 100644 --- a/Package.swift +++ b/Package.swift @@ -1566,8 +1566,8 @@ func firestoreTargets() -> [Target] { } else { return .binaryTarget( name: "FirebaseFirestoreInternal", - url: "https://dl.google.com/firebase/ios/bin/firestore/12.0.0/rc0/FirebaseFirestoreInternal.zip", - checksum: "e7add08e9044ef45f7923d0b9ea5518ddc66b090d3f7e9455382f769e74c48c4" + url: "https://dl.google.com/firebase/ios/bin/firestore/12.4.0/rc0/FirebaseFirestoreInternal.zip", + checksum: "58b916624c01a56c5de694cfc9c5cc7aabcafb13b54e7bde8c83bacc51a3460d" ) } }() From 83145589349466652b3451a97491ea557db23b82 Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:03:37 -0500 Subject: [PATCH 14/54] fix(ai): Use location in websocket endpoint (#15373) Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Co-authored-by: Andrew Heard --- FirebaseAI/Sources/FirebaseAI.swift | 29 ++------ .../Sources/Types/Internal/APIConfig.swift | 4 +- .../Internal/Live/LiveSessionService.swift | 7 +- FirebaseAI/Sources/Types/Public/Backend.swift | 16 ++-- .../Tests/Utilities/InstanceConfig.swift | 53 +++++++------ .../FirebaseAI+DefaultAPIConfig.swift | 22 ++++++ .../Tests/Unit/Types/BackendTests.swift | 9 +-- .../Unit/Types/Internal/APIConfigTests.swift | 72 ++++++++++++++---- .../Tests/Unit/VertexComponentTests.swift | 74 +++++++++++-------- 9 files changed, 178 insertions(+), 108 deletions(-) create mode 100644 FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift diff --git a/FirebaseAI/Sources/FirebaseAI.swift b/FirebaseAI/Sources/FirebaseAI.swift index fdd870ecfcf..f9ff5ea0424 100644 --- a/FirebaseAI/Sources/FirebaseAI.swift +++ b/FirebaseAI/Sources/FirebaseAI.swift @@ -47,7 +47,6 @@ public final class FirebaseAI: Sendable { useLimitedUseAppCheckTokens: Bool = false) -> FirebaseAI { let instance = createInstance( app: app, - location: backend.location, apiConfig: backend.apiConfig, useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens ) @@ -188,21 +187,14 @@ public final class FirebaseAI: Sendable { let apiConfig: APIConfig - /// A map of active `FirebaseAI` instances keyed by the `FirebaseApp` name and the `location`, - /// in the format `appName:location`. + /// A map of active `FirebaseAI` instances keyed by the `FirebaseApp`, the `APIConfig`, and + /// `useLimitedUseAppCheckTokens`. private nonisolated(unsafe) static var instances: [InstanceKey: FirebaseAI] = [:] /// Lock to manage access to the `instances` array to avoid race conditions. private nonisolated(unsafe) static var instancesLock: os_unfair_lock = .init() - let location: String? - - static let defaultVertexAIAPIConfig = APIConfig( - service: .vertexAI(endpoint: .firebaseProxyProd), - version: .v1beta - ) - - static func createInstance(app: FirebaseApp?, location: String?, + static func createInstance(app: FirebaseApp?, apiConfig: APIConfig, useLimitedUseAppCheckTokens: Bool) -> FirebaseAI { guard let app = app ?? FirebaseApp.app() else { @@ -216,7 +208,6 @@ public final class FirebaseAI: Sendable { let instanceKey = InstanceKey( appName: app.name, - location: location, apiConfig: apiConfig, useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens ) @@ -225,7 +216,6 @@ public final class FirebaseAI: Sendable { } let newInstance = FirebaseAI( app: app, - location: location, apiConfig: apiConfig, useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens ) @@ -233,7 +223,7 @@ public final class FirebaseAI: Sendable { return newInstance } - init(app: FirebaseApp, location: String?, apiConfig: APIConfig, + init(app: FirebaseApp, apiConfig: APIConfig, useLimitedUseAppCheckTokens: Bool) { guard let projectID = app.options.projectID else { fatalError("The Firebase app named \"\(app.name)\" has no project ID in its configuration.") @@ -254,7 +244,6 @@ public final class FirebaseAI: Sendable { useLimitedUseAppCheckTokens: useLimitedUseAppCheckTokens ) self.apiConfig = apiConfig - self.location = location } func modelResourceName(modelName: String) -> String { @@ -268,17 +257,14 @@ public final class FirebaseAI: Sendable { } switch apiConfig.service { - case .vertexAI: - return vertexAIModelResourceName(modelName: modelName) + case let .vertexAI(endpoint: _, location: location): + return vertexAIModelResourceName(modelName: modelName, location: location) case .googleAI: return developerModelResourceName(modelName: modelName) } } - private func vertexAIModelResourceName(modelName: String) -> String { - guard let location else { - fatalError("Location must be specified for the Firebase AI service.") - } + private func vertexAIModelResourceName(modelName: String, location: String) -> String { guard !location.isEmpty && location .allSatisfy({ !$0.isWhitespace && !$0.isNewline && $0 != "/" }) else { fatalError(""" @@ -307,7 +293,6 @@ public final class FirebaseAI: Sendable { /// This type is `Hashable` so that it can be used as a key in the `instances` dictionary. private struct InstanceKey: Sendable, Hashable { let appName: String - let location: String? let apiConfig: APIConfig let useLimitedUseAppCheckTokens: Bool } diff --git a/FirebaseAI/Sources/Types/Internal/APIConfig.swift b/FirebaseAI/Sources/Types/Internal/APIConfig.swift index e854db25c8c..97a8615e98a 100644 --- a/FirebaseAI/Sources/Types/Internal/APIConfig.swift +++ b/FirebaseAI/Sources/Types/Internal/APIConfig.swift @@ -45,7 +45,7 @@ extension APIConfig { /// See the [Cloud /// docs](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference) for /// more details. - case vertexAI(endpoint: Endpoint) + case vertexAI(endpoint: Endpoint, location: String) /// The Gemini Developer API provided by Google AI. /// @@ -57,7 +57,7 @@ extension APIConfig { /// This must correspond with the API set in `service`. var endpoint: Endpoint { switch self { - case let .vertexAI(endpoint: endpoint): + case let .vertexAI(endpoint: endpoint, _): return endpoint case let .googleAI(endpoint: endpoint): return endpoint diff --git a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift index 42f8364b90f..a49e34e6671 100644 --- a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift +++ b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift @@ -309,12 +309,11 @@ actor LiveSessionService { /// Will apply the required app check and auth headers, as the backend expects them. private nonisolated func createWebsocket() async throws -> AsyncWebSocket { let host = apiConfig.service.endpoint.rawValue.withoutPrefix("https://") - // TODO: (b/448722577) Set a location based on the api config let urlString = switch apiConfig.service { - case .vertexAI: - "wss://\(host)/ws/google.firebase.vertexai.v1beta.LlmBidiService/BidiGenerateContent/locations/us-central1" + case let .vertexAI(_, location: location): + "wss://\(host)/ws/google.firebase.vertexai.\(apiConfig.version.rawValue).LlmBidiService/BidiGenerateContent/locations/\(location)" case .googleAI: - "wss://\(host)/ws/google.firebase.vertexai.v1beta.GenerativeService/BidiGenerateContent" + "wss://\(host)/ws/google.firebase.vertexai.\(apiConfig.version.rawValue).GenerativeService/BidiGenerateContent" } guard let url = URL(string: urlString) else { throw NSError( diff --git a/FirebaseAI/Sources/Types/Public/Backend.swift b/FirebaseAI/Sources/Types/Public/Backend.swift index 132f3a2cd72..b4b55699494 100644 --- a/FirebaseAI/Sources/Types/Public/Backend.swift +++ b/FirebaseAI/Sources/Types/Public/Backend.swift @@ -25,26 +25,28 @@ public struct Backend { /// for a list of supported locations. public static func vertexAI(location: String = "us-central1") -> Backend { return Backend( - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta), - location: location + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: location), + version: .v1beta + ) ) } /// Initializes a `Backend` configured for the Google Developer API. public static func googleAI() -> Backend { return Backend( - apiConfig: APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta), - location: nil + apiConfig: APIConfig( + service: .googleAI(endpoint: .firebaseProxyProd), + version: .v1beta + ) ) } // MARK: - Internal let apiConfig: APIConfig - let location: String? - init(apiConfig: APIConfig, location: String?) { + init(apiConfig: APIConfig) { self.apiConfig = apiConfig - self.location = location } } diff --git a/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift b/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift index df06f43c91f..bf9d32c6e0d 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift @@ -21,19 +21,29 @@ import Testing struct InstanceConfig: Equatable, Encodable { static let vertexAI_v1beta = InstanceConfig( - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), + version: .v1beta + ) ) static let vertexAI_v1beta_global = InstanceConfig( - location: "global", - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "global"), + version: .v1beta + ) ) static let vertexAI_v1beta_global_appCheckLimitedUse = InstanceConfig( - location: "global", useLimitedUseAppCheckTokens: true, - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "global"), + version: .v1beta + ) ) static let vertexAI_v1beta_staging = InstanceConfig( - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyStaging), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyStaging, location: "us-central1"), + version: .v1beta + ) ) static let googleAI_v1beta = InstanceConfig( apiConfig: APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta) @@ -68,12 +78,18 @@ struct InstanceConfig: Equatable, Encodable { static let vertexAI_v1beta_appCheckNotConfigured = InstanceConfig( appName: FirebaseAppNames.appCheckNotConfigured, - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), + version: .v1beta + ) ) static let vertexAI_v1beta_appCheckNotConfigured_limitedUseTokens = InstanceConfig( appName: FirebaseAppNames.appCheckNotConfigured, useLimitedUseAppCheckTokens: true, - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), + version: .v1beta + ) ) static let googleAI_v1beta_appCheckNotConfigured = InstanceConfig( appName: FirebaseAppNames.appCheckNotConfigured, @@ -93,16 +109,11 @@ struct InstanceConfig: Equatable, Encodable { ] let appName: String? - let location: String? let useLimitedUseAppCheckTokens: Bool let apiConfig: APIConfig - init(appName: String? = nil, - location: String? = nil, - useLimitedUseAppCheckTokens: Bool = false, - apiConfig: APIConfig) { + init(appName: String? = nil, useLimitedUseAppCheckTokens: Bool = false, apiConfig: APIConfig) { self.appName = appName - self.location = location self.useLimitedUseAppCheckTokens = useLimitedUseAppCheckTokens self.apiConfig = apiConfig } @@ -136,7 +147,12 @@ extension InstanceConfig: CustomTestStringConvertible { case .googleAIBypassProxy: " - Bypass Proxy" } - let locationSuffix = location.map { " - \($0)" } ?? "" + let locationSuffix: String + if case let .vertexAI(_, location: location) = apiConfig.service { + locationSuffix = location + } else { + locationSuffix = "" + } let appCheckLimitedUseDesignator = useLimitedUseAppCheckTokens ? " - FAC Limited-Use" : "" return """ @@ -150,21 +166,14 @@ extension FirebaseAI { static func componentInstance(_ instanceConfig: InstanceConfig) -> FirebaseAI { switch instanceConfig.apiConfig.service { case .vertexAI: - let location = instanceConfig.location ?? "us-central1" return FirebaseAI.createInstance( app: instanceConfig.app, - location: location, apiConfig: instanceConfig.apiConfig, useLimitedUseAppCheckTokens: instanceConfig.useLimitedUseAppCheckTokens ) case .googleAI: - assert( - instanceConfig.location == nil, - "The Developer API is global and does not support `location`." - ) return FirebaseAI.createInstance( app: instanceConfig.app, - location: nil, apiConfig: instanceConfig.apiConfig, useLimitedUseAppCheckTokens: instanceConfig.useLimitedUseAppCheckTokens ) diff --git a/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift b/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift new file mode 100644 index 00000000000..48a596f01a4 --- /dev/null +++ b/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift @@ -0,0 +1,22 @@ +// 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. + +@testable import FirebaseAI + +extension FirebaseAI { + static let defaultVertexAIAPIConfig = APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), + version: .v1beta + ) +} diff --git a/FirebaseAI/Tests/Unit/Types/BackendTests.swift b/FirebaseAI/Tests/Unit/Types/BackendTests.swift index e4e87784e68..d0fe40c7cbc 100644 --- a/FirebaseAI/Tests/Unit/Types/BackendTests.swift +++ b/FirebaseAI/Tests/Unit/Types/BackendTests.swift @@ -19,27 +19,25 @@ import XCTest final class BackendTests: XCTestCase { func testVertexAI_defaultLocation() { let expectedAPIConfig = APIConfig( - service: .vertexAI(endpoint: .firebaseProxyProd), + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), version: .v1beta ) let backend = Backend.vertexAI() XCTAssertEqual(backend.apiConfig, expectedAPIConfig) - XCTAssertEqual(backend.location, "us-central1") } func testVertexAI_customLocation() { + let customLocation = "europe-west1" let expectedAPIConfig = APIConfig( - service: .vertexAI(endpoint: .firebaseProxyProd), + service: .vertexAI(endpoint: .firebaseProxyProd, location: customLocation), version: .v1beta ) - let customLocation = "europe-west1" let backend = Backend.vertexAI(location: customLocation) XCTAssertEqual(backend.apiConfig, expectedAPIConfig) - XCTAssertEqual(backend.location, customLocation) } func testGoogleAI() { @@ -51,6 +49,5 @@ final class BackendTests: XCTestCase { let backend = Backend.googleAI() XCTAssertEqual(backend.apiConfig, expectedAPIConfig) - XCTAssertNil(backend.location) } } diff --git a/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift b/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift index fe4c290831a..937b858d40b 100644 --- a/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift @@ -18,38 +18,70 @@ import XCTest @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class APIConfigTests: XCTestCase { + let defaultLocation = "us-central1" + let globalLocation = "global" + func testInitialize_vertexAI_prod_v1() { - let apiConfig = APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1) + let apiConfig = APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: defaultLocation), + version: .v1 + ) - XCTAssertEqual(apiConfig.service.endpoint.rawValue, "https://firebasevertexai.googleapis.com") + switch apiConfig.service { + case let .vertexAI(endpoint: endpoint, location: location): + XCTAssertEqual(endpoint.rawValue, "https://firebasevertexai.googleapis.com") + XCTAssertEqual(location, defaultLocation) + case .googleAI: + XCTFail("Expected .vertexAI, got .googleAI") + } XCTAssertEqual(apiConfig.version.rawValue, "v1") } func testInitialize_vertexAI_prod_v1beta() { - let apiConfig = APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta) + let apiConfig = APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: defaultLocation), + version: .v1beta + ) - XCTAssertEqual(apiConfig.service.endpoint.rawValue, "https://firebasevertexai.googleapis.com") + switch apiConfig.service { + case let .vertexAI(endpoint: endpoint, location: location): + XCTAssertEqual(endpoint.rawValue, "https://firebasevertexai.googleapis.com") + XCTAssertEqual(location, defaultLocation) + case .googleAI: + XCTFail("Expected .vertexAI, got .googleAI") + } XCTAssertEqual(apiConfig.version.rawValue, "v1beta") } func testInitialize_vertexAI_staging_v1() { - let apiConfig = APIConfig(service: .vertexAI(endpoint: .firebaseProxyStaging), version: .v1) - - XCTAssertEqual( - apiConfig.service.endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com" + let apiConfig = APIConfig( + service: .vertexAI(endpoint: .firebaseProxyStaging, location: defaultLocation), + version: .v1 ) + + switch apiConfig.service { + case let .vertexAI(endpoint: endpoint, location: location): + XCTAssertEqual(endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com") + XCTAssertEqual(location, defaultLocation) + case .googleAI: + XCTFail("Expected .vertexAI, got .googleAI") + } XCTAssertEqual(apiConfig.version.rawValue, "v1") } func testInitialize_vertexAI_staging_v1beta() { let apiConfig = APIConfig( - service: .vertexAI(endpoint: .firebaseProxyStaging), + service: .vertexAI(endpoint: .firebaseProxyStaging, location: defaultLocation), version: .v1beta ) - XCTAssertEqual( - apiConfig.service.endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com" - ) + switch apiConfig.service { + case let .vertexAI(endpoint: endpoint, location: location): + XCTAssertEqual(endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com") + XCTAssertEqual(location, defaultLocation) + case .googleAI: + XCTFail("Expected .vertexAI, got .googleAI") + } XCTAssertEqual(apiConfig.version.rawValue, "v1beta") } @@ -58,16 +90,24 @@ final class APIConfigTests: XCTestCase { service: .googleAI(endpoint: .firebaseProxyStaging), version: .v1beta ) - XCTAssertEqual( - apiConfig.service.endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com" - ) + switch apiConfig.service { + case .vertexAI: + XCTFail("Expected .googleAI, got .vertexAI") + case let .googleAI(endpoint: endpoint): + XCTAssertEqual(endpoint.rawValue, "https://staging-firebasevertexai.sandbox.googleapis.com") + } XCTAssertEqual(apiConfig.version.rawValue, "v1beta") } func testInitialize_developer_generativeLanguage_v1beta() { let apiConfig = APIConfig(service: .googleAI(endpoint: .googleAIBypassProxy), version: .v1beta) - XCTAssertEqual(apiConfig.service.endpoint.rawValue, "https://generativelanguage.googleapis.com") + switch apiConfig.service { + case .vertexAI: + XCTFail("Expected .googleAI, got .vertexAI") + case let .googleAI(endpoint: endpoint): + XCTAssertEqual(endpoint.rawValue, "https://generativelanguage.googleapis.com") + } XCTAssertEqual(apiConfig.version.rawValue, "v1beta") } } diff --git a/FirebaseAI/Tests/Unit/VertexComponentTests.swift b/FirebaseAI/Tests/Unit/VertexComponentTests.swift index 702c6e50871..66b3ae68576 100644 --- a/FirebaseAI/Tests/Unit/VertexComponentTests.swift +++ b/FirebaseAI/Tests/Unit/VertexComponentTests.swift @@ -57,8 +57,9 @@ class VertexComponentTests: XCTestCase { XCTAssertNotNil(vertex) XCTAssertEqual(vertex.firebaseInfo.projectID, VertexComponentTests.projectID) XCTAssertEqual(vertex.firebaseInfo.apiKey, VertexComponentTests.apiKey) - XCTAssertEqual(vertex.location, "us-central1") - XCTAssertEqual(vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd)) + XCTAssertEqual( + vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1") + ) XCTAssertEqual(vertex.apiConfig.service.endpoint, .firebaseProxyProd) XCTAssertEqual(vertex.apiConfig.version, .v1beta) } @@ -71,8 +72,9 @@ class VertexComponentTests: XCTestCase { XCTAssertNotNil(vertex) XCTAssertEqual(vertex.firebaseInfo.projectID, VertexComponentTests.projectID) XCTAssertEqual(vertex.firebaseInfo.apiKey, VertexComponentTests.apiKey) - XCTAssertEqual(vertex.location, location) - XCTAssertEqual(vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd)) + XCTAssertEqual( + vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd, location: location) + ) XCTAssertEqual(vertex.apiConfig.service.endpoint, .firebaseProxyProd) XCTAssertEqual(vertex.apiConfig.version, .v1beta) } @@ -87,8 +89,9 @@ class VertexComponentTests: XCTestCase { XCTAssertNotNil(vertex) XCTAssertEqual(vertex.firebaseInfo.projectID, VertexComponentTests.projectID) XCTAssertEqual(vertex.firebaseInfo.apiKey, VertexComponentTests.apiKey) - XCTAssertEqual(vertex.location, location) - XCTAssertEqual(vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd)) + XCTAssertEqual( + vertex.apiConfig.service, .vertexAI(endpoint: .firebaseProxyProd, location: location) + ) XCTAssertEqual(vertex.apiConfig.service.endpoint, .firebaseProxyProd) XCTAssertEqual(vertex.apiConfig.version, .v1beta) } @@ -154,14 +157,17 @@ class VertexComponentTests: XCTestCase { func testSameAppAndDifferentAPI_newInstanceCreated() throws { let vertex1 = FirebaseAI.createInstance( app: VertexComponentTests.app, - location: location, - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1beta), + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: location), + version: .v1beta + ), useLimitedUseAppCheckTokens: false ) let vertex2 = FirebaseAI.createInstance( app: VertexComponentTests.app, - location: location, - apiConfig: APIConfig(service: .vertexAI(endpoint: .firebaseProxyProd), version: .v1), + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: location), version: .v1 + ), useLimitedUseAppCheckTokens: false ) @@ -182,8 +188,10 @@ class VertexComponentTests: XCTestCase { weakApp = try XCTUnwrap(app1) let vertex = FirebaseAI( app: app1, - location: "transitory location", - apiConfig: FirebaseAI.defaultVertexAIAPIConfig, + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "transitory location"), + version: .v1beta + ), useLimitedUseAppCheckTokens: false ) weakVertex = vertex @@ -195,13 +203,13 @@ class VertexComponentTests: XCTestCase { func testModelResourceName_vertexAI() throws { let app = try XCTUnwrap(VertexComponentTests.app) + let location = "test-location" let vertex = FirebaseAI.firebaseAI(app: app, backend: .vertexAI(location: location)) let model = "test-model-name" let projectID = vertex.firebaseInfo.projectID let modelResourceName = vertex.modelResourceName(modelName: model) - let location = try XCTUnwrap(vertex.location) XCTAssertEqual( modelResourceName, "projects/\(projectID)/locations/\(location)/publishers/google/models/\(model)" @@ -212,10 +220,7 @@ class VertexComponentTests: XCTestCase { let app = try XCTUnwrap(VertexComponentTests.app) let apiConfig = APIConfig(service: .googleAI(endpoint: .googleAIBypassProxy), version: .v1beta) let vertex = FirebaseAI.createInstance( - app: app, - location: nil, - apiConfig: apiConfig, - useLimitedUseAppCheckTokens: false + app: app, apiConfig: apiConfig, useLimitedUseAppCheckTokens: false ) let model = "test-model-name" @@ -231,10 +236,7 @@ class VertexComponentTests: XCTestCase { version: .v1beta ) let vertex = FirebaseAI.createInstance( - app: app, - location: nil, - apiConfig: apiConfig, - useLimitedUseAppCheckTokens: false + app: app, apiConfig: apiConfig, useLimitedUseAppCheckTokens: false ) let model = "test-model-name" let projectID = vertex.firebaseInfo.projectID @@ -244,15 +246,14 @@ class VertexComponentTests: XCTestCase { XCTAssertEqual(modelResourceName, "projects/\(projectID)/models/\(model)") } - func testGenerativeModel_vertexAI() async throws { + func testGenerativeModel_vertexAI_defaultLocation() async throws { let app = try XCTUnwrap(VertexComponentTests.app) - let vertex = FirebaseAI.firebaseAI(app: app, backend: .vertexAI(location: location)) + let vertex = FirebaseAI.firebaseAI(app: app, backend: .vertexAI()) let modelResourceName = vertex.modelResourceName(modelName: modelName) let expectedSystemInstruction = ModelContent(role: nil, parts: systemInstruction.parts) let generativeModel = vertex.generativeModel( - modelName: modelName, - systemInstruction: systemInstruction + modelName: modelName, systemInstruction: systemInstruction ) XCTAssertEqual(generativeModel.modelResourceName, modelResourceName) @@ -260,6 +261,24 @@ class VertexComponentTests: XCTestCase { XCTAssertEqual(generativeModel.apiConfig, FirebaseAI.defaultVertexAIAPIConfig) } + func testGenerativeModel_vertexAI_customLocation() async throws { + let app = try XCTUnwrap(VertexComponentTests.app) + let vertex = FirebaseAI.firebaseAI(app: app, backend: .vertexAI(location: location)) + let modelResourceName = vertex.modelResourceName(modelName: modelName) + let expectedAPIConfig = APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: location), version: .v1beta + ) + let expectedSystemInstruction = ModelContent(role: nil, parts: systemInstruction.parts) + + let generativeModel = vertex.generativeModel( + modelName: modelName, systemInstruction: systemInstruction + ) + + XCTAssertEqual(generativeModel.modelResourceName, modelResourceName) + XCTAssertEqual(generativeModel.systemInstruction, expectedSystemInstruction) + XCTAssertEqual(generativeModel.apiConfig, expectedAPIConfig) + } + func testGenerativeModel_developerAPI() async throws { let app = try XCTUnwrap(VertexComponentTests.app) let apiConfig = APIConfig( @@ -267,10 +286,7 @@ class VertexComponentTests: XCTestCase { version: .v1beta ) let vertex = FirebaseAI.createInstance( - app: app, - location: nil, - apiConfig: apiConfig, - useLimitedUseAppCheckTokens: false + app: app, apiConfig: apiConfig, useLimitedUseAppCheckTokens: false ) let modelResourceName = vertex.modelResourceName(modelName: modelName) let expectedSystemInstruction = ModelContent(role: nil, parts: systemInstruction.parts) From 42b47c6f38766f24bc6892c96375487c43f1f332 Mon Sep 17 00:00:00 2001 From: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Date: Fri, 3 Oct 2025 09:48:09 -0400 Subject: [PATCH 15/54] chore(ci): Upload build log in spm.yml job (#15376) --- .github/workflows/spm.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spm.yml b/.github/workflows/spm.yml index e23f4703bfd..eb0dba8c438 100644 --- a/.github/workflows/spm.yml +++ b/.github/workflows/spm.yml @@ -85,6 +85,12 @@ jobs: max_attempts: 3 retry_wait_seconds: 120 command: scripts/build.sh Firebase-Package iOS ${{ matrix.test }} + - uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: spm-build-run-${{ matrix.os }}-${{ matrix.xcode }}-logs + path: xcodebuild-*.log + if-no-files-found: error # Test iOS Device build since some Firestore dependencies build different files. iOS-Device: @@ -116,8 +122,9 @@ jobs: - uses: actions/upload-artifact@v4 if: ${{ failure() }} with: - name: spm-ios-device-${{ matrix.os }}-${{ matrix.xcode }}-xcodebuild-build.log - path: xcodebuild-build.log + name: spm-ios-device-${{ matrix.os }}-${{ matrix.xcode }}-logs + path: xcodebuild-*.log + if-no-files-found: error platforms: # Don't run on private repo unless it is a PR. @@ -158,4 +165,5 @@ jobs: with: name: spm-platforms-${{ matrix.target }}-${{ matrix.os }}-${{ matrix.xcode }}-logs path: xcodebuild-*.log + if-no-files-found: error From 962bb60a140e4e627aed07a41202a7bb20b434fe Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Fri, 3 Oct 2025 11:44:06 -0500 Subject: [PATCH 16/54] fix(ai): Add missing available to extension (#15378) --- .../Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift b/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift index 48a596f01a4..9e89f605f75 100644 --- a/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift +++ b/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift @@ -14,6 +14,7 @@ @testable import FirebaseAI +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension FirebaseAI { static let defaultVertexAIAPIConfig = APIConfig( service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), From d760a89a6a5f35b83aa678e7c5f59a178d2552c0 Mon Sep 17 00:00:00 2001 From: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:31:31 -0400 Subject: [PATCH 17/54] feat(ci): Add verbose option to reusable lint workflow (#15377) --- .github/workflows/common_cocoapods.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/common_cocoapods.yml b/.github/workflows/common_cocoapods.yml index a8d02d13a86..79562cab290 100644 --- a/.github/workflows/common_cocoapods.yml +++ b/.github/workflows/common_cocoapods.yml @@ -75,6 +75,12 @@ on: required: false default: true + # Whether to lint with `--verbose`. Defaults to false. + verbose: + type: boolean + required: false + default: false + # Whether to additionally build with Swift 6. Defaults to false. supports_swift6: type: boolean @@ -151,6 +157,7 @@ jobs: command: | scripts/pod_lib_lint.rb ${{ inputs.product }}.podspec --platforms=${{ matrix.platform }} \ ${{ inputs.allow_warnings == true && '--allow-warnings' || '' }} \ + ${{ inputs.verbose == true && '--verbose' || '' }} \ ${{ inputs.analyze == false && '--no-analyze' || '' }} \ ${{ inputs.test_specs != '' && format('--test-specs={0}', inputs.test_specs) || '' }} \ ${{ (contains(inputs.buildonly_platforms, matrix.platform) || contains(inputs.buildonly_platforms, 'all')) && '--skip-tests' || '' }} From 541ac342abead313f2ce0ccf33278962b5c1e43c Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:20:54 -0500 Subject: [PATCH 18/54] fix(ai): Fix error propagation during setup (#15379) --- FirebaseAI/Sources/AILog.swift | 9 +- .../Internal/Live/LiveSessionService.swift | 289 ++++++++++-------- 2 files changed, 171 insertions(+), 127 deletions(-) diff --git a/FirebaseAI/Sources/AILog.swift b/FirebaseAI/Sources/AILog.swift index 460f1f3aaa8..52b44bf7c01 100644 --- a/FirebaseAI/Sources/AILog.swift +++ b/FirebaseAI/Sources/AILog.swift @@ -74,10 +74,11 @@ enum AILog { case liveSessionFailedToSendClientMessage = 3023 case liveSessionUnexpectedResponse = 3024 case liveSessionGoingAwaySoon = 3025 - case decodedMissingProtoDurationSuffix = 3026 - case decodedInvalidProtoDurationString = 3027 - case decodedInvalidProtoDurationSeconds = 3028 - case decodedInvalidProtoDurationNanoseconds = 3029 + case liveSessionClosedDuringSetup = 3026 + case decodedMissingProtoDurationSuffix = 3027 + case decodedInvalidProtoDurationString = 3028 + case decodedInvalidProtoDurationSeconds = 3029 + case decodedInvalidProtoDurationNanoseconds = 3030 // SDK State Errors case generateContentResponseNoCandidates = 4000 diff --git a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift index a49e34e6671..b53ef3446f9 100644 --- a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift +++ b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift @@ -54,11 +54,6 @@ actor LiveSessionService { private let jsonEncoder = JSONEncoder() private let jsonDecoder = JSONDecoder() - /// Task that doesn't complete until the server sends a setupComplete message. - /// - /// Used to hold off on sending messages until the server is ready. - private var setupTask: Task - /// Long running task that that wraps around the websocket, propogating messages through the /// public stream. private var responsesTask: Task? @@ -87,11 +82,9 @@ actor LiveSessionService { self.toolConfig = toolConfig self.systemInstruction = systemInstruction self.requestOptions = requestOptions - setupTask = Task {} } deinit { - setupTask.cancel() responsesTask?.cancel() messageQueueTask?.cancel() webSocket?.disconnect() @@ -114,29 +107,20 @@ actor LiveSessionService { /// /// Seperated into its own function to make it easier to surface a way to call it seperately when /// resuming the same session. + /// + /// This function will yield until the websocket is ready to communicate with the client. func connect() async throws { close() - // we launch the setup task in a seperate task to allow us to cancel it via close - setupTask = Task { [weak self] in - // we need a continuation to surface that the setup is complete, while still allowing us to - // listen to the server - try await withCheckedThrowingContinuation { setupContinuation in - // nested task so we can use await - Task { [weak self] in - guard let self else { return } - await self.listenToServer(setupContinuation) - } - } - } - try await setupTask.value + let stream = try await setupWebsocket() + try await waitForSetupComplete(stream: stream) + spawnMessageTasks(stream: stream) } /// Cancel any running tasks and close the websocket. /// /// This method is idempotent; if it's already ran once, it will effectively be a no-op. func close() { - setupTask.cancel() responsesTask?.cancel() messageQueueTask?.cancel() webSocket?.disconnect() @@ -146,38 +130,19 @@ actor LiveSessionService { messageQueueTask = nil } - /// Start a fresh websocket to the backend, and listen for responses. + /// Performs the initial setup procedure for the model. /// - /// Will hold off on sending any messages until the server sends a setupComplete message. + /// The setup procedure with the model follows the procedure: /// - /// Will also close out the old websocket and the previous long running tasks. - private func listenToServer(_ setupComplete: CheckedContinuation) async { - do { - webSocket = try await createWebsocket() - } catch { - let error = LiveSessionSetupError(underlyingError: error) - close() - setupComplete.resume(throwing: error) - return - } - + /// - Client sends `BidiGenerateContentSetup` + /// - Server sends back `BidiGenerateContentSetupComplete` when it's ready + /// + /// This function will yield until the setup is complete. + private func waitForSetupComplete(stream: MappedStream< + URLSessionWebSocketTask.Message, + Data + >) async throws { guard let webSocket else { return } - let stream = webSocket.connect() - - var resumed = false - - // remove the uncommon (and unexpected) responses from the stream, to make normal path cleaner - let dataStream = stream.compactMap { (message: URLSessionWebSocketTask.Message) -> Data? in - switch message { - case let .string(string): - AILog.error(code: .liveSessionUnexpectedResponse, "Unexpected string response: \(string)") - case let .data(data): - return data - @unknown default: - AILog.error(code: .liveSessionUnexpectedResponse, "Unknown message received: \(message)") - } - return nil - } do { let setup = BidiGenerateContentSetup( @@ -194,54 +159,87 @@ actor LiveSessionService { } catch { let error = LiveSessionSetupError(underlyingError: error) close() - setupComplete.resume(throwing: error) - return + throw error } - responsesTask = Task { - do { - for try await message in dataStream { - let response: BidiGenerateContentServerMessage - do { - response = try jsonDecoder.decode( - BidiGenerateContentServerMessage.self, - from: message - ) - } catch { - // only log the json if it wasn't a decoding error, but an unsupported message type - if error is InvalidMessageTypeError { - AILog.error( - code: .liveSessionUnsupportedMessage, - "The server sent a message that we don't currently have a mapping for." - ) + do { + for try await message in stream { + let response = try decodeServerMessage(message) + if case .setupComplete = response.messageType { + break + } else { + AILog.error( + code: .liveSessionUnexpectedResponse, + "The model sent us a message that wasn't a setup complete: \(response)" + ) + } + } + } catch { + if let error = mapWebsocketError(error) { + close() + throw error + } + // the user called close while setup was running + // this can't currently happen, but could when we add automatic session resumption + // in such case, we don't want to raise an error. this log is more-so to catch any edge cases + AILog.debug( + code: .liveSessionClosedDuringSetup, + "The live session was closed before setup could complete: \(error.localizedDescription)" + ) + } + } - AILog.debug( - code: .liveSessionUnsupportedMessagePayload, - message.encodeToJsonString() ?? "\(message)" - ) - } + /// Performs the initial setup procedure for a websocket. + /// + /// This includes creating the websocket url and connecting it. + /// + /// - Returns: A stream of `Data` frames from the websocket. + private func setupWebsocket() async throws + -> MappedStream { + do { + let webSocket = try await createWebsocket() + self.webSocket = webSocket + + let stream = webSocket.connect() + + // remove the uncommon (and unexpected) frames from the stream, to make normal path cleaner + return stream.compactMap { message in + switch message { + case let .string(string): + AILog.error(code: .liveSessionUnexpectedResponse, "Unexpected string response: \(string)") + case let .data(data): + return data + @unknown default: + AILog.error(code: .liveSessionUnexpectedResponse, "Unknown message received: \(message)") + } + return nil + } + } catch { + let error = LiveSessionSetupError(underlyingError: error) + close() + throw error + } + } - let error = LiveSessionUnsupportedMessageError(underlyingError: error) - // if we've already finished setting up, then only surface the error through responses - // otherwise, make the setup task error as well - if !resumed { - setupComplete.resume(throwing: error) - } - throw error - } + /// Spawn tasks for interacting with the model. + /// + /// The following tasks will be spawned: + /// + /// - `responsesTask`: Listen to messages from the server and yield them through `responses`. + /// - `messageQueueTask`: Listen to messages from the client and send them through the websocket. + private func spawnMessageTasks(stream: MappedStream) { + guard let webSocket else { return } + + responsesTask = Task { + do { + for try await message in stream { + let response = try decodeServerMessage(message) if case .setupComplete = response.messageType { - if resumed { - AILog.debug( - code: .duplicateLiveSessionSetupComplete, - "Setup complete was received multiple times; this may be a bug in the model." - ) - } else { - // calling resume multiple times is an error in swift, so we catch multiple calls - // to avoid causing any issues due to model quirks - resumed = true - setupComplete.resume() - } + AILog.debug( + code: .duplicateLiveSessionSetupComplete, + "Setup complete was received multiple times; this may be a bug in the model." + ) } else if let liveMessage = LiveServerMessage(from: response) { if case let .goingAwayNotice(message) = liveMessage.payload { // TODO: (b/444045023) When auto session resumption is enabled, call `connect` again @@ -255,21 +253,7 @@ actor LiveSessionService { } } } catch { - if let error = error as? WebSocketClosedError { - // only raise an error if the session didn't close normally (ie; the user calling close) - if error.closeCode != .goingAway { - let closureError: Error - if let error = error.underlyingError as? NSError, error.domain == NSURLErrorDomain, - error.code == NSURLErrorNetworkConnectionLost { - closureError = LiveSessionLostConnectionError(underlyingError: error) - } else { - closureError = LiveSessionUnexpectedClosureError(underlyingError: error) - } - close() - responseContinuation.finish(throwing: closureError) - } - } else { - // an error occurred outside the websocket, so it's likely not closed + if let error = mapWebsocketError(error) { close() responseContinuation.finish(throwing: error) } @@ -278,22 +262,7 @@ actor LiveSessionService { messageQueueTask = Task { for await message in messageQueue { - // we don't propogate errors, since those are surfaced in the responses stream - guard let _ = try? await setupTask.value else { - break - } - - let data: Data - do { - data = try jsonEncoder.encode(message) - } catch { - AILog.error(code: .liveSessionFailedToEncodeClientMessage, error.localizedDescription) - AILog.debug( - code: .liveSessionFailedToEncodeClientMessagePayload, - String(describing: message) - ) - continue - } + guard let data = encodeClientMessage(message) else { continue } do { try await webSocket.send(.data(data)) @@ -304,6 +273,75 @@ actor LiveSessionService { } } + /// Checks if an error should be propogated up, and maps it accordingly. + /// + /// Some errors have public api alternatives. This function will ensure they're mapped + /// accordingly. + private func mapWebsocketError(_ error: Error) -> Error? { + if let error = error as? WebSocketClosedError { + // only raise an error if the session didn't close normally (ie; the user calling close) + if error.closeCode == .goingAway { + return nil + } + + let closureError: Error + + if let error = error.underlyingError as? NSError, error.domain == NSURLErrorDomain, + error.code == NSURLErrorNetworkConnectionLost { + closureError = LiveSessionLostConnectionError(underlyingError: error) + } else { + closureError = LiveSessionUnexpectedClosureError(underlyingError: error) + } + + return closureError + } + + return error + } + + /// Decodes a message from the server's websocket into a valid `BidiGenerateContentServerMessage`. + /// + /// Will throw an error if decoding fails. + private func decodeServerMessage(_ message: Data) throws -> BidiGenerateContentServerMessage { + do { + return try jsonDecoder.decode( + BidiGenerateContentServerMessage.self, + from: message + ) + } catch { + // only log the json if it wasn't a decoding error, but an unsupported message type + if error is InvalidMessageTypeError { + AILog.error( + code: .liveSessionUnsupportedMessage, + "The server sent a message that we don't currently have a mapping for." + ) + AILog.debug( + code: .liveSessionUnsupportedMessagePayload, + message.encodeToJsonString() ?? "\(message)" + ) + } + + throw LiveSessionUnsupportedMessageError(underlyingError: error) + } + } + + /// Encodes a message from the client into `Data` that can be sent through a websocket data frame. + /// + /// Will return `nil` if decoding fails, and log an error describing why. + private func encodeClientMessage(_ message: BidiGenerateContentClientMessage) -> Data? { + do { + return try jsonEncoder.encode(message) + } catch { + AILog.error(code: .liveSessionFailedToEncodeClientMessage, error.localizedDescription) + AILog.debug( + code: .liveSessionFailedToEncodeClientMessagePayload, + String(describing: message) + ) + } + + return nil + } + /// Creates a websocket pointing to the backend. /// /// Will apply the required app check and auth headers, as the backend expects them. @@ -392,3 +430,8 @@ private extension String { } } } + +/// Helper alias for a compact mapped throwing stream. +/// +/// We use this to make signatures easier to read, since we can't support `AsyncSequence` quite yet. +private typealias MappedStream = AsyncCompactMapSequence, V> From 614320c5aaa385d46f09241ac22f71287634e8aa Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 6 Oct 2025 14:37:06 -0400 Subject: [PATCH 19/54] [Firebase AI] Handle known URL Context issue in integration test (#15386) --- .../GenerateContentIntegrationTests.swift | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 747b1dc5bea..fe3d96207fc 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -433,22 +433,28 @@ struct GenerateContentIntegrationTests { modelName: ModelNames.gemini2_5_Flash, tools: [.urlContext()] ) - let prompt = """ - Write a one paragraph summary of this blog post: \ - https://developers.googleblog.com/en/introducing-gemma-3-270m/ - """ + let url = "https://developers.googleblog.com/en/introducing-gemma-3-270m/" + let prompt = "Write a one paragraph summary of this blog post: \(url)" - let response = try await model.generateContent(prompt) + // TODO(#15385): Remove `withKnownIssue` when the URL Context tool works consistently using the + // Gemini Developer API. + try await withKnownIssue(isIntermittent: true) { + let response = try await model.generateContent(prompt) - let candidate = try #require(response.candidates.first) - let urlContextMetadata = try #require(candidate.urlContextMetadata) - #expect(urlContextMetadata.urlMetadata.count == 1) - let urlMetadata = try #require(urlContextMetadata.urlMetadata.first) - let retrievedURL = try #require(urlMetadata.retrievedURL) - #expect( - retrievedURL == URL(string: "https://developers.googleblog.com/en/introducing-gemma-3-270m/") - ) - #expect(urlMetadata.retrievalStatus == .success) + let candidate = try #require(response.candidates.first) + let urlContextMetadata = try #require(candidate.urlContextMetadata) + #expect(urlContextMetadata.urlMetadata.count == 1) + let urlMetadata = try #require(urlContextMetadata.urlMetadata.first) + let retrievedURL = try #require(urlMetadata.retrievedURL) + #expect(retrievedURL == URL(string: url)) + #expect(urlMetadata.retrievalStatus == .success) + } when: { + // This issue only impacts the Gemini Developer API (Google AI), Vertex AI is unaffected. + if case .googleAI = config.apiConfig.service { + return true + } + return false + } } @Test(arguments: InstanceConfig.allConfigs) From 94b1c76d3ee96added4c14ac7c8807be3e27ef7e Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Tue, 7 Oct 2025 12:21:04 -0500 Subject: [PATCH 20/54] chore(m171): Update carthage versions (#15389) --- ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseAIBinary.json | 3 ++- ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json | 1 + .../CarthageJSON/FirebaseMLModelDownloaderBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json | 1 + 17 files changed, 18 insertions(+), 1 deletion(-) diff --git a/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json index a03e94e57a6..0518f4ca48f 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseABTesting-bb0e44f97fd81c31.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseABTesting-328b9123860fa215.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseABTesting-240f73a221798c3b.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseABTesting-453e3715e84856d3.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/ABTesting-d0fdf10c43e985b1.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/ABTesting-d0fdf10c43e985b1.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/ABTesting-a71d17cadc209af9.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAIBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAIBinary.json index e381207e117..69dc27a5d40 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAIBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAIBinary.json @@ -5,5 +5,6 @@ "12.0.0": "https://dl.google.com/dl/firebase/ios/carthage/12.0.0/FirebaseAI-05a4568076093001.zip", "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseAI-1fa7d016c66b2331.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAI-8fac222fb35cd84e.zip", - "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAI-0b9e8cce3bf315f0.zip" + "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAI-0b9e8cce3bf315f0.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAI-e465e675fb9cbb7c.zip" } diff --git a/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json index e83b6fa2484..db8beb34b3a 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseAnalytics-88dad74aa8ab040a.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAnalytics-b37787f72cdbb950.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAnalytics-866ebeb7925d0267.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAnalytics-61b0d6c9596bf37a.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Analytics-2468c231ebeb7922.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Analytics-bc8101d420b896c5.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Analytics-d2b6a6b0242db786.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json index 730f4448260..d5aeab83eb4 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseAppCheck-072a1be1f8eb1177.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAppCheck-dac2380c7e1b9898.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAppCheck-f0fb8c2a38b272c7.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAppCheck-f45ebf0c8d5ae0aa.zip", "8.0.0": "https://dl.google.com/dl/firebase/ios/carthage/8.0.0/FirebaseAppCheck-9ef1d217cf057203.zip", "8.1.0": "https://dl.google.com/dl/firebase/ios/carthage/8.1.0/FirebaseAppCheck-fc03215d9fe45d3a.zip", "8.10.0": "https://dl.google.com/dl/firebase/ios/carthage/8.10.0/FirebaseAppCheck-6ebe9e9539f06003.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json index 5bb403553da..ddc93b6342c 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseAppDistribution-370884f5f825f098.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAppDistribution-042b04483c9241b6.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAppDistribution-8498aaebd9f9e633.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAppDistribution-aa1bb9ef501d82e7.zip", "6.31.0": "https://dl.google.com/dl/firebase/ios/carthage/6.31.0/FirebaseAppDistribution-07f6a2cf7f576a8a.zip", "6.32.0": "https://dl.google.com/dl/firebase/ios/carthage/6.32.0/FirebaseAppDistribution-a9c4f5db794508ca.zip", "6.33.0": "https://dl.google.com/dl/firebase/ios/carthage/6.33.0/FirebaseAppDistribution-448a96d2ade54581.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json index a0a4b7a240c..805c8c46dc4 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseAuth-2c17100b302eb080.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAuth-9f0a14da6c12ea6d.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAuth-e4ba94c15c57a75f.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAuth-9d35d5c62a3e1a75.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Auth-0fa76ba0f7956220.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Auth-5ddd2b4351012c7a.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Auth-5e248984d78d7284.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json index 8b7ee0355b6..33d5b801e1e 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseCrashlytics-fbf241b0c59f3821.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseCrashlytics-623ce628d0404f39.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseCrashlytics-6054b7e88b91a91d.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseCrashlytics-49a8a1b1f30115df.zip", "6.15.0": "https://dl.google.com/dl/firebase/ios/carthage/6.15.0/FirebaseCrashlytics-1c6d22d5b73c84fd.zip", "6.16.0": "https://dl.google.com/dl/firebase/ios/carthage/6.16.0/FirebaseCrashlytics-938e5fd0e2eab3b3.zip", "6.17.0": "https://dl.google.com/dl/firebase/ios/carthage/6.17.0/FirebaseCrashlytics-fa09f0c8f31ed5d9.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json b/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json index 9f585bed58a..fb91f3f7fc1 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseDatabase-d6c24e13e4b05437.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseDatabase-a87ae96a7eeb2535.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseDatabase-2b6c597465ec9d34.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseDatabase-34750cd661cbd49b.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Database-1f7a820452722c7d.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Database-1f7a820452722c7d.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Database-59a12d87456b3e1c.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json b/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json index 58e18b4caaa..918da9e2292 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseFirestore-6098779ef7b7b151.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseFirestore-8d65b82dc9d53ddf.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseFirestore-e8ec00ce472204d2.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseFirestore-acab074433fa0c6f.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Firestore-68fc02c229d0cc69.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Firestore-87a804ab561d91db.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Firestore-ecb3eea7bde7e8e8.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json index 7ffdb345717..7ea7586a341 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseFunctions-f4a1c660d9a2ea75.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseFunctions-f3aa95160827b0af.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseFunctions-6d891e5b755e773c.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseFunctions-8589fb2f6bff1e38.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Functions-f4c426016dd41e38.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Functions-c6c44427c3034736.zip", "5.0.0": "https://dl.google.com/dl/firebase/ios/carthage/5.0.0/Functions-146f34c401bd459b.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json b/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json index 368c8b27372..f01c21f0379 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/GoogleSignIn-01f98c11db934294.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/GoogleSignIn-31b2e32d1dadbaa8.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/GoogleSignIn-0a9fd70d77dbb99e.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/GoogleSignIn-bcaadfe04c892ecb.zip", "6.0.0": "https://dl.google.com/dl/firebase/ios/carthage/6.0.0/GoogleSignIn-de9c5d5e8eb6d6ea.zip", "6.1.0": "https://dl.google.com/dl/firebase/ios/carthage/6.1.0/GoogleSignIn-8c82f2870573a793.zip", "6.10.0": "https://dl.google.com/dl/firebase/ios/carthage/6.10.0/GoogleSignIn-ff3aef61c4a55b05.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json index e7ed4682208..a573475105f 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseInAppMessaging-78a0d591fb574512.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseInAppMessaging-0ec7907b67ce2888.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseInAppMessaging-349edad4650cdc0e.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseInAppMessaging-9447455910a3800d.zip", "5.10.0": "https://dl.google.com/dl/firebase/ios/carthage/5.10.0/InAppMessaging-a7a3f933362f6e95.zip", "5.11.0": "https://dl.google.com/dl/firebase/ios/carthage/5.11.0/InAppMessaging-fa28ce1b88fbca93.zip", "5.12.0": "https://dl.google.com/dl/firebase/ios/carthage/5.12.0/InAppMessaging-fa28ce1b88fbca93.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json b/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json index 0da5e938b8d..422cbc6a68a 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseMLModelDownloader-3864d35f4429bc08.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseMLModelDownloader-6bfb3459ae557ef3.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseMLModelDownloader-1d7e6bff24c9b2ec.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseMLModelDownloader-2ce9f1e78f15027f.zip", "8.0.0": "https://dl.google.com/dl/firebase/ios/carthage/8.0.0/FirebaseMLModelDownloader-8f972757fb181320.zip", "8.1.0": "https://dl.google.com/dl/firebase/ios/carthage/8.1.0/FirebaseMLModelDownloader-058ad59fa6dc0111.zip", "8.10.0": "https://dl.google.com/dl/firebase/ios/carthage/8.10.0/FirebaseMLModelDownloader-286479a966d2fb37.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json index 32a1c19a979..cd36358588e 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseMessaging-252cac88c87e9c55.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseMessaging-d1ab6eaf596d9b7d.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseMessaging-2a16804f5c5602a0.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseMessaging-9175a3fe41e7c83c.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Messaging-a22ef2b5f2f30f82.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Messaging-94fa4e090c7e9185.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Messaging-2a00a1c64a19d176.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json b/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json index a6a72e117e7..b5671d1577c 100644 --- a/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebasePerformance-dec4dc5c3edadd9a.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebasePerformance-1913383f1952dce6.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebasePerformance-5e59e383ee5e57f7.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebasePerformance-79cbc1d26656ac6d.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Performance-d8693eb892bfa05b.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Performance-0a400f9460f7a71d.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Performance-f5b4002ab96523e4.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json b/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json index 80a8e71d6d0..479a4881311 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseRemoteConfig-bb5ba29a5f73cd24.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseRemoteConfig-3e803b148769baed.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseRemoteConfig-41aaab0dc398a6fc.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseRemoteConfig-5d12611a14be55c9.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/RemoteConfig-7e9635365ccd4a17.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/RemoteConfig-e7928fcb6311c439.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/RemoteConfig-9ab1ca5f360a1780.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json b/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json index 127ee0cf03e..35304914da9 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json @@ -48,6 +48,7 @@ "12.1.0": "https://dl.google.com/dl/firebase/ios/carthage/12.1.0/FirebaseStorage-faeffdccd0d44a7c.zip", "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseStorage-20489713b94790a0.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseStorage-318fa79cc514a2be.zip", + "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseStorage-e758b10b671ddad7.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Storage-6b3e77e1a7fdbc61.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Storage-4721c35d2b90a569.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Storage-821299369b9d0fb2.zip", From 9cb17fca6368d49c2df4854f8e4099073395156d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Rojas?= Date: Tue, 7 Oct 2025 14:29:23 -0600 Subject: [PATCH 21/54] Fix #14273: Prevent race condition crash in FPRTraceBackgroundActivityTracker (#15382) --- FirebasePerformance/CHANGELOG.md | 3 + .../FPRTraceBackgroundActivityTracker.m | 28 +++++--- .../FPRTraceBackgroundActivityTrackerTest.m | 65 +++++++++++++++++++ 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/FirebasePerformance/CHANGELOG.md b/FirebasePerformance/CHANGELOG.md index 3e301bf9443..159d173e1f3 100644 --- a/FirebasePerformance/CHANGELOG.md +++ b/FirebasePerformance/CHANGELOG.md @@ -1,3 +1,6 @@ +# Unreleased +- [fixed] Prevent race condition crash in FPRTraceBackgroundActivityTracker. (#14273) + # 12.3.0 - [fixed] Add missing nanopb dependency to fix SwiftPM builds when building dynamically linked libraries. (#15276) diff --git a/FirebasePerformance/Sources/AppActivity/FPRTraceBackgroundActivityTracker.m b/FirebasePerformance/Sources/AppActivity/FPRTraceBackgroundActivityTracker.m index 5c7db89c9fe..74636f198b0 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRTraceBackgroundActivityTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRTraceBackgroundActivityTracker.m @@ -23,6 +23,8 @@ @interface FPRTraceBackgroundActivityTracker () @property(nonatomic, readwrite) FPRTraceState traceBackgroundState; +- (void)registerNotificationObservers; + @end @implementation FPRTraceBackgroundActivityTracker @@ -35,21 +37,29 @@ - (instancetype)init { } else { _traceBackgroundState = FPRTraceStateForegroundOnly; } + __weak typeof(self) weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:UIApplicationDidBecomeActiveNotification - object:[UIApplication sharedApplication]]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidEnterBackground:) - name:UIApplicationDidEnterBackgroundNotification - object:[UIApplication sharedApplication]]; + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf) { + [strongSelf registerNotificationObservers]; + } }); } return self; } +- (void)registerNotificationObservers { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(applicationDidBecomeActive:) + name:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(applicationDidEnterBackground:) + name:UIApplicationDidEnterBackgroundNotification + object:[UIApplication sharedApplication]]; +} + - (void)dealloc { // Remove all the notification observers registered. [[NSNotificationCenter defaultCenter] removeObserver:self]; diff --git a/FirebasePerformance/Tests/Unit/FPRTraceBackgroundActivityTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRTraceBackgroundActivityTrackerTest.m index 272a7c418fa..334027fa3aa 100644 --- a/FirebasePerformance/Tests/Unit/FPRTraceBackgroundActivityTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRTraceBackgroundActivityTrackerTest.m @@ -61,4 +61,69 @@ - (void)testBackgroundTracking { }]; } +/** Tests that synchronous observer registration works correctly and observers are immediately + * available. */ +- (void)testObservers_synchronousRegistrationAddsObserver { + NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter]; + FPRTraceBackgroundActivityTracker *tracker = [[FPRTraceBackgroundActivityTracker alloc] init]; + XCTAssertNotNil(tracker); + + [notificationCenter postNotificationName:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]; + XCTAssertEqual(tracker.traceBackgroundState, FPRTraceStateForegroundOnly); + + tracker = nil; + XCTAssertNil(tracker); + XCTAssertNoThrow([notificationCenter postNotificationName:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]); + XCTAssertNoThrow([notificationCenter + postNotificationName:UIApplicationDidEnterBackgroundNotification + object:[UIApplication sharedApplication]]); +} + +/** Tests rapid creation and deallocation to verify race condition. */ +- (void)testRapidCreationAndDeallocation_noRaceCondition { + for (int i = 0; i < 100; i++) { + @autoreleasepool { + FPRTraceBackgroundActivityTracker *tracker = [[FPRTraceBackgroundActivityTracker alloc] init]; + XCTAssertNotNil(tracker); + + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]; + } + } + + XCTAssertNoThrow([[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]); + XCTAssertNoThrow([[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidEnterBackgroundNotification + object:[UIApplication sharedApplication]]); +} + +/** Tests observer registration when created from background thread. */ +- (void)testObservers_registrationFromBackgroundThread { + XCTestExpectation *expectation = [self expectationWithDescription:@"Background thread creation"]; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + FPRTraceBackgroundActivityTracker *tracker = [[FPRTraceBackgroundActivityTracker alloc] init]; + XCTAssertNotNil(tracker); + + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidBecomeActiveNotification + object:[UIApplication sharedApplication]]; + + XCTAssertEqual(tracker.traceBackgroundState, FPRTraceStateForegroundOnly); + [expectation fulfill]; + }); + }); + + [self waitForExpectationsWithTimeout:5.0 + handler:^(NSError *error) { + XCTAssertNil(error, @"Test timed out"); + }]; +} + @end From 2422c716b0bcb59df4ee4f75d45db53e58284c88 Mon Sep 17 00:00:00 2001 From: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:21:08 -0400 Subject: [PATCH 22/54] docs(ai logic): Add docs callout 'LiveServerMessage.Payload' enum (#15395) --- FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift index 5868efca07f..af6caca90c1 100644 --- a/FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerMessage.swift @@ -19,6 +19,10 @@ public struct LiveServerMessage: Sendable { let serverMessage: BidiGenerateContentServerMessage /// The type of message sent from the server. + /// - Important: Potential future additions to the ``Payload`` enum may not + /// trigger a semantic versioning major version update. If ensure forward + /// compatibility, client code should avoid exhaustive switch statements + /// over this enum by adding a default case. public enum Payload: Sendable { /// Content generated by the model in response to client messages. case content(LiveServerContent) From d2cfcc8a2b206c90c86402562f0cdf4d175df603 Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Wed, 8 Oct 2025 15:01:24 -0500 Subject: [PATCH 23/54] chore(m171): Update versions for Release 12.5.0 (#15392) --- Firebase.podspec | 44 +++++++++---------- FirebaseABTesting.podspec | 4 +- FirebaseAI.podspec | 10 ++--- FirebaseAnalytics.podspec | 12 ++--- FirebaseAppCheck.podspec | 6 +-- FirebaseAppCheckInterop.podspec | 2 +- FirebaseAppDistribution.podspec | 6 +-- FirebaseAuth.podspec | 10 ++--- FirebaseAuthInterop.podspec | 2 +- FirebaseCombineSwift.podspec | 14 +++--- FirebaseCore.podspec | 4 +- FirebaseCoreExtension.podspec | 4 +- FirebaseCoreInternal.podspec | 2 +- FirebaseCrashlytics.podspec | 10 ++--- FirebaseDatabase.podspec | 10 ++--- FirebaseFirestore.podspec | 10 ++--- FirebaseFirestoreInternal.podspec | 6 +-- FirebaseFunctions.podspec | 14 +++--- FirebaseInAppMessaging.podspec | 8 ++-- FirebaseInstallations.podspec | 4 +- FirebaseMLModelDownloader.podspec | 8 ++-- FirebaseMessaging.podspec | 6 +-- FirebaseMessagingInterop.podspec | 2 +- FirebasePerformance.podspec | 10 ++--- FirebaseRemoteConfig.podspec | 12 ++--- FirebaseRemoteConfigInterop.podspec | 2 +- FirebaseSessions.podspec | 8 ++-- FirebaseSharedSwift.podspec | 2 +- FirebaseStorage.podspec | 14 +++--- GoogleAppMeasurement.podspec | 8 ++-- Package.swift | 2 +- .../FirebaseManifest/FirebaseManifest.swift | 2 +- 32 files changed, 129 insertions(+), 129 deletions(-) diff --git a/Firebase.podspec b/Firebase.podspec index 8409677a944..007a3e8292c 100644 --- a/Firebase.podspec +++ b/Firebase.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Firebase' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase' s.description = <<-DESC @@ -36,14 +36,14 @@ Simplify your app development, grow your user base, and monetize more effectivel ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '15.0' - ss.ios.dependency 'FirebaseAnalytics', '~> 12.4.0' - ss.osx.dependency 'FirebaseAnalytics', '~> 12.4.0' - ss.tvos.dependency 'FirebaseAnalytics', '~> 12.4.0' + ss.ios.dependency 'FirebaseAnalytics', '~> 12.5.0' + ss.osx.dependency 'FirebaseAnalytics', '~> 12.5.0' + ss.tvos.dependency 'FirebaseAnalytics', '~> 12.5.0' ss.dependency 'Firebase/CoreOnly' end s.subspec 'CoreOnly' do |ss| - ss.dependency 'FirebaseCore', '~> 12.4.0' + ss.dependency 'FirebaseCore', '~> 12.5.0' ss.source_files = 'CoreOnly/Sources/Firebase.h' ss.preserve_paths = 'CoreOnly/Sources/module.modulemap' if ENV['FIREBASE_POD_REPO_FOR_DEV_POD'] then @@ -70,7 +70,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'ABTesting' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseABTesting', '~> 12.4.0' + ss.dependency 'FirebaseABTesting', '~> 12.5.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -80,13 +80,13 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'AppDistribution' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.ios.dependency 'FirebaseAppDistribution', '~> 12.4.0-beta' + ss.ios.dependency 'FirebaseAppDistribution', '~> 12.5.0-beta' ss.ios.deployment_target = '15.0' end s.subspec 'AppCheck' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseAppCheck', '~> 12.4.0' + ss.dependency 'FirebaseAppCheck', '~> 12.5.0' ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '15.0' @@ -95,7 +95,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Auth' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseAuth', '~> 12.4.0' + ss.dependency 'FirebaseAuth', '~> 12.5.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -105,7 +105,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Crashlytics' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseCrashlytics', '~> 12.4.0' + ss.dependency 'FirebaseCrashlytics', '~> 12.5.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -115,7 +115,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Database' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseDatabase', '~> 12.4.0' + ss.dependency 'FirebaseDatabase', '~> 12.5.0' # Standard platforms PLUS watchOS 7. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -125,7 +125,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Firestore' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseFirestore', '~> 12.4.0' + ss.dependency 'FirebaseFirestore', '~> 12.5.0' ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '15.0' @@ -133,7 +133,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Functions' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseFunctions', '~> 12.4.0' + ss.dependency 'FirebaseFunctions', '~> 12.5.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -143,20 +143,20 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'InAppMessaging' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.ios.dependency 'FirebaseInAppMessaging', '~> 12.4.0-beta' - ss.tvos.dependency 'FirebaseInAppMessaging', '~> 12.4.0-beta' + ss.ios.dependency 'FirebaseInAppMessaging', '~> 12.5.0-beta' + ss.tvos.dependency 'FirebaseInAppMessaging', '~> 12.5.0-beta' ss.ios.deployment_target = '15.0' ss.tvos.deployment_target = '15.0' end s.subspec 'Installations' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseInstallations', '~> 12.4.0' + ss.dependency 'FirebaseInstallations', '~> 12.5.0' end s.subspec 'Messaging' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseMessaging', '~> 12.4.0' + ss.dependency 'FirebaseMessaging', '~> 12.5.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -166,7 +166,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'MLModelDownloader' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseMLModelDownloader', '~> 12.4.0-beta' + ss.dependency 'FirebaseMLModelDownloader', '~> 12.5.0-beta' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -176,15 +176,15 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Performance' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.ios.dependency 'FirebasePerformance', '~> 12.4.0' - ss.tvos.dependency 'FirebasePerformance', '~> 12.4.0' + ss.ios.dependency 'FirebasePerformance', '~> 12.5.0' + ss.tvos.dependency 'FirebasePerformance', '~> 12.5.0' ss.ios.deployment_target = '15.0' ss.tvos.deployment_target = '15.0' end s.subspec 'RemoteConfig' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseRemoteConfig', '~> 12.4.0' + ss.dependency 'FirebaseRemoteConfig', '~> 12.5.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -194,7 +194,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Storage' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseStorage', '~> 12.4.0' + ss.dependency 'FirebaseStorage', '~> 12.5.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' diff --git a/FirebaseABTesting.podspec b/FirebaseABTesting.podspec index 6e7eb0112e0..28fd2ac4e79 100644 --- a/FirebaseABTesting.podspec +++ b/FirebaseABTesting.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseABTesting' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase ABTesting' s.description = <<-DESC @@ -51,7 +51,7 @@ Firebase Cloud Messaging and Firebase Remote Config in your app. s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' } - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' s.test_spec 'unit' do |unit_tests| unit_tests.scheme = { :code_coverage => true } diff --git a/FirebaseAI.podspec b/FirebaseAI.podspec index 00e087a75ce..39ebbd0e40e 100644 --- a/FirebaseAI.podspec +++ b/FirebaseAI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAI' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase AI SDK' s.description = <<-DESC @@ -43,10 +43,10 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI SDK s.tvos.framework = 'UIKit' s.watchos.framework = 'WatchKit' - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseAuthInterop', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' + s.dependency 'FirebaseAuthInterop', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCoreExtension', '~> 12.5.0' s.test_spec 'unit' do |unit_tests| unit_tests_dir = 'FirebaseAI/Tests/Unit/' diff --git a/FirebaseAnalytics.podspec b/FirebaseAnalytics.podspec index 356649ad3c7..ddf1b198d37 100644 --- a/FirebaseAnalytics.podspec +++ b/FirebaseAnalytics.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAnalytics' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase Analytics for iOS' s.description = <<-DESC @@ -26,8 +26,8 @@ Pod::Spec.new do |s| s.libraries = 'c++', 'sqlite3', 'z' s.frameworks = 'StoreKit' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseInstallations', '~> 12.5.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/MethodSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/NSData+zlib', '~> 8.1' @@ -37,17 +37,17 @@ Pod::Spec.new do |s| s.default_subspecs = 'Default' s.subspec 'Default' do |ss| - ss.dependency 'GoogleAppMeasurement/Default', '12.4.0' + ss.dependency 'GoogleAppMeasurement/Default', '12.5.0' ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework' end s.subspec 'Core' do |ss| - ss.dependency 'GoogleAppMeasurement/Core', '12.4.0' + ss.dependency 'GoogleAppMeasurement/Core', '12.5.0' ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework' end s.subspec 'IdentitySupport' do |ss| - ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.4.0' + ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.5.0' ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework' end diff --git a/FirebaseAppCheck.podspec b/FirebaseAppCheck.podspec index 41996f1d2e7..e98c373f7cf 100644 --- a/FirebaseAppCheck.podspec +++ b/FirebaseAppCheck.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppCheck' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase App Check SDK.' s.description = <<-DESC @@ -45,8 +45,8 @@ Pod::Spec.new do |s| s.tvos.weak_framework = 'DeviceCheck' s.dependency 'AppCheckCore', '~> 11.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.5.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' diff --git a/FirebaseAppCheckInterop.podspec b/FirebaseAppCheckInterop.podspec index 86601279a4d..64a4be29441 100644 --- a/FirebaseAppCheckInterop.podspec +++ b/FirebaseAppCheckInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppCheckInterop' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Interfaces that allow other Firebase SDKs to use AppCheck functionality.' s.description = <<-DESC diff --git a/FirebaseAppDistribution.podspec b/FirebaseAppDistribution.podspec index 0c94ff6b98c..aac4c303906 100644 --- a/FirebaseAppDistribution.podspec +++ b/FirebaseAppDistribution.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppDistribution' - s.version = '12.4.0-beta' + s.version = '12.5.0-beta' s.summary = 'App Distribution for Firebase iOS SDK.' s.description = <<-DESC @@ -30,10 +30,10 @@ iOS SDK for App Distribution for Firebase. ] s.public_header_files = base_dir + 'Public/FirebaseAppDistribution/*.h' - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' - s.dependency 'FirebaseInstallations', '~> 12.4.0' + s.dependency 'FirebaseInstallations', '~> 12.5.0' s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' diff --git a/FirebaseAuth.podspec b/FirebaseAuth.podspec index 4b2a81768ea..3c55d98785e 100644 --- a/FirebaseAuth.podspec +++ b/FirebaseAuth.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAuth' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Apple platform client for Firebase Authentication' s.description = <<-DESC @@ -55,10 +55,10 @@ supports email and password accounts, as well as several 3rd party authenticatio } s.framework = 'Security' s.ios.framework = 'SafariServices' - s.dependency 'FirebaseAuthInterop', '~> 12.4.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' + s.dependency 'FirebaseAuthInterop', '~> 12.5.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCoreExtension', '~> 12.5.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GTMSessionFetcher/Core', '>= 3.4', '< 6.0' diff --git a/FirebaseAuthInterop.podspec b/FirebaseAuthInterop.podspec index e1525e77ccc..73bfcff722a 100644 --- a/FirebaseAuthInterop.podspec +++ b/FirebaseAuthInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAuthInterop' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Interfaces that allow other Firebase SDKs to use Auth functionality.' s.description = <<-DESC diff --git a/FirebaseCombineSwift.podspec b/FirebaseCombineSwift.podspec index 6c46db9ae8e..427db3a7508 100644 --- a/FirebaseCombineSwift.podspec +++ b/FirebaseCombineSwift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCombineSwift' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Swift extensions with Combine support for Firebase' s.description = <<-DESC @@ -51,11 +51,11 @@ for internal testing only. It should not be published. s.osx.framework = 'AppKit' s.tvos.framework = 'UIKit' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseAuth', '~> 12.4.0' - s.dependency 'FirebaseFunctions', '~> 12.4.0' - s.dependency 'FirebaseFirestore', '~> 12.4.0' - s.dependency 'FirebaseStorage', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseAuth', '~> 12.5.0' + s.dependency 'FirebaseFunctions', '~> 12.5.0' + s.dependency 'FirebaseFirestore', '~> 12.5.0' + s.dependency 'FirebaseStorage', '~> 12.5.0' s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"', @@ -104,6 +104,6 @@ for internal testing only. It should not be published. int_tests.resources = 'FirebaseStorage/Tests/Integration/Resources/1mb.dat', 'FirebaseStorage/Tests/Integration/Resources/GoogleService-Info.plist', 'FirebaseStorage/Tests/Integration/Resources/HomeImprovement.numbers' - int_tests.dependency 'FirebaseAuth', '~> 12.4.0' + int_tests.dependency 'FirebaseAuth', '~> 12.5.0' end end diff --git a/FirebaseCore.podspec b/FirebaseCore.podspec index fb5e5594da4..edec150ad24 100644 --- a/FirebaseCore.podspec +++ b/FirebaseCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCore' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase Core' s.description = <<-DESC @@ -53,7 +53,7 @@ Firebase Core includes FIRApp and FIROptions which provide central configuration # Remember to also update version in `cmake/external/GoogleUtilities.cmake` s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/Logger', '~> 8.1' - s.dependency 'FirebaseCoreInternal', '~> 12.4.0' + s.dependency 'FirebaseCoreInternal', '~> 12.5.0' s.pod_target_xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => 'Firebase_VERSION=' + s.version.to_s, diff --git a/FirebaseCoreExtension.podspec b/FirebaseCoreExtension.podspec index 582d7c03633..39ba825bb67 100644 --- a/FirebaseCoreExtension.podspec +++ b/FirebaseCoreExtension.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCoreExtension' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Extended FirebaseCore APIs for Firebase product SDKs' s.description = <<-DESC @@ -34,5 +34,5 @@ Pod::Spec.new do |s| "#{s.module_name}_Privacy" => 'FirebaseCore/Extension/Resources/PrivacyInfo.xcprivacy' } - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' end diff --git a/FirebaseCoreInternal.podspec b/FirebaseCoreInternal.podspec index 11d45777ec2..36eb7ed6cf1 100644 --- a/FirebaseCoreInternal.podspec +++ b/FirebaseCoreInternal.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCoreInternal' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'APIs for internal FirebaseCore usage.' s.description = <<-DESC diff --git a/FirebaseCrashlytics.podspec b/FirebaseCrashlytics.podspec index 0b041473102..deb29e67d12 100644 --- a/FirebaseCrashlytics.podspec +++ b/FirebaseCrashlytics.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCrashlytics' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Best and lightest-weight crash reporting for mobile, desktop and tvOS.' s.description = 'Firebase Crashlytics helps you track, prioritize, and fix stability issues that erode app quality.' s.homepage = 'https://firebase.google.com/' @@ -59,10 +59,10 @@ Pod::Spec.new do |s| cp -f ./Crashlytics/CrashlyticsInputFiles.xcfilelist ./CrashlyticsInputFiles.xcfilelist PREPARE_COMMAND_END - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' - s.dependency 'FirebaseSessions', '~> 12.4.0' - s.dependency 'FirebaseRemoteConfigInterop', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseInstallations', '~> 12.5.0' + s.dependency 'FirebaseSessions', '~> 12.5.0' + s.dependency 'FirebaseRemoteConfigInterop', '~> 12.5.0' s.dependency 'PromisesObjC', '~> 2.4' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' diff --git a/FirebaseDatabase.podspec b/FirebaseDatabase.podspec index 0380ba60221..ca55380ce64 100644 --- a/FirebaseDatabase.podspec +++ b/FirebaseDatabase.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseDatabase' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase Realtime Database' s.description = <<-DESC @@ -48,9 +48,9 @@ Simplify your iOS development, grow your user base, and monetize more effectivel s.macos.frameworks = 'CFNetwork', 'Security', 'SystemConfiguration' s.watchos.frameworks = 'CFNetwork', 'Security', 'WatchKit' s.dependency 'leveldb-library', '~> 1.22' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseSharedSwift', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' + s.dependency 'FirebaseSharedSwift', '~> 12.5.0' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' @@ -72,7 +72,7 @@ Simplify your iOS development, grow your user base, and monetize more effectivel 'SharedTestUtilities/FIRComponentTestUtilities.[mh]', 'SharedTestUtilities/FIROptionsMock.[mh]', ] - unit_tests.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' + unit_tests.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' unit_tests.dependency 'OCMock' unit_tests.resources = 'FirebaseDatabase/Tests/Resources/syncPointSpec.json', 'FirebaseDatabase/Tests/Resources/GoogleService-Info.plist' diff --git a/FirebaseFirestore.podspec b/FirebaseFirestore.podspec index 0b4b0380a11..c402aa5b31a 100644 --- a/FirebaseFirestore.podspec +++ b/FirebaseFirestore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFirestore' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Google Cloud Firestore' s.description = <<-DESC Google Cloud Firestore is a NoSQL document database built for automatic scaling, high performance, and ease of application development. @@ -35,9 +35,9 @@ Google Cloud Firestore is a NoSQL document database built for automatic scaling, "#{s.module_name}_Privacy" => 'Firestore/Swift/Source/Resources/PrivacyInfo.xcprivacy' } - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' - s.dependency 'FirebaseFirestoreInternal', '~> 12.4.0' - s.dependency 'FirebaseSharedSwift', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCoreExtension', '~> 12.5.0' + s.dependency 'FirebaseFirestoreInternal', '~> 12.5.0' + s.dependency 'FirebaseSharedSwift', '~> 12.5.0' end diff --git a/FirebaseFirestoreInternal.podspec b/FirebaseFirestoreInternal.podspec index a324ed9a54f..906e9978fd5 100644 --- a/FirebaseFirestoreInternal.podspec +++ b/FirebaseFirestoreInternal.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFirestoreInternal' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Google Cloud Firestore' s.description = <<-DESC @@ -91,8 +91,8 @@ Google Cloud Firestore is a NoSQL document database built for automatic scaling, "#{s.module_name}_Privacy" => 'Firestore/Source/Resources/PrivacyInfo.xcprivacy' } - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.5.0' abseil_version = '~> 1.20240722.0' s.dependency 'abseil/algorithm', abseil_version diff --git a/FirebaseFunctions.podspec b/FirebaseFunctions.podspec index e3276fc464d..4630aa43448 100644 --- a/FirebaseFunctions.podspec +++ b/FirebaseFunctions.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFunctions' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Cloud Functions for Firebase' s.description = <<-DESC @@ -35,12 +35,12 @@ Cloud Functions for Firebase. 'FirebaseFunctions/Sources/**/*.swift', ] - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseAuthInterop', '~> 12.4.0' - s.dependency 'FirebaseMessagingInterop', '~> 12.4.0' - s.dependency 'FirebaseSharedSwift', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCoreExtension', '~> 12.5.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' + s.dependency 'FirebaseAuthInterop', '~> 12.5.0' + s.dependency 'FirebaseMessagingInterop', '~> 12.5.0' + s.dependency 'FirebaseSharedSwift', '~> 12.5.0' s.dependency 'GTMSessionFetcher/Core', '>= 3.4', '< 6.0' s.test_spec 'objc' do |objc_tests| diff --git a/FirebaseInAppMessaging.podspec b/FirebaseInAppMessaging.podspec index f122ac31e15..1d9971d7464 100644 --- a/FirebaseInAppMessaging.podspec +++ b/FirebaseInAppMessaging.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseInAppMessaging' - s.version = '12.4.0-beta' + s.version = '12.5.0-beta' s.summary = 'Firebase In-App Messaging for iOS' s.description = <<-DESC @@ -80,9 +80,9 @@ See more product details at https://firebase.google.com/products/in-app-messagin s.framework = 'UIKit' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' - s.dependency 'FirebaseABTesting', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseInstallations', '~> 12.5.0' + s.dependency 'FirebaseABTesting', '~> 12.5.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' s.dependency 'nanopb', '~> 3.30910.0' diff --git a/FirebaseInstallations.podspec b/FirebaseInstallations.podspec index 69fa914c438..49afb1f97ea 100644 --- a/FirebaseInstallations.podspec +++ b/FirebaseInstallations.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseInstallations' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase Installations' s.description = <<-DESC @@ -45,7 +45,7 @@ Pod::Spec.new do |s| } s.framework = 'Security' - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' s.dependency 'PromisesObjC', '~> 2.4' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' diff --git a/FirebaseMLModelDownloader.podspec b/FirebaseMLModelDownloader.podspec index 66e6010c3fb..bd568869006 100644 --- a/FirebaseMLModelDownloader.podspec +++ b/FirebaseMLModelDownloader.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMLModelDownloader' - s.version = '12.4.0-beta' + s.version = '12.5.0-beta' s.summary = 'Firebase ML Model Downloader' s.description = <<-DESC @@ -36,9 +36,9 @@ Pod::Spec.new do |s| ] s.framework = 'Foundation' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCoreExtension', '~> 12.5.0' + s.dependency 'FirebaseInstallations', '~> 12.5.0' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' s.dependency 'SwiftProtobuf', '~> 1.19' diff --git a/FirebaseMessaging.podspec b/FirebaseMessaging.podspec index 7815af1b184..605bd0a6f73 100644 --- a/FirebaseMessaging.podspec +++ b/FirebaseMessaging.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMessaging' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase Messaging' s.description = <<-DESC @@ -60,8 +60,8 @@ device, and it is completely free. s.tvos.framework = 'SystemConfiguration' s.osx.framework = 'SystemConfiguration' s.weak_framework = 'UserNotifications' - s.dependency 'FirebaseInstallations', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' + s.dependency 'FirebaseInstallations', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.5.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/Reachability', '~> 8.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' diff --git a/FirebaseMessagingInterop.podspec b/FirebaseMessagingInterop.podspec index fa29a1849d9..72784bff0cf 100644 --- a/FirebaseMessagingInterop.podspec +++ b/FirebaseMessagingInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMessagingInterop' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Interfaces that allow other Firebase SDKs to use Messaging functionality.' s.description = <<-DESC diff --git a/FirebasePerformance.podspec b/FirebasePerformance.podspec index b571987c056..7016aa49533 100644 --- a/FirebasePerformance.podspec +++ b/FirebasePerformance.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebasePerformance' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase Performance' s.description = <<-DESC @@ -58,10 +58,10 @@ Firebase Performance library to measure performance of Mobile and Web Apps. s.ios.framework = 'CoreTelephony' s.framework = 'QuartzCore' s.framework = 'SystemConfiguration' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' - s.dependency 'FirebaseRemoteConfig', '~> 12.4.0' - s.dependency 'FirebaseSessions', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseInstallations', '~> 12.5.0' + s.dependency 'FirebaseRemoteConfig', '~> 12.5.0' + s.dependency 'FirebaseSessions', '~> 12.5.0' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/MethodSwizzler', '~> 8.1' diff --git a/FirebaseRemoteConfig.podspec b/FirebaseRemoteConfig.podspec index 8247f4cccf3..bb9f49f4383 100644 --- a/FirebaseRemoteConfig.podspec +++ b/FirebaseRemoteConfig.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseRemoteConfig' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase Remote Config' s.description = <<-DESC @@ -49,13 +49,13 @@ app update. s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' } - s.dependency 'FirebaseABTesting', '~> 12.4.0' - s.dependency 'FirebaseSharedSwift', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' + s.dependency 'FirebaseABTesting', '~> 12.5.0' + s.dependency 'FirebaseSharedSwift', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseInstallations', '~> 12.5.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/NSData+zlib', '~> 8.1' - s.dependency 'FirebaseRemoteConfigInterop', '~> 12.4.0' + s.dependency 'FirebaseRemoteConfigInterop', '~> 12.5.0' s.test_spec 'unit' do |unit_tests| unit_tests.scheme = { :code_coverage => true } diff --git a/FirebaseRemoteConfigInterop.podspec b/FirebaseRemoteConfigInterop.podspec index 6530c98dccc..8d5d38bf9f8 100644 --- a/FirebaseRemoteConfigInterop.podspec +++ b/FirebaseRemoteConfigInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseRemoteConfigInterop' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Interfaces that allow other Firebase SDKs to use Remote Config functionality.' s.description = <<-DESC diff --git a/FirebaseSessions.podspec b/FirebaseSessions.podspec index 56c7d3f6a13..cd0a7fd02ad 100644 --- a/FirebaseSessions.podspec +++ b/FirebaseSessions.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseSessions' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase Sessions' s.description = <<-DESC @@ -39,9 +39,9 @@ Pod::Spec.new do |s| base_dir + 'SourcesObjC/**/*.{c,h,m,mm}', ] - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' - s.dependency 'FirebaseInstallations', '~> 12.4.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCoreExtension', '~> 12.5.0' + s.dependency 'FirebaseInstallations', '~> 12.5.0' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' diff --git a/FirebaseSharedSwift.podspec b/FirebaseSharedSwift.podspec index e5be20b55a1..b3e043cf66f 100644 --- a/FirebaseSharedSwift.podspec +++ b/FirebaseSharedSwift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseSharedSwift' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Shared Swift Extensions for Firebase' s.description = <<-DESC diff --git a/FirebaseStorage.podspec b/FirebaseStorage.podspec index c8270ad7da5..0b79f6e1b76 100644 --- a/FirebaseStorage.podspec +++ b/FirebaseStorage.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseStorage' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Firebase Storage' s.description = <<-DESC @@ -37,10 +37,10 @@ Firebase Storage provides robust, secure file uploads and downloads from Firebas 'FirebaseStorage/Typedefs/*.h', ] - s.dependency 'FirebaseAppCheckInterop', '~> 12.4.0' - s.dependency 'FirebaseAuthInterop', '~> 12.4.0' - s.dependency 'FirebaseCore', '~> 12.4.0' - s.dependency 'FirebaseCoreExtension', '~> 12.4.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' + s.dependency 'FirebaseAuthInterop', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCoreExtension', '~> 12.5.0' s.dependency 'GTMSessionFetcher/Core', '>= 3.4', '< 6.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' @@ -57,7 +57,7 @@ Firebase Storage provides robust, secure file uploads and downloads from Firebas objc_tests.requires_app_host = true objc_tests.resources = 'FirebaseStorage/Tests/Integration/Resources/1mb.dat', 'FirebaseStorage/Tests/Integration/Resources/GoogleService-Info.plist' - objc_tests.dependency 'FirebaseAuth', '~> 12.4.0' + objc_tests.dependency 'FirebaseAuth', '~> 12.5.0' objc_tests.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' } @@ -86,6 +86,6 @@ Firebase Storage provides robust, secure file uploads and downloads from Firebas int_tests.resources = 'FirebaseStorage/Tests/Integration/Resources/1mb.dat', 'FirebaseStorage/Tests/Integration/Resources/GoogleService-Info.plist', 'FirebaseStorage/Tests/Integration/Resources/HomeImprovement.numbers' - int_tests.dependency 'FirebaseAuth', '~> 12.4.0' + int_tests.dependency 'FirebaseAuth', '~> 12.5.0' end end diff --git a/GoogleAppMeasurement.podspec b/GoogleAppMeasurement.podspec index 15a0e5bb817..0578e45b1b4 100644 --- a/GoogleAppMeasurement.podspec +++ b/GoogleAppMeasurement.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'GoogleAppMeasurement' - s.version = '12.4.0' + s.version = '12.5.0' s.summary = 'Shared measurement methods for Google libraries. Not intended for direct use.' s.description = <<-DESC @@ -37,8 +37,8 @@ Pod::Spec.new do |s| s.default_subspecs = 'Default' s.subspec 'Default' do |ss| - ss.dependency 'GoogleAppMeasurement/Core', '12.4.0' - ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.4.0' + ss.dependency 'GoogleAppMeasurement/Core', '12.5.0' + ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.5.0' ss.ios.dependency 'GoogleAdsOnDeviceConversion', '~> 3.1.0' end @@ -47,7 +47,7 @@ Pod::Spec.new do |s| end s.subspec 'IdentitySupport' do |ss| - ss.dependency 'GoogleAppMeasurement/Core', '12.4.0' + ss.dependency 'GoogleAppMeasurement/Core', '12.5.0' ss.vendored_frameworks = 'Frameworks/GoogleAppMeasurementIdentitySupport.xcframework' end end diff --git a/Package.swift b/Package.swift index 103d380af97..a3a5ecad82e 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,7 @@ import PackageDescription -let firebaseVersion = "12.4.0" +let firebaseVersion = "12.5.0" let package = Package( name: "Firebase", diff --git a/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift b/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift index 046c6f88e39..e55dc32df71 100755 --- a/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift +++ b/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift @@ -21,7 +21,7 @@ import Foundation /// The version and releasing fields of the non-Firebase pods should be reviewed every release. /// The array should be ordered so that any pod's dependencies precede it in the list. public let shared = Manifest( - version: "12.4.0", + version: "12.5.0", pods: [ Pod("FirebaseSharedSwift"), Pod("FirebaseCoreInternal"), From e7e5d8356a595993c0e000e9b6ce4a1d1a3f3494 Mon Sep 17 00:00:00 2001 From: cherylEnkidu <96084918+cherylEnkidu@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:35:30 -0400 Subject: [PATCH 24/54] Fix grpc version in cmake (#15391) --- Firestore/core/CMakeLists.txt | 9 +++++++++ cmake/external/grpc.cmake | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Firestore/core/CMakeLists.txt b/Firestore/core/CMakeLists.txt index cb405074816..90cd4af476c 100644 --- a/Firestore/core/CMakeLists.txt +++ b/Firestore/core/CMakeLists.txt @@ -245,6 +245,15 @@ target_include_directories( ${PROJECT_SOURCE_DIR}/Firestore/core/include ) +# Add the gRPC include directories as SYSTEM directories to silence warnings +target_include_directories( + firestore_core + SYSTEM # The SYSTEM keyword applies to all directories in this block + PUBLIC + # This generator expression automatically gets the correct include path(s) from the grpc++ target + $ +) + target_link_libraries( firestore_core PUBLIC LevelDB::LevelDB diff --git a/cmake/external/grpc.cmake b/cmake/external/grpc.cmake index 21d970a8d15..cc7a038a51a 100644 --- a/cmake/external/grpc.cmake +++ b/cmake/external/grpc.cmake @@ -18,7 +18,7 @@ if(TARGET grpc) return() endif() -set(version 1.62.0) +set(version 1.69.0) ExternalProject_Add( grpc @@ -26,7 +26,7 @@ ExternalProject_Add( DOWNLOAD_DIR ${FIREBASE_DOWNLOAD_DIR} DOWNLOAD_NAME grpc-${version}.tar.gz URL https://github.com/grpc/grpc/archive/v${version}.tar.gz - URL_HASH SHA256=f40bde4ce2f31760f65dc49a2f50876f59077026494e67dccf23992548b1b04f + URL_HASH SHA256=cd256d91781911d46a57506978b3979bfee45d5086a1b6668a3ae19c5e77f8dc PREFIX ${PROJECT_BINARY_DIR} SOURCE_DIR ${PROJECT_BINARY_DIR}/src/grpc From abf5e55728648890ed1c985d8dc03947e5830b1c Mon Sep 17 00:00:00 2001 From: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:24:01 -0400 Subject: [PATCH 25/54] docs(firestore): Add docs on Firestore x SPM integration (#15387) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- Firestore/README.md | 3 + docs/FirestoreSPM.md | 196 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 docs/FirestoreSPM.md diff --git a/Firestore/README.md b/Firestore/README.md index 9c7d01a3531..13a79d68606 100644 --- a/Firestore/README.md +++ b/Firestore/README.md @@ -48,6 +48,9 @@ scripts/build.sh Firestore iOS spm This is rarely necessary for primary development and is done automatically by CI. +For a detailed explanation of the Firestore target hierarchy in the +`Package.swift` manifest, see [FirestoreSPM.md](../docs/FirestoreSPM.md). + ### Improving the debugger experience You can install a set of type formatters to improve the presentation of diff --git a/docs/FirestoreSPM.md b/docs/FirestoreSPM.md new file mode 100644 index 00000000000..e473a986983 --- /dev/null +++ b/docs/FirestoreSPM.md @@ -0,0 +1,196 @@ +# Firestore Swift Package Manager Target Hierarchy + +This document outlines the hierarchy of the Firestore-related targets in the +`Package.swift` manifest. The setup is designed to support three different +build options for Firestore: from a pre-compiled binary (the default), from +source (via the `FIREBASE_SOURCE_FIRESTORE` environment variable), or from a +local binary for CI purposes (via the `FIREBASECI_USE_LOCAL_FIRESTORE_ZIP` +environment variable). + +--- + +## 1. Binary-based build (Default) + +When the `FIREBASE_SOURCE_FIRESTORE` environment variable is **not** set, SPM +will use pre-compiled binaries for Firestore and its heavy dependencies. This +is the default and recommended approach for most users. + +### Dependency hierarchy + +The dependency tree for a binary-based build is as follows: + +``` +FirebaseFirestore (Library Product) +└── FirebaseFirestoreTarget (Wrapper Target) + └── FirebaseFirestore (Swift Target) + ├── FirebaseAppCheckInterop + ├── FirebaseCore + ├── FirebaseCoreExtension + ├── FirebaseSharedSwift + ├── leveldb + ├── nanopb + ├── abseil (binary) (from https://github.com/google/abseil-cpp-binary.git) + ├── gRPC-C++ (binary) (from https://github.com/google/grpc-binary.git, contains BoringSSL-GRPC target) + └── FirebaseFirestoreInternalWrapper (Wrapper Target) + └── FirebaseFirestoreInternal (Binary Target) +``` + +### Target breakdown + +* **`FirebaseFirestore`**: The Swift target containing the public API. In this + configuration, it depends on the binary versions of abseil and gRPC, as + well as the `FirebaseFirestoreInternalWrapper`. +* **`FirebaseFirestoreInternalWrapper`**: A thin wrapper target that exists to + expose the headers from the underlying binary target. +* **`FirebaseFirestoreInternal`**: This is a `binaryTarget` that downloads and + links the pre-compiled `FirebaseFirestoreInternal.xcframework`. This + framework contains the compiled C++ core of Firestore. + +--- + +## 2. Source-based build + +When the `FIREBASE_SOURCE_FIRESTORE` environment variable is set, Firestore and +its dependencies (like abseil and gRPC) are compiled from source. + +### How to build Firestore from source + +To build Firestore from source, set the `FIREBASE_SOURCE_FIRESTORE` environment +variable before building the project. + +#### Building with Xcode + +A direct method for building within Xcode is to pass the environment variable +when opening it from the command line. This approach scopes the variable to the +Xcode instance. To enable an env var within Xcode, first quit any running Xcode +instance, and then open the project from the command line: + +```console +open --env FIREBASE_SOURCE_FIRESTORE Package.swift +``` + +To unset the env var, quit the running Xcode instance. If you need to pass +multiple variables, repeat the `--env` argument for each: +```console +open --env FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT \ +--env FIREBASE_SOURCE_FIRESTORE Package.swift +``` + +#### Command-line builds + +For command-line builds using `xcodebuild` or `swift build`, the recommended +approach is to prefix the build command with the environment variable. This sets +the variable only for that specific command, avoiding unintended side effects. + +```bash +FIREBASE_SOURCE_FIRESTORE=1 xcodebuild -scheme FirebaseFirestore \ +-destination 'generic/platform=iOS' +``` + +Alternatively, if you plan to run multiple commands that require the variable +to be set, you can `export` it. This will apply the variable to all subsequent +commands in that terminal session. + +```bash +export FIREBASE_SOURCE_FIRESTORE=1 +xcodebuild -scheme FirebaseFirestore -destination 'generic/platform=iOS' +# Any other commands here will also have the variable set +``` + +Once the project is built with the variable set, SPM will clone and build +Firestore and its C++ dependencies (like abseil and gRPC) from source. + +### Dependency hierarchy + +The dependency tree for a source-based build looks like this: + +``` +FirebaseFirestore (Library Product) +└── FirebaseFirestoreTarget (Wrapper Target) + └── FirebaseFirestore (Swift Target) + ├── FirebaseCore + ├── FirebaseCoreExtension + ├── FirebaseSharedSwift + └── FirebaseFirestoreInternalWrapper (C++ Target) + ├── FirebaseAppCheckInterop + ├── FirebaseCore + ├── leveldb + ├── nanopb + ├── abseil (source) (from https://github.com/firebase/abseil-cpp-SwiftPM.git) + └── gRPC-cpp (source) (from https://github.com/grpc/grpc-ios.git) + └── BoringSSL (source) (from https://github.com/firebase/boringSSL-SwiftPM.git) +``` + +### Target breakdown + +* **`FirebaseFirestore`**: The main Swift target containing the public Swift + API for Firestore. It acts as a bridge to the underlying C++ + implementation. +* **`FirebaseFirestoreInternalWrapper`**: This target compiles the core C++ + source code of Firestore. It depends on other low-level libraries and C++ + dependencies, which are also built from source. + +--- + +## 3. Local binary build (CI only) + +A third, less common build option is available for CI environments. When the +`FIREBASECI_USE_LOCAL_FIRESTORE_ZIP` environment variable is set, the build +system will use a local `FirebaseFirestoreInternal.xcframework` instead of +downloading the pre-compiled binary. This option assumes the xcframework is +located at the root of the repository. + +This option is primarily used by internal scripts, such as +`scripts/check_firestore_symbols.sh`, to perform validation against a locally +built version of the Firestore binary. It is not intended for general consumer +use. + +--- + +## Core target explanations + +### `FirebaseFirestore` (Library product) + +The main entry point for integrating Firestore via SPM is the +`FirebaseFirestore` library product. + +```swift +.library( + name: "FirebaseFirestore", + targets: ["FirebaseFirestoreTarget"]) +``` + +This product points to a wrapper target, `FirebaseFirestoreTarget`, which then +depends on the appropriate Firestore targets based on the chosen build option. + +### `FirebaseFirestoreTarget` (Wrapper target) + +The `FirebaseFirestoreTarget` is a thin wrapper that exists to work around a +limitation in SPM where a single target cannot conditionally depend on +different sets of targets (source vs. binary). + +By having clients depend on the wrapper, the `Package.swift` can internally +manage the complexity of switching between source and binary builds. This +provides a stable entry point for all clients and avoids pushing conditional +logic into their own package manifests. + +--- + +## Test targets + +The testing infrastructure for Firestore in SPM is designed to be independent +of the build choice (source vs. binary). + +* **`FirebaseFirestoreTestingSupport`**: This is a library target, not a test + target. It provides public testing utilities that consumers can use to + write unit tests for their Firestore-dependent code. It has a dependency on + `FirebaseFirestoreTarget`, which means it will link against whichever + version of Firestore (source or binary) is being used in the build. + +* **`FirestoreTestingSupportTests`**: This is a test target that contains the + unit tests for the `FirebaseFirestoreTestingSupport` library itself. Its + purpose is to validate the testing utilities. + +Because both of these targets depend on the `FirebaseFirestoreTarget` wrapper, +they seamlessly adapt to either the source-based or binary-based build path +without any conditional logic. From dcba4944aa9650110f636cfc00adabc21139b045 Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:27:08 -0500 Subject: [PATCH 26/54] fix(ci): Update the AI integration test secrets (#15397) --- .github/workflows/firebaseai.yml | 6 +++--- .../TestApp-Credentials.swift.gpg | Bin .../TestApp-GoogleService-Info-Spark.plist.gpg | 2 ++ .../TestApp-GoogleService-Info.plist.gpg | Bin 0 -> 622 bytes .../TestApp-GoogleService-Info-Spark.plist.gpg | Bin 543 -> 0 bytes .../VertexAI/TestApp-GoogleService-Info.plist.gpg | Bin 621 -> 0 bytes 6 files changed, 5 insertions(+), 3 deletions(-) rename scripts/gha-encrypted/{VertexAI => FirebaseAI}/TestApp-Credentials.swift.gpg (100%) create mode 100644 scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg create mode 100644 scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg delete mode 100644 scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info-Spark.plist.gpg delete mode 100644 scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info.plist.gpg diff --git a/.github/workflows/firebaseai.yml b/.github/workflows/firebaseai.yml index 21cdfb83540..610c3c7d07f 100644 --- a/.github/workflows/firebaseai.yml +++ b/.github/workflows/firebaseai.yml @@ -56,13 +56,13 @@ jobs: path: .build key: ${{ needs.spm.outputs.cache_key }} - name: Install Secret GoogleService-Info.plist - run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info.plist.gpg \ + run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg \ FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist "$secrets_passphrase" - name: Install Secret GoogleService-Info-Spark.plist - run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info-Spark.plist.gpg \ + run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg \ FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist "$secrets_passphrase" - name: Install Secret Credentials.swift - run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/VertexAI/TestApp-Credentials.swift.gpg \ + run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/FirebaseAI/TestApp-Credentials.swift.gpg \ FirebaseAI/Tests/TestApp/Tests/Integration/Credentials.swift "$secrets_passphrase" - name: Xcode run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer diff --git a/scripts/gha-encrypted/VertexAI/TestApp-Credentials.swift.gpg b/scripts/gha-encrypted/FirebaseAI/TestApp-Credentials.swift.gpg similarity index 100% rename from scripts/gha-encrypted/VertexAI/TestApp-Credentials.swift.gpg rename to scripts/gha-encrypted/FirebaseAI/TestApp-Credentials.swift.gpg diff --git a/scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg b/scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg new file mode 100644 index 00000000000..28619cee88a --- /dev/null +++ b/scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg @@ -0,0 +1,2 @@ +Œ  Vìµù_;2ƒúÒéÚlˆšê,Òn%7 A{·È®Á€¦²?S(3˜n»†¯Py.±Qž&ö¤CíçÝÁØùјO¬qU²kÓÍHàÒ»™~-9Ù\îÖ]˜âruÀˆú”ð' ÇÜDÕÇdm†Ú½¯²äý½m'§üö=Auï?s¾k¢Ú¿4¡U¾C[ÍÆÀX=>ɉ+ GÿŸ¬·HwuŸwÞ`mäˆûI,i„#îŒ÷õ~áü(Ú¢íöùYÓ]xG.¤ù3!GÉ#¼…‰9ÐÇîu.êŸ%¦t]3hŒ}v\©t{[º8«~ÆYŸÉwY•‹@+¼…])c‹§\¨ RÉ“cΕ‰¶¯÷w$úñPNA“¥’¬µføÀï»ÛmÒ8½hqsXŠÉhzs©È‘ÿíEüË6k<À˜©ÈÈé‚Hù>¹> aÖ],Þ,­À÷õÁE9Ä¿·pèÿ “–Õø¢_À®Û¥øª£ß~Û€¤ýþºlvä‡=S6)PßQ`”½Æ—•J#Û-Æ!Z9ôŒm l.@…ñ’K݉yCéx•ʈùÆXB4c±”€ 54@NŸ[Ç(¯:À~/º ÈÖ‹«ÜSÅ6EHj°²T‡¡Žæö£D쮎HànWÒ„oߢKs ;ôëå'°€‡š¸?K êF¼ž_ÓLЉô©)”:•¾ +|QMå0R@£T \ No newline at end of file diff --git a/scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg b/scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg new file mode 100644 index 0000000000000000000000000000000000000000..02695881bb388963223b9ee1eedc747e6b749df2 GIT binary patch literal 622 zcmV-!0+IcU4Fm}T2%(u7=A{J?`1;c60lCX=44=KR099!?i}|-fKKghG%d$?ga8W}9 z-enYbGqASL06RF1gDd;(nCl44Ewvw+3e(i6dwCFg;ENanFqh|%cbBw~J2u7CQ%>oz zf@2j&uP#?ho@>O2K@$Wu5feGqB$y9muw{A5n7&hK?@GkXG+2gpiEZ6U{k-|0lkC%x zK=mdTRwlSIp3dF3*UzKhyO{QLPWrUD^Ui%N_l!F{$Y1YCVfTA49|RFk@xlEvGn7Ic zcmVt|%Xi2xV<3uME{MdHZb$d#P5#MYO&W*ATXg5ec;c@k-yx-YGLsc-FAyYBUM>Z7 z5ugga{E;k! zRPWJFTHPo{Tc*2F7V6aTgGB}X0?XKuPrTK z!18VTG1+%i=wruNeU{Lb)esMYACSL8;$e}C0}l5E@knW>96smnIi5BNd_8Gj+(0Cdzw9q!YkTLQ!d!8=bO2q+%0Nkz$)HxnR7tYO7|UEP@)G<+-TJ049N5izC; zT?JAGZ~RY0>#r2(xeP8E$uVrKvZ|sN(@Na{=L7Rr%g8N{<03>va z${f7nwusnstv5E+sFBjCAqlo>V$}DOKpgDO9Q*}W)yF6hrsqx*-#%tmHr@97!gs{0 IP+Cj&mLbS7XaE2J literal 0 HcmV?d00001 diff --git a/scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info-Spark.plist.gpg b/scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info-Spark.plist.gpg deleted file mode 100644 index 71463a8043d690dfb788ef6d180dda616cb8dd37..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 543 zcmV+)0^t3O4Fm}T2!<8HBeG8e^ZC;00WzVA0Kov%a4&N&YcK=^eM|GuPDYKAwRN}gH*e&iWr$f#$?-lszf4Y~^*5Ci8TE(0Fx#2ub27NiR%jeh(GUtCi5 zL~_dJ<7+aRD+y?biv0sQ9gTXhqEcpBMrbQ z!RtnQBgB`||N8F#avJuS#1)b9YT(1S`&CT{%LG9s@9G^skX$>b&jBah1@w>DQfd$e zEiF8cJYkFzd@orHh3PJ0EKxNlJ*)j~(dfLta!W@Dq94?GYPCJ!rTXB&udFO_h@tJ% zGiuT^JPp60U$JAyd*15)cV8uVbf;2RTmuR$RVshkso6j9jOw9wG3cHtB=+0VrBLQ! zT!>wmD#>tQS`>v~ko!(ZMK!@B`1dISR5on?sc0Yc33q}y_onv@jg=X8GlBX!dtBuw zhsiH8!U-GAm#+a9wm0IYl;J}rY7|SXCaIFb4$TQ)cQwMhfEv{wQq$R)v10h2l;sh& zc)()VKSRr8^xz$?ag&d|NFUHeq{|p`r#J2K@%MItna?qclsQ_`-oL(}Lz)&s{{#cE h(k-{=ZCTK9UOc8I*F+A=4FF0Y>ZnHRh>j12tp>4o0rCI< diff --git a/scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info.plist.gpg b/scripts/gha-encrypted/VertexAI/TestApp-GoogleService-Info.plist.gpg deleted file mode 100644 index 47f836feff32d4e1d51285ca1bfc137928a1b903..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 621 zcmV-z0+RiV4Fm}T2ww7uPXf}QYx>gZ0cA3#5bfl=yNRrOXnS3ha>3)E!1Y| z)}B-o=h)uDn#$6!;=NWPngr+y9{y4k(1HaWT7#0$;y|kN4bNEWnL8xKU!T-JyYpl`PeFrVrKQrUb(b}Y_1t$|W^20oVz+aVB zBTyU9wX^JK&CVa+;;yo@O}`H7 zz@Pw%3+MJ^IL1F|@zH)qH$qJu8`w)vXR!@d>>5E>)C8%qFYJ|dIBpvqz?Q6$ He0efuWP~n4 From 455d291111577a87d0f34eea2c7f8de04598007b Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:03:44 -0500 Subject: [PATCH 27/54] fix(ai): Fix broken links and update docs per cl (#15399) --- FirebaseAI/CHANGELOG.md | 3 +++ FirebaseAI/Sources/FirebaseAI.swift | 21 ++++++++----------- FirebaseAI/Sources/GenerativeModel.swift | 2 +- .../Public/Live/LiveGenerationConfig.swift | 7 ++++--- .../Public/Live/LiveGenerativeModel.swift | 2 +- .../Types/Public/Live/LiveServerContent.swift | 18 +++++++++------- .../Public/Live/LiveServerToolCall.swift | 4 ++-- .../Live/LiveServerToolCallCancellation.swift | 4 ++-- .../Types/Public/Live/LiveSession.swift | 11 +++++----- .../Types/Public/Live/LiveSessionErrors.swift | 12 +++++++---- .../Types/Public/Live/SpeechConfig.swift | 2 +- FirebaseAI/Sources/Types/Public/Part.swift | 7 ++++--- 12 files changed, 51 insertions(+), 42 deletions(-) diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index 06f5f30908d..4ee5c21b9a6 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,3 +1,6 @@ +# Unreleased +- [fixed] Fixed various links in the Live API doc comments not mapping correctly. + # 12.4.0 - [feature] Added support for the URL context tool, which allows the model to access content from provided public web URLs to inform and enhance its responses. (#15221) diff --git a/FirebaseAI/Sources/FirebaseAI.swift b/FirebaseAI/Sources/FirebaseAI.swift index f9ff5ea0424..354c16b79ab 100644 --- a/FirebaseAI/Sources/FirebaseAI.swift +++ b/FirebaseAI/Sources/FirebaseAI.swift @@ -63,7 +63,7 @@ public final class FirebaseAI: Sendable { /// guidance on choosing an appropriate model for your use case. /// /// - Parameters: - /// - modelName: The name of the model to use, for example `"gemini-1.5-flash"`; see + /// - modelName: The name of the model to use; see /// [available model names /// ](https://firebase.google.com/docs/vertex-ai/gemini-models#available-model-names) for a /// list of supported model names. @@ -106,12 +106,11 @@ public final class FirebaseAI: Sendable { /// Initializes an ``ImagenModel`` with the given parameters. /// - /// > Important: Only Imagen 3 models (named `imagen-3.0-*`) are supported. + /// - Note: Refer to [Imagen models](https://firebase.google.com/docs/vertex-ai/models) for + /// guidance on choosing an appropriate model for your use case. /// /// - Parameters: - /// - modelName: The name of the Imagen 3 model to use, for example `"imagen-3.0-generate-002"`; - /// see [model versions](https://firebase.google.com/docs/vertex-ai/models) for a list of - /// supported Imagen 3 models. + /// - modelName: The name of the Imagen 3 model to use. /// - generationConfig: Configuration options for generating images with Imagen. /// - safetySettings: Settings describing what types of potentially harmful content your model /// should allow. @@ -138,18 +137,16 @@ public final class FirebaseAI: Sendable { /// **[Public Preview]** Initializes a ``LiveGenerativeModel`` with the given parameters. /// + /// - Note: Refer to [the Firebase docs on the Live + /// API](https://firebase.google.com/docs/ai-logic/live-api#models-that-support-capability) for + /// guidance on choosing an appropriate model for your use case. + /// /// > Warning: Using the Firebase AI Logic SDKs with the Gemini Live API is in Public /// Preview, which means that the feature is not subject to any SLA or deprecation policy and /// could change in backwards-incompatible ways. /// - /// > Important: Only models that support the Gemini Live API (typically containing `live-*` in - /// the name) are supported. - /// /// - Parameters: - /// - modelName: The name of the model to use, for example - /// `"gemini-live-2.5-flash-preview"`; - /// see [model versions](https://firebase.google.com/docs/ai-logic/live-api?api=dev#models-that-support-capability) - /// for a list of supported models. + /// - modelName: The name of the model to use. /// - generationConfig: The content generation parameters your model should use. /// - tools: A list of ``Tool`` objects that the model may use to generate the next response. /// - toolConfig: Tool configuration for any ``Tool`` specified in the request. diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index 428e1fe6f26..e3f905793ad 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -59,7 +59,7 @@ public final class GenerativeModel: Sendable { /// Initializes a new remote model with the given parameters. /// /// - Parameters: - /// - modelName: The name of the model, for example "gemini-2.0-flash". + /// - modelName: The name of the model. /// - modelResourceName: The model resource name corresponding with `modelName` in the backend. /// The form depends on the backend and will be one of: /// - Vertex AI via Firebase AI SDK: diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveGenerationConfig.swift b/FirebaseAI/Sources/Types/Public/Live/LiveGenerationConfig.swift index 21692f27eed..c7033567a91 100644 --- a/FirebaseAI/Sources/Types/Public/Live/LiveGenerationConfig.swift +++ b/FirebaseAI/Sources/Types/Public/Live/LiveGenerationConfig.swift @@ -107,13 +107,14 @@ public struct LiveGenerationConfig: Sendable { /// the model. /// /// Input transcripts are the model's interpretation of audio data sent to it, and they are - /// populated in model responses via ``LiveServerContent``. When this field is set to `nil`, - /// input transcripts are not populated in model responses. + /// populated in model responses via ``LiveServerContent/inputAudioTranscription``. When this + /// field is set to `nil`, input transcripts are not populated in model responses. /// - outputAudioTranscription: Configures (and enables) output transcriptions when streaming to /// the model. /// /// Output transcripts are text representations of the audio the model is sending to the - /// client, and they are populated in model responses via ``LiveServerContent``. When this + /// client, and they are populated in model responses via + /// ``LiveServerContent/outputAudioTranscription``. When this /// field is set to `nil`, output transcripts are not populated in model responses. /// /// > Important: Transcripts are independent to the model turn. This means transcripts may diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveGenerativeModel.swift b/FirebaseAI/Sources/Types/Public/Live/LiveGenerativeModel.swift index a9168789ff3..3a8236cb1d5 100644 --- a/FirebaseAI/Sources/Types/Public/Live/LiveGenerativeModel.swift +++ b/FirebaseAI/Sources/Types/Public/Live/LiveGenerativeModel.swift @@ -17,7 +17,7 @@ import Foundation /// A multimodal model (like Gemini) capable of real-time content generation based on /// various input types, supporting bidirectional streaming. /// -/// You can create a new session via ``connect()``. +/// You can create a new session via ``LiveGenerativeModel/connect()``. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) @available(watchOS, unavailable) public final class LiveGenerativeModel { diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerContent.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerContent.swift index 25e29e4b891..15a8b310cf6 100644 --- a/FirebaseAI/Sources/Types/Public/Live/LiveServerContent.swift +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerContent.swift @@ -45,13 +45,15 @@ public struct LiveServerContent: Sendable { /// The model has finished _generating_ data for the current turn. /// /// For realtime playback, there will be a delay between when the model finishes generating - /// content and the client has finished playing back the generated content. `generationComplete` - /// indicates that the model is done generating data, while `isTurnComplete` indicates the model - /// is waiting for additional client messages. Sending a message during this delay may cause a - /// `wasInterrupted` message to be sent. + /// content and the client has finished playing back the generated content. + /// ``LiveServerContent/isGenerationComplete`` indicates that the model is done generating data, + /// while ``LiveServerContent/isTurnComplete`` indicates the model is waiting for additional + /// client messages. Sending a message during this delay may cause a + /// ``LiveServerContent/wasInterrupted`` message to be sent. /// - /// Note that if the model `wasInterrupted`, this will not be set. The model will go from - /// `wasInterrupted` -> `turnComplete`. + /// > Important: If the model ``LiveServerContent/wasInterrupted``, this will not be set. The + /// > model will go from ``LiveServerContent/wasInterrupted`` -> + /// > ``LiveServerContent/isTurnComplete``. public var isGenerationComplete: Bool { serverContent.generationComplete ?? false } /// Metadata specifying the sources used to ground generated content. @@ -60,7 +62,7 @@ public struct LiveServerContent: Sendable { /// The model's interpretation of what the client said in an audio message. /// /// This field is only populated when an ``AudioTranscriptionConfig`` is provided to - /// ``LiveGenerationConfig``. + /// the `inputAudioTranscription` field in ``LiveGenerationConfig``. public var inputAudioTranscription: LiveAudioTranscription? { serverContent.inputTranscription.map { LiveAudioTranscription($0) } } @@ -68,7 +70,7 @@ public struct LiveServerContent: Sendable { /// Transcription matching the model's audio response. /// /// This field is only populated when an ``AudioTranscriptionConfig`` is provided to - /// ``LiveGenerationConfig``. + /// the `outputAudioTranscription` field in ``LiveGenerationConfig``. /// /// > Important: Transcripts are independent to the model turn. This means transcripts may /// > come earlier or later than when the model sends the corresponding audio responses. diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCall.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCall.swift index 7209e312c76..6c55ee5ff4c 100644 --- a/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCall.swift +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCall.swift @@ -14,8 +14,8 @@ /// Request for the client to execute the provided ``functionCalls``. /// -/// The client should return matching ``FunctionResponsePart``, where the `functionId` fields -/// correspond to individual ``FunctionCallPart``s. +/// The client should return matching ``FunctionResponsePart``, where the +/// ``FunctionResponsePart/functionId`` fields correspond to individual ``FunctionCallPart``s. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) @available(watchOS, unavailable) public struct LiveServerToolCall: Sendable { diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCallCancellation.swift b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCallCancellation.swift index ca7973c64b7..1572c30c5bc 100644 --- a/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCallCancellation.swift +++ b/FirebaseAI/Sources/Types/Public/Live/LiveServerToolCallCancellation.swift @@ -20,8 +20,8 @@ @available(watchOS, unavailable) public struct LiveServerToolCallCancellation: Sendable { let serverToolCallCancellation: BidiGenerateContentToolCallCancellation - /// A list of `functionId`s matching the `functionId` provided in a previous - /// ``LiveServerToolCall``, where only the provided `functionId`s should be cancelled. + /// A list of function ids matching the ``FunctionCallPart/functionId`` provided in a previous + /// ``LiveServerToolCall``, where only the provided ids should be cancelled. public var ids: [String]? { serverToolCallCancellation.ids } init(_ serverToolCallCancellation: BidiGenerateContentToolCallCancellation) { diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift b/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift index 3e5e6923a59..0799e35dc03 100644 --- a/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift +++ b/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift @@ -16,9 +16,10 @@ import Foundation /// A live WebSocket session, capable of streaming content to and from the model. /// -/// Messages are streamed through ``responses``, and can be sent through either the dedicated -/// realtime API function (such as ``sendAudioRealtime(audio:)`` or ``sendTextRealtime(text:)``), or -/// through the incremental API (such as ``sendContent(_:turnComplete:)``). +/// Messages are streamed through ``LiveSession/responses``, and can be sent through either the +/// dedicated realtime API function (such as ``LiveSession/sendAudioRealtime(_:)`` and +/// ``LiveSession/sendTextRealtime(_:)``), or through the incremental API (such as +/// ``LiveSession/sendContent(_:turnComplete:)-6x3ae``). /// /// To create an instance of this class, see ``LiveGenerativeModel``. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) @@ -26,7 +27,7 @@ import Foundation public final class LiveSession: Sendable { private let service: LiveSessionService - /// An asyncronous stream of messages from the server. + /// An asynchronous stream of messages from the server. /// /// These messages from the incremental updates from the model, for the current conversation. public var responses: AsyncThrowingStream { service.responses } @@ -41,7 +42,7 @@ public final class LiveSession: Sendable { /// /// - Parameters: /// - responses: Client generated function results, matched to their respective - /// ``FunctionCallPart`` by the `functionId` field. + /// ``FunctionCallPart`` by the ``FunctionCallPart/functionId`` field. public func sendFunctionResponses(_ responses: [FunctionResponsePart]) async { let message = BidiGenerateContentToolResponse( functionResponses: responses.map { $0.functionResponse } diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveSessionErrors.swift b/FirebaseAI/Sources/Types/Public/Live/LiveSessionErrors.swift index 90b7ab84476..59a1e920e84 100644 --- a/FirebaseAI/Sources/Types/Public/Live/LiveSessionErrors.swift +++ b/FirebaseAI/Sources/Types/Public/Live/LiveSessionErrors.swift @@ -20,7 +20,8 @@ import Foundation /// version, or that the model is just /// not supported. /// -/// Check the `NSUnderlyingErrorKey` entry in ``errorUserInfo`` for the error that caused this. +/// Check the `NSUnderlyingErrorKey` entry in ``LiveSessionUnsupportedMessageError/errorUserInfo`` +/// for the error that caused this. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) @available(watchOS, unavailable) public struct LiveSessionUnsupportedMessageError: Error, Sendable, CustomNSError { @@ -40,7 +41,8 @@ public struct LiveSessionUnsupportedMessageError: Error, Sendable, CustomNSError /// The live session was closed, because the network connection was lost. /// -/// Check the `NSUnderlyingErrorKey` entry in ``errorUserInfo`` for the error that caused this. +/// Check the `NSUnderlyingErrorKey` entry in ``LiveSessionLostConnectionError/errorUserInfo`` for +/// the error that caused this. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) @available(watchOS, unavailable) public struct LiveSessionLostConnectionError: Error, Sendable, CustomNSError { @@ -60,7 +62,8 @@ public struct LiveSessionLostConnectionError: Error, Sendable, CustomNSError { /// The live session was closed, but not for a reason the SDK expected. /// -/// Check the `NSUnderlyingErrorKey` entry in ``errorUserInfo`` for the error that caused this. +/// Check the `NSUnderlyingErrorKey` entry in ``LiveSessionUnexpectedClosureError/errorUserInfo`` +/// for the error that caused this. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) @available(watchOS, unavailable) public struct LiveSessionUnexpectedClosureError: Error, Sendable, CustomNSError { @@ -83,7 +86,8 @@ public struct LiveSessionUnexpectedClosureError: Error, Sendable, CustomNSError /// This can occur due to the model not supporting the requested response modalities, the project /// not having access to the model, the model being invalid, or some internal error. /// -/// Check the `NSUnderlyingErrorKey` entry in ``errorUserInfo`` for the error that caused this. +/// Check the `NSUnderlyingErrorKey` entry in ``LiveSessionSetupError/errorUserInfo`` for the error +/// that caused this. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, *) @available(watchOS, unavailable) public struct LiveSessionSetupError: Error, Sendable, CustomNSError { diff --git a/FirebaseAI/Sources/Types/Public/Live/SpeechConfig.swift b/FirebaseAI/Sources/Types/Public/Live/SpeechConfig.swift index 67f4799f6e4..a8e291d62f3 100644 --- a/FirebaseAI/Sources/Types/Public/Live/SpeechConfig.swift +++ b/FirebaseAI/Sources/Types/Public/Live/SpeechConfig.swift @@ -24,7 +24,7 @@ public struct SpeechConfig: Sendable { self.speechConfig = speechConfig } - /// Creates a new `LiveSpeechConfig` value. + /// Creates a new ``SpeechConfig`` value. /// /// - Parameters: /// - voiceName: The name of the prebuilt voice to be used for the model's speech response. diff --git a/FirebaseAI/Sources/Types/Public/Part.swift b/FirebaseAI/Sources/Types/Public/Part.swift index 8acf7b12e9a..379ba6e6a59 100644 --- a/FirebaseAI/Sources/Types/Public/Part.swift +++ b/FirebaseAI/Sources/Types/Public/Part.swift @@ -173,7 +173,7 @@ public struct FunctionCallPart: Part { /// - name: The name of the function to call. /// - args: The function parameters and values. /// - id: Unique id of the function call. If present, the returned ``FunctionResponsePart`` - /// should have a matching `id` field. + /// should have a matching ``FunctionResponsePart/functionId`` field. public init(name: String, args: JSONObject, id: String? = nil) { self.init(FunctionCall(name: name, args: args, id: id), isThought: nil, thoughtSignature: nil) } @@ -196,7 +196,7 @@ public struct FunctionResponsePart: Part { let _isThought: Bool? let thoughtSignature: String? - /// Matching `id` for a ``FunctionCallPart``, if one was provided. + /// Matching ``FunctionCallPart/functionId`` for a ``FunctionCallPart``, if one was provided. public var functionId: String? { functionResponse.id } /// The name of the function that was called. @@ -223,7 +223,8 @@ public struct FunctionResponsePart: Part { /// - Parameters: /// - name: The name of the function that was called. /// - response: The function's response. - /// - functionId: Matching `functionId` for a ``FunctionCallPart``, if one was provided. + /// - functionId: Matching ``FunctionCallPart/functionId`` for a ``FunctionCallPart``, if one + /// was provided. public init(name: String, response: JSONObject, functionId: String? = nil) { self.init( FunctionResponse(name: name, response: response, id: functionId), From d62802aceef61b57254bba1bb463d9c500055b07 Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:49:42 -0500 Subject: [PATCH 28/54] fix(ai): Fix fraction seconds bug with ProtoDuration (#15410) --- FirebaseAI/CHANGELOG.md | 2 + .../Types/Internal/ProtoDuration.swift | 6 +- .../Tests/Unit/TestUtilities/XCTUtil.swift | 30 ++++++ .../Tests/Unit/Types/ProtoDurationTests.swift | 99 +++++++++++++++++++ 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 FirebaseAI/Tests/Unit/TestUtilities/XCTUtil.swift create mode 100644 FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index 4ee5c21b9a6..e9939f869e9 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased - [fixed] Fixed various links in the Live API doc comments not mapping correctly. +- [fixed] Fixed minor translation issue for nanosecond conversion when receiving + `LiveServerGoingAwayNotice`. (#15410) # 12.4.0 - [feature] Added support for the URL context tool, which allows the model to access content diff --git a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift index 1dac21d6429..c2b6ad6f80f 100644 --- a/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift +++ b/FirebaseAI/Sources/Types/Internal/ProtoDuration.swift @@ -94,10 +94,10 @@ extension ProtoDuration: Decodable { )) } - guard let nanos = Int32(nanoseconds) else { + guard let fractionalSeconds = Double("0.\(nanoseconds)") else { AILog.warning( code: .decodedInvalidProtoDurationNanoseconds, - "Failed to parse the nanoseconds to an Int32: \(nanoseconds)." + "Failed to parse the nanoseconds to a Double: \(nanoseconds)." ) throw DecodingError.dataCorrupted(.init( @@ -107,6 +107,6 @@ extension ProtoDuration: Decodable { } self.seconds = secs - self.nanos = nanos + nanos = Int32(fractionalSeconds * 1_000_000_000) } } diff --git a/FirebaseAI/Tests/Unit/TestUtilities/XCTUtil.swift b/FirebaseAI/Tests/Unit/TestUtilities/XCTUtil.swift new file mode 100644 index 00000000000..33ef11de6a7 --- /dev/null +++ b/FirebaseAI/Tests/Unit/TestUtilities/XCTUtil.swift @@ -0,0 +1,30 @@ +// 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 XCTest + +/// Asserts that a string contains another string. +/// +/// ```swift +/// XCTAssertContains("my name is", "name") +/// ``` +/// +/// - Parameters: +/// - string: The source string that should contain the other. +/// - contains: The string that should be contained in the source string. +func XCTAssertContains(_ string: String, _ contains: String) { + if !string.contains(contains) { + XCTFail("(\"\(string)\") does not contain (\"\(contains)\")") + } +} diff --git a/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift b/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift new file mode 100644 index 00000000000..afe33740715 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift @@ -0,0 +1,99 @@ +// 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 XCTest + +@testable import FirebaseAI + +final class ProtoDurationTests: XCTestCase { + let decoder = JSONDecoder() + + private func decodeProtoDuration(_ jsonString: String) throws -> ProtoDuration { + let escapedString = "\"\(jsonString)\"" + let jsonData = try XCTUnwrap(escapedString.data(using: .utf8)) + + return try decoder.decode(ProtoDuration.self, from: jsonData) + } + + private func expectDecodeFailure(_ jsonString: String) throws -> DecodingError.Context? { + do { + let _ = try decodeProtoDuration(jsonString) + XCTFail("Expected decoding to fail") + return nil + } catch { + let decodingError = try XCTUnwrap(error as? DecodingError) + guard case let .dataCorrupted(dataCorrupted) = decodingError else { + XCTFail("Error was not a data corrupted error") + return nil + } + + return dataCorrupted + } + } + + func testDecodeProtoDuration_standardDuration() throws { + let duration = try decodeProtoDuration("120.000000123s") + XCTAssertEqual(duration.seconds, 120) + XCTAssertEqual(duration.nanos, 123) + + XCTAssertEqual(duration.timeInterval, 120.000000123, accuracy: 1e-9) + } + + func testDecodeProtoDuration_withoutNanoseconds() throws { + let duration = try decodeProtoDuration("120s") + XCTAssertEqual(duration.seconds, 120) + XCTAssertEqual(duration.nanos, 0) + + XCTAssertEqual(duration.timeInterval, 120, accuracy: 1e-9) + } + + func testDecodeProtoDuration_maxNanosecondDigits() throws { + let duration = try decodeProtoDuration("15.123456789s") + XCTAssertEqual(duration.seconds, 15) + XCTAssertEqual(duration.nanos, 123_456_789) + + XCTAssertEqual(duration.timeInterval, 15.123456789, accuracy: 1e-9) + } + + func testDecodeProtoDuration_withMilliseconds() throws { + let duration = try decodeProtoDuration("15.123s") + XCTAssertEqual(duration.seconds, 15) + XCTAssertEqual(duration.nanos, 123_000_000) + + XCTAssertEqual(duration.timeInterval, 15.123, accuracy: 1e-9) + } + + func testDecodeProtoDuration_invalidSeconds() throws { + guard let error = try expectDecodeFailure("invalid.123s") else { return } + XCTAssertContains(error.debugDescription, "Invalid proto duration seconds") + } + + func testDecodeProtoDuration_invalidNanoseconds() throws { + guard let error = try expectDecodeFailure("123.invalid") else { return } + XCTAssertContains(error.debugDescription, "Invalid proto duration nanoseconds") + } + + func testDecodeProtoDuration_tooManyDecimals() throws { + guard let error = try expectDecodeFailure("123.45.67") else { return } + XCTAssertContains(error.debugDescription, "Invalid proto duration string") + } + + func testDecodeProtoDuration_withoutSuffix() throws { + let duration = try decodeProtoDuration("123.456") + XCTAssertEqual(duration.seconds, 123) + XCTAssertEqual(duration.nanos, 456_000_000) + + XCTAssertEqual(duration.timeInterval, 123.456, accuracy: 1e-9) + } +} From d4d24af79bc51f2e69268d875076147f6abcabfd Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:34:53 -0500 Subject: [PATCH 29/54] chore(ai): Add integration tests for Live API (#15396) --- .../project.pbxproj | 6 + .../hello.dataset/Contents.json | 12 + .../Assets.xcassets/hello.dataset/hello.wav | Bin 0 -> 27164 bytes .../Tests/TestApp/Sources/Constants.swift | 3 + .../Tests/Integration/LiveSessionTests.swift | 490 ++++++++++++++++++ .../Tests/Utilities/InstanceConfig.swift | 15 + 6 files changed, 526 insertions(+) create mode 100644 FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/Contents.json create mode 100644 FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/hello.wav create mode 100644 FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift diff --git a/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj b/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj index fc62b25f132..8b1b80e54d8 100644 --- a/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj +++ b/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 0E460FAB2E9858E4007E26A6 /* LiveSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E460FAA2E9858E4007E26A6 /* LiveSessionTests.swift */; }; + 0EC8BAE22E98784E0075A4E0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 868A7C532CCC26B500E449DD /* Assets.xcassets */; }; 862218812D04E098007ED2D4 /* IntegrationTestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 862218802D04E08D007ED2D4 /* IntegrationTestUtils.swift */; }; 864F8F712D4980DD0002EA7E /* ImagenIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 864F8F702D4980D60002EA7E /* ImagenIntegrationTests.swift */; }; 8661385C2CC943DD00F4B78E /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8661385B2CC943DD00F4B78E /* TestApp.swift */; }; @@ -42,6 +44,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0E460FAA2E9858E4007E26A6 /* LiveSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveSessionTests.swift; sourceTree = ""; }; 862218802D04E08D007ED2D4 /* IntegrationTestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTestUtils.swift; sourceTree = ""; }; 864F8F702D4980D60002EA7E /* ImagenIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagenIntegrationTests.swift; sourceTree = ""; }; 866138582CC943DD00F4B78E /* FirebaseAITestApp-SPM.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FirebaseAITestApp-SPM.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -141,6 +144,7 @@ 868A7C572CCC27AF00E449DD /* Integration */ = { isa = PBXGroup; children = ( + 0E460FAA2E9858E4007E26A6 /* LiveSessionTests.swift */, DEF0BB502DA9B7400093E9F4 /* SchemaTests.swift */, DEF0BB4E2DA74F460093E9F4 /* TestHelpers.swift */, 8689CDCB2D7F8BCF00BF426B /* CountTokensIntegrationTests.swift */, @@ -271,6 +275,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0EC8BAE22E98784E0075A4E0 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -295,6 +300,7 @@ files = ( 8689CDCC2D7F8BD700BF426B /* CountTokensIntegrationTests.swift in Sources */, 86D77E042D7B6C9D003D155D /* InstanceConfig.swift in Sources */, + 0E460FAB2E9858E4007E26A6 /* LiveSessionTests.swift in Sources */, DEF0BB512DA9B7450093E9F4 /* SchemaTests.swift in Sources */, DEF0BB4F2DA74F680093E9F4 /* TestHelpers.swift in Sources */, 868A7C4F2CCC229F00E449DD /* Credentials.swift in Sources */, diff --git a/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/Contents.json b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/Contents.json new file mode 100644 index 00000000000..7e31b8c1616 --- /dev/null +++ b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/Contents.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "hello.wav", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/hello.wav b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/hello.dataset/hello.wav new file mode 100644 index 0000000000000000000000000000000000000000..c065afa21c3b722c357b7d8f2d004392c8dcf12e GIT binary patch literal 27164 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 String? { + guard case let .string(firstName) = args["firstName"] else { + Issue.record("Missing 'firstName' argument: \(String(describing: args))") + return nil + } + + switch firstName { + case "Alex": return "Smith" + case "Bob": return "Johnson" + default: + Issue.record("Unsupported 'firstName': \(firstName)") + return nil + } + } +} + +private extension LiveSession { + /// Collects the text that the model sends for the next turn. + /// + /// Will listen for `LiveServerContent` messages from the model, + /// incrementally keeping track of any `TextPart`s it sends. Once + /// the model signals that its turn is complete, the function will return + /// a string concatenated of all the `TextPart`s. + func collectNextTextResponse() async throws -> String { + var text = "" + + for try await content in responsesOf(LiveServerContent.self) { + text += content.modelTurn?.allText() ?? "" + + if content.isTurnComplete { + break + } + } + + return text + } + + /// Collects the audio output transcripts that the model sends for the next turn. + /// + /// Will listen for `LiveServerContent` messages from the model, + /// incrementally keeping track of any `LiveAudioTranscription`s it sends. + /// Once the model signals that its turn is complete, the function will return + /// a string concatenated of all the `LiveAudioTranscription`s. + func collectNextAudioOutputTranscript() async throws -> String { + var text = "" + + for try await content in responsesOf(LiveServerContent.self) { + text += content.outputAudioText() + + if content.isTurnComplete { + break + } + } + + return text + } + + /// Waits for the next `LiveServerToolCall` message from the model, and will return it. + /// + /// If the model instead sends `LiveServerContent`, the function will attempt to keep track of + /// any messages it sends (either via `LiveAudioTranscription` or `TextPart`), and will + /// record an issue describing the message. + /// + /// This is useful when testing function calling, as sometimes the model sends an error message, + /// does something unexpected, or will attempt to get clarification. Logging the message (instead + /// of just timing out), allows us to more easily debug such situations. + func collectNextToolCall() async throws -> LiveServerToolCall? { + var error = "" + for try await response in responses { + switch response.payload { + case let .toolCall(toolCall): + return toolCall + case let .content(content): + if let text = content.modelTurn?.allText() { + error += text + } else { + error += content.outputAudioText() + } + + if content.isTurnComplete { + Issue.record("The model didn't send a tool call. Text received: \(error)") + return nil + } + default: + continue + } + } + Issue.record("Failed to receive any responses") + return nil + } + + /// Filters responses from the model to a certain type. + /// + /// Useful when you only expect (or care about) certain types. + /// + /// ```swift + /// for try await content in session.responsesOf(LiveServerContent.self) { + /// // ... + /// } + /// ``` + /// + /// Is the equivelent to manually doing: + /// ```swift + /// for try await response in session.responses { + /// if case let .content(content) = response.payload { + /// // ... + /// } + /// } + /// ``` + func responsesOf(_: T.Type) -> AsyncCompactMapSequence, T> { + responses.compactMap { response in + switch response.payload { + case let .content(content): + if let casted = content as? T { + return casted + } + case let .toolCall(toolCall): + if let casted = toolCall as? T { + return casted + } + case let .toolCallCancellation(cancellation): + if let casted = cancellation as? T { + return casted + } + case let .goingAwayNotice(goingAway): + if let casted = goingAway as? T { + return casted + } + } + return nil + } + } +} + +private extension ModelContent { + /// A collection of text from all parts. + /// + /// If this doesn't contain any `TextPart`, then an empty + /// string will be returned instead. + func allText() -> String { + parts.compactMap { ($0 as? TextPart)?.text }.joined() + } +} + +extension LiveServerContent { + /// Text of the output `LiveAudioTranscript`, or an empty string if it's missing. + func outputAudioText() -> String { + outputAudioTranscription?.text ?? "" + } +} diff --git a/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift b/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift index bf9d32c6e0d..4a91b00456d 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift @@ -26,6 +26,13 @@ struct InstanceConfig: Equatable, Encodable { version: .v1beta ) ) + static let vertexAI_v1beta_appCheckLimitedUse = InstanceConfig( + useLimitedUseAppCheckTokens: true, + apiConfig: APIConfig( + service: .vertexAI(endpoint: .firebaseProxyProd, location: "us-central1"), + version: .v1beta + ) + ) static let vertexAI_v1beta_global = InstanceConfig( apiConfig: APIConfig( service: .vertexAI(endpoint: .firebaseProxyProd, location: "global"), @@ -76,6 +83,14 @@ struct InstanceConfig: Equatable, Encodable { // googleAI_v1beta_freeTier_bypassProxy, ] + static let liveConfigs = [ + vertexAI_v1beta, + vertexAI_v1beta_appCheckLimitedUse, + googleAI_v1beta, + googleAI_v1beta_appCheckLimitedUse, + googleAI_v1beta_freeTier, + ] + static let vertexAI_v1beta_appCheckNotConfigured = InstanceConfig( appName: FirebaseAppNames.appCheckNotConfigured, apiConfig: APIConfig( From 13ef3c0723006d67585c7638283dc7747d8bc501 Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:26:43 -0500 Subject: [PATCH 30/54] chore(ai): Add Live API snippets (#15400) --- .../Tests/Unit/Snippets/LiveSnippets.swift | 259 ++++++++++++++++++ FirebaseAI/Tests/Unit/Snippets/README.md | 2 +- 2 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 FirebaseAI/Tests/Unit/Snippets/LiveSnippets.swift diff --git a/FirebaseAI/Tests/Unit/Snippets/LiveSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/LiveSnippets.swift new file mode 100644 index 00000000000..fc23571ac36 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Snippets/LiveSnippets.swift @@ -0,0 +1,259 @@ +// 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 FirebaseAI +import FirebaseCore +import XCTest + +// These snippet tests are intentionally skipped in CI jobs; see the README file in this directory +// for instructions on running them manually. + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +final class LiveSnippets: XCTestCase { + override func setUpWithError() throws { + try FirebaseApp.configureDefaultAppForSnippets() + } + + override func tearDown() async throws { + await FirebaseApp.deleteDefaultAppForSnippets() + } + + func sendAudioReceiveAudio() async throws { + // Initialize the Vertex AI Gemini API backend service + // Set the location to `us-central1` (the flash-live model is only supported in that location) + // Create a `LiveGenerativeModel` instance with the flash-live model (only model that supports + // the Live API) + let model = FirebaseAI.firebaseAI(backend: .vertexAI(location: "us-central1")).liveModel( + modelName: "gemini-2.0-flash-exp", + // Configure the model to respond with audio + generationConfig: LiveGenerationConfig( + responseModalities: [.audio] + ) + ) + + do { + let session = try await model.connect() + + // Load the audio file, or tap a microphone + guard let audioFile = NSDataAsset(name: "audio.pcm") else { + fatalError("Failed to load audio file") + } + + // Provide the audio data + await session.sendAudioRealtime(audioFile.data) + + for try await message in session.responses { + if case let .content(content) = message.payload { + content.modelTurn?.parts.forEach { part in + if let part = part as? InlineDataPart, part.mimeType.starts(with: "audio/pcm") { + // Handle 16bit pcm audio data at 24khz + playAudio(part.data) + } + } + // Optional: if you don't require to send more requests. + if content.isTurnComplete { + await session.close() + } + } + } + } catch { + fatalError(error.localizedDescription) + } + } + + func sendAudioReceiveText() async throws { + // Initialize the Vertex AI Gemini API backend service + // Set the location to `us-central1` (the flash-live model is only supported in that location) + // Create a `LiveGenerativeModel` instance with the flash-live model (only model that supports + // the Live API) + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + // Configure the model to respond with text + generationConfig: LiveGenerationConfig( + responseModalities: [.text] + ) + ) + + do { + let session = try await model.connect() + + // Load the audio file, or tap a microphone + guard let audioFile = NSDataAsset(name: "audio.pcm") else { + fatalError("Failed to load audio file") + } + + // Provide the audio data + await session.sendAudioRealtime(audioFile.data) + + var outputText = "" + for try await message in session.responses { + if case let .content(content) = message.payload { + content.modelTurn?.parts.forEach { part in + if let part = part as? TextPart { + outputText += part.text + } + } + // Optional: if you don't require to send more requests. + if content.isTurnComplete { + await session.close() + } + } + } + + // Output received from the server. + print(outputText) + } catch { + fatalError(error.localizedDescription) + } + } + + func sendTextReceiveAudio() async throws { + // Initialize the Gemini Developer API backend service + // Create a `LiveModel` instance with the flash-live model (only model that supports the Live + // API) + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + // Configure the model to respond with audio + generationConfig: LiveGenerationConfig( + responseModalities: [.audio] + ) + ) + + do { + let session = try await model.connect() + + // Provide a text prompt + let text = "tell a short story" + + await session.sendTextRealtime(text) + + for try await message in session.responses { + if case let .content(content) = message.payload { + content.modelTurn?.parts.forEach { part in + if let part = part as? InlineDataPart, part.mimeType.starts(with: "audio/pcm") { + // Handle 16bit pcm audio data at 24khz + playAudio(part.data) + } + } + // Optional: if you don't require to send more requests. + if content.isTurnComplete { + await session.close() + } + } + } + } catch { + fatalError(error.localizedDescription) + } + } + + func sendTextReceiveText() async throws { + // Initialize the Gemini Developer API backend service + // Create a `LiveModel` instance with the flash-live model (only model that supports the Live + // API) + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + // Configure the model to respond with audio + generationConfig: LiveGenerationConfig( + responseModalities: [.audio] + ) + ) + + do { + let session = try await model.connect() + + // Provide a text prompt + let text = "tell a short story" + + await session.sendTextRealtime(text) + + for try await message in session.responses { + if case let .content(content) = message.payload { + content.modelTurn?.parts.forEach { part in + if let part = part as? InlineDataPart, part.mimeType.starts(with: "audio/pcm") { + // Handle 16bit pcm audio data at 24khz + playAudio(part.data) + } + } + // Optional: if you don't require to send more requests. + if content.isTurnComplete { + await session.close() + } + } + } + } catch { + fatalError(error.localizedDescription) + } + } + + func changeVoiceAndLanguage() { + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + // Configure the model to use a specific voice for its audio response + generationConfig: LiveGenerationConfig( + responseModalities: [.audio], + speech: SpeechConfig(voiceName: "Fenrir") + ) + ) + + // Not part of snippet + silenceWarning(model) + } + + func modelParameters() { + // ... + + // Set parameter values in a `LiveGenerationConfig` (example values shown here) + let config = LiveGenerationConfig( + temperature: 0.9, + topP: 0.1, + topK: 16, + maxOutputTokens: 200, + responseModalities: [.audio], + speech: SpeechConfig(voiceName: "Fenrir") + ) + + // Initialize the Vertex AI Gemini API backend service + // Specify the config as part of creating the `LiveGenerativeModel` instance + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + generationConfig: config + ) + + // ... + + // Not part of snippet + silenceWarning(model) + } + + func systemInstructions() { + // Specify the system instructions as part of creating the `LiveGenerativeModel` instance + let model = FirebaseAI.firebaseAI(backend: .googleAI()).liveModel( + modelName: "gemini-live-2.5-flash-preview", + systemInstruction: ModelContent(role: "system", parts: "You are a cat. Your name is Neko.") + ) + + // Not part of snippet + silenceWarning(model) + } + + private func playAudio(_ data: Data) { + // Use AVAudioPlayerNode or something akin to play back audio + } + + /// This function only exists to silence the "unused value" warnings. + /// + /// This allows us to ensure the snippets match devsite. + private func silenceWarning(_ model: LiveGenerativeModel) {} +} diff --git a/FirebaseAI/Tests/Unit/Snippets/README.md b/FirebaseAI/Tests/Unit/Snippets/README.md index 6c4313f6d57..77f9aa4ee2e 100644 --- a/FirebaseAI/Tests/Unit/Snippets/README.md +++ b/FirebaseAI/Tests/Unit/Snippets/README.md @@ -5,6 +5,6 @@ documentation continue to compile. They are intentionally skipped in CI but can be manually run to verify expected behavior / outputs. To run the tests, place a valid `GoogleService-Info.plist` file in the -[`FirebaseVertexAI/Tests/Unit/Resources`](https://github.com/firebase/firebase-ios-sdk/tree/main/FirebaseVertexAI/Tests/Unit/Resources) +[`FirebaseAI/Tests/Unit/Resources`](https://github.com/firebase/firebase-ios-sdk/tree/main/FirebaseAI/Tests/Unit/Resources) folder. They may then be invoked individually or alongside the rest of the unit tests in Xcode. From 5deec1415798608076dfd60d728eb6ccae6ee400 Mon Sep 17 00:00:00 2001 From: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Date: Wed, 15 Oct 2025 17:46:01 -0400 Subject: [PATCH 31/54] fix(zip): Fix Messaging zip quickstart (#15418) --- .github/workflows/zip.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/zip.yml b/.github/workflows/zip.yml index eba3c5b61b5..91666b92f35 100644 --- a/.github/workflows/zip.yml +++ b/.github/workflows/zip.yml @@ -577,6 +577,10 @@ jobs: "${HOME}"/ios_frameworks/Firebase/FirebaseAnalytics/* - name: Setup swift quickstart run: SAMPLE="$SDK" TARGET="${SDK}ExampleSwift" scripts/setup_quickstart_framework.sh + - name: Add frameworks to Crashlytics watchOS target + run: | + cd quickstart-ios/messaging + "${GITHUB_WORKSPACE}"/quickstart-ios/scripts/add_framework_script.rb --sdk Messaging --target NotificationServiceExtension --framework_path Firebase/ - name: Install Secret GoogleService-Info.plist run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/qs-messaging.plist.gpg \ quickstart-ios/messaging/GoogleService-Info.plist "$plist_secret" From a98ea60233d94ac91b8fe0aba950c70a06f480dc Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 16 Oct 2025 11:39:54 -0400 Subject: [PATCH 32/54] [Firebase AI] Rename module to `FirebaseAILogic` (#15275) --- .github/workflows/firebaseai.yml | 10 +- .github/workflows/zip.yml | 51 +++++ FirebaseAI.podspec | 15 +- .../CountTokensIntegrationTests.swift | 4 +- .../GenerateContentIntegrationTests.swift | 4 +- .../Integration/ImagenIntegrationTests.swift | 4 +- .../Tests/Integration/IntegrationTests.swift | 4 +- .../Tests/Integration/LiveSessionTests.swift | 4 +- .../Tests/Integration/SchemaTests.swift | 4 +- .../Tests/Utilities/InstanceConfig.swift | 4 +- FirebaseAI/Tests/Unit/APITests.swift | 2 +- FirebaseAI/Tests/Unit/ChatTests.swift | 2 +- .../Tests/Unit/GenerationConfigTests.swift | 2 +- .../Unit/GenerativeModelGoogleAITests.swift | 2 +- .../Unit/GenerativeModelVertexAITests.swift | 2 +- FirebaseAI/Tests/Unit/JSONValueTests.swift | 2 +- FirebaseAI/Tests/Unit/PartTests.swift | 2 +- .../Tests/Unit/PartsRepresentableTests.swift | 2 +- .../Tests/Unit/RequestOptionsTest.swift | 2 +- FirebaseAI/Tests/Unit/SafetyTests.swift | 2 +- .../Tests/Unit/Snippets/ChatSnippets.swift | 2 +- .../Unit/Snippets/CloudStorageSnippets.swift | 2 +- .../Unit/Snippets/CountTokensSnippets.swift | 2 +- .../Snippets/FunctionCallingSnippets.swift | 2 +- .../Tests/Unit/Snippets/LiveSnippets.swift | 2 +- .../Unit/Snippets/MultimodalSnippets.swift | 2 +- .../Snippets/StructuredOutputSnippets.swift | 2 +- .../Tests/Unit/Snippets/TextSnippets.swift | 2 +- .../FirebaseAI+DefaultAPIConfig.swift | 2 +- .../GenerativeModelTestUtil.swift | 2 +- .../Tests/Unit/Types/BackendTests.swift | 2 +- .../Unit/Types/CitationMetadataTests.swift | 2 +- .../Tests/Unit/Types/CitationTests.swift | 2 +- .../Types/GenerateContentResponseTests.swift | 2 +- .../Unit/Types/GroundingMetadataTests.swift | 2 +- .../Imagen/ImageGenerationInstanceTests.swift | 2 +- .../ImageGenerationOutputOptionsTests.swift | 2 +- .../ImageGenerationParametersTests.swift | 2 +- .../Types/Imagen/ImagenGCSImageTests.swift | 2 +- .../Imagen/ImagenGenerationRequestTests.swift | 2 +- .../ImagenGenerationResponseTests.swift | 2 +- .../Types/Imagen/ImagenInlineImageTests.swift | 2 +- .../Types/Imagen/RAIFilteredReasonTests.swift | 2 +- .../Unit/Types/Internal/APIConfigTests.swift | 2 +- .../Requests/CountTokensRequestTests.swift | 2 +- .../Tests/Unit/Types/InternalPartTests.swift | 2 +- .../Unit/Types/ModalityTokenCountTests.swift | 2 +- .../Tests/Unit/Types/ProtoDateTests.swift | 2 +- .../Tests/Unit/Types/ProtoDurationTests.swift | 2 +- FirebaseAI/Tests/Unit/Types/SchemaTests.swift | 2 +- FirebaseAI/Tests/Unit/Types/ToolTests.swift | 2 +- .../Tests/Unit/VertexComponentTests.swift | 2 +- .../Wrapper/Sources/FirebaseAIWrapper.swift | 15 ++ FirebaseAI/Wrapper/Tests/APITests.swift | 186 ++++++++++++++++++ FirebaseAILogic.podspec | 70 +++++++ Package.swift | 27 ++- .../CarthageJSON/FirebaseAILogicBinary.json | 1 + .../FirebaseManifest/FirebaseManifest.swift | 3 +- .../FirebaseAILogicUnit.xcscheme | 77 ++++++++ scripts/zip_quickstart_test.sh | 2 +- 60 files changed, 493 insertions(+), 76 deletions(-) create mode 100644 FirebaseAI/Wrapper/Sources/FirebaseAIWrapper.swift create mode 100644 FirebaseAI/Wrapper/Tests/APITests.swift create mode 100644 FirebaseAILogic.podspec create mode 100644 ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json create mode 100644 scripts/spm_test_schemes/FirebaseAILogicUnit.xcscheme diff --git a/.github/workflows/firebaseai.yml b/.github/workflows/firebaseai.yml index 610c3c7d07f..d4f2ba1cce1 100644 --- a/.github/workflows/firebaseai.yml +++ b/.github/workflows/firebaseai.yml @@ -27,9 +27,12 @@ permissions: jobs: spm: + strategy: + matrix: + target: [FirebaseAILogicUnit, FirebaseAIUnit] uses: ./.github/workflows/common.yml with: - target: FirebaseAIUnit + target: ${{ matrix.target }} setup_command: scripts/update_vertexai_responses.sh testapp-integration: @@ -77,9 +80,12 @@ jobs: retention-days: 2 pod_lib_lint: + strategy: + matrix: + product: [FirebaseAILogic, FirebaseAI] uses: ./.github/workflows/common_cocoapods.yml with: - product: FirebaseAI + product: ${{ matrix.product }} supports_swift6: true setup_command: scripts/update_vertexai_responses.sh diff --git a/.github/workflows/zip.yml b/.github/workflows/zip.yml index 91666b92f35..50b67b2602d 100644 --- a/.github/workflows/zip.yml +++ b/.github/workflows/zip.yml @@ -398,6 +398,57 @@ jobs: name: quickstart_artifacts database path: quickstart-ios/ + quickstart_framework_firebaseai: + needs: package-head + if: ${{ !cancelled() && (success() || github.event.inputs.zip_run_id != '') }} + env: + plist_secret: ${{ secrets.GHASecretsGPGPassphrase1 }} + SDK: "FirebaseAI" + # This is a workaround to use the FirebaseAIExampleZip scheme that does not have the SPM dependency. + SWIFT_SUFFIX: "Zip" + strategy: + matrix: + artifact: [Firebase-actions-dir, Firebase-actions-dir-dynamic] + build-env: + - os: macos-15 + xcode: Xcode_16.4 + runs-on: ${{ matrix.build-env.os }} + steps: + - uses: actions/checkout@v4 + - name: Get framework dir + uses: actions/download-artifact@v4.1.7 + with: + name: ${{ matrix.artifact }} + run-id: ${{ github.event.inputs.zip_run_id || github.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 # v1 + - name: Xcode + run: sudo xcode-select -s /Applications/${{ matrix.build-env.xcode }}.app/Contents/Developer + - name: Setup Bundler + run: ./scripts/setup_bundler.sh + - name: Move frameworks + run: | + mkdir -p "${HOME}"/ios_frameworks/ + find "${GITHUB_WORKSPACE}" -name "Firebase*latest.zip" -exec unzip -d "${HOME}"/ios_frameworks/ {} + + - uses: actions/checkout@v4 + - name: Setup quickstart + run: SAMPLE="$SDK" TARGET="${SDK}ExampleZip" scripts/setup_quickstart_framework.sh \ + "${HOME}"/ios_frameworks/Firebase/FirebaseAILogic/* \ + "${HOME}"/ios_frameworks/Firebase/FirebaseAnalytics/* + - name: Install Secret GoogleService-Info.plist + run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg \ + quickstart-ios/firebaseai/GoogleService-Info.plist "$plist_secret" + - name: Test Quickstart + run: ([ -z $plist_secret ] || scripts/third_party/travis/retry.sh scripts/test_quickstart_framework.sh "${SDK}") + - name: Remove data before upload + if: ${{ failure() }} + run: scripts/remove_data.sh firebaseai + - uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: quickstart_artifacts_firebaseai + path: quickstart-ios/ + quickstart_framework_firestore: needs: package-head if: ${{ !cancelled() && (success() || github.event.inputs.zip_run_id != '') }} diff --git a/FirebaseAI.podspec b/FirebaseAI.podspec index 39ebbd0e40e..41559ed7ca7 100644 --- a/FirebaseAI.podspec +++ b/FirebaseAI.podspec @@ -32,7 +32,7 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI SDK s.prefix_header_file = false s.source_files = [ - 'FirebaseAI/Sources/**/*.swift', + 'FirebaseAI/Wrapper/Sources/**/*.swift', ] s.swift_version = '5.9' @@ -43,13 +43,11 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI SDK s.tvos.framework = 'UIKit' s.watchos.framework = 'WatchKit' - s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' - s.dependency 'FirebaseAuthInterop', '~> 12.5.0' + s.dependency 'FirebaseAILogic', '12.5.0' s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseCoreExtension', '~> 12.5.0' s.test_spec 'unit' do |unit_tests| - unit_tests_dir = 'FirebaseAI/Tests/Unit/' + unit_tests_dir = 'FirebaseAI/Wrapper/Tests/' unit_tests.scheme = { :code_coverage => true } unit_tests.platforms = { :ios => ios_deployment_target, @@ -59,12 +57,5 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI SDK unit_tests.source_files = [ unit_tests_dir + '**/*.swift', ] - unit_tests.exclude_files = [ - unit_tests_dir + 'Snippets/**/*.swift', - ] - unit_tests.resources = [ - unit_tests_dir + 'vertexai-sdk-test-data/mock-responses', - unit_tests_dir + 'Resources/**/*', - ] end end diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift index 1e68b640dfb..30e8f897c58 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/CountTokensIntegrationTests.swift @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseAuth import FirebaseCore import FirebaseStorage import Testing -@testable import struct FirebaseAI.APIConfig +@testable import struct FirebaseAILogic.APIConfig @Suite(.serialized) struct CountTokensIntegrationTests { diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index fe3d96207fc..d83a6b4a18d 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseAuth import FirebaseCore @@ -23,7 +23,7 @@ import Testing import UIKit #endif // canImport(UIKit) -@testable import struct FirebaseAI.BackendError +@testable import struct FirebaseAILogic.BackendError @Suite(.serialized) struct GenerateContentIntegrationTests { diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift index ade781e6176..95a4f04ff2b 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseAuth import FirebaseCore @@ -24,7 +24,7 @@ import Testing #endif // canImport(UIKit) // TODO(#14452): Remove `@testable import` when `generateImages(prompt:gcsURI:)` is public. -@testable import class FirebaseAI.ImagenModel +@testable import class FirebaseAILogic.ImagenModel @Suite( .enabled( diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/IntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/IntegrationTests.swift index c4c49d4b45f..37353eba51a 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/IntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/IntegrationTests.swift @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseAuth import FirebaseCore import FirebaseStorage import XCTest -@testable import struct FirebaseAI.CountTokensRequest +@testable import struct FirebaseAILogic.CountTokensRequest // TODO(#14405): Migrate to Swift Testing and parameterize tests. final class IntegrationTests: XCTestCase { diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift index ee775a98cca..cb341697043 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift @@ -12,12 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import SwiftUI import Testing -@testable import struct FirebaseAI.APIConfig +@testable import struct FirebaseAILogic.APIConfig @Suite(.serialized) struct LiveSessionTests { diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift index 640b353dc2f..4f4dd1e3dc8 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseAuth import FirebaseCore @@ -23,7 +23,7 @@ import Testing import UIKit #endif // canImport(UIKit) -@testable import struct FirebaseAI.BackendError +@testable import struct FirebaseAILogic.BackendError /// Test the schema fields. @Suite(.serialized) diff --git a/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift b/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift index 4a91b00456d..12b8f4da70b 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift @@ -12,12 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAITestApp import FirebaseCore import Testing -@testable import struct FirebaseAI.APIConfig +@testable import struct FirebaseAILogic.APIConfig struct InstanceConfig: Equatable, Encodable { static let vertexAI_v1beta = InstanceConfig( diff --git a/FirebaseAI/Tests/Unit/APITests.swift b/FirebaseAI/Tests/Unit/APITests.swift index 16c963b1f0c..fbfd647533d 100644 --- a/FirebaseAI/Tests/Unit/APITests.swift +++ b/FirebaseAI/Tests/Unit/APITests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest #if canImport(AppKit) diff --git a/FirebaseAI/Tests/Unit/ChatTests.swift b/FirebaseAI/Tests/Unit/ChatTests.swift index 7ecebf42e28..b2e43ba610c 100644 --- a/FirebaseAI/Tests/Unit/ChatTests.swift +++ b/FirebaseAI/Tests/Unit/ChatTests.swift @@ -16,7 +16,7 @@ import FirebaseCore import Foundation import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ChatTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/GenerationConfigTests.swift b/FirebaseAI/Tests/Unit/GenerationConfigTests.swift index 22bcd70b035..2b38d1898d4 100644 --- a/FirebaseAI/Tests/Unit/GenerationConfigTests.swift +++ b/FirebaseAI/Tests/Unit/GenerationConfigTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import Foundation import XCTest diff --git a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift index 59e1581a638..f04e1d387df 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift @@ -17,7 +17,7 @@ import FirebaseAuthInterop import FirebaseCore import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class GenerativeModelGoogleAITests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift index 1d2498f07e5..b302af838c4 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift @@ -17,7 +17,7 @@ import FirebaseAuthInterop import FirebaseCore import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class GenerativeModelVertexAITests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/JSONValueTests.swift b/FirebaseAI/Tests/Unit/JSONValueTests.swift index 54ac3520e77..1f1beafe922 100644 --- a/FirebaseAI/Tests/Unit/JSONValueTests.swift +++ b/FirebaseAI/Tests/Unit/JSONValueTests.swift @@ -13,7 +13,7 @@ // limitations under the License. import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class JSONValueTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/PartTests.swift b/FirebaseAI/Tests/Unit/PartTests.swift index f538586d439..6c5429928d0 100644 --- a/FirebaseAI/Tests/Unit/PartTests.swift +++ b/FirebaseAI/Tests/Unit/PartTests.swift @@ -15,7 +15,7 @@ import Foundation import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class PartTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/PartsRepresentableTests.swift b/FirebaseAI/Tests/Unit/PartsRepresentableTests.swift index 658db79a50e..266a64b3429 100644 --- a/FirebaseAI/Tests/Unit/PartsRepresentableTests.swift +++ b/FirebaseAI/Tests/Unit/PartsRepresentableTests.swift @@ -23,7 +23,7 @@ import XCTest import CoreImage #endif // canImport(CoreImage) -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class PartsRepresentableTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/RequestOptionsTest.swift b/FirebaseAI/Tests/Unit/RequestOptionsTest.swift index 5c03a6b63f4..92ab65f1f95 100644 --- a/FirebaseAI/Tests/Unit/RequestOptionsTest.swift +++ b/FirebaseAI/Tests/Unit/RequestOptionsTest.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class RequestOptionsTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/SafetyTests.swift b/FirebaseAI/Tests/Unit/SafetyTests.swift index 4a1e07e04e3..b4422b61fb5 100644 --- a/FirebaseAI/Tests/Unit/SafetyTests.swift +++ b/FirebaseAI/Tests/Unit/SafetyTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class SafetyTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Snippets/ChatSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/ChatSnippets.swift index cfd8089b65b..67bd5cb9a4a 100644 --- a/FirebaseAI/Tests/Unit/Snippets/ChatSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/ChatSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/Snippets/CloudStorageSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/CloudStorageSnippets.swift index 63b1be6d069..3d04e156312 100644 --- a/FirebaseAI/Tests/Unit/Snippets/CloudStorageSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/CloudStorageSnippets.swift @@ -14,7 +14,7 @@ #if SWIFT_PACKAGE // The FirebaseStorage dependency has only been added in Package.swift. - import FirebaseAI + import FirebaseAILogic import FirebaseCore import FirebaseStorage diff --git a/FirebaseAI/Tests/Unit/Snippets/CountTokensSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/CountTokensSnippets.swift index 8b8e37368f9..37c4f7e3c04 100644 --- a/FirebaseAI/Tests/Unit/Snippets/CountTokensSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/CountTokensSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/Snippets/FunctionCallingSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/FunctionCallingSnippets.swift index e8ef9bf512c..dac1dea76ba 100644 --- a/FirebaseAI/Tests/Unit/Snippets/FunctionCallingSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/FunctionCallingSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/Snippets/LiveSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/LiveSnippets.swift index fc23571ac36..6b183d958b4 100644 --- a/FirebaseAI/Tests/Unit/Snippets/LiveSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/LiveSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/Snippets/MultimodalSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/MultimodalSnippets.swift index eeda8052cc6..7e8af1e3882 100644 --- a/FirebaseAI/Tests/Unit/Snippets/MultimodalSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/MultimodalSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/Snippets/StructuredOutputSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/StructuredOutputSnippets.swift index 8db4f803461..17c426c3651 100644 --- a/FirebaseAI/Tests/Unit/Snippets/StructuredOutputSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/StructuredOutputSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/Snippets/TextSnippets.swift b/FirebaseAI/Tests/Unit/Snippets/TextSnippets.swift index 31c54648c5a..47ee865585d 100644 --- a/FirebaseAI/Tests/Unit/Snippets/TextSnippets.swift +++ b/FirebaseAI/Tests/Unit/Snippets/TextSnippets.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseCore import XCTest diff --git a/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift b/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift index 9e89f605f75..3223a2abe2e 100644 --- a/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift +++ b/FirebaseAI/Tests/Unit/TestUtilities/FirebaseAI+DefaultAPIConfig.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension FirebaseAI { diff --git a/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift b/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift index ee4f47bc5b0..7f9a8724363 100644 --- a/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift +++ b/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift @@ -18,7 +18,7 @@ import FirebaseCore import Foundation import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) enum GenerativeModelTestUtil { diff --git a/FirebaseAI/Tests/Unit/Types/BackendTests.swift b/FirebaseAI/Tests/Unit/Types/BackendTests.swift index d0fe40c7cbc..5193918849e 100644 --- a/FirebaseAI/Tests/Unit/Types/BackendTests.swift +++ b/FirebaseAI/Tests/Unit/Types/BackendTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic final class BackendTests: XCTestCase { func testVertexAI_defaultLocation() { diff --git a/FirebaseAI/Tests/Unit/Types/CitationMetadataTests.swift b/FirebaseAI/Tests/Unit/Types/CitationMetadataTests.swift index d75325f1a88..4c908813d68 100644 --- a/FirebaseAI/Tests/Unit/Types/CitationMetadataTests.swift +++ b/FirebaseAI/Tests/Unit/Types/CitationMetadataTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class CitationMetadataTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/CitationTests.swift b/FirebaseAI/Tests/Unit/Types/CitationTests.swift index ced45526721..1a372d8fd47 100644 --- a/FirebaseAI/Tests/Unit/Types/CitationTests.swift +++ b/FirebaseAI/Tests/Unit/Types/CitationTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import XCTest @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift b/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift index dfc393e2d29..1d2a86d4526 100644 --- a/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift +++ b/FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -@testable import FirebaseAI +@testable import FirebaseAILogic import XCTest @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift b/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift index 132d47fc589..ca5e8dc3ede 100644 --- a/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift +++ b/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class GroundingMetadataTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationInstanceTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationInstanceTests.swift index ce66fe94cb7..4bebe401e55 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationInstanceTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationInstanceTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImageGenerationInstanceTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationOutputOptionsTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationOutputOptionsTests.swift index bd5b9f10e44..9e135fa622c 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationOutputOptionsTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationOutputOptionsTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImageGenerationOutputOptionsTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationParametersTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationParametersTests.swift index a96174f3b7d..0d398738111 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationParametersTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImageGenerationParametersTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImageGenerationParametersTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGCSImageTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGCSImageTests.swift index 6bf98306cbf..84da2d5a300 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGCSImageTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGCSImageTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImagenGCSImageTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift index 9a48ed7c8a2..f36376061d7 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImagenGenerationRequestTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift index 97122401253..66e6cab4c8c 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImagenGenerationResponseTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenInlineImageTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenInlineImageTests.swift index 31effc5c0bf..0894b27fb44 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenInlineImageTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenInlineImageTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ImagenInlineImageTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/RAIFilteredReasonTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/RAIFilteredReasonTests.swift index 90ac676f90a..4fdfa4416c7 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/RAIFilteredReasonTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/RAIFilteredReasonTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class RAIFilteredReasonTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift b/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift index 937b858d40b..79932f20e1b 100644 --- a/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Internal/APIConfigTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class APIConfigTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift b/FirebaseAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift index 6e2f1f790e8..7c43833ed45 100644 --- a/FirebaseAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Internal/Requests/CountTokensRequestTests.swift @@ -15,7 +15,7 @@ import Foundation import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class CountTokensRequestTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift b/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift index 65121e913c2..3094135c5ab 100644 --- a/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift +++ b/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -@testable import FirebaseAI +@testable import FirebaseAILogic import XCTest @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/FirebaseAI/Tests/Unit/Types/ModalityTokenCountTests.swift b/FirebaseAI/Tests/Unit/Types/ModalityTokenCountTests.swift index cd56a0c67d1..12a58e992bb 100644 --- a/FirebaseAI/Tests/Unit/Types/ModalityTokenCountTests.swift +++ b/FirebaseAI/Tests/Unit/Types/ModalityTokenCountTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import XCTest @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/FirebaseAI/Tests/Unit/Types/ProtoDateTests.swift b/FirebaseAI/Tests/Unit/Types/ProtoDateTests.swift index dbe6c2e27ca..7f4315f1012 100644 --- a/FirebaseAI/Tests/Unit/Types/ProtoDateTests.swift +++ b/FirebaseAI/Tests/Unit/Types/ProtoDateTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic final class ProtoDateTests: XCTestCase { let decoder = JSONDecoder() diff --git a/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift b/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift index afe33740715..8db761872a5 100644 --- a/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift +++ b/FirebaseAI/Tests/Unit/Types/ProtoDurationTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic final class ProtoDurationTests: XCTestCase { let decoder = JSONDecoder() diff --git a/FirebaseAI/Tests/Unit/Types/SchemaTests.swift b/FirebaseAI/Tests/Unit/Types/SchemaTests.swift index 4f911b31bd7..a24b4048645 100644 --- a/FirebaseAI/Tests/Unit/Types/SchemaTests.swift +++ b/FirebaseAI/Tests/Unit/Types/SchemaTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import Foundation import XCTest diff --git a/FirebaseAI/Tests/Unit/Types/ToolTests.swift b/FirebaseAI/Tests/Unit/Types/ToolTests.swift index 9bfdf2313b7..b429cb5369b 100644 --- a/FirebaseAI/Tests/Unit/Types/ToolTests.swift +++ b/FirebaseAI/Tests/Unit/Types/ToolTests.swift @@ -14,7 +14,7 @@ import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class ToolTests: XCTestCase { diff --git a/FirebaseAI/Tests/Unit/VertexComponentTests.swift b/FirebaseAI/Tests/Unit/VertexComponentTests.swift index 66b3ae68576..9d33df1ff50 100644 --- a/FirebaseAI/Tests/Unit/VertexComponentTests.swift +++ b/FirebaseAI/Tests/Unit/VertexComponentTests.swift @@ -17,7 +17,7 @@ internal import FirebaseCoreExtension import Foundation import XCTest -@testable import FirebaseAI +@testable import FirebaseAILogic @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) class VertexComponentTests: XCTestCase { diff --git a/FirebaseAI/Wrapper/Sources/FirebaseAIWrapper.swift b/FirebaseAI/Wrapper/Sources/FirebaseAIWrapper.swift new file mode 100644 index 00000000000..a4ff8613b44 --- /dev/null +++ b/FirebaseAI/Wrapper/Sources/FirebaseAIWrapper.swift @@ -0,0 +1,15 @@ +// 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. + +@_exported import FirebaseAILogic diff --git a/FirebaseAI/Wrapper/Tests/APITests.swift b/FirebaseAI/Wrapper/Tests/APITests.swift new file mode 100644 index 00000000000..16c963b1f0c --- /dev/null +++ b/FirebaseAI/Wrapper/Tests/APITests.swift @@ -0,0 +1,186 @@ +// Copyright 2023 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 FirebaseAI +import FirebaseCore +import XCTest +#if canImport(AppKit) + import AppKit // For NSImage extensions. +#elseif canImport(UIKit) + import UIKit // For UIImage extensions. +#endif + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class APITests: XCTestCase { + func codeSamples() async throws { + let app = FirebaseApp.app() + let config = GenerationConfig(temperature: 0.2, + topP: 0.1, + topK: 16, + candidateCount: 4, + maxOutputTokens: 256, + stopSequences: ["..."], + responseMIMEType: "text/plain") + let filters = [SafetySetting(harmCategory: .dangerousContent, threshold: .blockOnlyHigh)] + let systemInstruction = ModelContent( + role: "system", + parts: TextPart("Talk like a pirate.") + ) + + let requestOptions = RequestOptions() + let _ = RequestOptions(timeout: 30.0) + + // Instantiate Firebase AI SDK - Default App + let firebaseAI = FirebaseAI.firebaseAI() + let _ = FirebaseAI.firebaseAI(backend: .googleAI()) + let _ = FirebaseAI.firebaseAI(backend: .vertexAI()) + let _ = FirebaseAI.firebaseAI(backend: .vertexAI(location: "my-location")) + + // Instantiate Firebase AI SDK - Custom App + let _ = FirebaseAI.firebaseAI(app: app!) + let _ = FirebaseAI.firebaseAI(app: app!, backend: .googleAI()) + let _ = FirebaseAI.firebaseAI(app: app!, backend: .vertexAI()) + let _ = FirebaseAI.firebaseAI(app: app!, backend: .vertexAI(location: "my-location")) + + // Permutations without optional arguments. + + let _ = firebaseAI.generativeModel(modelName: "gemini-2.0-flash") + + let _ = firebaseAI.generativeModel( + modelName: "gemini-2.0-flash", + safetySettings: filters + ) + + let _ = firebaseAI.generativeModel( + modelName: "gemini-2.0-flash", + generationConfig: config + ) + + let _ = firebaseAI.generativeModel( + modelName: "gemini-2.0-flash", + systemInstruction: systemInstruction + ) + + // All arguments passed. + let model = firebaseAI.generativeModel( + modelName: "gemini-2.0-flash", + generationConfig: config, // Optional + safetySettings: filters, // Optional + systemInstruction: systemInstruction, // Optional + requestOptions: requestOptions // Optional + ) + + // Full Typed Usage + let pngData = Data() // .... + let contents = [ModelContent( + role: "user", + parts: [ + TextPart("Is it a cat?"), + InlineDataPart(data: pngData, mimeType: "image/png"), + ] + )] + + do { + let response = try await model.generateContent(contents) + print(response.text ?? "Couldn't get text... check status") + } catch { + print("Error generating content: \(error)") + } + + // Content input combinations. + let _ = try await model.generateContent("Constant String") + let str = "String Variable" + let _ = try await model.generateContent(str) + let _ = try await model.generateContent([str]) + let _ = try await model.generateContent(str, "abc", "def") + let _ = try await model.generateContent( + str, + FileDataPart(uri: "gs://test-bucket/image.jpg", mimeType: "image/jpeg") + ) + #if canImport(UIKit) + _ = try await model.generateContent(UIImage()) + _ = try await model.generateContent([UIImage()]) + _ = try await model.generateContent([str, UIImage(), TextPart(str)]) + _ = try await model.generateContent(str, UIImage(), "def", UIImage()) + _ = try await model.generateContent([str, UIImage(), "def", UIImage()]) + _ = try await model.generateContent([ModelContent(parts: "def", UIImage()), + ModelContent(parts: "def", UIImage())]) + #elseif canImport(AppKit) + _ = try await model.generateContent(NSImage()) + _ = try await model.generateContent([NSImage()]) + _ = try await model.generateContent(str, NSImage(), "def", NSImage()) + _ = try await model.generateContent([str, NSImage(), "def", NSImage()]) + #endif + + // PartsRepresentable combinations. + let _ = ModelContent(parts: [TextPart(str)]) + let _ = ModelContent(role: "model", parts: [TextPart(str)]) + let _ = ModelContent(parts: "Constant String") + let _ = ModelContent(parts: str) + let _ = ModelContent(parts: [str]) + let _ = ModelContent(parts: [str, InlineDataPart(data: Data(), mimeType: "foo")]) + #if canImport(UIKit) + _ = ModelContent(role: "user", parts: UIImage()) + _ = ModelContent(role: "user", parts: [UIImage()]) + _ = ModelContent(parts: [str, UIImage()]) + // Note: without explicitly specifying`: [any PartsRepresentable]` this will fail to compile + // below with "Cannot convert value of type `[Any]` to expected type `[any Part]`. + let representable2: [any PartsRepresentable] = [str, UIImage()] + _ = ModelContent(parts: representable2) + _ = ModelContent(parts: [str, UIImage(), TextPart(str)]) + #elseif canImport(AppKit) + _ = ModelContent(role: "user", parts: NSImage()) + _ = ModelContent(role: "user", parts: [NSImage()]) + _ = ModelContent(parts: [str, NSImage()]) + // Note: without explicitly specifying`: [any PartsRepresentable]` this will fail to compile + // below with "Cannot convert value of type `[Any]` to expected type `[any Part]`. + let representable2: [any PartsRepresentable] = [str, NSImage()] + _ = ModelContent(parts: representable2) + _ = ModelContent(parts: [str, NSImage(), TextPart(str)]) + #endif + + // countTokens API + let _: CountTokensResponse = try await model.countTokens("What color is the Sky?") + #if canImport(UIKit) + let _: CountTokensResponse = try await model.countTokens("What color is the Sky?", + UIImage()) + let _: CountTokensResponse = try await model.countTokens([ + ModelContent(parts: "What color is the Sky?", UIImage()), + ModelContent(parts: UIImage(), "What color is the Sky?", UIImage()), + ]) + #endif + + // Chat + _ = model.startChat() + _ = model.startChat(history: [ModelContent(parts: "abc")]) + } + + // Public API tests for GenerateContentResponse. + func generateContentResponseAPI() { + let response = GenerateContentResponse(candidates: []) + + let _: [Candidate] = response.candidates + let _: PromptFeedback? = response.promptFeedback + + // Usage Metadata + guard let usageMetadata = response.usageMetadata else { fatalError() } + let _: Int = usageMetadata.promptTokenCount + let _: Int = usageMetadata.candidatesTokenCount + let _: Int = usageMetadata.totalTokenCount + + // Computed Properties + let _: String? = response.text + let _: [FunctionCallPart] = response.functionCalls + } +} diff --git a/FirebaseAILogic.podspec b/FirebaseAILogic.podspec new file mode 100644 index 00000000000..74142cc82b2 --- /dev/null +++ b/FirebaseAILogic.podspec @@ -0,0 +1,70 @@ +Pod::Spec.new do |s| + s.name = 'FirebaseAILogic' + s.version = '12.5.0' + s.summary = 'Firebase AI Logic SDK' + + s.description = <<-DESC +Build AI-powered apps and features with the Gemini API using the Firebase AI Logic SDK. + DESC + + s.homepage = 'https://firebase.google.com' + s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } + s.authors = 'Google, Inc.' + + s.source = { + :git => 'https://github.com/firebase/firebase-ios-sdk.git', + :tag => 'CocoaPods-' + s.version.to_s + } + + s.social_media_url = 'https://twitter.com/Firebase' + + ios_deployment_target = '15.0' + osx_deployment_target = '12.0' + tvos_deployment_target = '15.0' + watchos_deployment_target = '8.0' + + s.ios.deployment_target = ios_deployment_target + s.osx.deployment_target = osx_deployment_target + s.tvos.deployment_target = tvos_deployment_target + s.watchos.deployment_target = watchos_deployment_target + + s.cocoapods_version = '>= 1.12.0' + s.prefix_header_file = false + + s.source_files = [ + 'FirebaseAI/Sources/**/*.swift', + ] + + s.swift_version = '5.9' + + s.framework = 'Foundation' + s.ios.framework = 'UIKit' + s.osx.framework = 'AppKit' + s.tvos.framework = 'UIKit' + s.watchos.framework = 'WatchKit' + + s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' + s.dependency 'FirebaseAuthInterop', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCoreExtension', '~> 12.5.0' + + s.test_spec 'unit' do |unit_tests| + unit_tests_dir = 'FirebaseAI/Tests/Unit/' + unit_tests.scheme = { :code_coverage => true } + unit_tests.platforms = { + :ios => ios_deployment_target, + :osx => osx_deployment_target, + :tvos => tvos_deployment_target + } + unit_tests.source_files = [ + unit_tests_dir + '**/*.swift', + ] + unit_tests.exclude_files = [ + unit_tests_dir + 'Snippets/**/*.swift', + ] + unit_tests.resources = [ + unit_tests_dir + 'vertexai-sdk-test-data/mock-responses', + unit_tests_dir + 'Resources/**/*', + ] + end +end diff --git a/Package.swift b/Package.swift index a3a5ecad82e..b5e4b2b2881 100644 --- a/Package.swift +++ b/Package.swift @@ -26,7 +26,16 @@ let package = Package( products: [ .library( name: "FirebaseAI", - targets: ["FirebaseAI"] + targets: [ + "FirebaseAI", + "FirebaseAILogic", + ] + ), + .library( + name: "FirebaseAILogic", + targets: [ + "FirebaseAILogic", + ] ), .library( name: "FirebaseAnalytics", @@ -178,7 +187,7 @@ let package = Package( // MARK: - Firebase AI .target( - name: "FirebaseAI", + name: "FirebaseAILogic", dependencies: [ "FirebaseAppCheckInterop", "FirebaseAuthInterop", @@ -188,9 +197,9 @@ let package = Package( path: "FirebaseAI/Sources" ), .testTarget( - name: "FirebaseAIUnit", + name: "FirebaseAILogicUnit", dependencies: [ - "FirebaseAI", + "FirebaseAILogic", "FirebaseStorage", ], path: "FirebaseAI/Tests/Unit", @@ -202,6 +211,16 @@ let package = Package( .headerSearchPath("../../../"), ] ), + .target( + name: "FirebaseAI", + dependencies: ["FirebaseAILogic"], + path: "FirebaseAI/Wrapper/Sources" + ), + .testTarget( + name: "FirebaseAIUnit", + dependencies: ["FirebaseAI"], + path: "FirebaseAI/Wrapper/Tests" + ), // MARK: - Firebase Core diff --git a/ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json @@ -0,0 +1 @@ +{} diff --git a/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift b/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift index e55dc32df71..de621d61523 100755 --- a/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift +++ b/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift @@ -38,7 +38,8 @@ public let shared = Manifest( Pod("FirebaseABTesting", zip: true), Pod("FirebaseAppCheck", zip: true), Pod("FirebaseRemoteConfig", zip: true), - Pod("FirebaseAI", zip: true), + Pod("FirebaseAILogic", zip: true), + Pod("FirebaseAI", zip: false), Pod("FirebaseAppDistribution", isBeta: true, platforms: ["ios"], zip: true), Pod("FirebaseAuth", zip: true), Pod("FirebaseCrashlytics", zip: true), diff --git a/scripts/spm_test_schemes/FirebaseAILogicUnit.xcscheme b/scripts/spm_test_schemes/FirebaseAILogicUnit.xcscheme new file mode 100644 index 00000000000..22c999d990a --- /dev/null +++ b/scripts/spm_test_schemes/FirebaseAILogicUnit.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/zip_quickstart_test.sh b/scripts/zip_quickstart_test.sh index 3b531f8e3f9..2771b8eb02a 100755 --- a/scripts/zip_quickstart_test.sh +++ b/scripts/zip_quickstart_test.sh @@ -46,7 +46,7 @@ fi xcodebuild \ -project ${SAMPLE}Example.xcodeproj \ -scheme ${SAMPLE}Example${SWIFT_SUFFIX} \ --destination "platform=iOS Simulator,name=$device_name" "SWIFT_VERSION=5.3" "OTHER_LDFLAGS=\$(OTHER_LDFLAGS) -ObjC" "FRAMEWORK_SEARCH_PATHS= \$(PROJECT_DIR)/Firebase/" HEADER_SEARCH_PATHS='$(PROJECT_DIR)/Firebase' \ +-destination "platform=iOS Simulator,name=$device_name" "SWIFT_VERSION=5.3" "OTHER_LDFLAGS=\$(OTHER_LDFLAGS) -ObjC" "FRAMEWORK_SEARCH_PATHS= \$(PROJECT_DIR)/Firebase/" HEADER_SEARCH_PATHS='$(inherited) $(PROJECT_DIR)/Firebase' \ build \ ) || EXIT_STATUS=$? From 58369d76bbd2840189a166cede5a700a861225b3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:47:46 -0400 Subject: [PATCH 33/54] NOTICES Change (#15424) Co-authored-by: Anka --- CoreOnly/NOTICES | 1 + 1 file changed, 1 insertion(+) diff --git a/CoreOnly/NOTICES b/CoreOnly/NOTICES index 73c5af857ff..b77d58829f9 100644 --- a/CoreOnly/NOTICES +++ b/CoreOnly/NOTICES @@ -2,6 +2,7 @@ AppCheckCore Firebase FirebaseABTesting FirebaseAI +FirebaseAILogic FirebaseAppCheck FirebaseAppCheckInterop FirebaseAppDistribution From 0e5a4e04242ca0d46b36d156b8871db57fe2fcdb Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:25:51 -0500 Subject: [PATCH 34/54] chore(ai): Add unit tests for Live API (#15411) Co-authored-by: Andrew Heard --- ...idiGenerateContentServerMessageTests.swift | 191 ++++++++++++++++++ .../Unit/Types/Live/VoiceConfigTests.swift | 62 ++++++ 2 files changed, 253 insertions(+) create mode 100644 FirebaseAI/Tests/Unit/Types/Live/BidiGenerateContentServerMessageTests.swift create mode 100644 FirebaseAI/Tests/Unit/Types/Live/VoiceConfigTests.swift diff --git a/FirebaseAI/Tests/Unit/Types/Live/BidiGenerateContentServerMessageTests.swift b/FirebaseAI/Tests/Unit/Types/Live/BidiGenerateContentServerMessageTests.swift new file mode 100644 index 00000000000..cf3403b37a1 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/Live/BidiGenerateContentServerMessageTests.swift @@ -0,0 +1,191 @@ +// 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 XCTest + +@testable import FirebaseAILogic + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +final class BidiGenerateContentServerMessageTests: XCTestCase { + let decoder = JSONDecoder() + + func testDecodeBidiGenerateContentServerMessage_setupComplete() throws { + let json = """ + { + "setupComplete" : {} + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let serverMessage = try decoder.decode(BidiGenerateContentServerMessage.self, from: jsonData) + guard case .setupComplete = serverMessage.messageType else { + XCTFail("Decoded message is not a setupComplete message.") + return + } + } + + func testDecodeBidiGenerateContentServerMessage_serverContent() throws { + let json = """ + { + "serverContent" : { + "modelTurn" : { + "parts" : [ + { + "inlineData" : { + "data" : "BQUFBQU=", + "mimeType" : "audio/pcm" + } + } + ], + "role" : "model" + }, + "turnComplete": true, + "groundingMetadata": { + "webSearchQueries": ["query1", "query2"], + "groundingChunks": [ + { "web": { "uri": "uri1", "title": "title1" } } + ], + "groundingSupports": [ + { "segment": { "endIndex": 10, "text": "text" }, "groundingChunkIndices": [0] } + ], + "searchEntryPoint": { "renderedContent": "html" } + }, + "inputTranscription": { + "text": "What day of the week is it?" + }, + "outputTranscription": { + "text": "Today is friday" + } + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let serverMessage = try decoder.decode(BidiGenerateContentServerMessage.self, from: jsonData) + guard case let .serverContent(serverContent) = serverMessage.messageType else { + XCTFail("Decoded message is not a serverContent message.") + return + } + + XCTAssertEqual(serverContent.turnComplete, true) + XCTAssertNil(serverContent.interrupted) + XCTAssertNil(serverContent.generationComplete) + + let modelTurn = try XCTUnwrap(serverContent.modelTurn) + XCTAssertEqual(modelTurn.role, "model") + XCTAssertEqual(modelTurn.parts.count, 1) + let part = try XCTUnwrap(modelTurn.parts.first as? InlineDataPart) + XCTAssertEqual(part.data, Data(repeating: 5, count: 5)) + XCTAssertEqual(part.mimeType, "audio/pcm") + + let metadata = try XCTUnwrap(serverContent.groundingMetadata) + XCTAssertEqual(metadata.webSearchQueries, ["query1", "query2"]) + XCTAssertEqual(metadata.groundingChunks.count, 1) + let groundingChunk = try XCTUnwrap(metadata.groundingChunks.first) + let webChunk = try XCTUnwrap(groundingChunk.web) + XCTAssertEqual(webChunk.uri, "uri1") + XCTAssertEqual(metadata.groundingSupports.count, 1) + let groundingSupport = try XCTUnwrap(metadata.groundingSupports.first) + XCTAssertEqual(groundingSupport.segment.startIndex, 0) + XCTAssertEqual(groundingSupport.segment.partIndex, 0) + XCTAssertEqual(groundingSupport.segment.endIndex, 10) + XCTAssertEqual(groundingSupport.segment.text, "text") + let searchEntryPoint = try XCTUnwrap(metadata.searchEntryPoint) + XCTAssertEqual(searchEntryPoint.renderedContent, "html") + + let inputTranscription = try XCTUnwrap(serverContent.inputTranscription) + XCTAssertEqual(inputTranscription.text, "What day of the week is it?") + + let outputTranscription = try XCTUnwrap(serverContent.outputTranscription) + XCTAssertEqual(outputTranscription.text, "Today is friday") + } + + func testDecodeBidiGenerateContentServerMessage_toolCall() throws { + let json = """ + { + "toolCall" : { + "functionCalls" : [ + { + "name": "changeBackgroundColor", + "id": "functionCall-12345-67890", + "args" : { + "color": "#F54927" + } + } + ] + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let serverMessage = try decoder.decode(BidiGenerateContentServerMessage.self, from: jsonData) + guard case let .toolCall(toolCall) = serverMessage.messageType else { + XCTFail("Decoded message is not a toolCall message.") + return + } + + let functionCalls = try XCTUnwrap(toolCall.functionCalls) + XCTAssertEqual(functionCalls.count, 1) + let functionCall = try XCTUnwrap(functionCalls.first) + XCTAssertEqual(functionCall.name, "changeBackgroundColor") + XCTAssertEqual(functionCall.id, "functionCall-12345-67890") + let args = try XCTUnwrap(functionCall.args) + guard case let .string(color) = args["color"] else { + XCTFail("Missing color argument") + return + } + XCTAssertEqual(color, "#F54927") + } + + func testDecodeBidiGenerateContentServerMessage_toolCallCancellation() throws { + let json = """ + { + "toolCallCancellation" : { + "ids" : ["functionCall-12345-67890"] + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let serverMessage = try decoder.decode(BidiGenerateContentServerMessage.self, from: jsonData) + guard case let .toolCallCancellation(toolCallCancellation) = serverMessage.messageType else { + XCTFail("Decoded message is not a toolCallCancellation message.") + return + } + + let ids = try XCTUnwrap(toolCallCancellation.ids) + XCTAssertEqual(ids, ["functionCall-12345-67890"]) + } + + func testDecodeBidiGenerateContentServerMessage_goAway() throws { + let json = """ + { + "goAway" : { + "timeLeft": "1.23456789s" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let serverMessage = try decoder.decode(BidiGenerateContentServerMessage.self, from: jsonData) + guard case let .goAway(goAway) = serverMessage.messageType else { + XCTFail("Decoded message is not a goAway message.") + return + } + + XCTAssertEqual(goAway.timeLeft?.seconds, 1) + XCTAssertEqual(goAway.timeLeft?.nanos, 234_567_890) + } +} diff --git a/FirebaseAI/Tests/Unit/Types/Live/VoiceConfigTests.swift b/FirebaseAI/Tests/Unit/Types/Live/VoiceConfigTests.swift new file mode 100644 index 00000000000..3e57ce2d621 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/Live/VoiceConfigTests.swift @@ -0,0 +1,62 @@ +// 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 XCTest + +@testable import FirebaseAILogic + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +@available(watchOS, unavailable) +final class VoiceConfigTests: XCTestCase { + let encoder = JSONEncoder() + + override func setUp() { + super.setUp() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + } + + func testEncodeVoiceConfig_prebuiltVoice() throws { + let voice = VoiceConfig.prebuiltVoiceConfig( + PrebuiltVoiceConfig(voiceName: "Zephyr") + ) + + let jsonData = try encoder.encode(voice) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "prebuiltVoiceConfig" : { + "voiceName" : "Zephyr" + } + } + """) + } + + func testEncodeVoiceConfig_customVoice() throws { + let voice = VoiceConfig.customVoiceConfig( + CustomVoiceConfig(customVoiceSample: Data(repeating: 5, count: 5)) + ) + + let jsonData = try encoder.encode(voice) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "customVoiceConfig" : { + "customVoiceSample" : "BQUFBQU=" + } + } + """) + } +} From fcf18dc66fe83b26b9189eb1afd2e8a56a39a96f Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:50:42 -0500 Subject: [PATCH 35/54] infra(all): Introduce generic script for integration tests (#15415) Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- scripts/repo.sh | 40 +++++ scripts/repo/Package.swift | 50 ++++++ scripts/repo/README.md | 13 ++ scripts/repo/Sources/Tests/Decrypt.swift | 183 ++++++++++++++++++++ scripts/repo/Sources/Tests/SecretFile.swift | 73 ++++++++ scripts/repo/Sources/Tests/main.swift | 54 ++++++ scripts/repo/Sources/Util/Process.swift | 120 +++++++++++++ scripts/secrets/AI.json | 14 ++ 8 files changed, 547 insertions(+) create mode 100755 scripts/repo.sh create mode 100755 scripts/repo/Package.swift create mode 100644 scripts/repo/README.md create mode 100755 scripts/repo/Sources/Tests/Decrypt.swift create mode 100755 scripts/repo/Sources/Tests/SecretFile.swift create mode 100755 scripts/repo/Sources/Tests/main.swift create mode 100755 scripts/repo/Sources/Util/Process.swift create mode 100755 scripts/secrets/AI.json diff --git a/scripts/repo.sh b/scripts/repo.sh new file mode 100755 index 00000000000..607ad987a1a --- /dev/null +++ b/scripts/repo.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# 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. + +# USAGE: ./repo.sh [args...] +# +# EXAMPLE: ./repo.sh tests decrypt --json ./scripts/secrets/AI.json +# +# Wraps around the local "repo" swift package, and facilitates calls to it. +# The main purpose of this is to make calling "repo" easier, as you typically +# need to call "swift run" with the package path. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ $# -eq 0 ]]; then + cat 1>&2 < [args...] +EOF + exit 1 +fi + +swift run --package-path "${ROOT}/repo" "$@" diff --git a/scripts/repo/Package.swift b/scripts/repo/Package.swift new file mode 100755 index 00000000000..1edeeb69aad --- /dev/null +++ b/scripts/repo/Package.swift @@ -0,0 +1,50 @@ +// swift-tools-version:6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +/* + * 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 PackageDescription + +/// Package containing CLI executables for our larger scripts that are a bit harder to follow in +/// bash form, or that need more advanced flag/optional requirements. +let package = Package( + name: "RepoScripts", + platforms: [.macOS(.v15)], + products: [ + .executable(name: "tests", targets: ["Tests"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", exact: "1.6.2"), + .package(url: "https://github.com/apple/swift-log", exact: "1.6.2"), + ], + targets: [ + .executableTarget( + name: "Tests", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .byName(name: "Util"), + ] + ), + .target( + name: "Util", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + ] + ), + ] +) diff --git a/scripts/repo/README.md b/scripts/repo/README.md new file mode 100644 index 00000000000..318c16c547d --- /dev/null +++ b/scripts/repo/README.md @@ -0,0 +1,13 @@ +# Firebase Apple repo commands + +This project includes commands that are too long and complicated to properly +maintain in a bash script, or that have unique option/flag constraints that +are better represented in a swift project. + +## Tests + +Commands for interacting with integration tests in the repo. + +```sh +./scripts/repo.sh tests --help +``` diff --git a/scripts/repo/Sources/Tests/Decrypt.swift b/scripts/repo/Sources/Tests/Decrypt.swift new file mode 100755 index 00000000000..b1836787be9 --- /dev/null +++ b/scripts/repo/Sources/Tests/Decrypt.swift @@ -0,0 +1,183 @@ +/* + * 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 ArgumentParser +import Foundation +import Logging +import Util + +extension Tests { + /// Command for decrypting the secret files needed for a test run. + struct Decrypt: ParsableCommand { + nonisolated(unsafe) static var configuration = CommandConfiguration( + abstract: "Decrypt the secret files for a test run.", + usage: """ + tests decrypt [--json] [--overwrite] [] + tests decrypt [--password ] [--overwrite] [ ...] + + tests decrypt --json secret_files.json + tests decrypt --json --overwrite secret_files.json + tests decrypt --password "super_secret" \\ + scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist \\ + scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist + """, + discussion: """ + The happy path usage is saving the secret passphrase in the environment variable \ + 'secrets_passphrase', and passing a json file to the command. Although, you can also \ + pass everything inline via options. + + When using a json file, it's expected that the json file is an array of json elements \ + in the format of: + { encrypted: , destination: } + """, + ) + + @Argument( + help: """ + An array of secret files to decrypt. \ + The files should be in the format "encrypted:destination", where "encrypted" is a path to \ + the encrypted file and "destination" is a path to where the decrypted file should be saved. + """ + ) + var secretFiles: [String] = [] + + @Option( + help: """ + The secret to use when decrypting the files. \ + Defaults to the environment variable 'secrets_passphrase'. + """ + ) + var password: String = "" + + @Flag(help: "Overwrite existing decrypted secret files.") + var overwrite: Bool = false + + @Flag( + help: """ + Use a json file of secret file mappings instead. \ + When this flag is enabled, should be a single json file. + """ + ) + var json: Bool = false + + /// The parsed version of ``secretFiles``. + /// + /// Only populated after `validate()` runs. + var files: [SecretFile] = [] + + static let log = Logger(label: "Tests::Decrypt") + private var log: Logger { Decrypt.log } + + mutating func validate() throws { + try validatePassword() + + if json { + try validateJSON() + } else { + try validateFileString() + } + + if !overwrite { + log.info("Overwrite is disabled, so we're skipping generation for existing files.") + files = files.filter { file in + let keep = !FileManager.default.fileExists(atPath: file.destination) + if !keep { + log.debug( + "Skipping generation for existing file", + metadata: ["destination": "\(file.destination)"] + ) + } + return keep + } + } + + for file in files { + guard FileManager.default.fileExists(atPath: file.encrypted) else { + throw ValidationError("Encrypted secret file does not exist: \(file.encrypted)") + } + } + } + + private mutating func validatePassword() throws { + if password.isEmpty { + // when a password isn't provided, try to load one from the environment variable + guard + let secrets_passphrase = ProcessInfo.processInfo.environment["secrets_passphrase"] + else { + throw ValidationError( + "Either provide a passphrase via the password option or set the environvment variable 'secrets_passphrase' to the passphrase." + ) + } + password = secrets_passphrase + } + } + + private mutating func validateJSON() throws { + guard let jsonPath = secretFiles.first else { + throw ValidationError("Missing path to json file for secret files") + } + + let fileURL = URL( + filePath: jsonPath, directoryHint: .notDirectory, + relativeTo: URL.currentDirectory() + ) + + files = try SecretFile.parseArrayFrom(file: fileURL) + guard !files.isEmpty else { + throw ValidationError("Missing secret files in json file: \(jsonPath)") + } + } + + private mutating func validateFileString() throws { + guard !secretFiles.isEmpty else { + throw ValidationError("Missing paths to secret files") + } + for string in secretFiles { + try files.append(SecretFile(string: string)) + } + } + + mutating func run() throws { + log.info("Decrypting files...") + + for file in files { + let gpg = Process("gpg", inheritEnvironment: true) + let result = try gpg.runWithSignals([ + "--quiet", + "--batch", + "--yes", + "--decrypt", + "--passphrase=\(password)", + "--output", + file.destination, + file.encrypted, + ]) + + guard result == 0 else { + log.error("Failed to decrypt file", metadata: ["file": "\(file.encrypted)"]) + throw ExitCode(result) + } + + log.debug( + "File encrypted", + metadata: ["file": "\(file.encrypted)", "destination": "\(file.destination)"] + ) + } + + log.info("Files decrypted") + } + } +} diff --git a/scripts/repo/Sources/Tests/SecretFile.swift b/scripts/repo/Sources/Tests/SecretFile.swift new file mode 100755 index 00000000000..67d5e953981 --- /dev/null +++ b/scripts/repo/Sources/Tests/SecretFile.swift @@ -0,0 +1,73 @@ +/* + * 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 ArgumentParser +import Foundation + +/// A representation of a secret file, which should be decrypted for an integration test. +struct SecretFile: Codable { + /// A relative path to the encrypted file. + let encrypted: String + + /// A relative path to where the decrypted file should be output to. + let destination: String +} + +extension SecretFile { + /// Parses a `SecretFile` from a string. + /// + /// The string should be in the format of "encrypted:destination". + /// If it's not, then a `ValidationError`will be thrown. + /// + /// - Parameters: + /// - string: A string in the format of "encrypted:destination". + init(string: String) throws { + let splits = string.split(separator: ":") + guard splits.count == 2 else { + throw ValidationError( + "Invalid secret file format. Format should be \"encrypted:destination\". Cause: \(string)" + ) + } + encrypted = String(splits[0]) + destination = String(splits[1]) + } + + /// Parses an array of `SecretFile` from a JSON file. + /// + /// It's expected that the secrets are encoded in the JSON file in the format of: + /// ```json + /// [ + /// { + /// "encrypted": "path-to-encrypted-file", + /// "destination": "where-to-output-decrypted-file" + /// } + /// ] + /// ``` + /// + /// - Parameters: + /// - file: The URL of a JSON file which contains an array of `SecretFile`, + /// encoded as JSON. + static func parseArrayFrom(file: URL) throws -> [SecretFile] { + do { + let data = try Data(contentsOf: file) + return try JSONDecoder().decode([SecretFile].self, from: data) + } catch { + throw ValidationError( + "Failed to load secret files from json file. Cause: \(error.localizedDescription)" + ) + } + } +} diff --git a/scripts/repo/Sources/Tests/main.swift b/scripts/repo/Sources/Tests/main.swift new file mode 100755 index 00000000000..0abb218e46a --- /dev/null +++ b/scripts/repo/Sources/Tests/main.swift @@ -0,0 +1,54 @@ +/* + * 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 ArgumentParser +import Foundation +import Logging + +struct Tests: ParsableCommand { + nonisolated(unsafe) static var configuration = CommandConfiguration( + abstract: "Commands for running and interacting with integration tests.", + discussion: """ + A note on logging: by default, only log levels "info" and above are logged. For further \ + debugging, you can set the "LOG_LEVEL" environment variable to a different minimum level \ + (eg; "debug"). + """, + subcommands: [Decrypt.self] + // defaultSubcommand: Run.self + ) +} + +LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + if let level = ProcessInfo.processInfo.environment["LOG_LEVEL"] { + if let parsedLevel = Logger.Level(rawValue: String(level)) { + handler.logLevel = parsedLevel + return handler + } else { + print( + """ + [WARNING]: Unrecognized log level "\(level)"; defaulting to "info". + Valid values: \(Logger.Level.allCases.map(\.rawValue)) + """ + ) + } + } + + handler.logLevel = .info + return handler +} + +Tests.main() diff --git a/scripts/repo/Sources/Util/Process.swift b/scripts/repo/Sources/Util/Process.swift new file mode 100755 index 00000000000..2959d5f0abb --- /dev/null +++ b/scripts/repo/Sources/Util/Process.swift @@ -0,0 +1,120 @@ +/* + * 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 Dispatch +import Foundation + +public extension Process { + /// Creates a new `Process` instance without running it. + /// + /// - Parameters: + /// - exe: The executable to run. + /// - args: An array of arguments to pass to the executable. + /// - env: A map of environment variables to set for the process. + /// - inheritEnvironment: When enabled, the parent process' environvment will also be applied + /// to this process. Effectively, this means that any environvment variables declared within the + /// parent process will propogate down to this new process. + convenience init(_ exe: String, + _ args: [String] = [], + env: [String: String] = [:], + inheritEnvironment: Bool = false) { + self.init() + executableURL = URL(filePath: "/usr/bin/env") + arguments = [exe] + args + environment = env + if inheritEnvironment { + mergeEnvironment(ProcessInfo.processInfo.environment) + } + } + + /// Merges the provided environment variables with this process' existing environment variables. + /// + /// If an environment variable is already set, then it will **NOT** be overwritten. Only + /// environment variables not currently set on the process will be applied. + /// + /// - Parameters: + /// - env: The environment variables to merge with this process. + func mergeEnvironment(_ env: [String: String]) { + guard environment != nil else { + // if this process doesn't have an environment, we can just set it instead of merging + environment = env + return + } + + environment = environment?.merging(env) { current, _ in current } + } + + /// Run the process with signals from the parent process. + /// + /// The signals `SIGINT` and `SIGTERM` will both be propogated + /// down to the process from the parent process. + /// + /// This function will not return until the process is done running. + /// + /// - Parameters: + /// - args: Optionally provide an array of arguments to run the process with. + /// + /// - Returns: The exit code that the process completed with. + @discardableResult + func runWithSignals(_ args: [String]? = nil) throws -> Int32 { + if let args { + arguments = (arguments ?? []) + args + } + + let sigint = bindSignal(signal: SIGINT) { + if self.isRunning { + self.interrupt() + } + } + + let sigterm = bindSignal(signal: SIGTERM) { + if self.isRunning { + self.terminate() + } + } + + sigint.resume() + sigterm.resume() + + try run() + waitUntilExit() + + return terminationStatus + } +} + +/// Binds a callback to a signal from the parent process. +/// +/// ```swift +/// bindSignal(SIGINT) { +/// print("SIGINT was triggered") +/// } +/// ``` +/// +/// - Parameters: +/// - signal: The signal to listen for. +/// - callback: The function to invoke when the signal is received. +func bindSignal(signal value: Int32, + callback: @escaping DispatchSourceProtocol + .DispatchSourceHandler) -> any DispatchSourceSignal { + // allow the process to survive long enough to trigger the callback + signal(value, SIG_IGN) + + let dispatch = DispatchSource.makeSignalSource(signal: value, queue: .main) + dispatch.setEventHandler(handler: callback) + + return dispatch +} diff --git a/scripts/secrets/AI.json b/scripts/secrets/AI.json new file mode 100755 index 00000000000..1f675c8fc3e --- /dev/null +++ b/scripts/secrets/AI.json @@ -0,0 +1,14 @@ +[ + { + "encrypted": "scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg", + "destination": "FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist" + }, + { + "encrypted": "scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg", + "destination": "FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist" + }, + { + "encrypted": "scripts/gha-encrypted/FirebaseAI/TestApp-Credentials.swift.gpg", + "destination": "FirebaseAI/Tests/TestApp/Tests/Integration/Credentials.swift" + } +] From 0c50b1b3368aa02002f8ca3e9a65968d08ef6d35 Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Mon, 20 Oct 2025 13:42:13 -0500 Subject: [PATCH 36/54] fix(ai): Add retry mechanism to flakey interrupt test (#15421) Co-authored-by: Andrew Heard --- .../Tests/Integration/LiveSessionTests.swift | 38 +++++++++++-------- .../Utilities/IntegrationTestUtils.swift | 33 ++++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift index cb341697043..beffcdfa61e 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift @@ -276,38 +276,39 @@ struct LiveSessionTests { await session.close() } - // Getting a limited use token adds too much of an overhead; we can't interrupt the model in time @Test( arguments: arguments.filter { !$0.0.useLimitedUseAppCheckTokens } ) + // Getting a limited use token adds too much of an overhead; we can't interrupt the model in time func realtime_interruption(_ config: InstanceConfig, modelName: String) async throws { let model = FirebaseAI.componentInstance(config).liveModel( modelName: modelName, generationConfig: audioConfig ) - let session = try await model.connect() - guard let audioFile = NSDataAsset(name: "hello") else { Issue.record("Missing audio file 'hello.wav' in Assets") return } - await session.sendAudioRealtime(audioFile.data) - await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) - // wait a second to allow the model to start generating (and cuase a proper interruption) - try await Task.sleep(nanoseconds: oneSecondInNanoseconds) - await session.sendAudioRealtime(audioFile.data) - await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) + try await retry(times: 3, delayInSeconds: 2.0) { + let session = try await model.connect() + await session.sendAudioRealtime(audioFile.data) + await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) - for try await content in session.responsesOf(LiveServerContent.self) { - if content.wasInterrupted { - break - } + // wait a second to allow the model to start generating (and cuase a proper interruption) + try await Task.sleep(nanoseconds: oneSecondInNanoseconds) + await session.sendAudioRealtime(audioFile.data) + await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) - if content.isTurnComplete { - Issue.record("The model never sent an interrupted message.") - return + for try await content in session.responsesOf(LiveServerContent.self) { + if content.wasInterrupted { + break + } + + if content.isTurnComplete { + throw NoInterruptionError() + } } } } @@ -472,6 +473,11 @@ private extension LiveSession { } } +private struct NoInterruptionError: Error, + CustomStringConvertible { + var description: String { "The model never sent an interrupted message." } +} + private extension ModelContent { /// A collection of text from all parts. /// diff --git a/FirebaseAI/Tests/TestApp/Tests/Utilities/IntegrationTestUtils.swift b/FirebaseAI/Tests/TestApp/Tests/Utilities/IntegrationTestUtils.swift index f133207540c..af1ef347c63 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Utilities/IntegrationTestUtils.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Utilities/IntegrationTestUtils.swift @@ -13,6 +13,7 @@ // limitations under the License. import Foundation +import Testing import XCTest enum IntegrationTestUtils { @@ -43,3 +44,35 @@ extension Numeric where Self: Strideable, Self.Stride.Magnitude: Comparable { return distance(to: other).magnitude <= accuracy.magnitude } } + +/// Retry a flakey test N times before failing. +/// +/// - Parameters: +/// - times: The amount of attempts to retry before failing. Must be greater than 0. +/// - delayInSeconds: How long to wait before performing the next attempt. +@discardableResult +func retry(times: Int, + delayInSeconds: TimeInterval = 0.1, + _ test: () async throws -> T) async throws -> T { + if times <= 0 { + precondition(times <= 0, "Times must be greater than 0.") + } + let delayNanos = UInt64(delayInSeconds * 1e+9) + var lastError: Error? + for attempt in 1 ... times { + do { return try await test() } + catch { + lastError = error + // only wait if we have more attempts + if attempt < times { + try? await Task.sleep(nanoseconds: delayNanos) + } + } + } + guard let lastError else { + // should not happen unless we change the above code in some way + fatalError("Internal error: retry loop finished without error") + } + Issue.record("Flaky test failed after \(times) attempt(s): \(String(describing: lastError))") + throw lastError +} From ea3f1293fbb32ec41ee2abd6ad363c1dffa6d23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Rojas?= Date: Mon, 20 Oct 2025 13:00:46 -0600 Subject: [PATCH 37/54] Fix app start trace outliers from network delays (#10733) (#15409) --- FirebasePerformance/CHANGELOG.md | 1 + .../AppActivity/FPRAppActivityTracker.m | 76 +++++++++++++++---- 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/FirebasePerformance/CHANGELOG.md b/FirebasePerformance/CHANGELOG.md index 159d173e1f3..a63bfa357fe 100644 --- a/FirebasePerformance/CHANGELOG.md +++ b/FirebasePerformance/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased - [fixed] Prevent race condition crash in FPRTraceBackgroundActivityTracker. (#14273) +- [fixed] Fix app start trace outliers from network delays. (#10733) # 12.3.0 - [fixed] Add missing nanopb dependency to fix SwiftPM builds when building diff --git a/FirebasePerformance/Sources/AppActivity/FPRAppActivityTracker.m b/FirebasePerformance/Sources/AppActivity/FPRAppActivityTracker.m index f6efa0972f9..fd38dd125fb 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRAppActivityTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRAppActivityTracker.m @@ -19,6 +19,7 @@ #import #import "FirebasePerformance/Sources/AppActivity/FPRSessionManager.h" +#import "FirebasePerformance/Sources/Common/FPRDiagnostics.h" #import "FirebasePerformance/Sources/Configurations/FPRConfigurations.h" #import "FirebasePerformance/Sources/Gauges/CPU/FPRCPUGaugeCollector+Private.h" #import "FirebasePerformance/Sources/Gauges/FPRGaugeManager.h" @@ -71,6 +72,9 @@ @interface FPRAppActivityTracker () /** Tracks if the gauge metrics are dispatched. */ @property(nonatomic) BOOL appStartGaugeMetricDispatched; +/** Tracks if app start trace completion logic has been executed. */ +@property(nonatomic) BOOL appStartTraceCompleted; + /** Firebase Performance Configuration object */ @property(nonatomic) FPRConfigurations *configurations; @@ -113,6 +117,18 @@ + (void)windowDidBecomeVisible:(NSNotification *)notification { + (void)applicationDidFinishLaunching:(NSNotification *)notification { applicationDidFinishLaunchTime = [NSDate date]; + + // Detect a background launch and invalidate app start time + // this prevents we measure duration from background launch + UIApplicationState state = [UIApplication sharedApplication].applicationState; + if (state == UIApplicationStateBackground) { + // App launched in background so we invalidate the captured app start time + // to prevent incorrect measurement when user later opens the app + appStartTime = nil; + FPRLogDebug(kFPRTraceNotCreated, + @"Background launch detected. App start measurement will be skipped."); + } + [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidFinishLaunchingNotification object:nil]; @@ -135,6 +151,7 @@ - (instancetype)initAppActivityTracker { if (self != nil) { _applicationState = FPRApplicationStateUnknown; _appStartGaugeMetricDispatched = NO; + _appStartTraceCompleted = NO; _configurations = [FPRConfigurations sharedInstance]; [self startTrackingNetwork]; } @@ -242,6 +259,15 @@ - (void)appDidBecomeActiveNotification:(NSNotification *)notification { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ + // Early bailout if background launch was detected, appStartTime will be nil if the app was + // launched in background + if (appStartTime == nil) { + FPRLogDebug(kFPRTraceNotCreated, + @"App start trace skipped due to background launch. " + @"This prevents reporting incorrect multi-minute/hour durations."); + return; + } + self.appStartTrace = [[FIRTrace alloc] initInternalTraceWithName:kFPRAppStartTraceName]; [self.appStartTrace startWithStartTime:appStartTime]; [self.appStartTrace startStageNamed:kFPRAppStartStageNameTimeToUI startTime:appStartTime]; @@ -250,9 +276,13 @@ - (void)appDidBecomeActiveNotification:(NSNotification *)notification { [self.appStartTrace startStageNamed:kFPRAppStartStageNameTimeToFirstDraw]; }); - // If ever the app start trace had it life in background stage, do not send the trace. - if (self.appStartTrace.backgroundTraceState != FPRTraceStateForegroundOnly) { + // If ever the app start trace had its life in background stage, do not send the trace. + if (self.appStartTrace && + self.appStartTrace.backgroundTraceState != FPRTraceStateForegroundOnly) { + [self.appStartTrace cancel]; self.appStartTrace = nil; + FPRLogDebug(kFPRTraceNotCreated, + @"App start trace cancelled due to background state contamination."); } // Stop the active background session trace. @@ -266,28 +296,44 @@ - (void)appDidBecomeActiveNotification:(NSNotification *)notification { self.foregroundSessionTrace = appTrace; // Start measuring time to make the app interactive on the App start trace. - static BOOL TTIStageStarted = NO; - if (!TTIStageStarted) { + if (!self.appStartTraceCompleted && self.appStartTrace) { [self.appStartTrace startStageNamed:kFPRAppStartStageNameTimeToUserInteraction]; - TTIStageStarted = YES; + self.appStartTraceCompleted = YES; // Assumption here is that - the app becomes interactive in the next runloop cycle. // It is possible that the app does more things later, but for now we are not measuring that. + __weak typeof(self) weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ - NSTimeInterval startTimeSinceEpoch = [self.appStartTrace startTimeSinceEpoch]; + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf || !strongSelf.appStartTrace) { + return; + } + + NSTimeInterval startTimeSinceEpoch = [strongSelf.appStartTrace startTimeSinceEpoch]; NSTimeInterval currentTimeSinceEpoch = [[NSDate date] timeIntervalSince1970]; + NSTimeInterval measuredAppStartTime = currentTimeSinceEpoch - startTimeSinceEpoch; - // The below check is to account for 2 scenarios. - // 1. The app gets started in the background and might come to foreground a lot later. - // 2. The app is launched, but immediately backgrounded for some reason and the actual launch + // The below check accounts for multiple scenarios: + // 1. App started in background and comes to foreground later + // 2. App launched but immediately backgroundedfor some reason and the actual launch // happens a lot later. - // Dropping the app start trace in such situations where the launch time is taking more than - // 60 minutes. This is an approximation, but a more agreeable timelimit for app start. - if ((currentTimeSinceEpoch - startTimeSinceEpoch < gAppStartMaxValidDuration) && - [self isAppStartEnabled] && ![self isApplicationPreWarmed]) { - [self.appStartTrace stop]; + // 3. Network delays during startup inflating metrics + // 4. iOS prewarm scenarios + // 5. Dropping the app start trace in such situations where the launch time is taking more + // than 60 minutes. This is an approximation, but a more agreeable timelimit for app start. + BOOL shouldDispatchAppStartTrace = (measuredAppStartTime < gAppStartMaxValidDuration) && + [strongSelf isAppStartEnabled] && + ![strongSelf isApplicationPreWarmed]; + + if (shouldDispatchAppStartTrace) { + [strongSelf.appStartTrace stop]; } else { - [self.appStartTrace cancel]; + [strongSelf.appStartTrace cancel]; + if (measuredAppStartTime >= gAppStartMaxValidDuration) { + FPRLogDebug(kFPRTraceInvalidName, + @"App start trace cancelled due to excessive duration: %.2fs", + measuredAppStartTime); + } } }); } From be30d46dfcc28ea566ce385849ca42362d68e6d7 Mon Sep 17 00:00:00 2001 From: Daymon <17409137+daymxn@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:45:05 -0500 Subject: [PATCH 38/54] feat(ai): Add support for sending videos via Live API. (#15432) --- FirebaseAI/CHANGELOG.md | 3 + .../Types/Public/Live/LiveSession.swift | 21 ++++-- .../project.pbxproj | 4 ++ .../Assets.xcassets/cat.dataset/Contents.json | 13 ++++ .../cat.dataset/videoplayback.mp4 | Bin 0 -> 206166 bytes .../Tests/Integration/LiveSessionTests.swift | 51 ++++++++++++++ .../TestApp/Tests/Utilities/DataUtils.swift | 64 ++++++++++++++++++ 7 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/Contents.json create mode 100644 FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/videoplayback.mp4 create mode 100644 FirebaseAI/Tests/TestApp/Tests/Utilities/DataUtils.swift diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index e9939f869e9..cda91aa2926 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -2,6 +2,9 @@ - [fixed] Fixed various links in the Live API doc comments not mapping correctly. - [fixed] Fixed minor translation issue for nanosecond conversion when receiving `LiveServerGoingAwayNotice`. (#15410) +- [feature] Added support for sending video frames with the Live API via the `sendVideoRealtime` + method on [`LiveSession`](https://firebase.google.com/docs/reference/swift/firebaseai/api/reference/Classes/LiveSession). + (#15432) # 12.4.0 - [feature] Added support for the URL context tool, which allows the model to access content diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift b/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift index 0799e35dc03..a4520e42d94 100644 --- a/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift +++ b/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift @@ -67,15 +67,24 @@ public final class LiveSession: Sendable { await service.send(.realtimeInput(message)) } - /// Sends a video input stream to the model, using the realtime API. + /// Sends a video frame to the model, using the realtime API. + /// + /// Instead of raw video data, the model expects individual frames of the video, + /// sent as images. + /// + /// If your video has audio, send it seperately through ``LiveSession/sendAudioRealtime(_:)``. + /// + /// For better performance, frames can also be sent at a lower rate than the video; + /// even as low as 1 frame per second. /// /// - Parameters: - /// - video: Encoded video data, used to update the model on the client's conversation. - /// - format: The format that the video was encoded in (eg; `mp4`, `webm`, `wmv`, etc.,). - // TODO: (b/448671945) Make public after testing and next release - func sendVideoRealtime(_ video: Data, format: String) async { + /// - video: Encoded image data extracted from a frame of the video, used to update the model on + /// the client's conversation. + /// - mimeType: The IANA standard MIME type of the video frame data (eg; `images/png`, + /// `images/jpeg`etc.,). + public func sendVideoRealtime(_ video: Data, mimeType: String) async { let message = BidiGenerateContentRealtimeInput( - video: InlineData(data: video, mimeType: "video/\(format)") + video: InlineData(data: video, mimeType: mimeType) ) await service.send(.realtimeInput(message)) } diff --git a/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj b/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj index 8b1b80e54d8..80ce94f5ef6 100644 --- a/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj +++ b/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0E0481222EA2E51300A50172 /* DataUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E0481212EA2E51100A50172 /* DataUtils.swift */; }; 0E460FAB2E9858E4007E26A6 /* LiveSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E460FAA2E9858E4007E26A6 /* LiveSessionTests.swift */; }; 0EC8BAE22E98784E0075A4E0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 868A7C532CCC26B500E449DD /* Assets.xcassets */; }; 862218812D04E098007ED2D4 /* IntegrationTestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 862218802D04E08D007ED2D4 /* IntegrationTestUtils.swift */; }; @@ -44,6 +45,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0E0481212EA2E51100A50172 /* DataUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataUtils.swift; sourceTree = ""; }; 0E460FAA2E9858E4007E26A6 /* LiveSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveSessionTests.swift; sourceTree = ""; }; 862218802D04E08D007ED2D4 /* IntegrationTestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTestUtils.swift; sourceTree = ""; }; 864F8F702D4980D60002EA7E /* ImagenIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagenIntegrationTests.swift; sourceTree = ""; }; @@ -168,6 +170,7 @@ 8698D7442CD3CEF700ABA833 /* Utilities */ = { isa = PBXGroup; children = ( + 0E0481212EA2E51100A50172 /* DataUtils.swift */, 86D77E032D7B6C95003D155D /* InstanceConfig.swift */, 862218802D04E08D007ED2D4 /* IntegrationTestUtils.swift */, ); @@ -304,6 +307,7 @@ DEF0BB512DA9B7450093E9F4 /* SchemaTests.swift in Sources */, DEF0BB4F2DA74F680093E9F4 /* TestHelpers.swift in Sources */, 868A7C4F2CCC229F00E449DD /* Credentials.swift in Sources */, + 0E0481222EA2E51300A50172 /* DataUtils.swift in Sources */, 864F8F712D4980DD0002EA7E /* ImagenIntegrationTests.swift in Sources */, 862218812D04E098007ED2D4 /* IntegrationTestUtils.swift in Sources */, 86D77DFC2D7A5340003D155D /* GenerateContentIntegrationTests.swift in Sources */, diff --git a/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/Contents.json b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/Contents.json new file mode 100644 index 00000000000..e0a74436bb9 --- /dev/null +++ b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/Contents.json @@ -0,0 +1,13 @@ +{ + "data" : [ + { + "filename" : "videoplayback.mp4", + "idiom" : "universal", + "universal-type-identifier" : "public.mpeg-4" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/videoplayback.mp4 b/FirebaseAI/Tests/TestApp/Resources/Assets.xcassets/cat.dataset/videoplayback.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..fbc8c4feb1284f2547b4e76305326e51e2aab7d6 GIT binary patch literal 206166 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
8~PPaJsAxn_@k@jb8iF@4C|$Ps9-9s@Rj+^*i`l zbK(-%bWh15eNDJF*24(^k>)9$jM4j`NBhkO-c=dRe^)-0{)oV>lyMd3JDRY^CV1%XBQh2_o&=mX{C&6YhX7A^=wQkIeg3zb@9k@fzHZ)+h#5eexDCrp zxaa6MglfMfuu*)M6akq!U$;+;4F)a`7dpu6vDWnlDQfXVL0Sbz((>hD#oX!ctcQ1J zkb?nh?E{wG&e$B7xJ~yZ?Vel3Jir(0V2FiMtYZ&EJCeja*XR6g{Ud5~f~_EMy2vgb zY+Aq=eN*GrX=uf}pz(=ceac&Gt=@*nPl1XgIO|IzRyMI7b^50#BeQTR@?VBl*5o{*-s2NjT={s4^?UlHTkI}?Jk2({% zR@a^pIvuhTwCvIDp!d8HV)Lczz3FUb_Pq(G$(J|DbK6uoc5+aIVQr-ky=NK>YpVE% zPs4atlwXxv0=j);7HV`Nm+&IQV;ILJ)U7`#m&yO2-Iapok04G9gFQ&xP8MEFu%$)A zFN=R;`5|=zd4;>+nCTS5!OB2`Iw5NUaa%Y%d`xN`KdVLzY1^Q!`tEsLFbT1MfAIr4 zkuJ2`du7ZF+BL=HqG?6w6$>uY^XOmD#h(}g`^Ij>aCEWjbuRH;v1!g8F-Q#>ci;|DHT@d8?|;V^-kw+c{-azh5Y;u*&Nk;|8Cg3k$paGW;a-16JQR8BhI7UBA8e zZl7GYe7)JGf2n$tfTa#j}@e<1Wu8O ztpSE`QDcqZ5o)WVC=OnlRCryb#jLU9p~9^5c^9fI2h$fRLG5zt-r8v}kyrm|k?k_W zHttHLVtjeb8QO#xO@I4|Y=MA%- zOSpd0*B)3{zk#S7CgzQ9&heUkI9|Ldco!a6en=w-p)eKvP>~6%eY=dSh9sW|bt*82 za5GbEMY2^i2w(FPc`ULomc+j*oBEE)9EW&}*((AHqY(Y37 zgwlJG0#(C!O8vFOY`pnu{3LY^a{K0US5-H|WOr*iCL4QC@43bFZ`-3#5bx`-2ZqD= z!p*Xf?&-^ml-lVI>7T}p!h&_q5&aPb446*(gb1r@rvny6E-4>0(>UtQ&Bv^jVc{+E zBj?zllHej6w)gy+sB}GtUb&9&>T%;KX2}_2%lCJrU~@OM892qogf;vCNqKX?jN*=D z!A}pBgB7nzhSI5o=;iOmtH04j7}8$e5{bX{kNAFC#`mBvHQh zsjkKj#bCdWn>}}O#8dG@#8FmAx9JeL;R{p3e-82v+cZiwDelE#S6|G;*(_%`%&gpm zixt*>7GO2}mY@FtH?bOYQzp<2DUswyb}Y~>dQoSENj5UpgWzu#skx^QJoZrYu2 z7U!m(^AD&v+`h-A%C;DU17$?ML_+C8YmK9s_)uJ{M*N(6e#-ME> zV)y+>bh~H%;oNI}kUoKYmsYyBy|TnXR?Ie?@>J!iB>6@#*3k`CalL#`qG=HU#dNIY z1$Ekl5HZw31rKQGc}S|EHJe@B?8A=%(R8~XDh}~XycYR&`8q4{j+yY;3mT%M%G^-} z@TH>g+&5vONQ{ZbLCrFo!tXY(ednnvGpjuEyXaU!h1}I+1NmcIh$L$}T6V`ptx1jLess

7%zF#mr|VAk_GAf65Sh9theTB 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 z zV!D9J6FkBZtle~DxYG?R+aeU8MT&>xo85~B)R@BB=-(Nv*F|$4*%3Sqmco5-6nvbT zC%d{7SaK87agz}9xc6Z$8xFmcJvPFWuZuDH`zfA5k|?4$UsBm7R43$0Z$gn?miXxt z;7PzTx}W2EST1T$0C8PX&I|}y3(sg)O>Al`RZ@CcfBv3%=o5^#@p5Z#4gFQ*e54Kb(iPU1RURT{T~&P6(^XOlejM zA!^-x@nuvY0{Pzg$BKRAs3#;w?|Tpj@H=!#lUKjjigtkXNn1Z8 zmcAYh!#A_dXS9)#U>evlYlZzd=`+?!{hw3Oz9>WO3nwI>b>HowaLBh4O%R$Hg<{G~ z@Glb`hC8XjP8;>gLtKjDoe;5U{}%vyK!v{`q|@^9>!?v2Pn&^RamZC7x$Seqkt+ZI z2#i6Sq?5uPOr``W|Lce&%nTr)_S;qzpyyN63X;!B)We+48B(%*A#t(Am*JB2MuAqe zm&9wYk;s=m_iXgiZdif(NeDDo0D3k4V-=Dj3)f=6Q>6W=XI%?exLR$qvMW5#D>Tlc zdxQ1l2Z)zJb-8Pn)-o%&gFrzX0l_>SZ?m_@*h`jgiE=ID@K@xu7XCqRv$8MmgG6-H zF8LlZS5R!*TzBT{4&FEY6TqQT>*R!v<92uGQ&1L2G?-EJle=8!{dpHYN!DCgmp?I| z{{aYb6xCk|@~$uNJoPP)ZHw)>o%LRon|N|0T14pp8AdMfZ6;#1T~bB{579hX=Eg}$ zFnT&F*d%(`a!4V0&HtxkHfD>L2yum(kO7{&&z0UjLd5pgT;|oMhA@M35`j`$TP~g_OYC=TqjsA+75IpWFeL_F{ z?m_L-$hD+Khm+qxl7`?O#Dd=>1QMjfmJMfu;g4H>%sto2q{zBiACs5gIS4P-or$Uj@khuLh(i`hj}&>^_BhuGfeO zsNOD#Og0rx^9)!g+GOX%{pDjw|CfVU&^+*uRWeHDIGO!H#fzTU@xtj7wKFTU>M&w@B<7_aU=emS#J zjcCKHSbWjiNJi`ektuRGDpW8s*F@-Up`0!UR=GB26Cr9%jDv*<*3kEMg(bygQjd{c zdoX(v=^Sgo3)38vt06Jhbh!2#xH?tm4s1N!gh?H7wZEh-H!bdT1-9Dc#MM;Ud6CHM z|Mcrl9YHMLHl>a)kp@@za{N|`FK0lh0ioo#Keo|!eqaoRIo77qfv@1rFA6BvKdV1a zk_Zc6kA-4&Q?VR=A^#rdP+w_b0+|S0Rd%JUoFmxcT+?>F^2r~$8Kk2va8r?A93QSE zD#1p)J^jLp=*Aeo0lCCZ40B!%iOk{}sSjO?BSAD6b}S5|$%%zvGrvY6YrdSe_fKOq zR0#TviJ&pg<`+tx3309zsxft)Y-76SBU{`SPGjr8BRaQr$1Kxt|3H-LqoA}-mEh@b zAnDztl)CleJ?q_EOT+Z|a*TdD7k4Vv`|uP&%DKzLQqJdQXwht= zYrd1kHctoD`LTl!5W<`?&!B=Q9xujcs%dPDDnXM()|J)lU?Q5h(TK@pBR8bw>>Zmn zSs-u*0cqKtKFxY0acSOd9`e>(V^PsPrArnv)w`%k0OR4DIElZ?$!Ce`IpX3D8UnT z$K-%T93pfFK;{#@?2$>%hL|Y*_&`#9#+e~kGr|fg9_P%=?dlj2fc&tq2?ZD@K(CUz z5hi3#cp%939~{;Vmuybl)$e{Bc}rLE1WJ?8sl#aK#69tM?jd#S)A{7P19WMl*n%zg5-91q7tB1C++*4tHz zD?}{Bl}`janag4=^pCDPBp42;6vJSs~7TKaTi{nOU4hL0o-H zO031hVvhe*6feGjF4vmBXB70~)K~rxFSXL1n)P_`GqfO!r;{fCwJuf-1`a0=`dyts8dj=mxQ}tXERssLG7O7g*tZ2zMj5yQ_`@iTeS0D2{){tfPBD zk7)5@ZOO+>ac@#1$)!&J8Y?yg-H==xREijUf4(mW!D#MahybL(<5&({(rQ)Ia5Oa{ zP<-oWkVgaz2J?87!Cz&F>l3Yh1fF^eLE$d@+o0R zN}F&EpFg63dZM+JnALBtA!w_d;+^DO100ADS|JFXc`CYgbta<|gx-WYWM3tvi;Re$ zr4(RNpelWKWxtfoh7UO&1QmYK-13tI^Mda`);R8imX^bpGdh^OxMER+UvXn)NnwDt z=t48eXCHL6#zZ%{{59ryyOc2&B5B_<77fH%l&`+7cQ7v^&f80I(9#@TCC$U|(u}n} zQMb>Xl5hw$!I5Wih?`6b1a5H`$#LTKAxCsrR%~V`iuz`@eK#-)P0DM*-odg9j!1cKHq&03b zD+wJNsnDi|&>Mp_HvBI$sts;cg&$b~L?&?$@MDGA8J&aA)Fm2CLN0?XZ1%%|PM;Vj;SCJ6RJ; zWAx^w6xjd(0vG|Gz|@96_CgcKE_>TvK^(?lkx}ED2Vgiijgx&*7+miQj4Yt^Zy-H8 zR8p=G9+ZI~g>F-f_^Tct5P3R3`m8H-yhbC4&&UY8;wUjE@1(?M;6EfpKA88L3*wd# zbRh7LEmdKFU8^qRgNuw=Oj@Rj;(ZS%8@qPXy@sF!@ZiSLb!+Lw51cXZXr8)#ZS>kh zYMHm6uXIs90)9+A&r0Q$#+M5+Tc%~qwE+E$!n*sX!Z-dD)c)=f$YZXQ*FpsDZAvtu zqbnMkvfSMl2-tctTP_JpOL-BD;k^G8JcYSBGao)XxQ&-Qnbv+Jd7uaIIMHnA{qPuB5H43(XylyM>5n zrLlOT%Bh8>PXyk8Kdyaatv4pJYn^(G+jgg;wL@C}CjS#KGGk?G?!Ss>)uj5@q9sRt z4Rd^~!43aPK(+#S8pdUYjZAc-&+J((bP?kA-q~BKVZ~ zXCoAhzG9Gq{}?+CsFVE3jAM3Nt_SN;_}@TVfOOu}q+L;OW0MHhj z0fX_v$a;k_pZDoB-&1G3zTnx(}Wt^HkJ=tVNyaLj`YOSrU>ACzV z_b%n1#VWi>FkSM=DTL<&UT@VJko?_H_4M*q_8rT$V;HYV4m;E}M9+jzTxp`=j-%}} zM1)A@A-0c)LrEf%s=~)d*Y_(_yt^DVBPgG9Q8~t(wG> z{wuC#LZ;eixyoTkHrI98vmuVg);c)Lv8_xruT<$?bZMX~9|rKoq21maw~zLxN^~x4}~h_b9Lv8Q5mWISvW)hVvL1%KmZWAIS01BUvBwXb~jX}Cs)|8 zU`6G1OgD9h*4U6#Rq|osjBp*6o&AIQ=`s;oSe`{*Xe?J)F`#Yc;-9pIphlwM000PI zL7U2x!W5*+U`_w_(!7KhUg#k&QJ6f@B4cg2#a$E_K$BlhbAIU2Df}ES_NsmUm`*sI zx7kDUM{HyhNQX0~BTRoY*GN0s?A9^JckBbwgKA>!k}aJ;TM=3F$N>FyaQ62zIGq5? zS;q#w0*-V*=G&99@A^vXBks_y=o)=APlTZ{FS#JY-MIs?xMyDJIb$TzylX5sg2MMk z8A}-Mh)74G^(B8a?!7-Y#=jR``-`MYiV$VWhD_#IoAcssNc?dugPGvmzL%#{?K zzO3(nU&@Am{Av}9}TEe zuo3&@ng<5cxG=!ZMRKg`7&tR(9i{c-kRD9g=;RQ9m(t8ABoTo9nO3AfG@1h1XyJ@| z=13U;&H_~)Bm8X^;G2RJ?=exin_)SNp#?4o+}0I$_B~d%lxoUy*J$v2gVjfWAj>zn z9jUw{VP_dz{`v}BHYUmk${vEeYs(m_MONlvROON0)u(AGt2K(X%?N~Z_Mx*3frcA* z;}`@-w!9i&Ha^;Lb0L@+0Np;C0J^nbs&2>}+ob@VHfl5?#rKpIq)J!_<=Be*0(tGxG6tkghZ%MC8!9)B54g6T`iqVQ(t_6!cxwH!wVi5S!jbsmmOVbXfbcEx{& zA1j1im&_6ojl$7D~yigmP4E; zh$eNMiua;{FW>S#*{x0LMy}s=9pP^8ZD^)CYZJZfz`t&!4Y!8M?AUEQXgC?71xDxx zQ{WGLdpw&_XqUuljeyWaD9)4G;qc_s+CM#Zf8$#7d3h>-U3$Vlo(>Y*P@DI?uVE^HxBxb9Kt$Vj8EN9Oh5A;&n7(d ztMQHj_2ncAtwd^ErAdlO!|Q6M83~hBQn}*rD=QgJC#NjydEA9N5e%B?)xs)=9ytp~ z-aPs5NoD{$FTs?c2tHMLF<}HJMsd&5YF}SW;wJ+5y>rgqgIbg7o%vQXqzvO57Hrs~ z0I|@szk;resiJ7zpI{sSg0+G&!Sz0|^hoIO$Fm*NUZ6BHO??BfEcw%J_}?CW5{-5# zit%vh)*>2VaO>paLmwyw$TR6o!B1tQ>jq@2k633v$(-s{*aFs$sy55Gw%HVp>GNM)1}i&;UN!v{)D5~1yT6Qdq$%gWr ztEV@RN3L1SQ|9Ue`KKdB0WzkHWhX;_Z?O71$E8KGNXX_Q#I#l zCNzhhwU7?-?F02xWT&`&pI|asfA=?yo1*BD0GeZnRRziLf!G6Nt;md3$^2h`pLz-- z5SoEv1Ap|epxMI{ek!N08(i7KDblyIlk$D4>V;STNo=?r8){uG#jVhfv#f^}N?#^c zEp|brp&Z{xFJ}wYC`YTLO*dR8{*^lBGSMfpxw`rJ^qaugxibTKWTYC(pO;##Efc-f zr34ftRYut$G#jZQg+$)D>&A5zN?ba!Xz40l63lxjSSg@jcaOFJ-?7C$Y z@X#^ba3P(5qX_{9v(5-&4zP{%8%e`F#W=&4Y5cq@-$u;;zcf+Nb(ny z)<*w&P9?+CmiH(C00IjEpXSttKlVRM+mE(Ue^Ww%CtB><4K~;wNXt3b2pPTzyngJP zS!H3d@OFmQO>}7S{FRvgds1k`ZA;K;!rW-aW2Q*xjOK2k7J&1FOj3X+&A?sHVBfMX z7iw~f&6d$UsiewzH72ESt_oh}H0HZmQm)kdMcJ6}I|pzlHv>_ePc^RzrrYK^ zKW#4C+>4r|$4g5!3Y}kBRLOf47OJc?B+_!2OFec($q{vIeEy9P39`xr+lBJ#*<-5v z*eq{Zy3kB(PYOOIlDAI!5m5yz_s%!SIH+Z%J;^dwHw0#|%|cmS=-37bChl~g zSq_5oN{Q!1{nl2w4m>Q5d6( z!?nj(PG229kL9x6CA_k+h-``ZbM^y9AaZdHv>@Ph{sX(2JrrRO)pUmMHVFkb)w0mcEJ=+uQj^#k+_r^IZI!6_Jd0mmOs{{nI1fN?mC zsJH~EY0p+Ke`C?JV$uProIat}#c|G>^^+^xxqmFfcAz#8wz?M{tpbsJ0D+SS5w4XP zpl2q(#)uBQ3>>tA9Pt(bOp0QmqhJ%uHx^dg8Huw;s4exUAmpEd2QEAWakdgOyepy={NfY9KWz2I$28=6CI28K~DQ(6RHyGKr9QR z^Rr)(>1o-K000NkL7VQA!X8Yf1V`OsNMPPu8lZSU>zSgGd{iBt_XiUClj&{^m^(hmM-`Z8ie@8F`*eUrA(a~c z5@{2F?koba`qs8~A*s^|WlgY=k49$?EL0t~%+AjswB1(}qQ_<26r9;1CQX zZThVLPtYtxK6ZEK^i}L1?RLFN#B^A_vg3&9q;u^*dRO{iCYqT_C#=Q$UF69@nyk3w zpzm<@)NTh@;;3A0WzH7H2yCrx!@<;5H}=$Q8}%=H9u3&r2yzRML~_sZ>fln$vHfYC zxvX3Tt{cqjSmXU>Z>T3}D*&AGXB#qKm9X}p-rO-eeDp%_zr^sShzXZuM|~^{HEA3( zURYJS=oS#@LKSFIn@qX@<+t>S*Mo_|Pp~X%amMv4ANS7BBs)-4{wgU)fi%dGh<-LZ z-}PJudX+feqiJv^IM3+TfaUczwT8#5U%f`J*n;aIqmNXsJ_v?!4pl~3U8|-zO%e-_ zj{LJF1X@9Da=LU4d^SSL?`V8TihA}Z5~dQBLO=5z<==m?V(iQy4R0GEoJqym&Gbi0ST;CqdO5&m?Q+q} z?|P5`%poM6v4EVA3>ZHT4&^G6knNoJE(BvdS}X?aejb(#+kxlGq4d!%V0( zm=%1`qmHhxSWqMf4`dJ3 zCZjD6y%9kzW8A3oaVlf`oyf+2*CvXGWv8uxzU&+E?|vuxKi9bgN%>n+mN0f)YX&sG zK_`Ry{#BsADXv=9o#$-c<@89(Z*rnbNYSeOTpJ-K0K1a^iP8Hl0|v!w2G2Ku?H zdw8oi=hv1!exLd{`~AxKKq?4-QvBiuY{41{^iQXOo0J_9#blo-t!9JvpzckQk~6_lkPaKQ%0ULw$=0H6Pja7 zgb1!z&}6e}yi!HqfG&<{0Nkb?(Tmp^Qq}2usC>|7D%X>?$qKq|yL%*BE1!26D8 zs82O9C9yQ)mbQgl{L9HEK_Mh*MXf&prW`^nRxwsd(G8k4hoV%o%`dt!>8Wv!(Y?y?qmD7L9h0+d@RaEtDZ63w6$ z?wltdHBZV>8*JG!$~A@D9V=>#4gkfMorBs(0erY8%T7D*_`4J%<0xhZ@B8(HW>GX3 z0f?$*JwLVR=I(#dVYnCpTLG6J8fGfvz1eDOesLE$`H?xw(X%}|c2KBYgpawxWj~&` zFtBb7%?hg1Wok@9Tq1fZWaxVqY0NR zN1pP^Vw6L`Qp|x48>Y;ZA^GBo2t@#6X5cR#ossa!d#D91i0EbWl~xh>on`3T1eHfw zogVKe7g`byDl*QV1}ueccN@nYc3}YAJ_ZtKOVjt~G){P+bX+p+Y0qv5xT7lKx4m^o zUCynI5^R|V;wl|0)yHWIx_6v~(JKr%o8aMhJqkJRpfKa2d5?`A73JHrj4V?``gI+e z<{H2~=!IEEw~r;j$#{j2Y+T(M_%?kGpiHc_nJ+Gokv?jO-Vnuy{}G^2S%{EVe}7mi zf}R3h8O{N*?$fQQyppqKb@}}7W+}F0LeU<8@c&{w5R3^gRR911XaSxKRF3~_>KBNr zJkH!1Q7tA&&157Fl4Os|yzK5k3ywKFX5 zb2nk%(@HuRupbGRHBFHyuyPN4O#ty|sdf1)bcr&qtS4Ld>$j5#Sg9A^;1a}}-nr<7 zM6Gd<^tbdnxErhw9sCMQXLZxpCYjaC6Go!!kdbRz>hr5(ZL1P*8jrxH3j&RI*+wbs zS|B?qUht51Q-$Q@VpqsonPH?)wQp{=aBE!>sry23ryXEiLYH9A3>_dbH0W1{tR_pY z;uhgYu|ZI5M;t0VIa$d!UFEWeqi9ApD0$u5%;Msf#PhncAA_bE#fN%1-3=j6>?8U7 zP2AJI6md?CE3y$~?wbAuB+0>D5;9revI;@B3hso9`0JG#YsGG02`jQTacrBicl+$L*rxj+HT1!Dmpr=>Gy|21-I40pF6mjD0=ctM&HlfoWMrUXaN z0A<%hHoB+=)d4Q;m`=!IjlN;S*u{<;#{MY(R_+w~r3h8p?=%xxgHP`+;3BtBmJCib zAgm16u_04l8vV@H+;IrLCLM!mo?(9Z@j6V!NE3PJxoyTaKfT8@S{&Xmf1(sjIy?yBe zih`ju=)wKi$p=%;Ds<*=&+qxG-v~jZUc^^s$RTtX$be zqttDyb73OiiQTP;9Pv+Itl$gz%eZ@}4*5&UJ;Y_GL7zR3`sHBj2=R8Wcz0#KgXl`6 zPW$$=*t)PlBb7-w8p;eM;W>Y{7AUiX8BLaW#X!{H5J5uYp>KxQ(2$k_u<`V>KW5(} z8dx27_Mo2K$*qd#Ru=pu$iO~@S{;cM)mx{09LO&b+c^#*`f)RVHZZ6D^$Tq@HHzdR zhXvI1lfnGKBa*%&jM1w74~^lrgfi@|bWna@RO1(?EaJSAO{a4Ul%@jmVr@jt{w&`Q z7A$o??z6iZ{QGz(7CVA>9r%YnVZL#)QHpa!-*q?U@89nSwc`^5z@}EZm_k&9H$l7& z;!dNXn9Lb`>5yH*PBA_D%^) z@b2G#4wX>kxoUA3+32ZU>AP`};@0{I`_HxBbv#ttw5Hq~dNsx!JKAkGMwMz!xvT0e zqrEAl-$;`j$nJ|E2H5HLC8+?3pmm?`*ve5{wPeqAT>c<74!gO}PVY3UNsuP0Mss>B zRIIYud40@JRX9P-h~{MX1N-;iX)3u=h9B0wN|m}0WOk3&@GE;$rq1^~GB;`1O~-z_ zwrRw58GA@9oiH!8n)vgXBDlV~*dOL?4H z!Q)eai%ERn3L)ta4VC19Kzs0@&fV4oXLA;lk)|X81zkhU$LD$0BriF}7xh%_CCiER zszibb7*6{Sr)`9ED+Lx234~S>0rwHM#~X>OLI&9Gz?h`q==q@ptJUNkf+s5WwTyv& zDZI(-&ymr^#0~^k|5-7KRJGUsnaaUO4j}uKov%={`l2`Bmt@2gS_)%lY*xU|U7-!uxV&L8v1j#XGd{DWjl)X0y&yMsg<#bJ;Wh&N;Kmf5 zC@NGsU)@Rc{`$d;!F*^9b+sFAcaAXHJU0dv08|dyr3hF3_v!~Ev002)f!KgW1(g?^ z*KZx}j@ig3tAIU3{)xU0OWW1aI`*{?Go*KNZT9~#<2(KSvbR4YaM=*A-$Q7EL6{R7sNp^ z-2iouzfHWHNB&?!5)NEo57_@yU^Bq7L;>ztnU;7U6|&Q@oNGuvG1@%0$KulNGSGWs zVC2i#?5u|!=hYW1jLmg=Q%Yvm?jf0FkcJ2!OQ$!aj$@*W2r10r5NVc4iVwZ{8MrTm z%7I~wOO7>JU&GM<(|xD?`s>1}EyYU+^3lO?PcPO6WhmEv=zq+T+-G>LFxuj;k{{+g zF9BX|z5jSYLF}g(Q?e^xjuIKq3Tz`T>7CKOv#S`vbO*hGZOKtk$$HBl_e(O^0Hx6%@40aI%9ZM!-v~9;%NDW86rb6W(|rH>4|W zN}GLi}vpGHq6~V;m81KYx1-F09Vc!2Z1aYA7-WLuOjtrjxG9q zfJ1bLl-n+XaZlvxvTRTslA7C$1K~fd6cXE4=Z>%wEqOhnQ%~f35_mo!mmE}C)71xr zH*mBSM7{BOUIMaTf$T({8HVY&5ca%<&tdNROg+z^adxq}Lw$RIChFs^5P3p0X>?PTU<3$1O09tY z%e#{KfHh&K4646g4SihFEU&q_QnHDmx|=fFcg#X^GC9&wP|twt1@`fz%b$#S6~Dc1 z3YLC*y5&Uip0g*Kv86N$g$_Cx;GMeV1QAQWmKHgM&p~afgc*{_9>;I|8{R{q z6n=A%ctrP_A~&N{)cdoK*58{d#(2VoR8uc4U-)3o>r*B~a*Wn5bu4{R<2E05k167| z;61dpJ~#7~3WN`JsP-KDJO5-8zP<`T+0nRrl&~Lm9DFugrz!Bz`bpK|tk;C=gmwm@ z6X^W}ubySd78IJ32q65;H)-RnKcrrzX{aomUg+J}>ywRq8$-A9GGsdFYRdzGYWG$e zaHs(iPo&`)h8}J-i(|PE;LCFL7OWt^fB*miqye5V)Q0@8WhS50^2A8v7m8#(+vVP~@oh}v$RleMHk?{K;;tFj$Nc3ds zs++b}TCDEeIPe?$u@yk*RoeY$;YFSL zf_BsGwIgR{m#vRWfkb4Sl9xs^+(2i;3wQa{`3+V7*@5$`6_{pNXw+v^1+8F0qM{M_ z-JHlu6l2nmU;f123y44sZymw3Ir;ktM*cAH8b)8Aa&jfJ#0*v!4%-EZNQ0@c%F{S& zFw{MRq;(n#Tw9{aO(~1_Q?B3A=dqs|Bi{%sC&@P!qvrQ6Q>h7J1mt80h!X5x-ad_y z)7(LXxFa$zM8nQs)bLDm^XemvnN6^9&j|UjA-8uJ@@7io>B4lVMI35_4<@>&-boRt zGj!7Uj(LWpJRABep95wkPqHuJ6Qc?P@&{%zBnrRBfB*mogF%`$lfoWMrUXaIMel$j z3Is*KGys?{hyv|*5@fTjM%N0V&9xgXN^9X4;_yuZQ^AOK9IL&m0DqRm2o~B(jt*l+ zH0<5>lvcDkEg@+;;mBG+ZR?pxwz&ovgi-^1a~g5vsN4_jG=ZyBN;8PPv++JLkJ~{V z_f}@KJHCC)x6+wEj000b(Pssrpsfbmpvo0UPt4h3Z7MgAbzH=%U_=O6QA`>DCh+-6 z!@u?Y&z?9dNi4rkj`81J%m2TWcam|*;`MWnvS_Y=&DSiWA5nSUIhTV!pjs=_5T$HB zYqWW_@Au%Fh6%xSRLK|T?EbsV198sk%7>2gx*sI6-?2aU5(8qE)KdV>b}&}cHH#e@ zy0U80KxJy#HK*--qAA{omwLbE>2ubLJ9hk@t%=Ec_?d+^|BR@d#H%d3w6*;N_hLnO z!dqjVN~gB__^sT6_=LQ*o~$JTmPqoCM5JZ8d($MHtkS1*e9oq|q=IU9s{rV3NF!`T zFFD8Ll+BT!*Gmg6D?gmm&fj~{d)_0&1F*L&&$JH*WJ@NRh<71}8R8^+x7ZEporJz< zMmf}|4Xy*4NkQZk$1yE457KfgmKzO6mv`B|UY(m=1hqLPb*TJo8`CAKVQ#DQP4WF& zC6OxxLKol8Wf1-ra^Nx7Fm|~!P|Sl*tHejQnq3KSjXXtgS+Pij1{0XU7?lnIbomb5()B=-&Ai@Q&qob5e zG-k4t*vMCtM|jtQ*$jdJ{CK=;B_+erfL&Xf57FPVZ~ClPTvkOdo1!jhQGwb1m-r%N z7N&snsUpPV&FvpOHfI09)tCb0FLB`pz$B^Ws09*?hj-{-kh?pyu_gMP3@`rqsG?R! zl!i)3GupMT2**gcLmPGa!Z4o}v!i2cKkMY(ib{0N`9fz7?*pn{VX>S#ejK6jUX1h^lz^P1PkVeji1UvfU8*&%Q_rEl^2miUYgQFPWG`HMJoEzeC6ul~ zrtc_882kqfn+y@B!PfUP&sSI6Sw`1ubvo)%q{XW>41=lm?{`T9Zxjr*X`q7_D&^y% z(`6Gb`zRPVg0D$fjR+OeB*T^97*RP4Bma+6O09mX$SIt{@|dBd26K+=%T){@;X773 zHpC|l7kkbiv7ag0!`A-R_OJvw9C}5OgC&{(A{Kls188H3_%vU^9nJ&*>SItY3?(8*Esoe{=Nj%Md(uNp@tH>(`9)ZpH5Gzu0Nxr(!g`guU*c zGt;kP8y0a{D?qbAEBp9iW6S6xk*_zYY=Wle$S&3dY)Q!6{^9raP<>=X@m&uc=|SH> zZUMYPrr}5Xxt1K=ujp@<);1$iBExZ>+5@P8X}{8TgN6QHa+|NpW~_M9Kzue;SSWsW z`j{BNYwsJvm00}TD~mb-=EE{yyOuct*mV%|vS~8`cI0l^SSYq8)pqrPHYo&i7RhaC z06Gh?xb0?WNlkt&(qZwuA)1>)Q<;^$D8+W&ZAj#*hr(~O2qX5xj7F1(z0=-2TrLcS zKK$badjI&a6+8_<$)*{j#O}e##96Nber*ys>`p|X}&Mo+**32rMVMCMKIw(#g+@qYL&lq{k%zLkJEAC;!yaQ*O( zdh9|n0dhlR|9#?>pF9od2{sIe!p@FC=V27%4g3biL)jv%$>(Dn!v4>Xmgmw5l{ROM zc=btX7}URqi?7uPUTEOm_$fDP7S-J(X%(1Uev2)0*najO=^G zaJYdZU&8V07lkxotY?#EPuxO-%#Na)jAPQ1KY3_y=+nc#!E<1mEoWUwpwyZVA6#kh z8AUF;$R`8>T#W=r4dC)<>?<5&D~*7XRT@RtRYP8oj8Id0s>pYy3JZ8$lgsa*Ll47_ zMX^TflQp_(A;sfG3nus_-EkGm{IInF7ZS}^ z%P;cg+$E)1Jw+51x-O<)|K9T-!rN^uFRxJG*^2wUolN8L0004$0iIOUj{n_T&D2t? z40|9Ps8|r4b=lvcuJ`k5+xq2b4oRIm>KQ#b^_)4u?mIkAes*9;wXa0Ygj7|M`*jM0EGqHvqfr>EPh$;5YcD6sEe}-iFQy000M(L7H5X>qNqsMSr;{Hi=pomYqK* zLH}iqVhPX`g|8y~8qj3VQQwo~uYJUeaAG>n1hau`WYKyD?v0o`)O5$nzm_BgnOZ7=wug_kLK#p@cIFmu|K~VFJe%|1;Cc|scZsy|yy&K%e-;Zr z-(eOOruZ}(zlB0rPSWD2=CU*XRu!SJTr7~dI+=y*GB&4 zPBctHe1t;1&F_p`CcCX2|9>bF8l-bHZE6&)DLY(Uo58wA?_In`-ofC&iSgKXMK;DEpB8o=Evv++>TT))C@c!t|ytNz(X)B8@JO^ijg)cCvYed zqcv)!jmHNK*DgqbV(|SYj5AqTt2N=oTSdOMgMfG%XXZo^g$2657Wt4*0D<7vZVFmF zBu!_$C?HCHgmvVU3Lv!=VYkQc#zR{0zGtOrvN`o*(>23-R(Rxl%M&Ch6#WxRS|3cm zfN=&kr#JUacZU$g?&TH(j@&s{kJtX!apM=~6klzuF!+xCa?UyR4aZf~5)G9$m9^y) zngZ~x;#e%7V)*gCWryC2YeEj*E)@m63mS^sieLSFP%Vxg>wEwXfm%nXmtG5jRJ9E% zq2LBvb=Lv7X?FF2xOsyib&`|f-g;$GX#43Jt`#*Bei(2JushbA?e#eS!MGr2KFP}~ zr&Q;21NStB8s;PWn-O551oL;ImSWUg>~2U>A}|M_Jt$ttt~|Axn5>w_Z&UVp!X21^ zDKhRz4B3N|P8NXRsoyi(PeFm=(fve_aa|&g%BA$olf5^0=BWTHTIX7`E=tC|y9j9enH@gcbP{8@jhEfXA)-U4XMXWr(AO&Flm#?+}wA~ zji{22Mu6DG|5d}5ERB~j(ouqOE6UuHKV?&YO?$0dpPaY1kw-@xE`e^~shMnnHy4Co zTO0iMVX>qW(Uy?GLKe#*4HeR9b7*;aIp@*xb|g-GOavml2nhEqA@!NW^d_BPb zXz%}W1#wF5$4Kipq`Xc_^F_jyiEJ}#d%f;@E8866IvXgyvM653ULrnXCOsw{A4mo` zsr8IA*GIB%57q97%QS?E%DKNk!m2!Gm(3;VFiI53c3XQUkzRY!pjcYPtz#hD@tR> zvqZ6=tEjj~oGXJfWXflhEe#~{;`P*lHn~jUkN2Pp&8sp>tf9{qT$1tVb?xTq6ccRp%`R>*GwhGpWsR7`dr}EET#;A1s`rylwl+@n?J@QK)KPL&CMO-3AOyiXj@5t+7dB zVOeOl8Wo0vVW9{>X`p4+UX_XBmT0|IZsnL98Dp7Sn%&3C(WUx1uoa-dkn0hQ$3~tn zIwm(-oEbwrdYHowFV(sT1jIrzQd_zHyPsG}E}UpK5eoGQq@3va@e=F;PlKB87)IlA zHkZKG)v2Zt8M((uhdg@5NdDipa67a)R5GY`wA~>xj1g0;;l9&1HrDv}sa!3b&%toJ zC(T}#_U$!jXz?9qI0xZSb}pqQ@EuR9dahyo>(LO!g@y&9y)vZ3rdro4#|@%0M~G)z z;J?MU6;k!$;wv{YzePyW257}+hg>;It#)?rEW$mny?P)0R;aiFY$A&afKrAy7?WuC zXzREvXEgL~HT+Kfwo;vdemkXs#Q}1$o?&Rd30^H)mljey%xjn02wSla5YB>C_&XFTsgL#FGn)XWy%_EK{J-SEN8Lhq zNk~{8;iB$$8<$>#tQwLa-reXQZyVK~h6iHdhMR{qBR*;A=e_dD%r;=z%9MLg`F4|( zr#=!MD#YMAE)A7OtUv(U1U9ZJv*P5W+$q>cil|N@o5SpyF$bg4dGn@n5hY+rXw%iK zDd}ATF2_N+3RCCxUtvs=xbzMRAP|T!)0;+l;@#~V(<3pv63qKM-mx45-Gb{d@SpQ& zONSBnpF_xHLA_!cUhJtcM@~Hy-eDu(I`WOPv)XNp6TV|!a5Y^XwNu^|+QcgM^=Npt zbexDe)?A4onlNmlkxtH+WYE~@SGFJ6KoVi26bQl#O;*4>zTx!G=O5TGhIIjME}`i5 zf*_>KWRcmqVo4h6GuxB*BfE)OI@=NhuZwR95`fEktGC}9|O06 zy1NfX;=UjDx<*3R(e!rqs#%g-|K%>=Z0Geue03=?#_M_kJUKquVhWdlXy~*$dH}RQ zOTRp98VJD$VM?u^HRci1^_ab5;rIa{3Z@3)@7)^Cv`IwpbM$=A{(j&3jyu=8?))@q zC`VL%6bnUqyxtMK$MO}xW!vv0%0>~L$&~ftl~fE9kpzHTym&dHyr#xVZmm@-gRRR{ zraannY2Cq^f>cqbv07KaY$cLXsCB9)IFXR{Z3_r#(&gH$8MmSiHnJV)Iuxd22~|Ah zn<|Ed7Ge%fE-An-OhazlV8p7Xk*TU;W%)6R7!?&;1Ljd!%=T_Bb1KEPYTpLv!=zDO zv8{eiWA0zT+k(tQq_Vz)exY>A83JCMFi!=ep_bzhCG%0B1Ch6(ELDcOJJdF{s8rQgO2wbj|J>XxEDxvd212n-r$j{T}Tt6-YWD;hFZiWB7CY_%y`r?o>>kQzM;M|IBQ*0eAcsN41bL(XLv zY==lH#3r(I#QBfT_$L7Tl#7^RD=g~n%+ATTRJ*bHm*_qZqV;X7k7A0fu#hy}d+DEUoE$!gj+ASbHpZ#q1iJu3D?L0-WmQLh(rlUH+zu&6u=EgjKswy0S%q)hWA zu}QUnhJJ)B!mv!JiDL7Y0KrA$h4sg7ua04|acV+?Lxc)xp*ns;X?t4a%=3nl0FXu+ zdWk)3pTj#%Ir58hZH6VzyI0QUS0=1L(q+&MDFJTi#KSDuAhedc(bc}4x-=L(HDjBn{hkv){mCC@Oy8hqz{R>Ue|x^Hu#uac2Xtxb~H2K-9H2cAV+gy%Vh>=!Ij&)Xb z6v=M%`_-Ej%@UW())8kAk8 zsRv`5<6s_OuCpJFtng4Z-#udbWI0LbT4I&fsP+SFMb+qjBNu~H zJ78yZJI*!d$mZbT^SaABdTl=Cw`6tJ7KbJ=%adr}-qp#BoWz`^q);Ux=#|gOHWt_l zd$7ib0zm*+_pIx|7YVgafEbNh)IAufv#?Rm%$}Jd0J169WR09mnOQP3F`L0%bzxlf z9L;&tKHJGeDHWl-mFD?99IQ7alqM2Rre#y3*nv9LM1~d0lXKB6K(7oe%6#P~aE;AQ zZC_DUdtz*-#V4&@X;pJrU#~{bQ&mTGU0Yj6w3jQlqXk}<4qlZ4+Lp}JB}GDW6Rm(m z>!{I$4=@S<`o|#}lx41+4KfE7-ij!+tkPGUB&0}CXLjdP5*aF0XAT8si<9Tlf07Hi ze0wGZZa$cIS47-5j5Z$-z`(pmJ@R9lfy*`6ZB{l$M2rWWVMog#YWW!%eZ9%@wt8Y(9z_rItj|Vi3G;V&!q2BFjTfv#`!^;QD_D4z^9H96B3{R#KDE7njMsnI9!lX3X(# z&Bv&tpGS0r+1FjHojucpJc4H9B3L*0!iFvE4p2h{#4tb#2nxYK zh(K#~%xD7__j=;e-h&d*mu?G)l*<@YW2X1!!%-IN9E%M-Ki+1qO@10gUaLCP=UAtI ziSGXs`TyqxYX67Kw{QS&`Gw-_`2puv&tjG&ER8R%=?ae_8k9|*tqDOGg2<>5Y*{rW zB_c^jBL}-cvmRn-p%5W(6X1}#Qy!ZtgQ!0Ln|?11{@it$k}0Vlb37aMDA1N4#vYCA40p`%s0)zItxu3WiY9xPbbtY_}9 z@O-K$;_(-V{rFYf@lPU9u<7~YJ*K4#K`4P>U~azFM`S5>gWT z5^cf_xq$uq z^yJN$xwI)iw&7KF*vS-FDNa%rHneSaHn_W%6iY59^-4$Fn^4hk%V>>=kEG5b=w;#5 z;=UxQ&mD;*!BVE$Jwi6t%L;?UY$(x0qY{dgh2RT!+m!e6yZIXAxVMYI_KMXLiFJM1 z-V-#&FBAX=@WBQOp#ca$Mqw94?bZck_-PLq+tljRE!tNT2O9-77=k7iAjg9UC#2Lh zPg1`WfzxVBDZy1!4)+_klEW81!ks$WP+%S=IB0KvT@HPj$Oc~*i_8qyjMMr*ZS40O ztXBeLfRg_AkMWS6u-`y>AsUoLrke_4Kv;qhS-{1Tb7>@zQb~(LkVFXnIXoiygpkoc zB$_Z9f&B z{?n2h%IC~U+9?7W36*ZVgi(%wZe0`gy{$HjQ4M>yDE)i8tGRNMlW2s=+O9_8JUA%m zEqKH}0J!wNOJ)Cu0ye~{l<)6dI^u4;i|jvK_1>WjF@P+O9p^NO%hp}&Ejwl|GE<*h z)E6n~+jQe_YD$GLMoZGTF;P~xk7Awt*p$-MMefkHoe2Wk?BO!nuT441!EUyrRg^S% zdVj9m?(aKZR6`P&rIh6vv9sSSE%5+V;guI#;WZS{6m#44Nif*OBSv?o1afGN6hEY@ zdmBS%FJ)O8C{EEumY@J(U_g~)b6;{6s>#l_Cjxql6;%s?o|>(gAHysv3k(dxF|P39 zP#6^2yrw$InD%>WyUX&vy%^9N9h`|bMqkbDi%ps!sVILWsaFdz-W6Iidi6Vt{0r}B z*?&!u7sMa{00TZu){~W)06h1|^Nu7_k`Z43o$ zg(FJ=BTfTzgi@Xzz2|AiWj7L|Di3a?oW83L>Bhw2s5HisNOUI^hnFKKfxZgqa%fu1 zlaFwdnVq9rGU6>!Hq;t#3^6B3(A5HI?oqLyY5A+aA8v@1rnB_%C|K9NMdi6_DpuMG zW|~YEHn+3{S4$;{jXxcxq2-5PY?O3U8ahtm_Asl_n1}%{g8b&3Z_mc9(Spo16X1a> zDJLNsa>%t#ZRFJ`IMH61ijr&}6eQ`QxoSp|yHPL;^Qg&}v4>66Tj2sM#UwlZz77g+ z!S%$HXhK4F|_YVxUxXt0E}qs;5B7n_Z{^cSb~VkvytEo0TRhg*m@~R zw{YCwx^+q0*7It7l};t1nyNYbWwO9bB2nwP_b&+?0x__ER%AJ?Usk&s+BE@D#24Cq zk-Lrj#y;nZPzBLkOQc|F2mr$z3MNQZ)vN%KU3Cqz09Jv7lKHy)A!db>A|wpP1n^$Wh z?U>esQYvGKl_55%S2?{K=$Co#SlwrrhT-ItWmfCzbxhpy#;=||O9_bqSznc1lZj+F zg6l$9D?E6oHHC9cIS1Mb_g2(r>5)(#iB2O2Dh;iCcpFt#YOM&s>nld8DAcW#CgTx& zp4z{c$IH8BpHHVZc525tD|OjPxg`dXLtxB{hEj}@#FRIMR9A&`7Le6c_er+~$!E4p z&hC}XbX>zNb=_CeS_1C=s(aDz5M-I`qAv#;I-EXN=T!DwUAO!spA~?=wzjqdWFNyU zQwSK!g*X7N<3XDfMEOxN>b`;~<(Cnq_*pS;_^{f=vpKkWF98VbddA{}JLnU_^b-z^ z_FfojgMZK8{I?=O7n?Eyh)SHbKpY_olvSRQ5o0L;wM{JEq-3;-6%egbV3eFn0ZG+u zynU62+G^zM^qL=8m@1vcsQd#fm7i_6E`I7tw{tdLBYMi9No3Dqqhy|>`uE7p3-t1~ zJOk@wOww|)Dm0T~N$L@mZ-q>}#M%A%?*#&v`DR?ku!6@>)^hkoW$-i;S+3&P)Jn$| z(zj|ups4RxQp6!W1iR!+bVd_wVbm;w(<(GwGswRkmAK^>3o)dP^~E~SBB3bL8U)0j z1sH}*g+@TvB&Lo`Qd3BY0U21PEGc5@VJ87ogDr(zCAX_qmRNUBEr2sc_^8@2ov?#k5e`)=}0TE^Ao z_Vv0auuYLsFAhRF&3`QoL)&YX@32r|D$K4c1Tra=QIXo?+w1D6mx+n-Dwu!;jL^;J zkV8liL546I1YrS-F6bp*TNGRS+tNeIoaE{wGKccgu$N8GwlqyIvi&yt?i%(>{a0+( zTM;kpx^H0W*iz6ah9DwDAm<(?S_BO?2qI#JuJsA+*`0x*XT~T&4DZ%*0O=tblvSdW z24%qjLLjvkSXP5B822XtxaD{T(#~}A=0!1xiy5j7T3zUkt#p=DQ3ij2be*{Pt(XzwN_?hq!vHA;}ekrd*VlLbj2EjgplI+IEi5qP@U zt)QL2Ap-~|AQ=P%HC6-W7r^G2?n3CDjO(tTDGf|;#<`}8)Lq=Fnt6LT=$+ZO)cIM0 zJn;sxI6pAyNt!`baS3V6s&_ z=cd1ZY96@IqsIMuEFT)Pz@nPU}VVxuRvChrMPAH zxK>`;_SQ-xUU=-%vlNM)Il#zLCG#cbA}aQw~t zy>z9Pw+T~?D~A*2*>HB;az z60ML4*%qtw->L2Shn*jmz9 z9rn*-YE80wyoO{e|YSKp>WbiHBrI&T|&>g8V)W~o^U=@?V(-j3y)EP){QXY075(Asw z0a^_Vf?p#zgZN>LVFI9xgb5S_v!|o|us7^Z|LT6mqk)%Zs`zdt6>RlO=CSD?WYyf? zx2&?iVq<~91?B<z#M=FAsUorrk@C783j6)4Fh*1%~V86NGMccy4pzw&c^2Zmzwn(hepf5;;-9N z+NWRe3(uVW1~`np#qtLh_D?< z6erWjs@V>6?4%v*RB?M1fL4Vbje(2>q)ByC=utxus3?+>mzEjkNHd#?wot)^eJZ0JVEq>gQdN`T= zejGaq)no(za`W+pTIuFDX+H0c8~)2NcI;=<)N@wPOC=AOK4Xa5Oz5*9MXEU>aCf4q z!h0&`cDL=JS|*=qNfcJme}QaZ8w!joVx4fl!M#EnWViyEiSn2b(RlEfjE;7b(mO5Z zh|y%+OLkBP@Wc^>Ap(d{LNqAU05xJu0K*U3zE4DRPpMFI#|Lo@r zdt&9*C}zoVJQHK*x|xoJ^Xz-x$ioD*7d|+_?Fut(!>Zm@0u2-iJvzdK+2yT%nU5Y4V|SHXytZ_qY~KLC2{I+zG@Vt(#7Z zS~T-@mxQNV=Da(^?^D2=;*rZ!{ZX_u+foZv|7f}E7+@l8wRlkHr|Qx3=NTqSz#U)m zsR-U4y!kZ6+nN{s()gGRaVpg;MdCYW2XduLvbUh%d5SV>zSb=}rJP-a{baNlIZHkz zLoK-!A&gvQmRE6-ra?Z&h#;QMEApF+d93W>kWm^QRu?Utv!P3G25LXaKeu$|-zCH- z%$hsMgS3#pczJ~Mzw%_YCL;Nj#_AH=6Pvo zFDp{^*;zWsod1nrb=c?3StcU&Eisr(V9AQ+I4gSLSFi)~fkDI)4hPc*8UIeKFmI3J zkhWM(aoS2l@~*7VQLdA@?K-MaCU3Ci#o+d=;Rn*hLk-xg zH4D&{{a9S5@5!IKo}b1Tu~87OkTT#jo@;SYlLJ9r$AJI<0jvR@denqJ9~7eo=-)eu)ptfyNxDasiq5jrOYkc0Y;NI+oRL?$aY{j&m<_ya+izGg6s` zTg>dL32$zeYKy_f43u#jVI~rzE7<%x9}94p!)O=&B)AJr@;a5lNg1_MiNIl9V+%5+ z-ZsnyJd&Cz?v@wS?u?=0cI9oo763LyYr#WNDgs!0+gM+@ffFE-xb)tb4**C{?>TU- z5A&V8i6C1XLe#E)(SYEzJ))yi0J)y8pSznu@sm)3x*tHOL$+TQFm4XMcbiU_1DG2! z4^kH8hxGW&nRQ`=^P3fID0@#T(9}r%VBmgvVfaP6O0`R~>&9znF#p>{!a)E4 z2q;0CVw3Ac!k9#V;fWPzczmN!AXg_1^!d=mV;ij|JOm;Sp_qLUvmIjml8t)D14ADDrxf*df4{TYjx?s-X0aXN+kRwQa%3>=SLlDts^Xdu%g(M#InkKHl zw>H^zI`;6>rN^oLd3g7NtfGwF3KbHkwcjbca=W0*LW6hgvwUR!oeRYUI~JuiZh!jT z$rp3$9&i&t#flp|A+(@-HJ8{2Nz5eu;%O!w^iHV_X9Y^ya^&&kA;_tQtL{SQEfF|w z@g#xew-)eLzy+pzPNe#-B|BU-?A`u4bt7KsYoRFw#g!<>U_O_ZKqUVj8IDGs8{FhW zx;2DN=_tDj^58Cy0u^Q-N1G%kFkBF{2Qh1bW+<6g{c?uKJ`8TD7tg&&xwTFx$i)N8 zqiD}G2w@i8$BU2iA|gLA1!{j&OhA`8ZfVG#>;R4KhR&wKoM5$c+31u*tY&hokylVh zCm+HIvq3t2vJth{bB^y_H?MGsWj40^bgOcf9aUIO8R1HnmF>&a&I8HG&IFXCY*p+m zSS5lIir<7FE**A?ZEuF$mo)^VVD!K$EZwUv_JYw1lB11GQv9j%oqM5*?!N@xGDoCk zkjvUowOKbo9tHyB!Z^ItG**mkn;;}$d`_Ka&T*&t1@a)4uCA-Th?B-~#F{ zY0h}s^BnG+Hl>?OnoO{Cy&#D*$P4zj}nYu?T z6XR!$|5t9T(%FO|tgZJr(Gi4R@ktb@2U`oMAD}klT z8C%cVNE;x=-Jry-8t7?Y^N2-{)YM;9E=(^?W_OW%xyoS_J&4D{uh$>;w!F56U`syS zBnR#Huoqkt4`@L&jNkZoNl?OGX!;&9w^WKB^NVerG+C7}tJH+)bo<4Vp;TJ(HGj^A zT)&*`j~^b}W_pp=CvqQ2l3Oh;Hq}0?k)a1Qs2c-PLo(d3=c>kA*dvAD;P3`Vk2pTt z3>-}4ep#2Z%IAzjyZ@LoLxc!B^&FjY*DcQP#!I{5C9qp5EfQ>~?He~3vQ^=HI`y3^ z=-@7%j_iSFZxoI4;(?pH=Bsys)`PHx`|t z29!ORF`cwSxwV2DRvWt-e(~^Nd98Z_YWty+s;U4pjKB8V2dpHVnNH6O zyYvDL{bpepkChjDkYfZ;$XIB^+#^GzIjf*j$dtpoqS&sm(O?le@+=dPhmM7WL_~B& z$pQO2MIK3GrQlR^H!LLrUz9AAocdXdpP|j2vJ-CJcTX&~K?IAd4@4c%@v?z0xpOF4 zNUTlQne4^}!B$dxr{^ZAHI({ESf45%Axs6}6JL+TT(;?3jK!#vYKlB{%d4plOc316 z(C$Vs!F?}uFdl&MpJB%^HU?HqdlJHhoMof0fZs+7;2GY@DfyQ+tj|-!W5(`z4+kWp zZugzbHGgPFsiuX`$Ls&OM|>f^+M+5LOX`F~l<2Ske4?N8cTe2q&4R%Ktfj1zBNRYZ zbux0#D`xl!WKbS{w~t;*+B7A!xul1zsW-I{xEUiTBjwj0#<>2ecLYeRNT(N%?&c)Y zew%t~5KG^&67*5DQK9HkT$ zr6!_|X-C9hg8&oL=Im*QV9Qgq7Q<|Xu^4CJan`BpNY3AaGap<_=PFH<_>L-y8ia#D z^YU3eA(HVcV*kZ9cZk_qCMDb@d?-~edg-Li3R^pz)||#MDgS&6!CnfGlPK3GXeHD0U7^`#ZB&ZU?QIF z;2_qSDSytNe!(+eumI2IcYu&FCSqM<6Vj8QjV*Klv%u=P?G4*3Pnv7Wa`*GQTk0kL zkvF6ELmu(UhIFaEn~%;e?qZM(6ahd9fHTVYAz?FGSlvZeN6pB zaMuGiqf--EIa!F^KEa=aKZULQjFy1vWZ!!i^Wik2xlHqmQeFiGY6;2^ux%^e71Z^% z642iS6#2q^46g3VV?h@Mi*oXK&e@sd%pe;Ro>$?&p!4CeVsJlOhAyTL3bxGglPjBU za;1U#-4^0xU_Mv^HN22be7~>Jxx^o1eAVp2W(_goettkg#`H^joK^rG(Qk9K&@BI^ zR*{Yg)zj|E5SPe`X0@h_SckC_ci(EOq=5Y*V?K#APl78VnUi;P_)$kP!IWT!M(dit z!T?!Rs+^CbRU8?d=T>)Pmmn{DuZtU(mTV1(=)rOkJLjRvFd?kj7J}`9kbU8IJ4w$v zcZZ&{@dOJA&S~0W!jBwTkQ=>M5)(8$h6Bau_Nu8$&cb5FeZo`L%IfZ|&~RArJhZ(R zIdMK3!1y)40%|#jAXQO4BBr(!g&T@q|7dkvakUS1uV-`{m$UOG|JDtA;{h1^#SnD@ z?U#P{;Dp?xHmxEnxKO14W?4Off-~c@f9|!|I;9FEf4y-pFyD^V>x7;kL693etmr8i zW`AheQoag|JM<5`(mwDn!!4QVvDWyeSFs)2m(<4{YOA2a5@!lQS)aVH;DXHyJ?FrsX@w(>= z*DGe&6qM^dfa2QS|M8yC$hzoJVijqe!}9NSTdm(M@uD{wJdsq{T*%`_6;a{dXfkvL z;08QKu!t06KwiF3T9;{bbOtPc^a2)*?Q{AkJ3sb&5eSz5V3Ks%4b;`X)R{-6K$Pxg|GIs-~WAMo*C(4eI4aBbVgqaA?AQg$(CnW;x;?? zn}gskwO!h?rIrz*V@4Ljl0qam<+)r-bK0KEKbQuq)bCs8MD67}hcx4&pb=`nh^ZB? zED9~12evx7X~lUu&6x?xm5;9iFY^(@O43k~Q4P76(8v`-2q`t-6~ILc7Dn4w4+1m< z)jEv)?r>}Q`PvS(000M%L7Iq@!X8Yf1R?+C!Tqaz;qDtTAfOKVa?I;xyAu0z{T6`kq!buRsnb+R5CU{KO;`R>jL1iVUs<~- zi-fzBSo$oZxI{rTUUQ%2`g2u*O`AHTg*?3jTeO#l>mmSNJi@wMC+?aV-Ge}2H%L9t zFIwtM{!Z==Zega|dsMO(wS0M$c^zy^8;$7#_4f7Q?*v1H+sqWKOq$cF4~IfJm*PJ|noVoib2Q-Kb1wj|l8tWicsfDhg z3t_!20p`t&7V5qYcC)kZ%;T5$PF(T9F7YnUZOMtVeZ}0a(Ay+a=?wTRj)? znux)_PyyQO%mrcTd^VI7X^D8!;+a3NODS5Uh@M~kGZMh!{su7KBPHOkls%R26}y=e zKD%wlcfrL;l~qU4ZYHMQ@Fih3v{W@s?q{)oh7?YQ^F}o>-(gU?@AeN!XE!5hJqYG@ zmq#`4X4DTP)02!8gdw-|N!HUaI|KYOjm^%Sbf%!v6I3`ZN6Y^Z@nUhHbh8fXbIZHlad(v=#t71OlWio+(_MBMA`9xsPsfS; zd{t%P-NpW`I}0K1RnRz>UbCi%?@LjW3)5d>ycr55NERAZSz_2taEdb*tX@Tc$b5*H z!XIfLY1ss4D)zB*&R2avNavxj%v=YNjP(6X)-@U@G0B|Cps>Yyp9z)qbDC@L|2#+1 z`w@Ye7wKc@R4raF$(1LbBHHp@OopK^_HGI~z50(scso?~Xh*-P>KCyaR&m?4+!5HRxnzSCox&Km;@Y@2c_ zmm|f8VDNv-n1T316Cvfyj9YRivsEKfZav(vy^wFg4pT4@?*LHtM_{b{fl_*(`1oh3 zc;@v)6cWpP6e+&US=&-o^9bfwYD5+5hlkx=mSmQaez7BSk8bCRa9!=Nrp`;m86g}K z>C6{HVyfGm#nAd)M9}&Mi@QccwzQ-apR-0Y`zPphw+5!lUjrY`n4b7Xd_V@emBgzt zRW7Lyt)NI)*z>hH<0&BBY`lH+C!MkmSS7Vv+?J*ts-Z7tVP9Dj?**|R7dXfk8{Z%; z#)GK^jn2c}SYEM*qZl+_Ci&l(@a-LzfG7>=&WG~1sX!KQ)wuwQ27<1_`3giYT~-oA zpUey+LNyh5F-5J_p9_`zWO1VNMOE~`G6e)LJF6D z#9CgA2LObpO@KiD9##=v`7M6nXaE=b*rgHd9ze)11Ek^Ge@RKM?;~WOqmF%=TV>sF z2@~N&=qu7eITxiYj0)TdlYG8+({X>57GW2?9<;r`mH<20hwlAqwa!s$+)56@EfC21 zDO_*Bil{{5>2rYU18}%oM^}KIDZKbH1f6;8^A1x5B#abrK9yvWj-6pvug!ixB}h{D z%_--rr%$4s`MUWdytc=3##|I#vBZ%lx&DOa@3biu==2z*NY2!M7X(p_dz#i{RQipV zVTSqPQofFWFsNOO4j_afAs)scB`r$nPk&1@(JDsczL0mDK2zj?Byb#*?Adsq*XIS0 z4L0gKkl3IQsra2LM5}4oTy2^R8%kGlQE!1W+i3Z(^+9lH=ouN>$3kOAoh~Fs_edA^ zdsm10cyOeNcWDP45ORtRIgh*3o=}o*K$#KEv+Ka2$H2PKeyV55ONWye)Q|y6pgz&3 z5(e^>P_=|&Bnov4_idW3DLtS`CwCFL^yvnLbm~SUS4BL*gD?`h*q`#&VFosFdSV=T zEcN1I&a`nnub=}BX+hL9I_`ul9#xx-q2H-+g`$c-m~-Fk(8PofVzJ-`p3~`piCx=X zaDr5fJtD)O82o3X{@b;|3>lGo7aDnI#21 zz9f}<_0%_uN7O4CdOXmg(<`x3=PPnr(GxRv#m07a10Uj@G1MEl_*5M!PG^d3w@LJk zMJUO#kumm56Z!hcFd=n3FqnHL1gK5Tv9-|v00GVco}|=;zx6*F^u$@%^b~vbYo^ID zZtz3(yp4uG$g=H0YOG5!-C@QJeY)PV#Es4Mplxug;2qv?HV>@7;(+fu1Tn?a()vDM zNqGArlnYIJ;|{B0RPcw^f*mxewpjiit6a<#cRU+JqxGcf9Z;93>sRZX5xMvDT)FfJ z?`BgKH_VU8a{{XH{9(TtioCDk?fk^ja;e&X!mHfpD9N~Q0a5}8)tQzL9<%bNR}eml z?q~tUB=V5ED(9NkIDc0$RW0&$hK|GB`LmIK&-j=LDCPz7ve3F;NFmUFd%#t=8GV)B zg6(O7Yg%o(3BsKScG_MFvNec|v+10+F z?>Y<+4@dI9487 z2pw}GVU0<#Af{2@^Rs@#wRsJ~hUypLga7~m#{r(E)P_IzYP@gWUJ9;YC?7Ua5xjBF z-ve77X*-OA(aUeR)0UU(`a!qr_3|%}h+6Q4k^HfwrnARKVko_l8!t9It*(a^lBOHf zx5{(hH%Gz>Fko{9`uYcg(ikjF1X?J;foP6H*^QG^_=kfI_rEtm&^r9GYG3YS;ICn= z+63{#ul@vxm0ZIO1G(NyEpx##6p)tVZAcZj4$9YQrK z&wec;z71y#oh9gQNVP1#XE>JqK<A$fvzP-LeQbX|Y4MlBrQVJ+Q&C9}a71L{Y8Sj`b{zHG}{k~98BwI$0Jz$Y}_DC#_Oe$R4f1_wnVuH`bZ&9VvgeA5VJJom}bh@a)pf*Z70qHsb zl5h>wB4Tq^g4yzki}!$d000JqL7J|U>qNqsLSO8}Fy0Ah_14a7)Fm~f<~;>J2r(PK zsGI{rp8$Rtw zEZU9EPR@n-k%y`(dNo6~3cTOwJ--U(CIQpbBjZGbN{$4>X#F*sfuib35tE!ii?3g# zq-&+*Mmy-9k0B=n9z@N@1zA z{O<%gO1x@}8BKxm>rownxPHPr_{UUEcGXpMrrAwddgod&J~}Vc{=ZSa9S8Oeo~W&$ z+vuT-?mVhAj@qLN8`HX4Ya2C_F-bIJjt#xD;0)qKUrjMzXJX9tB>*RmV#yblOCo)0 zfU@fJ*{n2D2J}WXy@*-_ee38FUB$usmbAZiY{iO^I7&ND5LwWm%Vx8a;<}tgbr^A9 zg^h}#DB=S0!&}g>T89*%OH27YspR4t4_l(Or&c;@&vwfI`BX*kCTSWz{1M(5H1RZ* zE`7+%422=W4Vo;~dYozhz6yWq2}pfWAseRXTbVN&Zk#ThR8@++{TLUSX5BCyTw^Mb zAc)MnBJyo&8#{9-JQ@wl2Ox1T>j#yiHf@pbslKeBX4v&f4tgELjwD6&q{t~^3#Edq zk9(h75}OyN?mX7W7C_BwZl!1xGU6jv9!AVAEOwQvLbQ1Oy3A)ZYO#|M~D zib|>ypVtwDdHH{p-ENeHCUI0Y7BYRlpw1rlx|0Zd&d2riAl4uZw9vokoT?*d1~Z%t z)Z_uNo}(XESE%AGu1xqKZ;Cps_1K!snw&2Oien5E2!cRDknq*c*-Be=5H#9U)I96( z1_tb)64e^E-PMuvwajZ-9D^3f=wnW6m~;m1$Z=%_UCTNQg2RfUD{BrVN&RoNpnW%= zLCw=`|6Sr{=+mPau5gU8WTjZCPSBOM$i1mMU`T<+9qW>mlotuPDJ&!I1W2it5?Z}^}(+QPUjnyoP zLvlJo@7Mkxy1M|vaZv1X4r20a)8`o(WCCIF^=UdvJEWm|i5$(&Tp(eGlN?$!;5f|4 zo?5P_zqI;rEhWKe@$VXhR@aY5Jz9lEzaG-p{{1zDy@0PJ@WOfcC=o$L{jH4`j+kBg|N#tTI778)z%BtNtxn zi7}CYE0kDa+%WcxR&)Y{F^-J>un)%o00G$np32mQKlf`(`4}weA+84KQk^ri&j)@d z$=Ybobu8)~!qO3}15Y9pFbO4G-$};QJsIh^5=lNBJPw%RJ;yQTySd_KsnMU=OkI$I zfCr)I?URLEO#rYs-a8A>*rylNN;da?<)~W6%l%I-N}B|o7LJxoc9~0tokpq}GMr%> z7ir^NW_(l_;t1<6uX?iH`v3#tLp<6!o-}NQ+p;|u!i5*#>BR5+qP{^>`ZLi zwkEc1+qONy&bP1q3wED$AKzVFYgMnR=TUr#fk9gR^U9Hx&9c376iB{di_Ph;RlO@k zz>|nRHgsaa5bV4cdXRRcT7PNblIR{=mQ8R}Wp?e9J+8R`)XA)gSg8^kWq;DFqh~K% z+7m)9v(HgFAK2~UO)Hg zWde@gX47K_3-#7GB7~1lE|{a?I(XJJacsW$_H9opQ|KLKB19%C^|8%FD3*K4f1L%= zf($47#@BqecF;W_MiqKydE9QS-gU43BR3D!2%4Blo?w52c9^+UH4UrrRSCf=CA#5U zLP9rnP^g5ipite~5T0GI3RSi%{t=zPlHXLKE{#Qu-^pLFW#t5?cEJU~-xZTye%t&a zff^!vQ7I^b+Q6Xktvqr*{+l}bXm3wWtv?4}Zmj zTJyr-0A&C%q$MU=43Qomj9NI9ft-K$iTw*bwbj#Skwryl@V3GIJftm zRYvIdS8N+Bmf^cmXg?84I2g_pETunq~f!KO{?8`K3^PLQH}fZZhO zM-&CFK&eXolVpNuv>QbakHU*+~_e>f5{%> zgqfTCCSia+&R!XK;YTh-Xr~Qxr_mZbfwUQSys9~ zJ~EwNu&JlmE~~7~%H|L#f-F(UIKpRUmNmPqxWVuUJHigSLX3yg@p5lRF zh3|!gJs?>IVL3K=fSp17@=aw`DPfVdX+owu#ri^cFN=Qz)X$p&Ti}wPzpw1CpKObt zp7~`^)Cdjl)}UIE13@16HNM;-m~)S8@$g`JJcQ)uwM(lb85xY!f&wG3-xw!;xnJ>{j|4;tOXmCxbc#VyFoCEtkK` z9UUvwjVPX39FT$+n|@xzJOv>85l94�~9M+3*yQyhe(3#}_hZ%GA}MxgueglVn7b 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 [Data] { + guard let fileExtension else { + fatalError( + "Failed to find file extension; ensure the \"File Type\" is set in the asset catalog." + ) + } + + // we need a temp file so we can provide a URL to AVURLAsset + let tempFileURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: false) + .appendingPathExtension(fileExtension) + + try data.write(to: tempFileURL) + + defer { + try? FileManager.default.removeItem(at: tempFileURL) + } + + let asset = AVURLAsset(url: tempFileURL) + let generator = AVAssetImageGenerator(asset: asset) + + let duration = try await asset.load(.duration).seconds + return try stride(from: 0, to: duration, by: 1).map { seconds in + let time = CMTime(seconds: seconds, preferredTimescale: 1) + let cg = try generator.copyCGImage(at: time, actualTime: nil) + + let image = UIImage(cgImage: cg) + guard let png = image.pngData() else { + fatalError("Failed to encode image to png") + } + + return png + } + } +} From 793b67f4652e1a39d03fab6650033768afe6d15e Mon Sep 17 00:00:00 2001 From: htcgh Date: Tue, 21 Oct 2025 15:55:15 -0700 Subject: [PATCH 39/54] Analytics 12.5.0 (#15435) --- FirebaseAnalytics.podspec | 2 +- GoogleAppMeasurement.podspec | 4 ++-- Package.swift | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/FirebaseAnalytics.podspec b/FirebaseAnalytics.podspec index ddf1b198d37..d31534ec33f 100644 --- a/FirebaseAnalytics.podspec +++ b/FirebaseAnalytics.podspec @@ -13,7 +13,7 @@ Pod::Spec.new do |s| s.authors = 'Google, Inc.' s.source = { - :http => 'https://dl.google.com/firebase/ios/analytics/8ca614257fd008b0/FirebaseAnalytics-12.4.0.tar.gz' + :http => 'https://dl.google.com/firebase/ios/analytics/1d0a9f91196548b3/FirebaseAnalytics-12.5.0.tar.gz' } s.cocoapods_version = '>= 1.12.0' diff --git a/GoogleAppMeasurement.podspec b/GoogleAppMeasurement.podspec index 0578e45b1b4..4f91cf1fb22 100644 --- a/GoogleAppMeasurement.podspec +++ b/GoogleAppMeasurement.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.authors = 'Google, Inc.' s.source = { - :http => 'https://dl.google.com/firebase/ios/analytics/2eb2929f64cc5fb8/GoogleAppMeasurement-12.4.0.tar.gz' + :http => 'https://dl.google.com/firebase/ios/analytics/4a8fa8d922b0b454/GoogleAppMeasurement-12.5.0.tar.gz' } s.cocoapods_version = '>= 1.12.0' @@ -39,7 +39,7 @@ Pod::Spec.new do |s| s.subspec 'Default' do |ss| ss.dependency 'GoogleAppMeasurement/Core', '12.5.0' ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.5.0' - ss.ios.dependency 'GoogleAdsOnDeviceConversion', '~> 3.1.0' + ss.ios.dependency 'GoogleAdsOnDeviceConversion', '~> 3.2.0' end s.subspec 'Core' do |ss| diff --git a/Package.swift b/Package.swift index b5e4b2b2881..0e91d0d06e0 100644 --- a/Package.swift +++ b/Package.swift @@ -348,8 +348,8 @@ let package = Package( ), .binaryTarget( name: "FirebaseAnalytics", - url: "https://dl.google.com/firebase/ios/swiftpm/12.4.0/FirebaseAnalytics.zip", - checksum: "625b4853a02b312eeb857cb6578b109d42459c65021115f864414141ff32a117" + url: "https://dl.google.com/firebase/ios/swiftpm/12.5.0/FirebaseAnalytics.zip", + checksum: "7ff922682f5d47e6add687979b3126f391c7d2e8f367599d4ec8d2a58dce8cc9" ), .testTarget( name: "AnalyticsSwiftUnit", @@ -1411,7 +1411,7 @@ func googleAppMeasurementDependency() -> Package.Dependency { return .package(url: appMeasurementURL, branch: "main") } - return .package(url: appMeasurementURL, exact: "12.4.0") + return .package(url: appMeasurementURL, exact: "12.5.0") } func abseilDependency() -> Package.Dependency { From ff0eac79040c2517ce0016d5959301a41604283c Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 22 Oct 2025 10:34:36 -0400 Subject: [PATCH 40/54] [Release] Update `Unreleased` changelog entries (#15436) --- FirebaseAI/CHANGELOG.md | 7 +++---- FirebasePerformance/CHANGELOG.md | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index cda91aa2926..67c5aed67b4 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,7 +1,6 @@ -# Unreleased -- [fixed] Fixed various links in the Live API doc comments not mapping correctly. -- [fixed] Fixed minor translation issue for nanosecond conversion when receiving - `LiveServerGoingAwayNotice`. (#15410) +# 12.5.0 +- [fixed] Fixed a nanoseconds parsing issue in the Live API when receiving a + `LiveServerGoingAwayNotice` message. (#15410) - [feature] Added support for sending video frames with the Live API via the `sendVideoRealtime` method on [`LiveSession`](https://firebase.google.com/docs/reference/swift/firebaseai/api/reference/Classes/LiveSession). (#15432) diff --git a/FirebasePerformance/CHANGELOG.md b/FirebasePerformance/CHANGELOG.md index a63bfa357fe..01f671e5db2 100644 --- a/FirebasePerformance/CHANGELOG.md +++ b/FirebasePerformance/CHANGELOG.md @@ -1,4 +1,4 @@ -# Unreleased +# 12.5.0 - [fixed] Prevent race condition crash in FPRTraceBackgroundActivityTracker. (#14273) - [fixed] Fix app start trace outliers from network delays. (#10733) From d5b6dec4ce58d6ec948c47c3f1656a6434ec9db6 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 22 Oct 2025 13:07:39 -0400 Subject: [PATCH 41/54] [Firebase AI] Fix Google AI `useLimitedUseAppCheckTokens` config (#15423) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../Tests/Integration/LiveSessionTests.swift | 42 +++++++++---------- .../Tests/Utilities/InstanceConfig.swift | 3 +- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift index ba932266fe0..6d77f87c0fe 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift @@ -61,20 +61,25 @@ struct LiveSessionTests { static let yesOrNo = ModelContent( role: "system", parts: """ - You can only respond with "yes" or "no". + You can only respond with "yes" or "no". """.trimmingCharacters(in: .whitespacesAndNewlines) ) static let helloGoodbye = ModelContent( role: "system", parts: """ - When you hear "Hello" say "Goodbye". If you hear anything else, say "The audio file is broken". + When you hear "Hello" say "Goodbye". If you hear anything else, say "The audio file is \ + broken". """.trimmingCharacters(in: .whitespacesAndNewlines) ) static let lastNames = ModelContent( role: "system", - parts: "When you receive a message, if the message is a single word, assume it's the first name of a person, and call the getLastName tool to get the last name of said person. Only respond with the last name." + parts: """ + When you receive a message, if the message is a single word, assume it's the first name of a \ + person, and call the getLastName tool to get the last name of said person. Only respond with \ + the last name. + """.trimmingCharacters(in: .whitespacesAndNewlines) ) static let animalInVideo = ModelContent( @@ -142,10 +147,9 @@ struct LiveSessionTests { let session = try await model.connect() - guard let audioFile = NSDataAsset(name: "hello") else { - Issue.record("Missing audio file 'hello.wav' in Assets") - return - } + let audioFile = try #require( + NSDataAsset(name: "hello"), "Missing audio file 'hello.wav' in Assets" + ) await session.sendAudioRealtime(audioFile.data) // The model can't infer that we're done speaking until we send null bytes await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) @@ -171,10 +175,9 @@ struct LiveSessionTests { let session = try await model.connect() - guard let audioFile = NSDataAsset(name: "hello") else { - Issue.record("Missing audio file 'hello.wav' in Assets") - return - } + let audioFile = try #require( + NSDataAsset(name: "hello"), "Missing audio file 'hello.wav' in Assets" + ) await session.sendAudioRealtime(audioFile.data) await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) @@ -281,7 +284,7 @@ struct LiveSessionTests { } @Test(arguments: arguments.filter { - // TODO: (b/450982184) Remove when vertex adds support + // TODO: (b/450982184) Remove when Vertex AI adds support for Function IDs and Cancellation switch $0.0.apiConfig.service { case .googleAI: true @@ -291,12 +294,6 @@ struct LiveSessionTests { }) func realtime_functionCalling_cancellation(_ config: InstanceConfig, modelName: String) async throws { - // TODO: (b/450982184) Remove when vertex adds support - guard case .googleAI = config.apiConfig.service else { - Issue.record("Vertex does not currently support function ids or function cancellation.") - return - } - let model = FirebaseAI.componentInstance(config).liveModel( modelName: modelName, generationConfig: textConfig, @@ -337,17 +334,16 @@ struct LiveSessionTests { generationConfig: audioConfig ) - guard let audioFile = NSDataAsset(name: "hello") else { - Issue.record("Missing audio file 'hello.wav' in Assets") - return - } + let audioFile = try #require( + NSDataAsset(name: "hello"), "Missing audio file 'hello.wav' in Assets" + ) try await retry(times: 3, delayInSeconds: 2.0) { let session = try await model.connect() await session.sendAudioRealtime(audioFile.data) await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) - // wait a second to allow the model to start generating (and cuase a proper interruption) + // Wait a second to allow the model to start generating (and cause a proper interruption) try await Task.sleep(nanoseconds: oneSecondInNanoseconds) await session.sendAudioRealtime(audioFile.data) await session.sendAudioRealtime(Data(repeating: 0, count: audioFile.data.count)) diff --git a/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift b/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift index 12b8f4da70b..1c515957a36 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Utilities/InstanceConfig.swift @@ -56,6 +56,7 @@ struct InstanceConfig: Equatable, Encodable { apiConfig: APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta) ) static let googleAI_v1beta_appCheckLimitedUse = InstanceConfig( + useLimitedUseAppCheckTokens: true, apiConfig: APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta) ) static let googleAI_v1beta_staging = InstanceConfig( @@ -164,7 +165,7 @@ extension InstanceConfig: CustomTestStringConvertible { } let locationSuffix: String if case let .vertexAI(_, location: location) = apiConfig.service { - locationSuffix = location + locationSuffix = " - (\(location))" } else { locationSuffix = "" } From f9754ba5fbefea5c64ebd57ff79b8c4065938d10 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 23 Oct 2025 16:08:33 -0400 Subject: [PATCH 42/54] [Firebase AI] Add internal JSON Schema support in `GenerationConfig` (#15404) --- FirebaseAI/Sources/GenerationConfig.swift | 26 ++ .../Tests/Integration/SchemaTests.swift | 427 +++++++++++++----- .../Tests/Unit/GenerationConfigTests.swift | 83 +++- 3 files changed, 419 insertions(+), 117 deletions(-) diff --git a/FirebaseAI/Sources/GenerationConfig.swift b/FirebaseAI/Sources/GenerationConfig.swift index 27c4310f12d..fe2b6963e22 100644 --- a/FirebaseAI/Sources/GenerationConfig.swift +++ b/FirebaseAI/Sources/GenerationConfig.swift @@ -48,6 +48,11 @@ public struct GenerationConfig: Sendable { /// Output schema of the generated candidate text. let responseSchema: Schema? + /// Output schema of the generated response in [JSON Schema](https://json-schema.org/) format. + /// + /// If set, `responseSchema` must be omitted and `responseMIMEType` is required. + let responseJSONSchema: JSONObject? + /// Supported modalities of the response. let responseModalities: [ResponseModality]? @@ -175,6 +180,26 @@ public struct GenerationConfig: Sendable { self.stopSequences = stopSequences self.responseMIMEType = responseMIMEType self.responseSchema = responseSchema + responseJSONSchema = nil + self.responseModalities = responseModalities + self.thinkingConfig = thinkingConfig + } + + init(temperature: Float? = nil, topP: Float? = nil, topK: Int? = nil, candidateCount: Int? = nil, + maxOutputTokens: Int? = nil, presencePenalty: Float? = nil, frequencyPenalty: Float? = nil, + stopSequences: [String]? = nil, responseMIMEType: String, responseJSONSchema: JSONObject, + responseModalities: [ResponseModality]? = nil, thinkingConfig: ThinkingConfig? = nil) { + self.temperature = temperature + self.topP = topP + self.topK = topK + self.candidateCount = candidateCount + self.maxOutputTokens = maxOutputTokens + self.presencePenalty = presencePenalty + self.frequencyPenalty = frequencyPenalty + self.stopSequences = stopSequences + self.responseMIMEType = responseMIMEType + responseSchema = nil + self.responseJSONSchema = responseJSONSchema self.responseModalities = responseModalities self.thinkingConfig = thinkingConfig } @@ -195,6 +220,7 @@ extension GenerationConfig: Encodable { case stopSequences case responseMIMEType = "responseMimeType" case responseSchema + case responseJSONSchema = "responseJsonSchema" case responseModalities case thinkingConfig } diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift index 4f4dd1e3dc8..a9e49818364 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift @@ -28,8 +28,6 @@ import Testing /// Test the schema fields. @Suite(.serialized) struct SchemaTests { - // 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 = [ SafetySetting(harmCategory: .harassment, threshold: .blockLowAndAbove), SafetySetting(harmCategory: .hateSpeech, threshold: .blockLowAndAbove), @@ -37,31 +35,32 @@ struct SchemaTests { SafetySetting(harmCategory: .dangerousContent, threshold: .blockLowAndAbove), SafetySetting(harmCategory: .civicIntegrity, threshold: .blockLowAndAbove), ] - // Candidates and total token counts may differ slightly between runs due to whitespace tokens. - let tokenCountAccuracy = 1 - let storage: Storage - let userID1: String - - init() async throws { - userID1 = try await TestHelpers.getUserID() - storage = Storage.storage() - } - - @Test(arguments: InstanceConfig.allConfigs) - func generateContentSchemaItems(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2FlashLite, - generationConfig: GenerationConfig( - responseMIMEType: "application/json", - responseSchema: - .array( - items: .string(description: "The name of the city"), - description: "A list of city names", - minItems: 3, - maxItems: 5 - ) + @Test( + arguments: testConfigs( + instanceConfigs: InstanceConfig.allConfigs, + openAPISchema: .array( + items: .string(description: "The name of the city"), + description: "A list of city names", + minItems: 3, + maxItems: 5 ), + jsonSchema: [ + "type": .string("array"), + "description": .string("A list of city names"), + "items": .object([ + "type": .string("string"), + "description": .string("The name of the city"), + ]), + "minItems": .number(3), + "maxItems": .number(5), + ] + ) + ) + func generateContentItemsSchema(_ config: InstanceConfig, _ schema: SchemaType) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: SchemaTests.generationConfig(schema: schema), safetySettings: safetySettings ) let prompt = "What are the biggest cities in Canada?" @@ -73,18 +72,25 @@ struct SchemaTests { #expect(decodedJSON.count <= 5, "Expected at most 5 cities, but got \(decodedJSON.count)") } - @Test(arguments: InstanceConfig.allConfigs) - func generateContentSchemaNumberRange(_ config: InstanceConfig) async throws { + @Test(arguments: testConfigs( + instanceConfigs: InstanceConfig.allConfigs, + openAPISchema: .integer( + description: "A number", + minimum: 110, + maximum: 120 + ), + jsonSchema: [ + "type": .string("integer"), + "description": .string("A number"), + "minimum": .number(110), + "maximum": .number(120), + ] + )) + func generateContentSchemaNumberRange(_ config: InstanceConfig, + _ schema: SchemaType) async throws { let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2FlashLite, - generationConfig: GenerationConfig( - responseMIMEType: "application/json", - responseSchema: .integer( - description: "A number", - minimum: 110, - maximum: 120 - ) - ), + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: SchemaTests.generationConfig(schema: schema), safetySettings: safetySettings ) let prompt = "Give me a number" @@ -96,41 +102,83 @@ struct SchemaTests { #expect(decodedNumber <= 120.0, "Expected a number <= 120, but got \(decodedNumber)") } - @Test(arguments: InstanceConfig.allConfigs) - func generateContentSchemaNumberRangeMultiType(_ config: InstanceConfig) async throws { + @Test(arguments: testConfigs( + instanceConfigs: InstanceConfig.allConfigs, + openAPISchema: .object( + properties: [ + "productName": .string(description: "The name of the product"), + "price": .double( + description: "A price", + minimum: 10.00, + maximum: 120.00 + ), + "salePrice": .float( + description: "A sale price", + minimum: 5.00, + maximum: 90.00 + ), + "rating": .integer( + description: "A rating", + minimum: 1, + maximum: 5 + ), + ], + propertyOrdering: ["salePrice", "rating", "price", "productName"], + title: "ProductInfo" + ), + jsonSchema: [ + "type": .string("object"), + "title": .string("ProductInfo"), + "properties": .object([ + "productName": .object([ + "type": .string("string"), + "description": .string("The name of the product"), + ]), + "price": .object([ + "type": .string("number"), + "description": .string("A price"), + "minimum": .number(10.00), + "maximum": .number(120.00), + ]), + "salePrice": .object([ + "type": .string("number"), + "description": .string("A sale price"), + "minimum": .number(5.00), + "maximum": .number(90.00), + ]), + "rating": .object([ + "type": .string("integer"), + "description": .string("A rating"), + "minimum": .number(1), + "maximum": .number(5), + ]), + ]), + "required": .array([ + .string("productName"), + .string("price"), + .string("salePrice"), + .string("rating"), + ]), + "propertyOrdering": .array([ + .string("salePrice"), + .string("rating"), + .string("price"), + .string("productName"), + ]), + ] + )) + func generateContentSchemaNumberRangeMultiType(_ config: InstanceConfig, + _ schema: SchemaType) async throws { struct ProductInfo: Codable { let productName: String - let rating: Int // Will correspond to .integer in schema - let price: Double // Will correspond to .double in schema - let salePrice: Float // Will correspond to .float in schema + let rating: Int + let price: Double + let salePrice: Float } + let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2FlashLite, - generationConfig: GenerationConfig( - responseMIMEType: "application/json", - responseSchema: .object( - properties: [ - "productName": .string(description: "The name of the product"), - "price": .double( - description: "A price", - minimum: 10.00, - maximum: 120.00 - ), - "salePrice": .float( - description: "A sale price", - minimum: 5.00, - maximum: 90.00 - ), - "rating": .integer( - description: "A rating", - minimum: 1, - maximum: 5 - ), - ], - propertyOrdering: ["salePrice", "rating", "price", "productName"], - title: "ProductInfo" - ), - ), + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: SchemaTests.generationConfig(schema: schema), safetySettings: safetySettings ) let prompt = "Describe a premium wireless headphone, including a user rating and price." @@ -149,63 +197,127 @@ struct SchemaTests { #expect(rating <= 5, "Expected a rating <= 5, but got \(rating)") } - @Test(arguments: InstanceConfig.allConfigs) - func generateContentAnyOfSchema(_ config: InstanceConfig) async throws { - struct MailingAddress: Decodable { - let streetAddress: String - let city: String - - // Canadian-specific - let province: String? - let postalCode: String? - - // U.S.-specific - let state: String? - let zipCode: String? - - var isCanadian: Bool { - return province != nil && postalCode != nil && state == nil && zipCode == nil + fileprivate struct MailingAddress { + enum PostalInfo { + struct Canada: Decodable { + let province: String + let postalCode: String } - var isAmerican: Bool { - return province == nil && postalCode == nil && state != nil && zipCode != nil + struct UnitedStates: Decodable { + let state: String + let zipCode: String } + + case canada(province: String, postalCode: String) + case unitedStates(state: String, zipCode: String) } + let streetAddress: String + let city: String + let postalInfo: PostalInfo + } + + private static let generateContentAnyOfOpenAPISchema = { let streetSchema = Schema.string(description: "The civic number and street name, for example, '123 Main Street'.") let citySchema = Schema.string(description: "The name of the city.") - let canadianAddressSchema = Schema.object( + let canadaPostalInfoSchema = Schema.object( properties: [ - "streetAddress": streetSchema, - "city": citySchema, "province": .string(description: "The 2-letter province or territory code, for example, 'ON', 'QC', or 'NU'."), "postalCode": .string(description: "The postal code, for example, 'A1A 1A1'."), - ], - description: "A Canadian mailing address" + ] ) - let americanAddressSchema = Schema.object( + let unitedStatesPostalInfoSchema = Schema.object( properties: [ - "streetAddress": streetSchema, - "city": citySchema, "state": .string(description: "The 2-letter U.S. state or territory code, for example, 'CA', 'NY', or 'TX'."), "zipCode": .string(description: "The 5-digit ZIP code, for example, '12345'."), - ], - description: "A U.S. mailing address" + ] ) + let mailingAddressSchema = Schema.object(properties: [ + "streetAddress": streetSchema, + "city": citySchema, + "postalInfo": .anyOf(schemas: [canadaPostalInfoSchema, unitedStatesPostalInfoSchema]), + ]) + return Schema.array(items: mailingAddressSchema) + }() + + private static let generateContentAnyOfJSONSchema = { + let streetSchema: JSONValue = .object([ + "type": .string("string"), + "description": .string("The civic number and street name, for example, '123 Main Street'."), + ]) + let citySchema: JSONValue = .object([ + "type": .string("string"), + "description": .string("The name of the city."), + ]) + let postalInfoSchema: JSONValue = .object([ + "anyOf": .array([ + .object([ + "type": .string("object"), + "properties": .object([ + "province": .object([ + "type": .string("string"), + "description": .string( + "The 2-letter Canadian province or territory code, for example, 'ON', 'QC', or 'NU'." + ), + ]), + "postalCode": .object([ + "type": .string("string"), + "description": .string("The Canadian postal code, for example, 'A1A 1A1'."), + ]), + ]), + "required": .array([.string("province"), .string("postalCode")]), + ]), + .object([ + "type": .string("object"), + "properties": .object([ + "state": .object([ + "type": .string("string"), + "description": .string( + "The 2-letter U.S. state or territory code, for example, 'CA', 'NY', or 'TX'." + ), + ]), + "zipCode": .object([ + "type": .string("string"), + "description": .string("The 5-digit U.S. ZIP code, for example, '12345'."), + ]), + ]), + "required": .array([.string("state"), .string("zipCode")]), + ]), + ]), + ]) + let mailingAddressSchema: JSONObject = [ + "type": .string("object"), + "description": .string("A mailing address"), + "properties": .object([ + "streetAddress": streetSchema, + "city": citySchema, + "postalInfo": postalInfoSchema, + ]), + "required": .array([ + .string("streetAddress"), + .string("city"), + .string("postalInfo"), + ]), + ] + return [ + "type": .string("array"), + "items": .object(mailingAddressSchema), + ] as JSONObject + }() + + @Test(arguments: testConfigs( + instanceConfigs: InstanceConfig.allConfigs, + openAPISchema: generateContentAnyOfOpenAPISchema, + jsonSchema: generateContentAnyOfJSONSchema + )) + func generateContentAnyOfSchema(_ config: InstanceConfig, _ schema: SchemaType) async throws { let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2Flash, - generationConfig: GenerationConfig( - temperature: 0.0, - topP: 0.0, - topK: 1, - responseMIMEType: "application/json", - responseSchema: .array(items: .anyOf( - schemas: [canadianAddressSchema, americanAddressSchema] - )) - ), + modelName: ModelNames.gemini2_5_Flash, + generationConfig: SchemaTests.generationConfig(schema: schema), safetySettings: safetySettings ) let prompt = """ @@ -217,19 +329,102 @@ struct SchemaTests { let decodedAddresses = try JSONDecoder().decode([MailingAddress].self, from: jsonData) try #require(decodedAddresses.count == 3, "Expected 3 JSON addresses, got \(text).") let waterlooAddress = decodedAddresses[0] - #expect( - waterlooAddress.isCanadian, - "Expected Canadian University of Waterloo address, got \(waterlooAddress)." - ) + #expect(waterlooAddress.city == "Waterloo") + if case let .canada(province, postalCode) = waterlooAddress.postalInfo { + #expect(province == "ON") + #expect(postalCode == "N2L 3G1") + } else { + Issue.record("Expected Canadian University of Waterloo address, got \(waterlooAddress).") + } let berkeleyAddress = decodedAddresses[1] - #expect( - berkeleyAddress.isAmerican, - "Expected American UC Berkeley address, got \(berkeleyAddress)." - ) + #expect(berkeleyAddress.city == "Berkeley") + if case let .unitedStates(state, zipCode) = berkeleyAddress.postalInfo { + #expect(state == "CA") + #expect(zipCode == "94720") + } else { + Issue.record("Expected American UC Berkeley address, got \(berkeleyAddress).") + } let queensAddress = decodedAddresses[2] - #expect( - queensAddress.isCanadian, - "Expected Canadian Queen's University address, got \(queensAddress)." + #expect(queensAddress.city == "Kingston") + if case let .canada(province, postalCode) = queensAddress.postalInfo { + #expect(province == "ON") + #expect(postalCode == "K7L 3N6") + } else { + Issue.record("Expected Canadian Queen's University address, got \(queensAddress).") + } + } + + enum SchemaType: CustomTestStringConvertible { + case openAPI(Schema) + case json(JSONObject) + + var testDescription: String { + switch self { + case .openAPI: + return "OpenAPI Schema" + case .json: + return "JSON Schema" + } + } + } + + private static func generationConfig(schema: SchemaType) -> GenerationConfig { + let mimeType = "application/json" + switch schema { + case let .openAPI(openAPISchema): + return GenerationConfig(temperature: 0.0, topP: 0.0, topK: 1, responseMIMEType: mimeType, + responseSchema: openAPISchema) + case let .json(jsonSchema): + return GenerationConfig(temperature: 0.0, topP: 0.0, topK: 1, responseMIMEType: mimeType, + responseJSONSchema: jsonSchema) + } + } + + private static func testConfigs(instanceConfigs: [InstanceConfig], openAPISchema: Schema, + jsonSchema: JSONObject) -> [(InstanceConfig, SchemaType)] { + return instanceConfigs.flatMap { [($0, .openAPI(openAPISchema)), ($0, .json(jsonSchema))] } + } +} + +extension SchemaTests.MailingAddress: Decodable { + enum CodingKeys: CodingKey { + case streetAddress + case city + case postalInfo + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + streetAddress = try container.decode(String.self, forKey: .streetAddress) + city = try container.decode(String.self, forKey: .city) + let canadaPostalInfo = try? container.decode(PostalInfo.Canada.self, forKey: .postalInfo) + let unitedStatesPostalInfo = try? container.decode( + PostalInfo.UnitedStates.self, forKey: .postalInfo ) + + if canadaPostalInfo != nil, unitedStatesPostalInfo != nil { + throw DecodingError.dataCorruptedError( + forKey: .postalInfo, + in: container, + debugDescription: "Ambiguous postal info: matches both Canadian and U.S. formats." + ) + } + + if let canadaPostalInfo { + postalInfo = .canada( + province: canadaPostalInfo.province, postalCode: canadaPostalInfo.postalCode + ) + } else if let unitedStatesPostalInfo { + postalInfo = .unitedStates( + state: unitedStatesPostalInfo.state, zipCode: unitedStatesPostalInfo.zipCode + ) + } else { + throw DecodingError.typeMismatch( + PostalInfo.self, .init( + codingPath: container.codingPath, + debugDescription: "Expected Canadian or U.S. postal info." + ) + ) + } } } diff --git a/FirebaseAI/Tests/Unit/GenerationConfigTests.swift b/FirebaseAI/Tests/Unit/GenerationConfigTests.swift index 2b38d1898d4..edbde87fc7d 100644 --- a/FirebaseAI/Tests/Unit/GenerationConfigTests.swift +++ b/FirebaseAI/Tests/Unit/GenerationConfigTests.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAILogic +@testable import FirebaseAILogic import Foundation import XCTest @@ -153,4 +153,85 @@ final class GenerationConfigTests: XCTestCase { } """) } + + func testEncodeGenerationConfig_responseJSONSchema() throws { + let mimeType = "application/json" + let responseJSONSchema: JSONObject = [ + "type": .string("object"), + "title": .string("Person"), + "properties": .object([ + "firstName": .object(["type": .string("string")]), + "middleNames": .object([ + "type": .string("array"), + "items": .object(["type": .string("string")]), + "minItems": .number(0), + "maxItems": .number(3), + ]), + "lastName": .object(["type": .string("string")]), + "age": .object(["type": .string("integer")]), + ]), + "required": .array([ + .string("firstName"), + .string("middleNames"), + .string("lastName"), + .string("age"), + ]), + "propertyOrdering": .array([ + .string("firstName"), + .string("middleNames"), + .string("lastName"), + .string("age"), + ]), + "additionalProperties": .bool(false), + ] + let generationConfig = GenerationConfig( + responseMIMEType: mimeType, + responseJSONSchema: responseJSONSchema + ) + + let jsonData = try encoder.encode(generationConfig) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "responseJsonSchema" : { + "additionalProperties" : false, + "properties" : { + "age" : { + "type" : "integer" + }, + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "middleNames" : { + "items" : { + "type" : "string" + }, + "maxItems" : 3, + "minItems" : 0, + "type" : "array" + } + }, + "propertyOrdering" : [ + "firstName", + "middleNames", + "lastName", + "age" + ], + "required" : [ + "firstName", + "middleNames", + "lastName", + "age" + ], + "title" : "Person", + "type" : "object" + }, + "responseMimeType" : "\(mimeType)" + } + """) + } } From 25edb153432ca49e786157b00c436fe940ab32cf Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Mon, 27 Oct 2025 14:31:41 -0700 Subject: [PATCH 43/54] Fix typos (#15445) --- FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift | 2 +- FirebaseAI/Sources/Types/Public/Live/LiveSession.swift | 2 +- .../Tests/TestApp/Tests/Integration/LiveSessionTests.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift index b53ef3446f9..6acb92a779a 100644 --- a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift +++ b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift @@ -105,7 +105,7 @@ actor LiveSessionService { /// Start a new connection to the backend. /// - /// Seperated into its own function to make it easier to surface a way to call it seperately when + /// Separated into its own function to make it easier to surface a way to call it separately when /// resuming the same session. /// /// This function will yield until the websocket is ready to communicate with the client. diff --git a/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift b/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift index a4520e42d94..d0d0046d035 100644 --- a/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift +++ b/FirebaseAI/Sources/Types/Public/Live/LiveSession.swift @@ -72,7 +72,7 @@ public final class LiveSession: Sendable { /// Instead of raw video data, the model expects individual frames of the video, /// sent as images. /// - /// If your video has audio, send it seperately through ``LiveSession/sendAudioRealtime(_:)``. + /// If your video has audio, send it separately through ``LiveSession/sendAudioRealtime(_:)``. /// /// For better performance, frames can also be sent at a lower rate than the video; /// even as low as 1 frame per second. diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift index 6d77f87c0fe..fecf8e80e7b 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/LiveSessionTests.swift @@ -484,7 +484,7 @@ private extension LiveSession { /// } /// ``` /// - /// Is the equivelent to manually doing: + /// Is the equivalent to manually doing: /// ```swift /// for try await response in session.responses { /// if case let .content(content) = response.payload { From bcd68df70c6dcbc557ae8f34159fce2e3feddbaa Mon Sep 17 00:00:00 2001 From: Tatsuyuki Kobayashi Date: Tue, 28 Oct 2025 22:52:29 +0900 Subject: [PATCH 44/54] Fix typo in SecureTokenService comment (#15448) --- .../Sources/Swift/SystemService/SecureTokenService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift b/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift index 223f06e8014..2d4dcee59fc 100644 --- a/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift +++ b/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift @@ -38,7 +38,7 @@ actor SecureTokenServiceInternal { /// Makes a request to STS for an access token. /// - /// This handles both the case that the token has not been granted yet and that it just needs + /// This handles both the case that the token has not been granted yet and that it just /// needs to be refreshed. /// /// - Returns: Token and Bool indicating if update occurred. From 9f773d70d853462a388708bdf9d9f8b506aa75d8 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 28 Oct 2025 18:15:12 -0400 Subject: [PATCH 45/54] [Release] Update Carthage artifacts for 12.5.0 (#15452) --- ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json | 4 +++- ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json | 1 + .../CarthageJSON/FirebaseAppDistributionBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json | 1 + .../CarthageJSON/FirebaseMLModelDownloaderBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json | 1 + ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json | 1 + 17 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json index 0518f4ca48f..7e2b17a6dd3 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseABTesting-328b9123860fa215.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseABTesting-240f73a221798c3b.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseABTesting-453e3715e84856d3.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseABTesting-53d336f7762176f9.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/ABTesting-d0fdf10c43e985b1.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/ABTesting-d0fdf10c43e985b1.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/ABTesting-a71d17cadc209af9.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json index 0967ef424bc..ca0fbd563b2 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAILogicBinary.json @@ -1 +1,3 @@ -{} +{ + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseAILogic-543b2fedd88a76fd.zip" +} diff --git a/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json index db8beb34b3a..c7878fc25bb 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAnalytics-b37787f72cdbb950.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAnalytics-866ebeb7925d0267.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAnalytics-61b0d6c9596bf37a.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseAnalytics-d3705dd81b8b5477.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Analytics-2468c231ebeb7922.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Analytics-bc8101d420b896c5.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Analytics-d2b6a6b0242db786.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json index d5aeab83eb4..f11b39dda48 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAppCheck-dac2380c7e1b9898.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAppCheck-f0fb8c2a38b272c7.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAppCheck-f45ebf0c8d5ae0aa.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseAppCheck-15439cab8a605863.zip", "8.0.0": "https://dl.google.com/dl/firebase/ios/carthage/8.0.0/FirebaseAppCheck-9ef1d217cf057203.zip", "8.1.0": "https://dl.google.com/dl/firebase/ios/carthage/8.1.0/FirebaseAppCheck-fc03215d9fe45d3a.zip", "8.10.0": "https://dl.google.com/dl/firebase/ios/carthage/8.10.0/FirebaseAppCheck-6ebe9e9539f06003.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json index ddc93b6342c..fe5ad8cc63f 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAppDistribution-042b04483c9241b6.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAppDistribution-8498aaebd9f9e633.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAppDistribution-aa1bb9ef501d82e7.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseAppDistribution-935f33cbb50ac9c0.zip", "6.31.0": "https://dl.google.com/dl/firebase/ios/carthage/6.31.0/FirebaseAppDistribution-07f6a2cf7f576a8a.zip", "6.32.0": "https://dl.google.com/dl/firebase/ios/carthage/6.32.0/FirebaseAppDistribution-a9c4f5db794508ca.zip", "6.33.0": "https://dl.google.com/dl/firebase/ios/carthage/6.33.0/FirebaseAppDistribution-448a96d2ade54581.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json index 805c8c46dc4..3fad38b709c 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseAuth-9f0a14da6c12ea6d.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseAuth-e4ba94c15c57a75f.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseAuth-9d35d5c62a3e1a75.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseAuth-873cd7b4bce5c5a6.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Auth-0fa76ba0f7956220.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Auth-5ddd2b4351012c7a.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Auth-5e248984d78d7284.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json index 33d5b801e1e..e918fc87460 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseCrashlytics-623ce628d0404f39.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseCrashlytics-6054b7e88b91a91d.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseCrashlytics-49a8a1b1f30115df.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseCrashlytics-0d4111d63d6eefd0.zip", "6.15.0": "https://dl.google.com/dl/firebase/ios/carthage/6.15.0/FirebaseCrashlytics-1c6d22d5b73c84fd.zip", "6.16.0": "https://dl.google.com/dl/firebase/ios/carthage/6.16.0/FirebaseCrashlytics-938e5fd0e2eab3b3.zip", "6.17.0": "https://dl.google.com/dl/firebase/ios/carthage/6.17.0/FirebaseCrashlytics-fa09f0c8f31ed5d9.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json b/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json index fb91f3f7fc1..cf1d7d7ce4d 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseDatabase-a87ae96a7eeb2535.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseDatabase-2b6c597465ec9d34.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseDatabase-34750cd661cbd49b.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseDatabase-12c5aa3455796be4.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Database-1f7a820452722c7d.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Database-1f7a820452722c7d.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Database-59a12d87456b3e1c.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json b/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json index 918da9e2292..7ed793df826 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseFirestore-8d65b82dc9d53ddf.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseFirestore-e8ec00ce472204d2.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseFirestore-acab074433fa0c6f.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseFirestore-ff38f0cab6f32140.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Firestore-68fc02c229d0cc69.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Firestore-87a804ab561d91db.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Firestore-ecb3eea7bde7e8e8.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json index 7ea7586a341..35c9605edd4 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseFunctions-f3aa95160827b0af.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseFunctions-6d891e5b755e773c.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseFunctions-8589fb2f6bff1e38.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseFunctions-9a267b1256451803.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Functions-f4c426016dd41e38.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Functions-c6c44427c3034736.zip", "5.0.0": "https://dl.google.com/dl/firebase/ios/carthage/5.0.0/Functions-146f34c401bd459b.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json b/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json index f01c21f0379..3b1a835b62d 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/GoogleSignIn-31b2e32d1dadbaa8.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/GoogleSignIn-0a9fd70d77dbb99e.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/GoogleSignIn-bcaadfe04c892ecb.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/GoogleSignIn-ad70a9d759c22eb6.zip", "6.0.0": "https://dl.google.com/dl/firebase/ios/carthage/6.0.0/GoogleSignIn-de9c5d5e8eb6d6ea.zip", "6.1.0": "https://dl.google.com/dl/firebase/ios/carthage/6.1.0/GoogleSignIn-8c82f2870573a793.zip", "6.10.0": "https://dl.google.com/dl/firebase/ios/carthage/6.10.0/GoogleSignIn-ff3aef61c4a55b05.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json index a573475105f..a0f968ba039 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseInAppMessaging-0ec7907b67ce2888.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseInAppMessaging-349edad4650cdc0e.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseInAppMessaging-9447455910a3800d.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseInAppMessaging-464b7174be10e192.zip", "5.10.0": "https://dl.google.com/dl/firebase/ios/carthage/5.10.0/InAppMessaging-a7a3f933362f6e95.zip", "5.11.0": "https://dl.google.com/dl/firebase/ios/carthage/5.11.0/InAppMessaging-fa28ce1b88fbca93.zip", "5.12.0": "https://dl.google.com/dl/firebase/ios/carthage/5.12.0/InAppMessaging-fa28ce1b88fbca93.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json b/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json index 422cbc6a68a..ca652f3f343 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseMLModelDownloader-6bfb3459ae557ef3.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseMLModelDownloader-1d7e6bff24c9b2ec.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseMLModelDownloader-2ce9f1e78f15027f.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseMLModelDownloader-99a4ce736c201f72.zip", "8.0.0": "https://dl.google.com/dl/firebase/ios/carthage/8.0.0/FirebaseMLModelDownloader-8f972757fb181320.zip", "8.1.0": "https://dl.google.com/dl/firebase/ios/carthage/8.1.0/FirebaseMLModelDownloader-058ad59fa6dc0111.zip", "8.10.0": "https://dl.google.com/dl/firebase/ios/carthage/8.10.0/FirebaseMLModelDownloader-286479a966d2fb37.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json index cd36358588e..d6cf70340c1 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseMessaging-d1ab6eaf596d9b7d.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseMessaging-2a16804f5c5602a0.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseMessaging-9175a3fe41e7c83c.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseMessaging-2ffe78328b8babb2.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Messaging-a22ef2b5f2f30f82.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Messaging-94fa4e090c7e9185.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Messaging-2a00a1c64a19d176.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json b/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json index b5671d1577c..a38ea1ecd9e 100644 --- a/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebasePerformance-1913383f1952dce6.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebasePerformance-5e59e383ee5e57f7.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebasePerformance-79cbc1d26656ac6d.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebasePerformance-31908337721c4819.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Performance-d8693eb892bfa05b.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Performance-0a400f9460f7a71d.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Performance-f5b4002ab96523e4.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json b/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json index 479a4881311..d0278766f6d 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseRemoteConfig-3e803b148769baed.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseRemoteConfig-41aaab0dc398a6fc.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseRemoteConfig-5d12611a14be55c9.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseRemoteConfig-925f509a1b213a01.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/RemoteConfig-7e9635365ccd4a17.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/RemoteConfig-e7928fcb6311c439.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/RemoteConfig-9ab1ca5f360a1780.zip", diff --git a/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json b/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json index 35304914da9..b75c403583e 100644 --- a/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json +++ b/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json @@ -49,6 +49,7 @@ "12.2.0": "https://dl.google.com/dl/firebase/ios/carthage/12.2.0/FirebaseStorage-20489713b94790a0.zip", "12.3.0": "https://dl.google.com/dl/firebase/ios/carthage/12.3.0/FirebaseStorage-318fa79cc514a2be.zip", "12.4.0": "https://dl.google.com/dl/firebase/ios/carthage/12.4.0/FirebaseStorage-e758b10b671ddad7.zip", + "12.5.0": "https://dl.google.com/dl/firebase/ios/carthage/12.5.0/FirebaseStorage-c060333135f118a7.zip", "4.11.0": "https://dl.google.com/dl/firebase/ios/carthage/4.11.0/Storage-6b3e77e1a7fdbc61.zip", "4.12.0": "https://dl.google.com/dl/firebase/ios/carthage/4.12.0/Storage-4721c35d2b90a569.zip", "4.9.0": "https://dl.google.com/dl/firebase/ios/carthage/4.9.0/Storage-821299369b9d0fb2.zip", From 00501279f73f8eefe5acad2940beb5c41d7dc022 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 29 Oct 2025 11:15:14 -0400 Subject: [PATCH 46/54] [Release] Update Carthage README for FirebaseAILogicBinary (#15453) --- Carthage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Carthage.md b/Carthage.md index 8bf4b21c65c..b0e35f07d52 100644 --- a/Carthage.md +++ b/Carthage.md @@ -31,7 +31,7 @@ Firebase components that you want to include in your app. Note that ``` binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseABTestingBinary.json" -binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAIBinary.json" +binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAILogicBinary.json" binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAdMobBinary.json" binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAnalyticsBinary.json" binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAppCheckBinary.json" From 1464eebcc46bec22a7ee0636c5a26edcf396bebc Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 29 Oct 2025 23:22:56 -0400 Subject: [PATCH 47/54] [Release] Update versions for 12.6.0 (#15458) --- Firebase.podspec | 44 +++++++++---------- FirebaseABTesting.podspec | 4 +- FirebaseAI.podspec | 6 +-- FirebaseAILogic.podspec | 10 ++--- FirebaseAnalytics.podspec | 12 ++--- FirebaseAppCheck.podspec | 6 +-- FirebaseAppCheckInterop.podspec | 2 +- FirebaseAppDistribution.podspec | 6 +-- FirebaseAuth.podspec | 10 ++--- FirebaseAuthInterop.podspec | 2 +- FirebaseCombineSwift.podspec | 14 +++--- FirebaseCore.podspec | 4 +- FirebaseCoreExtension.podspec | 4 +- FirebaseCoreInternal.podspec | 2 +- FirebaseCrashlytics.podspec | 10 ++--- FirebaseDatabase.podspec | 10 ++--- FirebaseFirestore.podspec | 10 ++--- FirebaseFirestoreInternal.podspec | 6 +-- FirebaseFunctions.podspec | 14 +++--- FirebaseInAppMessaging.podspec | 8 ++-- FirebaseInstallations.podspec | 4 +- FirebaseMLModelDownloader.podspec | 8 ++-- FirebaseMessaging.podspec | 6 +-- FirebaseMessagingInterop.podspec | 2 +- FirebasePerformance.podspec | 10 ++--- FirebaseRemoteConfig.podspec | 12 ++--- FirebaseRemoteConfigInterop.podspec | 2 +- FirebaseSessions.podspec | 8 ++-- FirebaseSharedSwift.podspec | 2 +- FirebaseStorage.podspec | 14 +++--- GoogleAppMeasurement.podspec | 8 ++-- Package.swift | 2 +- .../FirebaseManifest/FirebaseManifest.swift | 2 +- 33 files changed, 132 insertions(+), 132 deletions(-) diff --git a/Firebase.podspec b/Firebase.podspec index 007a3e8292c..43494852371 100644 --- a/Firebase.podspec +++ b/Firebase.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Firebase' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase' s.description = <<-DESC @@ -36,14 +36,14 @@ Simplify your app development, grow your user base, and monetize more effectivel ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '15.0' - ss.ios.dependency 'FirebaseAnalytics', '~> 12.5.0' - ss.osx.dependency 'FirebaseAnalytics', '~> 12.5.0' - ss.tvos.dependency 'FirebaseAnalytics', '~> 12.5.0' + ss.ios.dependency 'FirebaseAnalytics', '~> 12.6.0' + ss.osx.dependency 'FirebaseAnalytics', '~> 12.6.0' + ss.tvos.dependency 'FirebaseAnalytics', '~> 12.6.0' ss.dependency 'Firebase/CoreOnly' end s.subspec 'CoreOnly' do |ss| - ss.dependency 'FirebaseCore', '~> 12.5.0' + ss.dependency 'FirebaseCore', '~> 12.6.0' ss.source_files = 'CoreOnly/Sources/Firebase.h' ss.preserve_paths = 'CoreOnly/Sources/module.modulemap' if ENV['FIREBASE_POD_REPO_FOR_DEV_POD'] then @@ -70,7 +70,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'ABTesting' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseABTesting', '~> 12.5.0' + ss.dependency 'FirebaseABTesting', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -80,13 +80,13 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'AppDistribution' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.ios.dependency 'FirebaseAppDistribution', '~> 12.5.0-beta' + ss.ios.dependency 'FirebaseAppDistribution', '~> 12.6.0-beta' ss.ios.deployment_target = '15.0' end s.subspec 'AppCheck' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseAppCheck', '~> 12.5.0' + ss.dependency 'FirebaseAppCheck', '~> 12.6.0' ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '15.0' @@ -95,7 +95,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Auth' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseAuth', '~> 12.5.0' + ss.dependency 'FirebaseAuth', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -105,7 +105,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Crashlytics' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseCrashlytics', '~> 12.5.0' + ss.dependency 'FirebaseCrashlytics', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -115,7 +115,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Database' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseDatabase', '~> 12.5.0' + ss.dependency 'FirebaseDatabase', '~> 12.6.0' # Standard platforms PLUS watchOS 7. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -125,7 +125,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Firestore' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseFirestore', '~> 12.5.0' + ss.dependency 'FirebaseFirestore', '~> 12.6.0' ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' ss.tvos.deployment_target = '15.0' @@ -133,7 +133,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Functions' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseFunctions', '~> 12.5.0' + ss.dependency 'FirebaseFunctions', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -143,20 +143,20 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'InAppMessaging' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.ios.dependency 'FirebaseInAppMessaging', '~> 12.5.0-beta' - ss.tvos.dependency 'FirebaseInAppMessaging', '~> 12.5.0-beta' + ss.ios.dependency 'FirebaseInAppMessaging', '~> 12.6.0-beta' + ss.tvos.dependency 'FirebaseInAppMessaging', '~> 12.6.0-beta' ss.ios.deployment_target = '15.0' ss.tvos.deployment_target = '15.0' end s.subspec 'Installations' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseInstallations', '~> 12.5.0' + ss.dependency 'FirebaseInstallations', '~> 12.6.0' end s.subspec 'Messaging' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseMessaging', '~> 12.5.0' + ss.dependency 'FirebaseMessaging', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -166,7 +166,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'MLModelDownloader' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseMLModelDownloader', '~> 12.5.0-beta' + ss.dependency 'FirebaseMLModelDownloader', '~> 12.6.0-beta' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -176,15 +176,15 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Performance' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.ios.dependency 'FirebasePerformance', '~> 12.5.0' - ss.tvos.dependency 'FirebasePerformance', '~> 12.5.0' + ss.ios.dependency 'FirebasePerformance', '~> 12.6.0' + ss.tvos.dependency 'FirebasePerformance', '~> 12.6.0' ss.ios.deployment_target = '15.0' ss.tvos.deployment_target = '15.0' end s.subspec 'RemoteConfig' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseRemoteConfig', '~> 12.5.0' + ss.dependency 'FirebaseRemoteConfig', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' @@ -194,7 +194,7 @@ Simplify your app development, grow your user base, and monetize more effectivel s.subspec 'Storage' do |ss| ss.dependency 'Firebase/CoreOnly' - ss.dependency 'FirebaseStorage', '~> 12.5.0' + ss.dependency 'FirebaseStorage', '~> 12.6.0' # Standard platforms PLUS watchOS. ss.ios.deployment_target = '15.0' ss.osx.deployment_target = '10.15' diff --git a/FirebaseABTesting.podspec b/FirebaseABTesting.podspec index 28fd2ac4e79..9da362ecada 100644 --- a/FirebaseABTesting.podspec +++ b/FirebaseABTesting.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseABTesting' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase ABTesting' s.description = <<-DESC @@ -51,7 +51,7 @@ Firebase Cloud Messaging and Firebase Remote Config in your app. s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' } - s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.test_spec 'unit' do |unit_tests| unit_tests.scheme = { :code_coverage => true } diff --git a/FirebaseAI.podspec b/FirebaseAI.podspec index 41559ed7ca7..ba0fb9f5453 100644 --- a/FirebaseAI.podspec +++ b/FirebaseAI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAI' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase AI SDK' s.description = <<-DESC @@ -43,8 +43,8 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI SDK s.tvos.framework = 'UIKit' s.watchos.framework = 'WatchKit' - s.dependency 'FirebaseAILogic', '12.5.0' - s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseAILogic', '12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.test_spec 'unit' do |unit_tests| unit_tests_dir = 'FirebaseAI/Wrapper/Tests/' diff --git a/FirebaseAILogic.podspec b/FirebaseAILogic.podspec index 74142cc82b2..b940e4c2ad3 100644 --- a/FirebaseAILogic.podspec +++ b/FirebaseAILogic.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAILogic' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase AI Logic SDK' s.description = <<-DESC @@ -43,10 +43,10 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI Log s.tvos.framework = 'UIKit' s.watchos.framework = 'WatchKit' - s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' - s.dependency 'FirebaseAuthInterop', '~> 12.5.0' - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseCoreExtension', '~> 12.5.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseAuthInterop', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' s.test_spec 'unit' do |unit_tests| unit_tests_dir = 'FirebaseAI/Tests/Unit/' diff --git a/FirebaseAnalytics.podspec b/FirebaseAnalytics.podspec index d31534ec33f..d646e498bbe 100644 --- a/FirebaseAnalytics.podspec +++ b/FirebaseAnalytics.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAnalytics' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase Analytics for iOS' s.description = <<-DESC @@ -26,8 +26,8 @@ Pod::Spec.new do |s| s.libraries = 'c++', 'sqlite3', 'z' s.frameworks = 'StoreKit' - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseInstallations', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/MethodSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/NSData+zlib', '~> 8.1' @@ -37,17 +37,17 @@ Pod::Spec.new do |s| s.default_subspecs = 'Default' s.subspec 'Default' do |ss| - ss.dependency 'GoogleAppMeasurement/Default', '12.5.0' + ss.dependency 'GoogleAppMeasurement/Default', '12.6.0' ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework' end s.subspec 'Core' do |ss| - ss.dependency 'GoogleAppMeasurement/Core', '12.5.0' + ss.dependency 'GoogleAppMeasurement/Core', '12.6.0' ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework' end s.subspec 'IdentitySupport' do |ss| - ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.5.0' + ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.6.0' ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework' end diff --git a/FirebaseAppCheck.podspec b/FirebaseAppCheck.podspec index e98c373f7cf..50bcbb75b2b 100644 --- a/FirebaseAppCheck.podspec +++ b/FirebaseAppCheck.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppCheck' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase App Check SDK.' s.description = <<-DESC @@ -45,8 +45,8 @@ Pod::Spec.new do |s| s.tvos.weak_framework = 'DeviceCheck' s.dependency 'AppCheckCore', '~> 11.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' - s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' diff --git a/FirebaseAppCheckInterop.podspec b/FirebaseAppCheckInterop.podspec index 64a4be29441..c36bc7bc447 100644 --- a/FirebaseAppCheckInterop.podspec +++ b/FirebaseAppCheckInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppCheckInterop' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Interfaces that allow other Firebase SDKs to use AppCheck functionality.' s.description = <<-DESC diff --git a/FirebaseAppDistribution.podspec b/FirebaseAppDistribution.podspec index aac4c303906..fb2ae4ab9f6 100644 --- a/FirebaseAppDistribution.podspec +++ b/FirebaseAppDistribution.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAppDistribution' - s.version = '12.5.0-beta' + s.version = '12.6.0-beta' s.summary = 'App Distribution for Firebase iOS SDK.' s.description = <<-DESC @@ -30,10 +30,10 @@ iOS SDK for App Distribution for Firebase. ] s.public_header_files = base_dir + 'Public/FirebaseAppDistribution/*.h' - s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' - s.dependency 'FirebaseInstallations', '~> 12.5.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' diff --git a/FirebaseAuth.podspec b/FirebaseAuth.podspec index 3c55d98785e..2de9ddbb308 100644 --- a/FirebaseAuth.podspec +++ b/FirebaseAuth.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAuth' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Apple platform client for Firebase Authentication' s.description = <<-DESC @@ -55,10 +55,10 @@ supports email and password accounts, as well as several 3rd party authenticatio } s.framework = 'Security' s.ios.framework = 'SafariServices' - s.dependency 'FirebaseAuthInterop', '~> 12.5.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseCoreExtension', '~> 12.5.0' + s.dependency 'FirebaseAuthInterop', '~> 12.6.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GTMSessionFetcher/Core', '>= 3.4', '< 6.0' diff --git a/FirebaseAuthInterop.podspec b/FirebaseAuthInterop.podspec index 73bfcff722a..775b8a750de 100644 --- a/FirebaseAuthInterop.podspec +++ b/FirebaseAuthInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseAuthInterop' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Interfaces that allow other Firebase SDKs to use Auth functionality.' s.description = <<-DESC diff --git a/FirebaseCombineSwift.podspec b/FirebaseCombineSwift.podspec index 427db3a7508..b790fc8abde 100644 --- a/FirebaseCombineSwift.podspec +++ b/FirebaseCombineSwift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCombineSwift' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Swift extensions with Combine support for Firebase' s.description = <<-DESC @@ -51,11 +51,11 @@ for internal testing only. It should not be published. s.osx.framework = 'AppKit' s.tvos.framework = 'UIKit' - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseAuth', '~> 12.5.0' - s.dependency 'FirebaseFunctions', '~> 12.5.0' - s.dependency 'FirebaseFirestore', '~> 12.5.0' - s.dependency 'FirebaseStorage', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseAuth', '~> 12.6.0' + s.dependency 'FirebaseFunctions', '~> 12.6.0' + s.dependency 'FirebaseFirestore', '~> 12.6.0' + s.dependency 'FirebaseStorage', '~> 12.6.0' s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"', @@ -104,6 +104,6 @@ for internal testing only. It should not be published. int_tests.resources = 'FirebaseStorage/Tests/Integration/Resources/1mb.dat', 'FirebaseStorage/Tests/Integration/Resources/GoogleService-Info.plist', 'FirebaseStorage/Tests/Integration/Resources/HomeImprovement.numbers' - int_tests.dependency 'FirebaseAuth', '~> 12.5.0' + int_tests.dependency 'FirebaseAuth', '~> 12.6.0' end end diff --git a/FirebaseCore.podspec b/FirebaseCore.podspec index edec150ad24..ad0a18e4574 100644 --- a/FirebaseCore.podspec +++ b/FirebaseCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCore' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase Core' s.description = <<-DESC @@ -53,7 +53,7 @@ Firebase Core includes FIRApp and FIROptions which provide central configuration # Remember to also update version in `cmake/external/GoogleUtilities.cmake` s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/Logger', '~> 8.1' - s.dependency 'FirebaseCoreInternal', '~> 12.5.0' + s.dependency 'FirebaseCoreInternal', '~> 12.6.0' s.pod_target_xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => 'Firebase_VERSION=' + s.version.to_s, diff --git a/FirebaseCoreExtension.podspec b/FirebaseCoreExtension.podspec index 39ba825bb67..574c7257989 100644 --- a/FirebaseCoreExtension.podspec +++ b/FirebaseCoreExtension.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCoreExtension' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Extended FirebaseCore APIs for Firebase product SDKs' s.description = <<-DESC @@ -34,5 +34,5 @@ Pod::Spec.new do |s| "#{s.module_name}_Privacy" => 'FirebaseCore/Extension/Resources/PrivacyInfo.xcprivacy' } - s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' end diff --git a/FirebaseCoreInternal.podspec b/FirebaseCoreInternal.podspec index 36eb7ed6cf1..f055c2c7a12 100644 --- a/FirebaseCoreInternal.podspec +++ b/FirebaseCoreInternal.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCoreInternal' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'APIs for internal FirebaseCore usage.' s.description = <<-DESC diff --git a/FirebaseCrashlytics.podspec b/FirebaseCrashlytics.podspec index deb29e67d12..a8ace6fd337 100644 --- a/FirebaseCrashlytics.podspec +++ b/FirebaseCrashlytics.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseCrashlytics' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Best and lightest-weight crash reporting for mobile, desktop and tvOS.' s.description = 'Firebase Crashlytics helps you track, prioritize, and fix stability issues that erode app quality.' s.homepage = 'https://firebase.google.com/' @@ -59,10 +59,10 @@ Pod::Spec.new do |s| cp -f ./Crashlytics/CrashlyticsInputFiles.xcfilelist ./CrashlyticsInputFiles.xcfilelist PREPARE_COMMAND_END - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseInstallations', '~> 12.5.0' - s.dependency 'FirebaseSessions', '~> 12.5.0' - s.dependency 'FirebaseRemoteConfigInterop', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' + s.dependency 'FirebaseSessions', '~> 12.6.0' + s.dependency 'FirebaseRemoteConfigInterop', '~> 12.6.0' s.dependency 'PromisesObjC', '~> 2.4' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' diff --git a/FirebaseDatabase.podspec b/FirebaseDatabase.podspec index ca55380ce64..f4cf9e40198 100644 --- a/FirebaseDatabase.podspec +++ b/FirebaseDatabase.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseDatabase' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase Realtime Database' s.description = <<-DESC @@ -48,9 +48,9 @@ Simplify your iOS development, grow your user base, and monetize more effectivel s.macos.frameworks = 'CFNetwork', 'Security', 'SystemConfiguration' s.watchos.frameworks = 'CFNetwork', 'Security', 'WatchKit' s.dependency 'leveldb-library', '~> 1.22' - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' - s.dependency 'FirebaseSharedSwift', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseSharedSwift', '~> 12.6.0' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' @@ -72,7 +72,7 @@ Simplify your iOS development, grow your user base, and monetize more effectivel 'SharedTestUtilities/FIRComponentTestUtilities.[mh]', 'SharedTestUtilities/FIROptionsMock.[mh]', ] - unit_tests.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' + unit_tests.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' unit_tests.dependency 'OCMock' unit_tests.resources = 'FirebaseDatabase/Tests/Resources/syncPointSpec.json', 'FirebaseDatabase/Tests/Resources/GoogleService-Info.plist' diff --git a/FirebaseFirestore.podspec b/FirebaseFirestore.podspec index c402aa5b31a..c9f3402d0a8 100644 --- a/FirebaseFirestore.podspec +++ b/FirebaseFirestore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFirestore' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Google Cloud Firestore' s.description = <<-DESC Google Cloud Firestore is a NoSQL document database built for automatic scaling, high performance, and ease of application development. @@ -35,9 +35,9 @@ Google Cloud Firestore is a NoSQL document database built for automatic scaling, "#{s.module_name}_Privacy" => 'Firestore/Swift/Source/Resources/PrivacyInfo.xcprivacy' } - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseCoreExtension', '~> 12.5.0' - s.dependency 'FirebaseFirestoreInternal', '~> 12.5.0' - s.dependency 'FirebaseSharedSwift', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' + s.dependency 'FirebaseFirestoreInternal', '~> 12.6.0' + s.dependency 'FirebaseSharedSwift', '~> 12.6.0' end diff --git a/FirebaseFirestoreInternal.podspec b/FirebaseFirestoreInternal.podspec index 906e9978fd5..c40b791e8ef 100644 --- a/FirebaseFirestoreInternal.podspec +++ b/FirebaseFirestoreInternal.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFirestoreInternal' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Google Cloud Firestore' s.description = <<-DESC @@ -91,8 +91,8 @@ Google Cloud Firestore is a NoSQL document database built for automatic scaling, "#{s.module_name}_Privacy" => 'Firestore/Source/Resources/PrivacyInfo.xcprivacy' } - s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' - s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' abseil_version = '~> 1.20240722.0' s.dependency 'abseil/algorithm', abseil_version diff --git a/FirebaseFunctions.podspec b/FirebaseFunctions.podspec index 4630aa43448..a6506a644ba 100644 --- a/FirebaseFunctions.podspec +++ b/FirebaseFunctions.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseFunctions' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Cloud Functions for Firebase' s.description = <<-DESC @@ -35,12 +35,12 @@ Cloud Functions for Firebase. 'FirebaseFunctions/Sources/**/*.swift', ] - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseCoreExtension', '~> 12.5.0' - s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' - s.dependency 'FirebaseAuthInterop', '~> 12.5.0' - s.dependency 'FirebaseMessagingInterop', '~> 12.5.0' - s.dependency 'FirebaseSharedSwift', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseAuthInterop', '~> 12.6.0' + s.dependency 'FirebaseMessagingInterop', '~> 12.6.0' + s.dependency 'FirebaseSharedSwift', '~> 12.6.0' s.dependency 'GTMSessionFetcher/Core', '>= 3.4', '< 6.0' s.test_spec 'objc' do |objc_tests| diff --git a/FirebaseInAppMessaging.podspec b/FirebaseInAppMessaging.podspec index 1d9971d7464..c67d833fcd1 100644 --- a/FirebaseInAppMessaging.podspec +++ b/FirebaseInAppMessaging.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseInAppMessaging' - s.version = '12.5.0-beta' + s.version = '12.6.0-beta' s.summary = 'Firebase In-App Messaging for iOS' s.description = <<-DESC @@ -80,9 +80,9 @@ See more product details at https://firebase.google.com/products/in-app-messagin s.framework = 'UIKit' - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseInstallations', '~> 12.5.0' - s.dependency 'FirebaseABTesting', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' + s.dependency 'FirebaseABTesting', '~> 12.6.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' s.dependency 'nanopb', '~> 3.30910.0' diff --git a/FirebaseInstallations.podspec b/FirebaseInstallations.podspec index 49afb1f97ea..c7fa4a5f625 100644 --- a/FirebaseInstallations.podspec +++ b/FirebaseInstallations.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseInstallations' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase Installations' s.description = <<-DESC @@ -45,7 +45,7 @@ Pod::Spec.new do |s| } s.framework = 'Security' - s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.dependency 'PromisesObjC', '~> 2.4' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' diff --git a/FirebaseMLModelDownloader.podspec b/FirebaseMLModelDownloader.podspec index bd568869006..272d4a9ce60 100644 --- a/FirebaseMLModelDownloader.podspec +++ b/FirebaseMLModelDownloader.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMLModelDownloader' - s.version = '12.5.0-beta' + s.version = '12.6.0-beta' s.summary = 'Firebase ML Model Downloader' s.description = <<-DESC @@ -36,9 +36,9 @@ Pod::Spec.new do |s| ] s.framework = 'Foundation' - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseCoreExtension', '~> 12.5.0' - s.dependency 'FirebaseInstallations', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' s.dependency 'SwiftProtobuf', '~> 1.19' diff --git a/FirebaseMessaging.podspec b/FirebaseMessaging.podspec index 605bd0a6f73..7afc529c58c 100644 --- a/FirebaseMessaging.podspec +++ b/FirebaseMessaging.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMessaging' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase Messaging' s.description = <<-DESC @@ -60,8 +60,8 @@ device, and it is completely free. s.tvos.framework = 'SystemConfiguration' s.osx.framework = 'SystemConfiguration' s.weak_framework = 'UserNotifications' - s.dependency 'FirebaseInstallations', '~> 12.5.0' - s.dependency 'FirebaseCore', '~> 12.5.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 8.1' s.dependency 'GoogleUtilities/Reachability', '~> 8.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' diff --git a/FirebaseMessagingInterop.podspec b/FirebaseMessagingInterop.podspec index 72784bff0cf..d2d1062a555 100644 --- a/FirebaseMessagingInterop.podspec +++ b/FirebaseMessagingInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseMessagingInterop' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Interfaces that allow other Firebase SDKs to use Messaging functionality.' s.description = <<-DESC diff --git a/FirebasePerformance.podspec b/FirebasePerformance.podspec index 7016aa49533..6d9c9316394 100644 --- a/FirebasePerformance.podspec +++ b/FirebasePerformance.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebasePerformance' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase Performance' s.description = <<-DESC @@ -58,10 +58,10 @@ Firebase Performance library to measure performance of Mobile and Web Apps. s.ios.framework = 'CoreTelephony' s.framework = 'QuartzCore' s.framework = 'SystemConfiguration' - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseInstallations', '~> 12.5.0' - s.dependency 'FirebaseRemoteConfig', '~> 12.5.0' - s.dependency 'FirebaseSessions', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' + s.dependency 'FirebaseRemoteConfig', '~> 12.6.0' + s.dependency 'FirebaseSessions', '~> 12.6.0' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/MethodSwizzler', '~> 8.1' diff --git a/FirebaseRemoteConfig.podspec b/FirebaseRemoteConfig.podspec index bb9f49f4383..81417a7a5e5 100644 --- a/FirebaseRemoteConfig.podspec +++ b/FirebaseRemoteConfig.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseRemoteConfig' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase Remote Config' s.description = <<-DESC @@ -49,13 +49,13 @@ app update. s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' } - s.dependency 'FirebaseABTesting', '~> 12.5.0' - s.dependency 'FirebaseSharedSwift', '~> 12.5.0' - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseInstallations', '~> 12.5.0' + s.dependency 'FirebaseABTesting', '~> 12.6.0' + s.dependency 'FirebaseSharedSwift', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/NSData+zlib', '~> 8.1' - s.dependency 'FirebaseRemoteConfigInterop', '~> 12.5.0' + s.dependency 'FirebaseRemoteConfigInterop', '~> 12.6.0' s.test_spec 'unit' do |unit_tests| unit_tests.scheme = { :code_coverage => true } diff --git a/FirebaseRemoteConfigInterop.podspec b/FirebaseRemoteConfigInterop.podspec index 8d5d38bf9f8..49f8c976264 100644 --- a/FirebaseRemoteConfigInterop.podspec +++ b/FirebaseRemoteConfigInterop.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseRemoteConfigInterop' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Interfaces that allow other Firebase SDKs to use Remote Config functionality.' s.description = <<-DESC diff --git a/FirebaseSessions.podspec b/FirebaseSessions.podspec index cd0a7fd02ad..0ad66ff18c7 100644 --- a/FirebaseSessions.podspec +++ b/FirebaseSessions.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseSessions' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase Sessions' s.description = <<-DESC @@ -39,9 +39,9 @@ Pod::Spec.new do |s| base_dir + 'SourcesObjC/**/*.{c,h,m,mm}', ] - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseCoreExtension', '~> 12.5.0' - s.dependency 'FirebaseInstallations', '~> 12.5.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' + s.dependency 'FirebaseInstallations', '~> 12.6.0' s.dependency 'GoogleDataTransport', '~> 10.1' s.dependency 'GoogleUtilities/Environment', '~> 8.1' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.1' diff --git a/FirebaseSharedSwift.podspec b/FirebaseSharedSwift.podspec index b3e043cf66f..feb413fd285 100644 --- a/FirebaseSharedSwift.podspec +++ b/FirebaseSharedSwift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseSharedSwift' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Shared Swift Extensions for Firebase' s.description = <<-DESC diff --git a/FirebaseStorage.podspec b/FirebaseStorage.podspec index 0b79f6e1b76..3aff87e24b3 100644 --- a/FirebaseStorage.podspec +++ b/FirebaseStorage.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'FirebaseStorage' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Firebase Storage' s.description = <<-DESC @@ -37,10 +37,10 @@ Firebase Storage provides robust, secure file uploads and downloads from Firebas 'FirebaseStorage/Typedefs/*.h', ] - s.dependency 'FirebaseAppCheckInterop', '~> 12.5.0' - s.dependency 'FirebaseAuthInterop', '~> 12.5.0' - s.dependency 'FirebaseCore', '~> 12.5.0' - s.dependency 'FirebaseCoreExtension', '~> 12.5.0' + s.dependency 'FirebaseAppCheckInterop', '~> 12.6.0' + s.dependency 'FirebaseAuthInterop', '~> 12.6.0' + s.dependency 'FirebaseCore', '~> 12.6.0' + s.dependency 'FirebaseCoreExtension', '~> 12.6.0' s.dependency 'GTMSessionFetcher/Core', '>= 3.4', '< 6.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' @@ -57,7 +57,7 @@ Firebase Storage provides robust, secure file uploads and downloads from Firebas objc_tests.requires_app_host = true objc_tests.resources = 'FirebaseStorage/Tests/Integration/Resources/1mb.dat', 'FirebaseStorage/Tests/Integration/Resources/GoogleService-Info.plist' - objc_tests.dependency 'FirebaseAuth', '~> 12.5.0' + objc_tests.dependency 'FirebaseAuth', '~> 12.6.0' objc_tests.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"' } @@ -86,6 +86,6 @@ Firebase Storage provides robust, secure file uploads and downloads from Firebas int_tests.resources = 'FirebaseStorage/Tests/Integration/Resources/1mb.dat', 'FirebaseStorage/Tests/Integration/Resources/GoogleService-Info.plist', 'FirebaseStorage/Tests/Integration/Resources/HomeImprovement.numbers' - int_tests.dependency 'FirebaseAuth', '~> 12.5.0' + int_tests.dependency 'FirebaseAuth', '~> 12.6.0' end end diff --git a/GoogleAppMeasurement.podspec b/GoogleAppMeasurement.podspec index 4f91cf1fb22..b354f4e8f7c 100644 --- a/GoogleAppMeasurement.podspec +++ b/GoogleAppMeasurement.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'GoogleAppMeasurement' - s.version = '12.5.0' + s.version = '12.6.0' s.summary = 'Shared measurement methods for Google libraries. Not intended for direct use.' s.description = <<-DESC @@ -37,8 +37,8 @@ Pod::Spec.new do |s| s.default_subspecs = 'Default' s.subspec 'Default' do |ss| - ss.dependency 'GoogleAppMeasurement/Core', '12.5.0' - ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.5.0' + ss.dependency 'GoogleAppMeasurement/Core', '12.6.0' + ss.dependency 'GoogleAppMeasurement/IdentitySupport', '12.6.0' ss.ios.dependency 'GoogleAdsOnDeviceConversion', '~> 3.2.0' end @@ -47,7 +47,7 @@ Pod::Spec.new do |s| end s.subspec 'IdentitySupport' do |ss| - ss.dependency 'GoogleAppMeasurement/Core', '12.5.0' + ss.dependency 'GoogleAppMeasurement/Core', '12.6.0' ss.vendored_frameworks = 'Frameworks/GoogleAppMeasurementIdentitySupport.xcframework' end end diff --git a/Package.swift b/Package.swift index 0e91d0d06e0..a776a6b1d7a 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,7 @@ import PackageDescription -let firebaseVersion = "12.5.0" +let firebaseVersion = "12.6.0" let package = Package( name: "Firebase", diff --git a/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift b/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift index de621d61523..3b6453ee75c 100755 --- a/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift +++ b/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift @@ -21,7 +21,7 @@ import Foundation /// The version and releasing fields of the non-Firebase pods should be reviewed every release. /// The array should be ordered so that any pod's dependencies precede it in the list. public let shared = Manifest( - version: "12.5.0", + version: "12.6.0", pods: [ Pod("FirebaseSharedSwift"), Pod("FirebaseCoreInternal"), From 37715f03a9e205237c9ff7faa4e9e51e08742820 Mon Sep 17 00:00:00 2001 From: themiswang Date: Thu, 30 Oct 2025 10:44:22 -0400 Subject: [PATCH 48/54] remove func from codebase (#15456) --- .../Shared/FIRCLSMachO/FIRCLSMachOBinary.m | 60 +------------------ 1 file changed, 3 insertions(+), 57 deletions(-) diff --git a/Crashlytics/Shared/FIRCLSMachO/FIRCLSMachOBinary.m b/Crashlytics/Shared/FIRCLSMachO/FIRCLSMachOBinary.m index 2b8b67915a3..f953d990486 100644 --- a/Crashlytics/Shared/FIRCLSMachO/FIRCLSMachOBinary.m +++ b/Crashlytics/Shared/FIRCLSMachO/FIRCLSMachOBinary.m @@ -14,13 +14,11 @@ #import "Crashlytics/Shared/FIRCLSMachO/FIRCLSMachOBinary.h" -#import "Crashlytics/Shared/FIRCLSMachO/FIRCLSMachOSlice.h" - #import +#import "Crashlytics/Crashlytics/Helpers/FIRCLSUtility.h" +#import "Crashlytics/Shared/FIRCLSByteUtility.h" +#import "Crashlytics/Shared/FIRCLSMachO/FIRCLSMachOSlice.h" -static void FIRCLSSafeHexToString(const uint8_t* value, size_t length, char* outputBuffer); -static NSString* FIRCLSNSDataToNSString(NSData* data); -static NSString* FIRCLSHashBytes(const void* bytes, size_t length); static NSString* FIRCLSHashNSString(NSString* value); @interface FIRCLSMachOBinary () @@ -116,58 +114,6 @@ + (NSString*)hashNSString:(NSString*)value { @end -// TODO: Functions copied from the SDK. We should figure out a way to share this. -static void FIRCLSSafeHexToString(const uint8_t* value, size_t length, char* outputBuffer) { - const char hex[] = "0123456789abcdef"; - - if (!value) { - outputBuffer[0] = '\0'; - return; - } - - for (size_t i = 0; i < length; ++i) { - unsigned char c = value[i]; - outputBuffer[i * 2] = hex[c >> 4]; - outputBuffer[i * 2 + 1] = hex[c & 0x0F]; - } - - outputBuffer[length * 2] = '\0'; // null terminate -} - -static NSString* FIRCLSNSDataToNSString(NSData* data) { - NSString* string; - char* buffer; - size_t size; - NSUInteger length; - - // we need 2 hex char for every byte of data, plus one more spot for a - // null terminator - length = [data length]; - size = (length * 2) + 1; - buffer = calloc(1, sizeof(char) * size); - - if (!buffer) { - return nil; - } - - FIRCLSSafeHexToString([data bytes], length, buffer); - - string = [NSString stringWithUTF8String:buffer]; - - free(buffer); - - return string; -} - -static NSString* FIRCLSHashBytes(const void* bytes, size_t length) { - uint8_t digest[CC_SHA1_DIGEST_LENGTH] = {0}; - CC_SHA1(bytes, (CC_LONG)length, digest); - - NSData* result = [NSData dataWithBytes:digest length:CC_SHA1_DIGEST_LENGTH]; - - return FIRCLSNSDataToNSString(result); -} - static NSString* FIRCLSHashNSString(NSString* value) { const char* s = [value cStringUsingEncoding:NSUTF8StringEncoding]; From 4f36a1cc7b6f6c1765e994ce515945faf4445dcd Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Fri, 31 Oct 2025 16:48:46 -0700 Subject: [PATCH 49/54] [AI] Server Prompt Templates (#15402) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Andrew Heard Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- FirebaseAI/Sources/AILog.swift | 12 + FirebaseAI/Sources/Chat.swift | 79 +------ FirebaseAI/Sources/FirebaseAI.swift | 22 ++ .../Sources/GenerateContentRequest.swift | 16 +- FirebaseAI/Sources/GenerativeAIRequest.swift | 2 +- FirebaseAI/Sources/GenerativeAIService.swift | 4 +- FirebaseAI/Sources/History.swift | 94 ++++++++ FirebaseAI/Sources/TemplateChatSession.swift | 176 +++++++++++++++ .../TemplateGenerateContentRequest.swift | 63 ++++++ .../Sources/TemplateGenerativeModel.swift | 141 ++++++++++++ .../TemplateImagenGenerationRequest.swift | 67 ++++++ FirebaseAI/Sources/TemplateImagenModel.swift | 56 +++++ FirebaseAI/Sources/TemplateInput.swift | 66 ++++++ .../Imagen/ImagenGenerationRequest.swift | 10 +- .../Requests/CountTokensRequest.swift | 8 +- .../project.pbxproj | 4 + .../TestApp/Resources/TestApp.entitlements | 2 + ...ServerPromptTemplateIntegrationTests.swift | 205 ++++++++++++++++++ .../Tests/Unit/TemplateChatSessionTests.swift | 121 +++++++++++ .../Unit/TemplateGenerativeModelTests.swift | 72 ++++++ .../Tests/Unit/TemplateImagenModelTests.swift | 52 +++++ .../Tests/Unit/TemplateInputTests.swift | 29 +++ .../GenerativeModelTestUtil.swift | 32 ++- .../Imagen/ImagenGenerationRequestTests.swift | 4 +- 24 files changed, 1248 insertions(+), 89 deletions(-) create mode 100644 FirebaseAI/Sources/History.swift create mode 100644 FirebaseAI/Sources/TemplateChatSession.swift create mode 100644 FirebaseAI/Sources/TemplateGenerateContentRequest.swift create mode 100644 FirebaseAI/Sources/TemplateGenerativeModel.swift create mode 100644 FirebaseAI/Sources/TemplateImagenGenerationRequest.swift create mode 100644 FirebaseAI/Sources/TemplateImagenModel.swift create mode 100644 FirebaseAI/Sources/TemplateInput.swift create mode 100644 FirebaseAI/Tests/TestApp/Tests/Integration/ServerPromptTemplateIntegrationTests.swift create mode 100644 FirebaseAI/Tests/Unit/TemplateChatSessionTests.swift create mode 100644 FirebaseAI/Tests/Unit/TemplateGenerativeModelTests.swift create mode 100644 FirebaseAI/Tests/Unit/TemplateImagenModelTests.swift create mode 100644 FirebaseAI/Tests/Unit/TemplateInputTests.swift diff --git a/FirebaseAI/Sources/AILog.swift b/FirebaseAI/Sources/AILog.swift index 52b44bf7c01..345451bf07f 100644 --- a/FirebaseAI/Sources/AILog.swift +++ b/FirebaseAI/Sources/AILog.swift @@ -87,6 +87,7 @@ enum AILog { case generateContentResponseEmptyCandidates = 4003 case invalidWebsocketURL = 4004 case duplicateLiveSessionSetupComplete = 4005 + case malformedURL = 4006 // SDK Debugging case loadRequestStreamResponseLine = 5000 @@ -138,6 +139,17 @@ enum AILog { log(level: .debug, code: code, message) } + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + static func makeInternalError(message: String, code: MessageCode) -> GenerateContentError { + let error = GenerateContentError.internalError(underlying: NSError( + domain: "\(Constants.baseErrorDomain).Internal", + code: code.rawValue, + userInfo: [NSLocalizedDescriptionKey: message] + )) + AILog.error(code: code, message) + return error + } + /// Returns `true` if additional logging has been enabled via a launch argument. static func additionalLoggingEnabled() -> Bool { return ProcessInfo.processInfo.arguments.contains(enableArgumentKey) diff --git a/FirebaseAI/Sources/Chat.swift b/FirebaseAI/Sources/Chat.swift index 80e908a8f57..99c6fb13367 100644 --- a/FirebaseAI/Sources/Chat.swift +++ b/FirebaseAI/Sources/Chat.swift @@ -19,35 +19,21 @@ import Foundation @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public final class Chat: Sendable { private let model: GenerativeModel + private let _history: History - /// Initializes a new chat representing a 1:1 conversation between model and user. init(model: GenerativeModel, history: [ModelContent]) { self.model = model - self.history = history + _history = History(history: history) } - private let historyLock = NSLock() - private nonisolated(unsafe) var _history: [ModelContent] = [] /// The previous content from the chat that has been successfully sent and received from the /// model. This will be provided to the model for each message sent as context for the discussion. public var history: [ModelContent] { get { - historyLock.withLock { _history } + return _history.history } set { - historyLock.withLock { _history = newValue } - } - } - - private func appendHistory(contentsOf: [ModelContent]) { - historyLock.withLock { - _history.append(contentsOf: contentsOf) - } - } - - private func appendHistory(_ newElement: ModelContent) { - historyLock.withLock { - _history.append(newElement) + _history.history = newValue } } @@ -87,8 +73,8 @@ public final class Chat: Sendable { let toAdd = ModelContent(role: "model", parts: reply.parts) // Append the request and successful result to history, then return the value. - appendHistory(contentsOf: newContent) - appendHistory(toAdd) + _history.append(contentsOf: newContent) + _history.append(toAdd) return result } @@ -136,63 +122,16 @@ public final class Chat: Sendable { } // Save the request. - appendHistory(contentsOf: newContent) + _history.append(contentsOf: newContent) // Aggregate the content to add it to the history before we finish. - let aggregated = self.aggregatedChunks(aggregatedContent) - self.appendHistory(aggregated) + let aggregated = self._history.aggregatedChunks(aggregatedContent) + self._history.append(aggregated) continuation.finish() } } } - private func aggregatedChunks(_ chunks: [ModelContent]) -> ModelContent { - var parts: [InternalPart] = [] - var combinedText = "" - var combinedThoughts = "" - - func flush() { - if !combinedThoughts.isEmpty { - parts.append(InternalPart(.text(combinedThoughts), isThought: true, thoughtSignature: nil)) - combinedThoughts = "" - } - if !combinedText.isEmpty { - parts.append(InternalPart(.text(combinedText), isThought: nil, thoughtSignature: nil)) - combinedText = "" - } - } - - // Loop through all the parts, aggregating the text. - for part in chunks.flatMap({ $0.internalParts }) { - // Only text parts may be combined. - if case let .text(text) = part.data, part.thoughtSignature == nil { - // Thought summaries must not be combined with regular text. - if part.isThought ?? false { - // If we were combining regular text, flush it before handling "thoughts". - if !combinedText.isEmpty { - flush() - } - combinedThoughts += text - } else { - // If we were combining "thoughts", flush it before handling regular text. - if !combinedThoughts.isEmpty { - flush() - } - combinedText += text - } - } else { - // This is a non-combinable part (not text), flush any pending text. - flush() - parts.append(part) - } - } - - // Flush any remaining text. - flush() - - return ModelContent(role: "model", parts: parts) - } - /// Populates the `role` field with `user` if it doesn't exist. Required in chat sessions. private func populateContentRole(_ content: ModelContent) -> ModelContent { if content.role != nil { diff --git a/FirebaseAI/Sources/FirebaseAI.swift b/FirebaseAI/Sources/FirebaseAI.swift index 354c16b79ab..40cf38590cf 100644 --- a/FirebaseAI/Sources/FirebaseAI.swift +++ b/FirebaseAI/Sources/FirebaseAI.swift @@ -135,6 +135,28 @@ public final class FirebaseAI: Sendable { ) } + /// Initializes a new `TemplateGenerativeModel`. + /// + /// - Returns: A new `TemplateGenerativeModel` instance. + public func templateGenerativeModel() -> TemplateGenerativeModel { + return TemplateGenerativeModel( + generativeAIService: GenerativeAIService(firebaseInfo: firebaseInfo, + urlSession: GenAIURLSession.default), + apiConfig: apiConfig + ) + } + + /// Initializes a new `TemplateImagenModel`. + /// + /// - Returns: A new `TemplateImagenModel` instance. + public func templateImagenModel() -> TemplateImagenModel { + return TemplateImagenModel( + generativeAIService: GenerativeAIService(firebaseInfo: firebaseInfo, + urlSession: GenAIURLSession.default), + apiConfig: apiConfig + ) + } + /// **[Public Preview]** Initializes a ``LiveGenerativeModel`` with the given parameters. /// /// - Note: Refer to [the Firebase docs on the Live diff --git a/FirebaseAI/Sources/GenerateContentRequest.swift b/FirebaseAI/Sources/GenerateContentRequest.swift index 21acd502a75..bc4e9797760 100644 --- a/FirebaseAI/Sources/GenerateContentRequest.swift +++ b/FirebaseAI/Sources/GenerateContentRequest.swift @@ -73,15 +73,23 @@ extension GenerateContentRequest { extension GenerateContentRequest: GenerativeAIRequest { typealias Response = GenerateContentResponse - var url: URL { + func getURL() throws -> URL { let modelURL = "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(model)" + let urlString: String switch apiMethod { case .generateContent: - return URL(string: "\(modelURL):\(apiMethod.rawValue)")! + urlString = "\(modelURL):\(apiMethod.rawValue)" case .streamGenerateContent: - return URL(string: "\(modelURL):\(apiMethod.rawValue)?alt=sse")! + urlString = "\(modelURL):\(apiMethod.rawValue)?alt=sse" case .countTokens: - fatalError("\(Self.self) should be a property of \(CountTokensRequest.self).") + throw AILog.makeInternalError( + message: "\(Self.self) should be a property of \(CountTokensRequest.self).", + code: .malformedURL + ) } + guard let url = URL(string: urlString) else { + throw AILog.makeInternalError(message: "Malformed URL: \(urlString)", code: .malformedURL) + } + return url } } diff --git a/FirebaseAI/Sources/GenerativeAIRequest.swift b/FirebaseAI/Sources/GenerativeAIRequest.swift index 148e989db40..192de607137 100644 --- a/FirebaseAI/Sources/GenerativeAIRequest.swift +++ b/FirebaseAI/Sources/GenerativeAIRequest.swift @@ -18,7 +18,7 @@ import Foundation protocol GenerativeAIRequest: Sendable, Encodable { associatedtype Response: Sendable, Decodable - var url: URL { get } + func getURL() throws -> URL var options: RequestOptions { get } } diff --git a/FirebaseAI/Sources/GenerativeAIService.swift b/FirebaseAI/Sources/GenerativeAIService.swift index a17364f8cb6..ed385f942a0 100644 --- a/FirebaseAI/Sources/GenerativeAIService.swift +++ b/FirebaseAI/Sources/GenerativeAIService.swift @@ -26,7 +26,7 @@ struct GenerativeAIService { /// The Firebase SDK version in the format `fire/`. static let firebaseVersionTag = "fire/\(FirebaseVersion())" - private let firebaseInfo: FirebaseInfo + let firebaseInfo: FirebaseInfo private let urlSession: URLSession @@ -167,7 +167,7 @@ struct GenerativeAIService { // MARK: - Private Helpers private func urlRequest(request: T) async throws -> URLRequest { - var urlRequest = URLRequest(url: request.url) + var urlRequest = try URLRequest(url: request.getURL()) urlRequest.httpMethod = "POST" urlRequest.setValue(firebaseInfo.apiKey, forHTTPHeaderField: "x-goog-api-key") urlRequest.setValue( diff --git a/FirebaseAI/Sources/History.swift b/FirebaseAI/Sources/History.swift new file mode 100644 index 00000000000..827f7df5b46 --- /dev/null +++ b/FirebaseAI/Sources/History.swift @@ -0,0 +1,94 @@ +// 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 + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class History: Sendable { + private let historyLock = NSLock() + private nonisolated(unsafe) var _history: [ModelContent] = [] + /// The previous content from the chat that has been successfully sent and received from the + /// model. This will be provided to the model for each message sent as context for the discussion. + public var history: [ModelContent] { + get { + historyLock.withLock { _history } + } + set { + historyLock.withLock { _history = newValue } + } + } + + init(history: [ModelContent]) { + self.history = history + } + + func append(contentsOf: [ModelContent]) { + historyLock.withLock { + _history.append(contentsOf: contentsOf) + } + } + + func append(_ newElement: ModelContent) { + historyLock.withLock { + _history.append(newElement) + } + } + + func aggregatedChunks(_ chunks: [ModelContent]) -> ModelContent { + var parts: [InternalPart] = [] + var combinedText = "" + var combinedThoughts = "" + + func flush() { + if !combinedThoughts.isEmpty { + parts.append(InternalPart(.text(combinedThoughts), isThought: true, thoughtSignature: nil)) + combinedThoughts = "" + } + if !combinedText.isEmpty { + parts.append(InternalPart(.text(combinedText), isThought: nil, thoughtSignature: nil)) + combinedText = "" + } + } + + // Loop through all the parts, aggregating the text. + for part in chunks.flatMap({ $0.internalParts }) { + // Only text parts may be combined. + if case let .text(text) = part.data, part.thoughtSignature == nil { + // Thought summaries must not be combined with regular text. + if part.isThought ?? false { + // If we were combining regular text, flush it before handling "thoughts". + if !combinedText.isEmpty { + flush() + } + combinedThoughts += text + } else { + // If we were combining "thoughts", flush it before handling regular text. + if !combinedThoughts.isEmpty { + flush() + } + combinedText += text + } + } else { + // This is a non-combinable part (not text), flush any pending text. + flush() + parts.append(part) + } + } + + // Flush any remaining text. + flush() + + return ModelContent(role: "model", parts: parts) + } +} diff --git a/FirebaseAI/Sources/TemplateChatSession.swift b/FirebaseAI/Sources/TemplateChatSession.swift new file mode 100644 index 00000000000..abba669a1dd --- /dev/null +++ b/FirebaseAI/Sources/TemplateChatSession.swift @@ -0,0 +1,176 @@ +// 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 + +// TODO: Restore `public` to class and methods when determined to be releaseable. + +/// A chat session that allows for conversation with a model. +/// +/// **Public Preview**: This API is a public preview and may be subject to change. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class TemplateChatSession: Sendable { + private let model: TemplateGenerativeModel + private let templateID: String + private let _history: History + + init(model: TemplateGenerativeModel, templateID: String, history: [ModelContent]) { + self.model = model + self.templateID = templateID + _history = History(history: history) + } + + public var history: [ModelContent] { + get { + return _history.history + } + set { + _history.history = newValue + } + } + + /// Sends a message to the model and returns the response. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - content: The message to send to the model. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: The content generated by the model. + /// - Throws: A ``GenerateContentError`` if the request failed. + func sendMessage(_ content: [ModelContent], + inputs: [String: Any], + options: RequestOptions = RequestOptions()) async throws + -> GenerateContentResponse { + let templateInputs = try inputs.mapValues { try TemplateInput(value: $0) } + let newContent = content.map(populateContentRole) + let response = try await model.generateContentWithHistory( + history: _history.history + newContent, + template: templateID, + inputs: templateInputs, + options: options + ) + _history.append(contentsOf: newContent) + if let modelResponse = response.candidates.first { + _history.append(modelResponse.content) + } + return response + } + + /// Sends a message to the model and returns the response. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - message: The message to send to the model. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: The content generated by the model. + /// - Throws: A ``GenerateContentError`` if the request failed. + func sendMessage(_ message: any PartsRepresentable, + inputs: [String: Any], + options: RequestOptions = RequestOptions()) async throws + -> GenerateContentResponse { + return try await sendMessage([ModelContent(parts: message.partsValue)], + inputs: inputs, + options: options) + } + + /// Sends a message to the model and returns the response as a stream of + /// `GenerateContentResponse`s. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - content: The message to send to the model. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: An `AsyncThrowingStream` that yields `GenerateContentResponse` objects. + /// - Throws: A ``GenerateContentError`` if the request failed. + func sendMessageStream(_ content: [ModelContent], + inputs: [String: Any], + options: RequestOptions = RequestOptions()) throws + -> AsyncThrowingStream { + let templateInputs = try inputs.mapValues { try TemplateInput(value: $0) } + let newContent = content.map(populateContentRole) + let stream = try model.generateContentStreamWithHistory( + history: _history.history + newContent, + template: templateID, + inputs: templateInputs, + options: options + ) + return AsyncThrowingStream { continuation in + Task { + var aggregatedContent: [ModelContent] = [] + + do { + for try await chunk in stream { + // Capture any content that's streaming. This should be populated if there's no error. + if let chunkContent = chunk.candidates.first?.content { + aggregatedContent.append(chunkContent) + } + + // Pass along the chunk. + continuation.yield(chunk) + } + } catch { + // Rethrow the error that the underlying stream threw. Don't add anything to history. + continuation.finish(throwing: error) + return + } + + // Save the request. + _history.append(contentsOf: newContent) + + // Aggregate the content to add it to the history before we finish. + let aggregated = _history.aggregatedChunks(aggregatedContent) + _history.append(aggregated) + continuation.finish() + } + } + } + + /// Sends a message to the model and returns the response as a stream of + /// `GenerateContentResponse`s. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - message: The message to send to the model. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: An `AsyncThrowingStream` that yields `GenerateContentResponse` objects. + /// - Throws: A ``GenerateContentError`` if the request failed. + func sendMessageStream(_ message: any PartsRepresentable, + inputs: [String: Any], + options: RequestOptions = RequestOptions()) throws + -> AsyncThrowingStream { + return try sendMessageStream([ModelContent(parts: message.partsValue)], + inputs: inputs, + options: options) + } + + private func populateContentRole(_ content: ModelContent) -> ModelContent { + if content.role != nil { + return content + } else { + return ModelContent(role: "user", parts: content.parts) + } + } +} diff --git a/FirebaseAI/Sources/TemplateGenerateContentRequest.swift b/FirebaseAI/Sources/TemplateGenerateContentRequest.swift new file mode 100644 index 00000000000..20ba84b3571 --- /dev/null +++ b/FirebaseAI/Sources/TemplateGenerateContentRequest.swift @@ -0,0 +1,63 @@ +// 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 + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct TemplateGenerateContentRequest: Sendable { + let template: String + let inputs: [String: TemplateInput] + let history: [ModelContent] + let projectID: String + let stream: Bool + let apiConfig: APIConfig + let options: RequestOptions +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension TemplateGenerateContentRequest: Encodable { + enum CodingKeys: String, CodingKey { + case inputs + case history + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(inputs, forKey: .inputs) + try container.encode(history, forKey: .history) + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension TemplateGenerateContentRequest: GenerativeAIRequest { + typealias Response = GenerateContentResponse + + func getURL() throws -> URL { + var urlString = + "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/projects/\(projectID)" + if case let .vertexAI(_, location) = apiConfig.service { + urlString += "/locations/\(location)" + } + + if stream { + urlString += "/templates/\(template):templateStreamGenerateContent?alt=sse" + } else { + urlString += "/templates/\(template):templateGenerateContent" + } + guard let url = URL(string: urlString) else { + throw AILog.makeInternalError(message: "Malformed URL: \(urlString)", code: .malformedURL) + } + return url + } +} diff --git a/FirebaseAI/Sources/TemplateGenerativeModel.swift b/FirebaseAI/Sources/TemplateGenerativeModel.swift new file mode 100644 index 00000000000..bf727021c0f --- /dev/null +++ b/FirebaseAI/Sources/TemplateGenerativeModel.swift @@ -0,0 +1,141 @@ +// 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 + +/// A type that represents a remote multimodal model (like Gemini), with the ability to generate +/// content based on various input types. +/// +/// **Public Preview**: This API is a public preview and may be subject to change. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public final class TemplateGenerativeModel: Sendable { + let generativeAIService: GenerativeAIService + let apiConfig: APIConfig + + init(generativeAIService: GenerativeAIService, apiConfig: APIConfig) { + self.generativeAIService = generativeAIService + self.apiConfig = apiConfig + } + + /// Generates content from a prompt template and inputs. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - templateID: The ID of the prompt template to use. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: The content generated by the model. + /// - Throws: A ``GenerateContentError`` if the request failed. + public func generateContent(templateID: String, + inputs: [String: Any], + options: RequestOptions = RequestOptions()) async throws + -> GenerateContentResponse { + let templateInputs = try inputs.mapValues { try TemplateInput(value: $0) } + return try await generateContentWithHistory( + history: [], + template: templateID, + inputs: templateInputs, + options: options + ) + } + + /// Generates content from a prompt template, inputs, and history. + /// + /// - Parameters: + /// - history: The conversation history to use. + /// - template: The prompt template to use. + /// - inputs: A dictionary of variables to substitute into the template. + /// - Returns: The content generated by the model. + /// - Throws: A ``GenerateContentError`` if the request failed. + func generateContentWithHistory(history: [ModelContent], template: String, + inputs: [String: TemplateInput], + options: RequestOptions = RequestOptions()) async throws + -> GenerateContentResponse { + let request = TemplateGenerateContentRequest( + template: template, + inputs: inputs, + history: history, + projectID: generativeAIService.firebaseInfo.projectID, + stream: false, + apiConfig: apiConfig, + options: options + ) + let response: GenerateContentResponse = try await generativeAIService + .loadRequest(request: request) + return response + } + + /// Generates content from a prompt template and inputs, with streaming responses. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + /// + /// - Parameters: + /// - templateID: The ID of the prompt template to use. + /// - inputs: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: An `AsyncThrowingStream` that yields `GenerateContentResponse` objects. + /// - Throws: A ``GenerateContentError`` if the request failed. + public func generateContentStream(templateID: String, + inputs: [String: Any], + options: RequestOptions = RequestOptions()) throws + -> AsyncThrowingStream { + let templateInputs = try inputs.mapValues { try TemplateInput(value: $0) } + let request = TemplateGenerateContentRequest( + template: templateID, + inputs: templateInputs, + history: [], + projectID: generativeAIService.firebaseInfo.projectID, + stream: true, + apiConfig: apiConfig, + options: options + ) + return generativeAIService.loadRequestStream(request: request) + } + + func generateContentStreamWithHistory(history: [ModelContent], template: String, + inputs: [String: TemplateInput], + options: RequestOptions = RequestOptions()) throws + -> AsyncThrowingStream { + let request = TemplateGenerateContentRequest( + template: template, + inputs: inputs, + history: history, + projectID: generativeAIService.firebaseInfo.projectID, + stream: true, + apiConfig: apiConfig, + options: options + ) + return generativeAIService.loadRequestStream(request: request) + } + + // TODO: Restore `public` determined to be releaseable along with the contents of TemplateChatSession. + + /// Creates a new chat conversation using this model with the provided history and template. + /// + /// - Parameters: + /// - templateID: The ID of the prompt template to use. + /// - history: The conversation history to use. + /// - Returns: A new ``TemplateChatSession`` instance. + func startChat(templateID: String, + history: [ModelContent] = []) -> TemplateChatSession { + return TemplateChatSession( + model: self, + templateID: templateID, + history: history + ) + } +} diff --git a/FirebaseAI/Sources/TemplateImagenGenerationRequest.swift b/FirebaseAI/Sources/TemplateImagenGenerationRequest.swift new file mode 100644 index 00000000000..c155b66fe55 --- /dev/null +++ b/FirebaseAI/Sources/TemplateImagenGenerationRequest.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 Foundation + +enum ImageAPIMethod: String { + case generateImages = "templatePredict" +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct TemplateImagenGenerationRequest: Sendable { + typealias Response = ImagenGenerationResponse + + let template: String + let inputs: [String: TemplateInput] + let projectID: String + let apiConfig: APIConfig + let options: RequestOptions + + init(template: String, inputs: [String: TemplateInput], projectID: String, + apiConfig: APIConfig, options: RequestOptions) { + self.template = template + self.inputs = inputs + self.projectID = projectID + self.apiConfig = apiConfig + self.options = options + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension TemplateImagenGenerationRequest: GenerativeAIRequest where ImageType: Decodable { + func getURL() throws -> URL { + var urlString = + "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/projects/\(projectID)" + if case let .vertexAI(_, location) = apiConfig.service { + urlString += "/locations/\(location)" + } + urlString += "/templates/\(template):\(ImageAPIMethod.generateImages.rawValue)" + guard let url = URL(string: urlString) else { + throw AILog.makeInternalError(message: "Malformed URL: \(urlString)", code: .malformedURL) + } + return url + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension TemplateImagenGenerationRequest: Encodable { + enum CodingKeys: String, CodingKey { + case inputs + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(inputs, forKey: .inputs) + } +} diff --git a/FirebaseAI/Sources/TemplateImagenModel.swift b/FirebaseAI/Sources/TemplateImagenModel.swift new file mode 100644 index 00000000000..794965364bd --- /dev/null +++ b/FirebaseAI/Sources/TemplateImagenModel.swift @@ -0,0 +1,56 @@ +// 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 + +/// A type that represents a remote image generation model (like Imagen), with the ability to +/// generate +/// images based on various input types. +/// +/// **Public Preview**: This API is a public preview and may be subject to change. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public final class TemplateImagenModel: Sendable { + let generativeAIService: GenerativeAIService + let apiConfig: APIConfig + + init(generativeAIService: GenerativeAIService, apiConfig: APIConfig) { + self.generativeAIService = generativeAIService + self.apiConfig = apiConfig + } + + /// Generates images from a prompt template and variables. + /// + /// - Parameters: + /// - template: The prompt template to use. + /// - variables: A dictionary of variables to substitute into the template. + /// - options: The ``RequestOptions`` for the request, currently used to override default + /// request timeout. + /// - Returns: The images generated by the model. + /// - Throws: An error if the request failed. + public func generateImages(templateID: String, + inputs: [String: Any], + options: RequestOptions = RequestOptions()) async throws + -> ImagenGenerationResponse { + let templateInputs = try inputs.mapValues { try TemplateInput(value: $0) } + let projectID = generativeAIService.firebaseInfo.projectID + let request = TemplateImagenGenerationRequest( + template: templateID, + inputs: templateInputs, + projectID: projectID, + apiConfig: apiConfig, + options: options + ) + return try await generativeAIService.loadRequest(request: request) + } +} diff --git a/FirebaseAI/Sources/TemplateInput.swift b/FirebaseAI/Sources/TemplateInput.swift new file mode 100644 index 00000000000..606150ed824 --- /dev/null +++ b/FirebaseAI/Sources/TemplateInput.swift @@ -0,0 +1,66 @@ +// 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 + +enum TemplateInput: Encodable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([TemplateInput]) + case dictionary([String: TemplateInput]) + + init(value: Any) throws { + switch value { + case let value as String: + self = .string(value) + case let value as Int: + self = .int(value) + case let value as Double: + self = .double(value) + case let value as Float: + self = .double(Double(value)) + case let value as Bool: + self = .bool(value) + case let value as [Any]: + self = try .array(value.map { try TemplateInput(value: $0) }) + case let value as [String: Any]: + self = try .dictionary(value.mapValues { try TemplateInput(value: $0) }) + default: + throw EncodingError.invalidValue( + value, + EncodingError.Context(codingPath: [], debugDescription: "Invalid value") + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .string(value): + try container.encode(value) + case let .int(value): + try container.encode(value) + case let .double(value): + try container.encode(value) + case let .bool(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .dictionary(value): + try container.encode(value) + } + } +} diff --git a/FirebaseAI/Sources/Types/Internal/Imagen/ImagenGenerationRequest.swift b/FirebaseAI/Sources/Types/Internal/Imagen/ImagenGenerationRequest.swift index ffb0e8bcf57..9f5a76137d3 100644 --- a/FirebaseAI/Sources/Types/Internal/Imagen/ImagenGenerationRequest.swift +++ b/FirebaseAI/Sources/Types/Internal/Imagen/ImagenGenerationRequest.swift @@ -39,9 +39,13 @@ struct ImagenGenerationRequest: Sendable { extension ImagenGenerationRequest: GenerativeAIRequest where ImageType: Decodable { typealias Response = ImagenGenerationResponse - var url: URL { - return URL(string: - "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(model):predict")! + func getURL() throws -> URL { + let urlString = + "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(model):predict" + guard let url = URL(string: urlString) else { + throw AILog.makeInternalError(message: "Malformed URL: \(urlString)", code: .malformedURL) + } + return url } } diff --git a/FirebaseAI/Sources/Types/Internal/Requests/CountTokensRequest.swift b/FirebaseAI/Sources/Types/Internal/Requests/CountTokensRequest.swift index f282b02096b..be3e09c3060 100644 --- a/FirebaseAI/Sources/Types/Internal/Requests/CountTokensRequest.swift +++ b/FirebaseAI/Sources/Types/Internal/Requests/CountTokensRequest.swift @@ -29,10 +29,14 @@ extension CountTokensRequest: GenerativeAIRequest { var apiConfig: APIConfig { generateContentRequest.apiConfig } - var url: URL { + func getURL() throws -> URL { let version = apiConfig.version.rawValue let endpoint = apiConfig.service.endpoint.rawValue - return URL(string: "\(endpoint)/\(version)/\(modelResourceName):countTokens")! + let urlString = "\(endpoint)/\(version)/\(modelResourceName):countTokens" + guard let url = URL(string: urlString) else { + throw AILog.makeInternalError(message: "Malformed URL: \(urlString)", code: .malformedURL) + } + return url } } diff --git a/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj b/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj index 80ce94f5ef6..c903fa2cee2 100644 --- a/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj +++ b/FirebaseAI/Tests/TestApp/FirebaseAITestApp.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 86E850612DBAFBC3002E8D94 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 86E850602DBAFBC3002E8D94 /* FirebaseStorage */; }; DEF0BB4F2DA74F680093E9F4 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF0BB4E2DA74F460093E9F4 /* TestHelpers.swift */; }; DEF0BB512DA9B7450093E9F4 /* SchemaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF0BB502DA9B7400093E9F4 /* SchemaTests.swift */; }; + DEF4634B2EA1AA77004E79B1 /* ServerPromptTemplateIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF4634A2EA1AA77004E79B1 /* ServerPromptTemplateIntegrationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -68,6 +69,7 @@ 86D77E032D7B6C95003D155D /* InstanceConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceConfig.swift; sourceTree = ""; }; DEF0BB4E2DA74F460093E9F4 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; DEF0BB502DA9B7400093E9F4 /* SchemaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaTests.swift; sourceTree = ""; }; + DEF4634A2EA1AA77004E79B1 /* ServerPromptTemplateIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerPromptTemplateIntegrationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -146,6 +148,7 @@ 868A7C572CCC27AF00E449DD /* Integration */ = { isa = PBXGroup; children = ( + DEF4634A2EA1AA77004E79B1 /* ServerPromptTemplateIntegrationTests.swift */, 0E460FAA2E9858E4007E26A6 /* LiveSessionTests.swift */, DEF0BB502DA9B7400093E9F4 /* SchemaTests.swift */, DEF0BB4E2DA74F460093E9F4 /* TestHelpers.swift */, @@ -311,6 +314,7 @@ 864F8F712D4980DD0002EA7E /* ImagenIntegrationTests.swift in Sources */, 862218812D04E098007ED2D4 /* IntegrationTestUtils.swift in Sources */, 86D77DFC2D7A5340003D155D /* GenerateContentIntegrationTests.swift in Sources */, + DEF4634B2EA1AA77004E79B1 /* ServerPromptTemplateIntegrationTests.swift in Sources */, 8661386E2CC943DE00F4B78E /* IntegrationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/FirebaseAI/Tests/TestApp/Resources/TestApp.entitlements b/FirebaseAI/Tests/TestApp/Resources/TestApp.entitlements index ee95ab7e582..225aa48bc8c 100644 --- a/FirebaseAI/Tests/TestApp/Resources/TestApp.entitlements +++ b/FirebaseAI/Tests/TestApp/Resources/TestApp.entitlements @@ -6,5 +6,7 @@ com.apple.security.network.client + keychain-access-groups + diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/ServerPromptTemplateIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/ServerPromptTemplateIntegrationTests.swift new file mode 100644 index 00000000000..d3b5a8c96e1 --- /dev/null +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/ServerPromptTemplateIntegrationTests.swift @@ -0,0 +1,205 @@ +// 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. + +// TODO: remove @testable when Template Chat is restored to the public API. +@testable import FirebaseAILogic +import Testing +#if canImport(UIKit) + import UIKit +#endif + +struct ServerPromptTemplateIntegrationTests { + private static let testConfigs: [InstanceConfig] = [ + .googleAI_v1beta, + .vertexAI_v1beta, + .vertexAI_v1beta_global, + ] + private static let imageGenerationTestConfigs: [InstanceConfig] = [.vertexAI_v1beta] + + @Test(arguments: testConfigs) + func generateContentWithText(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + let userName = "paul" + let response = try await model.generateContent( + templateID: "greeting-5", + inputs: [ + "name": userName, + "language": "Spanish", + ] + ) + let text = try #require(response.text) + #expect(text.contains("Paul")) + } + + @Test(arguments: testConfigs) + func generateContentStream(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + let userName = "paul" + let stream = try model.generateContentStream( + templateID: "greeting-5", + inputs: [ + "name": userName, + "language": "English", + ] + ) + var resultText = "" + for try await response in stream { + if let text = response.text { + resultText += text + } + } + #expect(resultText.contains("Paul")) + } + + @Test(arguments: [ + InstanceConfig.googleAI_v1beta, + InstanceConfig.vertexAI_v1beta, + ]) + func generateImages(_ config: InstanceConfig) async throws { + let imagenModel = FirebaseAI.componentInstance(config).templateImagenModel() + let imagenPrompt = "firefly" + let response = try await imagenModel.generateImages( + templateID: "image-generation-basic", + inputs: [ + "prompt": imagenPrompt, + ] + ) + #expect(response.images.count == 4) + } + + @Test(arguments: testConfigs) + func generateContentWithMedia(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + #if canImport(UIKit) + let image = UIImage(systemName: "photo")! + #elseif canImport(AppKit) + let image = NSImage(systemSymbolName: "photo", accessibilityDescription: nil)! + #endif + let imageBytes = try #require( + image.jpegData(compressionQuality: 0.8), "Could not get image data." + ) + let base64Image = imageBytes.base64EncodedString() + + let response = try await model.generateContent( + templateID: "media", + inputs: [ + "imageData": [ + "isInline": true, + "mimeType": "image/jpeg", + "contents": base64Image, + ], + ] + ) + let text = try #require(response.text) + #expect(!text.isEmpty) + } + + @Test(arguments: testConfigs) + func generateContentStreamWithMedia(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + #if canImport(UIKit) + let image = UIImage(systemName: "photo")! + #elseif canImport(AppKit) + let image = NSImage(systemSymbolName: "photo", accessibilityDescription: nil)! + #endif + let imageBytes = try #require( + image.jpegData(compressionQuality: 0.8), "Could not get image data." + ) + let base64Image = imageBytes.base64EncodedString() + + let stream = try model.generateContentStream( + templateID: "media", + inputs: [ + "imageData": [ + "isInline": true, + "mimeType": "image/jpeg", + "contents": base64Image, + ], + ] + ) + var resultText = "" + for try await response in stream { + if let text = response.text { + resultText += text + } + } + #expect(!resultText.isEmpty) + } + + @Test(arguments: testConfigs) + func chat(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + let initialHistory = [ + ModelContent(role: "user", parts: "Hello!"), + ModelContent(role: "model", parts: "Hi there! How can I help?"), + ] + let chatSession = model.startChat(templateID: "chat-history", history: initialHistory) + + let userMessage = "What's the weather like?" + + let response = try await chatSession.sendMessage( + userMessage, + inputs: ["message": userMessage] + ) + let text = try #require(response.text) + #expect(!text.isEmpty) + #expect(chatSession.history.count == 4) + let textPart = try #require(chatSession.history[2].parts.first as? TextPart) + #expect(textPart.text == userMessage) + } + + @Test(arguments: testConfigs) + func chatStream(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).templateGenerativeModel() + let initialHistory = [ + ModelContent(role: "user", parts: "Hello!"), + ModelContent(role: "model", parts: "Hi there! How can I help?"), + ] + let chatSession = model.startChat(templateID: "chat-history", history: initialHistory) + + let userMessage = "What's the weather like?" + + let stream = try chatSession.sendMessageStream( + userMessage, + inputs: ["message": userMessage] + ) + var resultText = "" + for try await response in stream { + if let text = response.text { + resultText += text + } + } + #expect(!resultText.isEmpty) + #expect(chatSession.history.count == 4) + let textPart = try #require(chatSession.history[2].parts.first as? TextPart) + #expect(textPart.text == userMessage) + } +} + +#if canImport(AppKit) + import AppKit + + extension NSImage { + func jpegData(compressionQuality: CGFloat) -> Data? { + guard let tiffRepresentation = tiffRepresentation, + let bitmapImage = NSBitmapImageRep(data: tiffRepresentation) else { + return nil + } + return bitmapImage.representation( + using: .jpeg, + properties: [.compressionFactor: compressionQuality] + ) + } + } +#endif diff --git a/FirebaseAI/Tests/Unit/TemplateChatSessionTests.swift b/FirebaseAI/Tests/Unit/TemplateChatSessionTests.swift new file mode 100644 index 00000000000..3ff5ad14ff0 --- /dev/null +++ b/FirebaseAI/Tests/Unit/TemplateChatSessionTests.swift @@ -0,0 +1,121 @@ +// 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. + +@testable import FirebaseAILogic +import FirebaseCore +import XCTest + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class TemplateChatSessionTests: XCTestCase { + var model: TemplateGenerativeModel! + var urlSession: URLSession! + + override func setUp() { + super.setUp() + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + urlSession = URLSession(configuration: configuration) + let firebaseInfo = GenerativeModelTestUtil.testFirebaseInfo() + let generativeAIService = GenerativeAIService( + firebaseInfo: firebaseInfo, + urlSession: urlSession + ) + let apiConfig = APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta) + model = TemplateGenerativeModel(generativeAIService: generativeAIService, apiConfig: apiConfig) + } + + func testSendMessage() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-basic-reply-short", + withExtension: "json", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + let chat = model.startChat(templateID: "test-template") + let response = try await chat.sendMessage("Hello", inputs: ["name": "test"]) + XCTAssertEqual(chat.history.count, 2) + XCTAssertEqual(chat.history[0].role, "user") + XCTAssertEqual((chat.history[0].parts.first as? TextPart)?.text, "Hello") + XCTAssertEqual(chat.history[1].role, "model") + XCTAssertEqual( + (chat.history[1].parts.first as? TextPart)?.text, + "Google's headquarters, also known as the Googleplex, is located in **Mountain View, California**.\n" + ) + XCTAssertEqual(response.candidates.count, 1) + } + + func testSendMessageStream() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-basic-reply-short", + withExtension: "txt", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + let chat = model.startChat(templateID: "test-template") + let stream = try chat.sendMessageStream("Hello", inputs: ["name": "test"]) + + let content = try await GenerativeModelTestUtil.collectTextFromStream(stream) + + XCTAssertEqual(content, "The capital of Wyoming is **Cheyenne**.\n") + XCTAssertEqual(chat.history.count, 2) + XCTAssertEqual(chat.history[0].role, "user") + XCTAssertEqual((chat.history[0].parts.first as? TextPart)?.text, "Hello") + XCTAssertEqual(chat.history[1].role, "model") + } + + func testSendMessageWithModelContent() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-basic-reply-short", + withExtension: "json", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + let chat = model.startChat(templateID: "test-template") + let response = try await chat.sendMessage( + [ModelContent(parts: [TextPart("Hello")])], + inputs: ["name": "test"] + ) + XCTAssertEqual(chat.history.count, 2) + XCTAssertEqual(chat.history[0].role, "user") + XCTAssertEqual((chat.history[0].parts.first as? TextPart)?.text, "Hello") + XCTAssertEqual(chat.history[1].role, "model") + XCTAssertEqual( + (chat.history[1].parts.first as? TextPart)?.text, + "Google's headquarters, also known as the Googleplex, is located in **Mountain View, California**.\n" + ) + XCTAssertEqual(response.candidates.count, 1) + } + + func testSendMessageStreamWithModelContent() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-basic-reply-short", + withExtension: "txt", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + let chat = model.startChat(templateID: "test-template") + let stream = try chat.sendMessageStream( + [ModelContent(parts: [TextPart("Hello")])], + inputs: ["name": "test"] + ) + + let content = try await GenerativeModelTestUtil.collectTextFromStream(stream) + + XCTAssertEqual(content, "The capital of Wyoming is **Cheyenne**.\n") + XCTAssertEqual(chat.history.count, 2) + XCTAssertEqual(chat.history[0].role, "user") + XCTAssertEqual((chat.history[0].parts.first as? TextPart)?.text, "Hello") + XCTAssertEqual(chat.history[1].role, "model") + } +} diff --git a/FirebaseAI/Tests/Unit/TemplateGenerativeModelTests.swift b/FirebaseAI/Tests/Unit/TemplateGenerativeModelTests.swift new file mode 100644 index 00000000000..a9994b8cf7a --- /dev/null +++ b/FirebaseAI/Tests/Unit/TemplateGenerativeModelTests.swift @@ -0,0 +1,72 @@ +// 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. + +@testable import FirebaseAILogic +import FirebaseCore +import XCTest + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class TemplateGenerativeModelTests: XCTestCase { + var urlSession: URLSession! + var model: TemplateGenerativeModel! + + override func setUp() { + super.setUp() + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + urlSession = URLSession(configuration: configuration) + let firebaseInfo = GenerativeModelTestUtil.testFirebaseInfo() + let generativeAIService = GenerativeAIService( + firebaseInfo: firebaseInfo, + urlSession: urlSession + ) + let apiConfig = APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta) + model = TemplateGenerativeModel(generativeAIService: generativeAIService, apiConfig: apiConfig) + } + + func testGenerateContent() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-basic-reply-short", + withExtension: "json", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + + let response = try await model.generateContent( + templateID: "test-template", + inputs: ["name": "test"] + ) + XCTAssertEqual( + response.text, + "Google's headquarters, also known as the Googleplex, is located in **Mountain View, California**.\n" + ) + } + + func testGenerateContentStream() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-basic-reply-short", + withExtension: "txt", + subdirectory: "mock-responses/googleai", + isTemplateRequest: true + ) + + let stream = try model.generateContentStream( + templateID: "test-template", + inputs: ["name": "test"] + ) + + let content = try await GenerativeModelTestUtil.collectTextFromStream(stream) + XCTAssertEqual(content, "The capital of Wyoming is **Cheyenne**.\n") + } +} diff --git a/FirebaseAI/Tests/Unit/TemplateImagenModelTests.swift b/FirebaseAI/Tests/Unit/TemplateImagenModelTests.swift new file mode 100644 index 00000000000..04712b377b8 --- /dev/null +++ b/FirebaseAI/Tests/Unit/TemplateImagenModelTests.swift @@ -0,0 +1,52 @@ +// 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 of 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. + +@testable import FirebaseAILogic +import XCTest + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class TemplateImagenModelTests: XCTestCase { + var urlSession: URLSession! + var model: TemplateImagenModel! + + override func setUp() { + super.setUp() + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + urlSession = URLSession(configuration: configuration) + let firebaseInfo = GenerativeModelTestUtil.testFirebaseInfo() + let generativeAIService = GenerativeAIService( + firebaseInfo: firebaseInfo, + urlSession: urlSession + ) + let apiConfig = APIConfig(service: .googleAI(endpoint: .firebaseProxyProd), version: .v1beta) + model = TemplateImagenModel(generativeAIService: generativeAIService, apiConfig: apiConfig) + } + + func testGenerateImages() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-generate-images-base64", + withExtension: "json", + subdirectory: "mock-responses/vertexai", + isTemplateRequest: true + ) + + let response = try await model.generateImages( + templateID: "test-template", + inputs: ["prompt": "a cat picture"] + ) + XCTAssertEqual(response.images.count, 4) + XCTAssertNotNil(response.images.first?.data) + } +} diff --git a/FirebaseAI/Tests/Unit/TemplateInputTests.swift b/FirebaseAI/Tests/Unit/TemplateInputTests.swift new file mode 100644 index 00000000000..2ed428be12b --- /dev/null +++ b/FirebaseAI/Tests/Unit/TemplateInputTests.swift @@ -0,0 +1,29 @@ +// 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. + +@testable import FirebaseAILogic +import XCTest + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class TemplateInputTests: XCTestCase { + func testInitWithFloat() throws { + let floatValue: Float = 3.14 + let templateInput = try TemplateInput(value: floatValue) + guard case let .double(doubleValue) = templateInput else { + XCTFail("Expected a .double case, but got \(templateInput)") + return + } + XCTAssertEqual(doubleValue, Double(floatValue), accuracy: 1e-6) + } +} diff --git a/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift b/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift index 7f9a8724363..84062c58a2a 100644 --- a/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift +++ b/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift @@ -30,10 +30,12 @@ enum GenerativeModelTestUtil { timeout: TimeInterval = RequestOptions().timeout, appCheckToken: String? = nil, authToken: String? = nil, - dataCollection: Bool = true) throws -> ((URLRequest) throws -> ( - URLResponse, - AsyncLineSequence? - )) { + dataCollection: Bool = true, + isTemplateRequest: Bool = false) throws + -> ((URLRequest) throws -> ( + URLResponse, + AsyncLineSequence? + )) { // Skip tests using MockURLProtocol on watchOS; unsupported in watchOS 2 and later, see // https://developer.apple.com/documentation/foundation/urlprotocol for details. #if os(watchOS) @@ -45,7 +47,14 @@ enum GenerativeModelTestUtil { ) return { request in let requestURL = try XCTUnwrap(request.url) - XCTAssertEqual(requestURL.path.occurrenceCount(of: "models/"), 1) + if isTemplateRequest { + XCTAssertEqual( + requestURL.path.occurrenceCount(of: "templates/test-template:template"), + 1 + ) + } else { + XCTAssertEqual(requestURL.path.occurrenceCount(of: "models/"), 1) + } XCTAssertEqual(request.timeoutInterval, timeout) let apiClientTags = try XCTUnwrap(request.value(forHTTPHeaderField: "x-goog-api-client")) .components(separatedBy: " ") @@ -79,6 +88,19 @@ enum GenerativeModelTestUtil { #endif // os(watchOS) } + static func collectTextFromStream(_ stream: AsyncThrowingStream< + GenerateContentResponse, + Error + >) async throws -> String { + var content = "" + for try await response in stream { + if let text = response.text { + content += text + } + } + return content + } + static func nonHTTPRequestHandler() throws -> ((URLRequest) -> ( URLResponse, AsyncLineSequence? diff --git a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift index f36376061d7..70a98a54321 100644 --- a/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Imagen/ImagenGenerationRequestTests.swift @@ -60,7 +60,7 @@ final class ImagenGenerationRequestTests: XCTestCase { XCTAssertEqual(request.instances, [instance]) XCTAssertEqual(request.parameters, parameters) XCTAssertEqual( - request.url, + try request.getURL(), URL(string: "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(modelName):predict") ) @@ -80,7 +80,7 @@ final class ImagenGenerationRequestTests: XCTestCase { XCTAssertEqual(request.instances, [instance]) XCTAssertEqual(request.parameters, parameters) XCTAssertEqual( - request.url, + try request.getURL(), URL(string: "\(apiConfig.service.endpoint.rawValue)/\(apiConfig.version.rawValue)/\(modelName):predict") ) From 087bb95235f676c1a37e928769a5b6645dcbd325 Mon Sep 17 00:00:00 2001 From: Tushar Khandelwal <64364243+tusharkhandelwal8@users.noreply.github.com> Date: Wed, 5 Nov 2025 00:33:17 +0530 Subject: [PATCH 50/54] Prevent stale configuration data after iOS device restore (#15442) --- FirebaseRemoteConfig/CHANGELOG.md | 6 +++++ .../Sources/FIRRemoteConfig.m | 4 ++++ .../Sources/RCNConfigDBManager.h | 4 ++++ .../Sources/RCNConfigDBManager.m | 23 +++++++++++++++++-- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index 91ba0e80f24..495dbaeb55e 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,3 +1,9 @@ +# Unreleased +- [fixed] Fixed a bug where Remote Config does not work after a restore + of a previous backup of the device. (#14459) +- [fixed] Fixed a data race condition on the global database status flag + by synchronizing all read and write operations. (#14715) + # 12.3.0 - [fixed] Add missing GoogleUtilities dependency to fix SwiftPM builds when building dynamically linked libraries. (#15276) diff --git a/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m b/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m index 258eb362fe7..bfc55cdf877 100644 --- a/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m +++ b/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m @@ -160,6 +160,10 @@ - (instancetype)initWithAppName:(NSString *)appName // Initialize RCConfigContent if not already. _configContent = configContent; + + // We must ensure the DBManager's asynchronous setup (which sets gIsNewDatabase) + // completes before RCNConfigSettings tries to read that state for the resetUserDefaults logic. + [_DBManager waitForDatabaseOperationQueue]; _settings = [[RCNConfigSettings alloc] initWithDatabaseManager:_DBManager namespace:_FIRNamespace firebaseAppName:appName diff --git a/FirebaseRemoteConfig/Sources/RCNConfigDBManager.h b/FirebaseRemoteConfig/Sources/RCNConfigDBManager.h index e22b40d3779..d881381186b 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigDBManager.h +++ b/FirebaseRemoteConfig/Sources/RCNConfigDBManager.h @@ -130,4 +130,8 @@ typedef void (^RCNDBLoadCompletion)(BOOL success, /// Returns true if this a new install of the Config database. - (BOOL)isNewDatabase; + +/// Blocks the calling thread until all pending database operations on the internal serial queue are +/// completed. Used to enforce initialization order. +- (void)waitForDatabaseOperationQueue; @end diff --git a/FirebaseRemoteConfig/Sources/RCNConfigDBManager.m b/FirebaseRemoteConfig/Sources/RCNConfigDBManager.m index 161f678b8d6..c1fd403a246 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigDBManager.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigDBManager.m @@ -38,6 +38,9 @@ /// The storage sub-directory that the Remote Config database resides in. static NSString *const RCNRemoteConfigStorageSubDirectory = @"Google/RemoteConfig"; +/// Introduce a dedicated serial queue for gIsNewDatabase access. +static dispatch_queue_t gIsNewDatabaseQueue; + /// Remote Config database path for deprecated V0 version. static NSString *RemoteConfigPathForOldDatabaseV0(void) { NSArray *dirPaths = @@ -82,7 +85,9 @@ static BOOL RemoteConfigCreateFilePathIfNotExist(NSString *filePath) { } NSFileManager *fileManager = [NSFileManager defaultManager]; if (![fileManager fileExistsAtPath:filePath]) { - gIsNewDatabase = YES; + dispatch_sync(gIsNewDatabaseQueue, ^{ + gIsNewDatabase = YES; + }); NSError *error; [fileManager createDirectoryAtPath:[filePath stringByDeletingLastPathComponent] withIntermediateDirectories:YES @@ -119,6 +124,8 @@ + (instancetype)sharedInstance { static dispatch_once_t onceToken; static RCNConfigDBManager *sharedInstance; dispatch_once(&onceToken, ^{ + gIsNewDatabaseQueue = dispatch_queue_create("com.google.FirebaseRemoteConfig.gIsNewDatabase", + DISPATCH_QUEUE_SERIAL); sharedInstance = [[RCNConfigDBManager alloc] init]; }); return sharedInstance; @@ -1219,7 +1226,19 @@ - (BOOL)logErrorWithSQL:(const char *)SQL } - (BOOL)isNewDatabase { - return gIsNewDatabase; + __block BOOL isNew; + dispatch_sync(gIsNewDatabaseQueue, ^{ + isNew = gIsNewDatabase; + }); + return isNew; +} + +- (void)waitForDatabaseOperationQueue { + // This dispatch_sync call ensures that all blocks queued before it on _databaseOperationQueue + // (including the createOrOpenDatabase setup block) execute and complete before this method + // returns. + dispatch_sync(_databaseOperationQueue, ^{ + }); } @end From c5af832081f163dd970161865fd93484e1d05060 Mon Sep 17 00:00:00 2001 From: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Date: Tue, 4 Nov 2025 16:34:56 -0500 Subject: [PATCH 51/54] chore: Bump changwlogs for m173 (#15464) --- FirebaseRemoteConfig/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index 495dbaeb55e..4d4beeb2c21 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,4 +1,4 @@ -# Unreleased +# 12.6.0 - [fixed] Fixed a bug where Remote Config does not work after a restore of a previous backup of the device. (#14459) - [fixed] Fixed a data race condition on the global database status flag From dd4b95d5c90ca23629d48564ddb90d3a1115f687 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Wed, 5 Nov 2025 10:31:00 -0800 Subject: [PATCH 52/54] Run crashlytics nightly CI later (#15462) --- .github/workflows/crashlytics.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/crashlytics.yml b/.github/workflows/crashlytics.yml index dad83ff1202..d3f712b913e 100644 --- a/.github/workflows/crashlytics.yml +++ b/.github/workflows/crashlytics.yml @@ -18,8 +18,8 @@ on: - 'Interop/Analytics/Public/*.h' - 'Gemfile*' schedule: - # Run every day at 7pm (PDT) / 10pm (EDT) - cron uses UTC times - - cron: '0 2 * * *' + # Run every day at 11pm (PDT) / 2am (EDT) - cron uses UTC times + - cron: '0 6 * * *' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} From e1cb63bc6fb9fc140ec9e6e13c079276502b2927 Mon Sep 17 00:00:00 2001 From: Seyed Mojtaba Hosseini Zeidabadi Date: Mon, 10 Nov 2025 19:06:57 +0330 Subject: [PATCH 53/54] docs: Fix a bunch of typos (#15472) --- .../Sources/Types/Internal/Live/LiveSessionService.swift | 8 ++++---- scripts/repo/Sources/Util/Process.swift | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift index 6acb92a779a..1cc9f5e1be0 100644 --- a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift +++ b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift @@ -23,8 +23,8 @@ import Foundation /// Facilitates communication with the backend for a ``LiveSession``. /// /// Using an actor will make it easier to adopt session resumption, as we have an isolated place for -/// mainting mutablity, which is backed by Swift concurrency implicity; allowing us to avoid various -/// edge-case issues with dead-locks and data races. +/// maintaining mutablity, which is backed by Swift concurrency implicitly; allowing us to avoid +/// various edge-case issues with dead-locks and data races. /// /// This mainly comes into play when we don't want to block developers from sending messages while a /// session is being reloaded. @@ -54,7 +54,7 @@ actor LiveSessionService { private let jsonEncoder = JSONEncoder() private let jsonDecoder = JSONDecoder() - /// Long running task that that wraps around the websocket, propogating messages through the + /// Long running task that that wraps around the websocket, propagating messages through the /// public stream. private var responsesTask: Task? @@ -273,7 +273,7 @@ actor LiveSessionService { } } - /// Checks if an error should be propogated up, and maps it accordingly. + /// Checks if an error should be propagated up, and maps it accordingly. /// /// Some errors have public api alternatives. This function will ensure they're mapped /// accordingly. diff --git a/scripts/repo/Sources/Util/Process.swift b/scripts/repo/Sources/Util/Process.swift index 2959d5f0abb..0b5ca5bec9d 100755 --- a/scripts/repo/Sources/Util/Process.swift +++ b/scripts/repo/Sources/Util/Process.swift @@ -26,7 +26,7 @@ public extension Process { /// - env: A map of environment variables to set for the process. /// - inheritEnvironment: When enabled, the parent process' environvment will also be applied /// to this process. Effectively, this means that any environvment variables declared within the - /// parent process will propogate down to this new process. + /// parent process will propagate down to this new process. convenience init(_ exe: String, _ args: [String] = [], env: [String: String] = [:], @@ -59,7 +59,7 @@ public extension Process { /// Run the process with signals from the parent process. /// - /// The signals `SIGINT` and `SIGTERM` will both be propogated + /// The signals `SIGINT` and `SIGTERM` will both be propagated /// down to the process from the parent process. /// /// This function will not return until the process is done running. From ec7f92695598fa55f3ad08abf319c2b8d834a776 Mon Sep 17 00:00:00 2001 From: Seyed Mojtaba Hosseini Zeidabadi Date: Mon, 10 Nov 2025 19:42:03 +0330 Subject: [PATCH 54/54] fix: "mutability" throughout the code (#15473) --- FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift | 2 +- FirebasePerformance/Tests/Unit/FIRPerformanceTest.m | 2 +- FirebasePerformance/Tests/Unit/Instruments/FIRHTTPMetricTests.m | 2 +- FirebasePerformance/Tests/Unit/Timer/FIRTraceTest.m | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift index 1cc9f5e1be0..05ec6918cc5 100644 --- a/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift +++ b/FirebaseAI/Sources/Types/Internal/Live/LiveSessionService.swift @@ -23,7 +23,7 @@ import Foundation /// Facilitates communication with the backend for a ``LiveSession``. /// /// Using an actor will make it easier to adopt session resumption, as we have an isolated place for -/// maintaining mutablity, which is backed by Swift concurrency implicitly; allowing us to avoid +/// maintaining mutability, which is backed by Swift concurrency implicitly; allowing us to avoid /// various edge-case issues with dead-locks and data races. /// /// This mainly comes into play when we don't want to block developers from sending messages while a diff --git a/FirebasePerformance/Tests/Unit/FIRPerformanceTest.m b/FirebasePerformance/Tests/Unit/FIRPerformanceTest.m index 947311e4544..3dedf81bfc9 100644 --- a/FirebasePerformance/Tests/Unit/FIRPerformanceTest.m +++ b/FirebasePerformance/Tests/Unit/FIRPerformanceTest.m @@ -118,7 +118,7 @@ - (void)testReadingAttributesFromProperty { } /** Validates if attributes property is immutable. */ -- (void)testImmutablityOfAttributesProperty { +- (void)testImmutabilityOfAttributesProperty { [self.performance setValue:@"bar" forAttribute:@"foo"]; NSMutableDictionary *attributes = (NSMutableDictionary *)self.performance.attributes; diff --git a/FirebasePerformance/Tests/Unit/Instruments/FIRHTTPMetricTests.m b/FirebasePerformance/Tests/Unit/Instruments/FIRHTTPMetricTests.m index c837c477a44..e59e7c0e02a 100644 --- a/FirebasePerformance/Tests/Unit/Instruments/FIRHTTPMetricTests.m +++ b/FirebasePerformance/Tests/Unit/Instruments/FIRHTTPMetricTests.m @@ -283,7 +283,7 @@ - (void)testReadingAttributesFromProperty { } /** Validates if attributes property is immutable. */ -- (void)testImmutablityOfAttributesProperty { +- (void)testImmutabilityOfAttributesProperty { FIRHTTPMetric *metric = [[FIRHTTPMetric alloc] initWithURL:self.sampleURL HTTPMethod:FIRHTTPMethodGET]; [metric setValue:@"bar" forAttribute:@"foo"]; diff --git a/FirebasePerformance/Tests/Unit/Timer/FIRTraceTest.m b/FirebasePerformance/Tests/Unit/Timer/FIRTraceTest.m index 39a389fdb3b..f2c2608d9dc 100644 --- a/FirebasePerformance/Tests/Unit/Timer/FIRTraceTest.m +++ b/FirebasePerformance/Tests/Unit/Timer/FIRTraceTest.m @@ -612,7 +612,7 @@ - (void)testReadingAttributesFromProperty { } /** Validates if attributes property is immutable. */ -- (void)testImmutablityOfAttributesProperty { +- (void)testImmutabilityOfAttributesProperty { FIRTrace *trace = [[FIRTrace alloc] initWithName:@"Random"]; [trace setValue:@"bar" forAttribute:@"foo"]; NSMutableDictionary *attributes =