W03 · Hero image · Full-bleed

One cable ending in many different plugs, studio-lit.

Onefold

AI infrastructure · SystemsOne API surface over every model dialect
Overview

Onefold accepts an OpenAI-shaped request, translates it into whatever the target provider actually speaks, and streams the answer back without ever holding it. This is how the translation engine works, and where the design stops being clever.

RoleSole engineer
ScopeProtocol translation
Edge runtime
Data modelling
Analytics pipeline
StackTypeScript on Cloudflare Workers
Postgres via Hyperdrive, ClickHouse
Astro for the catalog surface
The problem

The cost of asking the question is higher than the value anybody assigns to the answer.

A team ships a feature on one model. It works. Two months later a cheaper model comes out that looks better on exactly their kind of workload, and someone says the obvious thing: we should try it.

Trying it means rewriting the request, because the system prompt is a message in one API and a top-level field in the other. Then the stream parser, because the events have different names and different shapes. Then the tool definitions, because one nests the schema under a function object and the other does not. Then error handling, because the failure body is a different shape again. None of this is hard. All of it is a day.

So the experiment gets scheduled, then bumped, and the feature stays on the model it launched with — not because it is the right model but because it was the first one. The cost of asking the question is higher than the value anybody assigns to the answer.

Onefold exists to make the question cost nothing: change the model string, send the same request, read the same response.

appappappdialect Adialect Bdialect CappappappOnefoldone surfaceABCevery client learns every dialectevery client learns oneswitching model rewrites four thingsswitching model changes a stringThe four things: request shape, parameter names, stream protocol, error body.
Figure 1. The switching cost is structural. Every client either learns every dialect, or exactly one.
Background

Providers diverge in four places

Providers diverge in four places, and it is worth being precise about them because the whole design follows from the list. The message shape: where the system turn goes, whether content is a string or typed blocks, whether consecutive turns from one role are legal. The parameter names: the same idea called different things, sometimes a scalar here and an array there. The stream protocol: both server-sent events, different event names, different delta semantics. And the error body: the same failure reported at a different path in a different envelope.

Each difference is trivial in isolation. The problem is that there are four of them per provider, and the combination is what a client has to learn.

Requirements
  • Compatible: An existing OpenAI client works by changing one string. No SDK to install, no wrapper to learn, no fork of anybody’s library.
  • Streaming: Translated frame by frame. The first token reaches the caller as soon as it reaches the gateway, because buffering a response to translate it would undo the reason people stream.
  • Extensible: A provider that reuses existing translation styles is configuration, not code. The engine should not grow a branch per vendor.
  • Attributable: Every request that reaches a provider produces one durable record of what it cost and how long it took.
  • Off the critical path: Nothing in the accounting or analytics path may delay, fail, or otherwise be visible to the caller.
Solutions considered
  • A client library per provider: The default answer, and it pushes the problem onto every app. Each one learns every dialect, upgrades on its own schedule, and nobody can see total spend.
  • A proxy with a module per provider: Centralises it, which is right, but every provider is still hand-written code. The modules start similar and drift apart, and the fifth one is as much work as the first.
  • Lowest-common-denominator API: Expose only what every provider shares. Easy to build and quietly useless — the reason to switch models is usually the thing that is not shared.
Architecture

The public surface is two routes: list the models, and run a chat completion. Everything else is internal. A request authenticates a bearer key to an organisation and a balance, reads one catalog row to learn where the model lives and what it costs, picks the spec for that provider’s dialect, and translates.

The catalog row is doing double duty, and that is deliberate. It is the routing table — upstream id, base URL, dialect — and the price list in the same record, so the request that decides where to go has already loaded what it will cost.

OpenAI SDKbaseURL changedWorkerkey → org, balancecatalog row → route + pricespec for that dialecttranslatebody + headersproviderone event, after the responsectx.waitUntil — never blocks the callerThe response streams back through the same path in reverse, translated frame by frame.
Figure 2. One request. The accounting write happens after the caller has been served, not before.
The spec

Config decides, the engine executes

A provider is a declarative object. It names the auth headers it needs, with a placeholder where the secret goes. It names a message style and a stream style, by string, rather than carrying the code for either. It maps each OpenAI parameter to a provider field with a small rule — rename it, wrap a scalar into an array, fill it from the catalog row when the provider requires it and the client omitted it, or hand the value to a named translator when the shape has to change rather than the name. And it names the dot paths where the provider’s error message and type live.

The engine reads that and does the work. The whole Anthropic spec is nineteen lines and contains no logic at all.

The honest boundary: this is only configuration for providers whose behaviour is already expressible in existing styles. A genuinely new protocol needs a new style, which is real code in the engine. The claim is that the second provider on a known protocol is nearly free, not that any provider is.

The spec — dataauth headers, with {key} placeholdermessage style, by nameparam rules: to · wrap · default · transformstream style, by nameerror paths, as dot pathsThe engine — codemessageStyles — reshape the turnsvalueStyles — reshape a valuestreamStyles — parse the eventsbuilt per request when statefulpoints atby nameA provider that reuses existing styles is nineteen lines of configuration and no new code.A genuinely new protocol still needs one new style — the claim is bounded, and worth stating.
Figure 3. The spec is data; the styles it names are code. New vendors mostly add the first kind.
Normalisation

One normalisation, two renderers

