Your first request¶
Problem — You have a model name and a prompt, and three providers
with three SDKs, three env vars, and three wire formats between you and
a response. lm15 gives you one Request type and a router that turns
the model string into the right provider — and can tell you exactly how
it decided.
Recipe¶
Keys first. lm15 reads nothing implicitly; you put keys in the
environment, the router finds them there. This loader searches the
current directory and its parents for a .env file:
import asyncio
import os
from pathlib import Path
from lm15 import (
AnthropicLM, AsyncLMRouter, GeminiLM, LMRouter, Message, OpenAILM, Request,
)
def load_env(filename=".env"):
for directory in (Path.cwd(), *Path.cwd().parents):
path = directory / filename
if path.exists():
for line in path.read_text().splitlines():
line = line.strip().removeprefix("export ")
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip().strip("\"'"))
return path
return None
print(load_env())
/home/maxime/Projects/lm15-dev/.env
(From a shell, the one-liner is export $(grep -v "^#" ../.env | xargs).)
Now the request. LMRouter is the front door: it maps
"gpt-4.1-mini" to OpenAILM, builds the LM with the key from
$OPENAI_API_KEY, and forwards the call.
router = LMRouter()
response = router.complete(
Request(model="gpt-4.1-mini", messages=(Message.user("Say hello in five words."),))
)
print(response.text)
Hello! Hope you're doing well.
response is a frozen dataclass, not a provider blob. Print it and you
see everything lm15 normalized:
print(response)
Response(
text="Hello! Hope you're doing well.",
model='gpt-4.1-mini-2025-04-14',
finish_reason='stop',
usage=Usage(input_tokens=13, output_tokens=8, …),
id='resp_09b0…',
provider_data=<dict: 35 keys>,
)
How did the router pick OpenAI? Ask it. resolve() is pure — no
network, no key values read — and its return value is the explanation:
res = router.resolve("gpt-4.1-mini")
print(res)
print(res.provider, res.adapter, res.source, res.env_key)
'gpt-4.1-mini' -> provider 'openai' (OpenAILM); via built-in rule prefix='gpt-' — OpenAI GPT family (Responses API; use openai-chat: for Chat Completions); wire model 'gpt-4.1-mini'; key from $OPENAI_API_KEY.
openai OpenAILM rule OPENAI_API_KEY
A provider: prefix bypasses the rule table and is always unambiguous:
print(router.resolve("anthropic:claude-sonnet-4-5"))
'anthropic:claude-sonnet-4-5' -> provider 'anthropic' (AnthropicLM); via explicit provider prefix; wire model 'claude-sonnet-4-5'; key from $ANTHROPIC_API_KEY.
The router never guesses. A string it cannot place raises
UnknownModelError with the fixes spelled out:
router.resolve("qwen3.5:0.8b")
Traceback (most recent call last):
…
lm15.router.UnknownModelError: could not route model 'qwen3.5:0.8b': no provider prefix, no catalog supplied, and none of the 9 built-in rules matched. Use an explicit provider prefix — "provider:qwen3.5:0.8b" with provider one of: anthropic, claude-code, gemini, groq, ollama, openai, openai-chat, openai-codex, openrouter, sglang, vllm, xai. Or pass a model catalog: …
The router is sugar, not a layer. The direct LM classes are first-class
and take the same Request. The same prompt, three providers:
prompt = (Message.user("Say hello in five words."),)
openai_lm = OpenAILM(api_key=os.environ["OPENAI_API_KEY"])
print(openai_lm.complete(Request(model="gpt-4.1-mini", messages=prompt)).text)
anthropic_lm = AnthropicLM(api_key=os.environ["ANTHROPIC_API_KEY"])
print(anthropic_lm.complete(Request(model="claude-sonnet-4-5", messages=prompt)).text)
gemini_lm = GeminiLM(api_key=os.environ["GEMINI_API_KEY"])
print(gemini_lm.complete(Request(model="gemini-3-flash-preview", messages=prompt)).text)
Hello there! Hope you're well!
Hello, how are you today?
Hello, how are you doing?
AsyncLMRouter is the async mirror — complete is awaitable,
resolve() stays sync because it is pure:
async def main():
arouter = AsyncLMRouter()
response = await arouter.complete(
Request(model="gemini-3-flash-preview", messages=prompt)
)
print(response.text)
asyncio.run(main())
Hello, how are you today?
How it works¶
A Request is a frozen dataclass: a model string and a tuple of
Message objects (plus optional Config, tools, and more in later
recipes). router.complete(request) does three things: resolve the
model string, build (and cache) the provider LM, and call its
complete(). Resolution walks four fixed rungs — a provider attribute
carried by the model value itself, explicit provider: prefix,
optional catalog, built-in prefix rules — first match wins, no
fallback chains. The full grammar and the catalog rung are in
Using the router.
Credentials follow the provider's manifest: OpenAILM declares
OPENAI_API_KEY, and that is the only place the router looks unless
you pass RouterConfig(api_keys=...). A missing key raises
MissingCredentialError at lm() time, not a 401 at request time.
resolve() records which env var would be read, never its value.
Both paths — router and direct LM — produce the identical Request
and identical wire bytes. lm15 deliberately does not retry, does not
pool connections behind your back, and does not pick a model for you:
one call in, one typed Response out.
Variations¶
- Keep the LM, drop the router.
router.lm("gpt-4.1-mini")returns a plainOpenAILM; configure it once and never resolve again. - Direct LMs are for configuration, not preference. Reach for them
when you need a custom
base_urlor compat preset (ollama, vLLM — recipe 16) or when you are a library taking an LM object from your caller. - Async direct LMs exist too:
AsyncOpenAILM,AsyncAnthropicLM,AsyncGeminiLM, same constructors, awaitablecomplete. - OpenAI has two providers. Bare
gpt-routes to the Responses API (OpenAILM);"openai-chat:gpt-4.1-mini"selects Chat Completions (OpenAIChatLM). The resolve output above says so.
See also¶
- 02 — Multi-turn conversations
- 04 — Controlling generation
- 05 — Streaming
- Using the router — grammar, catalogs, credentials
- 17 — Errors, retries & testing