From 0b97b889f19d1182943cad6a6bcb8686907fe896 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 18 Aug 2025 16:28:51 -0400 Subject: [PATCH 01/14] [Firebase AI] Add `URLContext` tool --- FirebaseAI/Sources/Tool.swift | 32 ++++++++++++---- .../GenerateContentIntegrationTests.swift | 38 +++++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/FirebaseAI/Sources/Tool.swift b/FirebaseAI/Sources/Tool.swift index 78dc8ef9443..8bac61bae98 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -63,6 +63,11 @@ public struct GoogleSearch: Sendable { public init() {} } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct URLContext: Sendable { + public init() {} +} + /// A helper tool that the model may use when generating responses. /// /// A `Tool` is a piece of code that enables the system to interact with external systems to perform @@ -76,14 +81,17 @@ public struct Tool: Sendable { let googleSearch: GoogleSearch? let codeExecution: CodeExecution? - - init(functionDeclarations: [FunctionDeclaration]? = nil, - googleSearch: GoogleSearch? = nil, - codeExecution: CodeExecution? = nil) { - self.functionDeclarations = functionDeclarations - self.googleSearch = googleSearch - self.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 +} /// Creates a tool that allows the model to perform function calling. /// @@ -128,6 +136,11 @@ public struct Tool: Sendable { return self.init(googleSearch: googleSearch) } + + public static func urlContext(_ urlContext: URLContext = URLContext()) -> Tool { + return self.init(urlContext: urlContext) + } + /// Creates a tool that allows the model to execute code. /// /// For more details, see ``CodeExecution``. @@ -222,5 +235,8 @@ extension FunctionCallingConfig.Mode: Encodable {} @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension GoogleSearch: Encodable {} +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension URLContext: Encodable {} + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension ToolConfig: Encodable {} diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index d2fb589a432..29b25cc02e9 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -424,6 +424,44 @@ 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 groundingMetadata = try #require(candidate.groundingMetadata) + #expect(!groundingMetadata.groundingChunks.isEmpty) + #expect(!groundingMetadata.groundingSupports.isEmpty) + + for chunk in groundingMetadata.groundingChunks { + #expect(chunk.web != nil) + } + + for support in groundingMetadata.groundingSupports { + let segment = support.segment + #expect(segment.endIndex > segment.startIndex) + #expect(!segment.text.isEmpty) + #expect(!support.groundingChunkIndices.isEmpty) + + // Ensure indices point to valid chunks + for index in support.groundingChunkIndices { + #expect(index < groundingMetadata.groundingChunks.count) + } + } + } + @Test(arguments: InstanceConfig.allConfigs) func generateContent_codeExecution_succeeds(_ config: InstanceConfig) async throws { let model = FirebaseAI.componentInstance(config).generativeModel( From 85a152f78fd34752c0bcd641a5cf80187fd625b2 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 5 Sep 2025 18:08:14 -0400 Subject: [PATCH 02/14] Add `URLContextMetadata` to `Candidate` --- FirebaseAI/Sources/AILog.swift | 1 + .../Sources/GenerateContentResponse.swift | 16 +++- FirebaseAI/Sources/Tool.swift | 8 -- .../Types/Public/Tools/URLContext.swift | 18 ++++ .../Types/Public/URLContextMetadata.swift | 33 ++++++++ .../Sources/Types/Public/URLMetadata.swift | 84 +++++++++++++++++++ 6 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 FirebaseAI/Sources/Types/Public/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/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..ce3e3865585 100644 --- a/FirebaseAI/Sources/GenerateContentResponse.swift +++ b/FirebaseAI/Sources/GenerateContentResponse.swift @@ -154,14 +154,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. @@ -499,6 +504,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 +546,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 8bac61bae98..f5f8ed00d4a 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -63,11 +63,6 @@ public struct GoogleSearch: Sendable { public init() {} } -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -public struct URLContext: Sendable { - public init() {} -} - /// A helper tool that the model may use when generating responses. /// /// A `Tool` is a piece of code that enables the system to interact with external systems to perform @@ -235,8 +230,5 @@ extension FunctionCallingConfig.Mode: Encodable {} @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension GoogleSearch: Encodable {} -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension URLContext: Encodable {} - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension ToolConfig: Encodable {} diff --git a/FirebaseAI/Sources/Types/Public/Tools/URLContext.swift b/FirebaseAI/Sources/Types/Public/Tools/URLContext.swift new file mode 100644 index 00000000000..eba8a48fcba --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/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, *) +public 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..b3acea4f374 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/URLContextMetadata.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. + +/// Metadata related to the ``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 context. + public let urlMetadata: [URLMetadata] +} + +// MARK: - Codable Conformances + +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..d42c5b8e24f --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/URLMetadata.swift @@ -0,0 +1,84 @@ +// 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 + +/// Context of a single URL retrieval. +@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 - default value. + static let unspecified = URLRetrievalStatus(kind: .unspecified) + + /// URL retrieval succeeded. + public static let success = URLRetrievalStatus(kind: .success) + + /// URL retrieval failed due to an error. + public static let error = URLRetrievalStatus(kind: .error) + + // URL retrieval failed failed because the content is behind paywall. + public static let paywall = URLRetrievalStatus(kind: .paywall) + + // 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 URL retrieved by the ``URLContext`` tool. + public let retrievedURL: URL? + + /// The status of the URL retrieval. + public let retrievalStatus: URLRetrievalStatus +} + +// MARK: - Codable Conformances + +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) + ) + } +} From b1425eedaf1c7d1f4613812377e23068ba07f38d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 5 Sep 2025 18:20:10 -0400 Subject: [PATCH 03/14] Update integration test to check the `URLContextMetadata` --- .../GenerateContentIntegrationTests.swift | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 29b25cc02e9..747b1dc5bea 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -441,25 +441,14 @@ struct GenerateContentIntegrationTests { let response = try await model.generateContent(prompt) let candidate = try #require(response.candidates.first) - let groundingMetadata = try #require(candidate.groundingMetadata) - #expect(!groundingMetadata.groundingChunks.isEmpty) - #expect(!groundingMetadata.groundingSupports.isEmpty) - - for chunk in groundingMetadata.groundingChunks { - #expect(chunk.web != nil) - } - - for support in groundingMetadata.groundingSupports { - let segment = support.segment - #expect(segment.endIndex > segment.startIndex) - #expect(!segment.text.isEmpty) - #expect(!support.groundingChunkIndices.isEmpty) - - // Ensure indices point to valid chunks - for index in support.groundingChunkIndices { - #expect(index < groundingMetadata.groundingChunks.count) - } - } + 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) From 35287ab779bcedb17a5f1971ef5eaa39d783e103 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 5 Sep 2025 18:20:26 -0400 Subject: [PATCH 04/14] Update availability annotations --- FirebaseAI/Sources/Types/Public/URLContextMetadata.swift | 1 + FirebaseAI/Sources/Types/Public/URLMetadata.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift index b3acea4f374..3364def8963 100644 --- a/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift +++ b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift @@ -21,6 +21,7 @@ public struct URLContextMetadata: Sendable, Hashable { // 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 diff --git a/FirebaseAI/Sources/Types/Public/URLMetadata.swift b/FirebaseAI/Sources/Types/Public/URLMetadata.swift index d42c5b8e24f..6bd9dfbf56e 100644 --- a/FirebaseAI/Sources/Types/Public/URLMetadata.swift +++ b/FirebaseAI/Sources/Types/Public/URLMetadata.swift @@ -58,6 +58,7 @@ public struct URLMetadata: Sendable, Hashable { // 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" From d5fcfa7f90b1735b606dccf1f8505490e2954731 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 5 Sep 2025 20:11:55 -0400 Subject: [PATCH 05/14] Fix DocC formatting --- FirebaseAI/Sources/Types/Public/URLMetadata.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/URLMetadata.swift b/FirebaseAI/Sources/Types/Public/URLMetadata.swift index 6bd9dfbf56e..960541aebe1 100644 --- a/FirebaseAI/Sources/Types/Public/URLMetadata.swift +++ b/FirebaseAI/Sources/Types/Public/URLMetadata.swift @@ -36,10 +36,10 @@ public struct URLMetadata: Sendable, Hashable { /// URL retrieval failed due to an error. public static let error = URLRetrievalStatus(kind: .error) - // URL retrieval failed failed because the content is behind paywall. + /// URL retrieval failed failed because the content is behind paywall. public static let paywall = URLRetrievalStatus(kind: .paywall) - // URL retrieval failed because the content is unsafe. + /// URL retrieval failed because the content is unsafe. public static let unsafe = URLRetrievalStatus(kind: .unsafe) /// Returns the raw string representation of the `URLRetrievalStatus` value. From b86db5655d3523f7d41e1f3aae9e52c6e993c295 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 5 Sep 2025 21:32:48 -0400 Subject: [PATCH 06/14] Add `toolUsePromptTokenCount` and `toolUsePromptTokensDetails` --- FirebaseAI/Sources/GenerateContentResponse.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/FirebaseAI/Sources/GenerateContentResponse.swift b/FirebaseAI/Sources/GenerateContentResponse.swift index ce3e3865585..c4400795e14 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 present in tool-use prompt(s). + 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 @@ -44,6 +47,9 @@ public struct GenerateContentResponse: Sendable { /// The breakdown, by modality, of how many tokens are consumed by the candidates public let candidatesTokensDetails: [ModalityTokenCount] + + /// List of modalities that were processed for tool-use request inputs. + public let toolUsePromptTokensDetails: [ModalityTokenCount] } /// A list of candidate response content, ordered from best to worst. @@ -474,10 +480,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 { @@ -485,6 +493,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 = @@ -493,6 +503,9 @@ extension GenerateContentResponse.UsageMetadata: Decodable { [ModalityTokenCount].self, forKey: .candidatesTokensDetails ) ?? [] + toolUsePromptTokensDetails = try container.decodeIfPresent( + [ModalityTokenCount].self, forKey: .toolUsePromptTokensDetails + ) ?? [] } } From 0d41dc303b9451144fecf2ed91bd260620fecec0 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 5 Sep 2025 21:48:34 -0400 Subject: [PATCH 07/14] Add `toolUsePromptTokenCount` and `toolUsePromptTokensDetails` tests --- FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift | 2 ++ FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift index c6335142959..dedd1223adb 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift @@ -333,6 +333,8 @@ 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_failure_invalidAPIKey() async throws { diff --git a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift index 847f5a8e643..304545ca5f9 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift @@ -487,6 +487,8 @@ 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_image_invalidSafetyRatingsIgnored() async throws { From 23818a85b97d4494790abf5785fc11f11d93ecf3 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Mon, 15 Sep 2025 18:19:07 -0700 Subject: [PATCH 08/14] Fixes after rebasing --- FirebaseAI/Sources/Tool.swift | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/FirebaseAI/Sources/Tool.swift b/FirebaseAI/Sources/Tool.swift index f5f8ed00d4a..e19b98997bb 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -78,15 +78,15 @@ public struct Tool: Sendable { 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 -} + 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 + } /// Creates a tool that allows the model to perform function calling. /// @@ -131,9 +131,8 @@ self.codeExecution = codeExecution return self.init(googleSearch: googleSearch) } - - public static func urlContext(_ urlContext: URLContext = URLContext()) -> Tool { - return self.init(urlContext: urlContext) + public static func urlContext() -> Tool { + return self.init(urlContext: URLContext()) } /// Creates a tool that allows the model to execute code. From 2cef56e8d0dc07c413e2b3999a1abcad33a9e701 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 16 Sep 2025 23:09:37 -0400 Subject: [PATCH 09/14] [Firebase AI] Make `URLContext` struct internal (#15335) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- .../Sources/Types/{Public => Internal}/Tools/URLContext.swift | 2 +- FirebaseAI/Sources/Types/Public/URLContextMetadata.swift | 2 +- FirebaseAI/Sources/Types/Public/URLMetadata.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename FirebaseAI/Sources/Types/{Public => Internal}/Tools/URLContext.swift (93%) diff --git a/FirebaseAI/Sources/Types/Public/Tools/URLContext.swift b/FirebaseAI/Sources/Types/Internal/Tools/URLContext.swift similarity index 93% rename from FirebaseAI/Sources/Types/Public/Tools/URLContext.swift rename to FirebaseAI/Sources/Types/Internal/Tools/URLContext.swift index eba8a48fcba..2033bb940f1 100644 --- a/FirebaseAI/Sources/Types/Public/Tools/URLContext.swift +++ b/FirebaseAI/Sources/Types/Internal/Tools/URLContext.swift @@ -13,6 +13,6 @@ // limitations under the License. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -public struct URLContext: Sendable, Encodable { +struct URLContext: Sendable, Encodable { init() {} } diff --git a/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift index 3364def8963..d3c87887ab6 100644 --- a/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift +++ b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// Metadata related to the ``URLContext`` tool. +/// 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 context. diff --git a/FirebaseAI/Sources/Types/Public/URLMetadata.swift b/FirebaseAI/Sources/Types/Public/URLMetadata.swift index 960541aebe1..1e42759b909 100644 --- a/FirebaseAI/Sources/Types/Public/URLMetadata.swift +++ b/FirebaseAI/Sources/Types/Public/URLMetadata.swift @@ -49,7 +49,7 @@ public struct URLMetadata: Sendable, Hashable { AILog.MessageCode.urlMetadataUnrecognizedURLRetrievalStatus } - /// The URL retrieved by the ``URLContext`` tool. + /// The URL retrieved by the ``Tool/urlContext()`` tool. public let retrievedURL: URL? /// The status of the URL retrieval. From 1fe4d8852f67adb426d8f9ae834f8061bc7c8823 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Fri, 19 Sep 2025 13:50:28 -0700 Subject: [PATCH 10/14] [AI] API docs for URLContext (#15341) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- FirebaseAI/Sources/GenerateContentResponse.swift | 7 ++++--- FirebaseAI/Sources/Tool.swift | 5 +++++ .../Sources/Types/Public/URLContextMetadata.swift | 2 +- FirebaseAI/Sources/Types/Public/URLMetadata.swift | 14 +++++++------- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/FirebaseAI/Sources/GenerateContentResponse.swift b/FirebaseAI/Sources/GenerateContentResponse.swift index c4400795e14..c13d3d53c8a 100644 --- a/FirebaseAI/Sources/GenerateContentResponse.swift +++ b/FirebaseAI/Sources/GenerateContentResponse.swift @@ -26,7 +26,7 @@ public struct GenerateContentResponse: Sendable { /// The total number of tokens across the generated response candidates. public let candidatesTokenCount: Int - /// The number of tokens present in tool-use prompt(s). + /// The number of tokens used by tools. public let toolUsePromptTokenCount: Int /// The number of tokens used by the model's internal "thinking" process. @@ -42,13 +42,14 @@ 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] - /// List of modalities that were processed for tool-use request inputs. + /// The breakdown, by modality, of how many tokens were consumed by the tools used to process + /// the request. public let toolUsePromptTokensDetails: [ModalityTokenCount] } diff --git a/FirebaseAI/Sources/Tool.swift b/FirebaseAI/Sources/Tool.swift index e19b98997bb..53e0ee8b49e 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -131,6 +131,11 @@ 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()) } diff --git a/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift index d3c87887ab6..5ff671f68eb 100644 --- a/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift +++ b/FirebaseAI/Sources/Types/Public/URLContextMetadata.swift @@ -15,7 +15,7 @@ /// 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 context. + /// List of URL metadata used to provide context to the Gemini model. public let urlMetadata: [URLMetadata] } diff --git a/FirebaseAI/Sources/Types/Public/URLMetadata.swift b/FirebaseAI/Sources/Types/Public/URLMetadata.swift index 1e42759b909..50ee16a7e86 100644 --- a/FirebaseAI/Sources/Types/Public/URLMetadata.swift +++ b/FirebaseAI/Sources/Types/Public/URLMetadata.swift @@ -14,7 +14,7 @@ import Foundation -/// Context of a single URL retrieval. +/// 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. @@ -27,19 +27,19 @@ public struct URLMetadata: Sendable, Hashable { case unsafe = "URL_RETRIEVAL_STATUS_UNSAFE" } - /// Internal only - default value. + /// Internal only - Unspecified retrieval status. static let unspecified = URLRetrievalStatus(kind: .unspecified) - /// URL retrieval succeeded. + /// The URL retrieval was successful. public static let success = URLRetrievalStatus(kind: .success) - /// URL retrieval failed due to an error. + /// The URL retrieval failed. public static let error = URLRetrievalStatus(kind: .error) - /// URL retrieval failed failed because the content is behind paywall. + /// The URL retrieval failed because the content is behind a paywall. public static let paywall = URLRetrievalStatus(kind: .paywall) - /// URL retrieval failed because the content is unsafe. + /// 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. @@ -49,7 +49,7 @@ public struct URLMetadata: Sendable, Hashable { AILog.MessageCode.urlMetadataUnrecognizedURLRetrievalStatus } - /// The URL retrieved by the ``Tool/urlContext()`` tool. + /// The retrieved URL. public let retrievedURL: URL? /// The status of the URL retrieval. From e02fd2962bb44c6d739df90db963e37cbc0b5251 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Mon, 22 Sep 2025 14:03:48 -0700 Subject: [PATCH 11/14] [AI] Unit tests for urlContext APIs (#15343) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Andrew Heard --- .../Unit/GenerativeModelGoogleAITests.swift | 70 +++++++++++++++ .../Unit/GenerativeModelVertexAITests.swift | 88 +++++++++++++++++++ FirebaseAI/Tests/Unit/MockURLProtocol.swift | 8 +- .../Types/GenerateContentResponseTests.swift | 51 +++++++++++ 4 files changed, 214 insertions(+), 3 deletions(-) diff --git a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift index dedd1223adb..59e1581a638 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift @@ -337,6 +337,53 @@ final class GenerativeModelGoogleAITests: XCTestCase { 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 { let expectedStatusCode = 400 MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( @@ -644,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 304545ca5f9..1d2498f07e5 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift @@ -491,6 +491,71 @@ final class GenerativeModelVertexAITests: XCTestCase { 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 { MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-image-invalid-safety-ratings", @@ -1720,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 a6e0ce8dbc314fc39405eb3bb77b699586c3dc66 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Mon, 22 Sep 2025 14:07:23 -0700 Subject: [PATCH 12/14] Add changelog --- FirebaseAI/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index d1a00c824c3..aaa79b00648 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,4 +1,5 @@ # Unreleased +- [feature] Added support for Gemini's URL context tool. (#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). From f9b050428351d24ce63d67ce69820248c63f6527 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Mon, 22 Sep 2025 16:00:01 -0700 Subject: [PATCH 13/14] More explanatory changelog --- FirebaseAI/CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index aaa79b00648..4ca28a6f2c1 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased -- [feature] Added support for Gemini's URL context tool. (#15221) +- [feature] Added support for the URLContext tool, allowing developers to + provide public web URLs as additional context to Gemini models for more + informed 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). From 0d9d3e9dac41cb6051970dd7163af2255bdfdc3c Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Tue, 23 Sep 2025 16:16:12 -0700 Subject: [PATCH 14/14] One more changelog tweak --- FirebaseAI/CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index 4ca28a6f2c1..8c0a0068fde 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,7 +1,6 @@ # Unreleased -- [feature] Added support for the URLContext tool, allowing developers to - provide public web URLs as additional context to Gemini models for more - informed responses. (#15221) +- [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).