skip to content
~/lab/projects/chat-console.mdactive

chat-console

active

A private streaming chat console on a Cloudflare Worker — provider-swappable between the Anthropic API and SAP AI Core, with the same event stream coming out of either.

year
2026
role
Solo
stack
TypeScriptCloudflare WorkersSAP AI CoreFirebaseAstro

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.