Skip to content

Commit

Permalink
[Vertex AI] Add error message for Firebase ML API not enabled (fireba…
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewheard authored May 21, 2024
1 parent 6af7e84 commit 05a5426
Show file tree
Hide file tree
Showing 8 changed files with 152 additions and 7 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/vertexai.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ jobs:
- os: macos-14
xcode: Xcode_15.2
runs-on: ${{ matrix.os }}
env:
FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT: 1
steps:
- uses: actions/checkout@v4
- name: Xcode
Expand Down
22 changes: 22 additions & 0 deletions FirebaseVertexAI/Sources/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ struct RPCError: Error {
self.status = status
self.details = details
}

func isFirebaseMLServiceDisabledError() -> Bool {
return details.contains { $0.isFirebaseMLServiceDisabledErrorDetails() }
}
}

extension RPCError: Decodable {
Expand Down Expand Up @@ -76,17 +80,35 @@ struct ErrorDetails {
let type: String
let reason: String?
let domain: String?
let metadata: [String: String]?

func isErrorInfo() -> Bool {
return type == ErrorDetails.errorInfoType
}

func isFirebaseMLServiceDisabledErrorDetails() -> Bool {
guard isErrorInfo() else {
return false
}
guard reason == "SERVICE_DISABLED" else {
return false
}
guard domain == "googleapis.com" else {
return false
}
guard let metadata, metadata["service"] == "firebaseml.googleapis.com" else {
return false
}
return true
}
}

extension ErrorDetails: Decodable, Equatable {
enum CodingKeys: String, CodingKey {
case type = "@type"
case reason
case domain
case metadata
}
}

Expand Down
25 changes: 23 additions & 2 deletions FirebaseVertexAI/Sources/GenerativeAIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ struct GenerativeAIService {
/// The Firebase SDK version in the format `fire/<version>`.
static let firebaseVersionTag = "fire/\(FirebaseVersion())"

private let projectID: String

/// Gives permission to talk to the backend.
private let apiKey: String

Expand All @@ -34,7 +36,9 @@ struct GenerativeAIService {

private let urlSession: URLSession

init(apiKey: String, appCheck: AppCheckInterop?, auth: AuthInterop?, urlSession: URLSession) {
init(projectID: String, apiKey: String, appCheck: AppCheckInterop?, auth: AuthInterop?,
urlSession: URLSession) {
self.projectID = projectID
self.apiKey = apiKey
self.appCheck = appCheck
self.auth = auth
Expand Down Expand Up @@ -236,13 +240,30 @@ struct GenerativeAIService {

private func parseError(responseData: Data) -> Error {
do {
return try JSONDecoder().decode(RPCError.self, from: responseData)
let rpcError = try JSONDecoder().decode(RPCError.self, from: responseData)
logRPCError(rpcError)
return rpcError
} catch {
// TODO: Return an error about an unrecognized error payload with the response body
return error
}
}

// 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) {
if error.isFirebaseMLServiceDisabledError() {
Logging.default.error("""
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 \
API is enabled in the Google Cloud Console \
(https://console.developers.google.com/apis/api/firebaseml.googleapis.com/overview?project=\
\(projectID)).
""")
}
}

private func parseResponse<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
do {
return try JSONDecoder().decode(type, from: data)
Expand Down
3 changes: 3 additions & 0 deletions FirebaseVertexAI/Sources/GenerativeModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public final class GenerativeModel {
///
/// - Parameters:
/// - name: The name of the model to use, for example `"gemini-1.0-pro"`.
/// - projectID: The project ID from the Firebase console.
/// - apiKey: The API key for your project.
/// - generationConfig: The content generation parameters your model should use.
/// - safetySettings: A value describing what types of harmful content your model should allow.
Expand All @@ -61,6 +62,7 @@ public final class GenerativeModel {
/// - requestOptions: Configuration parameters for sending requests to the backend.
/// - urlSession: The `URLSession` to use for requests; defaults to `URLSession.shared`.
init(name: String,
projectID: String,
apiKey: String,
generationConfig: GenerationConfig? = nil,
safetySettings: [SafetySetting]? = nil,
Expand All @@ -73,6 +75,7 @@ public final class GenerativeModel {
urlSession: URLSession = .shared) {
modelResourceName = GenerativeModel.modelResourceName(name: name)
generativeAIService = GenerativeAIService(
projectID: projectID,
apiKey: apiKey,
appCheck: appCheck,
auth: auth,
Expand Down
16 changes: 11 additions & 5 deletions FirebaseVertexAI/Sources/VertexAI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,23 @@ public class VertexAI: NSObject {
systemInstruction: ModelContent? = nil,
requestOptions: RequestOptions = RequestOptions())
-> GenerativeModel {
let modelResourceName = modelResourceName(modelName: modelName, location: location)
guard let projectID = app.options.projectID else {
fatalError("The Firebase app named \"\(app.name)\" has no project ID in its configuration.")
}

let modelResourceName = modelResourceName(
modelName: modelName,
projectID: projectID,
location: location
)

guard let apiKey = app.options.apiKey else {
fatalError("The Firebase app named \"\(app.name)\" has no API key in its configuration.")
}

return GenerativeModel(
name: modelResourceName,
projectID: projectID,
apiKey: apiKey,
generationConfig: generationConfig,
safetySettings: safetySettings,
Expand Down Expand Up @@ -121,10 +130,7 @@ public class VertexAI: NSObject {
auth = ComponentType<AuthInterop>.instance(for: AuthInterop.self, in: app.container)
}

private func modelResourceName(modelName: String, location: String) -> String {
guard let projectID = app.options.projectID else {
fatalError("The Firebase app named \"\(app.name)\" has no project ID in its configuration.")
}
private func modelResourceName(modelName: String, projectID: String, location: String) -> String {
guard !modelName.isEmpty && modelName
.allSatisfy({ !$0.isWhitespace && !$0.isNewline && $0 != "/" }) else {
fatalError("""
Expand Down
1 change: 1 addition & 0 deletions FirebaseVertexAI/Tests/Unit/ChatTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ final class ChatTests: XCTestCase {

let model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"error": {
"code": 403,
"message": "Firebase ML API has not been used in project 1234567890 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firebaseml.googleapis.com/overview?project=1234567890 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.",
"status": "PERMISSION_DENIED",
"details": [
{
"@type": "type.googleapis.com/google.rpc.Help",
"links": [
{
"description": "Google developers console API activation",
"url": "https://console.developers.google.com/apis/api/firebaseml.googleapis.com/overview?project=1234567890"
}
]
},
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "SERVICE_DISABLED",
"domain": "googleapis.com",
"metadata": {
"service": "firebaseml.googleapis.com",
"consumer": "projects/1234567890"
}
}
]
}
}
63 changes: 63 additions & 0 deletions FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ final class GenerativeModelTests: XCTestCase {
urlSession = try XCTUnwrap(URLSession(configuration: configuration))
model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand Down Expand Up @@ -180,6 +181,7 @@ final class GenerativeModelTests: XCTestCase {
let model = GenerativeModel(
// Model name is prefixed with "models/".
name: "models/test-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand Down Expand Up @@ -299,6 +301,7 @@ final class GenerativeModelTests: XCTestCase {
let appCheckToken = "test-valid-token"
model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand All @@ -319,6 +322,7 @@ final class GenerativeModelTests: XCTestCase {
func testGenerateContent_appCheck_tokenRefreshError() async throws {
model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand All @@ -340,6 +344,7 @@ final class GenerativeModelTests: XCTestCase {
let authToken = "test-valid-token"
model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand All @@ -360,6 +365,7 @@ final class GenerativeModelTests: XCTestCase {
func testGenerateContent_auth_nilAuthToken() async throws {
model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand All @@ -380,6 +386,7 @@ final class GenerativeModelTests: XCTestCase {
func testGenerateContent_auth_authTokenRefreshError() async throws {
model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand Down Expand Up @@ -441,6 +448,29 @@ final class GenerativeModelTests: XCTestCase {
}
}

func testGenerateContent_failure_firebaseMLAPINotEnabled() async throws {
let expectedStatusCode = 403
MockURLProtocol
.requestHandler = try httpRequestHandler(
forResource: "unary-failure-firebaseml-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: "Firebase ML API has not been used in project"))
XCTAssertTrue(error.isFirebaseMLServiceDisabledError())
return
} catch {
XCTFail("Should throw GenerateContentError.internalError(RPCError); error thrown: \(error)")
}
}

func testGenerateContent_failure_emptyContent() async throws {
MockURLProtocol
.requestHandler = try httpRequestHandler(
Expand Down Expand Up @@ -701,6 +731,7 @@ final class GenerativeModelTests: XCTestCase {
let requestOptions = RequestOptions(timeout: expectedTimeout)
model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: requestOptions,
Expand Down Expand Up @@ -738,6 +769,31 @@ final class GenerativeModelTests: XCTestCase {
XCTFail("Should have caught an error.")
}

func testGenerateContentStream_failure_firebaseMLAPINotEnabled() async throws {
let expectedStatusCode = 403
MockURLProtocol
.requestHandler = try httpRequestHandler(
forResource: "unary-failure-firebaseml-api-not-enabled",
withExtension: "json",
statusCode: expectedStatusCode
)

do {
let stream = 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: "Firebase ML API has not been used in project"))
XCTAssertTrue(error.isFirebaseMLServiceDisabledError())
return
}

XCTFail("Should have caught an error.")
}

func testGenerateContentStream_failureEmptyContent() async throws {
MockURLProtocol
.requestHandler = try httpRequestHandler(
Expand Down Expand Up @@ -912,6 +968,7 @@ final class GenerativeModelTests: XCTestCase {
let appCheckToken = "test-valid-token"
model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand All @@ -933,6 +990,7 @@ final class GenerativeModelTests: XCTestCase {
func testGenerateContentStream_appCheck_tokenRefreshError() async throws {
model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand Down Expand Up @@ -1078,6 +1136,7 @@ final class GenerativeModelTests: XCTestCase {
let requestOptions = RequestOptions(timeout: expectedTimeout)
model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: requestOptions,
Expand Down Expand Up @@ -1155,6 +1214,7 @@ final class GenerativeModelTests: XCTestCase {
let requestOptions = RequestOptions(timeout: expectedTimeout)
model = GenerativeModel(
name: "my-model",
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: requestOptions,
Expand All @@ -1176,6 +1236,7 @@ final class GenerativeModelTests: XCTestCase {

model = GenerativeModel(
name: modelName,
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand All @@ -1191,6 +1252,7 @@ final class GenerativeModelTests: XCTestCase {

model = GenerativeModel(
name: modelResourceName,
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand All @@ -1206,6 +1268,7 @@ final class GenerativeModelTests: XCTestCase {

model = GenerativeModel(
name: tunedModelResourceName,
projectID: "my-project-id",
apiKey: "API_KEY",
tools: nil,
requestOptions: RequestOptions(),
Expand Down

0 comments on commit 05a5426

Please sign in to comment.