Skip to content

Commit 05a5426

Browse files
authored
[Vertex AI] Add error message for Firebase ML API not enabled (#13007)
1 parent 6af7e84 commit 05a5426

File tree

8 files changed

+152
-7
lines changed

8 files changed

+152
-7
lines changed

.github/workflows/vertexai.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ jobs:
5151
- os: macos-14
5252
xcode: Xcode_15.2
5353
runs-on: ${{ matrix.os }}
54+
env:
55+
FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT: 1
5456
steps:
5557
- uses: actions/checkout@v4
5658
- name: Xcode

FirebaseVertexAI/Sources/Errors.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ struct RPCError: Error {
3030
self.status = status
3131
self.details = details
3232
}
33+
34+
func isFirebaseMLServiceDisabledError() -> Bool {
35+
return details.contains { $0.isFirebaseMLServiceDisabledErrorDetails() }
36+
}
3337
}
3438

3539
extension RPCError: Decodable {
@@ -76,17 +80,35 @@ struct ErrorDetails {
7680
let type: String
7781
let reason: String?
7882
let domain: String?
83+
let metadata: [String: String]?
7984

8085
func isErrorInfo() -> Bool {
8186
return type == ErrorDetails.errorInfoType
8287
}
88+
89+
func isFirebaseMLServiceDisabledErrorDetails() -> Bool {
90+
guard isErrorInfo() else {
91+
return false
92+
}
93+
guard reason == "SERVICE_DISABLED" else {
94+
return false
95+
}
96+
guard domain == "googleapis.com" else {
97+
return false
98+
}
99+
guard let metadata, metadata["service"] == "firebaseml.googleapis.com" else {
100+
return false
101+
}
102+
return true
103+
}
83104
}
84105

85106
extension ErrorDetails: Decodable, Equatable {
86107
enum CodingKeys: String, CodingKey {
87108
case type = "@type"
88109
case reason
89110
case domain
111+
case metadata
90112
}
91113
}
92114

FirebaseVertexAI/Sources/GenerativeAIService.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ struct GenerativeAIService {
2525
/// The Firebase SDK version in the format `fire/<version>`.
2626
static let firebaseVersionTag = "fire/\(FirebaseVersion())"
2727

28+
private let projectID: String
29+
2830
/// Gives permission to talk to the backend.
2931
private let apiKey: String
3032

@@ -34,7 +36,9 @@ struct GenerativeAIService {
3436

3537
private let urlSession: URLSession
3638

37-
init(apiKey: String, appCheck: AppCheckInterop?, auth: AuthInterop?, urlSession: URLSession) {
39+
init(projectID: String, apiKey: String, appCheck: AppCheckInterop?, auth: AuthInterop?,
40+
urlSession: URLSession) {
41+
self.projectID = projectID
3842
self.apiKey = apiKey
3943
self.appCheck = appCheck
4044
self.auth = auth
@@ -236,13 +240,30 @@ struct GenerativeAIService {
236240

237241
private func parseError(responseData: Data) -> Error {
238242
do {
239-
return try JSONDecoder().decode(RPCError.self, from: responseData)
243+
let rpcError = try JSONDecoder().decode(RPCError.self, from: responseData)
244+
logRPCError(rpcError)
245+
return rpcError
240246
} catch {
241247
// TODO: Return an error about an unrecognized error payload with the response body
242248
return error
243249
}
244250
}
245251

252+
// Log specific RPC errors that cannot be mitigated or handled by user code.
253+
// These errors do not produce specific GenerateContentError or CountTokensError cases.
254+
private func logRPCError(_ error: RPCError) {
255+
if error.isFirebaseMLServiceDisabledError() {
256+
Logging.default.error("""
257+
The Vertex AI for Firebase SDK requires the Firebase ML API `firebaseml.googleapis.com` to \
258+
be enabled for your project. Get started in the Firebase Console \
259+
(https://console.firebase.google.com/project/\(projectID)/genai/vertex) or verify that the \
260+
API is enabled in the Google Cloud Console \
261+
(https://console.developers.google.com/apis/api/firebaseml.googleapis.com/overview?project=\
262+
\(projectID)).
263+
""")
264+
}
265+
}
266+
246267
private func parseResponse<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
247268
do {
248269
return try JSONDecoder().decode(type, from: data)

FirebaseVertexAI/Sources/GenerativeModel.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public final class GenerativeModel {
5151
///
5252
/// - Parameters:
5353
/// - name: The name of the model to use, for example `"gemini-1.0-pro"`.
54+
/// - projectID: The project ID from the Firebase console.
5455
/// - apiKey: The API key for your project.
5556
/// - generationConfig: The content generation parameters your model should use.
5657
/// - safetySettings: A value describing what types of harmful content your model should allow.
@@ -61,6 +62,7 @@ public final class GenerativeModel {
6162
/// - requestOptions: Configuration parameters for sending requests to the backend.
6263
/// - urlSession: The `URLSession` to use for requests; defaults to `URLSession.shared`.
6364
init(name: String,
65+
projectID: String,
6466
apiKey: String,
6567
generationConfig: GenerationConfig? = nil,
6668
safetySettings: [SafetySetting]? = nil,
@@ -73,6 +75,7 @@ public final class GenerativeModel {
7375
urlSession: URLSession = .shared) {
7476
modelResourceName = GenerativeModel.modelResourceName(name: name)
7577
generativeAIService = GenerativeAIService(
78+
projectID: projectID,
7679
apiKey: apiKey,
7780
appCheck: appCheck,
7881
auth: auth,

FirebaseVertexAI/Sources/VertexAI.swift

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,23 @@ public class VertexAI: NSObject {
8383
systemInstruction: ModelContent? = nil,
8484
requestOptions: RequestOptions = RequestOptions())
8585
-> GenerativeModel {
86-
let modelResourceName = modelResourceName(modelName: modelName, location: location)
86+
guard let projectID = app.options.projectID else {
87+
fatalError("The Firebase app named \"\(app.name)\" has no project ID in its configuration.")
88+
}
89+
90+
let modelResourceName = modelResourceName(
91+
modelName: modelName,
92+
projectID: projectID,
93+
location: location
94+
)
8795

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

92100
return GenerativeModel(
93101
name: modelResourceName,
102+
projectID: projectID,
94103
apiKey: apiKey,
95104
generationConfig: generationConfig,
96105
safetySettings: safetySettings,
@@ -121,10 +130,7 @@ public class VertexAI: NSObject {
121130
auth = ComponentType<AuthInterop>.instance(for: AuthInterop.self, in: app.container)
122131
}
123132

124-
private func modelResourceName(modelName: String, location: String) -> String {
125-
guard let projectID = app.options.projectID else {
126-
fatalError("The Firebase app named \"\(app.name)\" has no project ID in its configuration.")
127-
}
133+
private func modelResourceName(modelName: String, projectID: String, location: String) -> String {
128134
guard !modelName.isEmpty && modelName
129135
.allSatisfy({ !$0.isWhitespace && !$0.isNewline && $0 != "/" }) else {
130136
fatalError("""

FirebaseVertexAI/Tests/Unit/ChatTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ final class ChatTests: XCTestCase {
4949

5050
let model = GenerativeModel(
5151
name: "my-model",
52+
projectID: "my-project-id",
5253
apiKey: "API_KEY",
5354
tools: nil,
5455
requestOptions: RequestOptions(),
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"error": {
3+
"code": 403,
4+
"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.",
5+
"status": "PERMISSION_DENIED",
6+
"details": [
7+
{
8+
"@type": "type.googleapis.com/google.rpc.Help",
9+
"links": [
10+
{
11+
"description": "Google developers console API activation",
12+
"url": "https://console.developers.google.com/apis/api/firebaseml.googleapis.com/overview?project=1234567890"
13+
}
14+
]
15+
},
16+
{
17+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
18+
"reason": "SERVICE_DISABLED",
19+
"domain": "googleapis.com",
20+
"metadata": {
21+
"service": "firebaseml.googleapis.com",
22+
"consumer": "projects/1234567890"
23+
}
24+
}
25+
]
26+
}
27+
}

FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ final class GenerativeModelTests: XCTestCase {
3838
urlSession = try XCTUnwrap(URLSession(configuration: configuration))
3939
model = GenerativeModel(
4040
name: "my-model",
41+
projectID: "my-project-id",
4142
apiKey: "API_KEY",
4243
tools: nil,
4344
requestOptions: RequestOptions(),
@@ -180,6 +181,7 @@ final class GenerativeModelTests: XCTestCase {
180181
let model = GenerativeModel(
181182
// Model name is prefixed with "models/".
182183
name: "models/test-model",
184+
projectID: "my-project-id",
183185
apiKey: "API_KEY",
184186
tools: nil,
185187
requestOptions: RequestOptions(),
@@ -299,6 +301,7 @@ final class GenerativeModelTests: XCTestCase {
299301
let appCheckToken = "test-valid-token"
300302
model = GenerativeModel(
301303
name: "my-model",
304+
projectID: "my-project-id",
302305
apiKey: "API_KEY",
303306
tools: nil,
304307
requestOptions: RequestOptions(),
@@ -319,6 +322,7 @@ final class GenerativeModelTests: XCTestCase {
319322
func testGenerateContent_appCheck_tokenRefreshError() async throws {
320323
model = GenerativeModel(
321324
name: "my-model",
325+
projectID: "my-project-id",
322326
apiKey: "API_KEY",
323327
tools: nil,
324328
requestOptions: RequestOptions(),
@@ -340,6 +344,7 @@ final class GenerativeModelTests: XCTestCase {
340344
let authToken = "test-valid-token"
341345
model = GenerativeModel(
342346
name: "my-model",
347+
projectID: "my-project-id",
343348
apiKey: "API_KEY",
344349
tools: nil,
345350
requestOptions: RequestOptions(),
@@ -360,6 +365,7 @@ final class GenerativeModelTests: XCTestCase {
360365
func testGenerateContent_auth_nilAuthToken() async throws {
361366
model = GenerativeModel(
362367
name: "my-model",
368+
projectID: "my-project-id",
363369
apiKey: "API_KEY",
364370
tools: nil,
365371
requestOptions: RequestOptions(),
@@ -380,6 +386,7 @@ final class GenerativeModelTests: XCTestCase {
380386
func testGenerateContent_auth_authTokenRefreshError() async throws {
381387
model = GenerativeModel(
382388
name: "my-model",
389+
projectID: "my-project-id",
383390
apiKey: "API_KEY",
384391
tools: nil,
385392
requestOptions: RequestOptions(),
@@ -441,6 +448,29 @@ final class GenerativeModelTests: XCTestCase {
441448
}
442449
}
443450

451+
func testGenerateContent_failure_firebaseMLAPINotEnabled() async throws {
452+
let expectedStatusCode = 403
453+
MockURLProtocol
454+
.requestHandler = try httpRequestHandler(
455+
forResource: "unary-failure-firebaseml-api-not-enabled",
456+
withExtension: "json",
457+
statusCode: expectedStatusCode
458+
)
459+
460+
do {
461+
_ = try await model.generateContent(testPrompt)
462+
XCTFail("Should throw GenerateContentError.internalError; no error thrown.")
463+
} catch let GenerateContentError.internalError(error as RPCError) {
464+
XCTAssertEqual(error.httpResponseCode, expectedStatusCode)
465+
XCTAssertEqual(error.status, .permissionDenied)
466+
XCTAssertTrue(error.message.starts(with: "Firebase ML API has not been used in project"))
467+
XCTAssertTrue(error.isFirebaseMLServiceDisabledError())
468+
return
469+
} catch {
470+
XCTFail("Should throw GenerateContentError.internalError(RPCError); error thrown: \(error)")
471+
}
472+
}
473+
444474
func testGenerateContent_failure_emptyContent() async throws {
445475
MockURLProtocol
446476
.requestHandler = try httpRequestHandler(
@@ -701,6 +731,7 @@ final class GenerativeModelTests: XCTestCase {
701731
let requestOptions = RequestOptions(timeout: expectedTimeout)
702732
model = GenerativeModel(
703733
name: "my-model",
734+
projectID: "my-project-id",
704735
apiKey: "API_KEY",
705736
tools: nil,
706737
requestOptions: requestOptions,
@@ -738,6 +769,31 @@ final class GenerativeModelTests: XCTestCase {
738769
XCTFail("Should have caught an error.")
739770
}
740771

772+
func testGenerateContentStream_failure_firebaseMLAPINotEnabled() async throws {
773+
let expectedStatusCode = 403
774+
MockURLProtocol
775+
.requestHandler = try httpRequestHandler(
776+
forResource: "unary-failure-firebaseml-api-not-enabled",
777+
withExtension: "json",
778+
statusCode: expectedStatusCode
779+
)
780+
781+
do {
782+
let stream = model.generateContentStream(testPrompt)
783+
for try await _ in stream {
784+
XCTFail("No content is there, this shouldn't happen.")
785+
}
786+
} catch let GenerateContentError.internalError(error as RPCError) {
787+
XCTAssertEqual(error.httpResponseCode, expectedStatusCode)
788+
XCTAssertEqual(error.status, .permissionDenied)
789+
XCTAssertTrue(error.message.starts(with: "Firebase ML API has not been used in project"))
790+
XCTAssertTrue(error.isFirebaseMLServiceDisabledError())
791+
return
792+
}
793+
794+
XCTFail("Should have caught an error.")
795+
}
796+
741797
func testGenerateContentStream_failureEmptyContent() async throws {
742798
MockURLProtocol
743799
.requestHandler = try httpRequestHandler(
@@ -912,6 +968,7 @@ final class GenerativeModelTests: XCTestCase {
912968
let appCheckToken = "test-valid-token"
913969
model = GenerativeModel(
914970
name: "my-model",
971+
projectID: "my-project-id",
915972
apiKey: "API_KEY",
916973
tools: nil,
917974
requestOptions: RequestOptions(),
@@ -933,6 +990,7 @@ final class GenerativeModelTests: XCTestCase {
933990
func testGenerateContentStream_appCheck_tokenRefreshError() async throws {
934991
model = GenerativeModel(
935992
name: "my-model",
993+
projectID: "my-project-id",
936994
apiKey: "API_KEY",
937995
tools: nil,
938996
requestOptions: RequestOptions(),
@@ -1078,6 +1136,7 @@ final class GenerativeModelTests: XCTestCase {
10781136
let requestOptions = RequestOptions(timeout: expectedTimeout)
10791137
model = GenerativeModel(
10801138
name: "my-model",
1139+
projectID: "my-project-id",
10811140
apiKey: "API_KEY",
10821141
tools: nil,
10831142
requestOptions: requestOptions,
@@ -1155,6 +1214,7 @@ final class GenerativeModelTests: XCTestCase {
11551214
let requestOptions = RequestOptions(timeout: expectedTimeout)
11561215
model = GenerativeModel(
11571216
name: "my-model",
1217+
projectID: "my-project-id",
11581218
apiKey: "API_KEY",
11591219
tools: nil,
11601220
requestOptions: requestOptions,
@@ -1176,6 +1236,7 @@ final class GenerativeModelTests: XCTestCase {
11761236

11771237
model = GenerativeModel(
11781238
name: modelName,
1239+
projectID: "my-project-id",
11791240
apiKey: "API_KEY",
11801241
tools: nil,
11811242
requestOptions: RequestOptions(),
@@ -1191,6 +1252,7 @@ final class GenerativeModelTests: XCTestCase {
11911252

11921253
model = GenerativeModel(
11931254
name: modelResourceName,
1255+
projectID: "my-project-id",
11941256
apiKey: "API_KEY",
11951257
tools: nil,
11961258
requestOptions: RequestOptions(),
@@ -1206,6 +1268,7 @@ final class GenerativeModelTests: XCTestCase {
12061268

12071269
model = GenerativeModel(
12081270
name: tunedModelResourceName,
1271+
projectID: "my-project-id",
12091272
apiKey: "API_KEY",
12101273
tools: nil,
12111274
requestOptions: RequestOptions(),

0 commit comments

Comments
 (0)