Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions FirebaseAI/Sources/GenerateObjectError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright 2025 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.

import Foundation

enum GenerateObjectError: Error, CustomStringConvertible {
case responseTextError(String)
case jsonDecodingError(Error)

var description: String {
switch self {
case let .responseTextError(message):
return message
case let .jsonDecodingError(error):
return "Failed to decode JSON: \(error)"
}
}
}
62 changes: 62 additions & 0 deletions FirebaseAI/Sources/GenerativeModel+GenerateObject.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2025 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.

import Foundation

@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
public extension GenerativeModel {
/// Generates a structured object of a specified type that conforms to ``FirebaseGenerable``.
///
/// This method simplifies the process of generating structured data by handling the schema
/// generation, API request, and JSON decoding automatically.
///
/// - Parameters:
/// - type: The `FirebaseGenerable` type to generate.
/// - prompt: The text prompt to send to the model.
/// - Returns: An instance of the requested type, decoded from the model's JSON response.
/// - Throws: A ``GenerateContentError`` if the model fails to generate the content or if
/// the response cannot be decoded into the specified type.
func generateObject<T: FirebaseGenerable>(as type: T.Type,
from prompt: String) async throws -> T {
let model = GenerativeModel(
modelName: modelName,
modelResourceName: modelResourceName,
firebaseInfo: generativeAIService.firebaseInfo,
apiConfig: apiConfig,
generationConfig: GenerationConfig(
responseMIMEType: "application/json",
responseSchema: T.firebaseGenerationSchema
),
safetySettings: safetySettings,
tools: tools,
toolConfig: toolConfig,
systemInstruction: systemInstruction,
requestOptions: requestOptions
)
let response = try await model.generateContent(prompt)

guard let text = response.text, let data = text.data(using: .utf8) else {
throw GenerateContentError.internalError(
underlying: GenerateObjectError.responseTextError("Failed to get response text or data.")
)
}

do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw GenerateContentError
.internalError(underlying: GenerateObjectError.jsonDecodingError(error))
}
}
}
22 changes: 22 additions & 0 deletions FirebaseAI/Sources/Types/Public/FirebaseGenerable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright 2025 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.

import Foundation

/// A type that can be generated by a large language model.
@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
public protocol FirebaseGenerable: Decodable {
/// A schema describing the structure of the generated type.
static var firebaseGenerationSchema: Schema { get }
}
23 changes: 23 additions & 0 deletions FirebaseAI/Sources/Types/Public/FirebaseGenerableMacro.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2025 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.

import Foundation

/// A macro that makes a ``Decodable`` type ``FirebaseGenerable``.
@attached(member, names: named(firebaseGenerationSchema))
@attached(extension, conformances: FirebaseGenerable)
public macro FirebaseGenerable() = #externalMacro(
module: "FirebaseAILogicMacro",
type: "FirebaseGenerableMacro"
)
27 changes: 27 additions & 0 deletions FirebaseAI/Sources/Types/Public/Schema+FirebaseGenerable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2025 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.

import Foundation

