Artificial Intelligence

Why I Route Every AI Call Through OpenRouter Instead of Direct Endpoints

I migrated gazar.dev off direct Gemini and OpenAI endpoints onto OpenRouter as a single gateway. One key, one request shape, one bill, and switching models is now a string change. Here is what that bought me and what it cost.

17 Jul 2026

Why I Route Every AI Call Through OpenRouter Instead of Direct Endpoints

Every article on this site has a cover image, and I generate them with a model rather than a stock library. For a while that meant talking to Google's Gemini image API directly for the watercolour covers and to OpenAI directly for a couple of other things. Two providers, two SDKs, two API keys, two billing dashboards, two subtly different request and response shapes. None of it was hard. It was just a small tax I paid every time I touched the image pipeline, and it quietly grew every time I wanted to try a new model.

A few weeks ago I ripped all of it out and pointed everything at OpenRouter instead. The direct provider code is gone. There is now one file in the repo that every image generation call goes through, one API key in the environment, and one place I look to see what I have spent. Switching from a cheap model to an expensive one is a one-line change. This is the story of why I did that, what it actually bought me, and where the trade-offs live, because there are some.

What "direct endpoints" actually costs you

The pitch for going direct is that it is simplest. You want Gemini, you talk to Gemini. No middleman, no markup, no extra hop. On a single provider with a single model, that is true, and if that describes your whole AI surface then you can stop reading, you do not need any of this.

The problem is that "a single provider with a single model" is almost never where you end up. You start with one. Then a model gets deprecated. Then a cheaper model ships and you want to A/B it against what you have. Then you need a capability your current provider is bad at and a different lab is good at. Each of those is a new account, a new key to store and rotate, a new SDK with its own retry semantics and its own idea of what an error looks like, and a new invoice. The cost is not any single integration. The cost is that every provider you add multiplies the surface area you have to keep in your head and in your secrets manager.

I felt this most sharply in the response shapes. When I was calling providers directly, the code that pulled the actual image bytes out of the response had to know exactly which provider it was talking to, because they do not agree on where the image lives. One returns base64 in data[].b64_json. Another hands you a URL. A third routes the image through a chat-completions-style envelope under choices[].message.images. That knowledge was smeared across the calling code, and it broke every time I swapped models.

What the gateway looks like now

The whole point of moving to OpenRouter was to collapse all of that into one seam. Every image call in the repo now goes through a single module, openrouter-image.mjs, and its job is to be the only place that knows about the provider at all. Callers hand it a prompt and an aspect ratio and get back a Buffer. They never see an API shape.

Js
export async function generateImage({ prompt, aspectRatio = '16:9', resolution = '1K', model } = {}) {
  const apiKey = process.env.OPENROUTER_API_KEY
  if (!apiKey) throw new Error('OPENROUTER_API_KEY missing')
  const chosen = model || process.env.OPENROUTER_IMAGE_MODEL || FALLBACK_MODEL

  const res = await fetch('https://openrouter.ai/api/v1/images', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
      'HTTP-Referer': 'https://gazar.dev',
      'X-Title': 'gazar.dev',
    },
    body: JSON.stringify({ model: chosen, prompt, n: 1, aspect_ratio: aspectRatio, resolution, output_format: 'png' }),
  })

  const json = await res.json()
  return toBuffer(json)
}

One key. One endpoint. The model is just a string, and the string can come from an argument, from an environment variable, or from a default. That last detail is the one that changed how I work. When I want a higher-fidelity cover for a specific article, I do not touch code, I do not sign up for anything, I do not add a dependency. I set OPENROUTER_IMAGE_MODEL to a different google/gemini-* model for that one run and the pipeline routes there. The default stays on the cheap flash-lite model at 1K because that lands around four cents an image versus roughly ten cents for the bigger model at 2K, and most covers do not need the extra resolution.

That is the real unlock. The cost of trying a different model dropped to almost zero, so I actually try them.

The response-shape problem does not disappear, it moves

