Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions api/.env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
USE_MOCK=true
GEMINI_API_KEY=your-gemini-key-here
GOOGLE_CLOUD_PROJECT=b2-beacon1
GOOGLE_CLOUD_LOCATION=us-east1
RATE_LIMIT=5/minute;60/day
# Comma-separated Origin allowlist. Leave empty in dev to accept everything.
# In production set it to the published extension origin, e.g.:
# ALLOWED_EXTENSION_ORIGINS=chrome-extension://abcdefghijklmnopabcdefghijklmnop
ALLOWED_EXTENSION_ORIGINS=
# GEMINI_MODEL=gemini-2.5-flash-lite
# FORWARDED_ALLOW_IPS=<proxy IP/CIDR — required behind a reverse proxy>
# FORWARDED_ALLOW_IPS=<proxy IP/CIDR — required behind a reverse proxy>
4 changes: 3 additions & 1 deletion api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ EXPOSE 3000
# --proxy-headers makes uvicorn trust X-Forwarded-For from the proxy in front
# of it, so per-client rate limiting keys on the real client IP instead of the
# proxy's. Set FORWARDED_ALLOW_IPS to the proxy's IP/CIDR in production.
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3000", "--proxy-headers"]
# Shell-form CMD so ${PORT} expands: Cloud Run injects PORT and expects the
# container to listen on it; locally PORT is unset and we default to 3000.
CMD uvicorn main:app --host 0.0.0.0 --port ${PORT:-3000} --proxy-headers
Comment on lines +15 to +17
8 changes: 4 additions & 4 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ Nothing is ever inverted on the wire.

| Variable | Required | Description |
|---|---|---|
| `USE_MOCK` | No (default: `true`) | Skip Gemini and use a deterministic mock. No API key needed. |
| `GEMINI_API_KEY` | Only if `USE_MOCK=false` | Gemini API key for live LLM analysis. Server-side only — never ships to clients. |
| `USE_MOCK` | No (default: `true`) | Skip Gemini and use a deterministic mock. No credentials needed. |
| `GOOGLE_CLOUD_PROJECT` | Only if `USE_MOCK=false` | GCP project for Vertex AI (`b2-beacon1`). Auth is via ADC / the runtime service account — no API key. |
| `GOOGLE_CLOUD_LOCATION` | No (default: `us-east1`) | Vertex AI region. |
| `GEMINI_MODEL` | No (default: `gemini-2.5-flash-lite`) | Gemini model name. Override to migrate to a newer model without a code change. |
Comment on lines +32 to 35
| `ALLOWED_EXTENSION_ORIGINS` | Recommended in production | Comma-separated `Origin` allowlist, e.g. `chrome-extension://<id>`. Also used as the CORS allowlist. Empty = accept all origins (dev; a warning is logged). |
| `RATE_LIMIT` | No (default: `5/minute;60/day`) | Per-IP limits, `;`-separated (e.g. `10/minute;100/day`). |
Expand All @@ -42,5 +43,4 @@ Nothing is ever inverted on the wire.

- `POST /v1/analyze` — Tier 2 analysis. Request/response schemas in `schemas.py`.
- `GET /health` — liveness probe, no auth, no rate limit.

