diff --git a/FirebaseVertexAI/CHANGELOG.md b/FirebaseVertexAI/CHANGELOG.md index fc54c727370..25f3f3181e0 100644 --- a/FirebaseVertexAI/CHANGELOG.md +++ b/FirebaseVertexAI/CHANGELOG.md @@ -1,3 +1,37 @@ +# 11.4.0 +- [changed] **Breaking Change**: The `HarmCategory` enum is no longer nested + inside the `SafetySetting` struct and the `unspecified` case has been + removed. (#13686) +- [changed] **Breaking Change**: The `BlockThreshold` enum in `SafetySetting` + has been renamed to `HarmBlockThreshold`. (#13696) +- [changed] **Breaking Change**: The `unspecified` case has been removed from + the `FinishReason`, `BlockReason` and `HarmProbability` enums; this scenario + is now handled by the existing `unknown` case. (#13699) +- [changed] **Breaking Change**: The `data` case in the `Part` enum has been + renamed to `inlineData`; no functionality changes. (#13700) +- [changed] **Breaking Change**: The property `citationSources` of + `CitationMetadata` has been renamed to `citations`. (#13702) +- [changed] **Breaking Change**: The constructor for `Schema` is now deprecated; + use the new static methods `Schema.string(...)`, `Schema.object(...)`, etc., + instead. (#13616) +- [changed] **Breaking Change**: The constructor for `FunctionDeclaration` now + accepts an array of *optional* parameters instead of a list of *required* + parameters; if a parameter is not listed as optional it is assumed to be + required. (#13616) +- [changed] **Breaking Change**: `CountTokensResponse.totalBillableCharacters` + is now optional (`Int?`); it may be `null` in cases such as when a + `GenerateContentRequest` contains only images or other non-text content. + (#13721) +- [changed] **Breaking Change**: The `ImageConversionError` enum is no longer + public; image conversion errors are still reported as + `GenerateContentError.promptImageContentError`. (#13735) +- [changed] **Breaking Change**: The `CountTokensError` enum has been removed; + errors occurring in `GenerativeModel.countTokens(...)` are now thrown directly + instead of being wrapped in a `CountTokensError.internalError`. (#13736) +- [changed] The default request timeout is now 180 seconds instead of the + platform-default value of 60 seconds for a `URLRequest`; this timeout may + still be customized in `RequestOptions`. (#13722) + # 11.3.0 - [added] Added `Decodable` conformance for `FunctionResponse`. (#13606) - [changed] **Breaking Change**: Reverted refactor of `GenerativeModel` and diff --git a/FirebaseVertexAI/Sample/ChatSample/Views/ErrorDetailsView.swift b/FirebaseVertexAI/Sample/ChatSample/Views/ErrorDetailsView.swift index dc5ce8f9561..b8febfe0e40 100644 --- a/FirebaseVertexAI/Sample/ChatSample/Views/ErrorDetailsView.swift +++ b/FirebaseVertexAI/Sample/ChatSample/Views/ErrorDetailsView.swift @@ -16,7 +16,7 @@ import FirebaseVertexAI import MarkdownUI import SwiftUI -extension SafetySetting.HarmCategory: CustomStringConvertible { +extension HarmCategory: CustomStringConvertible { public var description: String { switch self { case .dangerousContent: "Dangerous content" @@ -24,7 +24,6 @@ extension SafetySetting.HarmCategory: CustomStringConvertible { case .hateSpeech: "Hate speech" case .sexuallyExplicit: "Sexually explicit" case .unknown: "Unknown" - case .unspecified: "Unspecified" } } } @@ -37,7 +36,6 @@ extension SafetyRating.HarmProbability: CustomStringConvertible { case .medium: "Medium" case .negligible: "Negligible" case .unknown: "Unknown" - case .unspecified: "Unspecified" } } } diff --git a/FirebaseVertexAI/Sample/FunctionCallingSample/ViewModels/FunctionCallingViewModel.swift b/FirebaseVertexAI/Sample/FunctionCallingSample/ViewModels/FunctionCallingViewModel.swift index ac2ea5a1fcc..110cab9ce27 100644 --- a/FirebaseVertexAI/Sample/FunctionCallingSample/ViewModels/FunctionCallingViewModel.swift +++ b/FirebaseVertexAI/Sample/FunctionCallingSample/ViewModels/FunctionCallingViewModel.swift @@ -45,20 +45,15 @@ class FunctionCallingViewModel: ObservableObject { name: "get_exchange_rate", description: "Get the exchange rate for currencies between countries", parameters: [ - "currency_from": Schema( - type: .string, - format: "enum", - description: "The currency to convert from in ISO 4217 format", - enumValues: ["USD", "EUR", "JPY", "GBP", "AUD", "CAD"] + "currency_from": .enumeration( + values: ["USD", "EUR", "JPY", "GBP", "AUD", "CAD"], + description: "The currency to convert from in ISO 4217 format" ), - "currency_to": Schema( - type: .string, - format: "enum", - description: "The currency to convert to in ISO 4217 format", - enumValues: ["USD", "EUR", "JPY", "GBP", "AUD", "CAD"] + "currency_to": .enumeration( + values: ["USD", "EUR", "JPY", "GBP", "AUD", "CAD"], + description: "The currency to convert to in ISO 4217 format" ), - ], - requiredParameters: ["currency_from", "currency_to"] + ] ), ])] ) @@ -156,7 +151,7 @@ class FunctionCallingViewModel: ObservableObject { case let .functionCall(functionCall): messages.insert(functionCall.chatMessage(), at: messages.count - 1) functionCalls.append(functionCall) - case .data, .fileData, .functionResponse: + case .inlineData, .fileData, .functionResponse: fatalError("Unsupported response content.") } } diff --git a/FirebaseVertexAI/Sources/Chat.swift b/FirebaseVertexAI/Sources/Chat.swift index ff7a8aa9a09..10df040ab30 100644 --- a/FirebaseVertexAI/Sources/Chat.swift +++ b/FirebaseVertexAI/Sources/Chat.swift @@ -155,7 +155,7 @@ public class Chat { case let .text(str): combinedText += str - case .data, .fileData, .functionCall, .functionResponse: + case .inlineData, .fileData, .functionCall, .functionResponse: // Don't combine it, just add to the content. If there's any text pending, add that as // a part. if !combinedText.isEmpty { diff --git a/FirebaseVertexAI/Sources/Constants.swift b/FirebaseVertexAI/Sources/Constants.swift index 8f1768d7082..e3f2d45d6df 100644 --- a/FirebaseVertexAI/Sources/Constants.swift +++ b/FirebaseVertexAI/Sources/Constants.swift @@ -21,5 +21,8 @@ import Foundation /// Constants associated with the Vertex AI for Firebase SDK. enum Constants { /// The Vertex AI backend endpoint URL. + /// + /// TODO(andrewheard): Update to "https://firebasevertexai.googleapis.com" after the Vertex AI in + /// Firebase API launch. static let baseURL = "https://firebaseml.googleapis.com" } diff --git a/FirebaseVertexAI/Sources/CountTokensRequest.swift b/FirebaseVertexAI/Sources/CountTokensRequest.swift index feddf9b2a05..6b052da19e6 100644 --- a/FirebaseVertexAI/Sources/CountTokensRequest.swift +++ b/FirebaseVertexAI/Sources/CountTokensRequest.swift @@ -40,7 +40,7 @@ public struct CountTokensResponse { /// /// > Important: This does not include billable image, video or other non-text input. See /// [Vertex AI pricing](https://cloud.google.com/vertex-ai/generative-ai/pricing) for details. - public let totalBillableCharacters: Int + public let totalBillableCharacters: Int? } // MARK: - Codable Conformances @@ -53,18 +53,4 @@ extension CountTokensRequest: Encodable { } @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension CountTokensResponse: Decodable { - enum CodingKeys: CodingKey { - case totalTokens - case totalBillableCharacters - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - totalTokens = try container.decode(Int.self, forKey: .totalTokens) - totalBillableCharacters = try container.decodeIfPresent( - Int.self, - forKey: .totalBillableCharacters - ) ?? 0 - } -} +extension CountTokensResponse: Decodable {} diff --git a/FirebaseVertexAI/Sources/Errors.swift b/FirebaseVertexAI/Sources/Errors.swift index 17a4dd9e201..57fbe826580 100644 --- a/FirebaseVertexAI/Sources/Errors.swift +++ b/FirebaseVertexAI/Sources/Errors.swift @@ -31,9 +31,14 @@ struct RPCError: Error { self.details = details } + // TODO(andrewheard): Remove this method after the Vertex AI in Firebase API launch. func isFirebaseMLServiceDisabledError() -> Bool { return details.contains { $0.isFirebaseMLServiceDisabledErrorDetails() } } + + func isVertexAIInFirebaseServiceDisabledError() -> Bool { + return details.contains { $0.isVertexAIInFirebaseServiceDisabledErrorDetails() } + } } extension RPCError: Decodable { @@ -86,17 +91,26 @@ struct ErrorDetails { return type == ErrorDetails.errorInfoType } + func isServiceDisabledError() -> Bool { + return isErrorInfo() && reason == "SERVICE_DISABLED" && domain == "googleapis.com" + } + + // TODO(andrewheard): Remove this method after the Vertex AI in Firebase API launch. func isFirebaseMLServiceDisabledErrorDetails() -> Bool { - guard isErrorInfo() else { + guard isServiceDisabledError() else { return false } - guard reason == "SERVICE_DISABLED" else { + guard let metadata, metadata["service"] == "firebaseml.googleapis.com" else { return false } - guard domain == "googleapis.com" else { + return true + } + + func isVertexAIInFirebaseServiceDisabledErrorDetails() -> Bool { + guard isServiceDisabledError() else { return false } - guard let metadata, metadata["service"] == "firebaseml.googleapis.com" else { + guard let metadata, metadata["service"] == "firebasevertexai.googleapis.com" else { return false } return true diff --git a/FirebaseVertexAI/Sources/FunctionCalling.swift b/FirebaseVertexAI/Sources/FunctionCalling.swift index 3c88279c6df..4641a6f400f 100644 --- a/FirebaseVertexAI/Sources/FunctionCalling.swift +++ b/FirebaseVertexAI/Sources/FunctionCalling.swift @@ -43,17 +43,15 @@ public struct FunctionDeclaration { /// - name: The name of the function; must be a-z, A-Z, 0-9, or contain underscores and dashes, /// with a maximum length of 63. /// - description: A brief description of the function. - /// - parameters: Describes the parameters to this function; the keys are parameter names and - /// the values are ``Schema`` objects describing them. - /// - requiredParameters: A list of required parameters by name. - public init(name: String, description: String, parameters: [String: Schema]?, - requiredParameters: [String]? = nil) { + /// - parameters: Describes the parameters to this function. + public init(name: String, description: String, parameters: [String: Schema], + optionalParameters: [String] = []) { self.name = name self.description = description - self.parameters = Schema( - type: .object, + self.parameters = Schema.object( properties: parameters, - requiredProperties: requiredParameters + optionalProperties: optionalParameters, + nullable: false ) } } diff --git a/FirebaseVertexAI/Sources/GenerateContentError.swift b/FirebaseVertexAI/Sources/GenerateContentError.swift index 5428223853f..b5b52d0acd5 100644 --- a/FirebaseVertexAI/Sources/GenerateContentError.swift +++ b/FirebaseVertexAI/Sources/GenerateContentError.swift @@ -17,12 +17,12 @@ import Foundation /// Errors that occur when generating content from a model. @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public enum GenerateContentError: Error { - /// An error occurred when constructing the prompt. Examine the related error for details. - case promptImageContentError(underlying: ImageConversionError) - /// An internal error occurred. See the underlying error for more context. case internalError(underlying: Error) + /// An error occurred when constructing the prompt. Examine the related error for details. + case promptImageContentError(underlying: Error) + /// A prompt was blocked. See the response's `promptFeedback.blockReason` for more information. case promptBlocked(response: GenerateContentResponse) diff --git a/FirebaseVertexAI/Sources/GenerateContentResponse.swift b/FirebaseVertexAI/Sources/GenerateContentResponse.swift index c9f085971ea..66dd83aec16 100644 --- a/FirebaseVertexAI/Sources/GenerateContentResponse.swift +++ b/FirebaseVertexAI/Sources/GenerateContentResponse.swift @@ -42,8 +42,10 @@ public struct GenerateContentResponse: Sendable { /// The response's content as text, if it exists. public var text: String? { guard let candidate = candidates.first else { - Logging.default - .error("[FirebaseVertexAI] Could not get text from a response that had no candidates.") + VertexLog.error( + code: .generateContentResponseNoCandidates, + "Could not get text from a response that had no candidates." + ) return nil } let textValues: [String] = candidate.content.parts.compactMap { part in @@ -53,8 +55,10 @@ public struct GenerateContentResponse: Sendable { return text } guard textValues.count > 0 else { - Logging.default - .error("[FirebaseVertexAI] Could not get a text part from the first candidate.") + VertexLog.error( + code: .generateContentResponseNoText, + "Could not get a text part from the first candidate." + ) return nil } return textValues.joined(separator: " ") @@ -113,7 +117,7 @@ public struct CandidateResponse: Sendable { @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct CitationMetadata: Sendable { /// A list of individual cited sources and the parts of the content to which they apply. - public let citationSources: [Citation] + public let citations: [Citation] } /// A struct describing a source attribution. @@ -138,10 +142,9 @@ public struct Citation: Sendable { /// A value enumerating possible reasons for a model to terminate a content generation request. @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public enum FinishReason: String, Sendable { + /// The finish reason is unknown. case unknown = "FINISH_REASON_UNKNOWN" - case unspecified = "FINISH_REASON_UNSPECIFIED" - /// Natural stop point of the model or provided stop sequence. case stop = "STOP" @@ -168,9 +171,6 @@ public struct PromptFeedback: Sendable { /// The block reason is unknown. case unknown = "UNKNOWN" - /// The block reason was not specified in the server response. - case unspecified = "BLOCK_REASON_UNSPECIFIED" - /// The prompt was blocked because it was deemed unsafe. case safety = "SAFETY" @@ -294,11 +294,7 @@ extension CandidateResponse: Decodable { } @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension CitationMetadata: Decodable { - enum CodingKeys: String, CodingKey { - case citationSources = "citations" - } -} +extension CitationMetadata: Decodable {} @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension Citation: Decodable { @@ -338,8 +334,10 @@ extension FinishReason: Decodable { public init(from decoder: Decoder) throws { let value = try decoder.singleValueContainer().decode(String.self) guard let decodedFinishReason = FinishReason(rawValue: value) else { - Logging.default - .error("[FirebaseVertexAI] Unrecognized FinishReason with value \"\(value)\".") + VertexLog.error( + code: .generateContentResponseUnrecognizedFinishReason, + "Unrecognized FinishReason with value \"\(value)\"." + ) self = .unknown return } @@ -353,8 +351,10 @@ extension PromptFeedback.BlockReason: Decodable { public init(from decoder: Decoder) throws { let value = try decoder.singleValueContainer().decode(String.self) guard let decodedBlockReason = PromptFeedback.BlockReason(rawValue: value) else { - Logging.default - .error("[FirebaseVertexAI] Unrecognized BlockReason with value \"\(value)\".") + VertexLog.error( + code: .generateContentResponseUnrecognizedBlockReason, + "Unrecognized BlockReason with value \"\(value)\"." + ) self = .unknown return } diff --git a/FirebaseVertexAI/Sources/GenerativeAIRequest.swift b/FirebaseVertexAI/Sources/GenerativeAIRequest.swift index 08b178d5808..08ac22eaa5f 100644 --- a/FirebaseVertexAI/Sources/GenerativeAIRequest.swift +++ b/FirebaseVertexAI/Sources/GenerativeAIRequest.swift @@ -28,17 +28,18 @@ protocol GenerativeAIRequest: Encodable { public struct RequestOptions { /// The request’s timeout interval in seconds; if not specified uses the default value for a /// `URLRequest`. - let timeout: TimeInterval? + let timeout: TimeInterval /// The API version to use in requests to the backend. + /// + /// TODO(andrewheard): Update to "v1beta" after the Vertex AI in Firebase API launch. let apiVersion = "v2beta" /// Initializes a request options object. /// /// - Parameters: - /// - timeout The request’s timeout interval in seconds; if not specified uses the default value - /// for a `URLRequest`. - public init(timeout: TimeInterval? = nil) { + /// - timeout The request’s timeout interval in seconds; defaults to 180 seconds. + public init(timeout: TimeInterval = 180.0) { self.timeout = timeout } } diff --git a/FirebaseVertexAI/Sources/GenerativeAIService.swift b/FirebaseVertexAI/Sources/GenerativeAIService.swift index 3ebbf69f102..0e2e39169ea 100644 --- a/FirebaseVertexAI/Sources/GenerativeAIService.swift +++ b/FirebaseVertexAI/Sources/GenerativeAIService.swift @@ -16,6 +16,7 @@ import FirebaseAppCheckInterop import FirebaseAuthInterop import FirebaseCore import Foundation +import os.log @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) struct GenerativeAIService { @@ -60,9 +61,15 @@ struct GenerativeAIService { // Verify the status code is 200 guard response.statusCode == 200 else { - Logging.network.error("[FirebaseVertexAI] The server responded with an error: \(response)") + VertexLog.error( + code: .loadRequestResponseError, + "The server responded with an error: \(response)" + ) if let responseString = String(data: data, encoding: .utf8) { - Logging.default.error("[FirebaseVertexAI] Response payload: \(responseString)") + VertexLog.error( + code: .loadRequestResponseErrorPayload, + "Response payload: \(responseString)" + ) } throw parseError(responseData: data) @@ -108,14 +115,19 @@ struct GenerativeAIService { // Verify the status code is 200 guard response.statusCode == 200 else { - Logging.network - .error("[FirebaseVertexAI] The server responded with an error: \(response)") + VertexLog.error( + code: .loadRequestStreamResponseError, + "The server responded with an error: \(response)" + ) var responseBody = "" for try await line in stream.lines { responseBody += line + "\n" } - Logging.default.error("[FirebaseVertexAI] Response payload: \(responseBody)") + VertexLog.error( + code: .loadRequestStreamResponseErrorPayload, + "Response payload: \(responseBody)" + ) continuation.finish(throwing: parseError(responseBody: responseBody)) return @@ -127,7 +139,7 @@ struct GenerativeAIService { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase for try await line in stream.lines { - Logging.network.debug("[FirebaseVertexAI] Stream response: \(line)") + VertexLog.debug(code: .loadRequestStreamResponseLine, "Stream response: \(line)") if line.hasPrefix("data:") { // We can assume 5 characters since it's utf-8 encoded, removing `data:`. @@ -179,8 +191,10 @@ struct GenerativeAIService { let tokenResult = await appCheck.getToken(forcingRefresh: false) urlRequest.setValue(tokenResult.token, forHTTPHeaderField: "X-Firebase-AppCheck") if let error = tokenResult.error { - Logging.default - .debug("[FirebaseVertexAI] Failed to fetch AppCheck token. Error: \(error)") + VertexLog.error( + code: .appCheckTokenFetchFailed, + "Failed to fetch AppCheck token. Error: \(error)" + ) } } @@ -191,10 +205,7 @@ struct GenerativeAIService { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase urlRequest.httpBody = try encoder.encode(request) - - if let timeoutInterval = request.options.timeout { - urlRequest.timeoutInterval = timeoutInterval - } + urlRequest.timeoutInterval = request.options.timeout return urlRequest } @@ -202,10 +213,10 @@ struct GenerativeAIService { private func httpResponse(urlResponse: URLResponse) throws -> HTTPURLResponse { // Verify the status code is 200 guard let response = urlResponse as? HTTPURLResponse else { - Logging.default - .error( - "[FirebaseVertexAI] Response wasn't an HTTP response, internal error \(urlResponse)" - ) + VertexLog.error( + code: .generativeAIServiceNonHTTPResponse, + "Response wasn't an HTTP response, internal error \(urlResponse)" + ) throw NSError( domain: "com.google.generative-ai", code: -1, @@ -252,8 +263,9 @@ struct GenerativeAIService { // Log specific RPC errors that cannot be mitigated or handled by user code. // These errors do not produce specific GenerateContentError or CountTokensError cases. private func logRPCError(_ error: RPCError) { + // TODO(andrewheard): Remove this check after the Vertex AI in Firebase API launch. if error.isFirebaseMLServiceDisabledError() { - Logging.default.error(""" + VertexLog.error(code: .vertexAIInFirebaseAPIDisabled, """ The Vertex AI for Firebase SDK requires the Firebase ML API `firebaseml.googleapis.com` to \ be enabled for your project. Get started in the Firebase Console \ (https://console.firebase.google.com/project/\(projectID)/genai/vertex) or verify that the \ @@ -262,6 +274,15 @@ struct GenerativeAIService { \(projectID)). """) } + + if error.isVertexAIInFirebaseServiceDisabledError() { + VertexLog.error(code: .vertexAIInFirebaseAPIDisabled, """ + The Vertex AI for Firebase SDK requires the Firebase Vertex AI API \ + `firebasevertexai.googleapis.com` to be enabled for your project. Get started by visiting \ + the Firebase Console at: \ + https://console.firebase.google.com/project/\(projectID)/genai/vertex + """) + } } private func parseResponse(_ type: T.Type, from data: Data) throws -> T { @@ -269,9 +290,12 @@ struct GenerativeAIService { return try JSONDecoder().decode(type, from: data) } catch { if let json = String(data: data, encoding: .utf8) { - Logging.network.error("[FirebaseVertexAI] JSON response: \(json)") + VertexLog.error(code: .loadRequestParseResponseFailedJSON, "JSON response: \(json)") } - Logging.default.error("[FirebaseVertexAI] Error decoding server JSON: \(error)") + VertexLog.error( + code: .loadRequestParseResponseFailedJSONError, + "Error decoding server JSON: \(error)" + ) throw error } } @@ -297,9 +321,12 @@ struct GenerativeAIService { } private func printCURLCommand(from request: URLRequest) { + guard VertexLog.additionalLoggingEnabled() else { + return + } let command = cURLCommand(from: request) - Logging.verbose.debug(""" - [FirebaseVertexAI] Creating request with the equivalent cURL command: + os_log(.debug, log: VertexLog.logObject, """ + \(VertexLog.service) Creating request with the equivalent cURL command: ----- cURL command ----- \(command, privacy: .private) ------------------------ diff --git a/FirebaseVertexAI/Sources/GenerativeModel.swift b/FirebaseVertexAI/Sources/GenerativeModel.swift index 28d3ca4ba88..a5a8933e435 100644 --- a/FirebaseVertexAI/Sources/GenerativeModel.swift +++ b/FirebaseVertexAI/Sources/GenerativeModel.swift @@ -85,23 +85,15 @@ public final class GenerativeModel { self.systemInstruction = systemInstruction self.requestOptions = requestOptions - if Logging.additionalLoggingEnabled() { - if ProcessInfo.processInfo.arguments.contains(Logging.migrationEnableArgumentKey) { - Logging.verbose.debug(""" - [FirebaseVertexAI] Verbose logging enabled with the \ - \(Logging.migrationEnableArgumentKey, privacy: .public) launch argument; please migrate to \ - the \(Logging.enableArgumentKey, privacy: .public) argument to ensure future compatibility. - """) - } else { - Logging.verbose.debug("[FirebaseVertexAI] Verbose logging enabled.") - } + if VertexLog.additionalLoggingEnabled() { + VertexLog.debug(code: .verboseLoggingEnabled, "Verbose logging enabled.") } else { - Logging.default.info(""" + VertexLog.info(code: .verboseLoggingDisabled, """ [FirebaseVertexAI] To enable additional logging, add \ - `\(Logging.enableArgumentKey, privacy: .public)` as a launch argument in Xcode. + `\(VertexLog.enableArgumentKey)` as a launch argument in Xcode. """) } - Logging.default.debug("[FirebaseVertexAI] Model \(name, privacy: .public) initialized.") + VertexLog.debug(code: .generativeModelInitialized, "Model \(name) initialized.") } /// Generates content from String and/or image inputs, given to the model as a prompt, that are @@ -277,16 +269,12 @@ public final class GenerativeModel { /// invalid. public func countTokens(_ content: @autoclosure () throws -> [ModelContent]) async throws -> CountTokensResponse { - do { - let countTokensRequest = try CountTokensRequest( - model: modelResourceName, - contents: content(), - options: requestOptions - ) - return try await generativeAIService.loadRequest(request: countTokensRequest) - } catch { - throw CountTokensError.internalError(underlying: error) - } + let countTokensRequest = try CountTokensRequest( + model: modelResourceName, + contents: content(), + options: requestOptions + ) + return try await generativeAIService.loadRequest(request: countTokensRequest) } /// Returns a `GenerateContentError` (for public consumption) from an internal error. @@ -299,9 +287,3 @@ public final class GenerativeModel { return GenerateContentError.internalError(underlying: error) } } - -/// An error thrown in `GenerativeModel.countTokens(_:)`. -@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -public enum CountTokensError: Error { - case internalError(underlying: Error) -} diff --git a/FirebaseVertexAI/Sources/Logging.swift b/FirebaseVertexAI/Sources/Logging.swift deleted file mode 100644 index 5806ac2368a..00000000000 --- a/FirebaseVertexAI/Sources/Logging.swift +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2023 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 Foundation -import OSLog - -@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -struct Logging { - /// Subsystem that should be used for all Loggers. - static let subsystem = "com.google.firebase.vertex-ai" - - /// Default category used for most loggers, unless specialized. - static let defaultCategory = "" - - /// The argument required to enable additional logging. - static let enableArgumentKey = "-FIRDebugEnabled" - - /// The argument required to enable additional logging in the Google AI SDK; used for migration. - /// - /// To facilitate migration between the SDKs, this launch argument is also accepted to enable - /// additional logging at this time, though it is expected to be removed in the future. - static let migrationEnableArgumentKey = "-GoogleGenerativeAIDebugLogEnabled" - - // No initializer available. - @available(*, unavailable) - private init() {} - - /// The default logger that is visible for all users. Note: we shouldn't be using anything lower - /// than `.notice`. - static let `default` = Logger(subsystem: subsystem, category: defaultCategory) - - /// A non default - static let network: Logger = { - if additionalLoggingEnabled() { - return Logger(subsystem: subsystem, category: "NetworkResponse") - } else { - // Return a valid logger that's using `OSLog.disabled` as the logger, hiding everything. - return Logger(.disabled) - } - }() - - /// - static let verbose: Logger = { - if additionalLoggingEnabled() { - return Logger(subsystem: subsystem, category: defaultCategory) - } else { - // Return a valid logger that's using `OSLog.disabled` as the logger, hiding everything. - return Logger(.disabled) - } - }() - - /// Returns `true` if additional logging has been enabled via a launch argument. - static func additionalLoggingEnabled() -> Bool { - let arguments = ProcessInfo.processInfo.arguments - if arguments.contains(enableArgumentKey) || arguments.contains(migrationEnableArgumentKey) { - return true - } - return false - } -} diff --git a/FirebaseVertexAI/Sources/ModelContent.swift b/FirebaseVertexAI/Sources/ModelContent.swift index 3262a4eba15..f5699a600fb 100644 --- a/FirebaseVertexAI/Sources/ModelContent.swift +++ b/FirebaseVertexAI/Sources/ModelContent.swift @@ -26,7 +26,7 @@ public struct ModelContent: Equatable, Sendable { case text(String) /// Data with a specified media type. Not all media types may be supported by the AI model. - case data(mimetype: String, Data) + case inlineData(mimetype: String, Data) /// File data stored in Cloud Storage for Firebase, referenced by URI. /// @@ -53,12 +53,12 @@ public struct ModelContent: Equatable, Sendable { /// Convenience function for populating a Part with JPEG data. public static func jpeg(_ data: Data) -> Self { - return .data(mimetype: "image/jpeg", data) + return .inlineData(mimetype: "image/jpeg", data) } /// Convenience function for populating a Part with PNG data. public static func png(_ data: Data) -> Self { - return .data(mimetype: "image/png", data) + return .inlineData(mimetype: "image/png", data) } /// Returns the text contents of this ``Part``, if it contains text. @@ -144,7 +144,7 @@ extension ModelContent.Part: Codable { switch self { case let .text(a0): try container.encode(a0, forKey: .text) - case let .data(mimetype, bytes): + case let .inlineData(mimetype, bytes): var inlineDataContainer = container.nestedContainer( keyedBy: InlineDataKeys.self, forKey: .inlineData @@ -176,7 +176,7 @@ extension ModelContent.Part: Codable { ) let mimetype = try dataContainer.decode(String.self, forKey: .mimeType) let bytes = try dataContainer.decode(Data.self, forKey: .bytes) - self = .data(mimetype: mimetype, bytes) + self = .inlineData(mimetype: mimetype, bytes) } else if values.contains(.functionCall) { self = try .functionCall(values.decode(FunctionCall.self, forKey: .functionCall)) } else if values.contains(.functionResponse) { diff --git a/FirebaseVertexAI/Sources/PartsRepresentable+Image.swift b/FirebaseVertexAI/Sources/PartsRepresentable+Image.swift index 1991503a224..6b2cc977889 100644 --- a/FirebaseVertexAI/Sources/PartsRepresentable+Image.swift +++ b/FirebaseVertexAI/Sources/PartsRepresentable+Image.swift @@ -24,29 +24,15 @@ private let imageCompressionQuality: CGFloat = 0.8 /// An enum describing failures that can occur when converting image types to model content data. /// For some image types like `CIImage`, creating valid model content requires creating a JPEG /// representation of the image that may not yet exist, which may be computationally expensive. -public enum ImageConversionError: Error { - /// The image that could not be converted. - public enum SourceImage { - #if canImport(UIKit) - case uiImage(UIImage) - #elseif canImport(AppKit) - case nsImage(NSImage) - #endif // canImport(UIKit) - case cgImage(CGImage) - #if canImport(CoreImage) - case ciImage(CIImage) - #endif // canImport(CoreImage) - } - +enum ImageConversionError: Error { /// The image (the receiver of the call `toModelContentParts()`) was invalid. case invalidUnderlyingImage /// A valid image destination could not be allocated. case couldNotAllocateDestination - /// JPEG image data conversion failed, accompanied by the original image, which may be an - /// instance of `NSImage`, `UIImage`, `CGImage`, or `CIImage`. - case couldNotConvertToJPEG(SourceImage) + /// JPEG image data conversion failed. + case couldNotConvertToJPEG } #if canImport(UIKit) @@ -55,9 +41,9 @@ public enum ImageConversionError: Error { extension UIImage: ThrowingPartsRepresentable { public func tryPartsValue() throws -> [ModelContent.Part] { guard let data = jpegData(compressionQuality: imageCompressionQuality) else { - throw ImageConversionError.couldNotConvertToJPEG(.uiImage(self)) + throw ImageConversionError.couldNotConvertToJPEG } - return [ModelContent.Part.data(mimetype: "image/jpeg", data)] + return [ModelContent.Part.inlineData(mimetype: "image/jpeg", data)] } } @@ -72,9 +58,9 @@ public enum ImageConversionError: Error { let bmp = NSBitmapImageRep(cgImage: cgImage) guard let data = bmp.representation(using: .jpeg, properties: [.compressionFactor: 0.8]) else { - throw ImageConversionError.couldNotConvertToJPEG(.nsImage(self)) + throw ImageConversionError.couldNotConvertToJPEG } - return [ModelContent.Part.data(mimetype: "image/jpeg", data)] + return [ModelContent.Part.inlineData(mimetype: "image/jpeg", data)] } } #endif @@ -95,9 +81,9 @@ public enum ImageConversionError: Error { kCGImageDestinationLossyCompressionQuality: imageCompressionQuality, ] as CFDictionary) if CGImageDestinationFinalize(imageDestination) { - return [.data(mimetype: "image/jpeg", output as Data)] + return [.inlineData(mimetype: "image/jpeg", output as Data)] } - throw ImageConversionError.couldNotConvertToJPEG(.cgImage(self)) + throw ImageConversionError.couldNotConvertToJPEG } } #endif // !os(watchOS) @@ -116,9 +102,9 @@ public enum ImageConversionError: Error { context.jpegRepresentation(of: self, colorSpace: $0, options: [:]) } if let jpegData = jpegData { - return [.data(mimetype: "image/jpeg", jpegData)] + return [.inlineData(mimetype: "image/jpeg", jpegData)] } - throw ImageConversionError.couldNotConvertToJPEG(.ciImage(self)) + throw ImageConversionError.couldNotConvertToJPEG } } #endif // canImport(CoreImage) diff --git a/FirebaseVertexAI/Sources/Safety.swift b/FirebaseVertexAI/Sources/Safety.swift index 6ad99bbb5e5..a57900e7317 100644 --- a/FirebaseVertexAI/Sources/Safety.swift +++ b/FirebaseVertexAI/Sources/Safety.swift @@ -19,18 +19,21 @@ import Foundation /// responses that exceed a certain threshold. @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct SafetyRating: Equatable, Hashable, Sendable { - /// The category describing the potential harm a piece of content may pose. See - /// ``SafetySetting/HarmCategory`` for a list of possible values. - public let category: SafetySetting.HarmCategory - - /// The model-generated probability that a given piece of content falls under the harm category - /// described in ``SafetySetting/HarmCategory``. This does not indicate the severity of harm for a - /// piece of content. See ``HarmProbability`` for a list of possible values. + /// The category describing the potential harm a piece of content may pose. + /// + /// See ``HarmCategory`` for a list of possible values. + public let category: HarmCategory + + /// The model-generated probability that the content falls under the specified harm ``category``. + /// + /// See ``HarmProbability`` for a list of possible values. + /// + /// > Important: This does not indicate the severity of harm for a piece of content. public let probability: HarmProbability /// Initializes a new `SafetyRating` instance with the given category and probability. /// Use this initializer for SwiftUI previews or tests. - public init(category: SafetySetting.HarmCategory, probability: HarmProbability) { + public init(category: HarmCategory, probability: HarmProbability) { self.category = category self.probability = probability } @@ -41,9 +44,6 @@ public struct SafetyRating: Equatable, Hashable, Sendable { /// Unknown. A new server value that isn't recognized by the SDK. case unknown = "UNKNOWN" - /// The probability was not specified in the server response. - case unspecified = "HARM_PROBABILITY_UNSPECIFIED" - /// The probability is zero or close to zero. For benign content, the probability across all /// categories will be this value. case negligible = "NEGLIGIBLE" @@ -63,30 +63,8 @@ public struct SafetyRating: Equatable, Hashable, Sendable { /// fallback response instead of generated content. @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct SafetySetting { - /// A type describing safety attributes, which include harmful categories and topics that can - /// be considered sensitive. - public enum HarmCategory: String, Sendable { - /// Unknown. A new server value that isn't recognized by the SDK. - case unknown = "HARM_CATEGORY_UNKNOWN" - - /// Unspecified by the server. - case unspecified = "HARM_CATEGORY_UNSPECIFIED" - - /// Harassment content. - case harassment = "HARM_CATEGORY_HARASSMENT" - - /// Negative or harmful comments targeting identity and/or protected attributes. - case hateSpeech = "HARM_CATEGORY_HATE_SPEECH" - - /// Contains references to sexual acts or other lewd content. - case sexuallyExplicit = "HARM_CATEGORY_SEXUALLY_EXPLICIT" - - /// Promotes or enables access to harmful goods, services, or activities. - case dangerousContent = "HARM_CATEGORY_DANGEROUS_CONTENT" - } - /// Block at and beyond a specified ``SafetyRating/HarmProbability``. - public enum BlockThreshold: String, Sendable { + public enum HarmBlockThreshold: String, Sendable { // Content with `.negligible` will be allowed. case blockLowAndAbove = "BLOCK_LOW_AND_ABOVE" @@ -109,15 +87,33 @@ public struct SafetySetting { public let harmCategory: HarmCategory /// The threshold describing what content should be blocked. - public let threshold: BlockThreshold + public let threshold: HarmBlockThreshold /// Initializes a new safety setting with the given category and threshold. - public init(harmCategory: HarmCategory, threshold: BlockThreshold) { + public init(harmCategory: HarmCategory, threshold: HarmBlockThreshold) { self.harmCategory = harmCategory self.threshold = threshold } } +/// Categories describing the potential harm a piece of content may pose. +public enum HarmCategory: String, Sendable { + /// Unknown. A new server value that isn't recognized by the SDK. + case unknown = "HARM_CATEGORY_UNKNOWN" + + /// Harassment content. + case harassment = "HARM_CATEGORY_HARASSMENT" + + /// Negative or harmful comments targeting identity and/or protected attributes. + case hateSpeech = "HARM_CATEGORY_HATE_SPEECH" + + /// Contains references to sexual acts or other lewd content. + case sexuallyExplicit = "HARM_CATEGORY_SEXUALLY_EXPLICIT" + + /// Promotes or enables access to harmful goods, services, or activities. + case dangerousContent = "HARM_CATEGORY_DANGEROUS_CONTENT" +} + // MARK: - Codable Conformances @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) @@ -125,8 +121,10 @@ extension SafetyRating.HarmProbability: Decodable { public init(from decoder: Decoder) throws { let value = try decoder.singleValueContainer().decode(String.self) guard let decodedProbability = SafetyRating.HarmProbability(rawValue: value) else { - Logging.default - .error("[FirebaseVertexAI] Unrecognized HarmProbability with value \"\(value)\".") + VertexLog.error( + code: .generateContentResponseUnrecognizedHarmProbability, + "Unrecognized HarmProbability with value \"\(value)\"." + ) self = .unknown return } @@ -139,12 +137,14 @@ extension SafetyRating.HarmProbability: Decodable { extension SafetyRating: Decodable {} @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension SafetySetting.HarmCategory: Codable { +extension HarmCategory: Codable { public init(from decoder: Decoder) throws { let value = try decoder.singleValueContainer().decode(String.self) - guard let decodedCategory = SafetySetting.HarmCategory(rawValue: value) else { - Logging.default - .error("[FirebaseVertexAI] Unrecognized HarmCategory with value \"\(value)\".") + guard let decodedCategory = HarmCategory(rawValue: value) else { + VertexLog.error( + code: .generateContentResponseUnrecognizedHarmCategory, + "Unrecognized HarmCategory with value \"\(value)\"." + ) self = .unknown return } @@ -154,7 +154,7 @@ extension SafetySetting.HarmCategory: Codable { } @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension SafetySetting.BlockThreshold: Encodable {} +extension SafetySetting.HarmBlockThreshold: Encodable {} @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension SafetySetting: Encodable {} diff --git a/FirebaseVertexAI/Sources/Schema.swift b/FirebaseVertexAI/Sources/Schema.swift index 6ffadfd9025..f24a4d203b3 100644 --- a/FirebaseVertexAI/Sources/Schema.swift +++ b/FirebaseVertexAI/Sources/Schema.swift @@ -19,29 +19,45 @@ import Foundation /// These types can be objects, but also primitives and arrays. Represents a select subset of an /// [OpenAPI 3.0 schema object](https://spec.openapis.org/oas/v3.0.3#schema). public class Schema { + /// Modifiers describing the expected format of a string `Schema`. + public enum StringFormat { + /// A custom string format. + case custom(String) + } + + /// Modifiers describing the expected format of an integer `Schema`. + public enum IntegerFormat { + /// A 32-bit signed integer. + case int32 + /// A 64-bit signed integer. + case int64 + /// A custom integer format. + case custom(String) + } + /// The data type. - let type: DataType + public let type: DataType /// The format of the data. - let format: String? + public let format: String? /// A brief description of the parameter. - let description: String? + public let description: String? /// Indicates if the value may be null. - let nullable: Bool? + public let nullable: Bool? /// Possible values of the element of type ``DataType/string`` with "enum" format. - let enumValues: [String]? + public let enumValues: [String]? /// Schema of the elements of type ``DataType/array``. - let items: Schema? + public let items: Schema? /// Properties of type ``DataType/object``. - let properties: [String: Schema]? + public let properties: [String: Schema]? /// Required properties of type ``DataType/object``. - let requiredProperties: [String]? + public let requiredProperties: [String]? /// Constructs a new `Schema`. /// @@ -59,11 +75,29 @@ public class Schema { /// - items: Schema of the elements of type ``DataType/array``. /// - properties: Properties of type ``DataType/object``. /// - requiredProperties: Required properties of type ``DataType/object``. - public init(type: DataType, format: String? = nil, description: String? = nil, - nullable: Bool? = nil, - enumValues: [String]? = nil, items: Schema? = nil, - properties: [String: Schema]? = nil, - requiredProperties: [String]? = nil) { + @available(*, deprecated, message: """ + Use static methods `string(description:format:nullable:)`, `number(description:format:nullable:)`, + etc., instead. + """) + public convenience init(type: DataType, format: String? = nil, description: String? = nil, + nullable: Bool? = nil, enumValues: [String]? = nil, items: Schema? = nil, + properties: [String: Schema]? = nil, + requiredProperties: [String]? = nil) { + self.init( + type: type, + format: format, + description: description, + nullable: nullable ?? false, + enumValues: enumValues, + items: items, + properties: properties, + requiredProperties: requiredProperties + ) + } + + required init(type: DataType, format: String? = nil, description: String? = nil, + nullable: Bool = false, enumValues: [String]? = nil, items: Schema? = nil, + properties: [String: Schema]? = nil, requiredProperties: [String]? = nil) { self.type = type self.format = format self.description = description @@ -73,6 +107,228 @@ public class Schema { self.properties = properties self.requiredProperties = requiredProperties } + + /// Returns a `Schema` representing a string value. + /// + /// This schema instructs the model to produce data of type ``DataType/string``, which is suitable + /// for decoding into a Swift `String` (or `String?`, if `nullable` is set to `true`). + /// + /// > Tip: If a specific set of string values should be generated by the model (for example, + /// > "north", "south", "east", or "west"), use ``enumeration(values:description:nullable:)`` + /// > instead to constrain the generated values. + /// + /// - Parameters: + /// - description: An optional description of what the string should contain or represent; may + /// use Markdown format. + /// - nullable: If `true`, instructs the model that it *may* generate `null` instead of a + /// string; defaults to `false`, enforcing that a string value is generated. + /// - format: An optional modifier describing the expected format of the string. Currently no + /// formats are officially supported for strings but custom values may be specified using + /// ``StringFormat/custom(_:)``, for example `.custom("email")` or `.custom("byte")`; these + /// provide additional hints for how the model should respond but are not guaranteed to be + /// adhered to. + public static func string(description: String? = nil, nullable: Bool = false, + format: StringFormat? = nil) -> Schema { + return self.init( + type: .string, + format: format?.rawValue, + description: description, + nullable: nullable + ) + } + + /// Returns a `Schema` representing an enumeration of string values. + /// + /// This schema instructs the model to produce data of type ``DataType/string`` with the + /// `format` `"enum"`. This data is suitable for decoding into a Swift `String` (or `String?`, + /// if `nullable` is set to `true`), or an `enum` with strings as raw values. + /// + /// **Example:** + /// The values `["north", "south", "east", "west"]` for an enumation of directions. + /// ``` + /// enum Direction: String, Decodable { + /// case north, south, east, west + /// } + /// ``` + /// + /// - Parameters: + /// - values: The list of string values that may be generated by the model. + /// - description: An optional description of what the `values` contain or represent; may use + /// Markdown format. + /// - nullable: If `true`, instructs the model that it *may* generate `null` instead of one of + /// the strings specified in `values`; defaults to `false`, enforcing that one of the string + /// values is generated. + public static func enumeration(values: [String], description: String? = nil, + nullable: Bool = false) -> Schema { + return self.init( + type: .string, + format: "enum", + description: description, + nullable: nullable, + enumValues: values + ) + } + + /// Returns a `Schema` representing a single-precision floating-point number. + /// + /// This schema instructs the model to produce data of type ``DataType/number`` with the + /// `format` `"float"`, which is suitable for decoding into a Swift `Float` (or `Float?`, if + /// `nullable` is set to `true`). + /// + /// > Important: This `Schema` provides a hint to the model that it should generate a + /// > single-precision floating-point number, a `float`, but only guarantees that the value will + /// > be a number. + /// + /// - Parameters: + /// - description: An optional description of what the number should contain or represent; may + /// use Markdown format. + /// - nullable: If `true`, instructs the model that it may generate `null` instead of a number; + /// defaults to `false`, enforcing that a number is generated. + public static func float(description: String? = nil, nullable: Bool = false) -> Schema { + return self.init( + type: .number, + format: "float", + description: description, + nullable: nullable + ) + } + + /// Returns a `Schema` representing a double-precision floating-point number. + /// + /// This schema instructs the model to produce data of type ``DataType/number`` with the + /// `format` `"double"`, which is suitable for decoding into a Swift `Double` (or `Double?`, if + /// `nullable` is set to `true`). + /// + /// > Important: This `Schema` provides a hint to the model that it should generate a + /// > double-precision floating-point number, a `double`, but only guarantees that the value will + /// > be a number. + /// + /// - Parameters: + /// - description: An optional description of what the number should contain or represent; may + /// use Markdown format. + /// - nullable: If `true`, instructs the model that it may return `null` instead of a number; + /// defaults to `false`, enforcing that a number is returned. + public static func double(description: String? = nil, nullable: Bool = false) -> Schema { + return self.init( + type: .number, + format: "double", + description: description, + nullable: nullable + ) + } + + /// Returns a `Schema` representing an integer value. + /// + /// This schema instructs the model to produce data of type ``DataType/integer``, which is + /// suitable for decoding into a Swift `Int` (or `Int?`, if `nullable` is set to `true`) or other + /// integer types (such as `Int32`) based on the expected size of values being generated. + /// + /// > Important: If a `format` of ``IntegerFormat/int32`` or ``IntegerFormat/int64`` is + /// > specified, this provides a hint to the model that it should generate 32-bit or 64-bit + /// > integers but this `Schema` only guarantees that the value will be an integer. Therefore, it + /// > is *possible* that decoding into an `Int32` could overflow even if a `format` of + /// > ``IntegerFormat/int32`` is specified. + /// + /// - Parameters: + /// - description: An optional description of what the integer should contain or represent; may + /// use Markdown format. + /// - nullable: If `true`, instructs the model that it may return `null` instead of an integer; + /// defaults to `false`, enforcing that an integer is returned. + /// - format: An optional modifier describing the expected format of the integer. Currently the + /// formats ``IntegerFormat/int32`` and ``IntegerFormat/int64`` are supported; custom values + /// may be specified using ``IntegerFormat/custom(_:)`` but may be ignored by the model. + public static func integer(description: String? = nil, nullable: Bool = false, + format: IntegerFormat? = nil) -> Schema { + return self.init( + type: .integer, + format: format?.rawValue, + description: description, + nullable: nullable + ) + } + + /// Returns a `Schema` representing a boolean value. + /// + /// This schema instructs the model to produce data of type ``DataType/boolean``, which is + /// suitable for decoding into a Swift `Bool` (or `Bool?`, if `nullable` is set to `true`). + /// + /// - Parameters: + /// - description: An optional description of what the boolean should contain or represent; may + /// use Markdown format. + /// - nullable: If `true`, instructs the model that it may return `null` instead of a boolean; + /// defaults to `false`, enforcing that a boolean is returned. + public static func boolean(description: String? = nil, nullable: Bool = false) -> Schema { + return self.init(type: .boolean, description: description, nullable: nullable) + } + + /// Returns a `Schema` representing an array. + /// + /// This schema instructs the model to produce data of type ``DataType/array``, which has elements + /// of any other ``DataType`` (including nested ``DataType/array``s). This data is suitable for + /// decoding into many Swift collection types, including `Array`, holding elements of types + /// suitable for decoding from the respective `items` type. + /// + /// - Parameters: + /// - items: The `Schema` of the elements that the array will hold. + /// - description: An optional description of what the array should contain or represent; may + /// use Markdown format. + /// - nullable: If `true`, instructs the model that it may return `null` instead of an array; + /// defaults to `false`, enforcing that an array is returned. + public static func array(items: Schema, description: String? = nil, + nullable: Bool = false) -> Schema { + return self.init(type: .array, description: description, nullable: nullable, items: items) + } + + /// Returns a `Schema` representing an object. + /// + /// This schema instructs the model to produce data of type ``DataType/object``, which has keys + /// of type ``DataType/string`` and values of any other ``DataType`` (including nested + /// ``DataType/object``s). This data is suitable for decoding into Swift keyed collection types, + /// including `Dictionary`, or other custom `struct` or `class` types. + /// + /// **Example:** A `City` could be represented with the following object `Schema`. + /// ``` + /// Schema.object(properties: [ + /// "name" : .string(), + /// "population": .integer() + /// ]) + /// ``` + /// The generated data could be decoded into a Swift native type: + /// ``` + /// struct City: Decodable { + /// let name: String + /// let population: Int + /// } + /// ``` + /// + /// - Parameters: + /// - properties: A dictionary containing the object's property names as keys and their + /// respective `Schema`s as values. + /// - optionalProperties: A list of property names that may be be omitted in objects generated + /// by the model; these names must correspond to the keys provided in the `properties` + /// dictionary and may be an empty list. + /// - description: An optional description of what the object should contain or represent; may + /// use Markdown format. + /// - nullable: If `true`, instructs the model that it may return `null` instead of an object; + /// defaults to `false`, enforcing that an object is returned. + public static func object(properties: [String: Schema], optionalProperties: [String] = [], + description: String? = nil, nullable: Bool = false) -> Schema { + var requiredProperties = Set(properties.keys) + for optionalProperty in optionalProperties { + guard properties.keys.contains(optionalProperty) else { + fatalError("Optional property \"\(optionalProperty)\" not defined in object properties.") + } + requiredProperties.remove(optionalProperty) + } + + return self.init( + type: .object, + description: description, + nullable: nullable, + properties: properties, + requiredProperties: requiredProperties.sorted() + ) + } } /// A data type. @@ -114,3 +370,45 @@ extension Schema: Encodable { } extension DataType: Encodable {} + +// MARK: - RawRepresentable Conformance + +extension Schema.IntegerFormat: RawRepresentable { + public init?(rawValue: String) { + switch rawValue { + case "int32": + self = .int32 + case "int64": + self = .int64 + default: + self = .custom(rawValue) + } + } + + public var rawValue: String { + switch self { + case .int32: + return "int32" + case .int64: + return "int64" + case let .custom(format): + return format + } + } +} + +extension Schema.StringFormat: RawRepresentable { + public init?(rawValue: String) { + switch rawValue { + default: + self = .custom(rawValue) + } + } + + public var rawValue: String { + switch self { + case let .custom(format): + return format + } + } +} diff --git a/FirebaseVertexAI/Sources/VertexLog.swift b/FirebaseVertexAI/Sources/VertexLog.swift new file mode 100644 index 00000000000..bd400c200c2 --- /dev/null +++ b/FirebaseVertexAI/Sources/VertexLog.swift @@ -0,0 +1,112 @@ +// Copyright 2024 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 Foundation +import os.log + +@_implementationOnly import FirebaseCoreExtension + +enum VertexLog { + /// Log message codes for the Vertex AI SDK + /// + /// These codes should ideally not be re-used in order to facilitate matching error codes in + /// support requests to lines in the SDK. These codes should range between 0 and 999999 to avoid + /// being truncated in log messages. + enum MessageCode: Int { + // Logging Configuration + case verboseLoggingDisabled = 100 + case verboseLoggingEnabled = 101 + + // API Enablement Errors + case vertexAIInFirebaseAPIDisabled = 200 + + // Model Configuration + case generativeModelInitialized = 1000 + + // Network Errors + case generativeAIServiceNonHTTPResponse = 2000 + case loadRequestResponseError = 2001 + case loadRequestResponseErrorPayload = 2002 + case loadRequestStreamResponseError = 2003 + case loadRequestStreamResponseErrorPayload = 2004 + + // Parsing Errors + case loadRequestParseResponseFailedJSON = 3000 + case loadRequestParseResponseFailedJSONError = 3001 + case generateContentResponseUnrecognizedFinishReason = 3002 + case generateContentResponseUnrecognizedBlockReason = 3003 + case generateContentResponseUnrecognizedBlockThreshold = 3004 + case generateContentResponseUnrecognizedHarmProbability = 3005 + case generateContentResponseUnrecognizedHarmCategory = 3006 + + // SDK State Errors + case generateContentResponseNoCandidates = 4000 + case generateContentResponseNoText = 4001 + case appCheckTokenFetchFailed = 4002 + + // SDK Debugging + case loadRequestStreamResponseLine = 5000 + } + + /// Subsystem that should be used for all Loggers. + static let subsystem = "com.google.firebase" + + /// Log identifier for the Vertex AI SDK. + /// + /// > Note: This corresponds to the `category` in `OSLog`. + static let service = "[FirebaseVertexAI]" + + /// The raw `OSLog` log object. + /// + /// > Important: This is only needed for direct `os_log` usage. + static let logObject = OSLog(subsystem: subsystem, category: service) + + /// The argument required to enable additional logging. + static let enableArgumentKey = "-FIRDebugEnabled" + + static func log(level: FirebaseLoggerLevel, code: MessageCode, _ message: String) { + let messageCode = String(format: "I-VTX%06d", code.rawValue) + FirebaseLogger.log( + level: level, + service: VertexLog.service, + code: messageCode, + message: message + ) + } + + static func error(code: MessageCode, _ message: String) { + log(level: .error, code: code, message) + } + + static func warning(code: MessageCode, _ message: String) { + log(level: .warning, code: code, message) + } + + static func notice(code: MessageCode, _ message: String) { + log(level: .notice, code: code, message) + } + + static func info(code: MessageCode, _ message: String) { + log(level: .info, code: code, message) + } + + static func debug(code: MessageCode, _ message: String) { + log(level: .debug, code: code, message) + } + + /// Returns `true` if additional logging has been enabled via a launch argument. + static func additionalLoggingEnabled() -> Bool { + return ProcessInfo.processInfo.arguments.contains(enableArgumentKey) + } +} diff --git a/FirebaseVertexAI/Tests/Unit/GenerationConfigTests.swift b/FirebaseVertexAI/Tests/Unit/GenerationConfigTests.swift index 35450c03758..43cbfe6dd96 100644 --- a/FirebaseVertexAI/Tests/Unit/GenerationConfigTests.swift +++ b/FirebaseVertexAI/Tests/Unit/GenerationConfigTests.swift @@ -57,7 +57,7 @@ final class GenerationConfigTests: XCTestCase { maxOutputTokens: maxOutputTokens, stopSequences: stopSequences, responseMIMEType: responseMIMEType, - responseSchema: Schema(type: .array, items: Schema(type: .string)) + responseSchema: .array(items: .string()) ) let jsonData = try encoder.encode(generationConfig) @@ -70,8 +70,10 @@ final class GenerationConfigTests: XCTestCase { "responseMIMEType" : "\(responseMIMEType)", "responseSchema" : { "items" : { + "nullable" : false, "type" : "STRING" }, + "nullable" : false, "type" : "ARRAY" }, "stopSequences" : [ @@ -89,15 +91,11 @@ final class GenerationConfigTests: XCTestCase { let mimeType = "application/json" let generationConfig = GenerationConfig( responseMIMEType: mimeType, - responseSchema: Schema( - type: .object, - properties: [ - "firstName": Schema(type: .string), - "lastName": Schema(type: .string), - "age": Schema(type: .integer), - ], - requiredProperties: ["firstName", "lastName", "age"] - ) + responseSchema: .object(properties: [ + "firstName": .string(), + "lastName": .string(), + "age": .integer(), + ]) ) let jsonData = try encoder.encode(generationConfig) @@ -107,21 +105,25 @@ final class GenerationConfigTests: XCTestCase { { "responseMIMEType" : "\(mimeType)", "responseSchema" : { + "nullable" : false, "properties" : { "age" : { + "nullable" : false, "type" : "INTEGER" }, "firstName" : { + "nullable" : false, "type" : "STRING" }, "lastName" : { + "nullable" : false, "type" : "STRING" } }, "required" : [ + "age", "firstName", - "lastName", - "age" + "lastName" ], "type" : "OBJECT" } diff --git a/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift index dc76123d028..6956160b072 100644 --- a/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift +++ b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift @@ -113,20 +113,20 @@ final class GenerativeModelTests: XCTestCase { XCTAssertEqual(candidate.content.parts.count, 1) XCTAssertEqual(response.text, "Some information cited from an external source") let citationMetadata = try XCTUnwrap(candidate.citationMetadata) - XCTAssertEqual(citationMetadata.citationSources.count, 3) - let citationSource1 = try XCTUnwrap(citationMetadata.citationSources[0]) + XCTAssertEqual(citationMetadata.citations.count, 3) + let citationSource1 = try XCTUnwrap(citationMetadata.citations[0]) XCTAssertEqual(citationSource1.uri, "https://www.example.com/some-citation-1") XCTAssertEqual(citationSource1.startIndex, 0) XCTAssertEqual(citationSource1.endIndex, 128) XCTAssertNil(citationSource1.title) XCTAssertNil(citationSource1.license) - let citationSource2 = try XCTUnwrap(citationMetadata.citationSources[1]) + let citationSource2 = try XCTUnwrap(citationMetadata.citations[1]) XCTAssertEqual(citationSource2.title, "some-citation-2") XCTAssertEqual(citationSource2.startIndex, 130) XCTAssertEqual(citationSource2.endIndex, 265) XCTAssertNil(citationSource2.uri) XCTAssertNil(citationSource2.license) - let citationSource3 = try XCTUnwrap(citationMetadata.citationSources[2]) + let citationSource3 = try XCTUnwrap(citationMetadata.citations[2]) XCTAssertEqual(citationSource3.uri, "https://www.example.com/some-citation-3") XCTAssertEqual(citationSource3.startIndex, 272) XCTAssertEqual(citationSource3.endIndex, 431) @@ -453,6 +453,7 @@ final class GenerativeModelTests: XCTestCase { } } + // TODO(andrewheard): Remove this test case after the Vertex AI in Firebase API launch. func testGenerateContent_failure_firebaseMLAPINotEnabled() async throws { let expectedStatusCode = 403 MockURLProtocol @@ -476,6 +477,30 @@ final class GenerativeModelTests: XCTestCase { } } + func testGenerateContent_failure_firebaseVertexAIAPINotEnabled() async throws { + let expectedStatusCode = 403 + MockURLProtocol + .requestHandler = try httpRequestHandler( + forResource: "unary-failure-firebasevertexai-api-not-enabled", + withExtension: "json", + statusCode: expectedStatusCode + ) + + do { + _ = try await model.generateContent(testPrompt) + XCTFail("Should throw GenerateContentError.internalError; no error thrown.") + } catch let GenerateContentError.internalError(error as RPCError) { + XCTAssertEqual(error.httpResponseCode, expectedStatusCode) + XCTAssertEqual(error.status, .permissionDenied) + XCTAssertTrue(error.message + .starts(with: "Vertex AI in Firebase API has not been used in project")) + XCTAssertTrue(error.isVertexAIInFirebaseServiceDisabledError()) + return + } catch { + XCTFail("Should throw GenerateContentError.internalError(RPCError); error thrown: \(error)") + } + } + func testGenerateContent_failure_emptyContent() async throws { MockURLProtocol .requestHandler = try httpRequestHandler( @@ -774,6 +799,7 @@ final class GenerativeModelTests: XCTestCase { XCTFail("Should have caught an error.") } + // TODO(andrewheard): Remove this test case after the Vertex AI in Firebase API launch. func testGenerateContentStream_failure_firebaseMLAPINotEnabled() async throws { let expectedStatusCode = 403 MockURLProtocol @@ -799,6 +825,32 @@ final class GenerativeModelTests: XCTestCase { XCTFail("Should have caught an error.") } + func testGenerateContentStream_failure_vertexAIInFirebaseAPINotEnabled() async throws { + let expectedStatusCode = 403 + MockURLProtocol + .requestHandler = try httpRequestHandler( + forResource: "unary-failure-firebasevertexai-api-not-enabled", + withExtension: "json", + statusCode: expectedStatusCode + ) + + do { + let stream = try model.generateContentStream(testPrompt) + for try await _ in stream { + XCTFail("No content is there, this shouldn't happen.") + } + } catch let GenerateContentError.internalError(error as RPCError) { + XCTAssertEqual(error.httpResponseCode, expectedStatusCode) + XCTAssertEqual(error.status, .permissionDenied) + XCTAssertTrue(error.message + .starts(with: "Vertex AI in Firebase API has not been used in project")) + XCTAssertTrue(error.isVertexAIInFirebaseServiceDisabledError()) + return + } + + XCTFail("Should have caught an error.") + } + func testGenerateContentStream_failureEmptyContent() async throws { MockURLProtocol .requestHandler = try httpRequestHandler( @@ -947,7 +999,7 @@ final class GenerativeModelTests: XCTestCase { responses.append(content) XCTAssertNotNil(content.text) let candidate = try XCTUnwrap(content.candidates.first) - if let sources = candidate.citationMetadata?.citationSources { + if let sources = candidate.citationMetadata?.citations { citations.append(contentsOf: sources) } } @@ -1188,13 +1240,13 @@ final class GenerativeModelTests: XCTestCase { withExtension: "json" ) - let response = try await model.countTokens(ModelContent.Part.data( + let response = try await model.countTokens(ModelContent.Part.inlineData( mimetype: "image/jpeg", Data() )) XCTAssertEqual(response.totalTokens, 258) - XCTAssertEqual(response.totalBillableCharacters, 0) + XCTAssertNil(response.totalBillableCharacters) } func testCountTokens_modelNotFound() async throws { @@ -1206,7 +1258,7 @@ final class GenerativeModelTests: XCTestCase { do { _ = try await model.countTokens("Why is the sky blue?") XCTFail("Request should not have succeeded.") - } catch let CountTokensError.internalError(rpcError as RPCError) { + } catch let rpcError as RPCError { XCTAssertEqual(rpcError.httpResponseCode, 404) XCTAssertEqual(rpcError.status, .notFound) XCTAssert(rpcError.message.hasPrefix("models/test-model-name is not found")) @@ -1267,7 +1319,7 @@ final class GenerativeModelTests: XCTestCase { private func httpRequestHandler(forResource name: String, withExtension ext: String, statusCode: Int = 200, - timeout: TimeInterval = URLRequest.defaultTimeoutInterval(), + timeout: TimeInterval = RequestOptions().timeout, appCheckToken: String? = nil, authToken: String? = nil) throws -> ((URLRequest) throws -> ( URLResponse, @@ -1316,14 +1368,6 @@ private extension String { } } -private extension URLRequest { - /// Returns the default `timeoutInterval` for a `URLRequest`. - static func defaultTimeoutInterval() -> TimeInterval { - let placeholderURL = URL(string: "https://example.com")! - return URLRequest(url: placeholderURL).timeoutInterval - } -} - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) class AppCheckInteropFake: NSObject, AppCheckInterop { /// The placeholder token value returned when an error occurs diff --git a/FirebaseVertexAI/Tests/Unit/PartsRepresentableTests.swift b/FirebaseVertexAI/Tests/Unit/PartsRepresentableTests.swift index bd539d825a8..073f6582721 100644 --- a/FirebaseVertexAI/Tests/Unit/PartsRepresentableTests.swift +++ b/FirebaseVertexAI/Tests/Unit/PartsRepresentableTests.swift @@ -13,7 +13,6 @@ // limitations under the License. import CoreGraphics -import FirebaseVertexAI import XCTest #if canImport(UIKit) import UIKit @@ -24,6 +23,8 @@ import XCTest import CoreImage #endif // canImport(CoreImage) +@testable import FirebaseVertexAI + @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) final class PartsRepresentableTests: XCTestCase { #if !os(watchOS) @@ -61,22 +62,13 @@ final class PartsRepresentableTests: XCTestCase { do { _ = try image.tryPartsValue() XCTFail("Expected model content from invalid image to error") - } catch { - guard let imageError = (error as? ImageConversionError) else { - XCTFail("Got unexpected error type: \(error)") - return - } - switch imageError { - case let .couldNotConvertToJPEG(source): - guard case let .ciImage(ciImage) = source else { - XCTFail("Unexpected image source: \(source)") - return - } - XCTAssertEqual(ciImage, image) - default: - XCTFail("Expected image conversion error, got \(imageError) instead") + } catch let imageError as ImageConversionError { + guard case .couldNotConvertToJPEG = imageError else { + XCTFail("Expected JPEG conversion error, got \(imageError) instead.") return } + } catch { + XCTFail("Got unexpected error type: \(error)") } } #endif // canImport(CoreImage) @@ -87,22 +79,13 @@ final class PartsRepresentableTests: XCTestCase { do { _ = try image.tryPartsValue() XCTFail("Expected model content from invalid image to error") - } catch { - guard let imageError = (error as? ImageConversionError) else { - XCTFail("Got unexpected error type: \(error)") - return - } - switch imageError { - case let .couldNotConvertToJPEG(source): - guard case let .uiImage(uiImage) = source else { - XCTFail("Unexpected image source: \(source)") - return - } - XCTAssertEqual(uiImage, image) - default: - XCTFail("Expected image conversion error, got \(imageError) instead") + } catch let imageError as ImageConversionError { + guard case .couldNotConvertToJPEG = imageError else { + XCTFail("Expected JPEG conversion error, got \(imageError) instead.") return } + } catch { + XCTFail("Got unexpected error type: \(error)") } } diff --git a/FirebaseVertexAI/Tests/Unit/VertexAIAPITests.swift b/FirebaseVertexAI/Tests/Unit/VertexAIAPITests.swift index c68b69b03ec..1c469867f76 100644 --- a/FirebaseVertexAI/Tests/Unit/VertexAIAPITests.swift +++ b/FirebaseVertexAI/Tests/Unit/VertexAIAPITests.swift @@ -123,7 +123,7 @@ final class VertexAIAPITests: XCTestCase { // convert value of type 'String' to expected element type // 'Array.ArrayLiteralElement'. Not sure if there's a way we can get it to // work. - let _ = try ModelContent(parts: [str, ModelContent.Part.data( + let _ = try ModelContent(parts: [str, ModelContent.Part.inlineData( mimetype: "foo", Data() )] as [any ThrowingPartsRepresentable])