I want to be honest here, because this is the part people gloss over when they sell you on a gateway. OpenRouter gives you one request shape, which is genuinely nice. It does not fully give you one response shape, because different underlying models still route through different surfaces. The image can still come back as base64, or as a URL, or wrapped in a chat-completions envelope.

The difference is where that mess lives now. Instead of being spread across every caller, it is quarantined in one function whose entire job is to be tolerant:

Js
async function toBuffer(json) {
  const d = Array.isArray(json?.data) ? json.data[0] : null
  if (d?.b64_json) return Buffer.from(d.b64_json, 'base64')
  if (d?.url) return fromUrl(d.url)
  const img = json?.choices?.[0]?.message?.images?.[0]
  const u = img?.image_url?.url ?? img?.url
  if (u) return fromUrl(u)
  return null
}

This is not elegant and I am fine with that. It is a boundary adapter, the same shape of code you write when you wrap any third-party API you do not control. The win is not that the ugliness went away. The win is that there is exactly one of it, it is small, and when a new model routes its bytes somewhere new I add one branch here and nothing else in the codebase notices. Before OpenRouter, that same tolerance would have had to exist per provider, in the calling code, forever.

The things that actually made it worth it

If I strip away the specifics, three things justified the move.

The first is a single billing and spend surface. I have one OpenRouter account with credits on it, and I can see exactly what image generation has cost me without reconciling two invoices from two vendors with two different billing cycles. For a one-person operation that is not a rounding error, it is the difference between knowing my unit economics and guessing at them. The HTTP-Referer and X-Title headers mean every request is attributed in the dashboard too, so if I ever run more than one project through the same account I can still see which one is spending.

The second is that model choice became a runtime decision instead of an architectural one. Directly integrated, a model is a dependency: swapping it means new code, possibly a new SDK, and a deploy. Through OpenRouter, a model is configuration. The provider lives behind a name, and names are cheap to change. That is exactly the kind of decision you want to be reversible, because model quality and pricing move constantly and you do not want your architecture to fight you every time you react to that.

The third is resilience I mostly get for free. When a provider has a bad day, a gateway that can route the same request to an alternative is worth a lot, and it is not something I would have built myself for a side project. I am not leaning on it hard today, but the option exists without any work from me, and that is the point of putting the abstraction there before I need it rather than after.

What you give up

There is no free lunch, so here is the other side honestly.

You add a hop and a dependency. Every call now goes through someone else's infrastructure before it reaches the model, which is one more thing that can be slow or down, and one more company whose uptime is now your uptime. For a batch job that generates cover art this does not matter at all. For a synchronous, user-facing, latency-sensitive path you should measure the added round trip and decide with numbers, not vibes.

You accept a pricing intermediary. OpenRouter has to make money, and in general you should assume a gateway is not strictly cheaper per token than going direct at scale. What you are buying is not a lower unit price, it is flexibility and a smaller operational surface. If you are a large team burning serious volume through one stable model, the maths can tip back toward direct, and you should run that comparison rather than assume the gateway always wins.

You still have to understand the underlying models. A gateway unifies the request shape, it does not unify behaviour. A prompt that sings on one model can fall flat on another, resolution and aspect-ratio support vary, and the response shape, as I found, still leaks the provider. OpenRouter takes away the plumbing tax. It does not take away the need to actually know the models you are calling.

Where I landed

For gazar.dev the decision was easy in the end. I am one person, I touch several models across image generation, I care a lot about seeing my spend in one place, and I value being able to swap a model with a string more than I value shaving a hop off a background job. Every one of those points to a gateway, and OpenRouter is a good one.

The rule of thumb I would give someone else is narrower than "always use a gateway." If your AI surface is one provider and one model on a hot user-facing path, go direct and keep it simple. The moment you have more than one model in play, or you expect model choice to change, or you want your spend and your keys in one place instead of scattered across vendor dashboards, the plumbing you save is real and it compounds. I did not appreciate how much of that plumbing I had accumulated until I deleted it all and replaced it with one file and one key.


If you enjoyed this, I write a weekly newsletter for senior engineers thinking about architecture, infrastructure, and the move toward staff and principal roles. You can subscribe here.

Keep reading