diff --git a/FirebaseAI/Sources/Extensions/Public/SystemLanguageModel+LanguageModel.swift b/FirebaseAI/Sources/Extensions/Public/SystemLanguageModel+LanguageModel.swift index 045fdb1c82d..ba6794350eb 100644 --- a/FirebaseAI/Sources/Extensions/Public/SystemLanguageModel+LanguageModel.swift +++ b/FirebaseAI/Sources/Extensions/Public/SystemLanguageModel+LanguageModel.swift @@ -32,7 +32,7 @@ /// /// > Important: This method is for **internal use only** and may change at any time. public func _startSession(tools: [any ToolRepresentable]?, - instructions: String?) throws -> any _ModelSession { + instructions: FirebaseAI.Instructions?) throws -> any _ModelSession { switch availability { case .available: break @@ -81,7 +81,7 @@ return LanguageModelSession( model: self.systemLanguageModel, tools: afmTools, - instructions: instructions + instructions: instructions?.toFoundationModels() ) } #endif // canImport(FoundationModels) && IS_FOUNDATION_MODELS_SUPPORTED_PLATFORM diff --git a/FirebaseAI/Sources/FirebaseAI.swift b/FirebaseAI/Sources/FirebaseAI.swift index c9bc6df3447..7c12da03bc3 100644 --- a/FirebaseAI/Sources/FirebaseAI.swift +++ b/FirebaseAI/Sources/FirebaseAI.swift @@ -147,7 +147,8 @@ public final class FirebaseAI: Sendable { /// - instructions: System instructions that direct the model's behavior. public func generativeModelSession(model: any LanguageModelProvider, tools: [any ToolRepresentable]? = nil, - instructions: String? = nil) -> GenerativeModelSession { + instructions: FirebaseAI.Instructions? = nil) + -> GenerativeModelSession { let tools = tools?.map { $0.toolRepresentation } return GenerativeModelSession( @@ -157,6 +158,26 @@ public final class FirebaseAI: Sendable { ) } + public func generativeModelSession(model: any LanguageModelProvider, + tools: [any ToolRepresentable]? = nil, + @FirebaseAI.InstructionsBuilder instructions: () throws + -> FirebaseAI.Instructions) rethrows + -> GenerativeModelSession { + let tools = tools?.map { $0.toolRepresentation } + + return try generativeModelSession(model: model, tools: tools as [any ToolRepresentable]?, + instructions: instructions()) + } + + public func generativeModelSession(model: any LanguageModelProvider, + tools: [any ToolRepresentable]? = nil, + instructions: String?) -> GenerativeModelSession { + let tools = tools?.map { $0.toolRepresentation } + + return generativeModelSession(model: model, tools: tools as [any ToolRepresentable]?, + instructions: instructions) + } + #if canImport(FoundationModels) /// **[Public Preview]** Initializes a `GenerativeModelSession` with the given model. /// @@ -180,6 +201,20 @@ public final class FirebaseAI: Sendable { return generativeModelSession(model: model, tools: tools as [any ToolRepresentable]?, instructions: instructions) } + + @available(iOS 26.0, macOS 26.0, visionOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public func generativeModelSession(model: any LanguageModelProvider, + tools: [any FoundationModels.Tool], + @FirebaseAI.InstructionsBuilder instructions: () throws + -> FirebaseAI.Instructions) rethrows + -> GenerativeModelSession { + let tools = tools.map { FirebaseAILogic.Tool.autoFunctionDeclaration($0) } + + return try generativeModelSession(model: model, tools: tools as [any ToolRepresentable]?, + instructions: instructions) + } #endif // canImport(FoundationModels) #endif // compiler(>=6.2.3) diff --git a/FirebaseAI/Sources/GenerativeModelSession.swift b/FirebaseAI/Sources/GenerativeModelSession.swift index 9a26153df3a..75da09c605d 100644 --- a/FirebaseAI/Sources/GenerativeModelSession.swift +++ b/FirebaseAI/Sources/GenerativeModelSession.swift @@ -53,7 +53,7 @@ /// ``` public final class GenerativeModelSession: Sendable { let sessionManager: SessionManager - let instructions: String? + let instructions: FirebaseAI.Instructions? // The maximum number of automatic back-and-forth turns the session will perform to resolve // function calls. @@ -74,7 +74,7 @@ /// or instances conforming to ``ToolRepresentable`` for automatic function calling. /// - instructions: System instructions that direct the model's behavior. init(model: any LanguageModel, tools: [any ToolRepresentable]? = nil, - instructions: String? = nil) { + instructions: FirebaseAI.Instructions? = nil) { sessionManager = SessionManager(model: model, tools: tools) self.instructions = instructions } @@ -438,7 +438,7 @@ } } - func getOrStartSession(instructions: String?) throws -> any _ModelSession { + func getOrStartSession(instructions: FirebaseAI.Instructions?) throws -> any _ModelSession { try _isResponding.withLock { isResponding in if let currentSession = _activeSession { return currentSession diff --git a/FirebaseAI/Sources/Protocols/Internal/ConvertibleToGeneratedContent.swift b/FirebaseAI/Sources/Protocols/Internal/ConvertibleToGeneratedContent.swift index 9059b6b9fb1..baa3c280a4a 100644 --- a/FirebaseAI/Sources/Protocols/Internal/ConvertibleToGeneratedContent.swift +++ b/FirebaseAI/Sources/Protocols/Internal/ConvertibleToGeneratedContent.swift @@ -18,7 +18,7 @@ #endif // canImport(FoundationModels) extension FirebaseAI { - protocol ConvertibleToGeneratedContent { + protocol ConvertibleToGeneratedContent: FirebaseAI.InstructionsRepresentable { var firebaseGeneratedContent: FirebaseAI.GeneratedContent { get } } } diff --git a/FirebaseAI/Sources/Protocols/Public/InstructionsRepresentable.swift b/FirebaseAI/Sources/Protocols/Public/InstructionsRepresentable.swift new file mode 100644 index 00000000000..913707b4f20 --- /dev/null +++ b/FirebaseAI/Sources/Protocols/Public/InstructionsRepresentable.swift @@ -0,0 +1,55 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(FoundationModels) + import FoundationModels +#endif // canImport(FoundationModels) + +public extension FirebaseAI { + protocol InstructionsRepresentable { + @FirebaseAI.InstructionsBuilder + var firebaseInstructionsRepresentation: FirebaseAI.Instructions { get } + } +} + +extension String: FirebaseAI.InstructionsRepresentable { + public var firebaseInstructionsRepresentation: FirebaseAI.Instructions { + return FirebaseAI.Instructions(parts: [.text(self)]) + } +} + +extension Array: FirebaseAI.InstructionsRepresentable + where Element: FirebaseAI.InstructionsRepresentable { + public var firebaseInstructionsRepresentation: FirebaseAI.Instructions { + let allParts = flatMap { $0.firebaseInstructionsRepresentation.parts } + return FirebaseAI.Instructions(parts: allParts) + } +} + +extension FirebaseAI.GeneratedContent: FirebaseAI.InstructionsRepresentable { + public var firebaseInstructionsRepresentation: FirebaseAI.Instructions { + return FirebaseAI.Instructions(parts: [.structure(self)]) + } +} + +#if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, visionOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension FoundationModels.GeneratedContent: FirebaseAI.InstructionsRepresentable { + public var firebaseInstructionsRepresentation: FirebaseAI.Instructions { + FirebaseAI.Instructions(parts: [.structure(firebaseGeneratedContent)]) + } + } +#endif // canImport(FoundationModels) diff --git a/FirebaseAI/Sources/Protocols/Public/LanguageModel.swift b/FirebaseAI/Sources/Protocols/Public/LanguageModel.swift index 510595b2a02..9807b2ee24c 100644 --- a/FirebaseAI/Sources/Protocols/Public/LanguageModel.swift +++ b/FirebaseAI/Sources/Protocols/Public/LanguageModel.swift @@ -23,8 +23,8 @@ /// Returns a new session for this model. /// /// > Important: This method is for **internal use only** and may change at any time. - func _startSession(tools: [any ToolRepresentable]?, instructions: String?) throws - -> any _ModelSession + func _startSession(tools: [any ToolRepresentable]?, + instructions: FirebaseAI.Instructions?) throws -> any _ModelSession } // Default implementation to allow a `LanguageModel` to conform to `LanguageModelProvider`. diff --git a/FirebaseAI/Sources/Types/Internal/HybridModelSession.swift b/FirebaseAI/Sources/Types/Internal/HybridModelSession.swift index 21c08674891..f00b74405b8 100644 --- a/FirebaseAI/Sources/Types/Internal/HybridModelSession.swift +++ b/FirebaseAI/Sources/Types/Internal/HybridModelSession.swift @@ -19,13 +19,13 @@ private let primaryModel: any LanguageModel private let secondaryModel: any LanguageModel private let tools: [any ToolRepresentable]? - private let instructions: String? + private let instructions: FirebaseAI.Instructions? typealias SessionState = (primary: (any _ModelSession)?, secondary: (any _ModelSession)?) private let lock: UnfairLock init(primaryModel: any LanguageModel, secondaryModel: any LanguageModel, - tools: [any ToolRepresentable]?, instructions: String?) { + tools: [any ToolRepresentable]?, instructions: FirebaseAI.Instructions?) { self.primaryModel = primaryModel self.secondaryModel = secondaryModel self.tools = tools diff --git a/FirebaseAI/Sources/Types/Public/GeminiModel.swift b/FirebaseAI/Sources/Types/Public/GeminiModel.swift index 17b798a89b1..5d478c47fcf 100644 --- a/FirebaseAI/Sources/Types/Public/GeminiModel.swift +++ b/FirebaseAI/Sources/Types/Public/GeminiModel.swift @@ -62,7 +62,7 @@ import Foundation /// /// > Important: This method is for **internal use only** and may change at any time. public func _startSession(tools: [any ToolRepresentable]?, - instructions: String?) throws -> any _ModelSession { + instructions: FirebaseAI.Instructions?) throws -> any _ModelSession { let model = GenerativeModel( modelName: modelName, modelResourceName: modelResourceName, @@ -72,7 +72,7 @@ import Foundation safetySettings: safetySettings, tools: tools?.map { $0.toolRepresentation }, // TODO: Add toolConfig - systemInstruction: instructions.map { ModelContent(role: "system", parts: $0) }, + systemInstruction: instructions?.toModelContent(), requestOptions: requestOptions, urlSession: urlSession ) diff --git a/FirebaseAI/Sources/Types/Public/HybridModel.swift b/FirebaseAI/Sources/Types/Public/HybridModel.swift index 56f790d232b..10bb5276f18 100644 --- a/FirebaseAI/Sources/Types/Public/HybridModel.swift +++ b/FirebaseAI/Sources/Types/Public/HybridModel.swift @@ -45,7 +45,7 @@ /// /// > Important: This method is for **internal use only** and may change at any time. public func _startSession(tools: [any ToolRepresentable]?, - instructions: String?) throws -> any _ModelSession { + instructions: FirebaseAI.Instructions?) throws -> any _ModelSession { return HybridModelSession( primaryModel: primary, secondaryModel: secondary, diff --git a/FirebaseAI/Sources/Types/Public/Instructions.swift b/FirebaseAI/Sources/Types/Public/Instructions.swift new file mode 100644 index 00000000000..49ee82e9236 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Instructions.swift @@ -0,0 +1,78 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(FoundationModels) + import FoundationModels +#endif // canImport(FoundationModels) + +public extension FirebaseAI { + struct Instructions: Sendable { + enum InstructionsPart { + case text(String) + case structure(FirebaseAI.GeneratedContent) + } + + let parts: [InstructionsPart] + + init(parts: [InstructionsPart]) { + self.parts = parts + } + + public init(_ content: some FirebaseAI.InstructionsRepresentable) { + parts = content.firebaseInstructionsRepresentation.parts + } + + public init(@FirebaseAI.InstructionsBuilder _ content: () throws -> Instructions) rethrows { + self = try content() + } + + #if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, visionOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + func toFoundationModels() -> FoundationModels.Instructions { + return FoundationModels.Instructions { + for part in parts { + switch part { + case let .text(text): + text + case let .structure(structure): + structure + } + } + } + } + #endif // canImport(FoundationModels) + + func toModelContent() -> ModelContent { + return ModelContent( + role: "system", + parts: parts.map { + switch $0 { + case let .text(string): + return TextPart(string) + case let .structure(generatedContent): + return TextPart(generatedContent.jsonString) + } + } + ) + } + } +} + +extension FirebaseAI.Instructions: FirebaseAI.InstructionsRepresentable { + public var firebaseInstructionsRepresentation: FirebaseAI.Instructions { + self + } +} diff --git a/FirebaseAI/Sources/Types/Public/InstructionsBuilder.swift b/FirebaseAI/Sources/Types/Public/InstructionsBuilder.swift new file mode 100644 index 00000000000..0933a946b09 --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/InstructionsBuilder.swift @@ -0,0 +1,76 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(FoundationModels) + import FoundationModels +#endif // canImport(FoundationModels) + +public extension FirebaseAI { + @resultBuilder + struct InstructionsBuilder { + public static func buildBlock(_ components: repeat each I) + -> FirebaseAI.Instructions where repeat each I: FirebaseAI.InstructionsRepresentable { + var allComponents: [FirebaseAI.Instructions.InstructionsPart] = [] + repeat allComponents + .append(contentsOf: (each components).firebaseInstructionsRepresentation.parts) + return FirebaseAI.Instructions(parts: allComponents) + } + + public static func buildArray(_ instructions: [some FirebaseAI.InstructionsRepresentable]) + -> FirebaseAI.Instructions { + let allComponents = instructions.flatMap { $0.firebaseInstructionsRepresentation.parts } + return FirebaseAI.Instructions(parts: allComponents) + } + + public static func buildEither(first component: some FirebaseAI.InstructionsRepresentable) + -> FirebaseAI.Instructions { + component.firebaseInstructionsRepresentation + } + + public static func buildEither(second component: some FirebaseAI.InstructionsRepresentable) + -> FirebaseAI.Instructions { + component.firebaseInstructionsRepresentation + } + + public static func buildOptional(_ instructions: FirebaseAI.Instructions?) + -> FirebaseAI.Instructions { + instructions ?? FirebaseAI.Instructions(parts: []) + } + + public static func buildLimitedAvailability(_ instructions: some FirebaseAI + .InstructionsRepresentable) -> FirebaseAI.Instructions { + instructions.firebaseInstructionsRepresentation + } + + public static func buildExpression(_ expression: I) + -> I where I: FirebaseAI.InstructionsRepresentable { + expression + } + + public static func buildExpression(_ expression: FirebaseAI.Instructions) + -> FirebaseAI.Instructions { + expression + } + + #if canImport(FoundationModels) + @available(iOS 26.0, macOS 26.0, visionOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public static func buildExpression(_ expression: FoundationModels + .ConvertibleToGeneratedContent) -> FirebaseAI.Instructions { + FirebaseAI.Instructions(expression.generatedContent) + } + #endif // canImport(FoundationModels) + } +} diff --git a/FirebaseAI/Sources/Types/Public/StructuredOutput/GeneratedContent.swift b/FirebaseAI/Sources/Types/Public/StructuredOutput/GeneratedContent.swift index 266eceac6fb..683b09531bf 100644 --- a/FirebaseAI/Sources/Types/Public/StructuredOutput/GeneratedContent.swift +++ b/FirebaseAI/Sources/Types/Public/StructuredOutput/GeneratedContent.swift @@ -122,17 +122,35 @@ self.isComplete = isComplete } - #if canImport(FoundationModels) - /// The JSON string representation of the generated content. - /// - /// **Public Preview**: This API is a public preview and may be subject to change. - @available(iOS 26.0, macOS 26.0, visionOS 26.0, *) - @available(tvOS, unavailable) - @available(watchOS, unavailable) - public var jsonString: String { - return generatedContent.jsonString + /// The JSON string representation of the generated content. + /// + /// **Public Preview**: This API is a public preview and may be subject to change. + public var jsonString: String { + #if canImport(FoundationModels) && IS_FOUNDATION_MODELS_SUPPORTED_PLATFORM + if #available(iOS 26.0, macOS 26.0, visionOS 26.0, *) { + return generatedContent.jsonString + } + #endif // canImport(FoundationModels) && IS_FOUNDATION_MODELS_SUPPORTED_PLATFORM + + switch kind { + case .null: + return "null" + case let .bool(value): + return String(value) + case let .number(value): + return String(value) + case let .string(string): + return "\"\(string)\"" + case let .array(values): + return "[\(values.map { $0.jsonString }.joined(separator: ", "))]" + case let .structure(properties, orderedKeys): + let keyValuePairs = orderedKeys.compactMap { key -> String? in + guard let value = properties[key] else { return nil } + return "\"\(key)\": \(value.jsonString)" + } + return "{\(keyValuePairs.joined(separator: ", "))}" } - #endif // canImport(FoundationModels) + } // TODO: Replace `ConvertibleFromGeneratedContent` with custom protocol @@ -249,6 +267,15 @@ } } + @available(iOS 26.0, macOS 26.0, visionOS 26.0, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension FirebaseAI.GeneratedContent: FoundationModels.InstructionsRepresentable { + public var instructionsRepresentation: Instructions { + return Instructions(generatedContent) + } + } + @available(iOS 26.0, macOS 26.0, visionOS 26.0, *) @available(tvOS, unavailable) @available(watchOS, unavailable) diff --git a/FirebaseAI/Tests/Unit/HybridModelTests.swift b/FirebaseAI/Tests/Unit/HybridModelTests.swift index 0134ea2cf8b..43499605488 100644 --- a/FirebaseAI/Tests/Unit/HybridModelTests.swift +++ b/FirebaseAI/Tests/Unit/HybridModelTests.swift @@ -23,7 +23,7 @@ let startSessionHandler: @Sendable () throws -> any _ModelSession func _startSession(tools: [any ToolRepresentable]?, - instructions: String?) throws -> any _ModelSession { + instructions: FirebaseAI.Instructions?) throws -> any _ModelSession { return try startSessionHandler() } } diff --git a/FirebaseAI/Tests/Unit/Types/GeneratedContentTests.swift b/FirebaseAI/Tests/Unit/Types/GeneratedContentTests.swift new file mode 100644 index 00000000000..603f0e66efe --- /dev/null +++ b/FirebaseAI/Tests/Unit/Types/GeneratedContentTests.swift @@ -0,0 +1,103 @@ +// 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 FirebaseAILogic +import XCTest + +#if compiler(>=6.2.3) + @testable import FirebaseAILogic + import XCTest + + final class GeneratedContentTests: XCTestCase { + func testJsonString_null() throws { + let content = FirebaseAI.GeneratedContent(kind: .null, isComplete: true) + XCTAssertEqual(content.jsonString, "null") + } + + func testJsonString_bool() throws { + let contentTrue = FirebaseAI.GeneratedContent(kind: .bool(true), isComplete: true) + XCTAssertEqual(contentTrue.jsonString, "true") + + let contentFalse = FirebaseAI.GeneratedContent(kind: .bool(false), isComplete: true) + XCTAssertEqual(contentFalse.jsonString, "false") + } + + func testJsonString_number() throws { + let content = FirebaseAI.GeneratedContent(kind: .number(123.45), isComplete: true) + XCTAssertEqual(content.jsonString, "123.45") + } + + func testJsonString_string() throws { + let content = FirebaseAI.GeneratedContent(kind: .string("test-string"), isComplete: true) + XCTAssertEqual(content.jsonString, "\"test-string\"") + } + + func testJsonString_array() throws { + let content = FirebaseAI.GeneratedContent( + kind: .array([ + FirebaseAI.GeneratedContent(kind: .null, isComplete: true), + FirebaseAI.GeneratedContent(kind: .bool(true), isComplete: true), + FirebaseAI.GeneratedContent(kind: .number(12.5), isComplete: true), + FirebaseAI.GeneratedContent(kind: .string("abc"), isComplete: true), + ]), + isComplete: true + ) + XCTAssertEqual(content.jsonString, "[null, true, 12.5, \"abc\"]") + } + + func testJsonString_structureWithOrderedKeys() throws { + let content = FirebaseAI.GeneratedContent( + kind: .structure( + properties: [ + "z": FirebaseAI.GeneratedContent(kind: .number(1.5), isComplete: true), + "a": FirebaseAI.GeneratedContent(kind: .string("first"), isComplete: true), + "m": FirebaseAI.GeneratedContent(kind: .bool(false), isComplete: true), + ], + orderedKeys: ["m", "a", "z"] + ), + isComplete: true + ) + XCTAssertEqual(content.jsonString, "{\"m\": false, \"a\": \"first\", \"z\": 1.5}") + } + + func testJsonString_structureWithEmptyOrderedKeys() throws { + let content = FirebaseAI.GeneratedContent( + kind: .structure( + properties: [ + "z": FirebaseAI.GeneratedContent(kind: .number(1), isComplete: true), + "a": FirebaseAI.GeneratedContent(kind: .string("first"), isComplete: true), + ], + orderedKeys: [] + ), + isComplete: true + ) + XCTAssertEqual(content.jsonString, "{}") + } + + func testJsonString_structureWithPartialOrderedKeys() throws { + let content = FirebaseAI.GeneratedContent( + kind: .structure( + properties: [ + "z": FirebaseAI.GeneratedContent(kind: .number(1), isComplete: true), + "a": FirebaseAI.GeneratedContent(kind: .string("first"), isComplete: true), + "m": FirebaseAI.GeneratedContent(kind: .bool(false), isComplete: true), + ], + orderedKeys: ["m"] + ), + isComplete: true + ) + XCTAssertEqual(content.jsonString, "{\"m\": false}") + } + } +#endif // compiler(>=6.2.3)