Files
firebase-ios-sdk/GeminiLanguageModel/Sources/GeminiAPIClient/HTTPStreamingClient.swift
T
Andrew Heard 3796a55fdf feat(ai): add GeminiLanguageModel (#16601)
Implemented `GeminiLanguageModel`, providing an adapter for Google's
Gemini models conforming to Apple's `FoundationModels.LanguageModel`
protocol. This PR includes text to text generation capabilities; other
modalities and Gemini features will be handled in follow-up PRs.

**Note:** The `GeminiLanguageModel` implementation is guarded by the
environment variable `GEMINI_LANGUAGE_MODEL`. By default none of the
`GeminiLanguageModel` implementation is included.

Key additions:
- `GeminiLanguageModel`: Implements `LanguageModel` protocol with
  reasoning capabilities, configuring execution via `ModelResource`
  and `EndpointConfiguration`.
- `GeminiLanguageModelExecutor`: Converts prompt instructions and
  history into Gemini streaming requests, yielding responses as
  `LanguageModelSession.ResponseStream`.
- `GeminiTranscriptTranslator`: Translates Apple `Transcript` entries
  and segments into Gemini `Content` structures while preserving thought
  signatures and validating supported transcript features.
- `GeminiLanguageModel.Error` and `GeminiErrorMapper`: Translates Gemini
  API, HTTP, and network errors into standard `LanguageModelError` cases
  (`rateLimited`, `timeout`, `guardrailViolation`, `refusal`, and
  `unsupportedLanguageOrLocale`) or `GeminiLanguageModel.Error` cases
  (`serviceUnavailable`, `networkFailure`, `modelNotFound`, `apiError`).
  The `APIError` payload is designed for seamless forward compatibility
  with both GenerateContent and the upcoming Interactions API.
- Testing infrastructure: Adds `ConditionTrait.requireFoundationModels`
  to conditionally run Foundation Models tests only on supported
  platforms (macOS 27+, iOS 27+, watchOS 27+, visionOS 27+), along with
  shared test constants, mock fixtures, and comprehensive unit and
  integration test suites.
2026-09-04 18:05:45 -04:00

309 lines
11 KiB
Swift

// 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 Synchronization
#if canImport(Darwin)
package import Foundation
#else
import Foundation
package import FoundationNetworking
#endif
/// An HTTP client that streams bytes and lines of text using `URLSession`.
///
/// Supports Apple platforms and Linux with full Swift 6 strict concurrency compliance.
@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
package final class HTTPStreamingClient: Sendable {
private let session: URLSession
private let sessionDelegate: SessionDelegate
/// Initializes a new HTTP streaming client with the specified configuration.
///
/// - Parameter configuration: The `URLSessionConfiguration` to use. Defaults to `.ephemeral`.
package init(configuration: URLSessionConfiguration = .ephemeral) {
let delegate = SessionDelegate()
self.sessionDelegate = delegate
self.session = URLSession(
configuration: configuration,
delegate: delegate,
delegateQueue: nil
)
}
deinit {
session.invalidateAndCancel()
}
/// Sends a request and delivers an asynchronous sequence of lines of text and the HTTP response.
///
/// - Parameter request: The `URLRequest` to execute.
/// - Returns: A tuple of the `HTTPAsyncLineSequence` stream and `HTTPURLResponse` metadata.
/// - Throws: An error if the request fails to connect or if the response is not an HTTP response.
package func lines(
for request: URLRequest
) async throws -> (lines: HTTPAsyncLineSequence, response: HTTPURLResponse) {
let (dataStream, dataContinuation) = AsyncThrowingStream<Data, any Error>.makeStream()
let dataTask = session.dataTask(with: request)
dataContinuation.onTermination = { @Sendable [weak dataTask] _ in
dataTask?.cancel()
}
let taskDelegate = TaskDelegate(dataContinuation: dataContinuation)
sessionDelegate.addTaskDelegate(taskDelegate, for: dataTask.taskIdentifier)
let response: HTTPURLResponse = try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
taskDelegate.setResponseContinuation(continuation)
dataTask.resume()
}
} onCancel: {
dataTask.cancel()
}
let asyncLines = HTTPAsyncLineSequence(dataStream: dataStream, task: dataTask, client: self)
return (lines: asyncLines, response: response)
}
/// Invalidates the session, allowing any outstanding tasks to finish.
package func finishTasksAndInvalidate() {
session.finishTasksAndInvalidate()
}
/// Invalidates the session and cancels all active tasks.
package func invalidateAndCancel() {
session.invalidateAndCancel()
}
}
// MARK: - HTTP Async Line Sequence
/// An asynchronous sequence of lines of text parsed from an HTTP byte stream.
@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
package struct HTTPAsyncLineSequence: AsyncSequence, Sendable {
private let dataStream: AsyncThrowingStream<Data, any Error>
/// The underlying `URLSessionTask` performing the data transfer.
package let task: URLSessionTask
/// Retained to prevent the client (and its session) from deallocating during iteration.
private let client: HTTPStreamingClient
/// Initializes a new async line sequence from a data stream.
///
/// - Parameters:
/// - dataStream: The underlying byte stream.
/// - task: The URL session data task.
/// - client: The HTTP client that created this sequence.
init(
dataStream: AsyncThrowingStream<Data, any Error>,
task: URLSessionDataTask,
client: HTTPStreamingClient
) {
self.dataStream = dataStream
self.task = task
self.client = client
}
/// Creates an asynchronous iterator over the line sequence.
///
/// - Returns: An `AsyncIterator` instance.
package func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(streamIterator: dataStream.makeAsyncIterator(), client: client)
}
/// An asynchronous iterator over lines of text decoded from an HTTP response.
package struct AsyncIterator: AsyncIteratorProtocol {
private var streamIterator: AsyncThrowingStream<Data, any Error>.AsyncIterator
private var decoder = HTTPLineDecoder()
private var pendingLines: ArraySlice<String> = []
/// Retained to prevent the client (and its session) from deallocating during iteration.
private let client: HTTPStreamingClient
/// Initializes a new async iterator.
///
/// - Parameters:
/// - streamIterator: The underlying byte stream iterator.
/// - client: The HTTP client that created this sequence.
init(
streamIterator: AsyncThrowingStream<Data, any Error>.AsyncIterator,
client: HTTPStreamingClient
) {
self.streamIterator = streamIterator
self.client = client
}
/// Asynchronously advances to and returns the next line of text.
///
/// - Returns: The next decoded line of text, or `nil` if the stream has finished.
/// - Throws: An error if reading from the stream fails.
package mutating func next() async throws -> String? {
while pendingLines.isEmpty {
guard let chunk = try await streamIterator.next() else {
// Once the stream ends, flush any remaining bytes as a final line.
// Subsequent calls to next() will safely fall through and return nil.
return decoder.flush()
}
// Convert the returned Array into an ArraySlice
pendingLines = decoder.feed(chunk)[...]
}
return pendingLines.popFirst()
}
}
}
// MARK: - Internal Session Delegate Dispatcher
@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
private final class SessionDelegate: NSObject, URLSessionDataDelegate, Sendable {
private let taskDelegates = Mutex<[Int: TaskDelegate]>([:])
func addTaskDelegate(_ delegate: TaskDelegate, for taskIdentifier: Int) {
taskDelegates.withLock { $0[taskIdentifier] = delegate }
}
private func taskDelegate(for taskIdentifier: Int) -> TaskDelegate? {
taskDelegates.withLock { $0[taskIdentifier] }
}
private func removeTaskDelegate(for taskIdentifier: Int) -> TaskDelegate? {
taskDelegates.withLock { $0.removeValue(forKey: taskIdentifier) }
}
// MARK: - URLSessionDataDelegate
func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
guard let delegate = taskDelegate(for: dataTask.taskIdentifier) else {
completionHandler(.allow)
return
}
delegate.urlSession(
session,
dataTask: dataTask,
didReceive: response,
completionHandler: completionHandler
)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
guard let delegate = taskDelegate(for: dataTask.taskIdentifier) else { return }
delegate.urlSession(session, dataTask: dataTask, didReceive: data)
}
func urlSession(
_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?
) {
let delegate = removeTaskDelegate(for: task.taskIdentifier)
delegate?.urlSession(session, task: task, didCompleteWithError: error)
}
}
// MARK: - Internal Task Delegate
@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
private final class TaskDelegate: NSObject, URLSessionDataDelegate, Sendable {
private enum ResponseState: Sendable {
case pending
case waiting(CheckedContinuation<HTTPURLResponse, any Error>)
case completed(Result<HTTPURLResponse, any Error>)
}
private let responseState = Mutex<ResponseState>(.pending)
private let dataContinuation: AsyncThrowingStream<Data, any Error>.Continuation
init(dataContinuation: AsyncThrowingStream<Data, any Error>.Continuation) {
self.dataContinuation = dataContinuation
}
func setResponseContinuation(_ continuation: CheckedContinuation<HTTPURLResponse, any Error>) {
let resultToResume: Result<HTTPURLResponse, any Error>? = responseState.withLock { state in
switch state {
case .pending:
state = .waiting(continuation)
return nil
case .waiting:
fatalError("Response continuation set multiple times")
case .completed(let result):
return result
}
}
if let resultToResume {
continuation.resume(with: resultToResume)
}
}
/// Atomically transitions the state to completed and resumes any waiting continuation.
private func resumeResponse(with result: Result<HTTPURLResponse, any Error>) {
let continuationToResume: CheckedContinuation<HTTPURLResponse, any Error>? =
responseState
.withLock { state in
switch state {
case .pending:
state = .completed(result)
return nil
case .waiting(let continuation):
state = .completed(result)
return continuation
case .completed:
return nil
}
}
continuationToResume?.resume(with: result)
}
// MARK: - URLSessionDataDelegate
func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
// 1. Dedicated hook for the response header
if let httpResponse = response as? HTTPURLResponse {
if (100...199).contains(httpResponse.statusCode) {
completionHandler(.allow)
return
}
resumeResponse(with: .success(httpResponse))
completionHandler(.allow)
} else {
resumeResponse(with: .failure(URLError(.badServerResponse)))
completionHandler(.cancel)
}
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
// 2. Pure data passthrough
dataContinuation.yield(data)
}
func urlSession(
_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?
) {
// 3. Clean up stream and catch any pre-response failures
if let error {
resumeResponse(with: .failure(error))
dataContinuation.finish(throwing: error)
} else {
// Fallback in case the task completes successfully but somehow never sent a response
resumeResponse(with: .failure(URLError(.badServerResponse)))
dataContinuation.finish()
}
}
}