I read the Stripe–OpenRouter news and then opened my own chat code

Stripe wants OpenRouter. Everyone is talking about model routers this week. I still send name checks and real questions through the same Gemini door. That has to change.

I saw the Stripe news this morning and then I opened server/chat.ts. That was a slightly embarrassing order of events.

Stripe has tabled a bid for OpenRouter. People are throwing around $8 billion. I have not seen a contract, so I am not going to pretend I have. What I did see is the same story in three places: OpenRouter as one API in front of a pile of models, Cursor shipping its own router in July, Ramp putting Router.com up on the same day. The point is boring and I missed it on my own site. You should not use the expensive model for every request.

Ask Ayush already has GEMINI_API_KEY and GEMINI_API_KEY_2. It walks gemini-3.6-flash, then 3.5, then flash-latest, then lite. I wrote that so the chat still answers when the free tier dies. Fine. That is just panic order. Yesterday someone typed asdfghjk in the name field and I still woke Gemini to ask if it was a name. That is the spend I am embarrassed about, not the $8 billion headline.

What I am changing first

Three kinds of work hit that chat. “What should I call you?” only needs yes or no. “What is your stack?” should stay inside the facts I already pasted into the prompt. “How does NestJS injection work?” is a real question and can use a better model. Right now those three look the same to the API. I am going to tag them before I pick a model. The regex is ugly. I do not care. I can replace it later.

export type LlmTask = "name-check" | "portfolio" | "general";

export function classifyTask(
  text: string,
  intro: "name" | "country" | "ready",
): LlmTask {
  if (intro !== "ready") return "name-check";

  // Good enough for the corner chat. I will tighten this when it lies.
  if (/\b(ayush|hire|stack|project|nestjs|contact)\b/i.test(text)) {
    return "portfolio";
  }

  return "general";
}

The env var is not a plan

I do not need OpenRouter this weekend. I need more than GEMINI_MODEL. Name checks can go to flash-lite. Portfolio answers can stay on flash. If someone asks a real coding question, then I try 3.6. handleChatRequest already loops keys and models when one call fails. I just never told it that a name check and a long answer are different jobs.

import type { LlmTask } from "./llm-task";

const ROUTES: Record<LlmTask, string[]> = {
  "name-check": ["gemini-flash-lite-latest", "gemini-flash-latest"],
  portfolio: ["gemini-3.5-flash", "gemini-flash-latest"],
  general: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-flash-latest"],
};

export function modelsFor(task: LlmTask, preferred?: string) {
  return [preferred, ...ROUTES[task]].filter(
    (model, i, list): model is string =>
      Boolean(model) && list.indexOf(model) === i,
  );
}

If Gemini says 429, leave that model alone

I have sat on the free tier long enough to know what happens next. The good model returns 429, I retry the same key, and then the chat is dead until morning. Next time I get 429 or 503 I drop to lite. If I get 401 the key is done. I am not going to wrap this in a big theory about Stripe metering. I just do not want the afternoon lockout again.

export function shouldFailover(status: number) {
  return status === 429 || status === 503 || status >= 500;
}

export function keyIsDead(status: number) {
  return status === 401 || status === 403;
}

I cannot cut a bill I cannot see

OpenRouter and Ramp will sell you a dashboard. I am going to log the task, the model, which key I used, and how long it took. If name-check keeps showing 3.6-flash I will know I forgot to wire the router. Also I need to stop sending the whole FACTS block when I only asked “is this a name?” That prompt is huge and the question is tiny. I have been paying for that without looking.