Files
firebase-ios-sdk/GeminiLanguageModel/Tests/GeminiTestUtilities/AppCheckDebugClient.swift
T
Andrew Heard 97932016ef feat(ai): use VALIDATED function calling mode in GLM (#16649)
Default to VALIDATED function calling mode in GeminiLanguageModel and
switch guided generation to responseJsonSchema.
Previously, tool-calling requests mapped the default or allowed mode to
AUTO, which does not strictly enforce parameter schemas. Because Swift
decodes tool arguments strictly, schema malformation results in failed
turns. Additionally, guided generation used responseFormat, which the
Gemini backend ignores when VALIDATED mode is active during tool-calling
sessions, dropping response schemas and causing structured decoding
failures in respond(to:generating:).
* Default unset and explicit .allowed toolCallingMode to VALIDATED mode
  to strictly enforce schema adherence via constrained decoding.
* Throw LanguageModelError.unsupportedCapability on unrecognized future
  tool calling modes instead of silently falling back to allowed.
* Omit functionCallingConfig entirely when requests declare no functions
  to avoid contradictory constrained decoding states.
* Send responseJsonSchema with application/json MIME type for guided
  generation, which constrained decoding honors alongside VALIDATED.
* Remove unused _responseJsonSchema property from GenerationConfig.
* End SSE response streams and cancel tasks upon receiving terminal
  finish reasons or prompt feedback block reasons.
* Add integration test for combined tool calling and guided generation.
* Refresh cached App Check debug tokens proactively within TTL window.
2026-09-17 17:51:18 -04:00

215 lines
7.4 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.
#if canImport(Darwin)
package import Foundation
#else
import Foundation
#endif
#if canImport(FoundationNetworking)
package import FoundationNetworking
#endif
/// Lightweight client for exchanging and caching App Check debug tokens in integration tests.
///
/// The full `FirebaseAppCheck` SDK relies on Keychain access to persist tokens, which is
/// unavailable when running tests in a headless environment outside an application bundle host
/// (such as executing `swift test` directly from the command line).
///
/// This client directly queries the Firebase App Check REST API
/// (`projects/{projectID}/apps/{appID}:exchangeDebugToken`) using standard `URLSession` and
/// retains tokens in-memory.
///
/// Unlike the production SDK, token persistence relies on in-memory storage instead of Keychain.
/// Exchanged tokens are cached with their TTL and automatically refreshed when nearing expiration.
@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
package actor AppCheckDebugClient {
private let projectID: String
private let appID: String
private let apiKey: String
private let debugToken: String
private var cachedToken: String?
private var expirationDate: Date?
private var inFlightExchangeTask: Task<String, any Error>?
private static let baseURL = URL(string: "https://firebaseappcheck.googleapis.com")!
/// Initializes the client with the required project, app, and authentication parameters.
///
/// - Parameters:
/// - projectID: The Firebase project ID.
/// - appID: The Firebase app ID.
/// - apiKey: The Firebase API key.
/// - debugToken: The App Check debug token.
package init(
projectID: String,
appID: String,
apiKey: String,
debugToken: String
) {
assert(!projectID.isEmpty, "projectID must not be empty.")
assert(!appID.isEmpty, "appID must not be empty.")
assert(!apiKey.isEmpty, "apiKey must not be empty.")
assert(!debugToken.isEmpty, "debugToken must not be empty.")
self.projectID = projectID
self.appID = appID
self.apiKey = apiKey
self.debugToken = debugToken
}
/// Exchanges the debug token for an App Check token, caching the result in-memory.
///
/// Automatically refreshes the cached token if it has expired or is nearing expiration.
///
/// - Parameters:
/// - session: The `URLSession` to use. Defaults to `.shared`.
/// - forceRefresh: Whether to bypass the cached token and fetch a new one.
/// - Returns: The exchanged App Check token string.
/// - Throws: An error if the request fails or the response cannot be parsed.
package func exchangeDebugToken(
session: URLSession = .shared,
forceRefresh: Bool = false
) async throws -> String {
if !forceRefresh, isTokenValid, let cachedToken {
return cachedToken
}
if !forceRefresh, let inFlightExchangeTask {
return try await inFlightExchangeTask.value
}
let projectID = self.projectID
let appID = self.appID
let apiKey = self.apiKey
let debugToken = self.debugToken
let exchangeTask = Task<String, any Error> {
let endpointURL = Self.baseURL
.appending(component: "v1")
.appending(component: "projects")
.appending(component: projectID)
.appending(component: "apps")
.appending(component: "\(appID):exchangeDebugToken")
var request = URLRequest(url: endpointURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key")
request.httpBody = try JSONEncoder().encode(RequestBody(debugToken: debugToken))
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
let errorBody = String(decoding: data, as: UTF8.self)
throw URLError(.badServerResponse, userInfo: ["body": errorBody])
}
let decoded = try JSONDecoder().decode(ResponseBody.self, from: data)
let ttlSeconds = Self.parseTTLSeconds(decoded.ttl)
self.updateCachedToken(
decoded.token, expirationDate: Date().addingTimeInterval(ttlSeconds))
return decoded.token
}
self.inFlightExchangeTask = exchangeTask
do {
let token = try await exchangeTask.value
self.inFlightExchangeTask = nil
return token
} catch {
self.inFlightExchangeTask = nil
throw error
}
}
// MARK: - Private Helpers
/// Indicates whether the currently cached token is valid and not nearing expiration.
private var isTokenValid: Bool {
guard let cachedToken, !cachedToken.isEmpty, let expirationDate else {
return false
}
// Proactively refresh if within 5 minutes of expiration.
return Date().addingTimeInterval(300) < expirationDate
}
private func updateCachedToken(_ token: String, expirationDate: Date) {
self.cachedToken = token
self.expirationDate = expirationDate
}
private static func parseTTLSeconds(_ ttl: String?) -> TimeInterval {
guard let ttl else { return 3600 }
let trimmed = ttl.trimmingCharacters(in: .whitespaces)
if trimmed.hasSuffix("s") {
let secondsString = trimmed.dropLast()
return TimeInterval(secondsString) ?? 3600
}
return TimeInterval(trimmed) ?? 3600
}
}
// MARK: - Private Payload Types
@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
extension AppCheckDebugClient {
fileprivate struct RequestBody: Encodable {
let debugToken: String
}
fileprivate struct ResponseBody: Decodable {
let token: String
let ttl: String?
}
}
/// An in-memory cache for `AppCheckDebugClient` instances across integration tests.
///
/// Deduplicates token exchange requests and manages client instances keyed by
/// `projectID:appID:apiKey:debugToken` so tokens can be reused across parameterized or concurrent
/// test cases without hitting the exchange endpoint repeatedly.
@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
package actor AppCheckTokenCache {
package static let shared = AppCheckTokenCache()
private var clients: [String: AppCheckDebugClient] = [:]
/// Retrieves a cached App Check token, initializing a client if needed.
package func token(
projectID: String,
appID: String,
apiKey: String,
debugToken: String
) async throws -> String {
let key = "\(projectID):\(appID):\(apiKey):\(debugToken)"
let client: AppCheckDebugClient
if let existing = clients[key] {
client = existing
} else {
let newClient = AppCheckDebugClient(
projectID: projectID,
appID: appID,
apiKey: apiKey,
debugToken: debugToken
)
clients[key] = newClient
client = newClient
}
return try await client.exchangeDebugToken()
}
/// Clears all cached clients and tokens.
package func reset() {
clients.removeAll()
}
}