Migrate from OpenAI / OpenRouter¶
Two lines change: the base URL and the key. Below that, the list of what behaves differently.
The two lines¶
client = OpenAI(
base_url="https://api.keln.ai/v1", # was: default
api_key=os.environ["KELN_API_KEY"], # was: OPENAI_API_KEY
)
client = OpenAI(
base_url="https://api.keln.ai/v1", # was: https://openrouter.ai/api/v1
api_key=os.environ["KELN_API_KEY"], # was: OPENROUTER_API_KEY
)
Model names are the open ecosystem's own ids, canonically lowercase
(deepseek-ai/deepseek-v4-flash, openai/gpt-oss-120b, …), the full list comes from
/v1/models or the rate card. Matching is
case-insensitive.
What carries over unchanged¶
messages,stream,temperature,top_p,max_tokens/max_completion_tokens,stop,n, penalties,seed,logprobs, the standard surface, normalized per model- Tool calling and structured output (
tools,tool_choice,response_formatwithjson_object/json_schema), with server-side validation you didn't have before - SSE streaming, same chunk shape, same
[DONE]terminator,usagein the final chunk - The error envelope,
{"error": {"message", "type", "code"}}, same as OpenAI. One caution: Keln's message strings were rewritten in plain language and can be reworded again. Match on the HTTP status anderror.code, never on message text. Errors
What's different, and why¶
| On Keln | |
|---|---|
| Endpoints | /v1/chat/completions and /v1/models. There is no legacy /v1/completions text endpoint. |
| Reasoning | One standard reasoning_effort field on every model (vendor spellings accepted as aliases, including the OpenRouter/Vercel reasoning object). The trace returns in the canonical reasoning_content and is mirrored in reasoning, code that reads either gateway's response shape works unchanged. Details |
| Caching | Automatic. No cache_control blocks (stray ones are tolerated and ignored), repeat prefixes just bill at the per-model cached rate, reported in usage.prompt_tokens_details.cached_tokens. Details |
| Provider routing | There isn't any to configure. No provider preferences, no fallback model arrays, no route pinning, Keln routes on live health and price inside one published rate per model, and verifies quality so you don't choose vendors defensively. Unknown fields from your OpenRouter code are ignored, never fatal, the field ledger below accounts for each one. |
| Latency control | Nothing to configure: Keln holds each route to its own predicted first-token window and hedges past it. There is no deadline knob and no timeout error, slow attempts are re-raced, not abandoned. Details |
| Pricing | One published price per model, no per-provider price spread, no surge. Every priced response carries usage.cost, the exact spend in USD, with no usage: {"include"} flag needed. Rate card |
| Data retention | Zero. Prompts and completions are never stored, not "retained for 30 days", not "on by default". Details |
Constraints you can stop sending, they're already guaranteed¶
Three OpenRouter provider filters exist to protect you from a marketplace. On Keln the
protection is the platform, so the constraint is met by construction on every request, the
fields are accepted and satisfied, not ignored:
provider: {"zdr": true}, every request is zero-data-retention. There is no non-ZDR capacity to route around. Privacyprovider: {"data_collection": "deny"}, nothing is stored, nothing is trained on, ever. Same guarantee, no flag required.provider: {"quantizations": [...]}, you filter quantizations on a marketplace because a host might serve a degraded quant. Here, continuous quality verification detects and excludes quant downgrades platform-side.
Every OpenRouter field, accounted for¶
The full ledger of gateway-dialect fields we accept but deliberately don't act on, what it does
there, what happens here, and why that's fine. Nothing on this list can fail a request, and
nothing is silently ignored either: any request carrying inert fields gets them named in the
x-keln-inert-params response header.
| You send | There | Here |
|---|---|---|
provider: {order / only / ignore / allow_fallbacks / sort / max_price} |
steer or filter the provider pool | Stripped before dispatch, routing is Keln's job, inside one price and one SLA. See above for zdr / data_collection / quantizations. |
models: [...], route: "fallback" |
cross-model fallback | Stripped, same-model failover, hedging, and mid-stream rescue replace the resilience motive; one model per request keeps billing and the SLA legible. Want a different model? Ask for it explicitly. |
transforms: ["middle-out"] |
silently compress an oversized prompt | ⚠ Not offered. An oversized prompt returns 400 context_length_exceeded immediately, before billing. If you relied on middle-out, trim client-side against context_length from /v1/models. |
require_parameters: true |
only route to providers supporting all your params | Unnecessary, param-aware routing is always on: a demanded parameter routes to capacity that supports it, or you get an explicit 400 naming it. Details |
usage: {"include": true} |
opt into cost accounting | Accepted no-op, usage.cost is present on every priced response, flag or no flag. On streams it rides the final usage chunk. |
reasoning: {"exclude": true} |
think internally, omit the trace | Ignored, the trace is returned (billing is identical either way; reasoning bills as output tokens regardless). Skip rendering it client-side if you don't want it. |
plugins: [{"id":"web"}], web_search_options |
web search | Honored — Keln runs the search and returns citations; see Web search. prediction stays stripped |
model: "…:nitro" / ":floor" |
throughput-first / price-first routing hint | Suffix stripped, base model served. |
model: "…:free" / ":online" |
free tier / built-in web search | Explicit 400 variant_not_supported, and these two are the entire rejected set. Search is web_search_options: {} (Web search). |
numeric error.code handling |
OpenRouter mirrors the HTTP status as a number in the body | Keln's code is a string (OpenAI-shaped, as on Vercel). Match on HTTP status or string codes, see Errors. |
readers of response.provider, native_finish_reason, reasoning_details[] |
marketplace transparency fields | Never emitted, responses don't identify serving capacity by design; the trace lives in reasoning_content / reasoning. |
readers of usage.completion_tokens_details.reasoning_tokens |
itemized reasoning spend, when the serving provider happens to report it | Canonical on every route: passed through where the route reports a split, derived server-side with the model's own tokenizer where it doesn't. Reasoning bills as output either way. |
readers of /v1/models |
catalog metadata | Same OpenAI shape, three additions: performance (live tps_p50 / ttft_p50_ms / as_of, omitted when there's no fresh figure), added (the real date a model joined, created is a fixed constant kept only for strict clients), and input_modalities. No key needed, and it's CORS-open. Details |
mid-stream error events (finish_reason: "error" at HTTP 200) |
the client handles provider failures mid-stream | You can receive exactly one documented shape, and only as a last resort: if failover is exhausted after output began, the stream ends with a terminal chunk carrying finish_reason: "error" and error.code: "stream_interrupted", then [DONE]. Everything short of that is repaired mid-flight by Keln. Your existing handler maps onto that one case, keep it. Details |