Skip to content

Getting started

lm15 is one set of types for every LLM API. You build a Request, you get back a Response — the same two objects whichever provider answers.

This page is the whole core loop: install, one call, switch provider, stream, use a tool. You need Python 3.10+, about five minutes, and one API key — we sort the key out first. Every output below is real captured output, and every block is copy-paste runnable.

Install

python3 -m pip install --pre lm15

Zero dependencies, stdlib only. (--pre is needed while the current release is a pre-release; it goes away at 1.0 stable.)

Set an API key

Grab a key from any one provider and set its standard environment variable — that is the only setup. On macOS or Linux:

export ANTHROPIC_API_KEY="sk-ant-..."   # console.anthropic.com
export GEMINI_API_KEY="AIza..."         # aistudio.google.com/apikey
export GROQ_API_KEY="gsk_..."           # console.groq.com/keys
export OPENAI_API_KEY="sk-..."          # platform.openai.com/api-keys
First time setting an environment variable? Open this.

An environment variable is a named value your terminal hands to the programs it starts. Putting the key there means it never has to appear in your code. You only need one of the variables above — pick the provider you got a key from.

Paste the export line into your terminal, then run your Python from that same terminal. It lasts until the terminal closes. Check it took:

echo $ANTHROPIC_API_KEY

To make it permanent, add the same export line to the end of ~/.zshrc (macOS) or ~/.bashrc (most Linux) and open a new terminal.

$env:ANTHROPIC_API_KEY = "sk-ant-..."

Lasts until the window closes; check with echo $env:ANTHROPIC_API_KEY. To make it permanent:

setx ANTHROPIC_API_KEY "sk-ant-..."

setx writes it for future windows — open a new terminal before running Python.

set ANTHROPIC_API_KEY=sk-ant-...

Lasts until the window closes (note: no quotes, no spaces around =). Make it permanent with the same setx command as PowerShell.

Rather not touch the environment at all? Pass the key in code instead — LMRouter(RouterConfig(api_keys={"anthropic": "sk-ant-..."})) — just keep it out of version control.

The examples below use Anthropic; on a different provider, keep the code and swap the model string (the tabs below show exactly how). Running a local ollama? No key needed at all. Every other way to authenticate — explicit keys, rotating token providers, Claude/ChatGPT subscriptions — is on Authentication.

If you forget, the error says precisely what to do:

MissingCredentialError: no API key found for provider 'anthropic'.
Set ANTHROPIC_API_KEY in the environment, or pass
RouterConfig(api_keys={'anthropic': "..."}).

One call

LMRouter is the front door: give it a model string and it picks the right provider, the right adapter, and the right env var. (It is a lookup table you can inspect, not magic — more on that below.)

from lm15 import LMRouter, Message, Request

router = LMRouter()

response = router.complete(
    Request(model="claude-haiku-4-5",
            messages=(Message.user("Say hello in exactly three words."),))
)
print(response.text)
Hello, world friend.

response is typed all the way down: response.usage.input_tokens is 14, response.finish_reason is "stop", and response.message slots straight into the next request's messages to continue the conversation.

Why the trailing comma in (Message.user(...),)?

messages is a tuple, and in Python a one-element tuple needs a trailing comma. lm15's types are immutable throughout — a Request you built is never changed under you — and tuples are the price of that guarantee. (Lists are accepted too and converted.)

Any provider: change one string

The call never changes — only the model string does. Pick a tab:

response = router.complete(
    Request(model="claude-haiku-4-5",   # needs ANTHROPIC_API_KEY
            messages=(Message.user("The capital of Canada, two words max."),))
)
print(response.text)
Ottawa.
response = router.complete(
    Request(model="gemini-3-flash-preview",   # needs GEMINI_API_KEY
            messages=(Message.user("The capital of Canada, two words max."),))
)
print(response.text)
Ottawa
response = router.complete(
    Request(model="groq:llama-3.3-70b-versatile",   # needs GROQ_API_KEY
            messages=(Message.user("The capital of Canada, two words max."),))
)
print(response.text)
Ottawa
response = router.complete(
    Request(model="ollama:qwen3:4b",   # no key; any model you've pulled
            messages=(Message.user("The capital of Canada, two words max."),))
)
print(response.text)

