From f6cd7573ba5c3dbe45ba39b32737ccff3d4c0066 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Fri, 20 Jun 2025 14:11:16 -0400 Subject: [PATCH 01/12] [FirebaseAI] Add support for Grounding with Google Search --- FirebaseAI/Sources/FunctionCalling.swift | 39 +++ .../Sources/GenerateContentResponse.swift | 230 +++++++++++++++++- .../GenerateContentIntegrationTests.swift | 51 ++++ .../Unit/GenerativeModelGoogleAITests.swift | 62 +++++ .../Unit/GenerativeModelVertexAITests.swift | 52 ++++ .../Unit/Types/GroundingMetadataTests.swift | 100 ++++++++ FirebaseAI/Tests/Unit/Types/ToolTests.swift | 51 ++++ 7 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift create mode 100644 FirebaseAI/Tests/Unit/Types/ToolTests.swift diff --git a/FirebaseAI/Sources/FunctionCalling.swift b/FirebaseAI/Sources/FunctionCalling.swift index e85e05f5865..5f59970ceee 100644 --- a/FirebaseAI/Sources/FunctionCalling.swift +++ b/FirebaseAI/Sources/FunctionCalling.swift @@ -50,6 +50,17 @@ public struct FunctionDeclaration: Sendable { } } +/// A tool that allows the generative model to connect to Google Search to access and incorporate +/// up-to-date information from the web into its responses. +/// +/// When this tool is used, the model's responses may include "Grounded Results" which are subject +/// to the Grounding with Google Search terms outlined in the +/// [Service Specific Terms](https://cloud.google.com/terms/service-terms). +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct GoogleSearch: 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 @@ -58,9 +69,16 @@ public struct FunctionDeclaration: Sendable { public struct Tool: Sendable { /// A list of `FunctionDeclarations` available to the model. let functionDeclarations: [FunctionDeclaration]? + let googleSearch: GoogleSearch? init(functionDeclarations: [FunctionDeclaration]?) { self.functionDeclarations = functionDeclarations + googleSearch = nil + } + + init(googleSearch: GoogleSearch) { + self.googleSearch = googleSearch + functionDeclarations = nil } /// Creates a tool that allows the model to perform function calling. @@ -85,6 +103,24 @@ public struct Tool: Sendable { public static func functionDeclarations(_ functionDeclarations: [FunctionDeclaration]) -> Tool { return self.init(functionDeclarations: functionDeclarations) } + + /// Creates a tool that allows the model to use Grounding with Google Search. + /// + /// Grounding with Google Search can be used to allow the model to connect to Google Search to + /// access and incorporate up-to-date information from the web into it's responses. + /// + /// When this tool is used, the model's responses may include "Grounded Results" which are subject + /// to the Grounding with Google Search terms outlined in the [Service Specific + /// Terms](https://cloud.google.com/terms/service-terms). + /// + /// - Parameters: + /// - googleSearch: An empty ``GoogleSearch`` object. The presence of this object in the list + /// of tools enables the model to use Google Search. + /// + /// - Returns: A `Tool` configured for Google Search. + public static func googleSearch(_ googleSearch: GoogleSearch = GoogleSearch()) -> Tool { + return self.init(googleSearch: googleSearch) + } } /// Configuration for specifying function calling behavior. @@ -170,5 +206,8 @@ extension FunctionCallingConfig: Encodable {} @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) 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 ToolConfig: Encodable {} diff --git a/FirebaseAI/Sources/GenerateContentResponse.swift b/FirebaseAI/Sources/GenerateContentResponse.swift index cb212e5a616..6da3dfd8bd3 100644 --- a/FirebaseAI/Sources/GenerateContentResponse.swift +++ b/FirebaseAI/Sources/GenerateContentResponse.swift @@ -136,13 +136,16 @@ public struct Candidate: Sendable { /// Cited works in the model's response content, if it exists. public let citationMetadata: CitationMetadata? + public let groundingMetadata: GroundingMetadata? + /// Initializer for SwiftUI previews or tests. public init(content: ModelContent, safetyRatings: [SafetyRating], finishReason: FinishReason?, - citationMetadata: CitationMetadata?) { + citationMetadata: CitationMetadata?, groundingMetadata: GroundingMetadata? = nil) { self.content = content self.safetyRatings = safetyRatings self.finishReason = finishReason self.citationMetadata = citationMetadata + self.groundingMetadata = groundingMetadata } } @@ -299,6 +302,123 @@ public struct PromptFeedback: Sendable { } } +/// Metadata returned to the client when grounding is enabled. +/// +/// > Important: If using Grounding with Google Search, you are required to comply with the [Service +/// Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with Google +/// Search*. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct GroundingMetadata: Sendable { + /// A list of web search queries that the model performed to gather the grounding information. + /// These can be used to allow users to explore the search results themselves. + /// + /// > Important: If using Grounding with Google Search, you are required to comply with the + /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with + /// Google Search*. + public let webSearchQueries: [String] + /// A list of ``GroundingChunk`` structs. Each chunk represents a piece of retrieved content + /// (e.g., from a web page) that the model used to ground its response. + /// + /// > Important: If using Grounding with Google Search, you are required to comply with the + /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with + /// Google Search*. + public let groundingChunks: [GroundingChunk] + /// A list of ``GroundingSupport`` structs. Each object details how specific segments of the + /// model's response are supported by the `groundingChunks`. + /// + /// > Important: If using Grounding with Google Search, you are required to comply with the + /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with + /// Google Search*. + public let groundingSupports: [GroundingSupport] + /// Google search entry point for web searches. + /// This contains an HTML/CSS snippet that **must** be embedded in an app to display a Google + /// Search Entry point for follow-up web searches related to the model's "Grounded Response". + /// + /// > Important: If using Grounding with Google Search, you are required to comply with the + /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with + /// Google Search*. + public let searchEntryPoint: SearchEntryPoint? + + /// A struct representing the Google Search entry point. + /// + /// > Important: If using Grounding with Google Search, you are required to comply with the + /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with + /// Google Search*. + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + public struct SearchEntryPoint: Sendable { + /// An HTML/CSS snippet that can be embedded in your app. The snippet is designed to avoid + /// undesired interaction with the rest of the page's CSS. + /// + /// To ensure proper rendering, it's recommended to display this content within a `WKWebView`. + public let renderedContent: String? + } + + /// Represents a chunk of retrieved data that supports a claim in the model's response. This is + /// part + /// of the grounding information provided when grounding is enabled. + /// + /// > Important: If using Grounding with Google Search, you are required to comply with the + /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with + /// Google Search*. + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + public struct GroundingChunk: Sendable { + /// Contains details if the grounding chunk is from a web source. + public let web: WebGroundingChunk? + } + + /// A grounding chunk sourced from the web. + /// + /// > Important: If using Grounding with Google Search, you are required to comply with the + /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with + /// Google Search*. + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + public struct WebGroundingChunk: Sendable { + /// The URI of the retrieved web page. + public let uri: String? + /// The title of the retrieved web page. + public let title: String? + /// The domain of the original URI from which the content was retrieved (e.g., `example.com`). + /// + /// This field is only populated when using the Vertex AI Gemini API. + public let domain: String? + } + + /// Provides information about how a specific segment of the model's response is supported by the + /// retrieved grounding chunks. + /// + /// > Important: If using Grounding with Google Search, you are required to comply with the + /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with + /// Google Search*. + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + public struct GroundingSupport: Sendable { + /// Specifies the segment of the model's response content that this grounding support pertains + /// to. + public let segment: Segment? + + /// A list of indices that refer to specific ``GroundingChunk`` structs within the + /// ``GroundingMetadata/groundingChunks`` array. These referenced chunks are the sources that + /// support the claim made in the associated `segment` of the response. + public let groundingChunkIndices: [Int] + } +} + +/// Represents a specific segment within a ``ModelContent`` struct, often used to pinpoint the +/// exact location of text or data that grounding information refers to. +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct Segment: Sendable { + /// The zero-based index of the ``Part`` object within the `parts` array of its parent + /// ``ModelContent`` object. This identifies which part of the content the segment belongs to. + public let partIndex: Int + /// The zero-based start index of the segment within the specified ``Part``, measured in UTF-8 + /// bytes. This offset is inclusive, starting from 0 at the beginning of the part's content. + public let startIndex: Int + /// The zero-based end index of the segment within the specified ``Part``, measured in UTF-8 + /// bytes. This offset is exclusive. + public let endIndex: Int + /// The text content of the segment. + public let text: String +} + // MARK: - Codable Conformances @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) @@ -369,6 +489,7 @@ extension Candidate: Decodable { case safetyRatings case finishReason case citationMetadata + case groundingMetadata } /// Initializes a response from a decoder. Used for decoding server responses; not for public @@ -414,6 +535,11 @@ extension Candidate: Decodable { CitationMetadata.self, forKey: .citationMetadata ) + + groundingMetadata = try container.decodeIfPresent( + GroundingMetadata.self, + forKey: .groundingMetadata + ) } } @@ -513,3 +639,105 @@ extension PromptFeedback: Decodable { } } } + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension GroundingMetadata: Decodable { + enum CodingKeys: String, CodingKey { + case webSearchQueries + case groundingChunks + case groundingSupports + case searchEntryPoint + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + webSearchQueries = try container.decodeIfPresent([String].self, forKey: .webSearchQueries) ?? [] + groundingChunks = try container.decodeIfPresent( + [GroundingChunk].self, + forKey: .groundingChunks + ) ?? [] + groundingSupports = try container.decodeIfPresent( + [GroundingSupport].self, + forKey: .groundingSupports + ) ?? [] + searchEntryPoint = try container.decodeIfPresent( + SearchEntryPoint.self, + forKey: .searchEntryPoint + ) + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension GroundingMetadata.SearchEntryPoint: Decodable { + enum CodingKeys: String, CodingKey { + case renderedContent + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + renderedContent = try container.decodeIfPresent(String.self, forKey: .renderedContent) + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension GroundingMetadata.GroundingChunk: Decodable { + enum CodingKeys: String, CodingKey { + case web + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + web = try container.decodeIfPresent(GroundingMetadata.WebGroundingChunk.self, forKey: .web) + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension GroundingMetadata.WebGroundingChunk: Decodable { + enum CodingKeys: String, CodingKey { + case uri + case title + case domain + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + uri = try container.decodeIfPresent(String.self, forKey: .uri) + title = try container.decodeIfPresent(String.self, forKey: .title) + domain = try container.decodeIfPresent(String.self, forKey: .domain) + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension GroundingMetadata.GroundingSupport: Decodable { + enum CodingKeys: String, CodingKey { + case segment + case groundingChunkIndices + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + segment = try container.decodeIfPresent(Segment.self, forKey: .segment) + groundingChunkIndices = try container.decodeIfPresent( + [Int].self, + forKey: .groundingChunkIndices + ) ?? [] + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension Segment: Decodable { + enum CodingKeys: String, CodingKey { + case partIndex + case startIndex + case endIndex + case text + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + partIndex = try container.decodeIfPresent(Int.self, forKey: .partIndex) ?? 0 + startIndex = try container.decodeIfPresent(Int.self, forKey: .startIndex) ?? 0 + endIndex = try container.decodeIfPresent(Int.self, forKey: .endIndex) ?? 0 + text = try container.decodeIfPresent(String.self, forKey: .text) ?? "" + } +} diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 5a63ca41a6d..b80615b3a83 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -247,6 +247,57 @@ struct GenerateContentIntegrationTests { #endif // canImport(UIKit) } + @Test( + "generateContent with Google Search returns grounding metadata", + arguments: [ + (InstanceConfig.vertexAI_v1, ModelNames.gemini2FlashLite), + (InstanceConfig.vertexAI_v1_staging, ModelNames.gemini2FlashLite), + (InstanceConfig.vertexAI_v1beta, ModelNames.gemini2FlashLite), + (InstanceConfig.vertexAI_v1beta_staging, ModelNames.gemini2FlashLite), + (InstanceConfig.googleAI_v1beta, ModelNames.gemini2FlashLite), + (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemini2FlashLite), + (InstanceConfig.googleAI_v1_freeTier_bypassProxy, ModelNames.gemini2FlashLite), + (InstanceConfig.googleAI_v1beta_freeTier_bypassProxy, ModelNames.gemini2FlashLite), + ] + ) + func generateContent_withGoogleSearch_succeeds(_ config: InstanceConfig, + modelName: String) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: modelName, + generationConfig: generationConfig, + safetySettings: safetySettings, + tools: [.googleSearch()] + ) + let prompt = "What is the weather in Toronto today?" + + let response = try await model.generateContent(prompt) + + let candidate = try #require(response.candidates.first) + let groundingMetadata = try #require(candidate.groundingMetadata) + let searchEntrypoint = try #require(groundingMetadata.searchEntrypoint) + + #expect(!groundingMetadata.webSearchQueries.isEmpty) + #expect(searchEntrypoint.renderedContent != nil) + #expect(!groundingMetadata.groundingChunks.isEmpty) + #expect(!groundingMetadata.groundingSupports.isEmpty) + + for chunk in groundingMetadata.groundingChunks { + #expect(chunk.web != nil) + } + + for support in groundingMetadata.groundingSupports { + let segment = try #require(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) + } + } + } + // MARK: Streaming Tests @Test(arguments: [ diff --git a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift index 130dc47b8b4..c7363afee78 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift @@ -198,6 +198,68 @@ final class GenerativeModelGoogleAITests: XCTestCase { XCTAssertEqual(usageMetadata.candidatesTokensDetails[0].tokenCount, 22) } + func testGenerateContent_groundingMetadata() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-google-search-grounding", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + let groundingMetadata = try XCTUnwrap(candidate.groundingMetadata) + + XCTAssertEqual(groundingMetadata.webSearchQueries, ["current weather in London"]) + XCTAssertNotNil(groundingMetadata.searchEntryPoint) + XCTAssertNotNil(groundingMetadata.searchEntryPoint?.renderedContent) + + XCTAssertEqual(groundingMetadata.groundingChunks.count, 2) + let firstChunk = try XCTUnwrap(groundingMetadata.groundingChunks.first?.web) + XCTAssertEqual(firstChunk.title, "accuweather.com") + XCTAssertNotNil(firstChunk.uri) + XCTAssertNil(firstChunk.domain) // Domain is not supported by Google AI backend + + XCTAssertEqual(groundingMetadata.groundingSupports.count, 3) + let firstSupport = try XCTUnwrap(groundingMetadata.groundingSupports.first) + let segment = try XCTUnwrap(firstSupport.segment) + XCTAssertEqual(segment.text, "The current weather in London, United Kingdom is cloudy.") + XCTAssertEqual(segment.startIndex, 0) + XCTAssertEqual(segment.partIndex, 0) + XCTAssertEqual(segment.endIndex, 56) + XCTAssertEqual(firstSupport.groundingChunkIndices, [0]) + } + + // This test case can be deleted once https://b.corp.google.com/issues/422779395 (internal) is + // fixed. + func testGenerateContent_groundingMetadata_emptyGroundingChunks() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-google-search-grounding-empty-grounding-chunks", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + let groundingMetadata = try XCTUnwrap(candidate.groundingMetadata) + XCTAssertNotNil(groundingMetadata.searchEntryPoint?.renderedContent) + XCTAssertEqual(groundingMetadata.webSearchQueries, ["current weather London"]) + + // Chunks exist, but contain no web information. + XCTAssertEqual(groundingMetadata.groundingChunks.count, 2) + XCTAssertNil(groundingMetadata.groundingChunks[0].web) + XCTAssertNil(groundingMetadata.groundingChunks[1].web) + + XCTAssertEqual(groundingMetadata.groundingSupports.count, 1) + let support = try XCTUnwrap(groundingMetadata.groundingSupports.first) + XCTAssertEqual(support.groundingChunkIndices, [0]) + let segment = try XCTUnwrap(support.segment) + XCTAssertEqual(segment.text, "There is a 0% chance of rain and the humidity is around 41%.") + } + func testGenerateContent_failure_invalidAPIKey() async throws { let expectedStatusCode = 400 MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( diff --git a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift index 75a15376636..443c87c149a 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift @@ -619,6 +619,58 @@ final class GenerativeModelVertexAITests: XCTestCase { XCTAssertEqual(usageMetadata.candidatesTokensDetails.isEmpty, true) } + func testGenerateContent_groundingMetadata() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-google-search-grounding", + withExtension: "json", + subdirectory: vertexSubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + let groundingMetadata = try XCTUnwrap(candidate.groundingMetadata) + + XCTAssertEqual(groundingMetadata.webSearchQueries, ["current weather in London"]) + XCTAssertNotNil(groundingMetadata.searchEntryPoint) + XCTAssertNotNil(groundingMetadata.searchEntryPoint?.renderedContent) + + XCTAssertEqual(groundingMetadata.groundingChunks.count, 2) + let firstChunk = try XCTUnwrap(groundingMetadata.groundingChunks.first?.web) + XCTAssertEqual(firstChunk.title, "accuweather.com") + XCTAssertNotNil(firstChunk.uri) + XCTAssertNil(firstChunk.domain) // Domain is not supported by Google AI backend + + XCTAssertEqual(groundingMetadata.groundingSupports.count, 3) + let firstSupport = try XCTUnwrap(groundingMetadata.groundingSupports.first) + let segment = try XCTUnwrap(firstSupport.segment) + XCTAssertEqual(segment.text, "The current weather in London, United Kingdom is cloudy.") + XCTAssertEqual(segment.startIndex, 0) + XCTAssertEqual(segment.partIndex, 0) + XCTAssertEqual(segment.endIndex, 56) + XCTAssertEqual(firstSupport.groundingChunkIndices, [0]) + } + + func testGenerateContent_withGoogleSearchTool() async throws { + let model = GenerativeModel( + modelName: testModelName, + modelResourceName: testModelResourceName, + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(), + apiConfig: apiConfig, + tools: [.googleSearch()], + requestOptions: RequestOptions(), + urlSession: urlSession + ) + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-basic-reply-short", + withExtension: "json", + subdirectory: vertexSubdirectory + ) + + _ = try await model.generateContent(testPrompt) + } + func testGenerateContent_failure_invalidAPIKey() async throws { let expectedStatusCode = 400 MockURLProtocol diff --git a/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift b/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift new file mode 100644 index 00000000000..1dca2e04334 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift @@ -0,0 +1,100 @@ +// 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 + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class GroundingMetadataTests: XCTestCase { + let decoder = JSONDecoder() + + func testDecodeGroundingMetadata_allFields() throws { + let json = """ + { + "webSearchQueries": ["query1", "query2"], + "groundingChunks": [ + { "web": { "uri": "uri1", "title": "title1" } } + ], + "groundingSupports": [ + { "segment": { "endIndex": 10, "text": "text" }, "groundingChunkIndices": [0] } + ], + "searchEntryPoint": { "renderedContent": "html" } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let metadata = try decoder.decode(GroundingMetadata.self, from: jsonData) + + XCTAssertEqual(metadata.webSearchQueries, ["query1", "query2"]) + XCTAssertEqual(metadata.groundingChunks.count, 1) + XCTAssertEqual(metadata.groundingChunks.first?.web?.uri, "uri1") + XCTAssertEqual(metadata.groundingSupports.count, 1) + XCTAssertEqual(metadata.groundingSupports.first?.segment?.startIndex, 0) + XCTAssertEqual(metadata.groundingSupports.first?.segment?.partIndex, 0) + XCTAssertEqual(metadata.groundingSupports.first?.segment?.endIndex, 10) + XCTAssertEqual(metadata.groundingSupports.first?.segment?.text, "text") + XCTAssertEqual(metadata.searchEntryPoint?.renderedContent, "html") + } + + func testDecodeGroundingMetadata_missingOptionals() throws { + let json = "{}" + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let metadata = try decoder.decode(GroundingMetadata.self, from: jsonData) + + XCTAssertTrue(metadata.webSearchQueries.isEmpty) + XCTAssertTrue(metadata.groundingChunks.isEmpty) + XCTAssertTrue(metadata.groundingSupports.isEmpty) + XCTAssertNil(metadata.searchEntryPoint) + } + + func testDecodeGroundingChunk_withoutWeb() throws { + let json = "{}" + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + let chunk = try decoder.decode(GroundingMetadata.GroundingChunk.self, from: jsonData) + XCTAssertNil(chunk.web) + } + + func testDecodeWebGroundingChunk_withDomain() throws { + let json = """ + { "uri": "uri1", "title": "title1", "domain": "example.com" } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + let webChunk = try decoder.decode(GroundingMetadata.WebGroundingChunk.self, from: jsonData) + XCTAssertEqual(webChunk.uri, "uri1") + XCTAssertEqual(webChunk.title, "title1") + XCTAssertEqual(webChunk.domain, "example.com") + } + + func testDecodeGroundingSupport_withoutSegment() throws { + let json = """ + { "groundingChunkIndices": [1, 2] } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + let support = try decoder.decode(GroundingMetadata.GroundingSupport.self, from: jsonData) + XCTAssertNil(support.segment) + XCTAssertEqual(support.groundingChunkIndices, [1, 2]) + } + + func testDecodeSegment_defaults() throws { + let json = "{}" + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + let segment = try decoder.decode(Segment.self, from: jsonData) + XCTAssertEqual(segment.partIndex, 0) + XCTAssertEqual(segment.startIndex, 0) + XCTAssertEqual(segment.endIndex, 0) + XCTAssertEqual(segment.text, "") + } +} diff --git a/FirebaseAI/Tests/Unit/Types/ToolTests.swift b/FirebaseAI/Tests/Unit/Types/ToolTests.swift new file mode 100644 index 00000000000..5e0a847971c --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/ToolTests.swift @@ -0,0 +1,51 @@ +// 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 + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class ToolTests: XCTestCase { + let encoder = JSONEncoder() + + override func setUp() { + super.setUp() + encoder.outputFormatting = .sortedKeys + } + + func testEncodeTool_googleSearch() throws { + let tool = Tool.googleSearch() + let jsonData = try encoder.encode(tool) + let jsonString = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(jsonString, "{\"googleSearch\":{}}") + } + + func testEncodeTool_functionDeclarations() throws { + let functionDecl = FunctionDeclaration( + name: "test_function", + description: "A test function.", + parameters: ["param1": .string()] + ) + let tool = Tool.functionDeclarations([functionDecl]) + + encoder.outputFormatting.insert(.withoutEscapingSlashes) + let jsonData = try encoder.encode(tool) + let jsonString = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + + XCTAssertEqual(jsonString, """ + {"functionDeclarations":[{"description":"A test function.","name":"test_function","parameters":{"nullable":false,"properties":{"param1":{"nullable":false,"type":"STRING"}},"required":["param1"],"type":"OBJECT"}}]} + """) + } +} From 8e76a6b6709a061b2e77fc088d1b4e534b1e24ce Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Fri, 20 Jun 2025 14:19:51 -0400 Subject: [PATCH 02/12] Add changelog entry --- FirebaseAI/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index ff5db6078e7..60ad46f7a1f 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,3 +1,7 @@ +# 11.16.0 + +- [added] Added support for Grounding with Google Search. (#15014) + # 11.15.0 - [fixed] Fixed `Sendable` warnings introduced in the Xcode 26 beta. (#14947) - [added] Added support for setting `title` in string, number and array `Schema` From 5e5be35c43d1b4dba43b930492c3e57247608cf5 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Fri, 20 Jun 2025 14:26:18 -0400 Subject: [PATCH 03/12] update changelog --- FirebaseAI/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index 60ad46f7a1f..fa0b803777f 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,5 +1,4 @@ -# 11.16.0 - +# Unreleased - [added] Added support for Grounding with Google Search. (#15014) # 11.15.0 From bb421919076c35487d4c422a6ed2c1077a57201d Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Fri, 20 Jun 2025 14:57:22 -0400 Subject: [PATCH 04/12] Rename FunctionCalling.swift to Tool.swift and remove v1 integration --- .../{FunctionCalling.swift => Tool.swift} | 4 ++-- .../GenerateContentIntegrationTests.swift | 16 +++------------- 2 files changed, 5 insertions(+), 15 deletions(-) rename FirebaseAI/Sources/{FunctionCalling.swift => Tool.swift} (98%) diff --git a/FirebaseAI/Sources/FunctionCalling.swift b/FirebaseAI/Sources/Tool.swift similarity index 98% rename from FirebaseAI/Sources/FunctionCalling.swift rename to FirebaseAI/Sources/Tool.swift index 5f59970ceee..2dfefd13d19 100644 --- a/FirebaseAI/Sources/FunctionCalling.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// 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. @@ -53,7 +53,7 @@ public struct FunctionDeclaration: Sendable { /// A tool that allows the generative model to connect to Google Search to access and incorporate /// up-to-date information from the web into its responses. /// -/// When this tool is used, the model's responses may include "Grounded Results" which are subject +/// > Important: When this tool is used, the model's responses may include "Grounded Results" which are subject /// to the Grounding with Google Search terms outlined in the /// [Service Specific Terms](https://cloud.google.com/terms/service-terms). @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index b80615b3a83..d796f4241d7 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -249,21 +249,11 @@ struct GenerateContentIntegrationTests { @Test( "generateContent with Google Search returns grounding metadata", - arguments: [ - (InstanceConfig.vertexAI_v1, ModelNames.gemini2FlashLite), - (InstanceConfig.vertexAI_v1_staging, ModelNames.gemini2FlashLite), - (InstanceConfig.vertexAI_v1beta, ModelNames.gemini2FlashLite), - (InstanceConfig.vertexAI_v1beta_staging, ModelNames.gemini2FlashLite), - (InstanceConfig.googleAI_v1beta, ModelNames.gemini2FlashLite), - (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemini2FlashLite), - (InstanceConfig.googleAI_v1_freeTier_bypassProxy, ModelNames.gemini2FlashLite), - (InstanceConfig.googleAI_v1beta_freeTier_bypassProxy, ModelNames.gemini2FlashLite), - ] + arguments: InstanceConfig.allConfigs ) - func generateContent_withGoogleSearch_succeeds(_ config: InstanceConfig, - modelName: String) async throws { + func generateContent_withGoogleSearch_succeeds(_ config: InstanceConfig) async throws { let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: modelName, + modelName: ModelNames.gemini2FlashLite, generationConfig: generationConfig, safetySettings: safetySettings, tools: [.googleSearch()] From 1dad0e61c4e7da0fe75a872c3b8f47476fc7a228 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Fri, 20 Jun 2025 15:33:50 -0400 Subject: [PATCH 05/12] Fix formatting --- FirebaseAI/Sources/Tool.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/FirebaseAI/Sources/Tool.swift b/FirebaseAI/Sources/Tool.swift index 2dfefd13d19..9502f5c48aa 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -53,7 +53,8 @@ public struct FunctionDeclaration: Sendable { /// A tool that allows the generative model to connect to Google Search to access and incorporate /// up-to-date information from the web into its responses. /// -/// > Important: When this tool is used, the model's responses may include "Grounded Results" which are subject +/// > Important: When this tool is used, the model's responses may include "Grounded Results" which +/// are subject /// to the Grounding with Google Search terms outlined in the /// [Service Specific Terms](https://cloud.google.com/terms/service-terms). @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) From 84f548c6d9cc81f25ca88d0209579bc64a97d801 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Fri, 20 Jun 2025 15:34:54 -0400 Subject: [PATCH 06/12] Fix integration test --- .../Tests/Integration/GenerateContentIntegrationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index d796f4241d7..977f5fc9d29 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -264,7 +264,7 @@ struct GenerateContentIntegrationTests { let candidate = try #require(response.candidates.first) let groundingMetadata = try #require(candidate.groundingMetadata) - let searchEntrypoint = try #require(groundingMetadata.searchEntrypoint) + let searchEntrypoint = try #require(groundingMetadata.searchEntryPoint) #expect(!groundingMetadata.webSearchQueries.isEmpty) #expect(searchEntrypoint.renderedContent != nil) From 72901b44b1cc48ea29a4e2e607497e7a4e65493f Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Fri, 20 Jun 2025 16:23:38 -0400 Subject: [PATCH 07/12] use gemini2Flash for integration test --- .../Tests/Integration/GenerateContentIntegrationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 977f5fc9d29..787c9b5a278 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -253,7 +253,7 @@ struct GenerateContentIntegrationTests { ) func generateContent_withGoogleSearch_succeeds(_ config: InstanceConfig) async throws { let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2FlashLite, + modelName: ModelNames.gemini2Flash, generationConfig: generationConfig, safetySettings: safetySettings, tools: [.googleSearch()] From c7eb33eb854bcab616d0b868f7d6bdc25c29f9f4 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Wed, 25 Jun 2025 11:47:33 -0400 Subject: [PATCH 08/12] review fixes --- .../Sources/GenerateContentResponse.swift | 37 ++----------------- FirebaseAI/Sources/Tool.swift | 2 +- 2 files changed, 4 insertions(+), 35 deletions(-) diff --git a/FirebaseAI/Sources/GenerateContentResponse.swift b/FirebaseAI/Sources/GenerateContentResponse.swift index 6da3dfd8bd3..a74f5c6d5a1 100644 --- a/FirebaseAI/Sources/GenerateContentResponse.swift +++ b/FirebaseAI/Sources/GenerateContentResponse.swift @@ -668,44 +668,13 @@ extension GroundingMetadata: Decodable { } @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension GroundingMetadata.SearchEntryPoint: Decodable { - enum CodingKeys: String, CodingKey { - case renderedContent - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - renderedContent = try container.decodeIfPresent(String.self, forKey: .renderedContent) - } -} +extension GroundingMetadata.SearchEntryPoint: Decodable {} @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension GroundingMetadata.GroundingChunk: Decodable { - enum CodingKeys: String, CodingKey { - case web - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - web = try container.decodeIfPresent(GroundingMetadata.WebGroundingChunk.self, forKey: .web) - } -} +extension GroundingMetadata.GroundingChunk: Decodable {} @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension GroundingMetadata.WebGroundingChunk: Decodable { - enum CodingKeys: String, CodingKey { - case uri - case title - case domain - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - uri = try container.decodeIfPresent(String.self, forKey: .uri) - title = try container.decodeIfPresent(String.self, forKey: .title) - domain = try container.decodeIfPresent(String.self, forKey: .domain) - } -} +extension GroundingMetadata.WebGroundingChunk: Decodable {} @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension GroundingMetadata.GroundingSupport: Decodable { diff --git a/FirebaseAI/Sources/Tool.swift b/FirebaseAI/Sources/Tool.swift index 9502f5c48aa..bd4ae00e520 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -1,4 +1,4 @@ -// Copyright 2025 Google LLC +// 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. From da9fdc9f81420f17dccb3d25fb311996bce61c54 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Wed, 25 Jun 2025 14:46:41 -0400 Subject: [PATCH 09/12] Pretty print expected json --- FirebaseAI/Tests/Unit/Types/ToolTests.swift | 32 +++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/FirebaseAI/Tests/Unit/Types/ToolTests.swift b/FirebaseAI/Tests/Unit/Types/ToolTests.swift index 5e0a847971c..b163894932d 100644 --- a/FirebaseAI/Tests/Unit/Types/ToolTests.swift +++ b/FirebaseAI/Tests/Unit/Types/ToolTests.swift @@ -22,14 +22,20 @@ final class ToolTests: XCTestCase { override func setUp() { super.setUp() - encoder.outputFormatting = .sortedKeys + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] } func testEncodeTool_googleSearch() throws { let tool = Tool.googleSearch() let jsonData = try encoder.encode(tool) let jsonString = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) - XCTAssertEqual(jsonString, "{\"googleSearch\":{}}") + XCTAssertEqual(jsonString, """ + { + "googleSearch" : { + + } + } + """) } func testEncodeTool_functionDeclarations() throws { @@ -45,7 +51,27 @@ final class ToolTests: XCTestCase { let jsonString = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) XCTAssertEqual(jsonString, """ - {"functionDeclarations":[{"description":"A test function.","name":"test_function","parameters":{"nullable":false,"properties":{"param1":{"nullable":false,"type":"STRING"}},"required":["param1"],"type":"OBJECT"}}]} + { + "functionDeclarations" : [ + { + "description" : "A test function.", + "name" : "test_function", + "parameters" : { + "nullable" : false, + "properties" : { + "param1" : { + "nullable" : false, + "type" : "STRING" + } + }, + "required" : [ + "param1" + ], + "type" : "OBJECT" + } + } + ] + } """) } } From a63057aa3d2f5fd1833f35c70ce448bd457126b4 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Mon, 30 Jun 2025 13:07:10 -0400 Subject: [PATCH 10/12] make `segment` and `renderedContent` required --- .../Sources/GenerateContentResponse.swift | 25 ++++++-- .../GenerateContentIntegrationTests.swift | 2 - .../Unit/Types/GroundingMetadataTests.swift | 58 +++++++++++++++++-- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/FirebaseAI/Sources/GenerateContentResponse.swift b/FirebaseAI/Sources/GenerateContentResponse.swift index a74f5c6d5a1..b7ef2cc8440 100644 --- a/FirebaseAI/Sources/GenerateContentResponse.swift +++ b/FirebaseAI/Sources/GenerateContentResponse.swift @@ -350,7 +350,7 @@ public struct GroundingMetadata: Sendable { /// undesired interaction with the rest of the page's CSS. /// /// To ensure proper rendering, it's recommended to display this content within a `WKWebView`. - public let renderedContent: String? + public let renderedContent: String } /// Represents a chunk of retrieved data that supports a claim in the model's response. This is @@ -393,12 +393,27 @@ public struct GroundingMetadata: Sendable { public struct GroundingSupport: Sendable { /// Specifies the segment of the model's response content that this grounding support pertains /// to. - public let segment: Segment? + public let segment: Segment /// A list of indices that refer to specific ``GroundingChunk`` structs within the /// ``GroundingMetadata/groundingChunks`` array. These referenced chunks are the sources that /// support the claim made in the associated `segment` of the response. public let groundingChunkIndices: [Int] + + struct Internal { + let segment: Segment? + let groundingChunkIndices: [Int] + + func toPublic() -> GroundingSupport? { + if segment == nil { + return nil + } + return GroundingSupport( + segment: segment!, + groundingChunkIndices: groundingChunkIndices + ) + } + } } } @@ -657,9 +672,9 @@ extension GroundingMetadata: Decodable { forKey: .groundingChunks ) ?? [] groundingSupports = try container.decodeIfPresent( - [GroundingSupport].self, + [GroundingSupport.Internal].self, forKey: .groundingSupports - ) ?? [] + )?.compactMap { $0.toPublic() } ?? [] searchEntryPoint = try container.decodeIfPresent( SearchEntryPoint.self, forKey: .searchEntryPoint @@ -677,7 +692,7 @@ extension GroundingMetadata.GroundingChunk: Decodable {} extension GroundingMetadata.WebGroundingChunk: Decodable {} @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension GroundingMetadata.GroundingSupport: Decodable { +extension GroundingMetadata.GroundingSupport.Internal: Decodable { enum CodingKeys: String, CodingKey { case segment case groundingChunkIndices diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 787c9b5a278..bf6dc96a801 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -254,8 +254,6 @@ struct GenerateContentIntegrationTests { func generateContent_withGoogleSearch_succeeds(_ config: InstanceConfig) async throws { let model = FirebaseAI.componentInstance(config).generativeModel( modelName: ModelNames.gemini2Flash, - generationConfig: generationConfig, - safetySettings: safetySettings, tools: [.googleSearch()] ) let prompt = "What is the weather in Toronto today?" diff --git a/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift b/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift index 1dca2e04334..642e5fd75c3 100644 --- a/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift +++ b/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift @@ -41,13 +41,33 @@ final class GroundingMetadataTests: XCTestCase { XCTAssertEqual(metadata.groundingChunks.count, 1) XCTAssertEqual(metadata.groundingChunks.first?.web?.uri, "uri1") XCTAssertEqual(metadata.groundingSupports.count, 1) - XCTAssertEqual(metadata.groundingSupports.first?.segment?.startIndex, 0) - XCTAssertEqual(metadata.groundingSupports.first?.segment?.partIndex, 0) - XCTAssertEqual(metadata.groundingSupports.first?.segment?.endIndex, 10) - XCTAssertEqual(metadata.groundingSupports.first?.segment?.text, "text") + XCTAssertEqual(metadata.groundingSupports.first?.segment.startIndex, 0) + XCTAssertEqual(metadata.groundingSupports.first?.segment.partIndex, 0) + XCTAssertEqual(metadata.groundingSupports.first?.segment.endIndex, 10) + XCTAssertEqual(metadata.groundingSupports.first?.segment.text, "text") XCTAssertEqual(metadata.searchEntryPoint?.renderedContent, "html") } + func testDecodeGroundingMetadata_missingSegments() throws { + let json = """ + { + "groundingSupports": [ + { "segment": { "endIndex": 10, "text": "text" }, "groundingChunkIndices": [0] }, + { "groundingChunkIndices": [0] } + ], + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let metadata = try decoder.decode(GroundingMetadata.self, from: jsonData) + + XCTAssertEqual(metadata.groundingSupports.count, 1) + XCTAssertEqual(metadata.groundingSupports.first?.segment.startIndex, 0) + XCTAssertEqual(metadata.groundingSupports.first?.segment.partIndex, 0) + XCTAssertEqual(metadata.groundingSupports.first?.segment.endIndex, 10) + XCTAssertEqual(metadata.groundingSupports.first?.segment.text, "text") + } + func testDecodeGroundingMetadata_missingOptionals() throws { let json = "{}" let jsonData = try XCTUnwrap(json.data(using: .utf8)) @@ -60,6 +80,30 @@ final class GroundingMetadataTests: XCTestCase { XCTAssertNil(metadata.searchEntryPoint) } + func testDecodeSearchEntrypoint_missingRenderedContent() throws { + let json = "{}" + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + XCTAssertThrowsError(try decoder.decode( + GroundingMetadata.SearchEntryPoint.self, + from: jsonData + )) + } + + func testDecodeSearchEntrypoint_withRenderedContent() throws { + let json = """ + { "renderedContent": "html" } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let searchEntrypoint = try decoder.decode( + GroundingMetadata.SearchEntryPoint.self, + from: jsonData + ) + + XCTAssertEqual(searchEntrypoint.renderedContent, "html") + } + func testDecodeGroundingChunk_withoutWeb() throws { let json = "{}" let jsonData = try XCTUnwrap(json.data(using: .utf8)) @@ -83,9 +127,13 @@ final class GroundingMetadataTests: XCTestCase { { "groundingChunkIndices": [1, 2] } """ let jsonData = try XCTUnwrap(json.data(using: .utf8)) - let support = try decoder.decode(GroundingMetadata.GroundingSupport.self, from: jsonData) + let support = try decoder.decode( + GroundingMetadata.GroundingSupport.Internal.self, + from: jsonData + ) XCTAssertNil(support.segment) XCTAssertEqual(support.groundingChunkIndices, [1, 2]) + XCTAssertNil(support.toPublic()) } func testDecodeSegment_defaults() throws { From 842802af83c5eb614f78b7835e1561aabf8a681e Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Wed, 2 Jul 2025 15:37:27 -0400 Subject: [PATCH 11/12] docs fixes --- .../Sources/GenerateContentResponse.swift | 62 +++++-------------- FirebaseAI/Sources/Tool.swift | 18 +++--- 2 files changed, 28 insertions(+), 52 deletions(-) diff --git a/FirebaseAI/Sources/GenerateContentResponse.swift b/FirebaseAI/Sources/GenerateContentResponse.swift index b7ef2cc8440..b0e348d2192 100644 --- a/FirebaseAI/Sources/GenerateContentResponse.swift +++ b/FirebaseAI/Sources/GenerateContentResponse.swift @@ -304,62 +304,38 @@ public struct PromptFeedback: Sendable { /// Metadata returned to the client when grounding is enabled. /// -/// > Important: If using Grounding with Google Search, you are required to comply with the [Service -/// Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with Google -/// Search*. +/// > Important: If using Grounding with Google Search, you are required to comply with the +/// "Grounding with Google Search" usage requirements for your chosen API provider: +/// [Gemini Developer API](https://ai.google.dev/gemini-api/terms#grounding-with-google-search) +/// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) +/// section within the Service Specific Terms). @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct GroundingMetadata: Sendable { /// A list of web search queries that the model performed to gather the grounding information. /// These can be used to allow users to explore the search results themselves. - /// - /// > Important: If using Grounding with Google Search, you are required to comply with the - /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with - /// Google Search*. public let webSearchQueries: [String] /// A list of ``GroundingChunk`` structs. Each chunk represents a piece of retrieved content /// (e.g., from a web page) that the model used to ground its response. - /// - /// > Important: If using Grounding with Google Search, you are required to comply with the - /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with - /// Google Search*. public let groundingChunks: [GroundingChunk] /// A list of ``GroundingSupport`` structs. Each object details how specific segments of the /// model's response are supported by the `groundingChunks`. - /// - /// > Important: If using Grounding with Google Search, you are required to comply with the - /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with - /// Google Search*. public let groundingSupports: [GroundingSupport] - /// Google search entry point for web searches. + /// Google Search entry point for web searches. /// This contains an HTML/CSS snippet that **must** be embedded in an app to display a Google - /// Search Entry point for follow-up web searches related to the model's "Grounded Response". - /// - /// > Important: If using Grounding with Google Search, you are required to comply with the - /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with - /// Google Search*. + /// Search entry point for follow-up web searches related to the model's "Grounded Response". public let searchEntryPoint: SearchEntryPoint? /// A struct representing the Google Search entry point. - /// - /// > Important: If using Grounding with Google Search, you are required to comply with the - /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with - /// Google Search*. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct SearchEntryPoint: Sendable { - /// An HTML/CSS snippet that can be embedded in your app. The snippet is designed to avoid - /// undesired interaction with the rest of the page's CSS. + /// An HTML/CSS snippet that can be embedded in your app. /// /// To ensure proper rendering, it's recommended to display this content within a `WKWebView`. public let renderedContent: String } /// Represents a chunk of retrieved data that supports a claim in the model's response. This is - /// part - /// of the grounding information provided when grounding is enabled. - /// - /// > Important: If using Grounding with Google Search, you are required to comply with the - /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with - /// Google Search*. + /// part of the grounding information provided when grounding is enabled. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct GroundingChunk: Sendable { /// Contains details if the grounding chunk is from a web source. @@ -367,17 +343,13 @@ public struct GroundingMetadata: Sendable { } /// A grounding chunk sourced from the web. - /// - /// > Important: If using Grounding with Google Search, you are required to comply with the - /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with - /// Google Search*. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct WebGroundingChunk: Sendable { /// The URI of the retrieved web page. public let uri: String? /// The title of the retrieved web page. public let title: String? - /// The domain of the original URI from which the content was retrieved (e.g., `example.com`). + /// The domain of the original URI from which the content was retrieved. /// /// This field is only populated when using the Vertex AI Gemini API. public let domain: String? @@ -385,10 +357,6 @@ public struct GroundingMetadata: Sendable { /// Provides information about how a specific segment of the model's response is supported by the /// retrieved grounding chunks. - /// - /// > Important: If using Grounding with Google Search, you are required to comply with the - /// [Service Specific Terms](https://cloud.google.com/terms/service-terms) for *Grounding with - /// Google Search*. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct GroundingSupport: Sendable { /// Specifies the segment of the model's response content that this grounding support pertains @@ -397,7 +365,10 @@ public struct GroundingMetadata: Sendable { /// A list of indices that refer to specific ``GroundingChunk`` structs within the /// ``GroundingMetadata/groundingChunks`` array. These referenced chunks are the sources that - /// support the claim made in the associated `segment` of the response. + /// support the claim made in the associated `segment` of the response. For example, an array + /// `[1, 3, 4]` + /// means that `groundingChunks[1]`, `groundingChunks[3]`, `groundingChunks[4]` are the + /// retrieved content supporting this part of the response. public let groundingChunkIndices: [Int] struct Internal { @@ -428,9 +399,10 @@ public struct Segment: Sendable { /// bytes. This offset is inclusive, starting from 0 at the beginning of the part's content. public let startIndex: Int /// The zero-based end index of the segment within the specified ``Part``, measured in UTF-8 - /// bytes. This offset is exclusive. + /// bytes. This offset is exclusive, meaning the character at this index is not included in the + /// segment. public let endIndex: Int - /// The text content of the segment. + /// The text corresponding to the segment from the response. public let text: String } diff --git a/FirebaseAI/Sources/Tool.swift b/FirebaseAI/Sources/Tool.swift index bd4ae00e520..16c05b3a2e4 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -53,10 +53,11 @@ public struct FunctionDeclaration: Sendable { /// A tool that allows the generative model to connect to Google Search to access and incorporate /// up-to-date information from the web into its responses. /// -/// > Important: When this tool is used, the model's responses may include "Grounded Results" which -/// are subject -/// to the Grounding with Google Search terms outlined in the -/// [Service Specific Terms](https://cloud.google.com/terms/service-terms). +/// > Important: When using this feature, you are required to comply with the +/// "Grounding with Google Search" usage requirements for your chosen API provider: +/// [Gemini Developer API](https://ai.google.dev/gemini-api/terms#grounding-with-google-search) +/// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) +/// section within the Service Specific Terms). @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct GoogleSearch: Sendable { public init() {} @@ -70,6 +71,7 @@ public struct GoogleSearch: Sendable { public struct Tool: Sendable { /// A list of `FunctionDeclarations` available to the model. let functionDeclarations: [FunctionDeclaration]? + /// Specifies the Google Search configuration. let googleSearch: GoogleSearch? init(functionDeclarations: [FunctionDeclaration]?) { @@ -110,9 +112,11 @@ public struct Tool: Sendable { /// Grounding with Google Search can be used to allow the model to connect to Google Search to /// access and incorporate up-to-date information from the web into it's responses. /// - /// When this tool is used, the model's responses may include "Grounded Results" which are subject - /// to the Grounding with Google Search terms outlined in the [Service Specific - /// Terms](https://cloud.google.com/terms/service-terms). + /// > Important: When using this feature, you are required to comply with the + /// "Grounding with Google Search" usage requirements for your chosen API provider: + /// [Gemini Developer API](https://ai.google.dev/gemini-api/terms#grounding-with-google-search) + /// or Vertex AI Gemini API (see [Service Terms](https://cloud.google.com/terms/service-terms) + /// section within the Service Specific Terms). /// /// - Parameters: /// - googleSearch: An empty ``GoogleSearch`` object. The presence of this object in the list From a48c749882d08a09b53ded31bd12d3bf5921d0b2 Mon Sep 17 00:00:00 2001 From: Daniel La Rocque Date: Wed, 2 Jul 2025 15:53:28 -0400 Subject: [PATCH 12/12] fix tests --- .../GenerateContentIntegrationTests.swift | 4 +-- .../Unit/GenerativeModelGoogleAITests.swift | 12 +++++---- .../Unit/Types/GroundingMetadataTests.swift | 25 +++++++++++-------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index bf6dc96a801..da77cea9df7 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -265,7 +265,7 @@ struct GenerateContentIntegrationTests { let searchEntrypoint = try #require(groundingMetadata.searchEntryPoint) #expect(!groundingMetadata.webSearchQueries.isEmpty) - #expect(searchEntrypoint.renderedContent != nil) + #expect(!searchEntrypoint.renderedContent.isEmpty) #expect(!groundingMetadata.groundingChunks.isEmpty) #expect(!groundingMetadata.groundingSupports.isEmpty) @@ -274,7 +274,7 @@ struct GenerateContentIntegrationTests { } for support in groundingMetadata.groundingSupports { - let segment = try #require(support.segment) + let segment = support.segment #expect(segment.endIndex > segment.startIndex) #expect(!segment.text.isEmpty) #expect(!support.groundingChunkIndices.isEmpty) diff --git a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift index c7363afee78..103943e6f92 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift @@ -212,8 +212,8 @@ final class GenerativeModelGoogleAITests: XCTestCase { let groundingMetadata = try XCTUnwrap(candidate.groundingMetadata) XCTAssertEqual(groundingMetadata.webSearchQueries, ["current weather in London"]) - XCTAssertNotNil(groundingMetadata.searchEntryPoint) - XCTAssertNotNil(groundingMetadata.searchEntryPoint?.renderedContent) + let searchEntryPoint = try XCTUnwrap(groundingMetadata.searchEntryPoint) + XCTAssertFalse(searchEntryPoint.renderedContent.isEmpty) XCTAssertEqual(groundingMetadata.groundingChunks.count, 2) let firstChunk = try XCTUnwrap(groundingMetadata.groundingChunks.first?.web) @@ -245,7 +245,7 @@ final class GenerativeModelGoogleAITests: XCTestCase { XCTAssertEqual(response.candidates.count, 1) let candidate = try XCTUnwrap(response.candidates.first) let groundingMetadata = try XCTUnwrap(candidate.groundingMetadata) - XCTAssertNotNil(groundingMetadata.searchEntryPoint?.renderedContent) + XCTAssertNotNil(groundingMetadata.searchEntryPoint) XCTAssertEqual(groundingMetadata.webSearchQueries, ["current weather London"]) // Chunks exist, but contain no web information. @@ -256,8 +256,10 @@ final class GenerativeModelGoogleAITests: XCTestCase { XCTAssertEqual(groundingMetadata.groundingSupports.count, 1) let support = try XCTUnwrap(groundingMetadata.groundingSupports.first) XCTAssertEqual(support.groundingChunkIndices, [0]) - let segment = try XCTUnwrap(support.segment) - XCTAssertEqual(segment.text, "There is a 0% chance of rain and the humidity is around 41%.") + XCTAssertEqual( + support.segment.text, + "There is a 0% chance of rain and the humidity is around 41%." + ) } func testGenerateContent_failure_invalidAPIKey() async throws { diff --git a/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift b/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift index 642e5fd75c3..132d47fc589 100644 --- a/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift +++ b/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift @@ -39,13 +39,17 @@ final class GroundingMetadataTests: XCTestCase { XCTAssertEqual(metadata.webSearchQueries, ["query1", "query2"]) XCTAssertEqual(metadata.groundingChunks.count, 1) - XCTAssertEqual(metadata.groundingChunks.first?.web?.uri, "uri1") + let groundingChunk = try XCTUnwrap(metadata.groundingChunks.first) + let webChunk = try XCTUnwrap(groundingChunk.web) + XCTAssertEqual(webChunk.uri, "uri1") XCTAssertEqual(metadata.groundingSupports.count, 1) - XCTAssertEqual(metadata.groundingSupports.first?.segment.startIndex, 0) - XCTAssertEqual(metadata.groundingSupports.first?.segment.partIndex, 0) - XCTAssertEqual(metadata.groundingSupports.first?.segment.endIndex, 10) - XCTAssertEqual(metadata.groundingSupports.first?.segment.text, "text") - XCTAssertEqual(metadata.searchEntryPoint?.renderedContent, "html") + 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") } func testDecodeGroundingMetadata_missingSegments() throws { @@ -62,10 +66,11 @@ final class GroundingMetadataTests: XCTestCase { let metadata = try decoder.decode(GroundingMetadata.self, from: jsonData) XCTAssertEqual(metadata.groundingSupports.count, 1) - XCTAssertEqual(metadata.groundingSupports.first?.segment.startIndex, 0) - XCTAssertEqual(metadata.groundingSupports.first?.segment.partIndex, 0) - XCTAssertEqual(metadata.groundingSupports.first?.segment.endIndex, 10) - XCTAssertEqual(metadata.groundingSupports.first?.segment.text, "text") + 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") } func testDecodeGroundingMetadata_missingOptionals() throws {