@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
public extension Schema {
/// Returns a `Schema` representing a `Decodable` type.
static func from<T: Decodable>(_ type: T.Type) -> Schema {
if let type = type as? FirebaseGenerable.Type {
return type.firebaseGenerationSchema
}

return .object(properties: [:])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,150 @@ struct GenerateContentIntegrationTests {
#expect(candidatesTokensDetails.tokenCount == usageMetadata.candidatesTokenCount)
}

@Test("Generate a JSON object", arguments: InstanceConfig.allConfigs)
func generateContentJSONObject(_ config: InstanceConfig) async throws {
struct Recipe: Codable {
let name: String
let ingredients: [Ingredient]
let isDelicious: Bool
}

struct Ingredient: Codable {
let name: String
let quantity: Int
}

let expectedResponse = Recipe(
name: "Apple Pie",
ingredients: [
Ingredient(name: "Apple", quantity: 6),
Ingredient(name: "Cinnamon", quantity: 1),
Ingredient(name: "Sugar", quantity: 1),
],
isDelicious: true
)
let recipeSchema = Schema.object(properties: [
"name": .string(),
"ingredients": .array(items: .object(properties: [
"name": .string(),
"quantity": .integer(),
])),
"isDelicious": .boolean(),
])
let model = FirebaseAI.componentInstance(config).generativeModel(
modelName: ModelNames.gemini2FlashLite,
generationConfig: GenerationConfig(
responseMIMEType: "application/json",
responseSchema: recipeSchema
),
safetySettings: safetySettings,
systemInstruction: ModelContent(
role: "system",
parts: "Always respond with a recipe for apple pie."
)
)
let prompt = "Give me a recipe for a dessert."

let response = try await model.generateContent(prompt)

let responseData = try #require(response.text?.data(using: .utf8))
let recipe = try JSONDecoder().decode(Recipe.self, from: responseData)
#expect(recipe.name.lowercased() == expectedResponse.name.lowercased())
#expect(recipe.ingredients.count >= expectedResponse.ingredients.count)
#expect(recipe.isDelicious == expectedResponse.isDelicious)

let usageMetadata = try #require(response.usageMetadata)
#expect(usageMetadata.promptTokenCount.isEqual(to: 36, accuracy: tokenCountAccuracy))
#expect(usageMetadata.candidatesTokenCount >= 92)
#expect(usageMetadata.thoughtsTokenCount == 0)
#expect(usageMetadata.totalTokenCount
== usageMetadata.promptTokenCount + usageMetadata.candidatesTokenCount)
#expect(usageMetadata.promptTokensDetails.count == 1)
let promptTokensDetails = try #require(usageMetadata.promptTokensDetails.first)
#expect(promptTokensDetails.modality == .text)
#expect(promptTokensDetails.tokenCount == usageMetadata.promptTokenCount)
#expect(usageMetadata.candidatesTokensDetails.count == 1)
let candidatesTokensDetails = try #require(usageMetadata.candidatesTokensDetails.first)
#expect(candidatesTokensDetails.modality == .text)
#expect(candidatesTokensDetails.tokenCount == usageMetadata.candidatesTokenCount)
}

@FirebaseGenerable
struct Ingredient: Codable {
let name: String
let quantity: Int
}

@FirebaseGenerable
struct Dessert: Codable {
let name: String
let ingredients: [Ingredient]
let isDelicious: Bool
}

@Test("Generate a JSON object with @FirebaseGenerable", arguments: InstanceConfig.allConfigs)
func generateContentWithFirebaseGenerable(_ config: InstanceConfig) async throws {
let expectedResponse = Dessert(
name: "Apple Pie",
ingredients: [
Ingredient(name: "Apple", quantity: 6),
Ingredient(name: "Cinnamon", quantity: 1),
Ingredient(name: "Sugar", quantity: 1),
],
isDelicious: true
)
let model = FirebaseAI.componentInstance(config).generativeModel(
modelName: ModelNames.gemini2FlashLite,
generationConfig: GenerationConfig(
responseMIMEType: "application/json",
responseSchema: Dessert.firebaseGenerationSchema
),
safetySettings: safetySettings,
systemInstruction: ModelContent(
role: "system",
parts: "Always respond with a recipe for apple pie."
)
)
let prompt = "Give me a recipe for a dessert."

let response = try await model.generateContent(prompt)

let responseData = try #require(response.text?.data(using: .utf8))
let dessert = try JSONDecoder().decode(Dessert.self, from: responseData)
#expect(dessert.name.lowercased() == expectedResponse.name.lowercased())
#expect(dessert.ingredients.count >= expectedResponse.ingredients.count)
#expect(dessert.isDelicious == expectedResponse.isDelicious)
}

@Test("Generate a JSON object with generateObject", arguments: InstanceConfig.allConfigs)
func generateObject(_ config: InstanceConfig) async throws {
let expectedResponse = Dessert(
name: "Apple Pie",
ingredients: [
Ingredient(name: "Apple", quantity: 6),
Ingredient(name: "Cinnamon", quantity: 1),
Ingredient(name: "Sugar", quantity: 1),
],
isDelicious: true
)
let model = FirebaseAI.componentInstance(config).generativeModel(
modelName: ModelNames.gemini2FlashLite,
systemInstruction: ModelContent(
role: "system",
parts: "Always respond with a recipe for apple pie."
)
)

let dessert = try await model.generateObject(
as: Dessert.self,
from: "Give me a recipe for a dessert."
)

#expect(dessert.name.lowercased() == expectedResponse.name.lowercased())
#expect(dessert.ingredients.count >= expectedResponse.ingredients.count)
#expect(dessert.isDelicious == expectedResponse.isDelicious)
}

@Test(
arguments: [
(.vertexAI_v1beta, ModelNames.gemini2_5_Flash, ThinkingConfig(thinkingBudget: 0)),
Expand Down
Loading
Loading