mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-10 22:45:59 +05:45
Add guided generation (structured output) support to GeminiLanguageModel conforming to Apple's LanguageModel. * Extend GenerationSchema to translate schemas into Gemini-compatible JSON Schema definitions, remapping x-order to propertyOrdering. * Detect unsupported regex pattern guides during encoding and throw LanguageModelError.unsupportedGenerationGuide. * Introduce GeminiRequestTranslator to map generation requests and schemas into GenerateContentRequest and GenerationConfig. * Delegate request translation in GeminiLanguageModelExecutor to GeminiRequestTranslator. * Declare the .guidedGeneration capability on GeminiLanguageModel. * Add comprehensive unit tests covering property ordering, nested definitions, enums, recursive schemas, and error throwing. * Add parameterized integration tests for single-turn and streaming structured output generation, enum classification, and recursive hierarchies.
169 lines
6.3 KiB
Swift
169 lines
6.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 guided generation (structured output) using `GeminiLanguageModel`.
|
|
@Suite(
|
|
"Guided Generation Integration Tests",
|
|
.requireFoundationModels,
|
|
.tags(.integration)
|
|
)
|
|
struct GuidedGenerationIntegrationTests {
|
|
@Generable(description: "A summary of a city")
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
@available(tvOS, unavailable)
|
|
struct CitySummary {
|
|
var name: String
|
|
var country: String
|
|
}
|
|
|
|
@Test(
|
|
.requireIntegrationTestingBackend,
|
|
arguments: IntegrationTestingBackend.availableBackends
|
|
)
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func sessionRespondWithSchema(backend: IntegrationTestingBackend) async throws {
|
|
let model = try await backend.makeModel()
|
|
let session = LanguageModelSession(model: model)
|
|
|
|
let response = try await session.respond(
|
|
to: "Provide details for the city of Paris.",
|
|
generating: CitySummary.self
|
|
)
|
|
|
|
#expect(!response.content.name.isEmpty)
|
|
#expect(!response.content.country.isEmpty)
|
|
#expect(response.usage.totalTokenCount > 0)
|
|
#expect(response.usage.input.totalTokenCount > 0)
|
|
#expect(response.usage.output.totalTokenCount > 0)
|
|
}
|
|
|
|
@Test(.requireIntegrationTestingBackend, arguments: IntegrationTestingBackend.availableBackends)
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func sessionStreamResponseWithSchema(backend: IntegrationTestingBackend) async throws {
|
|
let model = try await backend.makeModel()
|
|
let session = LanguageModelSession(model: model)
|
|
|
|
let stream = session.streamResponse(
|
|
to: "Provide details for the city of Tokyo.",
|
|
generating: CitySummary.self
|
|
)
|
|
var snapshotCount = 0
|
|
for try await _ in stream {
|
|
snapshotCount += 1
|
|
}
|
|
let response = try await stream.collect()
|
|
|
|
#expect(snapshotCount > 0)
|
|
#expect(!response.content.name.isEmpty)
|
|
#expect(!response.content.country.isEmpty)
|
|
#expect(response.usage.totalTokenCount > 0)
|
|
#expect(response.usage.input.totalTokenCount > 0)
|
|
#expect(response.usage.output.totalTokenCount > 0)
|
|
}
|
|
|
|
@Generable(description: "A priority level for a support ticket")
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
@available(tvOS, unavailable)
|
|
enum TicketPriority: String {
|
|
case low
|
|
case medium
|
|
case high
|
|
case critical
|
|
}
|
|
|
|
@Generable(description: "A classified support ticket")
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
@available(tvOS, unavailable)
|
|
struct TicketClassification {
|
|
var summary: String
|
|
var priority: TicketPriority
|
|
}
|
|
|
|
@Test(.requireIntegrationTestingBackend, arguments: IntegrationTestingBackend.availableBackends)
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func sessionRespondWithEnumClassification(backend: IntegrationTestingBackend) async throws {
|
|
let model = try await backend.makeModel()
|
|
let session = LanguageModelSession(model: model)
|
|
|
|
let response = try await session.respond(
|
|
to: "The entire production database server is down and users cannot log in.",
|
|
generating: TicketClassification.self
|
|
)
|
|
|
|
#expect(!response.content.summary.isEmpty)
|
|
#expect(
|
|
response.content.priority == .high || response.content.priority == .critical
|
|
)
|
|
#expect(response.usage.totalTokenCount > 0)
|
|
#expect(response.usage.input.totalTokenCount > 0)
|
|
#expect(response.usage.output.totalTokenCount > 0)
|
|
}
|
|
|
|
@Generable(description: "An organization node in an organizational hierarchy")
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
@available(tvOS, unavailable)
|
|
struct OrganizationNode {
|
|
var name: String
|
|
var headcount: Int
|
|
var budgetMillions: Double
|
|
var isActive: Bool
|
|
var notes: String?
|
|
var skills: [String]
|
|
var subteams: [OrganizationNode]
|
|
}
|
|
|
|
@Test(.requireIntegrationTestingBackend, arguments: IntegrationTestingBackend.availableBackends)
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func sessionRespondWithRecursiveHierarchy(backend: IntegrationTestingBackend) async throws {
|
|
let model = try await backend.makeModel()
|
|
let session = LanguageModelSession(model: model)
|
|
|
|
let prompt = """
|
|
Generate an engineering department named "Platform" with 50 people, a budget of \
|
|
4.5 million, active status, notes "Core infrastructure team", skills ["Swift", "Go"], \
|
|
and one subteam named "Networking" with 10 people, budget 1.0 million, active status, \
|
|
skills ["TCP", "HTTP"], and no subteams.
|
|
"""
|
|
let response = try await session.respond(
|
|
to: prompt,
|
|
generating: OrganizationNode.self
|
|
)
|
|
|
|
#expect(!response.content.name.isEmpty)
|
|
#expect(response.content.headcount > 0)
|
|
#expect(response.content.budgetMillions > 0.0)
|
|
#expect(response.content.isActive)
|
|
#expect(!response.content.skills.isEmpty)
|
|
#expect(response.content.subteams.count >= 1)
|
|
let firstSubteam = try #require(response.content.subteams.first)
|
|
#expect(!firstSubteam.name.isEmpty)
|
|
#expect(firstSubteam.headcount > 0)
|
|
#expect(firstSubteam.budgetMillions > 0.0)
|
|
#expect(firstSubteam.isActive)
|
|
#expect(response.usage.totalTokenCount > 0)
|
|
#expect(response.usage.input.totalTokenCount > 0)
|
|
#expect(response.usage.output.totalTokenCount > 0)
|
|
}
|
|
}
|
|
#endif // canImport(FoundationModels) && compiler(>=6.4)
|