Skip to content
Draft
110 changes: 106 additions & 4 deletions FirebaseAI/Sources/GenerativeModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -370,8 +370,9 @@ public final class GenerativeModel: Sendable {
/// Produces a generable object as a response to a prompt.
///
/// - Parameters:
/// - prompt: A prompt for the model to respond to.
/// - type: A type to produce as the response.
/// - parts: The input(s) given to the model as a prompt (see ``PartsRepresentable`` for
/// conforming types).
/// - Returns: ``GeneratedContent`` containing the fields and values defined in the schema.
@available(iOS 26.0, macOS 26.0, *)
@available(tvOS, unavailable)
Expand Down Expand Up @@ -405,16 +406,61 @@ public final class GenerativeModel: Sendable {
)

// TODO: Remove when extraneous '```json' prefix from JSON payload no longer returned.
let json = response.text?.replacingOccurrences(of: "```json", with: "") ?? ""
let json = GenerativeModel.cleanedJSON(from: response.text)

let generatedContent = try GeneratedContent(json: json)
let rawContent = try GenerativeModel.parseModelOutput(from: json)
let generatedContent = rawContent.generatedContent
let content = try Content(generatedContent)
let rawContent = try ModelOutput(generatedContent)

return Response(content: content, rawContent: rawContent)
}
#endif // canImport(FoundationModels)

/// Produces a generable object as a response to a prompt.
///
/// - Parameters:
/// - type: A type to produce as the response.
/// - parts: The input(s) given to the model as a prompt (see ``PartsRepresentable`` for
/// conforming types).
/// - Returns: ``Response`` containing the fields and values defined in the schema.
@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
public final func generateObject<Content>(_ type: Content.Type = Content.self,
parts: any PartsRepresentable...) async throws
-> Response<Content>
where Content: FirebaseGenerable {
let jsonSchema = try type.jsonSchema.asGeminiJSONSchema()

let generationConfig = {
var generationConfig = self.generationConfig ?? GenerationConfig()
if generationConfig.candidateCount != nil {
generationConfig.candidateCount = nil
}
generationConfig.responseMIMEType = "application/json"
if generationConfig.responseSchema != nil {
generationConfig.responseSchema = nil
}
generationConfig.responseJSONSchema = jsonSchema
if generationConfig.responseModalities != nil {
generationConfig.responseModalities = nil
}

return generationConfig
}()

let response = try await generateContent(
[ModelContent(parts: parts)],
generationConfig: generationConfig
)

// TODO: Remove when extraneous '```json' prefix from JSON payload no longer returned.
let json = GenerativeModel.cleanedJSON(from: response.text)

let rawContent = try GenerativeModel.parseModelOutput(from: json)
let content = try Content(rawContent)

return Response(content: content, rawContent: rawContent)
}

#if canImport(FoundationModels)
// TODO: Remove this method when Gemini vs. AFM is a configuration (hybrid mode)
@available(iOS 26.0, macOS 26.0, *)
Expand Down Expand Up @@ -446,6 +492,38 @@ public final class GenerativeModel: Sendable {
return GenerateContentError.internalError(underlying: error)
}

/// Returns a JSON string cleaned of markdown code block delimiters.
private static func cleanedJSON(from text: String?) -> String {
var json = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if json.hasPrefix("```json") {
json = String(json.dropFirst("```json".count))
}
if json.hasSuffix("```") {
json = String(json.dropLast("```".count))
}
return json.trimmingCharacters(in: .whitespacesAndNewlines)
}

/// Parses a cleaned JSON string into a `ModelOutput`.
private static func parseModelOutput(from json: String) throws -> ModelOutput {
guard let data = json.data(using: .utf8) else {
throw GenerationError.decodingFailure(GenerationError.Context(
debugDescription: "Failed to convert JSON string to data."
))
}

let jsonValue: JSONValue
do {
jsonValue = try JSONDecoder().decode(JSONValue.self, from: data)
} catch {
throw GenerationError.decodingFailure(GenerationError.Context(
debugDescription: "Failed to decode JSON: \(error)"
))
}

return ModelOutput(jsonValue: jsonValue)
}

/// A structure that stores the output of a response call.
@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
public struct Response<Content> {
Expand All @@ -458,3 +536,27 @@ public final class GenerativeModel: Sendable {
public let rawContent: ModelOutput
}
}

@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
private extension ModelOutput {
init(jsonValue: JSONValue) {
switch jsonValue {
case .null:
self.init(kind: .null)
case let .bool(value):
self.init(kind: .bool(value))
case let .number(value):
self.init(kind: .number(value))
case let .string(value):
self.init(kind: .string(value))
case let .array(values):
self.init(kind: .array(values.map { ModelOutput(jsonValue: $0) }))
case let .object(jsonObject):
// Sort keys to maintain a deterministic order, or respect original if possible (JSONObject is
// Dictionary, so unordered)
let orderedKeys = jsonObject.keys.sorted()
let properties = jsonObject.mapValues { ModelOutput(jsonValue: $0) }
self.init(kind: .structure(properties: properties, orderedKeys: orderedKeys))
}
}
}
60 changes: 55 additions & 5 deletions FirebaseAI/Sources/Types/Public/Generable/JSONSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,59 @@ public struct JSONSchema: Sendable {
/// A suggestion that indicates how to handle the error.
public var recoverySuggestion: String? { nil }
}