The temptation with a gateway is to write two response paths — one that forwards a stream and one that returns a whole body — and then spend a year fixing the places where they disagree. Onefold has one.

Provider bytes become server-sent event frames, frames become dialect-neutral events, and there are only six kinds of those: some text, a tool call opened, a fragment of that call’s arguments, a usage report, a finish reason, an error. Both response modes consume that same sequence. Streaming re-emits each event as an OpenAI chunk; non-streaming consumes the sequence to the end and assembles one body from it.

Non-streaming is therefore not a second implementation. It is the streaming path, read to completion before anything is written. The two can not disagree about what the model said, because they are reading the same events.

raw bytesfrom providerSSE framesgeneratorstream styleper-request stateNorm eventstext · tool_start · tool_argsusage · finish · errorstreamedre-emitted as chunkscollectedone body at the endNon-streaming is not a second code path. It is the same event sequence, consumed to the endbefore anything is written — so the two responses cannot disagree about what the model said.
Figure 4. Six event types are enough to describe any completion, and both response modes read them.
Tool calls

The part that is genuinely hard

Tool calls are where dialects stop being cosmetic. Anthropic indexes every content block in a response against one counter — text blocks and tool blocks share it. OpenAI numbers tool calls densely from zero and does not count text at all. A response that goes text, tool, text, tool produces Anthropic blocks one and three, which the client expects to see as tool calls zero and one.

So the translator carries a map from block index to tool index, built as the response arrives. That map is meaningless outside a single response, which is why a stream style is a factory rather than a function: the engine constructs a fresh translator per request, and any state a dialect needs lives in the closure rather than in a module.

The second half is that tool arguments stream as partial JSON. Not an object, not valid JSON on its own — a fragment. The gateway forwards fragments verbatim and lets the client concatenate them, because the alternative is buffering until the object closes, and a gateway that waits for a whole tool call before saying anything has quietly stopped streaming.

block 0 — textblock 1 — tool_useblock 2 — textblock 3 — tool_useMap<block, tool>1→0, 3→1tool_calls[0]tool_calls[1]one counter for everythingdense, tools onlyThe mapping only exists while the response does, so the translator is built per requestrather than shared. Argument fragments arrive as partial JSON and are forwarded verbatim —the gateway never waits to parse a whole object it does not need to understand.
Figure 5. Two counters that do not agree, reconciled per response.
Accounting

Accounting that cannot cost you a response

Every request that reaches a provider produces one record: which model, which provider, status, finish reason, token counts both ways, latency, whether it streamed, which key and organisation, and what it cost. That record is written after the response has been handed to the caller, on the runtime’s post-response hook, and a failure to write it is a log line rather than an error.

It lands in a Postgres staging table first and is drained into ClickHouse once a minute. The hop exists for an unglamorous reason — the Worker runtime cannot reach ClickHouse directly — but it bought something real. The analytics store is append-only, queried by aggregation over time ranges, and has no business sharing a transactional database with the catalog. The staging table makes the boundary explicit and gives the drain somewhere to retry from.

Retries are safe because the destination table collapses rows with the same id on merge, so a batch replayed after a partial failure produces one row rather than two. Idempotency is a property of the schema instead of a discipline the drain script has to maintain.

WorkerwaitUntil, after sendPostgres stagingthe Worker can reach itdrain jobevery minute, on the boxClickHouseReplacingMergeTreeThree properties fall out of this shape:· A failed write costs a log line, never a response — the caller has already been served.· A replayed batch collapses on id instead of double-counting, so the drain can retry freely.· Per-request rows never touch the transactional database the catalog lives in.The staging hop exists because Workers cannot reach ClickHouse directly. It is a workaroundthat turned out to be the durability layer.
Figure 6. A workaround that turned into the durability layer.
Guarantees

What the system guarantees

Token order is preserved and nothing is held: a byte that arrives from a provider leaves the gateway as soon as it has been translated. Streaming and non-streaming responses describe the same completion, because they are built from the same events. Every provider error surfaces in one shape regardless of what the provider sent. And no analytics failure is ever visible to a caller, because the caller has already been served by the time the write is attempted.

Limits

Where the design stops

An OpenAI-shaped surface is a ceiling. Anything a provider offers that has no OpenAI equivalent is unreachable through the compatible route. The abstraction that makes switching cheap is the same one that hides the reason you might want a particular model.

A failure mid-stream cannot be retried. Once the first token is out, the response has been committed. Failing over to a second provider would mean either buffering — which defeats the point — or emitting a contradiction. Today the stream ends and the client sees a truncated response.

Catalog reads sit on the request path. Routing needs a database round trip through a connection pooler. It is fast and it is a dependency: if Postgres is unreachable, the gateway cannot route, when it could plausibly serve a cached routing table and degrade instead.

Specs drift silently. A provider that changes a field name breaks one dialect at runtime, not at build time. Config-as-data moves the failure from the compiler to production, which is the real cost of the flexibility.

Takeaway

The takeaway

Almost everything good here came from finding the narrow waist. Six event types describe any completion well enough that both response modes can be built on them. One declarative object describes a provider well enough that the fifth one is configuration. One catalog row answers both where to send this and what it costs.

The parts that stayed hard are the parts where no narrow waist exists — tool call indices, partial JSON, a stream that cannot be un-sent. That is the useful line, and it is worth knowing which side of it a problem is on before deciding how much abstraction to spend on it.