mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-10 22:45:59 +05:45
Add function calling (tool calling) support to GeminiLanguageModel
conforming to Apple's FoundationModels framework.
* Add .toolCalling capability to GeminiLanguageModel.
* Translate FoundationModels ToolDefinition into Gemini Tool and
FunctionDeclaration using OpenAPI parametersJsonSchema.
* Map GenerationOptions.ToolCallingMode (.allowed, .required,
.disallowed) to Gemini FunctionCallingConfig.Mode.
* Stream function calls in GeminiLanguageModelExecutor, forwarding
JSON-encoded arguments or empty fallback "{}" into channel actions.
* Translate Transcript tool calls and tool outputs into Gemini Content
parts, preserving thought signatures from reasoning models and
flushing buffered reasoning thoughts cleanly.
* Unpack structured tool responses and wrap scalar text tool outputs
under the result field.
* Support parameterless tools with empty arguments across request
schema translation, model response handling, and transcript history.
* Add unit tests across GeminiLanguageModel, GeminiRequestTranslator,
and GeminiTranscriptTranslator.
* Add end-to-end integration tests covering single-turn, sequential,
parallel, disallowed, parameterless, and reasoning tool calling
across all supported backends.
246 lines
8.3 KiB
Swift
246 lines
8.3 KiB
Swift
// Copyright 2026 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.
|
|
|
|
#if canImport(FoundationModels) && compiler(>=6.4)
|
|
import Foundation
|
|
import FoundationModels
|
|
import GeminiAPIClient
|
|
import GeminiTestUtilities
|
|
import Testing
|
|
|
|
@testable import GeminiLanguageModel
|
|
|
|
/// Integration tests for tool calling using `GeminiLanguageModel`.
|
|
@Suite(
|
|
"Tool Calling Integration Tests",
|
|
.requireFoundationModels,
|
|
.tags(.integration)
|
|
)
|
|
struct ToolCallingIntegrationTests {
|
|
/// A tool that provides mock weather information for a given city.
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
@available(tvOS, unavailable)
|
|
struct WeatherTool: FoundationModels.Tool {
|
|
let name = "getWeather"
|
|
let description = "Get the current weather for a city."
|
|
|
|
@Generable
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
@available(tvOS, unavailable)
|
|
struct Arguments {
|
|
@Guide(description: "The city name to look up weather for")
|
|
var city: String
|
|
}
|
|
|
|
func call(arguments: Arguments) async throws -> String {
|
|
"The current weather in \(arguments.city) is 72°F and sunny."
|
|
}
|
|
}
|
|
|
|
/// A tool that provides the current time and takes no arguments.
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
@available(tvOS, unavailable)
|
|
struct CurrentTimeTool: FoundationModels.Tool {
|
|
let name = "getCurrentTime"
|
|
let description = "Get the current time."
|
|
|
|
@Generable
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
@available(tvOS, unavailable)
|
|
struct Arguments {}
|
|
|
|
func call(arguments: Arguments) async throws -> String {
|
|
"12:00 PM UTC"
|
|
}
|
|
}
|
|
|
|
@Test(
|
|
.requireIntegrationTestingBackend,
|
|
arguments: IntegrationTestingBackend.availableBackends
|
|
)
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func sessionRespondSingleToolCall(backend: IntegrationTestingBackend) async throws {
|
|
let model = try await backend.makeModel()
|
|
let session = LanguageModelSession(
|
|
model: model,
|
|
tools: [WeatherTool()]
|
|
)
|
|
|
|
let response = try await session.respond(
|
|
to: "What is the weather in Paris right now?"
|
|
)
|
|
|
|
#expect(!response.content.isEmpty)
|
|
let containsExpected =
|
|
response.content.localizedCaseInsensitiveContains("Paris")
|
|
|| response.content.contains("72")
|
|
#expect(containsExpected)
|
|
let hasToolCalls = session.transcript.contains { entry in
|
|
if case .toolCalls = entry { return true }
|
|
return false
|
|
}
|
|
#expect(hasToolCalls)
|
|
let hasToolOutput = session.transcript.contains { entry in
|
|
if case .toolOutput = entry { return true }
|
|
return false
|
|
}
|
|
#expect(hasToolOutput)
|
|
#expect(response.usage.totalTokenCount > 0)
|
|
}
|
|
|
|
@Test(
|
|
.requireIntegrationTestingBackend,
|
|
arguments: IntegrationTestingBackend.availableBackends
|
|
)
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func sessionRespondToolWithEmptyArguments(backend: IntegrationTestingBackend) async throws {
|
|
let model = try await backend.makeModel()
|
|
let session = LanguageModelSession(
|
|
model: model,
|
|
tools: [CurrentTimeTool()]
|
|
)
|
|
|
|
let response = try await session.respond(to: "What time is it right now?")
|
|
|
|
#expect(!response.content.isEmpty)
|
|
let containsExpected =
|
|
response.content.localizedCaseInsensitiveContains("12:00")
|
|
|| response.content.localizedCaseInsensitiveContains("PM")
|
|
#expect(containsExpected)
|
|
let hasToolCalls = session.transcript.contains { entry in
|
|
if case .toolCalls = entry { return true }
|
|
return false
|
|
}
|
|
#expect(hasToolCalls)
|
|
let hasToolOutput = session.transcript.contains { entry in
|
|
if case .toolOutput = entry { return true }
|
|
return false
|
|
}
|
|
#expect(hasToolOutput)
|
|
#expect(response.usage.totalTokenCount > 0)
|
|
}
|
|
|
|
@Test(
|
|
.requireIntegrationTestingBackend,
|
|
arguments: IntegrationTestingBackend.availableBackends
|
|
)
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func sessionRespondSequentialToolCalls(backend: IntegrationTestingBackend) async throws {
|
|
let model = try await backend.makeModel()
|
|
let session = LanguageModelSession(
|
|
model: model,
|
|
tools: [WeatherTool()]
|
|
)
|
|
|
|
_ = try await session.respond(to: "What is the weather in Seattle?")
|
|
|
|
let secondResponse = try await session.respond(to: "What about in Miami?")
|
|
|
|
#expect(!secondResponse.content.isEmpty)
|
|
let containsMiamiOrTemp =
|
|
secondResponse.content.localizedCaseInsensitiveContains("Miami")
|
|
|| secondResponse.content.contains("72")
|
|
#expect(containsMiamiOrTemp)
|
|
let toolCallCount = session.transcript.filter { entry in
|
|
if case .toolCalls = entry { return true }
|
|
return false
|
|
}.count
|
|
#expect(toolCallCount >= 2)
|
|
}
|
|
|
|
@Test(
|
|
.requireIntegrationTestingBackend,
|
|
arguments: IntegrationTestingBackend.availableBackends
|
|
)
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func sessionRespondParallelToolCalls(backend: IntegrationTestingBackend) async throws {
|
|
let model = try await backend.makeModel()
|
|
let session = LanguageModelSession(
|
|
model: model,
|
|
tools: [WeatherTool()]
|
|
)
|
|
|
|
let response = try await session.respond(
|
|
to: "What is the weather in Tokyo and in London right now? Compare both."
|
|
)
|
|
|
|
#expect(!response.content.isEmpty)
|
|
#expect(response.content.localizedCaseInsensitiveContains("Tokyo"))
|
|
#expect(response.content.localizedCaseInsensitiveContains("London"))
|
|
let hasToolCalls = session.transcript.contains { entry in
|
|
if case .toolCalls = entry { return true }
|
|
return false
|
|
}
|
|
#expect(hasToolCalls)
|
|
}
|
|
|
|
@Test(
|
|
.requireIntegrationTestingBackend,
|
|
arguments: IntegrationTestingBackend.availableBackends
|
|
)
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func sessionRespondToolCallingModeDisallowed(
|
|
backend: IntegrationTestingBackend
|
|
) async throws {
|
|
let model = try await backend.makeModel()
|
|
let session = LanguageModelSession(
|
|
model: model,
|
|
tools: [WeatherTool()]
|
|
)
|
|
|
|
let response = try await session.respond(
|
|
to: "What is the weather in Boston?",
|
|
options: GenerationOptions(toolCallingMode: .disallowed)
|
|
)
|
|
|
|
#expect(!response.content.isEmpty)
|
|
let hasToolCalls = session.transcript.contains { entry in
|
|
if case .toolCalls = entry { return true }
|
|
return false
|
|
}
|
|
#expect(!hasToolCalls)
|
|
}
|
|
|
|
@Test(
|
|
.requireIntegrationTestingBackend,
|
|
arguments: IntegrationTestingBackend.availableBackends
|
|
)
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func sessionRespondWithReasoningAndToolCalls(
|
|
backend: IntegrationTestingBackend
|
|
) async throws {
|
|
let model = try await backend.makeModel(modelID: ModelResource.gemini38FlashID)
|
|
let session = LanguageModelSession(
|
|
model: model,
|
|
tools: [WeatherTool()]
|
|
)
|
|
|
|
let response = try await session.respond(
|
|
to: "What is the weather in Chicago?"
|
|
)
|
|
|
|
#expect(!response.content.isEmpty)
|
|
let containsChicagoOrTemp =
|
|
response.content.localizedCaseInsensitiveContains("Chicago")
|
|
|| response.content.contains("72")
|
|
#expect(containsChicagoOrTemp)
|
|
let hasToolCalls = session.transcript.contains { entry in
|
|
if case .toolCalls = entry { return true }
|
|
return false
|
|
}
|
|
#expect(hasToolCalls)
|
|
}
|
|
}
|
|
#endif // canImport(FoundationModels) && compiler(>=6.4)
|