diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index 35d87cc537d..0c0768cae07 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,4 +1,5 @@ # Unreleased +- [added] Added support for Grounding with Google Search. (#15014) - [removed] Removed `CountTokensResponse.totalBillableCharacters` which was deprecated in 11.15.0. Use `totalTokens` instead. (#15056) diff --git a/FirebaseAI/Sources/GenerateContentResponse.swift b/FirebaseAI/Sources/GenerateContentResponse.swift index cb212e5a616..b0e348d2192 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,110 @@ 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 +/// "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. + 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. + 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`. + 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". + public let searchEntryPoint: SearchEntryPoint? + + /// A struct representing the Google Search entry point. + @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. + /// + /// 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. + @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. + @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. + /// + /// 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. + @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. 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 { + let segment: Segment? + let groundingChunkIndices: [Int] + + func toPublic() -> GroundingSupport? { + if segment == nil { + return nil + } + return GroundingSupport( + segment: segment!, + groundingChunkIndices: groundingChunkIndices + ) + } + } + } +} + +/// 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, meaning the character at this index is not included in the + /// segment. + public let endIndex: Int + /// The text corresponding to the segment from the response. + 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 +476,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 +522,11 @@ extension Candidate: Decodable { CitationMetadata.self, forKey: .citationMetadata ) + + groundingMetadata = try container.decodeIfPresent( + GroundingMetadata.self, + forKey: .groundingMetadata + ) } } @@ -513,3 +626,74 @@ 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.Internal].self, + forKey: .groundingSupports + )?.compactMap { $0.toPublic() } ?? [] + 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 {} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension GroundingMetadata.GroundingChunk: Decodable {} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension GroundingMetadata.WebGroundingChunk: Decodable {} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension GroundingMetadata.GroundingSupport.Internal: 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/Sources/FunctionCalling.swift b/FirebaseAI/Sources/Tool.swift similarity index 77% rename from FirebaseAI/Sources/FunctionCalling.swift rename to FirebaseAI/Sources/Tool.swift index e85e05f5865..16c05b3a2e4 100644 --- a/FirebaseAI/Sources/FunctionCalling.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -50,6 +50,19 @@ 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 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() {} +} + /// 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 +71,17 @@ public struct FunctionDeclaration: 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]?) { 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 +106,26 @@ 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. + /// + /// > 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 + /// 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 +211,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/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 5a63ca41a6d..da77cea9df7 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -247,6 +247,45 @@ struct GenerateContentIntegrationTests { #endif // canImport(UIKit) } + @Test( + "generateContent with Google Search returns grounding metadata", + arguments: InstanceConfig.allConfigs + ) + func generateContent_withGoogleSearch_succeeds(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2Flash, + 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.isEmpty) + #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) + } + } + } + // MARK: Streaming Tests @Test(arguments: [ diff --git a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift index 130dc47b8b4..103943e6f92 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift @@ -198,6 +198,70 @@ 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"]) + let searchEntryPoint = try XCTUnwrap(groundingMetadata.searchEntryPoint) + XCTAssertFalse(searchEntryPoint.renderedContent.isEmpty) + + 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) + 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]) + XCTAssertEqual( + support.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 b03c3a0a9f6..6557735ccc4 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..132d47fc589 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/GroundingMetadataTests.swift @@ -0,0 +1,153 @@ +// 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) + let groundingChunk = try XCTUnwrap(metadata.groundingChunks.first) + let webChunk = try XCTUnwrap(groundingChunk.web) + XCTAssertEqual(webChunk.uri, "uri1") + XCTAssertEqual(metadata.groundingSupports.count, 1) + let groundingSupport = try XCTUnwrap(metadata.groundingSupports.first) + XCTAssertEqual(groundingSupport.segment.startIndex, 0) + XCTAssertEqual(groundingSupport.segment.partIndex, 0) + XCTAssertEqual(groundingSupport.segment.endIndex, 10) + XCTAssertEqual(groundingSupport.segment.text, "text") + let searchEntryPoint = try XCTUnwrap(metadata.searchEntryPoint) + XCTAssertEqual(searchEntryPoint.renderedContent, "html") + } + + 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) + 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 { + 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 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)) + 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.Internal.self, + from: jsonData + ) + XCTAssertNil(support.segment) + XCTAssertEqual(support.groundingChunkIndices, [1, 2]) + XCTAssertNil(support.toPublic()) + } + + 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..b163894932d --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/ToolTests.swift @@ -0,0 +1,77 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +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 = [.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" : { + + } + } + """) + } + + 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" + } + } + ] + } + """) + } +}