I wanted a chat console on my own site rather than a tab in someone else’s, and I wanted it to talk to whichever backend I happened to have credentials for. Those are two different problems: one is a streaming UI, the other is that “Claude” means something structurally different depending on who is serving it.
design
The Worker talks to a Provider interface, selected by an env var. Both
implementations normalise to the same event stream, so the page never learns
which one answered. Swapping backends is a config change and a redeploy, not a
rewrite.
The gate sits in front of anything that costs money. Without it the endpoint is an open model proxy billed to whoever owns the credentials — so the Worker verifies the Firebase ID token properly (signature against Google’s JWKS, then issuer, audience and expiry) before checking the email against an allowlist. Decoding a JWT without verifying its signature would be worthless; anyone can craft one claiming to be you.
what made it work
The SAP path is where the assumptions break. Anthropic models on SAP AI Core
are Bedrock-backed, and the deployment speaks the native Anthropic Messages
format rather than the OpenAI-shaped one. Asking a Claude deployment for
/chat/completions returns:
400 Subpath 'chat/completions' is not allowed for model 'anthropic--claude-4.6-sonnet'
Two subpaths are accepted — /invoke and /invoke-with-response-stream — and
because the second emits Anthropic SSE, the streaming parser is shared with the
direct-Anthropic provider rather than duplicated.
What the adapter has to absorb instead: OAuth2 client credentials rather than a
static key, an AI-Resource-Group header on every request, anthropic_version: "bedrock-2023-05-31" with no model field since the deployment decides that,
and double-dash model ids.
The one that bites later is deployment discovery. The listing hands back a ready deployment URL, and those ids change when SAP updates a model version — hardcoding one is the usual reason a working integration breaks weeks after anyone last touched it. So it is looked up, not stored.
what I’d change
Conversations are kept in Firestore per account, but the Worker trims what it forwards to the last 40 messages so a long session cannot grow without bound. That is a silent ceiling: a conversation can visibly contain more than the model is actually being shown, and nothing on the page says so. Summarising the older turns instead of dropping them is the obvious fix, and it is not written yet.