mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-09 22:36:01 +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.
531 lines
19 KiB
Swift
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)
|