mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-09 22:36:01 +05:45
Default to VALIDATED function calling mode in GeminiLanguageModel and switch guided generation to responseJsonSchema. Previously, tool-calling requests mapped the default or allowed mode to AUTO, which does not strictly enforce parameter schemas. Because Swift decodes tool arguments strictly, schema malformation results in failed turns. Additionally, guided generation used responseFormat, which the Gemini backend ignores when VALIDATED mode is active during tool-calling sessions, dropping response schemas and causing structured decoding failures in respond(to:generating:). * Default unset and explicit .allowed toolCallingMode to VALIDATED mode to strictly enforce schema adherence via constrained decoding. * Throw LanguageModelError.unsupportedCapability on unrecognized future tool calling modes instead of silently falling back to allowed. * Omit functionCallingConfig entirely when requests declare no functions to avoid contradictory constrained decoding states. * Send responseJsonSchema with application/json MIME type for guided generation, which constrained decoding honors alongside VALIDATED. * Remove unused _responseJsonSchema property from GenerationConfig. * End SSE response streams and cancel tasks upon receiving terminal finish reasons or prompt feedback block reasons. * Add integration test for combined tool calling and guided generation. * Refresh cached App Check debug tokens proactively within TTL window.
298 lines
9.9 KiB
Swift
298 lines
9.9 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 22°C 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"
|
|
}
|
|
}
|
|
|
|
/// A structured weather summary used to exercise guided generation alongside tool calling.
|
|
@Generable
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
@available(tvOS, unavailable)
|
|
struct CurrentWeather {
|
|
@Generable
|
|
enum Conditions {
|
|
case cloudy
|
|
case rainy
|
|
case sunny
|
|
}
|
|
|
|
@Guide(description: "The current temperature in Celsius")
|
|
let temperature: Int
|
|
|
|
@Guide(description: "The current weather conditions")
|
|
let conditions: Conditions
|
|
}
|
|
|
|
@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("22")
|
|
#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("22")
|
|
#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("22")
|
|
#expect(containsChicagoOrTemp)
|
|
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 sessionRespondToolCallAndGuidedGeneration(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?",
|
|
generating: CurrentWeather.self
|
|
)
|
|
|
|
#expect(response.content.temperature == 22)
|
|
#expect(response.content.conditions == .sunny)
|
|
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)
|
|
}
|
|
}
|
|
#endif // canImport(FoundationModels) && compiler(>=6.4)
|