Unified AI Gateway for App Builders
A platform that combines model routing, tracing, cost analysis and more — so builders like me can drop AI infrastructure into any app instead of rebuilding it every time.
Role
Sole engineer
Architecture, build, operations
Scope
Platform architecture
Protocol translation
Edge runtime and streaming
Developer experience
Telemetry pipeline
Platform infrastructure
Stack
TypeScript on Cloudflare Workers
Postgres 18 via Hyperdrive, ClickHouse
k3s on Hetzner, OpenTofu, CloudNativePG
React console, Astro catalog
Three apps, and the same scaffolding built three times
I shipped three apps this year. Two of them lean on models for the part that makes them worth using.
Every one started with the same checklist, and none of it was product work. Pick a provider and install their SDK. Paste the key into another secret store. Bolt on something to see what the model actually returned, because the logs only tell you it responded. Wire up a harness for tool calls and retries. Build another chat UI. Add cost tracking, always right after an invoice surprised me.
Days of work before a single line of the thing I actually wanted to build.
The tools meant to fix that came with their own tax. Wire up an observability platform, spend an afternoon configuring it, then open the dashboard and still not be sure what I'm looking at. I wanted one number — what a session costs me on average — and getting it meant learning someone else's schema first.
Evals were worse, because mostly I skipped them. A better model ships every few weeks. I had no way to check whether the one running in my app was the right pick for the job it was actually doing. Not better on a leaderboard. Better at this. Every app I own is running a model I picked on day one and never checked again.
Onefold is that scaffolding: built once, reused by every app in the fleet.
AI gateway with tracing and experiments built in
Apps point at one endpoint and speak one dialect. The gateway resolves the model, rewrites the request into whatever that provider actually speaks, streams the answer back untouched, and records what it cost. A thin SDK carries a session and trace id with every call, so a conversation is one object in the console rather than a pile of unrelated requests — and swapping a model becomes an experiment you can measure instead of a guess.
Optimized for scalability at the lowest cost
A scheduled job polls every provider in the catalog on a fixed interval and records uptime, error rate and latency alongside the request events. That's what lets the console call a provider degraded instead of guessing, and what keeps a routing decision defensible after the fact.
Measuring upstream availability sets the bar for our own. Routing reads the catalog on every request, so the control plane has to survive losing a machine or the gateway stops admitting traffic entirely — and a layer that grades its providers on uptime has to hold the same standard.
The managed version of this is RDS Multi-AZ, EKS and a managed stream — roughly four hundred dollars a month to protect a system with no paying users. The same guarantees on Hetzner cost thirty-one. That meant six decisions:
Compute
Three nodes in Falkenstein, Nuremberg and Helsinki, on one private network with a Hetzner load balancer in front.
Orchestration
k3s. Scheduling, rolling deploys and self-healing in a single binary, with Hetzner's cloud controller and CSI driver wired in.
Database Failover
CloudNativePG runs a primary and two replicas, synchronous commit to at least one. Promotion completes in under thirty seconds.
Backups
WAL ships to R2 as it's written. Thirty days of point-in-time recovery. A restore drill runs monthly into a scratch namespace.
Infrastructure as Code
OpenTofu owns the servers, network, firewall rules, load balancer and DNS. State lives in R2. A full rebuild takes twenty minutes.
Edge Degradation
The routing catalog is cached at the edge and served stale on error, so losing every node degrades prices instead of refusing traffic.
ClickHouse stays single-node. Analytics is append-only, off the request path, and rebuildable from the staging table, so replicating it would double the cost to protect data nobody is waiting on. It snapshots to R2 nightly.
Three nodes at €22.65, load balancer at €5.39, volumes at €1.90, R2 for about a euro. Thirty-one a month. Recovery point under a minute, recovery time under five.
Once a month I delete a node from the CLI without warning and watch the cluster pick it up.
What adding a provider actually requires
Providers diverge in four places: the shape of the message list, the names of the parameters, the event protocol on the stream, and the path where the error message sits. A module per vendor encodes those four differences once per vendor, and the modules drift apart as they are maintained — a fix applied to one is a fix missing from the others.
So a provider is not code here. It is a declarative spec the engine executes. The spec names the auth headers with a placeholder where the secret goes, a message style and a stream style by name, a map of parameter rules, and dot paths into the provider's error body.
Parameter rules cover four cases. Rename a field. Wrap a scalar the provider only accepts as an array. Fill a required field from the catalog row when the client omits it. Or hand the value to a named translator when the shape has to change rather than the name.
The engine reads that and does the work. The entire Anthropic provider is nineteen lines and contains no logic.
export const anthropic: Spec = {
dialect: 'anthropic',
auth: { 'x-api-key': '{key}' },
headers: { 'anthropic-version': '2023-06-01' },
messages: 'system-hoisted-blocks',
params: {
max_tokens: { to: 'max_tokens', default: 'maxOutput' },
temperature: { to: 'temperature' },
stop: { to: 'stop_sequences', wrap: 'array' },
tools: { to: 'tools', transform: 'tools-to-anthropic' },
},
stream: { flag: 'stream', style: 'anthropic-events' },
error: { message: 'error.message', type: 'error.type' },
}; Config stops where the behavior is genuinely new. A provider whose message shape and stream protocol match an existing style is a data change: add the spec, add the catalog rows, ship. A provider speaking a protocol nothing else speaks needs a new stream style, which is code in the engine.
So the second provider on a known protocol is close to free and the first on a new one is not. That is the intended property — the cost sits where the novelty is.
What a trace is actually made of
A trace is not one record. A single turn in an app fans out into several model calls — a plan, a tool call, a summarization — and the useful question is almost always about the group rather than the call. So the SDK carries three identifiers: a session id that lives as long as the conversation, a trace id for one logical operation, and a request id per model call. The gateway validates them, bounds their length, and stamps them onto the event it records.
The record itself is split by access pattern. Metadata — the three ids, model, provider, status, finish reason, token counts, latency and cost — is a narrow row of roughly two hundred bytes in ClickHouse. The bodies, meaning the prompt, the response and any tool arguments, go to R2 under a key derived from the request id. The row stores the key and nothing else about the content.
That split follows from how the data gets read. Aggregations scan millions of rows and never open a body. A trace view reads one body at a time, and only when someone expands a call. Keeping bodies in the columnar store would make every scan carry bytes nobody asked for, and object storage costs roughly an order of magnitude less per gigabyte than analytic storage does.
The table is ordered by organization, then session, then time, so every call in a session is contiguous on disk. Rendering a trace is one range scan rather than a search, and it stays one range scan as the table grows.
None of this sits on the request path. Both writes happen after the response has been handed back — the row through the staging table and the minute drain, the body straight to R2 — so a tracing failure costs a log line rather than a call. For a streamed response the row is written when the stream drains, which is why latency measures generation rather than time to first byte, and why a stream that breaks mid-flight still produces a trace instead of a gap.
Cost is stamped on each row at request-time prices, so average cost per session is a sum over a contiguous range. That is the question I could not answer before any of this existed.
Comparing models on traffic you already ran
Model choice in most apps is a decision made once, on the day the feature shipped, and never revisited. New models arrive every few weeks, and the public benchmarks measure general capability, which is not the question anyone actually has. The question is whether a particular model is better at the particular job one app gives it.
The compare screen answers that with traffic that already happened. Pick a trace, or a saved set of them, pick two or three models, and the playground replays the recorded request against each. Output, token counts, latency and cost land side by side.
This is only cheap because of the storage split. The full request body is already in R2, keyed by request id, so a replay reconstructs nothing — it reads the recorded payload and sends it again. It sends it through the same adapter engine the gateway uses in production, so a comparison exercises the identical translation path rather than a separate harness that can drift from it.
Replays are marked at write time and carry a flag through to ClickHouse, so experiment traffic never lands in production cost rollups or latency percentiles. They still bill against the same key and the same balance. A model comparison that hides its own cost is not much of a comparison.
A group of traces can be promoted into a fixed set — twenty representative conversations, say — and rerun whenever a new model ships. The set stays constant, the model changes, and the diff is the only thing left to read.
What the screen does not do is grade. There is no score and no automatic winner: outputs sit next to each other and a person decides. An automated judge is the obvious next step and is not built.
Every piece, in one path
One diagram for the system described above. Solid is the request path. Dashed is everything that happens after the caller already has their answer — the event row, the payload body, the health checks and the drain.
See the next project ↓
Chamber
A task system that keeps the estimate and the actual, and shows you the gap.
Records
Companies
71| Company | Stage | Industry | People | Notes | Added | |
|---|---|---|---|---|---|---|
| | Reply received | AI / Foundation Models | SA GB | ChatGPT, GPT-4, Codex | May 9 | |
| | Intro booked | AI / Foundation Models | DA | Claude, Constitutional AI | May 9 | |
| | Message sent | Fintech / Payments | PC JD | Online payments infra | Apr 21 | |
| | Interviewing | Dev tools / Infra | GR | Frontend cloud, Next.js | Apr 10 | |
| | Reply received | Dev tools / PM | KH | Issue tracking for SaaS | Apr 21 | |
| | Person identified | Design / Collab | DF | Collaborative design tool | May 9 | |
| | Message sent | Productivity | IZ | Docs, wikis, projects | Apr 10 | |
| | Message sent | Marketplace | BC | Marketplace for stays | Apr 21 | |
| | Person identified | E-commerce | TL | E-commerce platform | May 9 | |
| | Reply received | Fintech / Cards | EG KM | Corporate cards & spend | Apr 10 | |
| | Message sent | Comms / Social | JV | Voice, video, chat | Apr 21 | |
| | Reply received | Consumer / Audio | DE | Music streaming | Apr 10 | |
| | Message sent | Crypto / Fintech | BA | Crypto exchange | Apr 21 | |
| | Person identified | Fintech / Infra | ZP | Banking API infrastructure | May 9 |
Atlas
A career CRM that runs the job search like a sales pipeline.