func asGeminiJSONSchema() throws -> JSONObject {
let jsonRepresentation = try JSONEncoder().encode(self)
return try JSONDecoder().decode(JSONObject.self, from: jsonRepresentation)
}

private func makeInternal() -> Internal {
if let schema {
return schema
}
guard let kind else {
fatalError("JSONSchema must have either `schema` or `kind`.")
}
return kind.makeInternal()
}
}

@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
extension JSONSchema.Kind {
func makeInternal() -> JSONSchema.Internal {
switch self {
case .string:
return JSONSchema.Internal(type: .string)
case .integer:
return JSONSchema.Internal(type: .integer)
case .double:
return JSONSchema.Internal(type: .number)
case .boolean:
return JSONSchema.Internal(type: .boolean)
case let .array(item):
// Recursive call for array items
return JSONSchema.Internal(type: .array, items: item.jsonSchema.makeInternal())
case let .object(name, description, properties):
var internalProperties = [String: JSONSchema.Internal]()
var required = [String]()
var order = [String]()
for property in properties {
internalProperties[property.name] = property.type.jsonSchema.makeInternal()
if !property.isOptional {
required.append(property.name)
}
order.append(property.name)
}
return JSONSchema.Internal(
type: .object,
title: name,
description: description,
properties: internalProperties,
required: required.isEmpty ? nil : required,
order: order
)
}
}
}

@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
Expand All @@ -180,11 +233,8 @@ extension JSONSchema: Codable {
}

public func encode(to encoder: any Encoder) throws {
// TODO: Encode from `kind` and remove `schema` property.
guard let schema else {
fatalError("Cannot encode JSONSchema without an internal representation.")
}
try schema.encode(to: encoder)
let schemaToEncode = makeInternal()
try schemaToEncode.encode(to: encoder)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// 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 FirebaseAILogic
import FirebaseAITestApp
import FirebaseAuth
import FirebaseCore
import Testing

@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
struct Recipe: FirebaseGenerable, Equatable {
let name: String
let ingredients: [String]

static var jsonSchema: JSONSchema {
JSONSchema(
type: Self.self,
properties: [
JSONSchema.Property(name: "name", type: String.self),
JSONSchema.Property(name: "ingredients", type: [String].self),
]
)
}

init(_ content: ModelOutput) throws {
name = try content.value(forProperty: "name")
ingredients = try content.value(forProperty: "ingredients")
}

var modelOutput: ModelOutput {
let properties: [(String, any ConvertibleToModelOutput)] = [
("name", name),
("ingredients", ingredients),
]
return ModelOutput(properties: properties, uniquingKeysWith: { _, second in second })
}
}

@Suite(.serialized)
struct GenerateObjectIntegrationTests {
// Set temperature to 0 for deterministic output.
let generationConfig = GenerationConfig(temperature: 0.0, topP: 0.0, topK: 1)
let safetySettings = [
SafetySetting(harmCategory: .harassment, threshold: .blockLowAndAbove),
SafetySetting(harmCategory: .hateSpeech, threshold: .blockLowAndAbove),
SafetySetting(harmCategory: .sexuallyExplicit, threshold: .blockLowAndAbove),
SafetySetting(harmCategory: .dangerousContent, threshold: .blockLowAndAbove),
]

@Test(arguments: [
(InstanceConfig.vertexAI_v1beta, ModelNames.gemini2_5_Flash),
(InstanceConfig.googleAI_v1beta, ModelNames.gemini2_5_FlashLite),
(InstanceConfig.googleAI_v1beta, ModelNames.gemini3FlashPreview),
])
func generateObject_recipe(_ config: InstanceConfig, modelName: String) async throws {
let model = FirebaseAI.componentInstance(config).generativeModel(
modelName: modelName,
generationConfig: generationConfig,
safetySettings: safetySettings
)
let prompt = "Generate a recipe for chocolate chip cookies."

let response = try await model.generateObject(Recipe.self, parts: prompt)

let recipe = response.content
#expect(!recipe.name.isEmpty)
#expect(!recipe.ingredients.isEmpty)
#expect(recipe.name.lowercased().contains("cookie"))

// verify rawContent structure
let rawContent = response.rawContent
guard case .structure = rawContent.kind else {
Issue.record("Raw content should be a structure")
return
}
}

#if canImport(FoundationModels)
@Test(arguments: [
(InstanceConfig.vertexAI_v1beta, ModelNames.gemini2_5_Flash),
(InstanceConfig.googleAI_v1beta, ModelNames.gemini2_5_FlashLite),
(InstanceConfig.googleAI_v1beta, ModelNames.gemini3FlashPreview),
])
@available(iOS 26.0, macOS 26.0, *)
func generateObject_macroRecipe(_ config: InstanceConfig, modelName: String) async throws {
let model = FirebaseAI.componentInstance(config).generativeModel(
modelName: modelName,
generationConfig: generationConfig,
safetySettings: safetySettings
)
let prompt = "Generate a recipe for brownies."

// MacroRecipe is defined below
let response = try await model.generateObject(MacroRecipe.self, parts: prompt)

let recipe = response.content
#expect(!recipe.name.isEmpty)
#expect(!recipe.ingredients.isEmpty)
#expect(recipe.name.lowercased().contains("brownie"))
}
#endif
}

#if canImport(FoundationModels)
import FoundationModels

@available(iOS 26.0, macOS 26.0, *)
@Generable
struct MacroRecipe: Equatable {
let name: String
let ingredients: [String]
}
#endif
Loading