Skip to content

Commit 81f5614

Browse files
jacksonejjackson-entriVinayGuthal
authored
[AI] Attach X-Firebase-AppCheck header on Live API WebSocket handshake (#8063)
Fixes #8060 ## Summary `APIController.getWebSocketSession()` did not call `applyHeaderProvider()`, so the `X-Firebase-AppCheck` header was missing from the WebSocket upgrade request. This broke Gemini Live whenever App Check was enforced on AI Logic, while HTTPS methods on the same class continued to work because they route through either `applyHeaderProvider()` directly or `postStream` (which calls it internally). ## Fix `applyHeaderProvider()` is `suspend` and Ktor's `webSocketSession { }` config lambda is non-suspend, so the fix pre-fetches headers in the outer suspend context and applies them synchronously inside the lambda. ## Test plan - [x] Added unit test `headers from HeaderProvider are added to the WebSocket handshake` mirroring the existing HTTP-path test — passes. - [x] Existing `RequestFormatTests` suite still passes (`./gradlew :ai-logic:firebase-ai:test`). - [x] Code formatted with `./gradlew :ai-logic:firebase-ai:spotlessApply`. - [x] Manual E2E reproduction: built the SDK locally, consumed via `mavenLocal()` in a real app, confirmed `liveModel().connect()` succeeds with App Check enforced on AI Logic. --------- Co-authored-by: Jackson E J <jackson@entri.me> Co-authored-by: Vinay Guthal <vguthal@google.com>
1 parent c1aabba commit 81f5614

3 files changed

Lines changed: 64 additions & 12 deletions

File tree

ai-logic/firebase-ai/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
- [fixed] Fixed an issue causing the SDK to throw an exception if an unknown message was received
2020
from the LiveAPI model, instead of ignoring it (#7975)
2121

22+
- [fixed] Fixed `LiveGenerativeModel.connect()` not attaching the `X-Firebase-AppCheck`
23+
header, causing Live API requests to be rejected when App Check is enforced on AI Logic. (#8060)
24+
2225
# 17.10.1
2326

2427
- [fixed] Fixed an issue causing Live API to fail when using the `GoogleAI` backend (#7880)

ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,19 @@ internal constructor(
255255
"wss://firebasevertexai.googleapis.com/ws/google.firebase.vertexai.v1beta.GenerativeService/BidiGenerateContent?key=$key"
256256
}
257257

258-
suspend fun getWebSocketSession(location: String): DefaultClientWebSocketSession =
259-
client.webSocketSession(getBidiEndpoint(location)) { applyCommonHeaders() }
258+
suspend fun getWebSocketSession(location: String): DefaultClientWebSocketSession {
259+
// applyHeaderProvider() is suspend; Ktor's webSocketSession { } config lambda is not.
260+
// Pre-fetch headers (including X-Firebase-AppCheck) in the outer suspend context using
261+
// the same timeout-protected path as HTTP methods, then set them synchronously inside the
262+
// lambda.
263+
val extraHeaders = extractHeaders(headerProvider)
264+
return client.webSocketSession(getBidiEndpoint(location)) {
265+
applyCommonHeaders()
266+
for ((tag, value) in extraHeaders) {
267+
header(tag, value)
268+
}
269+
}
270+
}
260271

261272
fun generateContentStream(
262273
request: GenerateContentRequest
@@ -306,16 +317,18 @@ internal constructor(
306317
}
307318

308319
private suspend fun HttpRequestBuilder.applyHeaderProvider() {
309-
if (headerProvider != null) {
310-
try {
311-
withTimeout(headerProvider.timeout) {
312-
for ((tag, value) in headerProvider.generateHeaders()) {
313-
header(tag, value)
314-
}
315-
}
316-
} catch (e: TimeoutCancellationException) {
317-
Log.w(TAG, "HeaderProvided timed out without generating headers, ignoring")
318-
}
320+
for ((tag, value) in extractHeaders(headerProvider)) {
321+
header(tag, value)
322+
}
323+
}
324+
325+
private suspend fun extractHeaders(headerProvider: HeaderProvider?): Map<String, String> {
326+
if (headerProvider == null) return emptyMap()
327+
return try {
328+
withTimeout(headerProvider.timeout) { headerProvider.generateHeaders() }
329+
} catch (e: TimeoutCancellationException) {
330+
Log.w(TAG, "HeaderProvided timed out without generating headers, ignoring", e)
331+
emptyMap()
319332
}
320333
}
321334

ai-logic/firebase-ai/src/test/java/com/google/firebase/ai/common/APIControllerTests.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,42 @@ internal class RequestFormatTests {
417417
mockEngine.requestHistory.first().headers.contains("header1") shouldBe false
418418
}
419419

420+
@Test
421+
fun `headers from HeaderProvider are added to the WebSocket handshake`() = doBlocking {
422+
val mockEngine = MockEngine {
423+
// MockEngine isn't designed to complete a WebSocket upgrade handshake, but the
424+
// outgoing request is recorded in requestHistory before the handshake attempt,
425+
// so we can still assert on its headers.
426+
respond("", HttpStatusCode.OK)
427+
}
428+
429+
val testHeaderProvider =
430+
object : HeaderProvider {
431+
override val timeout: Duration
432+
get() = 5.seconds
433+
434+
override suspend fun generateHeaders(): Map<String, String> =
435+
mapOf("X-Firebase-AppCheck" to "test-token")
436+
}
437+
438+
val controller =
439+
APIController(
440+
"super_cool_test_key",
441+
"gemini-pro-2.5",
442+
RequestOptions(),
443+
mockEngine,
444+
TEST_CLIENT_ID,
445+
mockFirebaseApp,
446+
TEST_VERSION,
447+
TEST_APP_ID,
448+
testHeaderProvider,
449+
)
450+
451+
runCatching { withTimeout(5.seconds) { controller.getWebSocketSession("us-central1") } }
452+
453+
mockEngine.requestHistory.first().headers["X-Firebase-AppCheck"] shouldBe "test-token"
454+
}
455+
420456
@Test
421457
fun `code execution tool serialization contains correct keys`() = doBlocking {
422458
val channel = ByteChannel(autoFlush = true)

0 commit comments

Comments
 (0)