> **Tip:** Keep `USE_MOCK=true` while developing. Switch to `USE_MOCK=false` only when you need a real Gemini response.
```
66 changes: 66 additions & 0 deletions api/cloudbuild.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Cloud Build pipeline: build the Tier-2 API image, push it to Artifact
# Registry, and deploy it to Cloud Run on b2-beacon1.
#
# The Cloud Run service runs AS beacon-app-service, so Vertex AI calls
# authenticate through that service account automatically — no API key, no
# mounted key file. Config is injected via --set-env-vars (secrets in .env are
# excluded from the image by .dockerignore).
#
# Submit from the api/ directory so the build context is this folder:
# gcloud builds submit --config cloudbuild.yaml \
# --substitutions=_ALLOWED_ORIGINS=chrome-extension://<published-id>
#
# Prerequisites (grant once, out of band):
# - roles/aiplatform.user on beacon-app-service@b2-beacon1 (call Vertex)
# - the Cloud Build SA needs roles/run.admin, roles/artifactregistry.writer,
# and roles/iam.serviceAccountUser on beacon-app-service (deploy as it)
# - an Artifact Registry repo named ${_AR_REPO} must exist in ${_REGION}

substitutions:
_REGION: us-east1
_SERVICE: beacon-api
_AR_REPO: beacon
_RUNTIME_SA: beacon-app-service@b2-beacon1.iam.gserviceaccount.com
_TAG: latest
# Published extension origin, e.g. chrome-extension://<id>. Empty accepts all
# origins (dev only). A single origin — commas would clash with the env-var
# delimiter below.
_ALLOWED_ORIGINS: ""

steps:
# 1. Build the container image.
- name: gcr.io/cloud-builders/docker
args:
- build
- -t
- ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/${_SERVICE}:${_TAG}
- .

Comment on lines +25 to +38
# 2. Push it to Artifact Registry.
- name: gcr.io/cloud-builders/docker
args:
- push
- ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/${_SERVICE}:${_TAG}

# 3. Deploy to Cloud Run as the beacon-app-service service account.
# Public (--allow-unauthenticated) by design: there is no client secret;
# abuse control is the Origin allowlist + per-IP rate limit (see auth.py).
- name: gcr.io/google.com/cloudsdktool/cloud-sdk
entrypoint: gcloud
args:
- run
- deploy
- ${_SERVICE}
- --image=${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/${_SERVICE}:${_TAG}
- --platform=managed
- --region=${_REGION}
- --service-account=${_RUNTIME_SA}
- --allow-unauthenticated
- --port=3000
- --set-env-vars=USE_MOCK=false,GOOGLE_CLOUD_PROJECT=${PROJECT_ID},GOOGLE_CLOUD_LOCATION=${_REGION},GEMINI_MODEL=gemini-2.5-flash-lite,ALLOWED_EXTENSION_ORIGINS=${_ALLOWED_ORIGINS},FORWARDED_ALLOW_IPS=*
Comment on lines +58 to +60

images:
- ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/${_SERVICE}:${_TAG}

options:
logging: CLOUD_LOGGING_ONLY
5 changes: 3 additions & 2 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ def _validate_config() -> None:
"extension origin (chrome-extension://<id>) before deploying."
)

# Eagerly initialise the provider so a missing GEMINI_API_KEY fails here
# with a clear message rather than producing a 503 on the first request.
# Eagerly initialise the provider so missing Vertex config (e.g.
# GOOGLE_CLOUD_PROJECT) fails here with a clear message rather than
# producing a 503 on the first request.
get_provider()

_validate_config()
Expand Down
17 changes: 13 additions & 4 deletions api/providers/gemini_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,21 @@ def build_prompt(req: AnalyzeRequest) -> str:

class GeminiProvider:
def __init__(self):
api_key = os.getenv("GEMINI_API_KEY", "")
if not api_key:
# b2 does not use API keys — Gemini is reached through Vertex AI, which
# authenticates with Application Default Credentials. Locally that comes
# from `gcloud auth application-default login --impersonate-service-account=
# beacon-app-service@b2-beacon1.iam.gserviceaccount.com`; on Cloud Run it
# is the attached beacon-app-service account automatically. No key is read
# or stored anywhere.
project = os.getenv("GOOGLE_CLOUD_PROJECT", "")
location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-east1")
if not project:
raise ProviderConfigError(
"GEMINI_API_KEY is not set. Set it in api/.env or the environment, or set USE_MOCK=true."
"GOOGLE_CLOUD_PROJECT is not set. Set it in api/.env and run "
"`gcloud auth application-default login --impersonate-service-account="
"beacon-app-service@b2-beacon1.iam.gserviceaccount.com`, or set USE_MOCK=true."
)
self.client = genai.Client(api_key=api_key)
self.client = genai.Client(vertexai=True, project=project, location=location)

async def analyze(self, request: AnalyzeRequest) -> AnalyzeResponse:
response = await self.client.aio.models.generate_content(
Expand Down
2 changes: 1 addition & 1 deletion api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ fastapi>=0.115
uvicorn[standard]>=0.30
python-dotenv>=1.0
slowapi>=0.1.9
google-genai>=1.0
google-genai==2.8.0
17 changes: 17 additions & 0 deletions api/tests/test_provider_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Provider configuration guards.
#
# Constructing the real (Vertex) provider without the required config must fail
# fast with ProviderConfigError — not a network/auth error later — so a
# misconfigured deploy is caught at startup by _validate_config(). No network is
# touched: the guard raises before any Vertex client is built.

import pytest

from providers.gemini_provider import GeminiProvider
from exceptions import ProviderConfigError


def test_missing_project_raises_config_error(monkeypatch):
monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False)
with pytest.raises(ProviderConfigError):
GeminiProvider()