No key, no config: local servers (ollama:, vllm:, sglang:) route by name and use a placeholder credential.

The same code covers OpenAI (gpt-4.1-mini) and anything else the router knows — the full matrix of providers, endpoints, and env vars is on Providers & models. And nothing about it is magic — ask it to explain itself:

print(router.resolve("claude-haiku-4-5"))
'claude-haiku-4-5' -> provider 'anthropic' (AnthropicLM); via built-in rule prefix='claude-' — Anthropic Claude family; wire model 'claude-haiku-4-5'; key from $ANTHROPIC_API_KEY.

Prefer no router at all? The adapter classes are equally first-class: AnthropicLM(api_key="...") takes the same Request. See Using the router and Using the providers.

Streaming

Two pieces share the work: router.stream() yields typed events as the provider sends them, and ResponseStream assembles them — iterate it and you get the text as it arrives:

from lm15 import ResponseStream

req = Request(model="claude-haiku-4-5",
              messages=(Message.user("One short sentence about rivers."),))
result = ResponseStream(router.stream(req), req)
for text in result:
    print(text, end="", flush=True)
Rivers flow from high elevations to the sea, shaping landscapes and sustaining life along their paths.

When you need more than text — tool calls, thinking, audio — iterate result.events() instead. And after the stream ends, result.response is the very same Response a non-streaming call would have returned: nothing about your downstream code has to care which way the answer arrived. Full recipe: Streaming.

Tools

A tool call is a conversation in two rounds: the model answers your question with "call get_weather with {'city': 'Montreal'}", you run the function yourself, send the result back, and the model writes the final answer. lm15 hands you each step as plain data — it never executes anything for you, which means no framework to fight when you want control over errors, retries, or budgets.

from lm15 import tool

def get_weather(city: str) -> str:
    """Current weather for a city."""
    return f"18°C and sunny in {city}"

weather = tool(get_weather)   # FunctionTool derived from the signature

messages = (Message.user("What's the weather in Montreal right now?"),)
r = router.complete(Request(model="claude-haiku-4-5", messages=messages, tools=(weather,)))

call = r.tool_calls[0]        # ToolCallPart(name='get_weather', input={'city': 'Montreal'})
messages = (*messages, r.message, Message.tool(call.id, get_weather(**call.input)))

final = router.complete(Request(model="claude-haiku-4-5", messages=messages, tools=(weather,)))
print(final.text)
The weather in Montreal right now is **18°C (about 64°F) and sunny**. It's a nice day!

Full recipe, including hand-written schemas and parallel calls: Function tools.

Async

Working in an async app? Everything has an async mirror with the same shape — add Async to the name and await the call:

import asyncio
from lm15 import AsyncLMRouter

async def main() -> None:
    router = AsyncLMRouter()
    response = await router.complete(
        Request(model="groq:llama-3.3-70b-versatile",
                messages=(Message.user("Say ok."),))
    )
    print(response.text)

asyncio.run(main())
Ok.

Credentials, the short version

The one-line summary of each option — the full story, with examples, is on Authentication:

  • Env vars — the router reads each provider's standard variable (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, …). The adapter classes themselves never touch the environment.
  • ExplicitLMRouter(RouterConfig(api_keys={"anthropic": "..."})), or pass api_key= when constructing an adapter directly.
  • Rotating — anywhere a key string goes, a zero-argument callable works too (an Azure Entra token provider, your own refresh logic); it is resolved once per request, so long-lived clients never go stale.
  • Subscriptions — logged into the Claude or Codex CLI? ClaudeCodeLM() / OpenAICodexLM() use those local credentials directly. On a SuperGrok plan, lm15.auth.login_xai() sets up XaiLM() the same way.
  • Local serversollama:, vllm:, and sglang: models need no key at all.

Where next