You did the analysis and landed on a split: Kimi K3 for bulk, Claude Sonnet 5 for the hard tasks. Now don't hard-code either one into your agent. Two prices are about to move — Kimi K3's open weights drop July 27 and Claude Sonnet 5's promo ends August 31 — and if a model id is scattered across fifty call sites, every one of those events is a refactor. Put both behind one interface and each one becomes a one-line config change.
The idea: one request shape, many models#
Both Kimi K3 and Claude Sonnet 5 are reachable through an OpenAI-compatible gateway — OpenRouter is the common choice, and Anthropic also exposes an OpenAI-compatible endpoint directly. That means you don't need one SDK per vendor. A single openai client calls either model by changing one string: the model field.
from openai import OpenAI
import os
client = OpenAI(
base_url="https://openrouter.ai/api/v1", # any OpenAI-compatible gateway
api_key=os.environ["OPENROUTER_API_KEY"],
)
# One place that names every model you use.
MODELS = {
"bulk": "moonshotai/kimi-k3", # cheap tier, high-output-token work
"hard": "anthropic/claude-sonnet-5", # near-frontier coding, expensive-to-miss tasks
}
That MODELS dict is the whole policy. It's the only place a model id appears, so swapping providers, pointing at a self-hosted endpoint, or adding a third tier is an edit here — never in your agent logic.
Route by task, not by preference#
Pick the model from the kind of work, so cost-routing is a property of the call, not a decision you re-make everywhere:
def complete(task_type, messages, **kw):
model = MODELS[task_type]
resp = client.chat.completions.create(
model=model, messages=messages, **kw
)
return resp.choices[0].message.content
# bulk codegen -> cheap tier
complete("bulk", [{"role": "user", "content": "Generate a REST client for this OpenAPI spec..."}])
# a hard, multi-file repo fix -> the capable tier
complete("hard", [{"role": "user", "content": "This test is flaky; find and fix the race..."}])
Send the high-volume, high-output-token, non-frontier work (boilerplate, codegen, summarization, tool-call loops) to "bulk". Reserve "hard" for tasks where a wrong answer is expensive or where you need the bigger context window. Because output tokens dominate an agent's bill, routing bulk work to the cheaper model is where the savings actually live.
Add failover in one wrapper#
When both models share the same request shape, failover is trivial: catch a transient error on the primary and replay the identical call against a backup id.
FALLBACK = {
"moonshotai/kimi-k3": "anthropic/claude-sonnet-5",
"anthropic/claude-sonnet-5": "moonshotai/kimi-k3",
}
def complete(task_type, messages, **kw):
model = MODELS[task_type]
try:
resp = client.chat.completions.create(model=model, messages=messages, **kw)
except Exception: # 5xx, timeout, rate limit
backup = FALLBACK[model]
resp = client.chat.completions.create(model=backup, messages=messages, **kw)
return resp.choices[0].message.content
No per-vendor error handling, no second SDK — the same messages payload works against either model because the gateway normalizes the contract. In production you'd narrow the except to retryable statuses and add backoff, but the shape is this small.
Why this survives the next price change#
That's the entire router: a client, a MODELS dict, a FALLBACK dict, and a try/except — around forty lines, no framework required.
The payoff is optionality. When Kimi K3's open weights land on July 27, self-host the model behind an OpenAI-compatible server (vLLM and SGLang both speak the API) and point "bulk" at your own URL — the agent code never moves. When Sonnet 5's promo ends August 31 and its output price rises to $15/M, shift volume by editing one mapping. You made the model a runtime detail instead of a hard dependency, and that's what lets you chase price and capability without rewriting your product every time the market moves.



