mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-10 22:45:59 +05:45
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.
215 lines
7.4 KiB
Swift
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()
|
|
}
|
|
}
|