Files
firebase-ios-sdk/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiTranscriptTranslatorTests.swift
T
Andrew Heard 7828d410ba feat(ai): add GeminiLanguageModel tool calling (#16620)
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.
2026-09-08 18:19:18 -04:00

531 lines
19 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 GeminiAPIDataModels
import GeminiTestUtilities
import Testing
@testable import GeminiLanguageModel
@Suite("GeminiTranscriptTranslator Tests", .requireFoundationModels)
struct GeminiTranscriptTranslatorTests {
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func translatesInstructionsAndPrompt() throws {
let transcript = Transcript(
entries: [
.instructions(
Transcript.Instructions(
segments: [.text(Transcript.TextSegment(content: "You are a helpful assistant."))],
toolDefinitions: []
)
),
.prompt(
Transcript.Prompt(
segments: [.text(Transcript.TextSegment(content: "Hello!"))]
)
),
]
)
let result = try GeminiTranscriptTranslator.translate(transcript)
let systemInstruction = try #require(result.systemInstruction)
#expect(
systemInstruction.parts?.first?.data == .text("You are a helpful assistant."))
#expect(result.contents.count == 1)
#expect(result.contents.first?.role == "user")
#expect(result.contents.first?.parts?.first?.data == .text("Hello!"))
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func coalescesReasoningAndResponseIntoSingleModelTurn() throws {
let signatureData = Data("test-sig".utf8)
let transcript = Transcript(
entries: [
.prompt(
Transcript.Prompt(
segments: [.text(Transcript.TextSegment(content: "Explain 2+2."))]
)
),
.reasoning(
Transcript.Reasoning(
segments: [.text(Transcript.TextSegment(content: "Thinking about addition..."))],
signature: signatureData
)
),
.response(
Transcript.Response(
segments: [.text(Transcript.TextSegment(content: "2+2 is 4."))]
)
),
]
)
let result = try GeminiTranscriptTranslator.translate(transcript)
#expect(result.contents.count == 2)
#expect(result.contents[0].role == "user")
#expect(result.contents[1].role == "model")
let modelParts = try #require(result.contents[1].parts)
#expect(modelParts.count == 2)
#expect(modelParts[0].thought == true)
#expect(modelParts[0].thoughtSignature == "test-sig")
#expect(modelParts[0].data == .text("Thinking about addition..."))
#expect(modelParts[1].data == .text("2+2 is 4."))
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func preservesThoughtSignatureWhenReasoningSegmentsEmpty() throws {
let signatureData = Data("sig-only".utf8)
let transcript = Transcript(
entries: [
.prompt(
Transcript.Prompt(
segments: [.text(Transcript.TextSegment(content: "Hi."))]
)
),
.reasoning(
Transcript.Reasoning(
segments: [],
signature: signatureData
)
),
.response(
Transcript.Response(
segments: [.text(Transcript.TextSegment(content: "Hello!"))]
)
),
]
)
let result = try GeminiTranscriptTranslator.translate(transcript)
#expect(result.contents.count == 2)
let modelParts = try #require(result.contents[1].parts)
#expect(modelParts.count == 2)
#expect(modelParts[0].thought == true)
#expect(modelParts[0].thoughtSignature == "sig-only")
#expect(modelParts[0].data == nil)
#expect(modelParts[1].data == .text("Hello!"))
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func translatesMultipleInstructionsEntriesIntoMultipleParts() throws {
let transcript = Transcript(
entries: [
.instructions(
Transcript.Instructions(
segments: [.text(Transcript.TextSegment(content: "First instruction."))],
toolDefinitions: []
)
),
.instructions(
Transcript.Instructions(
segments: [.text(Transcript.TextSegment(content: "Second instruction."))],
toolDefinitions: []
)
),
.prompt(
Transcript.Prompt(
segments: [.text(Transcript.TextSegment(content: "Hello!"))]
)
),
]
)
let result = try GeminiTranscriptTranslator.translate(transcript)
let systemInstruction = try #require(result.systemInstruction)
let parts = try #require(systemInstruction.parts)
#expect(parts.count == 2)
#expect(parts[0].data == .text("First instruction."))
#expect(parts[1].data == .text("Second instruction."))
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func translatesToolCallsIntoModelTurn() throws {
let args = try GeneratedContent(json: "{\"location\":\"Paris\"}")
let toolCall = Transcript.ToolCall(
id: "call-1",
toolName: "get_weather",
arguments: args
)
let transcript = Transcript(
entries: [
.prompt(
Transcript.Prompt(
segments: [.text(Transcript.TextSegment(content: "Weather?"))]
)
),
.toolCalls(Transcript.ToolCalls(id: "calls-1", [toolCall])),
]
)
let result = try GeminiTranscriptTranslator.translate(transcript)
#expect(result.contents.count == 2)
#expect(result.contents[0].role == "user")
#expect(result.contents[1].role == "model")
let modelParts = try #require(result.contents[1].parts)
#expect(modelParts.count == 1)
guard case .functionCall(let functionCall) = modelParts[0].data else {
Issue.record("Expected functionCall part data")
return
}
#expect(functionCall.id == "call-1")
#expect(functionCall.name == "get_weather")
#expect(functionCall.args?["location"] == .string("Paris"))
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func translatesToolOutputWithTextSegmentWrappingUnderResult() throws {
let toolOutput = Transcript.ToolOutput(
id: "call-1",
toolName: "get_weather",
segments: [.text(Transcript.TextSegment(content: "Sunny, 22°C"))]
)
let transcript = Transcript(entries: [.toolOutput(toolOutput)])
let result = try GeminiTranscriptTranslator.translate(transcript)
#expect(result.contents.count == 1)
#expect(result.contents[0].role == "user")
let userParts = try #require(result.contents[0].parts)
#expect(userParts.count == 1)
guard case .functionResponse(let functionResponse) = userParts[0].data else {
Issue.record("Expected functionResponse part data")
return
}
#expect(functionResponse.id == "call-1")
#expect(functionResponse.name == "get_weather")
#expect(functionResponse.response?["result"] == .string("Sunny, 22°C"))
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func translatesToolOutputWithJSONTextPreservingStringUnderResult() throws {
let jsonString = "{\"temperature\":22,\"condition\":\"sunny\"}"
let toolOutput = Transcript.ToolOutput(
id: "call-1",
toolName: "get_weather",
segments: [
.text(Transcript.TextSegment(content: jsonString))
]
)
let transcript = Transcript(entries: [.toolOutput(toolOutput)])
let result = try GeminiTranscriptTranslator.translate(transcript)
let userParts = try #require(result.contents.first?.parts)
guard case .functionResponse(let functionResponse) = userParts.first?.data else {
Issue.record("Expected functionResponse part data")
return
}
#expect(functionResponse.id == "call-1")
#expect(functionResponse.name == "get_weather")
#expect(functionResponse.response?["result"] == .string(jsonString))
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func translatesToolOutputWithStructuredSegmentUnpackingDirectly() throws {
let content = try GeneratedContent(json: "{\"temperature\":22}")
let structuredSegment = Transcript.StructuredSegment(
id: "seg-1",
schemaName: "Weather",
content: content
)
let toolOutput = Transcript.ToolOutput(
id: "call-1",
toolName: "get_weather",
segments: [.structure(structuredSegment)]
)
let transcript = Transcript(entries: [.toolOutput(toolOutput)])
let result = try GeminiTranscriptTranslator.translate(transcript)
let userParts = try #require(result.contents.first?.parts)
guard case .functionResponse(let functionResponse) = userParts.first?.data else {
Issue.record("Expected functionResponse part data")
return
}
#expect(functionResponse.id == "call-1")
#expect(functionResponse.name == "get_weather")
#expect(functionResponse.response?["temperature"] == .number(22))
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func coalescesParallelToolCallsAndOutputs() throws {
let call1 = Transcript.ToolCall(
id: "call-1",
toolName: "get_weather",
arguments: try GeneratedContent(json: "{\"location\":\"Paris\"}")
)
let call2 = Transcript.ToolCall(
id: "call-2",
toolName: "get_time",
arguments: try GeneratedContent(json: "{\"location\":\"Tokyo\"}")
)
let output1 = Transcript.ToolOutput(
id: "call-1",
toolName: "get_weather",
segments: [.text(Transcript.TextSegment(content: "22°C"))]
)
let output2 = Transcript.ToolOutput(
id: "call-2",
toolName: "get_time",
segments: [.text(Transcript.TextSegment(content: "15:00"))]
)
let transcript = Transcript(
entries: [
.toolCalls(Transcript.ToolCalls(id: "calls-1", [call1, call2])),
.toolOutput(output1),
.toolOutput(output2),
]
)
let result = try GeminiTranscriptTranslator.translate(transcript)
#expect(result.contents.count == 2)
#expect(result.contents[0].role == "model")
let modelParts = try #require(result.contents[0].parts)
#expect(modelParts.count == 2)
guard case .functionCall(let fc1) = modelParts[0].data,
case .functionCall(let fc2) = modelParts[1].data
else {
Issue.record("Expected two functionCall parts")
return
}
#expect(fc1.id == "call-1")
#expect(fc2.id == "call-2")
#expect(result.contents[1].role == "user")
let userParts = try #require(result.contents[1].parts)
#expect(userParts.count == 2)
guard case .functionResponse(let fr1) = userParts[0].data,
case .functionResponse(let fr2) = userParts[1].data
else {
Issue.record("Expected two functionResponse parts in single user turn")
return
}
#expect(fr1.id == "call-1")
#expect(fr2.id == "call-2")
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func translatesToolCallsWithEmptyArguments() throws {
let call = Transcript.ToolCall(
id: "call-1",
toolName: "get_time",
arguments: try GeneratedContent(json: "{}")
)
let transcript = Transcript(
entries: [.toolCalls(Transcript.ToolCalls(id: "calls-1", [call]))]
)
let result = try GeminiTranscriptTranslator.translate(transcript)
let modelParts = try #require(result.contents.first?.parts)
#expect(modelParts.count == 1)
guard case .functionCall(let functionCall) = modelParts[0].data else {
Issue.record("Expected functionCall part")
return
}
#expect(functionCall.id == "call-1")
#expect(functionCall.name == "get_time")
#expect(functionCall.args == nil)
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func coalescesReasoningAndToolCallsIntoSingleModelTurn() throws {
let signatureData = Data("test-sig".utf8)
let call = Transcript.ToolCall(
id: "call-1",
toolName: "get_weather",
arguments: try GeneratedContent(json: "{\"location\":\"Paris\"}")
)
let transcript = Transcript(
entries: [
.prompt(Transcript.Prompt(segments: [.text(Transcript.TextSegment(content: "Hi."))])),
.reasoning(
Transcript.Reasoning(
segments: [.text(Transcript.TextSegment(content: "Need to check weather."))],
signature: signatureData
)
),
.toolCalls(Transcript.ToolCalls(id: "calls-1", [call])),
]
)
let result = try GeminiTranscriptTranslator.translate(transcript)
#expect(result.contents.count == 2)
#expect(result.contents[0].role == "user")
#expect(result.contents[1].role == "model")
let modelParts = try #require(result.contents[1].parts)
#expect(modelParts.count == 2)
#expect(modelParts[0].thought == true)
#expect(modelParts[0].thoughtSignature == nil)
#expect(modelParts[0].data == .text("Need to check weather."))
#expect(modelParts[1].thoughtSignature == "test-sig")
guard case .functionCall(let functionCall) = modelParts[1].data else {
Issue.record("Expected functionCall part")
return
}
#expect(functionCall.id == "call-1")
#expect(functionCall.name == "get_weather")
}
@Generable
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
@available(tvOS, unavailable)
struct City {
var name: String
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func instructionsWithToolDefinitionsTranslatesCleanly() throws {
let toolDefinition = Transcript.ToolDefinition(
name: "get_weather",
description: "Get weather",
parameters: City.generationSchema
)
let transcript = Transcript(
entries: [
.instructions(
Transcript.Instructions(
segments: [.text(Transcript.TextSegment(content: "Be concise."))],
toolDefinitions: [toolDefinition]
)
),
.prompt(Transcript.Prompt(segments: [.text(Transcript.TextSegment(content: "Hello"))])),
]
)
let result = try GeminiTranscriptTranslator.translate(transcript)
let systemInstruction = try #require(result.systemInstruction)
#expect(systemInstruction.parts?.first?.data == Part.PartData.text("Be concise."))
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func translatesToolOutputWithMultipleSegmentsIntoMultipleParts() throws {
let toolOutput = Transcript.ToolOutput(
id: "call-1",
toolName: "get_weather",
segments: [
.text(Transcript.TextSegment(content: "Part 1")),
.text(Transcript.TextSegment(content: "Part 2")),
]
)
let transcript = Transcript(entries: [.toolOutput(toolOutput)])
let result = try GeminiTranscriptTranslator.translate(transcript)
let userParts = try #require(result.contents.first?.parts)
#expect(userParts.count == 2)
guard case .functionResponse(let fr1) = userParts[0].data,
case .functionResponse(let fr2) = userParts[1].data
else {
Issue.record("Expected functionResponse part data for both segments")
return
}
#expect(fr1.id == "call-1")
#expect(fr1.name == "get_weather")
#expect(fr1.response?["result"] == .string("Part 1"))
#expect(fr2.id == "call-1")
#expect(fr2.name == "get_weather")
#expect(fr2.response?["result"] == .string("Part 2"))
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func translatesToolOutputWithAttachmentThrowsUnsupportedTranscriptContent() throws {
let imageAttachment = Transcript.ImageAttachment(
imageURL: URL(fileURLWithPath: "/tmp/test.png")
)
let attachment = Transcript.Segment.attachment(
Transcript.AttachmentSegment(
id: "att-1",
content: .image(imageAttachment)
)
)
let toolOutput = Transcript.ToolOutput(
id: "call-1",
toolName: "get_weather",
segments: [
.text(Transcript.TextSegment(content: "Here is the image:")),
attachment,
]
)
let transcript = Transcript(entries: [.toolOutput(toolOutput)])
do {
_ = try GeminiTranscriptTranslator.translate(transcript)
Issue.record("Expected unsupportedTranscriptContent error")
} catch LanguageModelError.unsupportedTranscriptContent {
// Expected
} catch {
Issue.record("Unexpected error thrown: \(error)")
}
}
@Test
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
func emptyToolCallsDoesNotDropPendingReasoningSignature() throws {
let signatureData = Data("test-sig".utf8)
let prompt = Transcript.Prompt(
segments: [.text(Transcript.TextSegment(content: "Hi"))]
)
let reasoning = Transcript.Reasoning(
segments: [.text(Transcript.TextSegment(content: "Thinking..."))],
signature: signatureData
)
let emptyToolCalls = Transcript.ToolCalls(id: "calls-empty", [])
let finalResponse = Transcript.Response(
segments: [.text(Transcript.TextSegment(content: "Hello!"))]
)
let transcript = Transcript(
entries: [
.prompt(prompt),
.reasoning(reasoning),
.toolCalls(emptyToolCalls),
.response(finalResponse),
]
)
let result = try GeminiTranscriptTranslator.translate(transcript)
let modelTurn = try #require(result.contents.last)
let modelParts = try #require(modelTurn.parts)
#expect(modelParts.first?.thoughtSignature == "test-sig")
}
}
#endif // canImport(FoundationModels) && compiler(>=6.4)