diff --git a/FirebaseAI/Sources/Extensions/Internal/GeminiLanguageModel+Firebase.swift b/FirebaseAI/Sources/Extensions/Internal/GeminiLanguageModel+Firebase.swift index 4a058683ebd..e4966963c26 100644 --- a/FirebaseAI/Sources/Extensions/Internal/GeminiLanguageModel+Firebase.swift +++ b/FirebaseAI/Sources/Extensions/Internal/GeminiLanguageModel+Firebase.swift @@ -20,7 +20,13 @@ @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) @available(tvOS, unavailable) extension GeminiLanguageModel { - init(name: String, firebaseAI: FirebaseAI) { + /// Initializes a Gemini language model adapter using Firebase AI configuration. + /// + /// - Parameters: + /// - firebaseAI: The Firebase AI instance providing credentials and endpoint routing. + /// - name: The model name. + /// - thinking: An optional thinking configuration. Defaults to `nil`. + init(firebaseAI: FirebaseAI, name: String, thinking: Thinking? = nil) { let endpointURL = firebaseAI.apiConfig.service.endpoint.rawValue guard let urlComponents = URLComponents(string: endpointURL) else { preconditionFailure("Invalid Gemini API URL: \(endpointURL)") @@ -41,14 +47,15 @@ apiVersion: firebaseAI.apiConfig.version.rawValue ), headerProvider: firebaseAI.headerProvider, - configuration: .ephemeral + configuration: .ephemeral, + thinking: thinking ) } } @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) @available(tvOS, unavailable) - private extension FirebaseAI { + fileprivate extension FirebaseAI { func headerProvider() async throws -> [String: String] { try await firebaseInfo.requestHeaders( additionalClientTags: [Constants.foundationModelsRequestTag] diff --git a/FirebaseAI/Sources/Extensions/Public/FirebaseAI+GeminiLanguageModel.swift b/FirebaseAI/Sources/Extensions/Public/FirebaseAI+GeminiLanguageModel.swift index 3f6478b680d..76a2ec76767 100644 --- a/FirebaseAI/Sources/Extensions/Public/FirebaseAI+GeminiLanguageModel.swift +++ b/FirebaseAI/Sources/Extensions/Public/FirebaseAI+GeminiLanguageModel.swift @@ -16,10 +16,17 @@ import GeminiLanguageModel public extension FirebaseAI { + /// Creates a new Gemini language model adapter configured with this Firebase AI instance. + /// + /// - Parameters: + /// - name: The model name (e.g., `"gemini-3.8-flash"`). + /// - thinking: An optional thinking configuration for thought summaries. Defaults to `nil`. + /// - Returns: A configured `GeminiLanguageModel` instance. @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) @available(tvOS, unavailable) - func geminiLanguageModel(name: String) -> GeminiLanguageModel { - return GeminiLanguageModel(name: name, firebaseAI: self) + func geminiLanguageModel(name: String, + thinking: GeminiLanguageModel.Thinking? = nil) -> GeminiLanguageModel { + GeminiLanguageModel(firebaseAI: self, name: name, thinking: thinking) } } #endif // compiler(>=6.4) && canImport(FoundationModels) && canImport(GeminiLanguageModel) diff --git a/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModel+DynamicProfile.swift b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModel+DynamicProfile.swift new file mode 100644 index 00000000000..1c8a1acf88d --- /dev/null +++ b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModel+DynamicProfile.swift @@ -0,0 +1,235 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(FoundationModels) && compiler(>=6.4) + public import FoundationModels + + /// Property key for storing Gemini thought summaries in `SessionPropertyValues`. + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + enum GeminiThoughtSummaryPropertyKey: SessionPropertyKey { + /// The default value when no thought summary has been produced. + static let defaultValue: String? = nil + } + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + extension SessionPropertyValues { + /// The thought summary produced by the Gemini model during the session, or `nil` if no + /// thought summary was produced. + /// + /// When using a dynamic profile configured with `.geminiThinking()`, this property is + /// automatically updated as the model reasons and resets at the start of each new turn. + /// Since `SessionPropertyValues` conforms to `Observable`, SwiftUI views can observe this + /// property directly: + /// + /// ```swift + /// struct TutorView: View { + /// @State var session: LanguageModelSession + /// + /// var body: some View { + /// if let thought = session.properties.geminiThoughtSummary { + /// Text(thought) + /// .font(.caption) + /// .foregroundStyle(.secondary) + /// } + /// } + /// } + /// ``` + public var geminiThoughtSummary: String? { + get { self[GeminiThoughtSummaryPropertyKey.self] } + set { self[GeminiThoughtSummaryPropertyKey.self] = newValue } + } + } + + /// A dynamic profile modifier that configures Gemini thought summaries on prompt entries + /// and updates the observable thought summary on the session. + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + struct GeminiThinkingSummaryProfileModifier: LanguageModelSession.DynamicProfileModifier { + @SessionProperty(\.history) + var history + + @SessionProperty(\.geminiThoughtSummary) + var thoughtSummary + + let mode: GeminiLanguageModel.Thinking.SummaryMode + + /// Creates a new profile modifier with the specified thinking summaries mode. + /// + /// - Parameter mode: The mode for returning thought summaries. + init(summaries mode: GeminiLanguageModel.Thinking.SummaryMode) { + self.mode = mode + } + + /// Applies the thinking summary configuration and reasoning observation to the dynamic profile. + /// + /// - Parameter content: The dynamic profile content being modified. + /// - Returns: The modified dynamic profile. + func body(content: Content) -> some LanguageModelSession.DynamicProfile { + content + .onPrompt { prompt in + guard + let promptIndex = history.lastIndex(where: { + guard case .prompt(let entryPrompt) = $0 else { return false } + return entryPrompt.id == prompt.id + }) + else { + return + } + + var updatedPrompt = prompt + var metadata = + updatedPrompt.metadata[GeminiRequestMetadata.metadataKey].flatMap( + GeminiRequestMetadata.init + ) ?? GeminiRequestMetadata() + metadata.thinkingSummaries = mode + updatedPrompt.metadata[GeminiRequestMetadata.metadataKey] = metadata.generatedContent + history[promptIndex] = Transcript.Entry.prompt(updatedPrompt) + thoughtSummary = nil + } + .onReasoning { reasoning in + guard mode != .off else { return } + let text = reasoning.segments.compactMap { segment in + if case .text(let textSegment) = segment { + return textSegment.content + } + return nil + }.joined() + guard !text.isEmpty else { return } + if var current = thoughtSummary { + current.append(text) + thoughtSummary = current + } else { + thoughtSummary = text + } + } + } + } + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + extension LanguageModelSession.DynamicProfile { + /// Configures thought summaries for Gemini models on this dynamic profile. + /// + /// Thought summaries produced during generation are automatically recorded to + /// `session.properties.geminiThoughtSummary`. + /// + /// > Note: To configure the reasoning depth, use Apple's `.reasoningLevel` profile modifier with + /// > `.light`, `.moderate`, or `.deep`. + /// + /// - Parameter mode: The thinking summaries mode. Defaults to `.auto`. + /// - Returns: A dynamic profile configured with the thinking summary setting. + public func geminiThinking( + summaries mode: GeminiLanguageModel.Thinking.SummaryMode = .auto + ) -> some LanguageModelSession.DynamicProfile { + modifier(GeminiThinkingSummaryProfileModifier(summaries: mode)) + } + + /// Enables thought summaries for Gemini models on this dynamic profile and observes incoming + /// thought summaries during reasoning. + /// + /// > Note: To configure the reasoning depth/budget, use Apple's `.reasoningLevel` profile + /// > modifier with `.light`, `.moderate`, or `.deep`. + /// + /// - Parameter action: A closure called with the complete thought summary text for the + /// reasoning entry whenever the model produces reasoning. + /// - Returns: A dynamic profile configured with the thinking summary setting and reasoning + /// observer. + public func geminiThinking( + perform action: @Sendable @escaping (String) async throws -> Void + ) -> some LanguageModelSession.DynamicProfile { + geminiThinking(summaries: .auto) + .onReasoning { reasoning in + let text = reasoning.segments.compactMap { segment in + if case .text(let textSegment) = segment { + return textSegment.content + } + return nil + }.joined() + guard !text.isEmpty else { return } + try await action(text) + } + } + } + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + extension Transcript.Entry { + /// The concatenated text content of this reasoning entry, or `nil` if this entry is not reasoning + /// or contains no text content. + var reasoningText: String? { + guard case .reasoning(let reasoning) = self else { return nil } + let text = reasoning.segments.compactMap { segment in + guard case .text(let textSegment) = segment else { return nil } + return textSegment.content + }.joined() + return text.isEmpty ? nil : text + } + } + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + extension LanguageModelSession.Response { + /// The concatenated thought summary text generated by the model during this response, or + /// `nil` if no thought summary was produced. + /// + /// ```swift + /// let response = try await session.respond(to: "What is 17 multiplied by 24?") + /// if let thought = response.geminiThoughtSummary { + /// print(thought) + /// } + /// ``` + public var geminiThoughtSummary: String? { + let texts = transcriptEntries.compactMap(\.reasoningText) + return texts.isEmpty ? nil : texts.joined() + } + } + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + extension LanguageModelSession.ResponseStream.Snapshot { + /// The concatenated thought summary text generated so far in this streaming response, or + /// `nil` if no thought summary has been produced. + /// + /// ```swift + /// for try await snapshot in session.streamResponse(to: "What is 17 multiplied by 24?") { + /// if let thought = snapshot.geminiThoughtSummary { + /// print("Thinking: \(thought)") + /// } + /// } + /// ``` + public var geminiThoughtSummary: String? { + let texts = transcriptEntries.compactMap(\.reasoningText) + return texts.isEmpty ? nil : texts.joined() + } + } + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + extension Transcript { + /// The concatenated thought summary text recorded in this transcript, or `nil` if no + /// thought summary exists. + /// + /// ```swift + /// if let thought = session.transcript.geminiThoughtSummary { + /// print(thought) + /// } + /// ``` + public var geminiThoughtSummary: String? { + let texts = compactMap(\.reasoningText) + return texts.isEmpty ? nil : texts.joined() + } + } +#endif // canImport(FoundationModels) && compiler(>=6.4) diff --git a/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModel+Thinking.swift b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModel+Thinking.swift new file mode 100644 index 00000000000..e18901e67e6 --- /dev/null +++ b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModel+Thinking.swift @@ -0,0 +1,42 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(FoundationModels) && compiler(>=6.4) + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + extension GeminiLanguageModel { + /// Configuration for Gemini internal thinking process. + public struct Thinking: Sendable, Hashable { + /// Modes for returning thought summaries from the model. + public enum SummaryMode: String, Sendable, Hashable { + /// The model automatically outputs thought summaries when reasoning. + case auto + + /// Thought summaries are not returned. + case off + } + + /// The configuration for returning thought summaries. + public var summaries: SummaryMode? + + /// Creates a thinking configuration. + /// + /// - Parameter summaries: The configuration mode for returning thought summaries. + /// Defaults to `nil`. + public init(summaries: SummaryMode? = nil) { + self.summaries = summaries + } + } + } +#endif // canImport(FoundationModels) && compiler(>=6.4) diff --git a/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModel.swift b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModel.swift index 97c8f9cd267..40bd5c7b1f6 100644 --- a/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModel.swift +++ b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModel.swift @@ -22,6 +22,7 @@ @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) @available(tvOS, unavailable) public struct GeminiLanguageModel: Sendable { + /// A configuration for an executor capable of running this model. public let executorConfiguration: Executor.Configuration /// Initializes a new Gemini language model. @@ -31,17 +32,20 @@ /// - endpointConfiguration: The network endpoint configuration. /// - headerProvider: An optional async provider for dynamic headers (such as auth tokens). /// - configuration: The `URLSessionConfiguration` to use. Defaults to `.ephemeral`. + /// - thinking: An optional thinking configuration. Defaults to `nil`. package init( modelResource: ModelResource, endpointConfiguration: EndpointConfiguration, headerProvider: (@Sendable () async throws -> [String: String])? = nil, - configuration: URLSessionConfiguration = .ephemeral + configuration: URLSessionConfiguration = .ephemeral, + thinking: Thinking? = nil ) { executorConfiguration = Executor.Configuration( modelResource: modelResource, endpointConfiguration: endpointConfiguration, headerProvider: headerProvider.map { HeaderProvider($0) }, - sessionConfiguration: configuration + sessionConfiguration: configuration, + thinking: thinking ) } } diff --git a/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModelExecutor.swift b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModelExecutor.swift index 055b91b5f7e..810a0df48c8 100644 --- a/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModelExecutor.swift +++ b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiLanguageModelExecutor.swift @@ -37,24 +37,8 @@ /// The `URLSessionConfiguration` to use. let sessionConfiguration: URLSessionConfiguration - /// Initializes an executor configuration. - /// - /// - Parameters: - /// - modelResource: The model resource configuration. - /// - endpointConfiguration: The network endpoint configuration. - /// - headerProvider: An optional async provider for dynamic headers. - /// - sessionConfiguration: The `URLSessionConfiguration` to use. - init( - modelResource: ModelResource, - endpointConfiguration: EndpointConfiguration, - headerProvider: HeaderProvider?, - sessionConfiguration: URLSessionConfiguration - ) { - self.modelResource = modelResource - self.endpointConfiguration = endpointConfiguration - self.headerProvider = headerProvider - self.sessionConfiguration = sessionConfiguration - } + /// An optional thinking configuration. + let thinking: Thinking? } private let configuration: Configuration @@ -79,7 +63,10 @@ model: GeminiLanguageModel, streamingInto channel: LanguageModelExecutorGenerationChannel ) async throws { - let generateRequest = try GeminiRequestTranslator.translate(request) + let generateRequest = try GeminiRequestTranslator.translate( + request, + thinking: configuration.thinking + ) let client = GeminiAPIClient( modelResource: configuration.modelResource, diff --git a/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiRequestMetadata.swift b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiRequestMetadata.swift new file mode 100644 index 00000000000..27ef7a672c8 --- /dev/null +++ b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiRequestMetadata.swift @@ -0,0 +1,112 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(FoundationModels) && compiler(>=6.4) + public import FoundationModels + + /// Metadata specifying Gemini-specific configuration options for generation requests. + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + public struct GeminiRequestMetadata: Sendable, Hashable, Equatable { + /// The top-level metadata key used to store Gemini configuration in requests and prompt entries. + public static let metadataKey = "gemini" + + /// Property key for the thinking summaries setting within `GeminiRequestMetadata`. + private static let thinkingSummariesKey = "thinkingSummaries" + + /// The configuration mode for returning thought summaries from the model. + public var thinkingSummaries: GeminiLanguageModel.Thinking.SummaryMode? + + /// Creates a new Gemini request metadata container. + /// + /// - Parameter thinkingSummaries: The mode for returning thought summaries. Defaults to `nil`. + public init( + thinkingSummaries: GeminiLanguageModel.Thinking.SummaryMode? = nil + ) { + self.thinkingSummaries = thinkingSummaries + } + } + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + extension GeminiRequestMetadata: ConvertibleToGeneratedContent { + /// Converts this metadata into structured `GeneratedContent`. + public var generatedContent: GeneratedContent { + var properties: [String: GeneratedContent] = [:] + var orderedKeys: [String] = [] + if let thinkingSummaries { + properties[Self.thinkingSummariesKey] = GeneratedContent(thinkingSummaries.rawValue) + orderedKeys.append(Self.thinkingSummariesKey) + } + return GeneratedContent( + kind: .structure(properties: properties, orderedKeys: orderedKeys) + ) + } + } + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + extension GeminiRequestMetadata: ConvertibleFromGeneratedContent { + /// Initializes this metadata from structured `GeneratedContent`. + /// + /// - Parameter content: The generated content to decode from. + public init(_ content: GeneratedContent) { + if case .structure(let properties, _) = content.kind { + if let modeContent = properties[Self.thinkingSummariesKey] { + if case .string(let rawValue) = modeContent.kind { + self.thinkingSummaries = GeminiLanguageModel.Thinking.SummaryMode(rawValue: rawValue) + } else if case .bool(let boolVal) = modeContent.kind { + self.thinkingSummaries = boolVal ? .auto : .off + } else { + self.thinkingSummaries = nil + } + } else { + self.thinkingSummaries = nil + } + } else if case .bool(let boolVal) = content.kind { + self.thinkingSummaries = boolVal ? .auto : .off + } else { + self.thinkingSummaries = nil + } + } + } + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, visionOS 27.0, *) + @available(tvOS, unavailable) + extension Dictionary where Key == String, Value == any ConvertibleToGeneratedContent { + /// Creates a metadata dictionary containing the specified Gemini request metadata. + /// + /// - Parameter thinkingSummaries: The configuration mode for returning thought summaries. + /// - Returns: A metadata dictionary suitable for passing to `session.respond` or `session.streamResponse`. + public static func gemini( + thinkingSummaries: GeminiLanguageModel.Thinking.SummaryMode? = nil + ) -> [String: any ConvertibleToGeneratedContent] { + var dict: [String: any ConvertibleToGeneratedContent] = [:] + dict[GeminiRequestMetadata.metadataKey] = GeminiRequestMetadata( + thinkingSummaries: thinkingSummaries + ) + return dict + } + + /// Creates a metadata dictionary containing the given `GeminiRequestMetadata`. + /// + /// - Parameter metadata: The Gemini request metadata container. + /// - Returns: A metadata dictionary suitable for passing to `session.respond` or `session.streamResponse`. + public static func gemini( + _ metadata: GeminiRequestMetadata + ) -> [String: any ConvertibleToGeneratedContent] { + [GeminiRequestMetadata.metadataKey: metadata] + } + } +#endif // canImport(FoundationModels) && compiler(>=6.4) diff --git a/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiRequestTranslator.swift b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiRequestTranslator.swift index 2b07dfd151c..92e239b89b3 100644 --- a/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiRequestTranslator.swift +++ b/GeminiLanguageModel/Sources/GeminiLanguageModel/GeminiRequestTranslator.swift @@ -23,16 +23,43 @@ enum GeminiRequestTranslator { /// Translates a `LanguageModelExecutorGenerationRequest` into a `GenerateContentRequest`. /// - /// - Parameter request: The generation request from the Foundation Models session. + /// - Parameters: + /// - request: The generation request from the Foundation Models session. + /// - thinking: An optional thinking configuration from the model. /// - Returns: A `GenerateContentRequest` configured for the Gemini API. /// - Throws: An error if transcript or schema translation fails. static func translate( - _ request: LanguageModelExecutorGenerationRequest + _ request: LanguageModelExecutorGenerationRequest, + thinking: GeminiLanguageModel.Thinking? = nil ) throws -> GenerateContentRequest { + var effectiveThinking = thinking + if let metadataContent = request.metadata[GeminiRequestMetadata.metadataKey] { + let meta = GeminiRequestMetadata(metadataContent) + if let summaries = meta.thinkingSummaries { + effectiveThinking = GeminiLanguageModel.Thinking(summaries: summaries) + } + } else { + for entry in request.transcript.reversed() { + if case .prompt(let prompt) = entry { + if let metadataContent = prompt.metadata[GeminiRequestMetadata.metadataKey] { + let meta = GeminiRequestMetadata(metadataContent) + if let summaries = meta.thinkingSummaries { + effectiveThinking = GeminiLanguageModel.Thinking(summaries: summaries) + } + } + break + } + } + } + let (contents, systemInstruction) = try GeminiTranscriptTranslator.translate( request.transcript ) - let generationConfig = try translateGenerationConfig(schema: request.schema) + let generationConfig = try translateGenerationConfig( + schema: request.schema, + reasoningLevel: request.contextOptions.reasoningLevel, + thinking: effectiveThinking + ) let tools = try translateTools(request.enabledToolDefinitions) let toolConfig = translateToolConfig( toolCallingMode: request.generationOptions.toolCallingMode @@ -47,24 +74,112 @@ ) } - /// Translates an optional `GenerationSchema` into a Gemini `GenerationConfig`. + /// Translates schema and thinking options into a Gemini `GenerationConfig`. /// - /// - Parameter schema: An optional generation schema specifying structured output constraints. - /// - Returns: A `GenerationConfig` configured with response schema, or `nil` if `schema` - /// is `nil`. - /// - Throws: An error if encoding the schema fails or if an unsupported generation guide is - /// detected. + /// - Parameters: + /// - schema: An optional generation schema specifying structured output constraints. + /// - reasoningLevel: An optional reasoning level from the request's context options. + /// - thinking: An optional thinking configuration from the model. + /// - Returns: A `GenerationConfig` configured with response format and thinking options, + /// or `nil` if none are specified. + /// - Throws: An error if encoding the schema fails. static func translateGenerationConfig( - schema: GenerationSchema? + schema: GenerationSchema?, + reasoningLevel: ContextOptions.ReasoningLevel? = nil, + thinking: GeminiLanguageModel.Thinking? = nil ) throws -> GenerationConfig? { - guard let schema else { return nil } + let responseFormat: ResponseFormatConfig? + if let schema { + let jsonSchema = try schema.toGeminiJSONSchema() + let textFormat = TextResponseFormat( + mimeType: .applicationJson, + schema: .object(jsonSchema) + ) + responseFormat = ResponseFormatConfig(text: textFormat) + } else { + responseFormat = nil + } + + let thinkingConfig = translateThinkingConfig( + reasoningLevel: reasoningLevel, + thinking: thinking + ) + + guard responseFormat != nil || thinkingConfig != nil else { return nil } - let jsonSchema = try schema.toGeminiJSONSchema() - let textFormat = TextResponseFormat( - mimeType: .applicationJson, - schema: .object(jsonSchema) + return GenerationConfig( + thinkingConfig: thinkingConfig, + responseFormat: responseFormat ) - return GenerationConfig(responseFormat: ResponseFormatConfig(text: textFormat)) + } + + /// Translates context reasoning level and model thinking configuration into a Gemini `ThinkingConfig`. + /// + /// - Parameters: + /// - reasoningLevel: An optional reasoning level from context options. + /// - thinking: An optional thinking configuration from the model. + /// - Returns: A `ThinkingConfig` configured for the Gemini API, or `nil` if unspecified. + static func translateThinkingConfig( + reasoningLevel: ContextOptions.ReasoningLevel?, + thinking: GeminiLanguageModel.Thinking? + ) -> ThinkingConfig? { + let thinkingLevel = reasoningLevel.flatMap(translateThinkingLevel) + let includeThoughts = thinking?.summaries.flatMap(translateIncludeThoughts) + + guard thinkingLevel != nil || includeThoughts != nil else { return nil } + + return ThinkingConfig( + includeThoughts: includeThoughts, + thinkingLevel: thinkingLevel + ) + } + + /// Translates a FoundationModels `ReasoningLevel` into a Gemini `ThinkingConfig.ThinkingLevel`. + /// + /// - Parameter reasoningLevel: The reasoning level to translate. + /// - Returns: The corresponding Gemini thinking level. + static func translateThinkingLevel( + _ reasoningLevel: ContextOptions.ReasoningLevel + ) -> ThinkingConfig.ThinkingLevel { + switch reasoningLevel { + case .light: + return .low + case .moderate: + return .medium + case .deep: + return .high + case .custom(let value): + if value.caseInsensitiveCompare("MINIMAL") == .orderedSame { + return .minimal + } + if value.caseInsensitiveCompare("LOW") == .orderedSame { + return .low + } + if value.caseInsensitiveCompare("MEDIUM") == .orderedSame { + return .medium + } + if value.caseInsensitiveCompare("HIGH") == .orderedSame { + return .high + } + return .unrecognized(value) + @unknown default: + return .unrecognized(String(describing: reasoningLevel)) + } + } + + /// Translates a `GeminiLanguageModel.Thinking.SummaryMode` into a boolean for `ThinkingConfig.includeThoughts`. + /// + /// - Parameter mode: The thinking summaries mode. + /// - Returns: `true` for `.auto`, `false` for `.off`. + static func translateIncludeThoughts( + _ mode: GeminiLanguageModel.Thinking.SummaryMode + ) -> Bool { + switch mode { + case .auto: + return true + case .off: + return false + } } /// Translates enabled tool definitions into a list of Gemini `Tool` objects. diff --git a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiLanguageModelTests.swift b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiLanguageModelTests.swift index 3e7999f7008..68caaf3a12f 100644 --- a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiLanguageModelTests.swift +++ b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiLanguageModelTests.swift @@ -485,6 +485,409 @@ #expect(fr.response?["result"] == .string("12:00 PM")) } + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func sessionRespondWithThinkingSummariesIntoTranscript() async throws { + defer { MockHTTPURLProtocol.reset() } + let model = Self.makeMockModel( + thinking: GeminiLanguageModel.Thinking(summaries: .auto) + ) + let expectedURL = try Self.makeExpectedStreamURL() + let httpResponse = try HTTPURLResponse.mock( + url: expectedURL, + headerFields: ["Content-Type": "text/event-stream"] + ) + let ssePayload = """ + data: {"candidates": [{"content": {"parts": [{"text": "I am thinking through the problem.", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Here is the final answer."}], "role": "model"}, "finishReason": "STOP", "index": 0}]} + + """ + let receivedRequest = Mutex(nil) + MockHTTPURLProtocol.setHandler(for: expectedURL) { request, proto in + if let body = request.httpBodyData, + let decoded = try? JSONDecoder().decode(GenerateContentRequest.self, from: body) + { + receivedRequest.withLock { $0 = decoded } + } + proto.client?.urlProtocol(proto, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + proto.client?.urlProtocol(proto, didLoad: Data(ssePayload.utf8)) + proto.client?.urlProtocolDidFinishLoading(proto) + } + + let session = LanguageModelSession(model: model) + + let response = try await session.respond(to: "Solve this problem") + + #expect(response.content == "Here is the final answer.") + let capturedRequest = try #require(receivedRequest.withLock { $0 }) + #expect(capturedRequest.generationConfig?.thinkingConfig?.includeThoughts == true) + + let reasoningEntries = session.transcript.compactMap { entry -> Transcript.Reasoning? in + if case .reasoning(let reasoning) = entry { + return reasoning + } + return nil + } + #expect(reasoningEntries.count == 1) + let reasoning = try #require(reasoningEntries.first) + let reasoningText = reasoning.segments.compactMap { segment -> String? in + if case .text(let textSegment) = segment { + return textSegment.content + } + return nil + }.joined() + #expect(reasoningText == "I am thinking through the problem.") + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func sessionDynamicProfileWithGeminiThinkingAction() async throws { + defer { MockHTTPURLProtocol.reset() } + let model = Self.makeMockModel() + let expectedURL = try Self.makeExpectedStreamURL() + let httpResponse = try HTTPURLResponse.mock( + url: expectedURL, + headerFields: ["Content-Type": "text/event-stream"] + ) + let ssePayload = """ + data: {"candidates": [{"content": {"parts": [{"text": "Step 1. ", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Step 2.", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Done."}], "role": "model"}, "finishReason": "STOP", "index": 0}]} + + """ + MockHTTPURLProtocol.setHandler(for: expectedURL) { _, proto in + proto.client?.urlProtocol(proto, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + proto.client?.urlProtocol(proto, didLoad: Data(ssePayload.utf8)) + proto.client?.urlProtocolDidFinishLoading(proto) + } + + let observedThoughts = Mutex<[String]>([]) + let profile = LanguageModelSession.Profile { + Instructions("You are a helpful assistant.") + } + .model(model) + .geminiThinking { summary in + observedThoughts.withLock { $0.append(summary) } + } + let session = LanguageModelSession(profile: profile) + + let response = try await session.respond(to: "Think step by step") + + #expect(response.content == "Done.") + let thoughts = observedThoughts.withLock { $0 } + #expect(thoughts == ["Step 1. Step 2."]) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func sessionRespondWithRequestMetadata() async throws { + defer { MockHTTPURLProtocol.reset() } + let model = Self.makeMockModel() + let expectedURL = try Self.makeExpectedStreamURL() + let httpResponse = try HTTPURLResponse.mock( + url: expectedURL, + headerFields: ["Content-Type": "text/event-stream"] + ) + let ssePayload = """ + data: {"candidates": [{"content": {"parts": [{"text": "Thoughts...", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Direct answer."}], "role": "model"}, "finishReason": "STOP", "index": 0}]} + + """ + let receivedRequest = Mutex(nil) + MockHTTPURLProtocol.setHandler(for: expectedURL) { request, proto in + if let body = request.httpBodyData, + let decoded = try? JSONDecoder().decode(GenerateContentRequest.self, from: body) + { + receivedRequest.withLock { $0 = decoded } + } + proto.client?.urlProtocol(proto, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + proto.client?.urlProtocol(proto, didLoad: Data(ssePayload.utf8)) + proto.client?.urlProtocolDidFinishLoading(proto) + } + + let session = LanguageModelSession(model: model) + + let response = try await session.respond( + metadata: .gemini(thinkingSummaries: .auto) + ) { + "Explain quantum computing" + } + + #expect(response.content == "Direct answer.") + let capturedRequest = try #require(receivedRequest.withLock { $0 }) + #expect(capturedRequest.generationConfig?.thinkingConfig?.includeThoughts == true) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func sessionDynamicProfileWithGeminiThinkingModifier() async throws { + defer { MockHTTPURLProtocol.reset() } + let baseModel = Self.makeMockModel() + let expectedURL = try Self.makeExpectedStreamURL() + let httpResponse = try HTTPURLResponse.mock( + url: expectedURL, + headerFields: ["Content-Type": "text/event-stream"] + ) + let ssePayload = """ + data: {"candidates": [{"content": {"parts": [{"text": "Thinking...", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Result."}], "role": "model"}, "finishReason": "STOP", "index": 0}]} + + """ + let receivedRequest = Mutex(nil) + MockHTTPURLProtocol.setHandler(for: expectedURL) { request, proto in + if let body = request.httpBodyData, + let decoded = try? JSONDecoder().decode(GenerateContentRequest.self, from: body) + { + receivedRequest.withLock { $0 = decoded } + } + proto.client?.urlProtocol(proto, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + proto.client?.urlProtocol(proto, didLoad: Data(ssePayload.utf8)) + proto.client?.urlProtocolDidFinishLoading(proto) + } + + let profile = LanguageModelSession.Profile { + Instructions("You are a helpful assistant.") + } + .model(baseModel) + .geminiThinking(summaries: .auto) + let session = LanguageModelSession(profile: profile) + + let response = try await session.respond(to: "Hello") + + #expect(response.content == "Result.") + let capturedRequest = try #require(receivedRequest.withLock { $0 }) + #expect(capturedRequest.generationConfig?.thinkingConfig?.includeThoughts == true) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func sessionDynamicProfileWithGeminiThinkingModifierMultiTurn() async throws { + defer { MockHTTPURLProtocol.reset() } + let baseModel = Self.makeMockModel() + let expectedURL = try Self.makeExpectedStreamURL() + let httpResponse = try HTTPURLResponse.mock( + url: expectedURL, + headerFields: ["Content-Type": "text/event-stream"] + ) + let turn1SSE = """ + data: {"candidates": [{"content": {"parts": [{"text": "Thinking 1...", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Result 1."}], "role": "model"}, "finishReason": "STOP", "index": 0}]} + + """ + let turn2SSE = """ + data: {"candidates": [{"content": {"parts": [{"text": "Thinking 2...", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Result 2."}], "role": "model"}, "finishReason": "STOP", "index": 0}]} + + """ + let capturedRequests = Mutex<[GenerateContentRequest]>([]) + let requestCount = Mutex(0) + MockHTTPURLProtocol.setHandler(for: expectedURL) { request, proto in + let count = requestCount.withLock { c -> Int in + c += 1 + return c + } + if let body = request.httpBodyData, + let decoded = try? JSONDecoder().decode(GenerateContentRequest.self, from: body) + { + capturedRequests.withLock { $0.append(decoded) } + } + proto.client?.urlProtocol(proto, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + let payload = count == 1 ? turn1SSE : turn2SSE + proto.client?.urlProtocol(proto, didLoad: Data(payload.utf8)) + proto.client?.urlProtocolDidFinishLoading(proto) + } + + let profile = LanguageModelSession.Profile { + Instructions("You are a helpful assistant.") + } + .model(baseModel) + .geminiThinking(summaries: .auto) + let session = LanguageModelSession(profile: profile) + + let res1 = try await session.respond(to: "First prompt") + #expect(res1.content == "Result 1.") + + let res2 = try await session.respond(to: "Second prompt") + #expect(res2.content == "Result 2.") + + let requests = capturedRequests.withLock { $0 } + #expect(requests.count == 2) + #expect(requests[0].generationConfig?.thinkingConfig?.includeThoughts == true) + #expect(requests[1].generationConfig?.thinkingConfig?.includeThoughts == true) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func sessionDynamicProfileWithGeminiThinkingModifierOff() async throws { + defer { MockHTTPURLProtocol.reset() } + let baseModel = Self.makeMockModel( + thinking: GeminiLanguageModel.Thinking(summaries: .auto) + ) + let expectedURL = try Self.makeExpectedStreamURL() + let httpResponse = try HTTPURLResponse.mock( + url: expectedURL, + headerFields: ["Content-Type": "text/event-stream"] + ) + let ssePayload = """ + data: {"candidates": [{"content": {"parts": [{"text": "Result without thoughts."}], "role": "model"}, "finishReason": "STOP", "index": 0}]} + + """ + let receivedRequest = Mutex(nil) + MockHTTPURLProtocol.setHandler(for: expectedURL) { request, proto in + if let body = request.httpBodyData, + let decoded = try? JSONDecoder().decode(GenerateContentRequest.self, from: body) + { + receivedRequest.withLock { $0 = decoded } + } + proto.client?.urlProtocol(proto, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + proto.client?.urlProtocol(proto, didLoad: Data(ssePayload.utf8)) + proto.client?.urlProtocolDidFinishLoading(proto) + } + + let profile = LanguageModelSession.Profile { + Instructions("You are a helpful assistant.") + } + .model(baseModel) + .geminiThinking(summaries: .off) + let session = LanguageModelSession(profile: profile) + + let response = try await session.respond(to: "Hello") + + #expect(response.content == "Result without thoughts.") + let capturedRequest = try #require(receivedRequest.withLock { $0 }) + #expect(capturedRequest.generationConfig?.thinkingConfig?.includeThoughts == false) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func responseGeminiThoughtSummary() async throws { + defer { MockHTTPURLProtocol.reset() } + let model = Self.makeMockModel() + let expectedURL = try Self.makeExpectedStreamURL() + let httpResponse = try HTTPURLResponse.mock( + url: expectedURL, + headerFields: ["Content-Type": "text/event-stream"] + ) + let ssePayload = """ + data: {"candidates": [{"content": {"parts": [{"text": "Deep thinking step 1...", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Deep thinking step 2...", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Final answer."}], "role": "model"}, "finishReason": "STOP", "index": 0}]} + + """ + MockHTTPURLProtocol.setHandler(for: expectedURL) { request, proto in + proto.client?.urlProtocol(proto, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + proto.client?.urlProtocol(proto, didLoad: Data(ssePayload.utf8)) + proto.client?.urlProtocolDidFinishLoading(proto) + } + + let session = LanguageModelSession(model: model) + let response = try await session.respond(to: "Solve problem") + + #expect(response.content == "Final answer.") + #expect(response.geminiThoughtSummary == "Deep thinking step 1...Deep thinking step 2...") + #expect( + session.transcript.geminiThoughtSummary == "Deep thinking step 1...Deep thinking step 2..." + ) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func snapshotGeminiThoughtSummary() async throws { + defer { MockHTTPURLProtocol.reset() } + let model = Self.makeMockModel() + let expectedURL = try Self.makeExpectedStreamURL() + let httpResponse = try HTTPURLResponse.mock( + url: expectedURL, + headerFields: ["Content-Type": "text/event-stream"] + ) + let ssePayload = """ + data: {"candidates": [{"content": {"parts": [{"text": "Thinking in stream...", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Streaming answer."}], "role": "model"}, "finishReason": "STOP", "index": 0}]} + + """ + MockHTTPURLProtocol.setHandler(for: expectedURL) { request, proto in + proto.client?.urlProtocol(proto, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + proto.client?.urlProtocol(proto, didLoad: Data(ssePayload.utf8)) + proto.client?.urlProtocolDidFinishLoading(proto) + } + + let session = LanguageModelSession(model: model) + let stream = session.streamResponse(to: "Stream with thoughts") + + var lastThoughtSummary: String? + for try await snapshot in stream { + if let thought = snapshot.geminiThoughtSummary { + lastThoughtSummary = thought + } + } + + #expect(lastThoughtSummary == "Thinking in stream...") + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func sessionPropertiesGeminiThoughtSummary() async throws { + defer { MockHTTPURLProtocol.reset() } + let baseModel = Self.makeMockModel() + let expectedURL = try Self.makeExpectedStreamURL() + let httpResponse = try HTTPURLResponse.mock( + url: expectedURL, + headerFields: ["Content-Type": "text/event-stream"] + ) + let turn1SSE = """ + data: {"candidates": [{"content": {"parts": [{"text": "Thought for turn 1", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Response 1."}], "role": "model"}, "finishReason": "STOP", "index": 0}]} + + """ + let turn2SSE = """ + data: {"candidates": [{"content": {"parts": [{"text": "Thought for turn 2", "thought": true}], "role": "model"}, "index": 0}]} + + data: {"candidates": [{"content": {"parts": [{"text": "Response 2."}], "role": "model"}, "finishReason": "STOP", "index": 0}]} + + """ + let requestCount = Mutex(0) + MockHTTPURLProtocol.setHandler(for: expectedURL) { request, proto in + let count = requestCount.withLock { c -> Int in + c += 1 + return c + } + proto.client?.urlProtocol(proto, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + let payload = count == 1 ? turn1SSE : turn2SSE + proto.client?.urlProtocol(proto, didLoad: Data(payload.utf8)) + proto.client?.urlProtocolDidFinishLoading(proto) + } + + let profile = LanguageModelSession.Profile { + Instructions("You are a helpful assistant.") + } + .model(baseModel) + .geminiThinking(summaries: .auto) + let session = LanguageModelSession(profile: profile) + + #expect(session.properties.geminiThoughtSummary == nil) + + let res1 = try await session.respond(to: "First") + #expect(res1.content == "Response 1.") + #expect(res1.geminiThoughtSummary == "Thought for turn 1") + #expect(session.properties.geminiThoughtSummary == "Thought for turn 1") + + let res2 = try await session.respond(to: "Second") + #expect(res2.content == "Response 2.") + #expect(res2.geminiThoughtSummary == "Thought for turn 2") + #expect(session.properties.geminiThoughtSummary == "Thought for turn 2") + } + // MARK: - Helper Methods @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) @@ -492,7 +895,8 @@ private static func makeMockModel( modelResource: ModelResource = .gemini38Flash, endpointConfiguration: EndpointConfiguration = .geminiDeveloperAPI, - headerProvider: (@Sendable () async throws -> [String: String])? = nil + headerProvider: (@Sendable () async throws -> [String: String])? = nil, + thinking: GeminiLanguageModel.Thinking? = nil ) -> GeminiLanguageModel { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [MockHTTPURLProtocol.self] @@ -500,7 +904,8 @@ modelResource: modelResource, endpointConfiguration: endpointConfiguration, headerProvider: headerProvider, - configuration: configuration + configuration: configuration, + thinking: thinking ) } diff --git a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiRequestMetadataTests.swift b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiRequestMetadataTests.swift new file mode 100644 index 00000000000..49083fad270 --- /dev/null +++ b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiRequestMetadataTests.swift @@ -0,0 +1,81 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(FoundationModels) && compiler(>=6.4) + import Foundation + import FoundationModels + import GeminiTestUtilities + import Testing + + @testable import GeminiLanguageModel + + @Suite("GeminiRequestMetadata Tests", .requireFoundationModels) + struct GeminiRequestMetadataTests { + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func encodeAndDecodeThinkingSummariesAuto() { + let metadata = GeminiRequestMetadata(thinkingSummaries: .auto) + let content = metadata.generatedContent + + let decoded = GeminiRequestMetadata(content) + + #expect(decoded.thinkingSummaries == .auto) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func encodeAndDecodeThinkingSummariesOff() { + let metadata = GeminiRequestMetadata(thinkingSummaries: .off) + let content = metadata.generatedContent + + let decoded = GeminiRequestMetadata(content) + + #expect(decoded.thinkingSummaries == .off) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func encodeAndDecodeThinkingSummariesNil() { + let metadata = GeminiRequestMetadata(thinkingSummaries: nil) + let content = metadata.generatedContent + + let decoded = GeminiRequestMetadata(content) + + #expect(decoded.thinkingSummaries == nil) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func decodeFromBooleanContent() { + let boolTrueContent = GeneratedContent(kind: .bool(true)) + let decodedTrue = GeminiRequestMetadata(boolTrueContent) + #expect(decodedTrue.thinkingSummaries == .auto) + + let boolFalseContent = GeneratedContent(kind: .bool(false)) + let decodedFalse = GeminiRequestMetadata(boolFalseContent) + #expect(decodedFalse.thinkingSummaries == .off) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func dictionaryGeminiHelper() { + let dict: [String: any ConvertibleToGeneratedContent] = .gemini(thinkingSummaries: .auto) + + #expect(dict.count == 1) + let entry = dict[GeminiRequestMetadata.metadataKey] + let geminiMetadata = entry as? GeminiRequestMetadata + #expect(geminiMetadata?.thinkingSummaries == .auto) + } + } +#endif // canImport(FoundationModels) && compiler(>=6.4) diff --git a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiRequestTranslatorTests.swift b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiRequestTranslatorTests.swift index ad84820ce6b..95191d1b285 100644 --- a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiRequestTranslatorTests.swift +++ b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/GeminiRequestTranslatorTests.swift @@ -247,5 +247,206 @@ #expect(disallowedConfig?.functionCallingConfig?.mode == FunctionCallingConfig.Mode.none) #expect(nilConfig == nil) } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func translatesThinkingSummariesAuto() throws { + let promptEntry = Transcript.Entry.prompt( + Transcript.Prompt( + id: "prompt-1", + segments: [.text(Transcript.TextSegment(content: "Hello"))] + ) + ) + let transcript = Transcript(entries: [promptEntry]) + let request = LanguageModelExecutorGenerationRequest( + id: UUID(), + transcript: transcript, + enabledTools: [], + schema: nil, + generationOptions: GenerationOptions(), + contextOptions: ContextOptions(), + metadata: [:] + ) + let thinking = GeminiLanguageModel.Thinking(summaries: .auto) + + let result = try GeminiRequestTranslator.translate(request, thinking: thinking) + + let generationConfig = try #require(result.generationConfig) + let thinkingConfig = try #require(generationConfig.thinkingConfig) + #expect(thinkingConfig.includeThoughts == true) + #expect(thinkingConfig.thinkingLevel == nil) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func translatesThinkingSummariesOff() throws { + let promptEntry = Transcript.Entry.prompt( + Transcript.Prompt( + id: "prompt-1", + segments: [.text(Transcript.TextSegment(content: "Hello"))] + ) + ) + let transcript = Transcript(entries: [promptEntry]) + let request = LanguageModelExecutorGenerationRequest( + id: UUID(), + transcript: transcript, + enabledTools: [], + schema: nil, + generationOptions: GenerationOptions(), + contextOptions: ContextOptions(), + metadata: [:] + ) + let thinking = GeminiLanguageModel.Thinking(summaries: .off) + + let result = try GeminiRequestTranslator.translate(request, thinking: thinking) + + let generationConfig = try #require(result.generationConfig) + let thinkingConfig = try #require(generationConfig.thinkingConfig) + #expect(thinkingConfig.includeThoughts == false) + #expect(thinkingConfig.thinkingLevel == nil) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func translatesReasoningLevels() { + let lightLevel = GeminiRequestTranslator.translateThinkingLevel(.light) + let moderateLevel = GeminiRequestTranslator.translateThinkingLevel(.moderate) + let deepLevel = GeminiRequestTranslator.translateThinkingLevel(.deep) + let minimalCustom = GeminiRequestTranslator.translateThinkingLevel(.custom("MINIMAL")) + let lowCustom = GeminiRequestTranslator.translateThinkingLevel(.custom("low")) + let mediumCustom = GeminiRequestTranslator.translateThinkingLevel(.custom("Medium")) + let highCustom = GeminiRequestTranslator.translateThinkingLevel(.custom("HIGH")) + let unknownCustom = GeminiRequestTranslator.translateThinkingLevel(.custom("custom_val")) + + #expect(lightLevel == .low) + #expect(moderateLevel == .medium) + #expect(deepLevel == .high) + #expect(minimalCustom == .minimal) + #expect(lowCustom == .low) + #expect(mediumCustom == .medium) + #expect(highCustom == .high) + #expect(unknownCustom == .unrecognized("custom_val")) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func translatesRequestWithReasoningLevelAndThinkingSummaries() throws { + let promptEntry = Transcript.Entry.prompt( + Transcript.Prompt( + id: "prompt-1", + segments: [.text(Transcript.TextSegment(content: "Hello"))] + ) + ) + let transcript = Transcript(entries: [promptEntry]) + var contextOptions = ContextOptions() + contextOptions.reasoningLevel = .deep + let request = LanguageModelExecutorGenerationRequest( + id: UUID(), + transcript: transcript, + enabledTools: [], + schema: nil, + generationOptions: GenerationOptions(), + contextOptions: contextOptions, + metadata: [:] + ) + let thinking = GeminiLanguageModel.Thinking(summaries: .auto) + + let result = try GeminiRequestTranslator.translate(request, thinking: thinking) + + let generationConfig = try #require(result.generationConfig) + let thinkingConfig = try #require(generationConfig.thinkingConfig) + #expect(thinkingConfig.includeThoughts == true) + #expect(thinkingConfig.thinkingLevel == .high) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func translatesRequestWithRequestMetadataOverridingModel() throws { + let promptEntry = Transcript.Entry.prompt( + Transcript.Prompt( + id: "prompt-1", + segments: [.text(Transcript.TextSegment(content: "Hello"))] + ) + ) + let transcript = Transcript(entries: [promptEntry]) + let request = LanguageModelExecutorGenerationRequest( + id: UUID(), + transcript: transcript, + enabledTools: [], + schema: nil, + generationOptions: GenerationOptions(), + contextOptions: ContextOptions(), + metadata: [ + GeminiRequestMetadata.metadataKey: GeminiRequestMetadata(thinkingSummaries: .off) + .generatedContent + ] + ) + let thinking = GeminiLanguageModel.Thinking(summaries: .auto) + + let result = try GeminiRequestTranslator.translate(request, thinking: thinking) + + let generationConfig = try #require(result.generationConfig) + let thinkingConfig = try #require(generationConfig.thinkingConfig) + #expect(thinkingConfig.includeThoughts == false) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func translatesRequestWithPromptMetadataOverridingModel() throws { + var prompt = Transcript.Prompt( + id: "prompt-1", + segments: [.text(Transcript.TextSegment(content: "Hello"))] + ) + prompt.metadata[GeminiRequestMetadata.metadataKey] = + GeminiRequestMetadata(thinkingSummaries: .off).generatedContent + let transcript = Transcript(entries: [.prompt(prompt)]) + let request = LanguageModelExecutorGenerationRequest( + id: UUID(), + transcript: transcript, + enabledTools: [], + schema: nil, + generationOptions: GenerationOptions(), + contextOptions: ContextOptions(), + metadata: [:] + ) + let thinking = GeminiLanguageModel.Thinking(summaries: .auto) + + let result = try GeminiRequestTranslator.translate(request, thinking: thinking) + + let generationConfig = try #require(result.generationConfig) + let thinkingConfig = try #require(generationConfig.thinkingConfig) + #expect(thinkingConfig.includeThoughts == false) + } + + @Test + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func translatesRequestWithRequestMetadataOverridingPromptMetadata() throws { + var prompt = Transcript.Prompt( + id: "prompt-1", + segments: [.text(Transcript.TextSegment(content: "Hello"))] + ) + prompt.metadata[GeminiRequestMetadata.metadataKey] = + GeminiRequestMetadata(thinkingSummaries: .off).generatedContent + let transcript = Transcript(entries: [.prompt(prompt)]) + let request = LanguageModelExecutorGenerationRequest( + id: UUID(), + transcript: transcript, + enabledTools: [], + schema: nil, + generationOptions: GenerationOptions(), + contextOptions: ContextOptions(), + metadata: [ + GeminiRequestMetadata.metadataKey: GeminiRequestMetadata(thinkingSummaries: .auto) + .generatedContent + ] + ) + let thinking = GeminiLanguageModel.Thinking(summaries: .off) + + let result = try GeminiRequestTranslator.translate(request, thinking: thinking) + + let generationConfig = try #require(result.generationConfig) + let thinkingConfig = try #require(generationConfig.thinkingConfig) + #expect(thinkingConfig.includeThoughts == true) + } } #endif // canImport(FoundationModels) && compiler(>=6.4) diff --git a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/BasicContentGenerationIntegrationTests.swift b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/BasicContentGenerationIntegrationTests.swift index fae81f160fc..e945d5ba99d 100644 --- a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/BasicContentGenerationIntegrationTests.swift +++ b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/BasicContentGenerationIntegrationTests.swift @@ -25,7 +25,8 @@ @Suite( "Basic Content Generation Integration Tests", .requireFoundationModels, - .tags(.integration) + .tags(.integration), + .serialized ) struct BasicContentGenerationIntegrationTests { @Test( diff --git a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/GuidedGenerationIntegrationTests.swift b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/GuidedGenerationIntegrationTests.swift index 3b4b9655e12..38aefbd4854 100644 --- a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/GuidedGenerationIntegrationTests.swift +++ b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/GuidedGenerationIntegrationTests.swift @@ -25,7 +25,8 @@ @Suite( "Guided Generation Integration Tests", .requireFoundationModels, - .tags(.integration) + .tags(.integration), + .serialized ) struct GuidedGenerationIntegrationTests { @Generable(description: "A summary of a city") diff --git a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/IntegrationTestingBackend+GeminiLanguageModel.swift b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/IntegrationTestingBackend+GeminiLanguageModel.swift index 1a948532357..83c88da67da 100644 --- a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/IntegrationTestingBackend+GeminiLanguageModel.swift +++ b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/IntegrationTestingBackend+GeminiLanguageModel.swift @@ -24,16 +24,20 @@ extension IntegrationTestingBackend { /// Creates a `GeminiLanguageModel` configured for this backend. /// - /// - Parameter modelID: The model identifier to use. Defaults to `gemini-3.5-flash-lite`. + /// - Parameters: + /// - modelID: The model identifier to use. Defaults to `gemini-3.5-flash-lite`. + /// - thinking: An optional thinking configuration for the model. /// - Returns: A configured `GeminiLanguageModel` instance. /// - Throws: An error if model resource or credentials resolution fails. func makeModel( - modelID: String = ModelResource.gemini35FlashLiteID + modelID: String = ModelResource.gemini35FlashLiteID, + thinking: GeminiLanguageModel.Thinking? = nil ) async throws -> GeminiLanguageModel { GeminiLanguageModel( modelResource: try modelResource(modelID: modelID), endpointConfiguration: endpointConfiguration, - headerProvider: try await makeHeaderProvider() + headerProvider: try await makeHeaderProvider(), + thinking: thinking ) } } diff --git a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/README.md b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/README.md index 3be4c1937cc..87f3e092262 100644 --- a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/README.md +++ b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/README.md @@ -50,21 +50,13 @@ All integration tests are parameterized across ## Files in this Directory -* [`BasicContentGenerationIntegrationTests.swift`](BasicContentGenerationIntegrationTests.swift): - Parameterized integration tests for single-turn prompt response, multi-turn - chat sessions, and streaming chunk responses. -* [`GuidedGenerationIntegrationTests.swift`](GuidedGenerationIntegrationTests.swift): - Parameterized integration tests for guided generation (structured outputs) - using `@Generable` types, including single-turn and streaming generation, - enum classification, and rich multi-type recursive hierarchies. -* [`ToolCallingIntegrationTests.swift`](ToolCallingIntegrationTests.swift): - Parameterized integration tests for tool calling (function calling) using - FoundationModels `Tool` definitions, covering single-turn tool calls, - sequential tool calls, parallel tool calls, tool calling mode configuration, - parameterless tools with empty arguments, and reasoning models. -* [`IntegrationTestingBackend+GeminiLanguageModel.swift`](IntegrationTestingBackend+GeminiLanguageModel.swift): - Convenience extension providing `backend.makeModel()` to instantiate a - pre-configured `GeminiLanguageModel`. +| File | Description | +|---|---| +| [`BasicContentGenerationIntegrationTests.swift`](BasicContentGenerationIntegrationTests.swift) | Parameterized integration tests for single-turn prompt response, multi-turn chat sessions, and streaming chunk responses. | +| [`GuidedGenerationIntegrationTests.swift`](GuidedGenerationIntegrationTests.swift) | Parameterized integration tests for guided generation (structured outputs) using `@Generable` types, including single-turn and streaming generation, enum classification, and rich multi-type recursive hierarchies. | +| [`ToolCallingIntegrationTests.swift`](ToolCallingIntegrationTests.swift) | Parameterized integration tests for tool calling (function calling) using FoundationModels `Tool` definitions, covering single-turn tool calls, sequential tool calls, parallel tool calls, tool calling mode configuration, parameterless tools with empty arguments, and reasoning models. | +| [`ReasoningIntegrationTests.swift`](ReasoningIntegrationTests.swift) | Parameterized integration tests for model reasoning and thought summaries, validating default thought summary generation (`Thinking(summaries: .auto)`), dynamic profile session properties (`session.properties.geminiThoughtSummary`), `response.geminiThoughtSummary`, and per-turn request metadata (`metadata: .gemini(...)`). | +| [`IntegrationTestingBackend+GeminiLanguageModel.swift`](IntegrationTestingBackend+GeminiLanguageModel.swift) | Convenience extension providing `backend.makeModel()` to instantiate a pre-configured `GeminiLanguageModel`, with optional `thinking:` configuration. | ## Writing New Integration Tests diff --git a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/ReasoningIntegrationTests.swift b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/ReasoningIntegrationTests.swift new file mode 100644 index 00000000000..c28a22071d8 --- /dev/null +++ b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/ReasoningIntegrationTests.swift @@ -0,0 +1,111 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(FoundationModels) && compiler(>=6.4) + import Foundation + import FoundationModels + import GeminiAPIClient + import GeminiTestUtilities + import Testing + + @testable import GeminiLanguageModel + + /// Integration tests for reasoning and thought summaries using `GeminiLanguageModel`. + @Suite( + "Reasoning Integration Tests", + .requireFoundationModels, + .tags(.integration), + .serialized + ) + struct ReasoningIntegrationTests { + @Test( + .requireIntegrationTestingBackend, + arguments: IntegrationTestingBackend.availableBackends + ) + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func sessionRespondWithThinkingSummariesAuto(backend: IntegrationTestingBackend) async throws { + let model = try await backend.makeModel( + thinking: GeminiLanguageModel.Thinking(summaries: .auto) + ) + let session = LanguageModelSession(model: model) + + let response = try await session.respond( + to: """ + Solve this step-by-step logic puzzle: In a room, there are 3 switches that control 3 \ + light bulbs in another room. You can only enter the room with the bulbs once. How do you \ + determine which switch controls which bulb? Think thoroughly and deduce the answer. + """, + contextOptions: ContextOptions(reasoningLevel: .deep) + ) + + #expect(!response.content.isEmpty) + let thoughtSummary = try #require(response.geminiThoughtSummary) + #expect(!thoughtSummary.isEmpty) + #expect(session.transcript.geminiThoughtSummary == thoughtSummary) + } + + @Test( + .requireIntegrationTestingBackend, + arguments: IntegrationTestingBackend.availableBackends + ) + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func sessionDynamicProfileWithGeminiThinking(backend: IntegrationTestingBackend) + async throws + { + let model = try await backend.makeModel() + + let profile = LanguageModelSession.Profile { + Instructions("You are a helpful math tutor.") + } + .model(model) + .reasoningLevel(.deep) + .geminiThinking(summaries: .auto) + + let session = LanguageModelSession(profile: profile) + + let response = try await session.respond( + to: "What is 17 multiplied by 24? Show your steps and final answer." + ) + + #expect(!response.content.isEmpty) + let thoughtSummary = try #require(response.geminiThoughtSummary) + #expect(!thoughtSummary.isEmpty) + let sessionThoughtSummary = try #require(session.properties.geminiThoughtSummary) + #expect(!sessionThoughtSummary.isEmpty) + #expect(sessionThoughtSummary == thoughtSummary) + } + + @Test( + .requireIntegrationTestingBackend, + arguments: IntegrationTestingBackend.availableBackends + ) + @available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *) + func sessionRespondWithRequestMetadata(backend: IntegrationTestingBackend) async throws { + let model = try await backend.makeModel() + let session = LanguageModelSession(model: model) + + let response = try await session.respond( + contextOptions: ContextOptions(reasoningLevel: .deep), + metadata: .gemini(thinkingSummaries: .auto), + ) { + "Which is heavier: a pound of feathers or a pound of gold? Explain your reasoning briefly." + } + + #expect(!response.content.isEmpty) + let thoughtSummary = try #require(response.geminiThoughtSummary) + #expect(!thoughtSummary.isEmpty) + #expect(session.transcript.geminiThoughtSummary == thoughtSummary) + } + } +#endif // canImport(FoundationModels) && compiler(>=6.4) diff --git a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/ToolCallingIntegrationTests.swift b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/ToolCallingIntegrationTests.swift index 249f75b66bf..8d39df333e5 100644 --- a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/ToolCallingIntegrationTests.swift +++ b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/IntegrationTests/ToolCallingIntegrationTests.swift @@ -25,7 +25,8 @@ @Suite( "Tool Calling Integration Tests", .requireFoundationModels, - .tags(.integration) + .tags(.integration), + .serialized ) struct ToolCallingIntegrationTests { /// A tool that provides mock weather information for a given city. diff --git a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/README.md b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/README.md index 9c2045d94de..92d6ad09f87 100644 --- a/GeminiLanguageModel/Tests/GeminiLanguageModelTests/README.md +++ b/GeminiLanguageModel/Tests/GeminiLanguageModelTests/README.md @@ -18,14 +18,42 @@ swift test --filter GeminiLanguageModelTests | Suite / File | Scope | Strategy | |---|---|---| -| [`GeminiLanguageModelTests.swift`](GeminiLanguageModelTests.swift) | Protocol conformance, single/multi-turn responses, streaming, tool calling | Unit (`MockHTTPURLProtocol`) | +| [`GeminiLanguageModelTests.swift`](GeminiLanguageModelTests.swift) | Protocol conformance, single/multi-turn responses, streaming, tool calling, thought summaries, and dynamic profiles | Unit (`MockHTTPURLProtocol`) | +| [`GeminiRequestMetadataTests.swift`](GeminiRequestMetadataTests.swift) | Structured request metadata serialization, deserialization, and dictionary helpers | Unit (pure transformation) | | [`GenerationSchema+GeminiTests.swift`](GenerationSchema+GeminiTests.swift) | JSON Schema encoding and property ordering conversion | Unit (pure transformation) | -| [`GeminiRequestTranslatorTests.swift`](GeminiRequestTranslatorTests.swift) | Request, generation config, tool declarations, and tool mode mapping | Unit (pure transformation) | +| [`GeminiRequestTranslatorTests.swift`](GeminiRequestTranslatorTests.swift) | Request, generation config, tool declarations, thinking configuration, and metadata precedence | Unit (pure transformation) | | [`GeminiTranscriptTranslatorTests.swift`](GeminiTranscriptTranslatorTests.swift) | Apple `Transcript` <-> Gemini payload mapping (tools, reasoning, prompts) | Unit (pure transformation) | | [`GeminiErrorMapperTests.swift`](GeminiErrorMapperTests.swift) | HTTP and API error mapping to `LanguageModelError` | Unit (exhaustive mapping) | | [`IntegrationTestingBackendTests.swift`](IntegrationTestingBackendTests.swift) | Endpoint resolution and credential discovery | Unit / Infrastructure | | [`IntegrationTests/`](IntegrationTests/) | End-to-end Gemini and Firebase AI Logic integration tests | Integration (see [`IntegrationTests/README.md`](IntegrationTests/README.md)) | +## Reasoning and Thought Summaries + +Gemini reasoning capabilities and thought summaries are validated across +multiple layers: + +* **Model Configuration**: `GeminiLanguageModel(thinking:)` sets the default + thought summary mode (`.auto` or `.off`) on the model. +* **Dynamic Profiles**: `LanguageModelSession.DynamicProfile` extensions provide + `.geminiThinking(summaries:)` and `.geminiThinking(perform:)` to configure or + observe thought summaries within a session profile without mutating model + instances. Active profiles automatically maintain + `session.properties.geminiThoughtSummary` as observable state. + Reasoning depth/budget is configured via Apple's standard + `.reasoningLevel` profile modifier using `.light`, `.moderate`, or `.deep`. +* **Response & Snapshot Inspection**: `response.geminiThoughtSummary` and + `snapshot.geminiThoughtSummary` provide first-class, zero-synchronization + access to generated thought summary text without requiring callbacks or locks. +* **Request Metadata**: Per-turn overrides via `session.respond(metadata:)` or + `session.streamResponse(metadata:)` use `GeminiRequestMetadata` (stored under + the `"gemini"` key) or the `.gemini(thinkingSummaries:)` dictionary helper. +* **Precedence Resolution**: The request translator resolves configurations + hierarchically: per-turn request metadata > profile prompt metadata > model + default. +* **Transcript Translation**: Thought parts returned by the Gemini API + (`thought: true`) stream into Apple's `Transcript.Reasoning` entries with + preserved thought signatures. + ## Testing Architecture This target separates tests into two distinct tiers: