diff --git a/jetpacker/README.md b/jetpacker/README.md index 165abf84..ac8383a1 100644 --- a/jetpacker/README.md +++ b/jetpacker/README.md @@ -68,8 +68,13 @@ This project is built using the standard Android Gradle build system, allowing d - Update `sdk.dir` inside `local.properties` with your local Android SDK directory path. 2. **Firebase Setup (`google-services.json`)**: - - Register the application in your Firebase Project Console. - - Download the project's custom `google-services.json` and place it in the `android/app/` directory (overwriting the mock placeholder file). + - Register an Android app in your **Firebase Project Console** using the package name `com.example.jetpacker`. + - In the Firebase Console, navigate to **Authentication** -> **Sign-in method** and enable **Anonymous** sign-in (used for session-based communication with the booking coordinator and AI services). + - Download your project's `google-services.json` and place it in the `android/app/` directory (replacing the mock placeholder file). The Gradle `google-services` plugin automatically parses this file and configures the required resources at build time—no code changes or extra XML files are required. + - *Git Safety*: To prevent accidentally committing your personal Firebase credentials back to the repository, tell git to ignore local changes to this file: + ```bash + git update-index --skip-worktree android/app/google-services.json + ``` 3. **Firebase App Check Debug Attestation**: - Run the application on an emulator or a connected device. @@ -114,6 +119,69 @@ docker build -t jetpacker-server . docker run -p 8080:8080 jetpacker-server ``` +#### Deploying to Google Cloud Run (Production vs Parallel Staging) + +The booking server can be deployed to Cloud Run behind a Google Cloud API Gateway that validates Firebase Authentication tokens. + +We support side-by-side execution so you can run and test updates on a parallel staging backend without affecting the production service: +- **Production Service (`jetpacker-server`)**: Backs `/` routes on API Gateway (`/stream`, `/respond`, etc.). +- **Parallel Staging Service (`jetpacker-server-v2`)**: Backs `/v2/*` routes on API Gateway (`/v2/stream`, `/v2/respond`, etc.). + +##### Deploying to Parallel Staging (`jetpacker-server-v2`) +1. **Build and submit the image**: + ```bash + gcloud builds submit --tag gcr.io/android-devrel-ci/jetpacker-server:v2 \ + --project=android-devrel-ci server/ + ``` +2. **Deploy to Cloud Run**: + ```bash + gcloud run deploy jetpacker-server-v2 \ + --image gcr.io/android-devrel-ci/jetpacker-server:v2 \ + --region us-central1 \ + --project android-devrel-ci \ + --set-env-vars CLOUD_RUN_AUDIENCE=https://jetpacker-server-v2-254502043090.us-central1.run.app \ + --no-allow-unauthenticated + ``` + +##### Deploying to Production (`jetpacker-server`) +1. **Build and submit the image**: + ```bash + gcloud builds submit --tag gcr.io/android-devrel-ci/jetpacker-server:latest \ + --project=android-devrel-ci server/ + ``` +2. **Deploy to Cloud Run**: + ```bash + gcloud run deploy jetpacker-server \ + --image gcr.io/android-devrel-ci/jetpacker-server:latest \ + --region us-central1 \ + --project android-devrel-ci \ + --set-env-vars CLOUD_RUN_AUDIENCE=https://jetpacker-server-254502043090.us-central1.run.app \ + --no-allow-unauthenticated + ``` + +##### Updating the API Gateway Config +To update API Gateway routes using `server/openapi.yaml`: +```bash +gcloud api-gateway api-configs create jetset-config-v \ + --api=jetset-api \ + --openapi-spec=server/openapi.yaml \ + --project=android-devrel-ci \ + --backend-auth-service-account=api-gateway-invoker@android-devrel-ci.iam.gserviceaccount.com + +gcloud api-gateway gateways update jetset-gateway \ + --api=jetset-api \ + --api-config=jetset-config-v \ + --location=us-central1 \ + --project=android-devrel-ci +``` + +##### Configuring the Android App Endpoint +In `BookingAssistantViewModel.kt`, adjust `backendEndpoint`: +- `BackendEndpoint.PARALLEL_STAGING` *(default)*: Connects to `/v2` on API Gateway (`jetpacker-server-v2`). +- `BackendEndpoint.PRODUCTION`: Connects to `/` on API Gateway (`jetpacker-server`). +- `BackendEndpoint.LOCAL`: Connects to `http://localhost:8000` (via `adb reverse tcp:8000 tcp:8000`). + + ## On-Device AI Features JetPacker integrates local on-device AI capabilities using ML Kit. These features run entirely on-device and can be toggled or customized in `android/core/flags/src/main/kotlin/com/example/jetpacker/core/flags/FeatureFlags.kt`: - **ENABLE_TRIP_SUMMARY_AND_TIPS**: Generates an on-device card summary of the current trip using ML Kit GenAI Prompt. diff --git a/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/JetPackerApplication.kt b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/JetPackerApplication.kt index 0108e18d..09ad16f6 100644 --- a/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/JetPackerApplication.kt +++ b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/JetPackerApplication.kt @@ -36,17 +36,24 @@ class JetPackerApplication : Application() { ComposeUiFlags.isMediaQueryIntegrationEnabled = true super.onCreate() FeatureFlags.initialize(this) - Firebase.initialize(context = this) - Firebase.appCheck.installAppCheckProviderFactory( - DebugAppCheckProviderFactory.getInstance(), - ) - Firebase.auth.signInAnonymously() - .addOnCompleteListener { task -> - if (task.isSuccessful) { - Log.d("JetPackerApplication", "Anonymous auth successful") - } else { - Log.w("JetPackerApplication", "Anonymous auth failed", task.exception) - } + try { + if (Firebase.initialize(context = this) != null) { + Firebase.appCheck.installAppCheckProviderFactory( + DebugAppCheckProviderFactory.getInstance(), + ) + Firebase.auth.signInAnonymously() + .addOnCompleteListener { task -> + if (task.isSuccessful) { + Log.d("JetPackerApplication", "Anonymous auth successful") + } else { + Log.w("JetPackerApplication", "Anonymous auth failed", task.exception) + } + } + } else { + Log.w("JetPackerApplication", "Firebase not initialized") } + } catch (e: Exception) { + Log.w("JetPackerApplication", "Firebase initialization error", e) + } } } diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt index 175df686..e16b2772 100644 --- a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt @@ -24,6 +24,9 @@ import androidx.a2ui.model.protocol.A2uiClientEventMessage import androidx.a2ui.model.protocol.A2uiComponentPayload import androidx.a2ui.model.protocol.A2uiCreateSurfaceMessage import androidx.a2ui.model.protocol.A2uiUpdateComponentsMessage +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -75,8 +78,32 @@ class BookingAssistantViewModel @Inject constructor( private var activeStreamJob: Job? = null private var streamIdleJob: Job? = null - // Set to true when testing against local server (http://localhost:8000) - val useLocalServer: Boolean = false + enum class BackendEndpoint { + LOCAL, + PARALLEL_STAGING, + PRODUCTION, + } + + /** + * Active backend endpoint. Defaults to [BackendEndpoint.PARALLEL_STAGING] to connect to the + * side-by-side /v2 Cloud Run service without impacting production. + */ + var backendEndpoint by mutableStateOf(BackendEndpoint.PARALLEL_STAGING) + + // Backwards compatibility property + var useLocalServer: Boolean + get() = backendEndpoint == BackendEndpoint.LOCAL + set(value) { + backendEndpoint = if (value) BackendEndpoint.LOCAL else BackendEndpoint.PARALLEL_STAGING + } + + val baseUrl: String + get() = + when (backendEndpoint) { + BackendEndpoint.LOCAL -> "http://localhost:8000" + BackendEndpoint.PARALLEL_STAGING -> "https://jetset-gateway-38wzy18y.uc.gateway.dev/v2" + BackendEndpoint.PRODUCTION -> "https://jetset-gateway-38wzy18y.uc.gateway.dev" + } private val client = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS).build() @@ -310,9 +337,9 @@ class BookingAssistantViewModel @Inject constructor( } } - log("Starting connectToStream with sId: $sId, useLocalServer: $useLocalServer") + log("Starting connectToStream with sId: $sId, backendEndpoint: $backendEndpoint") - if (useLocalServer) { + if (backendEndpoint == BackendEndpoint.LOCAL) { streamToken = "local_dev_token" streamUrl = "http://localhost:8000/run_sse" } else { @@ -323,7 +350,7 @@ class BookingAssistantViewModel @Inject constructor( return } - val gatewayUrl = "https://jetset-gateway-38wzy18y.uc.gateway.dev/stream?auth_only=true" + val gatewayUrl = "$baseUrl/stream?auth_only=true" val tokenRequest = Request.Builder() .url(gatewayUrl) .addHeader("Authorization", "Bearer $fbToken") @@ -656,7 +683,6 @@ class BookingAssistantViewModel @Inject constructor( } val token = getFirebaseAuthToken() - val baseUrl = if (useLocalServer) "http://localhost:8000" else "https://jetset-gateway-38wzy18y.uc.gateway.dev" val encodedSessionId = URLEncoder.encode(sId, "UTF-8") val encodedAgentId = URLEncoder.encode(agentId, "UTF-8") val request = Request.Builder() @@ -680,7 +706,13 @@ class BookingAssistantViewModel @Inject constructor( private suspend fun getFirebaseAuthToken(): String { log("getFirebaseAuthToken: getting FirebaseAuth instance") - val auth = FirebaseAuth.getInstance() + val auth = try { + FirebaseAuth.getInstance() + } catch (e: Exception) { + log("FirebaseAuth.getInstance failed: ${e.message}") + Log.e("BookingAssistant", "FirebaseAuth.getInstance failed", e) + return "" + } var user = auth.currentUser log("getFirebaseAuthToken: current user is ${user?.uid ?: "null"}") if (user == null) { diff --git a/jetpacker/server/.dockerignore b/jetpacker/server/.dockerignore new file mode 100644 index 00000000..b5130b6d --- /dev/null +++ b/jetpacker/server/.dockerignore @@ -0,0 +1,8 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +booking/.adk diff --git a/jetpacker/server/booking/__init__.py b/jetpacker/server/booking/__init__.py new file mode 100644 index 00000000..05368a38 --- /dev/null +++ b/jetpacker/server/booking/__init__.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import sys +from typing import Any + + +def __getattr__(name: str) -> Any: + if name == "root_agent": + if "main" in sys.modules: + return sys.modules["main"].ItineraryOrchestratorAgent() + import main + + return main.ItineraryOrchestratorAgent() + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") diff --git a/jetpacker/server/booking/agent.py b/jetpacker/server/booking/agent.py new file mode 100644 index 00000000..05368a38 --- /dev/null +++ b/jetpacker/server/booking/agent.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import sys +from typing import Any + + +def __getattr__(name: str) -> Any: + if name == "root_agent": + if "main" in sys.modules: + return sys.modules["main"].ItineraryOrchestratorAgent() + import main + + return main.ItineraryOrchestratorAgent() + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") diff --git a/jetpacker/server/main.py b/jetpacker/server/main.py index eda91dc7..a9a0a51e 100644 --- a/jetpacker/server/main.py +++ b/jetpacker/server/main.py @@ -1,6 +1,6 @@ import asyncio import json -from typing import AsyncGenerator +from typing import Any, AsyncGenerator import urllib.request import fastapi @@ -13,6 +13,8 @@ from google.adk.events.event_actions import EventActions from google import genai from google.genai import types +import inspect +import os import pydantic import uvicorn @@ -482,20 +484,39 @@ def list_agents(self) -> list[str]: return ["booking"] -app = get_fast_api_app( - agents_dir=".", - agent_loader=BookingAgentLoader(), - web=False, - auto_create_session=True, -) +_adk_sig = inspect.signature(get_fast_api_app) +_adk_kwargs: dict[str, Any] = { + "agents_dir": ".", +} +if "web" in _adk_sig.parameters: + _adk_kwargs["web"] = False +if "agent_loader" in _adk_sig.parameters: + _adk_kwargs["agent_loader"] = BookingAgentLoader() +if "auto_create_session" in _adk_sig.parameters: + _adk_kwargs["auto_create_session"] = True + +print(f"DEBUG: Initializing ADK FastAPI with kwargs: {_adk_kwargs}") +app = get_fast_api_app(**_adk_kwargs) class ResponseData(pydantic.BaseModel): seat: str | None = None time: str | None = None + selectedFlightId: str | None = None + selectedRoomId: str | None = None + nightlyRate: int | None = None + checkInTime: str | None = None + floorPreference: str | None = None + breakfastIncluded: bool | None = None + lateCheckout: bool | None = None tickets: str | None = None people: str | None = None confirmed: bool | None = None + selectionType: str | None = None + priorityBoarding: bool | None = None + extraBag: bool | None = None + totalFare: int | None = None + clientDataModel: dict[str, Any] | None = None @app.post("/respond") @@ -508,7 +529,18 @@ async def respond(session_id: str, data: ResponseData, agent_id: str | None = No # For backward compatibility or default flight use flight_Flight pause_key = agent_id or "flight_Flight" - if data.seat: + if ( + data.clientDataModel is not None + or data.selectionType in ("time", "confirm_flight_config", "hotel_room", "confirm_hotel_config") + or data.selectedFlightId is not None + or data.selectedRoomId is not None + or data.priorityBoarding is not None + or data.extraBag is not None + or data.breakfastIncluded is not None + or data.lateCheckout is not None + ): + session.results[pause_key] = data.model_dump(exclude_none=True) + elif data.seat: session.results[pause_key] = data.seat elif data.time: session.results[pause_key] = data.time @@ -536,7 +568,11 @@ async def respond(session_id: str, data: ResponseData, agent_id: str | None = No @app.get("/stream") def get_stream_token(auth_only: bool = False): """Returns an OIDC token for direct streaming access.""" - audience = "https://jetpacker-server-254502043090.us-central1.run.app" + del auth_only # Unused parameter kept for gateway compatibility + audience = os.environ.get( + "CLOUD_RUN_AUDIENCE", + "https://jetpacker-server-254502043090.us-central1.run.app", + ) url = f"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience={audience}" try: @@ -545,7 +581,8 @@ def get_stream_token(auth_only: bool = False): token = urllib.request.urlopen(req).read().decode("utf-8") return {"token": token, "url": f"{audience}/run_sse"} except Exception as e: - raise fastapi.HTTPException(status_code=500, detail=str(e)) + # If metadata server fails or running unauthenticated/local, provide fallback + return {"token": "dev_stream_token", "url": f"{audience}/run_sse"} if __name__ == "__main__": diff --git a/jetpacker/server/openapi.yaml b/jetpacker/server/openapi.yaml index 15bd37d6..f31c6adb 100644 --- a/jetpacker/server/openapi.yaml +++ b/jetpacker/server/openapi.yaml @@ -59,3 +59,44 @@ paths: responses: '200': description: OK + /v2/orchestrate: + post: + summary: Orchestrate V2 + operationId: orchestrate_v2 + x-google-backend: + address: https://jetpacker-server-v2-254502043090.us-central1.run.app/orchestrate + deadline: 300.0 + responses: + '200': + description: OK + /v2/stream: + get: + summary: Stream V2 + operationId: stream_v2 + x-google-backend: + address: https://jetpacker-server-v2-254502043090.us-central1.run.app/stream + deadline: 300.0 + responses: + '200': + description: OK + /v2/respond: + post: + summary: Respond V2 + operationId: respond_v2 + x-google-backend: + address: https://jetpacker-server-v2-254502043090.us-central1.run.app/respond + deadline: 300.0 + responses: + '200': + description: OK + /v2/status_stream: + post: + summary: Status Stream V2 + operationId: status_stream_v2 + x-google-backend: + address: https://jetpacker-server-v2-254502043090.us-central1.run.app/status_stream + deadline: 300.0 + responses: + '200': + description: OK +