From bde28ed1a4b46e1884ad6d0b3e0ec8a7bcd78d9b Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 6 Feb 2026 16:50:07 -0500 Subject: [PATCH 01/17] [Firebase AI] Conform `ModelOutput` to `ConvertibleToGeneratedContent` --- .../ConvertibleFromModelOutput.swift | 16 +++++ .../ConvertibleToModelOutput.swift | 16 +++++ .../Public/StructuredOutput/ModelOutput.swift | 71 ++++++++++++++++++- .../Public/StructuredOutput/ResponseID.swift | 11 +++ 4 files changed, 113 insertions(+), 1 deletion(-) diff --git a/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleFromModelOutput.swift b/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleFromModelOutput.swift index d903abde2d1..b8b14ccf6a2 100644 --- a/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleFromModelOutput.swift +++ b/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleFromModelOutput.swift @@ -12,7 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if canImport(FoundationModels) +public import protocol FoundationModels.ConvertibleFromGeneratedContent +public import struct FoundationModels.GeneratedContent +#endif // canImport(FoundationModels) + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public protocol ConvertibleFromModelOutput { init(_ content: ModelOutput) throws } + +#if canImport(FoundationModels) +@available(iOS 26.0, macOS 26.0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +public extension ConvertibleFromModelOutput where Self: ConvertibleFromGeneratedContent { + init(_ content: ModelOutput) throws { + try self.init(try GeneratedContent(content)) + } +} +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleToModelOutput.swift b/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleToModelOutput.swift index 55c2628d17d..153bf5fa855 100644 --- a/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleToModelOutput.swift +++ b/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleToModelOutput.swift @@ -12,6 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +#if canImport(FoundationModels) +public import protocol FoundationModels.ConvertibleToGeneratedContent +public import struct FoundationModels.GeneratedContent +#endif // canImport(FoundationModels) + #if compiler(>=6.2) @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public protocol ConvertibleToModelOutput: SendableMetatype { @@ -23,3 +28,14 @@ var modelOutput: ModelOutput { get } } #endif // compiler(>=6.2) + +#if canImport(FoundationModels) +@available(iOS 26.0, macOS 26.0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +public extension ConvertibleToModelOutput where Self: ConvertibleToGeneratedContent { + var modelOutput: ModelOutput { + self.generatedContent.modelOutput + } +} +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift index 1f1942ed6c9..fb638a8052f 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift @@ -13,6 +13,11 @@ // limitations under the License. import Foundation +#if canImport(FoundationModels) +public import protocol FoundationModels.ConvertibleToGeneratedContent +public import struct FoundationModels.GenerationID +public import struct FoundationModels.GeneratedContent +#endif // canImport(FoundationModels) @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct ModelOutput: Sendable, CustomDebugStringConvertible, FirebaseGenerable { @@ -24,8 +29,9 @@ public struct ModelOutput: Sendable, CustomDebugStringConvertible, FirebaseGener public var id: ResponseID? - init(kind: Kind) { + init(kind: Kind, id: ResponseID? = nil) { self.kind = kind + self.id = id } public var debugDescription: String { @@ -173,3 +179,66 @@ public extension ModelOutput { } } } + +#if canImport(FoundationModels) +@available(iOS 26.0, macOS 26.0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension ModelOutput: ConvertibleToGeneratedContent { + public var generatedContent: GeneratedContent { + let generationID = id?.generationID + + switch kind { + case .null: + return GeneratedContent(kind: .null, id: generationID) + case let .bool(value): + return GeneratedContent(kind: .bool(value), id: generationID) + case let .number(value): + return GeneratedContent(kind: .number(value), id: generationID) + case let .string(value): + return GeneratedContent(kind: .string(value), id: generationID) + case let .array(values): + return GeneratedContent(kind: .array(values.map { $0.generatedContent }), id: generationID) + case let .structure(properties: properties, orderedKeys: orderedKeys): + return GeneratedContent( + kind: .structure( + properties: properties.mapValues { $0.generatedContent }, orderedKeys: orderedKeys + ), + id: generationID + ) + } + } +} + +@available(iOS 26.0, macOS 26.0, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +extension GeneratedContent: ConvertibleToModelOutput { + public var modelOutput: ModelOutput { + let responseID = self.id.map { ResponseID(generationID: $0) } + + switch self.kind { + case .null: + return ModelOutput(kind: .null, id: responseID) + case let .bool(value): + return ModelOutput(kind: .bool(value), id: responseID) + case let .number(value): + return ModelOutput(kind: .number(value), id: responseID) + case let .string(value): + return ModelOutput(kind: .string(value), id: responseID) + case let .array(values): + return ModelOutput(kind: .array(values.map { $0.modelOutput }), id: responseID) + case let .structure(properties: properties, orderedKeys: orderedKeys): + return ModelOutput( + kind: .structure( + properties: properties.mapValues { $0.modelOutput }, orderedKeys: orderedKeys + ), + id: responseID + ) + @unknown default: + assertionFailure("Unknown `FoundationModels.GeneratedContent` kind: \(kind)") + return ModelOutput(kind: .null, id: responseID) + } + } +} +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/ResponseID.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/ResponseID.swift index bc3552b45ba..61b88c6507d 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/ResponseID.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/ResponseID.swift @@ -50,6 +50,17 @@ public struct ResponseID: Sendable, Hashable { init(generationID: GenerationID) { identifier = .generationID(generationID) } + + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + var generationID: GenerationID? { + guard case let .generationID(value) = identifier else { + return nil + } + + return value as? GenerationID + } #endif // canImport(FoundationModels) } From 265fc39fb32e033d2d54fce3c33d7338308e31f8 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 6 Feb 2026 16:58:25 -0500 Subject: [PATCH 02/17] Fix formatting --- .../ConvertibleFromModelOutput.swift | 18 +-- .../ConvertibleToModelOutput.swift | 18 +-- .../Public/StructuredOutput/ModelOutput.swift | 118 +++++++++--------- 3 files changed, 77 insertions(+), 77 deletions(-) diff --git a/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleFromModelOutput.swift b/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleFromModelOutput.swift index b8b14ccf6a2..f51be74fab4 100644 --- a/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleFromModelOutput.swift +++ b/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleFromModelOutput.swift @@ -13,8 +13,8 @@ // limitations under the License. #if canImport(FoundationModels) -public import protocol FoundationModels.ConvertibleFromGeneratedContent -public import struct FoundationModels.GeneratedContent + public import protocol FoundationModels.ConvertibleFromGeneratedContent + public import struct FoundationModels.GeneratedContent #endif // canImport(FoundationModels) @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) @@ -23,12 +23,12 @@ public protocol ConvertibleFromModelOutput { } #if canImport(FoundationModels) -@available(iOS 26.0, macOS 26.0, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -public extension ConvertibleFromModelOutput where Self: ConvertibleFromGeneratedContent { - init(_ content: ModelOutput) throws { - try self.init(try GeneratedContent(content)) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public extension ConvertibleFromModelOutput where Self: ConvertibleFromGeneratedContent { + init(_ content: ModelOutput) throws { + try self.init(GeneratedContent(content)) + } } -} #endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleToModelOutput.swift b/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleToModelOutput.swift index 153bf5fa855..abd027cdefa 100644 --- a/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleToModelOutput.swift +++ b/FirebaseAI/Sources/Protocols/Public/StructuredOutput/ConvertibleToModelOutput.swift @@ -13,8 +13,8 @@ // limitations under the License. #if canImport(FoundationModels) -public import protocol FoundationModels.ConvertibleToGeneratedContent -public import struct FoundationModels.GeneratedContent + public import protocol FoundationModels.ConvertibleToGeneratedContent + public import struct FoundationModels.GeneratedContent #endif // canImport(FoundationModels) #if compiler(>=6.2) @@ -30,12 +30,12 @@ public import struct FoundationModels.GeneratedContent #endif // compiler(>=6.2) #if canImport(FoundationModels) -@available(iOS 26.0, macOS 26.0, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -public extension ConvertibleToModelOutput where Self: ConvertibleToGeneratedContent { - var modelOutput: ModelOutput { - self.generatedContent.modelOutput + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public extension ConvertibleToModelOutput where Self: ConvertibleToGeneratedContent { + var modelOutput: ModelOutput { + generatedContent.modelOutput + } } -} #endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift index fb638a8052f..2c54420aff2 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift @@ -14,9 +14,9 @@ import Foundation #if canImport(FoundationModels) -public import protocol FoundationModels.ConvertibleToGeneratedContent -public import struct FoundationModels.GenerationID -public import struct FoundationModels.GeneratedContent + public import protocol FoundationModels.ConvertibleToGeneratedContent + public import struct FoundationModels.GenerationID + public import struct FoundationModels.GeneratedContent #endif // canImport(FoundationModels) @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) @@ -181,64 +181,64 @@ public extension ModelOutput { } #if canImport(FoundationModels) -@available(iOS 26.0, macOS 26.0, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension ModelOutput: ConvertibleToGeneratedContent { - public var generatedContent: GeneratedContent { - let generationID = id?.generationID - - switch kind { - case .null: - return GeneratedContent(kind: .null, id: generationID) - case let .bool(value): - return GeneratedContent(kind: .bool(value), id: generationID) - case let .number(value): - return GeneratedContent(kind: .number(value), id: generationID) - case let .string(value): - return GeneratedContent(kind: .string(value), id: generationID) - case let .array(values): - return GeneratedContent(kind: .array(values.map { $0.generatedContent }), id: generationID) - case let .structure(properties: properties, orderedKeys: orderedKeys): - return GeneratedContent( - kind: .structure( - properties: properties.mapValues { $0.generatedContent }, orderedKeys: orderedKeys - ), - id: generationID - ) + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension ModelOutput: ConvertibleToGeneratedContent { + public var generatedContent: GeneratedContent { + let generationID = id?.generationID + + switch kind { + case .null: + return GeneratedContent(kind: .null, id: generationID) + case let .bool(value): + return GeneratedContent(kind: .bool(value), id: generationID) + case let .number(value): + return GeneratedContent(kind: .number(value), id: generationID) + case let .string(value): + return GeneratedContent(kind: .string(value), id: generationID) + case let .array(values): + return GeneratedContent(kind: .array(values.map { $0.generatedContent }), id: generationID) + case let .structure(properties: properties, orderedKeys: orderedKeys): + return GeneratedContent( + kind: .structure( + properties: properties.mapValues { $0.generatedContent }, orderedKeys: orderedKeys + ), + id: generationID + ) + } } } -} -@available(iOS 26.0, macOS 26.0, *) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -extension GeneratedContent: ConvertibleToModelOutput { - public var modelOutput: ModelOutput { - let responseID = self.id.map { ResponseID(generationID: $0) } - - switch self.kind { - case .null: - return ModelOutput(kind: .null, id: responseID) - case let .bool(value): - return ModelOutput(kind: .bool(value), id: responseID) - case let .number(value): - return ModelOutput(kind: .number(value), id: responseID) - case let .string(value): - return ModelOutput(kind: .string(value), id: responseID) - case let .array(values): - return ModelOutput(kind: .array(values.map { $0.modelOutput }), id: responseID) - case let .structure(properties: properties, orderedKeys: orderedKeys): - return ModelOutput( - kind: .structure( - properties: properties.mapValues { $0.modelOutput }, orderedKeys: orderedKeys - ), - id: responseID - ) - @unknown default: - assertionFailure("Unknown `FoundationModels.GeneratedContent` kind: \(kind)") - return ModelOutput(kind: .null, id: responseID) - } + @available(iOS 26.0, macOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension GeneratedContent: ConvertibleToModelOutput { + public var modelOutput: ModelOutput { + let responseID = id.map { ResponseID(generationID: $0) } + + switch kind { + case .null: + return ModelOutput(kind: .null, id: responseID) + case let .bool(value): + return ModelOutput(kind: .bool(value), id: responseID) + case let .number(value): + return ModelOutput(kind: .number(value), id: responseID) + case let .string(value): + return ModelOutput(kind: .string(value), id: responseID) + case let .array(values): + return ModelOutput(kind: .array(values.map { $0.modelOutput }), id: responseID) + case let .structure(properties: properties, orderedKeys: orderedKeys): + return ModelOutput( + kind: .structure( + properties: properties.mapValues { $0.modelOutput }, orderedKeys: orderedKeys + ), + id: responseID + ) + @unknown default: + assertionFailure("Unknown `FoundationModels.GeneratedContent` kind: \(kind)") + return ModelOutput(kind: .null, id: responseID) + } + } } -} #endif // canImport(FoundationModels) From ee1ca493f8ebf1a8f75813a40c6983ee88426482 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 6 Feb 2026 18:09:34 -0500 Subject: [PATCH 03/17] [Firebase AI] Add streaming JSON decoding support to `ModelOutput` --- .../Public/StructuredOutput/ModelOutput.swift | 58 +++- .../StreamingJSONParser.swift | 327 ++++++++++++++++++ 2 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 FirebaseAI/Sources/Types/Public/StructuredOutput/StreamingJSONParser.swift diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift index 2c54420aff2..4c83a94cb51 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift @@ -72,15 +72,63 @@ public struct ModelOutput: Sendable, CustomDebugStringConvertible, FirebaseGener } public init(json: String, id: ResponseID? = nil) throws { + var modelOutput: ModelOutput + var decodingError: Error? + + // 1. Attempt to decode the JSON with the standard `JSONDecoder` since it likely offers the best + // performance and is available on iOS 15+. + // TODO: Skip this approach when streaming. guard let jsonData = json.data(using: .utf8) else { - fatalError() + fatalError("TODO: Throw a reasonable decoding error") + } + do { + let jsonValue = try JSONDecoder().decode(JSONValue.self, from: jsonData) + modelOutput = jsonValue.modelOutput + modelOutput.id = id + + self = modelOutput + + return + } catch { + decodingError = error } - let jsonValue = try JSONDecoder().decode(JSONValue.self, from: jsonData) - var modelOutput = jsonValue.modelOutput - modelOutput.id = id + // 2. Attempt to decode using `GeneratedContent` from Foundation Models when available. It is + // designed to handle streaming JSON. + #if canImport(FoundationModels) + if #available(iOS 26.0, macOS 26.0, *) { + do { + let generatedContent = try GeneratedContent(json: json) + modelOutput = generatedContent.modelOutput + modelOutput.id = id - self = modelOutput + self = modelOutput + + return + } catch { + decodingError = error + } + } + #endif // canImport(FoundationModels) + + // 3. Fallback to decoding with a custom `StreamingJSONParser` when `GeneratedContent` is not + // available. + let parser = StreamingJSONParser(json) + if let parsedModelOutput = parser.parse() { + modelOutput = parsedModelOutput + modelOutput.id = id + + self = modelOutput + + return + } + + // 4. Throw a decoding error if all attempts to decode the JSON have failed. + if let decodingError { + throw decodingError + } else { + fatalError("TODO: Throw a decoding error") + } } public func value(_ type: Value.Type = Value.self) throws -> Value diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/StreamingJSONParser.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/StreamingJSONParser.swift new file mode 100644 index 00000000000..4a15b2da9d9 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/StreamingJSONParser.swift @@ -0,0 +1,327 @@ +// 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. + +import Foundation + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class StreamingJSONParser { + private let input: [UnicodeScalar] + private var index: Int + + init(_ json: String) { + input = Array(json.unicodeScalars) + index = 0 + } + + private func skipWhitespace() { + while index < input.count { + let char = input[index] + if !CharacterSet.whitespacesAndNewlines.contains(char) { + break + } + index += 1 + } + } + + private func peek() -> UnicodeScalar? { + guard index < input.count else { return nil } + return input[index] + } + + private func advance() -> UnicodeScalar { + let char = input[index] + index += 1 + return char + } + + func parse() -> ModelOutput? { + skipWhitespace() + guard let char = peek() else { return nil } + + switch char { + case "{": return parseObject() + case "[": return parseArray() + case "\"": return parseString() + case "t", "f": return parseBool() + case "n": return parseNull() + case "-", "0" ... "9": return parseNumber() + default: return nil + } + } + + private func parseObject() -> ModelOutput { + _ = advance() // consume '{' + var properties: [String: ModelOutput] = [:] + var orderedKeys: [String] = [] + + while true { + skipWhitespace() + + if let char = peek(), char == "}" { + _ = advance() + break + } + if peek() == nil { + break + } + + // Expecting a key (string) + if peek() != "\"" { + break + } + + let keyOutput = parseString() + guard case let .string(key) = keyOutput.kind else { + break + } + + skipWhitespace() + + if let char = peek(), char == ":" { + _ = advance() + } else { + break + } + + skipWhitespace() + + if let value = parse() { + properties[key] = value + orderedKeys.append(key) + } else { + // Missing value or partial value (like "1" that returned nil). + // We don't add this key. + break + } + + skipWhitespace() + + if let char = peek() { + if char == "," { + _ = advance() + } else if char == "}" { + _ = advance() + break + } else { + break + } + } else { + break + } + } + + return ModelOutput(kind: .structure(properties: properties, orderedKeys: orderedKeys)) + } + + private func parseArray() -> ModelOutput { + _ = advance() // consume '[' + var elements: [ModelOutput] = [] + + while true { + skipWhitespace() + + if let char = peek(), char == "]" { + _ = advance() + break + } + if peek() == nil { + break + } + + if let value = parse() { + elements.append(value) + } else { + break + } + + skipWhitespace() + + if let char = peek() { + if char == "," { + _ = advance() + } else if char == "]" { + _ = advance() + break + } else { + break + } + } else { + break + } + } + return ModelOutput(kind: .array(elements)) + } + + private func parseString() -> ModelOutput { + _ = advance() // consume '"' + var currentString = "" + + while let char = peek() { + if char == "\"" { + _ = advance() + break + } + + if char == "\\" { + _ = advance() + guard let escapeChar = peek() else { break } + + switch escapeChar { + case "\"", "\\", "/": + currentString.append(Character(escapeChar)) + _ = advance() + case "b": + currentString.append("\u{08}") + _ = advance() + case "f": + currentString.append("\u{0C}") + _ = advance() + case "n": + currentString.append("\n") + _ = advance() + case "r": + currentString.append("\r") + _ = advance() + case "t": + currentString.append("\t") + _ = advance() + case "u": + if index + 5 <= input.count { + _ = advance() // consume 'u' + var hexString = "" + var validHex = true + for _ in 0 ..< 4 { + guard let h = peek() else { validHex = false; break } + if CharacterSet(charactersIn: "0123456789ABCDEFabcdef").contains(h) { + hexString.append(Character(h)) + _ = advance() + } else { + validHex = false; break + } + } + + if validHex, let codePoint = Int(hexString, radix: 16), + let scalar = UnicodeScalar(codePoint) { + currentString.append(Character(scalar)) + } else { + // Invalid hex or failure to create scalar. Return partial. + return ModelOutput(kind: .string(currentString)) + } + } else { + // Incomplete unicode escape. + return ModelOutput(kind: .string(currentString)) + } + default: + _ = advance() + } + } else { + currentString.append(Character(char)) + _ = advance() + } + } + + return ModelOutput(kind: .string(currentString)) + } + + private func parseBool() -> ModelOutput? { + if let char = peek(), char == "t" { + if index + 4 <= input.count { + let start = index + if input[start] == "t", input[start + 1] == "r", input[start + 2] == "u", + input[start + 3] == "e" { + index += 4 + return ModelOutput(kind: .bool(true)) + } + } + } else if let char = peek(), char == "f" { + if index + 5 <= input.count { + let start = index + if input[start] == "f", input[start + 1] == "a", input[start + 2] == "l", + input[start + 3] == "s", input[start + 4] == "e" { + index += 5 + return ModelOutput(kind: .bool(false)) + } + } + } + return nil + } + + private func parseNull() -> ModelOutput? { + if index + 4 <= input.count { + let start = index + if input[start] == "n", input[start + 1] == "u", input[start + 2] == "l", + input[start + 3] == "l" { + index += 4 + return ModelOutput(kind: .null) + } + } + return nil + } + + private func parseNumber() -> ModelOutput? { + let start = index + // Optional minus sign + if index < input.count && input[index] == "-" { + index += 1 + } + + // Integer part + if index < input.count && input[index] == "0" { + index += 1 + } else if index < input.count && CharacterSet(charactersIn: "123456789") + .contains(input[index]) { + index += 1 + while index < input.count, CharacterSet(charactersIn: "0123456789").contains(input[index]) { + index += 1 + } + } else { + // Invalid number start + return nil + } + + // Fraction part + if index < input.count && input[index] == "." { + index += 1 + while index < input.count, CharacterSet(charactersIn: "0123456789").contains(input[index]) { + index += 1 + } + } + + // Exponent part + if index < input.count && (input[index] == "e" || input[index] == "E") { + index += 1 + if index < input.count, input[index] == "+" || input[index] == "-" { + index += 1 + } + while index < input.count, CharacterSet(charactersIn: "0123456789").contains(input[index]) { + index += 1 + } + } + + // Check terminator + guard let char = peek() else { + // EOF - incomplete number + return nil + } + + if CharacterSet.whitespacesAndNewlines + .contains(char) || char == "," || char == "]" || char == "}" { + let numberString = String(String.UnicodeScalarView(input[start ..< index])) + if let doubleVal = Double(numberString) { + return ModelOutput(kind: .number(doubleVal)) + } + } + return nil + } +} From e4fb0bf99c38cf464daffe11c203a8356e2e1559 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 6 Feb 2026 18:25:59 -0500 Subject: [PATCH 04/17] Add `visionOS 26.0` to availability check --- .../Sources/Types/Public/StructuredOutput/ModelOutput.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift index 4c83a94cb51..7e48c0733e2 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift @@ -96,7 +96,7 @@ public struct ModelOutput: Sendable, CustomDebugStringConvertible, FirebaseGener // 2. Attempt to decode using `GeneratedContent` from Foundation Models when available. It is // designed to handle streaming JSON. #if canImport(FoundationModels) - if #available(iOS 26.0, macOS 26.0, *) { + if #available(iOS 26.0, macOS 26.0, visionOS 26.0, *) { do { let generatedContent = try GeneratedContent(json: json) modelOutput = generatedContent.modelOutput From 468f5c8369af04ea0ff17d6c6d1d8671d85ae106 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 12 Feb 2026 11:58:17 -0500 Subject: [PATCH 05/17] Add `streamResponse(to:generating:includeSchemaInPrompt:options:)` --- FirebaseAI/Sources/GenerativeModel.swift | 273 +++++++++++------- .../GenerativeModel+Response.swift | 28 ++ .../GenerativeModel+ResponseStream.swift | 129 +++++++++ .../Public/StructuredOutput/ModelOutput.swift | 34 ++- 4 files changed, 350 insertions(+), 114 deletions(-) create mode 100644 FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+Response.swift create mode 100644 FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index ec0f570bfe2..cd2b3dd66e4 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -146,49 +146,6 @@ public final class GenerativeModel: Sendable { return try await generateContent(content, generationConfig: generationConfig) } - func generateContent(_ content: [ModelContent], - generationConfig: GenerationConfig?) async throws - -> GenerateContentResponse { - try content.throwIfError() - let response: GenerateContentResponse - let generateContentRequest = GenerateContentRequest( - model: modelResourceName, - contents: content, - generationConfig: generationConfig, - safetySettings: safetySettings, - tools: tools, - toolConfig: toolConfig, - systemInstruction: systemInstruction, - apiConfig: apiConfig, - apiMethod: .generateContent, - options: requestOptions - ) - do { - response = try await generativeAIService.loadRequest(request: generateContentRequest) - } catch { - throw GenerativeModel.generateContentError(from: error) - } - - // Check the prompt feedback to see if the prompt was blocked. - if response.promptFeedback?.blockReason != nil { - throw GenerateContentError.promptBlocked(response: response) - } - - // Check to see if an error should be thrown for stop reason. - if let reason = response.candidates.first?.finishReason, reason != .stop { - throw GenerateContentError.responseStoppedEarly(reason: reason, response: response) - } - - // If all candidates are empty (contain no information that a developer could act on) then throw - if response.candidates.allSatisfy({ $0.isEmpty }) { - throw GenerateContentError.internalError(underlying: InvalidCandidateError.emptyContent( - underlyingError: Candidate.EmptyContentError() - )) - } - - return response - } - /// Generates content from String and/or image inputs, given to the model as a prompt, that are /// representable as one or more ``Part``s. /// @@ -231,57 +188,10 @@ public final class GenerativeModel: Sendable { options: requestOptions ) - return AsyncThrowingStream { continuation in - let responseStream = generativeAIService.loadRequestStream(request: generateContentRequest) - Task { - do { - var didYieldResponse = false - for try await response in responseStream { - // Check the prompt feedback to see if the prompt was blocked. - if response.promptFeedback?.blockReason != nil { - throw GenerateContentError.promptBlocked(response: response) - } - - // If the stream ended early unexpectedly, throw an error. - if let finishReason = response.candidates.first?.finishReason, finishReason != .stop { - throw GenerateContentError.responseStoppedEarly( - reason: finishReason, - response: response - ) - } - - // Skip returning the response if all candidates are empty (i.e., they contain no - // information that a developer could act on). - if response.candidates.allSatisfy({ $0.isEmpty }) { - AILog.log( - level: .debug, - code: .generateContentResponseEmptyCandidates, - "Skipped response with all empty candidates: \(response)" - ) - } else { - continuation.yield(response) - didYieldResponse = true - } - } - - // Throw an error if all responses were skipped due to empty content. - if didYieldResponse { - continuation.finish() - } else { - continuation.finish(throwing: GenerativeModel.generateContentError( - from: InvalidCandidateError.emptyContent( - underlyingError: Candidate.EmptyContentError() - ) - )) - } - } catch { - continuation.finish(throwing: GenerativeModel.generateContentError(from: error)) - return - } - } - } + return try generateContentStream(content, generationConfig: generationConfig) } + // TODO: Update public API public func generate(_ type: Content.Type, from parts: any PartsRepresentable...) async throws -> Response where Content: FirebaseGenerable { @@ -335,6 +245,68 @@ public final class GenerativeModel: Sendable { return Response(content: content, rawContent: modelOutput, rawResponse: response) } + public final func streamResponse(to parts: any PartsRepresentable..., + generating type: Content.Type = Content.self, + includeSchemaInPrompt: Bool = true, + options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream + where Content: FirebaseGenerable { + let parts = [ModelContent(parts: parts)] + + return streamResponse( + to: parts, + generating: type, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ) + } + + public final func streamResponse(to parts: [ModelContent], + generating type: Content.Type = Content.self, + includeSchemaInPrompt: Bool = true, + options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream + where Content: FirebaseGenerable { + // TODO: Merge `options` with `self.generationConfig` + let generationConfig = { + var generationConfig = self.generationConfig ?? GenerationConfig() + generationConfig.candidateCount = nil + generationConfig.responseMIMEType = "application/json" + generationConfig.responseJSONSchema = type.jsonSchema + generationConfig.responseModalities = nil + + return generationConfig + }() + + return GenerativeModel.ResponseStream { context in + do { + let stream = try self.generateContentStream(parts, generationConfig: generationConfig) + var json = "" + for try await response in stream { + if let text = response.text { + json += text + let responseID = response.responseID.map { ResponseID(responseID: $0) } + let modelOutput = try ModelOutput(json: json, id: responseID, streaming: true) + try await context.yield( + GenerativeModel.ResponseStream.Snapshot( + content: Content.Partial(modelOutput), + rawContent: modelOutput, + rawResponse: response + ) + ) + } + } + await context.finish() + } catch let error as GenerateContentError { + await context.finish(throwing: GenerationError.generationFailure(error)) + } catch { + await context.finish(throwing: GenerationError.generationFailure( + GenerateContentError.internalError(underlying: error) + )) + } + } + } + /// Creates a new chat conversation using this model with the provided history. public func startChat(history: [ModelContent] = []) -> Chat { return Chat(model: self, history: history) @@ -425,15 +397,116 @@ public final class GenerativeModel: Sendable { return GenerateContentError.internalError(underlying: error) } - public struct Response where Content: FirebaseGenerable { - public let content: Content - public let rawContent: ModelOutput - public let rawResponse: GenerateContentResponse + // MARK: - Internal Helpers + + func generateContent(_ content: [ModelContent], + generationConfig: GenerationConfig?) async throws + -> GenerateContentResponse { + try content.throwIfError() + let response: GenerateContentResponse + let generateContentRequest = GenerateContentRequest( + model: modelResourceName, + contents: content, + generationConfig: generationConfig, + safetySettings: safetySettings, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + apiConfig: apiConfig, + apiMethod: .generateContent, + options: requestOptions + ) + do { + response = try await generativeAIService.loadRequest(request: generateContentRequest) + } catch { + throw GenerativeModel.generateContentError(from: error) + } + + // Check the prompt feedback to see if the prompt was blocked. + if response.promptFeedback?.blockReason != nil { + throw GenerateContentError.promptBlocked(response: response) + } + + // Check to see if an error should be thrown for stop reason. + if let reason = response.candidates.first?.finishReason, reason != .stop { + throw GenerateContentError.responseStoppedEarly(reason: reason, response: response) + } + + // If all candidates are empty (contain no information that a developer could act on) then throw + if response.candidates.allSatisfy({ $0.isEmpty }) { + throw GenerateContentError.internalError(underlying: InvalidCandidateError.emptyContent( + underlyingError: Candidate.EmptyContentError() + )) + } + + return response + } + + func generateContentStream(_ content: [ModelContent], + generationConfig: GenerationConfig?) throws + -> AsyncThrowingStream { + try content.throwIfError() + let generateContentRequest = GenerateContentRequest( + model: modelResourceName, + contents: content, + generationConfig: generationConfig, + safetySettings: safetySettings, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + apiConfig: apiConfig, + apiMethod: .streamGenerateContent, + options: requestOptions + ) + + return AsyncThrowingStream { continuation in + let responseStream = generativeAIService.loadRequestStream(request: generateContentRequest) + Task { + do { + var didYieldResponse = false + for try await response in responseStream { + // Check the prompt feedback to see if the prompt was blocked. + if response.promptFeedback?.blockReason != nil { + throw GenerateContentError.promptBlocked(response: response) + } + + // If the stream ended early unexpectedly, throw an error. + if let finishReason = response.candidates.first?.finishReason, finishReason != .stop { + throw GenerateContentError.responseStoppedEarly( + reason: finishReason, + response: response + ) + } + + // Skip returning the response if all candidates are empty (i.e., they contain no + // information that a developer could act on). + if response.candidates.allSatisfy({ $0.isEmpty }) { + AILog.log( + level: .debug, + code: .generateContentResponseEmptyCandidates, + "Skipped response with all empty candidates: \(response)" + ) + } else { + continuation.yield(response) + didYieldResponse = true + } + } - init(content: Content, rawContent: ModelOutput, rawResponse: GenerateContentResponse) { - self.content = content - self.rawContent = rawContent - self.rawResponse = rawResponse + // Throw an error if all responses were skipped due to empty content. + if didYieldResponse { + continuation.finish() + } else { + continuation.finish(throwing: GenerativeModel.generateContentError( + from: InvalidCandidateError.emptyContent( + underlyingError: Candidate.EmptyContentError() + ) + )) + } + } catch { + continuation.finish(throwing: GenerativeModel.generateContentError(from: error)) + return + } + } } } } diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+Response.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+Response.swift new file mode 100644 index 00000000000..3b08d7843b4 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+Response.swift @@ -0,0 +1,28 @@ +// 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. + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public extension GenerativeModel { + struct Response where Content: FirebaseGenerable { + public let content: Content + public let rawContent: ModelOutput + public let rawResponse: GenerateContentResponse + + init(content: Content, rawContent: ModelOutput, rawResponse: GenerateContentResponse) { + self.content = content + self.rawContent = rawContent + self.rawResponse = rawResponse + } + } +} diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift new file mode 100644 index 00000000000..0182ab83bd9 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift @@ -0,0 +1,129 @@ +// 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. + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public extension GenerativeModel { + struct ResponseStream: AsyncSequence, Sendable + where Content: FirebaseGenerable & Sendable, Content.Partial: Sendable { + public typealias Element = Snapshot + public typealias AsyncIterator = AsyncThrowingStream.Iterator + + private let _stream: AsyncThrowingStream + private let _context: StreamContext + + public struct Snapshot: Sendable { + public let content: Content.Partial + public let rawContent: ModelOutput + public let rawResponse: GenerateContentResponse + } + + init(_ builder: @escaping @Sendable (StreamContext) async -> Void) { + var extractedContinuation: AsyncThrowingStream.Continuation! + let stream = AsyncThrowingStream(Snapshot.self) { continuation in + extractedContinuation = continuation + } + _stream = stream + + let context = StreamContext(continuation: extractedContinuation) + _context = context + + Task { + await builder(context) + } + } + + public func makeAsyncIterator() -> AsyncIterator { + return _stream.makeAsyncIterator() + } + + public nonisolated(nonsending) func collect() async throws -> sending GenerativeModel + .Response { + let finalResult = try await _context.value + return try GenerativeModel.Response( + content: Content(finalResult.rawContent), + rawContent: finalResult.rawContent, + rawResponse: finalResult.rawResponse + ) + } + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension GenerativeModel.ResponseStream { + actor StreamContext { + struct RawResult: Sendable { + let rawContent: ModelOutput + let rawResponse: GenerateContentResponse + } + + private let continuation: AsyncThrowingStream.Continuation + private var _finalResult: Result? + private var _waitingContinuations: [CheckedContinuation] = [] + private var _latestRaw: RawResult? + + init(continuation: AsyncThrowingStream.Continuation) { + self.continuation = continuation + } + + func yield(_ snapshot: Snapshot) { + _latestRaw = RawResult(rawContent: snapshot.rawContent, rawResponse: snapshot.rawResponse) + continuation.yield(snapshot) + } + + func finish() { + continuation.finish() + finalize(with: nil) + } + + func finish(throwing error: Error) { + continuation.finish(throwing: error) + finalize(with: error) + } + + var value: RawResult { + get async throws { + if let result = _finalResult { + return try result.get() + } + return try await withCheckedThrowingContinuation { continuation in + _waitingContinuations.append(continuation) + } + } + } + + private func finalize(with error: Error?) { + let result: Result + + if let error = error { + result = .failure(error) + } else if let last = _latestRaw { + result = .success(last) + } else { + result = .failure(AsyncSequenceErrors.unexpectedlyEmpty) + } + + _finalResult = result + + for continuation in _waitingContinuations { + continuation.resume(with: result) + } + _waitingContinuations.removeAll() + } + + // TODO: Create a better error type + enum AsyncSequenceErrors: Error { + case unexpectedlyEmpty + } + } +} diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift index 310c0e2ccf7..cab3091adf0 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift @@ -95,26 +95,28 @@ public struct ModelOutput: Sendable, CustomDebugStringConvertible, FirebaseGener self = value.modelOutput } - public init(json: String, id: ResponseID? = nil) throws { + init(json: String, id: ResponseID? = nil, streaming: Bool) throws { var modelOutput: ModelOutput var decodingError: Error? // 1. Attempt to decode the JSON with the standard `JSONDecoder` since it likely offers the best - // performance and is available on iOS 15+. - // TODO: Skip this approach when streaming. - guard let jsonData = json.data(using: .utf8) else { - fatalError("TODO: Throw a reasonable decoding error") - } - do { - let jsonValue = try JSONDecoder().decode(JSONValue.self, from: jsonData) - modelOutput = jsonValue.modelOutput - modelOutput.id = id + // performance and is available on iOS 15+. Note: This approach does not support decoding + // partial JSON when streaming. + if !streaming { + guard let jsonData = json.data(using: .utf8) else { + fatalError("TODO: Throw a reasonable decoding error") + } + do { + let jsonValue = try JSONDecoder().decode(JSONValue.self, from: jsonData) + modelOutput = jsonValue.modelOutput + modelOutput.id = id - self = modelOutput + self = modelOutput - return - } catch { - decodingError = error + return + } catch { + decodingError = error + } } // 2. Attempt to decode using `GeneratedContent` from Foundation Models when available. It is @@ -155,6 +157,10 @@ public struct ModelOutput: Sendable, CustomDebugStringConvertible, FirebaseGener } } + public init(json: String) throws { + try self.init(json: json, id: nil, streaming: true) + } + public func value(_ type: Value.Type = Value.self) throws -> Value where Value: ConvertibleFromModelOutput { return try Value(self) From 269e9229920d3a00441cf93a24267cdce1088fc2 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 12 Feb 2026 15:42:05 -0500 Subject: [PATCH 06/17] Xcode 16 fixes --- .../GenerativeModel+ResponseStream.swift | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift index 0182ab83bd9..c7bed393537 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift @@ -47,15 +47,27 @@ public extension GenerativeModel { return _stream.makeAsyncIterator() } - public nonisolated(nonsending) func collect() async throws -> sending GenerativeModel - .Response { - let finalResult = try await _context.value - return try GenerativeModel.Response( - content: Content(finalResult.rawContent), - rawContent: finalResult.rawContent, - rawResponse: finalResult.rawResponse - ) - } + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + public nonisolated(nonsending) func collect() + async throws -> sending GenerativeModel.Response { + let finalResult = try await _context.value + return try GenerativeModel.Response( + content: Content(finalResult.rawContent), + rawContent: finalResult.rawContent, + rawResponse: finalResult.rawResponse + ) + } + #else + public func collect() async throws -> sending GenerativeModel.Response { + let finalResult = try await _context.value + return try GenerativeModel.Response( + content: Content(finalResult.rawContent), + rawContent: finalResult.rawContent, + rawResponse: finalResult.rawResponse + ) + } + #endif // compiler(>=6.2) } } From c1e650c168cb2e255e7602ce791b0760b03f1be7 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 12 Feb 2026 16:46:00 -0500 Subject: [PATCH 07/17] Merge `GenerationConfig` values --- FirebaseAI/Sources/GenerationConfig.swift | 101 ++++++++++++++++++++-- FirebaseAI/Sources/GenerativeModel.swift | 13 +-- 2 files changed, 95 insertions(+), 19 deletions(-) diff --git a/FirebaseAI/Sources/GenerationConfig.swift b/FirebaseAI/Sources/GenerationConfig.swift index 8ff4e847d63..692c6449ea6 100644 --- a/FirebaseAI/Sources/GenerationConfig.swift +++ b/FirebaseAI/Sources/GenerationConfig.swift @@ -19,34 +19,34 @@ import Foundation @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct GenerationConfig: Sendable { /// Controls the degree of randomness in token selection. - let temperature: Float? + var temperature: Float? /// Controls diversity of generated text. - let topP: Float? + var topP: Float? /// Limits the number of highest probability words considered. - let topK: Int? + var topK: Int? /// The number of response variations to return. var candidateCount: Int? /// Maximum number of tokens that can be generated in the response. - let maxOutputTokens: Int? + var maxOutputTokens: Int? /// Controls the likelihood of repeating the same words or phrases already generated in the text. - let presencePenalty: Float? + var presencePenalty: Float? /// Controls the likelihood of repeating words, with the penalty increasing for each repetition. - let frequencyPenalty: Float? + var frequencyPenalty: Float? /// A set of up to 5 `String`s that will stop output generation. - let stopSequences: [String]? + var stopSequences: [String]? /// Output response MIME type of the generated candidate text. var responseMIMEType: String? /// Output schema of the generated candidate text. - let responseSchema: Schema? + var responseSchema: Schema? /// Output schema of the generated response in [JSON Schema](https://json-schema.org/) format. /// @@ -57,7 +57,7 @@ public struct GenerationConfig: Sendable { var responseModalities: [ResponseModality]? /// Configuration for controlling the "thinking" behavior of compatible Gemini models. - let thinkingConfig: ThinkingConfig? + var thinkingConfig: ThinkingConfig? /// Creates a new `GenerationConfig` value. /// @@ -203,6 +203,89 @@ public struct GenerationConfig: Sendable { self.responseModalities = responseModalities self.thinkingConfig = thinkingConfig } + + /// Merges two configurations, giving precedence to values found in the `overrides` parameter. + /// + /// - Parameters: + /// - base: The foundational configuration (e.g., model-level defaults). + /// - overrides: The configuration containing values that should supersede the base (e.g., + /// request-level specific settings). + /// - Returns: A merged `GenerationConfig` prioritizing `overrides`, or `nil` if both inputs are + /// `nil`. + static func merge(_ base: GenerationConfig?, + with overrides: GenerationConfig?) -> GenerationConfig? { + // 1. If the base config is missing, return the overrides (which might be nil). + guard let baseConfig = base else { + return overrides + } + + // 2. If overrides are missing, strictly return the base. + guard let overrideConfig = overrides else { + return baseConfig + } + + // 3. Start with a copy of the base config. + var config = baseConfig + + // 4. Overwrite with any non-nil values found in the overrides. + if let temperature = overrideConfig.temperature { config.temperature = temperature } + if let topP = overrideConfig.topP { config.topP = topP } + if let topK = overrideConfig.topK { config.topK = topK } + if let candidateCount = overrideConfig.candidateCount { config.candidateCount = candidateCount } + if let maxOutputTokens = overrideConfig.maxOutputTokens { + config.maxOutputTokens = maxOutputTokens + } + if let presencePenalty = overrideConfig.presencePenalty { + config.presencePenalty = presencePenalty + } + if let frequencyPenalty = overrideConfig.frequencyPenalty { + config.frequencyPenalty = frequencyPenalty + } + if let stopSequences = overrideConfig.stopSequences { config.stopSequences = stopSequences } + if let responseMIMEType = overrideConfig.responseMIMEType { + config.responseMIMEType = responseMIMEType + } + if let responseModalities = overrideConfig.responseModalities { + config.responseModalities = responseModalities + } + if let thinkingConfig = overrideConfig.thinkingConfig { config.thinkingConfig = thinkingConfig } + + // 5. Handle Schema mutual exclusivity with precedence for `responseJSONSchema`. + if let responseJSONSchema = overrideConfig.responseJSONSchema { + config.responseJSONSchema = responseJSONSchema + config.responseSchema = nil + } else if let responseSchema = overrideConfig.responseSchema { + config.responseSchema = responseSchema + config.responseJSONSchema = nil + } + + return config + } + + /// Merges configurations and explicitly enforces settings required for JSON structured output. + /// + /// - Parameters: + /// - base: The foundational configuration (e.g., model defaults). + /// - overrides: The configuration containing overrides (e.g., request specific). + /// - jsonSchema: The JSON schema to enforce on the output. + /// - Returns: A non-nil `GenerationConfig` with the merged values and JSON constraints applied. + static func merge(_ base: GenerationConfig?, + with overrides: GenerationConfig?, + enforcingJSONSchema jsonSchema: JSONSchema) -> GenerationConfig { + // 1. Merge base and overrides, defaulting to a fresh config if both are nil. + var config = GenerationConfig.merge(base, with: overrides) ?? GenerationConfig() + + // 2. Enforce the specific constraints for JSON Schema generation. + config.responseMIMEType = "application/json" + config.responseJSONSchema = jsonSchema + config.responseSchema = nil // Clear conflicting legacy schema + + // 3. Clear incompatible or conflicting options. + config.candidateCount = nil // Structured output typically requires default candidate behaviour + config.responseModalities = nil // Ensure text-only output for JSON + + return config + } } // MARK: - Codable Conformances diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index cd2b3dd66e4..33e570f3865 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -267,16 +267,9 @@ public final class GenerativeModel: Sendable { options: GenerationConfig? = nil) -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { - // TODO: Merge `options` with `self.generationConfig` - let generationConfig = { - var generationConfig = self.generationConfig ?? GenerationConfig() - generationConfig.candidateCount = nil - generationConfig.responseMIMEType = "application/json" - generationConfig.responseJSONSchema = type.jsonSchema - generationConfig.responseModalities = nil - - return generationConfig - }() + let generationConfig = GenerationConfig.merge( + self.generationConfig, with: options, enforcingJSONSchema: type.jsonSchema + ) ?? GenerationConfig() return GenerativeModel.ResponseStream { context in do { From e32f66827f182a6575ef7756d8cafe3d27a52bf8 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 12 Feb 2026 16:46:57 -0500 Subject: [PATCH 08/17] Remove extraneous value `generateContentRequest` --- FirebaseAI/Sources/GenerativeModel.swift | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index 33e570f3865..723ff08e6f5 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -175,18 +175,6 @@ public final class GenerativeModel: Sendable { public func generateContentStream(_ content: [ModelContent]) throws -> AsyncThrowingStream { try content.throwIfError() - let generateContentRequest = GenerateContentRequest( - model: modelResourceName, - contents: content, - generationConfig: generationConfig, - safetySettings: safetySettings, - tools: tools, - toolConfig: toolConfig, - systemInstruction: systemInstruction, - apiConfig: apiConfig, - apiMethod: .streamGenerateContent, - options: requestOptions - ) return try generateContentStream(content, generationConfig: generationConfig) } From 28bd5ce6e3d4997ccf31db8ddf3359adb11bb96b Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 12 Feb 2026 17:20:10 -0500 Subject: [PATCH 09/17] Add `streamResponse` overloads --- FirebaseAI/Sources/GenerativeModel.swift | 123 +++++++++++++++-------- 1 file changed, 81 insertions(+), 42 deletions(-) diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index 723ff08e6f5..637fb2dadb3 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -233,59 +233,54 @@ public final class GenerativeModel: Sendable { return Response(content: content, rawContent: modelOutput, rawResponse: response) } + public final func streamResponse(to parts: any PartsRepresentable..., + options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream { + let parts = [ModelContent(parts: parts)] + return streamResponse(to: parts, options: options) + } + + public final func streamResponse(to parts: [ModelContent], + options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream { + return streamResponse(to: parts, generating: String.self, schema: nil, + includeSchemaInPrompt: false, options: options) + } + + public final func streamResponse(to parts: any PartsRepresentable..., schema: JSONSchema, + includeSchemaInPrompt: Bool = true, + options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream { + let parts = [ModelContent(parts: parts)] + return streamResponse(to: parts, schema: schema, includeSchemaInPrompt: includeSchemaInPrompt, + options: options) + } + + public final func streamResponse(to parts: [ModelContent], schema: JSONSchema, + includeSchemaInPrompt: Bool = true, + options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream { + return streamResponse(to: parts, generating: ModelOutput.self, schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, options: options) + } + public final func streamResponse(to parts: any PartsRepresentable..., generating type: Content.Type = Content.self, includeSchemaInPrompt: Bool = true, options: GenerationConfig? = nil) - -> sending GenerativeModel.ResponseStream - where Content: FirebaseGenerable { + -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { let parts = [ModelContent(parts: parts)] - - return streamResponse( - to: parts, - generating: type, - includeSchemaInPrompt: includeSchemaInPrompt, - options: options - ) + return streamResponse(to: parts, generating: type, includeSchemaInPrompt: includeSchemaInPrompt, + options: options) } public final func streamResponse(to parts: [ModelContent], generating type: Content.Type = Content.self, includeSchemaInPrompt: Bool = true, options: GenerationConfig? = nil) - -> sending GenerativeModel.ResponseStream - where Content: FirebaseGenerable { - let generationConfig = GenerationConfig.merge( - self.generationConfig, with: options, enforcingJSONSchema: type.jsonSchema - ) ?? GenerationConfig() - - return GenerativeModel.ResponseStream { context in - do { - let stream = try self.generateContentStream(parts, generationConfig: generationConfig) - var json = "" - for try await response in stream { - if let text = response.text { - json += text - let responseID = response.responseID.map { ResponseID(responseID: $0) } - let modelOutput = try ModelOutput(json: json, id: responseID, streaming: true) - try await context.yield( - GenerativeModel.ResponseStream.Snapshot( - content: Content.Partial(modelOutput), - rawContent: modelOutput, - rawResponse: response - ) - ) - } - } - await context.finish() - } catch let error as GenerateContentError { - await context.finish(throwing: GenerationError.generationFailure(error)) - } catch { - await context.finish(throwing: GenerationError.generationFailure( - GenerateContentError.internalError(underlying: error) - )) - } - } + -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { + return streamResponse(to: parts, generating: type, schema: type.jsonSchema, + includeSchemaInPrompt: includeSchemaInPrompt, options: options) } /// Creates a new chat conversation using this model with the provided history. @@ -490,4 +485,48 @@ public final class GenerativeModel: Sendable { } } } + + public final func streamResponse(to parts: [ModelContent], + generating type: Content.Type, + schema: JSONSchema?, + includeSchemaInPrompt: Bool, + options: GenerationConfig?) + -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { + let generationConfig: GenerationConfig? + if let schema { + generationConfig = GenerationConfig.merge( + self.generationConfig, with: options, enforcingJSONSchema: schema + ) + } else { + generationConfig = GenerationConfig.merge(self.generationConfig, with: options) + } + + return GenerativeModel.ResponseStream { context in + do { + let stream = try self.generateContentStream(parts, generationConfig: generationConfig) + var json = "" + for try await response in stream { + if let text = response.text { + json += text + let responseID = response.responseID.map { ResponseID(responseID: $0) } + let modelOutput = try ModelOutput(json: json, id: responseID, streaming: true) + try await context.yield( + GenerativeModel.ResponseStream.Snapshot( + content: Content.Partial(modelOutput), + rawContent: modelOutput, + rawResponse: response + ) + ) + } + } + await context.finish() + } catch let error as GenerateContentError { + await context.finish(throwing: GenerationError.generationFailure(error)) + } catch { + await context.finish(throwing: GenerationError.generationFailure( + GenerateContentError.internalError(underlying: error) + )) + } + } + } } From 88f57b82752c22e3652ba411b6209500b308a239 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 12 Feb 2026 18:36:22 -0500 Subject: [PATCH 10/17] Add `respond(to:generating:includeSchemaInPrompt:options:)` overloads --- FirebaseAI/Sources/GenerativeModel.swift | 363 ++++++++++++------ .../GenerativeModel+Response.swift | 25 +- .../GenerativeModel+ResponseStream.swift | 181 +++++---- 3 files changed, 344 insertions(+), 225 deletions(-) diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index 637fb2dadb3..5cd5b30ed04 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -179,109 +179,196 @@ public final class GenerativeModel: Sendable { return try generateContentStream(content, generationConfig: generationConfig) } - // TODO: Update public API - public func generate(_ type: Content.Type, - from parts: any PartsRepresentable...) async throws - -> Response where Content: FirebaseGenerable { - var generationConfig = self.generationConfig ?? GenerationConfig() - generationConfig.candidateCount = nil - generationConfig.responseMIMEType = "application/json" - generationConfig.responseJSONSchema = type.jsonSchema - generationConfig.responseModalities = nil + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + + // TODO: Update public API + public func generate(_ type: Content.Type, + from parts: any PartsRepresentable...) async throws + -> Response where Content: FirebaseGenerable { + var generationConfig = self.generationConfig ?? GenerationConfig() + generationConfig.candidateCount = nil + generationConfig.responseMIMEType = "application/json" + generationConfig.responseJSONSchema = type.jsonSchema + generationConfig.responseModalities = nil + + let response: GenerateContentResponse + do { + response = try await generateContent( + [ModelContent(parts: parts)], generationConfig: generationConfig + ) + } catch let error as GenerateContentError { + throw GenerationError.generationFailure(error) + } catch { + throw GenerationError + .generationFailure(GenerateContentError.internalError(underlying: error)) + } - let response: GenerateContentResponse - do { - response = try await generateContent( - [ModelContent(parts: parts)], generationConfig: generationConfig - ) - } catch let error as GenerateContentError { - throw GenerationError.generationFailure(error) - } catch { - throw GenerationError.generationFailure(GenerateContentError.internalError(underlying: error)) + // TODO: Add `GenerateContentResponse` as context in errors. + + guard let jsonText = response.text else { + throw GenerationError.decodingFailure( + GenerationError.Context(debugDescription: "No JSON text in response.") + ) + } + + let modelOutput: ModelOutput + do { + modelOutput = try ModelOutput(json: jsonText) + } catch let error as GenerationError { + throw error + } catch { + throw GenerationError.decodingFailure( + GenerationError.Context(debugDescription: "Failed to decode response JSON: \(jsonText)") + ) + } + + let content: Content + do { + content = try Content(modelOutput) + } catch let error as GenerationError { + throw error + } catch { + throw GenerationError.decodingFailure( + GenerationError.Context(debugDescription: "Failed to decode \(type) from: \(modelOutput)") + ) + } + + return Response(content: content, rawContent: modelOutput, rawResponse: response) } - // TODO: Add `GenerateContentResponse` as context in errors. + @discardableResult + public final nonisolated(nonsending) + func respond(to parts: any PartsRepresentable..., options: GenerationConfig? = nil) + async throws -> GenerativeModel.Response { + let parts = [ModelContent(parts: parts)] + return try await respond(to: parts, options: options) + } - guard let jsonText = response.text else { - throw GenerationError.decodingFailure( - GenerationError.Context(debugDescription: "No JSON text in response.") + @discardableResult + public final nonisolated(nonsending) + func respond(to parts: [ModelContent], options: GenerationConfig? = nil) + async throws -> GenerativeModel.Response { + return try await respond( + to: parts, + generating: String.self, + schema: nil, + includeSchemaInPrompt: false, + options: options ) } - let modelOutput: ModelOutput - do { - modelOutput = try ModelOutput(json: jsonText) - } catch let error as GenerationError { - throw error - } catch { - throw GenerationError.decodingFailure( - GenerationError.Context(debugDescription: "Failed to decode response JSON: \(jsonText)") + @discardableResult + public final nonisolated(nonsending) + func respond(to parts: any PartsRepresentable..., schema: JSONSchema, + includeSchemaInPrompt: Bool = true, options: GenerationConfig? = nil) + async throws -> GenerativeModel.Response { + let parts = [ModelContent(parts: parts)] + return try await respond( + to: parts, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options ) } - let content: Content - do { - content = try Content(modelOutput) - } catch let error as GenerationError { - throw error - } catch { - throw GenerationError.decodingFailure( - GenerationError.Context(debugDescription: "Failed to decode \(type) from: \(modelOutput)") + @discardableResult + public final nonisolated(nonsending) + func respond(to parts: [ModelContent], schema: JSONSchema, includeSchemaInPrompt: Bool = true, + options: GenerationConfig? = nil) + async throws -> GenerativeModel.Response { + return try await respond( + to: parts, + generating: ModelOutput.self, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options ) } - return Response(content: content, rawContent: modelOutput, rawResponse: response) - } + @discardableResult + public final nonisolated(nonsending) + func respond(to parts: any PartsRepresentable..., + generating type: Content.Type = Content.self, + includeSchemaInPrompt: Bool = true, options: GenerationConfig? = nil) + async throws -> GenerativeModel.Response where Content: FirebaseGenerable { + let parts = [ModelContent(parts: parts)] + return try await respond( + to: parts, + generating: type, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ) + } - public final func streamResponse(to parts: any PartsRepresentable..., - options: GenerationConfig? = nil) - -> sending GenerativeModel.ResponseStream { - let parts = [ModelContent(parts: parts)] - return streamResponse(to: parts, options: options) - } + @discardableResult + public final nonisolated(nonsending) + func respond(to parts: [ModelContent], + generating type: Content.Type = Content.self, + includeSchemaInPrompt: Bool = true, options: GenerationConfig? = nil) + async throws -> GenerativeModel.Response where Content: FirebaseGenerable { + return try await respond( + to: parts, + generating: type, + schema: type.jsonSchema, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ) + } - public final func streamResponse(to parts: [ModelContent], - options: GenerationConfig? = nil) - -> sending GenerativeModel.ResponseStream { - return streamResponse(to: parts, generating: String.self, schema: nil, - includeSchemaInPrompt: false, options: options) - } + public final func streamResponse(to parts: any PartsRepresentable..., + options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream { + let parts = [ModelContent(parts: parts)] + return streamResponse(to: parts, options: options) + } - public final func streamResponse(to parts: any PartsRepresentable..., schema: JSONSchema, - includeSchemaInPrompt: Bool = true, - options: GenerationConfig? = nil) - -> sending GenerativeModel.ResponseStream { - let parts = [ModelContent(parts: parts)] - return streamResponse(to: parts, schema: schema, includeSchemaInPrompt: includeSchemaInPrompt, - options: options) - } + public final func streamResponse(to parts: [ModelContent], options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream { + return streamResponse(to: parts, generating: String.self, schema: nil, + includeSchemaInPrompt: false, options: options) + } - public final func streamResponse(to parts: [ModelContent], schema: JSONSchema, - includeSchemaInPrompt: Bool = true, - options: GenerationConfig? = nil) - -> sending GenerativeModel.ResponseStream { - return streamResponse(to: parts, generating: ModelOutput.self, schema: schema, - includeSchemaInPrompt: includeSchemaInPrompt, options: options) - } + public final func streamResponse(to parts: any PartsRepresentable..., schema: JSONSchema, + includeSchemaInPrompt: Bool = true, + options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream { + let parts = [ModelContent(parts: parts)] + return streamResponse(to: parts, schema: schema, includeSchemaInPrompt: includeSchemaInPrompt, + options: options) + } - public final func streamResponse(to parts: any PartsRepresentable..., - generating type: Content.Type = Content.self, - includeSchemaInPrompt: Bool = true, - options: GenerationConfig? = nil) - -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { - let parts = [ModelContent(parts: parts)] - return streamResponse(to: parts, generating: type, includeSchemaInPrompt: includeSchemaInPrompt, - options: options) - } + public final func streamResponse(to parts: [ModelContent], schema: JSONSchema, + includeSchemaInPrompt: Bool = true, + options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream { + return streamResponse(to: parts, generating: ModelOutput.self, schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, options: options) + } - public final func streamResponse(to parts: [ModelContent], - generating type: Content.Type = Content.self, - includeSchemaInPrompt: Bool = true, - options: GenerationConfig? = nil) - -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { - return streamResponse(to: parts, generating: type, schema: type.jsonSchema, - includeSchemaInPrompt: includeSchemaInPrompt, options: options) - } + public final func streamResponse(to parts: any PartsRepresentable..., + generating type: Content.Type = Content.self, + includeSchemaInPrompt: Bool = true, + options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { + let parts = [ModelContent(parts: parts)] + return streamResponse( + to: parts, + generating: type, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ) + } + + public final func streamResponse(to parts: [ModelContent], + generating type: Content.Type = Content.self, + includeSchemaInPrompt: Bool = true, + options: GenerationConfig? = nil) + -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { + return streamResponse(to: parts, generating: type, schema: type.jsonSchema, + includeSchemaInPrompt: includeSchemaInPrompt, options: options) + } + #endif // compiler(>=6.2) /// Creates a new chat conversation using this model with the provided history. public func startChat(history: [ModelContent] = []) -> Chat { @@ -486,47 +573,85 @@ public final class GenerativeModel: Sendable { } } - public final func streamResponse(to parts: [ModelContent], - generating type: Content.Type, - schema: JSONSchema?, - includeSchemaInPrompt: Bool, - options: GenerationConfig?) - -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { - let generationConfig: GenerationConfig? - if let schema { - generationConfig = GenerationConfig.merge( - self.generationConfig, with: options, enforcingJSONSchema: schema - ) - } else { - generationConfig = GenerationConfig.merge(self.generationConfig, with: options) - } + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + final nonisolated(nonsending) + func respond(to parts: [ModelContent], generating type: Content.Type, + schema: JSONSchema?, includeSchemaInPrompt: Bool, + options: GenerationConfig?) + async throws -> GenerativeModel.Response where Content: FirebaseGenerable { + let generationConfig: GenerationConfig? + if let schema { + generationConfig = GenerationConfig.merge( + self.generationConfig, with: options, enforcingJSONSchema: schema + ) + } else { + generationConfig = GenerationConfig.merge(self.generationConfig, with: options) + } - return GenerativeModel.ResponseStream { context in do { - let stream = try self.generateContentStream(parts, generationConfig: generationConfig) - var json = "" - for try await response in stream { - if let text = response.text { - json += text - let responseID = response.responseID.map { ResponseID(responseID: $0) } - let modelOutput = try ModelOutput(json: json, id: responseID, streaming: true) - try await context.yield( - GenerativeModel.ResponseStream.Snapshot( - content: Content.Partial(modelOutput), - rawContent: modelOutput, - rawResponse: response - ) - ) - } + let response = try await generateContent(parts, generationConfig: generationConfig) + guard let json = response.text else { + throw GenerationError.decodingFailure(.init(debugDescription: "No text in response.")) } - await context.finish() + let responseID = response.responseID.map { ResponseID(responseID: $0) } + let modelOutput = try ModelOutput(json: json, id: responseID, streaming: false) + return try GenerativeModel.Response( + content: Content(modelOutput), + rawContent: modelOutput, + rawResponse: response + ) + } catch let error as GenerationError { + throw error } catch let error as GenerateContentError { - await context.finish(throwing: GenerationError.generationFailure(error)) + throw GenerationError.generationFailure(error) } catch { - await context.finish(throwing: GenerationError.generationFailure( + throw GenerationError.generationFailure( GenerateContentError.internalError(underlying: error) - )) + ) } } - } + + final func streamResponse(to parts: [ModelContent], generating type: Content.Type, + schema: JSONSchema?, includeSchemaInPrompt: Bool, + options: GenerationConfig?) + -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { + let generationConfig: GenerationConfig? + if let schema { + generationConfig = GenerationConfig.merge( + self.generationConfig, with: options, enforcingJSONSchema: schema + ) + } else { + generationConfig = GenerationConfig.merge(self.generationConfig, with: options) + } + + return GenerativeModel.ResponseStream { context in + do { + let stream = try self.generateContentStream(parts, generationConfig: generationConfig) + var json = "" + for try await response in stream { + if let text = response.text { + json += text + let responseID = response.responseID.map { ResponseID(responseID: $0) } + let modelOutput = try ModelOutput(json: json, id: responseID, streaming: true) + try await context.yield( + GenerativeModel.ResponseStream.Snapshot( + content: Content.Partial(modelOutput), + rawContent: modelOutput, + rawResponse: response + ) + ) + } + } + await context.finish() + } catch let error as GenerateContentError { + await context.finish(throwing: GenerationError.generationFailure(error)) + } catch { + await context.finish(throwing: GenerationError.generationFailure( + GenerateContentError.internalError(underlying: error) + )) + } + } + } + #endif // compiler(>=6.2) } diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+Response.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+Response.swift index 3b08d7843b4..6ecb35eadf1 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+Response.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+Response.swift @@ -12,17 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -public extension GenerativeModel { - struct Response where Content: FirebaseGenerable { - public let content: Content - public let rawContent: ModelOutput - public let rawResponse: GenerateContentResponse +// TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. +#if compiler(>=6.2) + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + public extension GenerativeModel { + struct Response where Content: FirebaseGenerable { + public let content: Content + public let rawContent: ModelOutput + public let rawResponse: GenerateContentResponse - init(content: Content, rawContent: ModelOutput, rawResponse: GenerateContentResponse) { - self.content = content - self.rawContent = rawContent - self.rawResponse = rawResponse + init(content: Content, rawContent: ModelOutput, rawResponse: GenerateContentResponse) { + self.content = content + self.rawContent = rawContent + self.rawResponse = rawResponse + } } } -} +#endif // compiler(>=6.2) diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift index c7bed393537..547651359f9 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift @@ -12,43 +12,43 @@ // See the License for the specific language governing permissions and // limitations under the License. -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -public extension GenerativeModel { - struct ResponseStream: AsyncSequence, Sendable - where Content: FirebaseGenerable & Sendable, Content.Partial: Sendable { - public typealias Element = Snapshot - public typealias AsyncIterator = AsyncThrowingStream.Iterator - - private let _stream: AsyncThrowingStream - private let _context: StreamContext - - public struct Snapshot: Sendable { - public let content: Content.Partial - public let rawContent: ModelOutput - public let rawResponse: GenerateContentResponse - } - - init(_ builder: @escaping @Sendable (StreamContext) async -> Void) { - var extractedContinuation: AsyncThrowingStream.Continuation! - let stream = AsyncThrowingStream(Snapshot.self) { continuation in - extractedContinuation = continuation +// TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. +#if compiler(>=6.2) + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + public extension GenerativeModel { + struct ResponseStream: AsyncSequence, Sendable + where Content: FirebaseGenerable & Sendable, Content.Partial: Sendable { + public typealias Element = Snapshot + public typealias AsyncIterator = AsyncThrowingStream.Iterator + + private let _stream: AsyncThrowingStream + private let _context: StreamContext + + public struct Snapshot: Sendable { + public let content: Content.Partial + public let rawContent: ModelOutput + public let rawResponse: GenerateContentResponse } - _stream = stream - let context = StreamContext(continuation: extractedContinuation) - _context = context + init(_ builder: @escaping @Sendable (StreamContext) async -> Void) { + var extractedContinuation: AsyncThrowingStream.Continuation! + let stream = AsyncThrowingStream(Snapshot.self) { continuation in + extractedContinuation = continuation + } + _stream = stream - Task { - await builder(context) + let context = StreamContext(continuation: extractedContinuation) + _context = context + + Task { + await builder(context) + } } - } - public func makeAsyncIterator() -> AsyncIterator { - return _stream.makeAsyncIterator() - } + public func makeAsyncIterator() -> AsyncIterator { + return _stream.makeAsyncIterator() + } - // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. - #if compiler(>=6.2) public nonisolated(nonsending) func collect() async throws -> sending GenerativeModel.Response { let finalResult = try await _context.value @@ -58,84 +58,75 @@ public extension GenerativeModel { rawResponse: finalResult.rawResponse ) } - #else - public func collect() async throws -> sending GenerativeModel.Response { - let finalResult = try await _context.value - return try GenerativeModel.Response( - content: Content(finalResult.rawContent), - rawContent: finalResult.rawContent, - rawResponse: finalResult.rawResponse - ) - } - #endif // compiler(>=6.2) - } -} - -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension GenerativeModel.ResponseStream { - actor StreamContext { - struct RawResult: Sendable { - let rawContent: ModelOutput - let rawResponse: GenerateContentResponse } + } - private let continuation: AsyncThrowingStream.Continuation - private var _finalResult: Result? - private var _waitingContinuations: [CheckedContinuation] = [] - private var _latestRaw: RawResult? + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + extension GenerativeModel.ResponseStream { + actor StreamContext { + struct RawResult: Sendable { + let rawContent: ModelOutput + let rawResponse: GenerateContentResponse + } - init(continuation: AsyncThrowingStream.Continuation) { - self.continuation = continuation - } + private let continuation: AsyncThrowingStream.Continuation + private var _finalResult: Result? + private var _waitingContinuations: [CheckedContinuation] = [] + private var _latestRaw: RawResult? - func yield(_ snapshot: Snapshot) { - _latestRaw = RawResult(rawContent: snapshot.rawContent, rawResponse: snapshot.rawResponse) - continuation.yield(snapshot) - } + init(continuation: AsyncThrowingStream.Continuation) { + self.continuation = continuation + } - func finish() { - continuation.finish() - finalize(with: nil) - } + func yield(_ snapshot: Snapshot) { + _latestRaw = RawResult(rawContent: snapshot.rawContent, rawResponse: snapshot.rawResponse) + continuation.yield(snapshot) + } - func finish(throwing error: Error) { - continuation.finish(throwing: error) - finalize(with: error) - } + func finish() { + continuation.finish() + finalize(with: nil) + } - var value: RawResult { - get async throws { - if let result = _finalResult { - return try result.get() - } - return try await withCheckedThrowingContinuation { continuation in - _waitingContinuations.append(continuation) + func finish(throwing error: Error) { + continuation.finish(throwing: error) + finalize(with: error) + } + + var value: RawResult { + get async throws { + if let result = _finalResult { + return try result.get() + } + return try await withCheckedThrowingContinuation { continuation in + _waitingContinuations.append(continuation) + } } } - } - private func finalize(with error: Error?) { - let result: Result + private func finalize(with error: Error?) { + let result: Result - if let error = error { - result = .failure(error) - } else if let last = _latestRaw { - result = .success(last) - } else { - result = .failure(AsyncSequenceErrors.unexpectedlyEmpty) - } + if let error = error { + result = .failure(error) + } else if let last = _latestRaw { + result = .success(last) + } else { + result = .failure(AsyncSequenceErrors.unexpectedlyEmpty) + } - _finalResult = result + _finalResult = result - for continuation in _waitingContinuations { - continuation.resume(with: result) + for continuation in _waitingContinuations { + continuation.resume(with: result) + } + _waitingContinuations.removeAll() } - _waitingContinuations.removeAll() - } - // TODO: Create a better error type - enum AsyncSequenceErrors: Error { - case unexpectedlyEmpty + // TODO: Create a better error type + enum AsyncSequenceErrors: Error { + case unexpectedlyEmpty + } } } -} +#endif // compiler(>=6.2) From 567c44d03b8fafcd59233e382084fc75ab48221f Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 12 Feb 2026 19:13:01 -0500 Subject: [PATCH 11/17] Fix unary `String` generation --- FirebaseAI/Sources/GenerativeModel.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index 5cd5b30ed04..20710ac480a 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -591,11 +591,16 @@ public final class GenerativeModel: Sendable { do { let response = try await generateContent(parts, generationConfig: generationConfig) - guard let json = response.text else { + guard let text = response.text else { throw GenerationError.decodingFailure(.init(debugDescription: "No text in response.")) } let responseID = response.responseID.map { ResponseID(responseID: $0) } - let modelOutput = try ModelOutput(json: json, id: responseID, streaming: false) + let modelOutput: ModelOutput + if schema == nil { + modelOutput = ModelOutput(kind: .string(text), id: responseID, isComplete: true) + } else { + modelOutput = try ModelOutput(json: text, id: responseID, streaming: false) + } return try GenerativeModel.Response( content: Content(modelOutput), rawContent: modelOutput, From d8b2d3db215dd84307f09817a1e43219ef68b8bb Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 12 Feb 2026 19:43:55 -0500 Subject: [PATCH 12/17] Update/add integration tests --- FirebaseAI/Sources/GenerativeModel.swift | 58 +-- .../GenerateContentIntegrationTests.swift | 109 ++++ .../Tests/Integration/SchemaTests.swift | 471 ++++++++++-------- 3 files changed, 366 insertions(+), 272 deletions(-) diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index 20710ac480a..0c08a6d63bf 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -181,62 +181,6 @@ public final class GenerativeModel: Sendable { // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. #if compiler(>=6.2) - - // TODO: Update public API - public func generate(_ type: Content.Type, - from parts: any PartsRepresentable...) async throws - -> Response where Content: FirebaseGenerable { - var generationConfig = self.generationConfig ?? GenerationConfig() - generationConfig.candidateCount = nil - generationConfig.responseMIMEType = "application/json" - generationConfig.responseJSONSchema = type.jsonSchema - generationConfig.responseModalities = nil - - let response: GenerateContentResponse - do { - response = try await generateContent( - [ModelContent(parts: parts)], generationConfig: generationConfig - ) - } catch let error as GenerateContentError { - throw GenerationError.generationFailure(error) - } catch { - throw GenerationError - .generationFailure(GenerateContentError.internalError(underlying: error)) - } - - // TODO: Add `GenerateContentResponse` as context in errors. - - guard let jsonText = response.text else { - throw GenerationError.decodingFailure( - GenerationError.Context(debugDescription: "No JSON text in response.") - ) - } - - let modelOutput: ModelOutput - do { - modelOutput = try ModelOutput(json: jsonText) - } catch let error as GenerationError { - throw error - } catch { - throw GenerationError.decodingFailure( - GenerationError.Context(debugDescription: "Failed to decode response JSON: \(jsonText)") - ) - } - - let content: Content - do { - content = try Content(modelOutput) - } catch let error as GenerationError { - throw error - } catch { - throw GenerationError.decodingFailure( - GenerationError.Context(debugDescription: "Failed to decode \(type) from: \(modelOutput)") - ) - } - - return Response(content: content, rawContent: modelOutput, rawResponse: response) - } - @discardableResult public final nonisolated(nonsending) func respond(to parts: any PartsRepresentable..., options: GenerationConfig? = nil) @@ -615,6 +559,7 @@ public final class GenerativeModel: Sendable { GenerateContentError.internalError(underlying: error) ) } + // TODO: Add `GenerateContentResponse` as context in errors. } final func streamResponse(to parts: [ModelContent], generating type: Content.Type, @@ -656,6 +601,7 @@ public final class GenerativeModel: Sendable { GenerateContentError.internalError(underlying: error) )) } + // TODO: Add `GenerateContentResponse` as context in errors. } } #endif // compiler(>=6.2) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index ed24e083a41..d9ac7dd7ec9 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -112,6 +112,74 @@ struct GenerateContentIntegrationTests { usageMetadata.thoughtsTokenCount)) } + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: [ + (InstanceConfig.vertexAI_v1beta, ModelNames.gemini2FlashLite), + (InstanceConfig.vertexAI_v1beta_global, ModelNames.gemini2FlashLite), + (InstanceConfig.vertexAI_v1beta_global_appCheckLimitedUse, ModelNames.gemini2FlashLite), + (InstanceConfig.googleAI_v1beta, ModelNames.gemini2FlashLite), + (InstanceConfig.googleAI_v1beta_appCheckLimitedUse, ModelNames.gemini2FlashLite), + (InstanceConfig.googleAI_v1beta, ModelNames.gemini3FlashPreview), + (InstanceConfig.googleAI_v1beta_appCheckLimitedUse, ModelNames.gemini3FlashPreview), + (InstanceConfig.googleAI_v1beta, ModelNames.gemma3_4B), + (InstanceConfig.googleAI_v1beta_freeTier, ModelNames.gemma3_4B), + // Note: The following configs are commented out for easy one-off manual testing. + // (InstanceConfig.googleAI_v1beta_freeTier, ModelNames.gemini2FlashLite), + // (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemini2FlashLite), + // (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemma3_4B), + // (InstanceConfig.vertexAI_v1beta_staging, ModelNames.gemini2FlashLite), + // (InstanceConfig.googleAI_v1beta_freeTier_bypassProxy, ModelNames.gemini2FlashLite), + // (InstanceConfig.googleAI_v1beta_freeTier_bypassProxy, ModelNames.gemma3_4B), + ]) + func respondWithString(_ config: InstanceConfig, modelName: String) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: modelName, + generationConfig: generationConfig, + safetySettings: safetySettings, + ) + let prompt = "Where is Google headquarters located? Answer with the city name only." + + let response = try await model.respond(to: prompt) + + let text = response.content.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(text == "Mountain View") + + let usageMetadata = try #require(response.rawResponse.usageMetadata) + #expect(usageMetadata.promptTokenCount.isEqual(to: 13, accuracy: tokenCountAccuracy)) + #expect(usageMetadata.promptTokensDetails.count == 1) + let promptTokensDetails = try #require(usageMetadata.promptTokensDetails.first) + #expect(promptTokensDetails.modality == .text) + #expect(promptTokensDetails.tokenCount == usageMetadata.promptTokenCount) + if modelName.hasPrefix("gemini-3") { + // For gemini-3 models, the thoughtsTokenCount can vary slightly between runs. + #expect(usageMetadata.thoughtsTokenCount >= 64) + } else { + #expect(usageMetadata.thoughtsTokenCount == 0) + } + // The fields `candidatesTokenCount` and `candidatesTokensDetails` are not included when using + // Gemma models. + if modelName.hasPrefix("gemini-3") { + #expect(usageMetadata.candidatesTokenCount == 2) + #expect(usageMetadata.candidatesTokensDetails.isEmpty) + } else if modelName.hasPrefix("gemma") { + #expect(usageMetadata.candidatesTokenCount == 0) + #expect(usageMetadata.candidatesTokensDetails.isEmpty) + } else { + #expect(usageMetadata.candidatesTokenCount.isEqual(to: 3, accuracy: tokenCountAccuracy)) + #expect(usageMetadata.candidatesTokensDetails.count == 1) + let candidatesTokensDetails = try #require(usageMetadata.candidatesTokensDetails.first) + #expect(candidatesTokensDetails.modality == .text) + #expect(candidatesTokensDetails.tokenCount == usageMetadata.candidatesTokenCount) + } + #expect(usageMetadata.cachedContentTokenCount == 0) + #expect(usageMetadata.cacheTokensDetails.isEmpty) + #expect(usageMetadata.totalTokenCount == (usageMetadata.promptTokenCount + + usageMetadata.candidatesTokenCount + + usageMetadata.thoughtsTokenCount)) + } + #endif // compiler(>=6.2) + @Test( "Generate an enum and provide a system instruction", arguments: InstanceConfig.allConfigs @@ -151,6 +219,47 @@ struct GenerateContentIntegrationTests { #expect(candidatesTokensDetails.tokenCount == usageMetadata.candidatesTokenCount) } + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test( + "Generate an enum and provide a system instruction", + arguments: InstanceConfig.allConfigs + ) + func respondWithEnum(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2FlashLite, + safetySettings: safetySettings, + tools: [], + toolConfig: .init(functionCallingConfig: .none()), + systemInstruction: ModelContent(role: "system", parts: "Always pick blue.") + ) + let prompt = "What is your favourite colour?" + + let response = try await model.respond(to: prompt, options: GenerationConfig( + responseMIMEType: "text/x.enum", + responseSchema: .enumeration(values: ["Red", "Green", "Blue"]) + )) + + let text = response.content.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(text == "Blue") + + let usageMetadata = try #require(response.rawResponse.usageMetadata) + #expect(usageMetadata.promptTokenCount.isEqual(to: 15, accuracy: tokenCountAccuracy)) + #expect(usageMetadata.candidatesTokenCount.isEqual(to: 1, accuracy: tokenCountAccuracy)) + #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) + } + #endif // compiler(>=6.2) + @Test( arguments: [ (.vertexAI_v1beta, ModelNames.gemini2_5_Flash, ThinkingConfig(thinkingBudget: 0)), diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift index 284e30180ad..68c138e9d71 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/SchemaTests.swift @@ -78,27 +78,30 @@ struct SchemaTests { ) } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeWithArray(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = "What are the biggest cities in Canada?" + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithArray(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = "What are the biggest cities in Canada?" - let response = try await model.generate(CityList.self, from: prompt) + let response = try await model.respond(to: prompt, generating: CityList.self) - let cityList = response.content - #expect( - cityList.cities.count >= 3, - "Expected at least 3 cities, but got \(cityList.cities.count)" - ) - #expect( - cityList.cities.count <= 5, - "Expected at most 5 cities, but got \(cityList.cities.count)" - ) - } + let cityList = response.content + #expect( + cityList.cities.count >= 3, + "Expected at least 3 cities, but got \(cityList.cities.count)" + ) + #expect( + cityList.cities.count <= 5, + "Expected at most 5 cities, but got \(cityList.cities.count)" + ) + } + #endif // compiler(>=6.2) @FirebaseGenerable struct TestNumber { @@ -138,21 +141,24 @@ struct SchemaTests { #expect(testNumber.value <= 120, "Expected a number <= 120, but got \(testNumber.value)") } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeWithNumber(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = "Give me a number" + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithNumber(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = "Give me a number" - let response = try await model.generate(TestNumber.self, from: prompt) + let response = try await model.respond(to: prompt, generating: TestNumber.self) - let testNumber = response.content - #expect(testNumber.value >= 110, "Expected a number >= 110, but got \(testNumber.value)") - #expect(testNumber.value <= 120, "Expected a number <= 120, but got \(testNumber.value)") - } + let testNumber = response.content + #expect(testNumber.value >= 110, "Expected a number >= 110, but got \(testNumber.value)") + #expect(testNumber.value <= 120, "Expected a number <= 120, but got \(testNumber.value)") + } + #endif // compiler(>=6.2) @FirebaseGenerable struct ProductInfo { @@ -217,28 +223,31 @@ struct SchemaTests { #expect(rating <= 5, "Expected a rating <= 5, but got \(rating)") } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeWithMultipleDataTypes(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = "Describe a premium wireless headphone, including a user rating and price." - - let response = try await model.generate(ProductInfo.self, from: prompt) - - let productInfo = response.content - let price = productInfo.price - let salePrice = productInfo.salePrice - let rating = productInfo.rating - #expect(price >= 10.0, "Expected a price >= 10.00, but got \(price)") - #expect(price <= 120.0, "Expected a price <= 120.00, but got \(price)") - #expect(salePrice >= 5.0, "Expected a salePrice >= 5.00, but got \(salePrice)") - #expect(salePrice <= 90.0, "Expected a salePrice <= 90.00, but got \(salePrice)") - #expect(rating >= 1, "Expected a rating >= 1, but got \(rating)") - #expect(rating <= 5, "Expected a rating <= 5, but got \(rating)") - } + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithMultipleDataTypes(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = "Describe a premium wireless headphone, including a user rating and price." + + let response = try await model.respond(to: prompt, generating: ProductInfo.self) + + let productInfo = response.content + let price = productInfo.price + let salePrice = productInfo.salePrice + let rating = productInfo.rating + #expect(price >= 10.0, "Expected a price >= 10.00, but got \(price)") + #expect(price <= 120.0, "Expected a price <= 120.00, but got \(price)") + #expect(salePrice >= 5.0, "Expected a salePrice >= 5.00, but got \(salePrice)") + #expect(salePrice <= 90.0, "Expected a salePrice <= 90.00, but got \(salePrice)") + #expect(rating >= 1, "Expected a rating >= 1, but got \(rating)") + #expect(rating <= 5, "Expected a rating <= 5, but got \(rating)") + } + #endif // compiler(>=6.2) @FirebaseGenerable struct MailingAddress { @@ -344,49 +353,52 @@ struct SchemaTests { } } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeAnyOf(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_Flash, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = """ - What are the mailing addresses for the University of Waterloo, UC Berkeley and Queen's U? - """ + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithAnyOfArray(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_Flash, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = """ + What are the mailing addresses for the University of Waterloo, UC Berkeley and Queen's U? + """ - let response = try await model.generate([MailingAddress].self, from: prompt) + let response = try await model.respond(to: prompt, generating: [MailingAddress].self) - let mailingAddresses = response.content - try #require( - mailingAddresses.count == 3, - "Expected 3 JSON addresses, got \(mailingAddresses.count)." - ) - let waterlooAddress = mailingAddresses[0] - #expect(waterlooAddress.city == "Waterloo") - if case let .canada(province, postalCode) = waterlooAddress.postalInfo { - #expect(province == "ON") - #expect(postalCode == "N2L 3G1") - } else { - Issue.record("Expected Canadian University of Waterloo address, got \(waterlooAddress).") - } - let berkeleyAddress = mailingAddresses[1] - #expect(berkeleyAddress.city == "Berkeley") - if case let .unitedStates(state, zipCode) = berkeleyAddress.postalInfo { - #expect(state == "CA") - #expect(zipCode == "94720") - } else { - Issue.record("Expected American UC Berkeley address, got \(berkeleyAddress).") - } - let queensAddress = mailingAddresses[2] - #expect(queensAddress.city == "Kingston") - if case let .canada(province, postalCode) = queensAddress.postalInfo { - #expect(province == "ON") - #expect(postalCode == "K7L 3N6") - } else { - Issue.record("Expected Canadian Queen's University address, got \(queensAddress).") + let mailingAddresses = response.content + try #require( + mailingAddresses.count == 3, + "Expected 3 JSON addresses, got \(mailingAddresses.count)." + ) + let waterlooAddress = mailingAddresses[0] + #expect(waterlooAddress.city == "Waterloo") + if case let .canada(province, postalCode) = waterlooAddress.postalInfo { + #expect(province == "ON") + #expect(postalCode == "N2L 3G1") + } else { + Issue.record("Expected Canadian University of Waterloo address, got \(waterlooAddress).") + } + let berkeleyAddress = mailingAddresses[1] + #expect(berkeleyAddress.city == "Berkeley") + if case let .unitedStates(state, zipCode) = berkeleyAddress.postalInfo { + #expect(state == "CA") + #expect(zipCode == "94720") + } else { + Issue.record("Expected American UC Berkeley address, got \(berkeleyAddress).") + } + let queensAddress = mailingAddresses[2] + #expect(queensAddress.city == "Kingston") + if case let .canada(province, postalCode) = queensAddress.postalInfo { + #expect(province == "ON") + #expect(postalCode == "K7L 3N6") + } else { + Issue.record("Expected Canadian Queen's University address, got \(queensAddress).") + } } - } + #endif // compiler(>=6.2) @FirebaseGenerable struct FeatureToggle { @@ -420,20 +432,23 @@ struct SchemaTests { #expect(featureToggle.isEnabled) } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeBoolean(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = "Should the experimental feature be active? Answer yes." + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithBoolean(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = "Should the experimental feature be active? Answer yes." - let response = try await model.generate(FeatureToggle.self, from: prompt) + let response = try await model.respond(to: prompt, generating: FeatureToggle.self) - let featureToggle = response.content - #expect(featureToggle.isEnabled) - } + let featureToggle = response.content + #expect(featureToggle.isEnabled) + } + #endif // compiler(>=6.2) @FirebaseGenerable struct UserProfile { @@ -471,21 +486,24 @@ struct SchemaTests { #expect(userProfile.middleName == nil) } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeOptional(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = "Create a user profile for 'jdoe' without a middle name." + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithUserProfile(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = "Create a user profile for 'jdoe' without a middle name." - let response = try await model.generate(UserProfile.self, from: prompt) + let response = try await model.respond(to: prompt, generating: UserProfile.self) - let userProfile = response.content - #expect(userProfile.username == "jdoe") - #expect(userProfile.middleName == nil) - } + let userProfile = response.content + #expect(userProfile.username == "jdoe") + #expect(userProfile.middleName == nil) + } + #endif // compiler(>=6.2) @FirebaseGenerable struct Pet { @@ -531,21 +549,24 @@ struct SchemaTests { #expect(pet.species == .cat) } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeSimpleStringEnum(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = "Create a pet dog named 'Buddy'." + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithSimpleStringEnum(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = "Create a pet dog named 'Buddy'." - let response = try await model.generate(Pet.self, from: prompt) + let response = try await model.respond(to: prompt, generating: Pet.self) - let pet = response.content - #expect(pet.name == "Buddy") - #expect(pet.species == .dog) - } + let pet = response.content + #expect(pet.name == "Buddy") + #expect(pet.species == .dog) + } + #endif // compiler(>=6.2) @FirebaseGenerable struct Task { @@ -594,21 +615,24 @@ struct SchemaTests { #expect(task.priority == .medium) } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeStringRawValueEnum(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = "Create a high priority task titled 'Fix Bug'." + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithStringRawValueEnum(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = "Create a high priority task titled 'Fix Bug'." - let response = try await model.generate(Task.self, from: prompt) + let response = try await model.respond(to: prompt, generating: Task.self) - let task = response.content - #expect(task.title == "Fix Bug") - #expect(task.priority == .high) - } + let task = response.content + #expect(task.title == "Fix Bug") + #expect(task.priority == .high) + } + #endif // compiler(>=6.2) @FirebaseGenerable struct GradeBook { @@ -649,23 +673,26 @@ struct SchemaTests { } } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeArrayConstraints(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = "Generate a gradebook with scores 95, 80, and 100." + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithConstrainedArray(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = "Generate a gradebook with scores 95, 80, and 100." - let response = try await model.generate(GradeBook.self, from: prompt) + let response = try await model.respond(to: prompt, generating: GradeBook.self) - let gradeBook = response.content - #expect(gradeBook.scores.count == 3) - for score in gradeBook.scores { - #expect(score >= 0 && score <= 100) + let gradeBook = response.content + #expect(gradeBook.scores.count == 3) + for score in gradeBook.scores { + #expect(score >= 0 && score <= 100) + } } - } + #endif // compiler(>=6.2) @FirebaseGenerable struct Catalog { @@ -731,27 +758,30 @@ struct SchemaTests { #expect(catalog.categories[0].items[0].price == 999.99) } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeNesting(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = """ - Create a catalog named 'Tech' with a category 'Computers' containing an item 'Laptop' for 999.99. - """ - - let response = try await model.generate(Catalog.self, from: prompt) - - let catalog = response.content - #expect(catalog.name == "Tech") - #expect(catalog.categories.count == 1) - #expect(catalog.categories[0].title == "Computers") - #expect(catalog.categories[0].items.count == 1) - #expect(catalog.categories[0].items[0].name == "Laptop") - #expect(catalog.categories[0].items[0].price == 999.99) - } + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithNestedType(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = """ + Create a catalog named 'Tech' with a category 'Computers' containing an item 'Laptop' for 999.99. + """ + + let response = try await model.respond(to: prompt, generating: Catalog.self) + + let catalog = response.content + #expect(catalog.name == "Tech") + #expect(catalog.categories.count == 1) + #expect(catalog.categories[0].title == "Computers") + #expect(catalog.categories[0].items.count == 1) + #expect(catalog.categories[0].items[0].name == "Laptop") + #expect(catalog.categories[0].items[0].price == 999.99) + } + #endif // compiler(>=6.2) @FirebaseGenerable struct Statement { @@ -785,20 +815,23 @@ struct SchemaTests { #expect(statement.balance == Decimal(string: "123.45")!) } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeDecimal(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = "Generate a statement with balance 123.45." + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithDecimalType(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = "Generate a statement with balance 123.45." - let response = try await model.generate(Statement.self, from: prompt) + let response = try await model.respond(to: prompt, generating: Statement.self) - let statement = response.content - #expect(statement.balance == Decimal(string: "123.45")!) - } + let statement = response.content + #expect(statement.balance == Decimal(string: "123.45")!) + } + #endif // compiler(>=6.2) @FirebaseGenerable struct Metadata { @@ -837,20 +870,23 @@ struct SchemaTests { #expect(metadata.tags.isEmpty) } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeEmptyCollection(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = "Generate metadata with no tags." + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithEmptyCollection(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = "Generate metadata with no tags." - let response = try await model.generate(Metadata.self, from: prompt) + let response = try await model.respond(to: prompt, generating: Metadata.self) - let metadata = response.content - #expect(metadata.tags.isEmpty) - } + let metadata = response.content + #expect(metadata.tags.isEmpty) + } + #endif // compiler(>=6.2) @FirebaseGenerable struct ConstrainedValue { @@ -884,20 +920,23 @@ struct SchemaTests { #expect(constrainedValue.value == 15) } - @Test(arguments: InstanceConfig.allConfigs) - func generateTypeCombinedGuides(_ config: InstanceConfig) async throws { - let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2_5_FlashLite, - generationConfig: generationConfig, - safetySettings: safetySettings - ) - let prompt = "Give me the value 15." + // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. + #if compiler(>=6.2) + @Test(arguments: InstanceConfig.allConfigs) + func respondWithTypeCombinedGuides(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2_5_FlashLite, + generationConfig: generationConfig, + safetySettings: safetySettings + ) + let prompt = "Give me the value 15." - let response = try await model.generate(ConstrainedValue.self, from: prompt) + let response = try await model.respond(to: prompt, generating: ConstrainedValue.self) - let constrainedValue = response.content - #expect(constrainedValue.value == 15) - } + let constrainedValue = response.content + #expect(constrainedValue.value == 15) + } + #endif // compiler(>=6.2) @Test(arguments: testConfigs( instanceConfigs: InstanceConfig.allConfigs, From b8ed3efbbcebbd276f87bef4be00fb5f664d03dc Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 13 Feb 2026 14:20:18 -0500 Subject: [PATCH 13/17] Remove `parts: [ModelContent]` overloads and variadics --- FirebaseAI/Sources/GenerativeModel.swift | 102 +++++------------------ 1 file changed, 20 insertions(+), 82 deletions(-) diff --git a/FirebaseAI/Sources/GenerativeModel.swift b/FirebaseAI/Sources/GenerativeModel.swift index 0c08a6d63bf..a9f8751c469 100644 --- a/FirebaseAI/Sources/GenerativeModel.swift +++ b/FirebaseAI/Sources/GenerativeModel.swift @@ -183,18 +183,10 @@ public final class GenerativeModel: Sendable { #if compiler(>=6.2) @discardableResult public final nonisolated(nonsending) - func respond(to parts: any PartsRepresentable..., options: GenerationConfig? = nil) - async throws -> GenerativeModel.Response { - let parts = [ModelContent(parts: parts)] - return try await respond(to: parts, options: options) - } - - @discardableResult - public final nonisolated(nonsending) - func respond(to parts: [ModelContent], options: GenerationConfig? = nil) + func respond(to prompt: any PartsRepresentable, options: GenerationConfig? = nil) async throws -> GenerativeModel.Response { return try await respond( - to: parts, + to: prompt, generating: String.self, schema: nil, includeSchemaInPrompt: false, @@ -204,25 +196,11 @@ public final class GenerativeModel: Sendable { @discardableResult public final nonisolated(nonsending) - func respond(to parts: any PartsRepresentable..., schema: JSONSchema, + func respond(to prompt: any PartsRepresentable, schema: JSONSchema, includeSchemaInPrompt: Bool = true, options: GenerationConfig? = nil) async throws -> GenerativeModel.Response { - let parts = [ModelContent(parts: parts)] - return try await respond( - to: parts, - schema: schema, - includeSchemaInPrompt: includeSchemaInPrompt, - options: options - ) - } - - @discardableResult - public final nonisolated(nonsending) - func respond(to parts: [ModelContent], schema: JSONSchema, includeSchemaInPrompt: Bool = true, - options: GenerationConfig? = nil) - async throws -> GenerativeModel.Response { return try await respond( - to: parts, + to: prompt, generating: ModelOutput.self, schema: schema, includeSchemaInPrompt: includeSchemaInPrompt, @@ -232,27 +210,12 @@ public final class GenerativeModel: Sendable { @discardableResult public final nonisolated(nonsending) - func respond(to parts: any PartsRepresentable..., + func respond(to prompt: any PartsRepresentable, generating type: Content.Type = Content.self, includeSchemaInPrompt: Bool = true, options: GenerationConfig? = nil) async throws -> GenerativeModel.Response where Content: FirebaseGenerable { - let parts = [ModelContent(parts: parts)] return try await respond( - to: parts, - generating: type, - includeSchemaInPrompt: includeSchemaInPrompt, - options: options - ) - } - - @discardableResult - public final nonisolated(nonsending) - func respond(to parts: [ModelContent], - generating type: Content.Type = Content.self, - includeSchemaInPrompt: Bool = true, options: GenerationConfig? = nil) - async throws -> GenerativeModel.Response where Content: FirebaseGenerable { - return try await respond( - to: parts, + to: prompt, generating: type, schema: type.jsonSchema, includeSchemaInPrompt: includeSchemaInPrompt, @@ -260,56 +223,27 @@ public final class GenerativeModel: Sendable { ) } - public final func streamResponse(to parts: any PartsRepresentable..., + public final func streamResponse(to prompt: any PartsRepresentable, options: GenerationConfig? = nil) -> sending GenerativeModel.ResponseStream { - let parts = [ModelContent(parts: parts)] - return streamResponse(to: parts, options: options) - } - - public final func streamResponse(to parts: [ModelContent], options: GenerationConfig? = nil) - -> sending GenerativeModel.ResponseStream { - return streamResponse(to: parts, generating: String.self, schema: nil, + return streamResponse(to: prompt, generating: String.self, schema: nil, includeSchemaInPrompt: false, options: options) } - public final func streamResponse(to parts: any PartsRepresentable..., schema: JSONSchema, + public final func streamResponse(to prompt: any PartsRepresentable, schema: JSONSchema, includeSchemaInPrompt: Bool = true, options: GenerationConfig? = nil) -> sending GenerativeModel.ResponseStream { - let parts = [ModelContent(parts: parts)] - return streamResponse(to: parts, schema: schema, includeSchemaInPrompt: includeSchemaInPrompt, - options: options) - } - - public final func streamResponse(to parts: [ModelContent], schema: JSONSchema, - includeSchemaInPrompt: Bool = true, - options: GenerationConfig? = nil) - -> sending GenerativeModel.ResponseStream { - return streamResponse(to: parts, generating: ModelOutput.self, schema: schema, + return streamResponse(to: prompt, generating: ModelOutput.self, schema: schema, includeSchemaInPrompt: includeSchemaInPrompt, options: options) } - public final func streamResponse(to parts: any PartsRepresentable..., + public final func streamResponse(to prompt: any PartsRepresentable, generating type: Content.Type = Content.self, includeSchemaInPrompt: Bool = true, options: GenerationConfig? = nil) -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { - let parts = [ModelContent(parts: parts)] - return streamResponse( - to: parts, - generating: type, - includeSchemaInPrompt: includeSchemaInPrompt, - options: options - ) - } - - public final func streamResponse(to parts: [ModelContent], - generating type: Content.Type = Content.self, - includeSchemaInPrompt: Bool = true, - options: GenerationConfig? = nil) - -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { - return streamResponse(to: parts, generating: type, schema: type.jsonSchema, + return streamResponse(to: prompt, generating: type, schema: type.jsonSchema, includeSchemaInPrompt: includeSchemaInPrompt, options: options) } #endif // compiler(>=6.2) @@ -520,10 +454,12 @@ public final class GenerativeModel: Sendable { // TODO: Remove the `#if compiler(>=6.2)` when Xcode 26 is the minimum supported version. #if compiler(>=6.2) final nonisolated(nonsending) - func respond(to parts: [ModelContent], generating type: Content.Type, + func respond(to prompt: any PartsRepresentable, generating type: Content.Type, schema: JSONSchema?, includeSchemaInPrompt: Bool, options: GenerationConfig?) async throws -> GenerativeModel.Response where Content: FirebaseGenerable { + let parts = [ModelContent(parts: prompt)] + let generationConfig: GenerationConfig? if let schema { generationConfig = GenerationConfig.merge( @@ -562,10 +498,12 @@ public final class GenerativeModel: Sendable { // TODO: Add `GenerateContentResponse` as context in errors. } - final func streamResponse(to parts: [ModelContent], generating type: Content.Type, - schema: JSONSchema?, includeSchemaInPrompt: Bool, - options: GenerationConfig?) + final func streamResponse(to prompt: any PartsRepresentable, + generating type: Content.Type, schema: JSONSchema?, + includeSchemaInPrompt: Bool, options: GenerationConfig?) -> sending GenerativeModel.ResponseStream where Content: FirebaseGenerable { + let parts = [ModelContent(parts: prompt)] + let generationConfig: GenerationConfig? if let schema { generationConfig = GenerationConfig.merge( From a4d7107d3763ecbc026cface3e4b5cdba14bd57d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 13 Feb 2026 15:54:23 -0500 Subject: [PATCH 14/17] Remove `StreamingJSONParser` --- .../Public/StructuredOutput/ModelOutput.swift | 20 +- .../StreamingJSONParser.swift | 327 ------------------ 2 files changed, 9 insertions(+), 338 deletions(-) delete mode 100644 FirebaseAI/Sources/Types/Public/StructuredOutput/StreamingJSONParser.swift diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift index cab3091adf0..8add2797bfa 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift @@ -104,7 +104,11 @@ public struct ModelOutput: Sendable, CustomDebugStringConvertible, FirebaseGener // partial JSON when streaming. if !streaming { guard let jsonData = json.data(using: .utf8) else { - fatalError("TODO: Throw a reasonable decoding error") + throw GenerativeModel.GenerationError.decodingFailure( + GenerativeModel.GenerationError.Context( + debugDescription: "Failed to convert JSON to `Data`: \(json)" + ) + ) } do { let jsonValue = try JSONDecoder().decode(JSONValue.self, from: jsonData) @@ -139,21 +143,15 @@ public struct ModelOutput: Sendable, CustomDebugStringConvertible, FirebaseGener // 3. Fallback to decoding with a custom `StreamingJSONParser` when `GeneratedContent` is not // available. - let parser = StreamingJSONParser(json) - if let parsedModelOutput = parser.parse() { - modelOutput = parsedModelOutput - modelOutput.id = id - - self = modelOutput - - return - } + // TODO: Add a fallback streaming JSON parser // 4. Throw a decoding error if all attempts to decode the JSON have failed. if let decodingError { throw decodingError } else { - fatalError("TODO: Throw a decoding error") + throw GenerativeModel.GenerationError.decodingFailure( + GenerativeModel.GenerationError.Context(debugDescription: "Failed to decode JSON: \(json)") + ) } } diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/StreamingJSONParser.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/StreamingJSONParser.swift deleted file mode 100644 index 4a15b2da9d9..00000000000 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/StreamingJSONParser.swift +++ /dev/null @@ -1,327 +0,0 @@ -// 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. - -import Foundation - -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -final class StreamingJSONParser { - private let input: [UnicodeScalar] - private var index: Int - - init(_ json: String) { - input = Array(json.unicodeScalars) - index = 0 - } - - private func skipWhitespace() { - while index < input.count { - let char = input[index] - if !CharacterSet.whitespacesAndNewlines.contains(char) { - break - } - index += 1 - } - } - - private func peek() -> UnicodeScalar? { - guard index < input.count else { return nil } - return input[index] - } - - private func advance() -> UnicodeScalar { - let char = input[index] - index += 1 - return char - } - - func parse() -> ModelOutput? { - skipWhitespace() - guard let char = peek() else { return nil } - - switch char { - case "{": return parseObject() - case "[": return parseArray() - case "\"": return parseString() - case "t", "f": return parseBool() - case "n": return parseNull() - case "-", "0" ... "9": return parseNumber() - default: return nil - } - } - - private func parseObject() -> ModelOutput { - _ = advance() // consume '{' - var properties: [String: ModelOutput] = [:] - var orderedKeys: [String] = [] - - while true { - skipWhitespace() - - if let char = peek(), char == "}" { - _ = advance() - break - } - if peek() == nil { - break - } - - // Expecting a key (string) - if peek() != "\"" { - break - } - - let keyOutput = parseString() - guard case let .string(key) = keyOutput.kind else { - break - } - - skipWhitespace() - - if let char = peek(), char == ":" { - _ = advance() - } else { - break - } - - skipWhitespace() - - if let value = parse() { - properties[key] = value - orderedKeys.append(key) - } else { - // Missing value or partial value (like "1" that returned nil). - // We don't add this key. - break - } - - skipWhitespace() - - if let char = peek() { - if char == "," { - _ = advance() - } else if char == "}" { - _ = advance() - break - } else { - break - } - } else { - break - } - } - - return ModelOutput(kind: .structure(properties: properties, orderedKeys: orderedKeys)) - } - - private func parseArray() -> ModelOutput { - _ = advance() // consume '[' - var elements: [ModelOutput] = [] - - while true { - skipWhitespace() - - if let char = peek(), char == "]" { - _ = advance() - break - } - if peek() == nil { - break - } - - if let value = parse() { - elements.append(value) - } else { - break - } - - skipWhitespace() - - if let char = peek() { - if char == "," { - _ = advance() - } else if char == "]" { - _ = advance() - break - } else { - break - } - } else { - break - } - } - return ModelOutput(kind: .array(elements)) - } - - private func parseString() -> ModelOutput { - _ = advance() // consume '"' - var currentString = "" - - while let char = peek() { - if char == "\"" { - _ = advance() - break - } - - if char == "\\" { - _ = advance() - guard let escapeChar = peek() else { break } - - switch escapeChar { - case "\"", "\\", "/": - currentString.append(Character(escapeChar)) - _ = advance() - case "b": - currentString.append("\u{08}") - _ = advance() - case "f": - currentString.append("\u{0C}") - _ = advance() - case "n": - currentString.append("\n") - _ = advance() - case "r": - currentString.append("\r") - _ = advance() - case "t": - currentString.append("\t") - _ = advance() - case "u": - if index + 5 <= input.count { - _ = advance() // consume 'u' - var hexString = "" - var validHex = true - for _ in 0 ..< 4 { - guard let h = peek() else { validHex = false; break } - if CharacterSet(charactersIn: "0123456789ABCDEFabcdef").contains(h) { - hexString.append(Character(h)) - _ = advance() - } else { - validHex = false; break - } - } - - if validHex, let codePoint = Int(hexString, radix: 16), - let scalar = UnicodeScalar(codePoint) { - currentString.append(Character(scalar)) - } else { - // Invalid hex or failure to create scalar. Return partial. - return ModelOutput(kind: .string(currentString)) - } - } else { - // Incomplete unicode escape. - return ModelOutput(kind: .string(currentString)) - } - default: - _ = advance() - } - } else { - currentString.append(Character(char)) - _ = advance() - } - } - - return ModelOutput(kind: .string(currentString)) - } - - private func parseBool() -> ModelOutput? { - if let char = peek(), char == "t" { - if index + 4 <= input.count { - let start = index - if input[start] == "t", input[start + 1] == "r", input[start + 2] == "u", - input[start + 3] == "e" { - index += 4 - return ModelOutput(kind: .bool(true)) - } - } - } else if let char = peek(), char == "f" { - if index + 5 <= input.count { - let start = index - if input[start] == "f", input[start + 1] == "a", input[start + 2] == "l", - input[start + 3] == "s", input[start + 4] == "e" { - index += 5 - return ModelOutput(kind: .bool(false)) - } - } - } - return nil - } - - private func parseNull() -> ModelOutput? { - if index + 4 <= input.count { - let start = index - if input[start] == "n", input[start + 1] == "u", input[start + 2] == "l", - input[start + 3] == "l" { - index += 4 - return ModelOutput(kind: .null) - } - } - return nil - } - - private func parseNumber() -> ModelOutput? { - let start = index - // Optional minus sign - if index < input.count && input[index] == "-" { - index += 1 - } - - // Integer part - if index < input.count && input[index] == "0" { - index += 1 - } else if index < input.count && CharacterSet(charactersIn: "123456789") - .contains(input[index]) { - index += 1 - while index < input.count, CharacterSet(charactersIn: "0123456789").contains(input[index]) { - index += 1 - } - } else { - // Invalid number start - return nil - } - - // Fraction part - if index < input.count && input[index] == "." { - index += 1 - while index < input.count, CharacterSet(charactersIn: "0123456789").contains(input[index]) { - index += 1 - } - } - - // Exponent part - if index < input.count && (input[index] == "e" || input[index] == "E") { - index += 1 - if index < input.count, input[index] == "+" || input[index] == "-" { - index += 1 - } - while index < input.count, CharacterSet(charactersIn: "0123456789").contains(input[index]) { - index += 1 - } - } - - // Check terminator - guard let char = peek() else { - // EOF - incomplete number - return nil - } - - if CharacterSet.whitespacesAndNewlines - .contains(char) || char == "," || char == "]" || char == "}" { - let numberString = String(String.UnicodeScalarView(input[start ..< index])) - if let doubleVal = Double(numberString) { - return ModelOutput(kind: .number(doubleVal)) - } - } - return nil - } -} From 4d167038f7824abf9566242f7e74a746a64ef11d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 13 Feb 2026 16:21:32 -0500 Subject: [PATCH 15/17] Switch to `ResponseStreamError` and simplify `GenerationConfig` merge --- FirebaseAI/Sources/GenerationConfig.swift | 26 ++++++------------- .../GenerativeModel+ResponseStream.swift | 11 ++++---- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/FirebaseAI/Sources/GenerationConfig.swift b/FirebaseAI/Sources/GenerationConfig.swift index 692c6449ea6..06977990cdd 100644 --- a/FirebaseAI/Sources/GenerationConfig.swift +++ b/FirebaseAI/Sources/GenerationConfig.swift @@ -231,24 +231,14 @@ public struct GenerationConfig: Sendable { if let temperature = overrideConfig.temperature { config.temperature = temperature } if let topP = overrideConfig.topP { config.topP = topP } if let topK = overrideConfig.topK { config.topK = topK } - if let candidateCount = overrideConfig.candidateCount { config.candidateCount = candidateCount } - if let maxOutputTokens = overrideConfig.maxOutputTokens { - config.maxOutputTokens = maxOutputTokens - } - if let presencePenalty = overrideConfig.presencePenalty { - config.presencePenalty = presencePenalty - } - if let frequencyPenalty = overrideConfig.frequencyPenalty { - config.frequencyPenalty = frequencyPenalty - } - if let stopSequences = overrideConfig.stopSequences { config.stopSequences = stopSequences } - if let responseMIMEType = overrideConfig.responseMIMEType { - config.responseMIMEType = responseMIMEType - } - if let responseModalities = overrideConfig.responseModalities { - config.responseModalities = responseModalities - } - if let thinkingConfig = overrideConfig.thinkingConfig { config.thinkingConfig = thinkingConfig } + config.candidateCount = overrideConfig.candidateCount ?? config.candidateCount + config.maxOutputTokens = overrideConfig.maxOutputTokens ?? config.maxOutputTokens + config.presencePenalty = overrideConfig.presencePenalty ?? config.presencePenalty + config.frequencyPenalty = overrideConfig.frequencyPenalty ?? config.frequencyPenalty + config.stopSequences = overrideConfig.stopSequences ?? config.stopSequences + config.responseMIMEType = overrideConfig.responseMIMEType ?? config.responseMIMEType + config.responseModalities = overrideConfig.responseModalities ?? config.responseModalities + config.thinkingConfig = overrideConfig.thinkingConfig ?? config.thinkingConfig // 5. Handle Schema mutual exclusivity with precedence for `responseJSONSchema`. if let responseJSONSchema = overrideConfig.responseJSONSchema { diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift index 547651359f9..76751d411a0 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/GenerativeModel+ResponseStream.swift @@ -112,7 +112,7 @@ } else if let last = _latestRaw { result = .success(last) } else { - result = .failure(AsyncSequenceErrors.unexpectedlyEmpty) + result = .failure(ResponseStreamError.noContentGenerated) } _finalResult = result @@ -122,11 +122,12 @@ } _waitingContinuations.removeAll() } + } - // TODO: Create a better error type - enum AsyncSequenceErrors: Error { - case unexpectedlyEmpty - } + enum ResponseStreamError: Error { + /// Thrown when `collect()` is called on a stream that finishes without producing any + /// snapshots. + case noContentGenerated } } #endif // compiler(>=6.2) From d5a8facf9aeb4c46e62edfd1f67dffd201cd1f0d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 13 Feb 2026 16:28:08 -0500 Subject: [PATCH 16/17] Simplify `GenerationConfig` merging --- FirebaseAI/Sources/GenerationConfig.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/FirebaseAI/Sources/GenerationConfig.swift b/FirebaseAI/Sources/GenerationConfig.swift index 06977990cdd..90d8eef7798 100644 --- a/FirebaseAI/Sources/GenerationConfig.swift +++ b/FirebaseAI/Sources/GenerationConfig.swift @@ -228,9 +228,9 @@ public struct GenerationConfig: Sendable { var config = baseConfig // 4. Overwrite with any non-nil values found in the overrides. - if let temperature = overrideConfig.temperature { config.temperature = temperature } - if let topP = overrideConfig.topP { config.topP = topP } - if let topK = overrideConfig.topK { config.topK = topK } + config.temperature = overrideConfig.temperature ?? config.temperature + config.topP = overrideConfig.topP ?? config.topP + config.topK = overrideConfig.topK ?? config.topK config.candidateCount = overrideConfig.candidateCount ?? config.candidateCount config.maxOutputTokens = overrideConfig.maxOutputTokens ?? config.maxOutputTokens config.presencePenalty = overrideConfig.presencePenalty ?? config.presencePenalty From 8d80d5f23d9aee0b19a9925c5ff9cb375c481340 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 13 Feb 2026 18:45:05 -0500 Subject: [PATCH 17/17] Attempt to use `JSONDecoder` in public `ModelOutput(json: String)` --- .../Types/Public/StructuredOutput/ModelOutput.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift index 8add2797bfa..076735743c7 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/ModelOutput.swift @@ -95,14 +95,15 @@ public struct ModelOutput: Sendable, CustomDebugStringConvertible, FirebaseGener self = value.modelOutput } - init(json: String, id: ResponseID? = nil, streaming: Bool) throws { + init(json: String, id: ResponseID? = nil, streaming: Bool?) throws { var modelOutput: ModelOutput var decodingError: Error? // 1. Attempt to decode the JSON with the standard `JSONDecoder` since it likely offers the best - // performance and is available on iOS 15+. Note: This approach does not support decoding - // partial JSON when streaming. - if !streaming { + // performance and is available on iOS 15+. + // Note: This approach does not support decoding partial JSON when streaming. As an + // optimization, this approach is skipped when `streaming` is explicitly set to `true`. + if streaming != true { guard let jsonData = json.data(using: .utf8) else { throw GenerativeModel.GenerationError.decodingFailure( GenerativeModel.GenerationError.Context( @@ -156,7 +157,9 @@ public struct ModelOutput: Sendable, CustomDebugStringConvertible, FirebaseGener } public init(json: String) throws { - try self.init(json: json, id: nil, streaming: true) + // Since it's unknown if the JSON is partial (for streaming), disable the optimizations by + // specifying `streaming: nil`. + try self.init(json: json, id: nil, streaming: nil) } public func value(_ type: Value.Type = Value.self) throws -> Value