Skip to content

Enter a model ID

GitHub

Set up authentication

Every request you send to a provider is paid for by someone, and the provider knows who from the credential that travels with it: an API key, or the sign-in of a subscription. On your own laptop, a single key in an environment variable is usually all you need. A program that runs on a server, for several people, or for months at a time raises harder questions. Which account is it using? What happens when a key changes, or someone signs out? LM15 answers them with one rule: it never chooses a credential for you behind your back. It uses the one you gave it, and it can always tell you which one that is.

On this page, you’ll learn how LM15 decides which credential a request uses, and how to check that decision before you send anything. We’ll start with API keys, from the environment and from your own code, including keys that change while your program runs. Then we’ll use a subscription you already pay for instead of a key. Next, we’ll let LM15 save connections for you: first by answering a few questions at the keyboard, then with the pieces underneath, the way a server does it. We’ll finish by checking what’s saved, and signing out.

To run the examples, you’ll need an API key for Anthropic, set up as in Make your first request. One section also uses a ChatGPT subscription through OpenAI’s Codex app; you can read it without one.

The examples in these guides follow one small program: an assistant for a wildlife research station. Here’s its first question again, addressed to Anthropic:

from lm15 import LMRouter, Message, Request, RouterConfig
from lm15.doctor import explain_auth
request = Request(
model="anthropic:claude-haiku-4-5",
system=(
"You are the field assistant for a wildlife research station. "
"Answer in two sentences."
),
messages=[Message.user(
"What might be eating the acorns under our oak trees at "
"night?"
)],
)

To send it, the router needs a key for Anthropic. You can ask where it would find one without sending anything:

print(explain_auth("anthropic"))
What it printed September 24, 2026
auth for provider 'anthropic':
   - explicit api_keys entry: not provided
  => env $ANTHROPIC_API_KEY: set (value never shown)
  configured: yes — env $ANTHROPIC_API_KEY

This report comes from LM15’s doctor, which answers the question “what credential would this use, and why?” It lists every place the router looks, in order, and marks the one it will use with =>. Here, the program gives no key of its own, so the key comes from the environment variable ANTHROPIC_API_KEY. The report never contains a key, only where one comes from, so it’s safe to print, log, or paste into a bug report.

Now give the router a key yourself, as in Connect a provider, and ask again:

import os
router = LMRouter(RouterConfig(
api_keys={"anthropic": os.environ["STATION_API_KEY"]},
))
print(explain_auth("anthropic", config=router.config))
What it printed September 24, 2026
auth for provider 'anthropic':
  => explicit api_keys entry: provided (value never shown)
   ~ env $ANTHROPIC_API_KEY: set (value never shown)
  configured: yes — explicit api_keys entry

The key you gave in your code wins. The environment variable is still set, and the report says so, but the ~ marks it as shadowed: present, and not used. For a provider that works with API keys, like Anthropic, the order is always the same:

  1. A key you give the router in your code.
  2. The provider’s environment variable.

There’s no third step where LM15 goes looking somewhere else. If neither is there, the router stops before sending anything and names the variable it looked for. Whenever a request seems to use an account you didn’t expect, run the doctor with your router’s settings, as above: it follows the same steps as the router, without contacting the provider.

Some keys don’t last. A company might replace its keys every week, and cloud platforms such as Azure hand out tokens that expire after an hour. A key read once, when your program starts, will eventually be out of date, and a program that runs for days then fails with an authentication error in the middle of the night.

Instead of a key, you can give the router a function that returns one. The router calls it for every request, so whatever it returns now is what gets sent now. Here, the function reads the key from a file, and says so each time:

from pathlib import Path
def station_key():
print("(reading the key)")
return Path("secrets/anthropic.key").read_text().strip()
router = LMRouter(RouterConfig(api_keys={"anthropic": station_key}))
first = router.complete(request)
second = router.complete(request)
print(second.text)
What it printed claude-haiku-4-5 through Anthropic, September 24, 2026
(reading the key)
(reading the key)
Based on nocturnal activity patterns, the most likely culprits are raccoons, opossums, or foxes, though deer and squirrels may also forage at night depending on your region. To help identify which animal it is, I'd recommend setting up a trail camera near the oak trees, as the footage would show us exactly what's visiting and help us tailor any management strategies accordingly.

The function ran once for each request. When the file changes, the next request uses the new key, without a restart. Anything cleverer is up to your function: if fetching a key is slow, keep it in the function, and fetch a new one only when the old one is about to expire. The token helpers in the Azure, AWS and Google Cloud libraries are functions of exactly this kind, so they plug in directly. The Python guide to cloud hosts shows how, and how LM15 can also find a cloud identity on its own.

API keys are billed per request. Many people pay a monthly subscription for a chat app instead, and some of those subscriptions can be used from code. If you use OpenAI’s Codex app with a ChatGPT plan, or Claude Code with a Claude plan, you’re already signed in on your computer, and LM15 can use that sign-in. The provider names are openai-codex and claude-code:

from dataclasses import replace
print(explain_auth("openai-codex"))
router = LMRouter()
codex = replace(request, model="openai-codex:gpt-5.6-sol")
print(router.complete(codex).text)
What it printed gpt-5.6-sol through OpenAI Codex, September 24, 2026
auth for provider 'openai-codex':
  => local OAuth credential ~/.codex/auth.json: fresh, expires in 26h 20m
  configured: yes — local OAuth credential ~/.codex/auth.json
Likely nighttime acorn eaters include deer, raccoons, opossums, mice, rats, and wild pigs; squirrels and jays may also cache acorns during the day. Check for tracks, scat, rooting, or gnawed shells, and set up a motion-activated trail camera to confirm the visitor.

This time the doctor has no key to look for. It points to the file where the Codex app keeps its sign-in, and says how long that sign-in has left. LM15 reads the file for each request, and renews the sign-in there when it’s about to expire, so there’s nothing to copy and nothing to keep up to date. If you’re not signed in, the error says which app to sign in with.

Two things are worth knowing before you rely on a subscription. It has its own list of models, which may not match the provider’s API. And it isn’t a promise of free use: what your plan covers, and when it charges more, is up to the provider.

So far, every program has said where its credential comes from. When you’re working at the keyboard, in a script or a notebook, it’s often more convenient to be asked once, and have the answer remembered. That’s what connect() does:

from lm15 import Message
from lm15.interactive import connect
with connect("openai-codex") as lm:
answer = lm.complete(
system=(
"You are the field assistant for a wildlife research "
"station. Answer in two sentences."
),
messages=[Message.user(
"What might be eating the acorns under our oak trees at "
"night?"
)],
)
print(answer.text)

Here’s what happened when we ran it in a terminal:

In the terminal September 24, 2026
Connections are saved privately in ~/.config/lm15/credentials.json.

Which openai-codex model?
  1. gpt-reserve  — listed by your account just now
  2. gpt-5.6-sol  — listed by your account just now
  3. gpt-5.6-terra  — listed by your account just now
  4. gpt-5.6-luna  — listed by your account just now
  5. gpt-5.5  — listed by your account just now
  6. codex-auto-review  — listed by your account just now
  7. Type a model id (not verified against your account)
Choose a number: gpt-5.6-sol
Ready: openai-codex:gpt-5.6-sol through openai-codex via your Codex CLI login (~/.codex/auth.json).
Likely nighttime acorn eaters include deer, raccoons, opossums, mice, rats, and flying squirrels, while rabbits and wild boar may also forage depending on your region. Check for tracks, droppings, rooting, or gnawed shells, and use a motion-triggered trail camera to identify the visitor.

connect() starts by saying where it keeps what it saves: one file in your home folder, readable only by you. Then it works out how to reach ChatGPT. It found the Codex app’s sign-in, which is the way LM15 supports, so it had nothing to ask. It did ask which model to use, from the list the account offers, and then returned a client tied to that connection and that model.

Run the program again and it goes straight to the model, because the connection is saved. Leave out the provider, and connect() starts by asking which one, with your saved connections first. For a provider you haven’t used before, it offers the ways to connect: subscriptions first, then keys. A key that’s already in your environment is offered as one of the choices, and never used without asking.

connect() needs a person to answer it. When there’s no one at the keyboard, as on a server, it stops straight away rather than wait forever:

What LM15 raised September 24, 2026
AuthOperationError: connect() needs a person: no interactive terminal here and no ui= was supplied. On a server, attach an Auth with saved connections (RouterConfig(auth=...)) instead of calling connect().

The station runs its assistant every night on a small server, to sort the day’s field notes. A server needs saved connections too, but without the questions. The piece underneath connect() is Auth, which looks after the saved file. Attach it to a router, and ask the doctor about Anthropic again:

from lm15.login import Auth
auth = Auth.local()
router = LMRouter(RouterConfig(auth=auth))
print(explain_auth("anthropic", config=router.config))
What it printed September 24, 2026
auth for provider 'anthropic':
   - explicit api_keys entry: not provided
   - saved connection in ~/.config/lm15/credentials.json: none saved in this scope
   ~ env $ANTHROPIC_API_KEY: set, not consulted under a managed Auth (pass it explicitly to use it)
  configured: no

Nothing is saved for Anthropic yet, and the last line of the walk is new: the environment variable is set, but the router won’t use it. Once you attach Auth, a request uses a key you give in your code or a connection you saved, and nothing else. This is deliberate. A server usually has many variables, some left behind by other programs, and a request that quietly used one of them could charge an account that nobody meant to use.

To use the variable, save it as a connection. LM15 saves the variable’s name, not the key, and reads the key from the environment each time it sends a request:

auth.configure(
"anthropic", method="env", answers={"name": "ANTHROPIC_API_KEY"},
)
print(router.complete(request).text)
What it printed claude-haiku-4-5 through Anthropic, September 24, 2026
Several nocturnal animals commonly eat acorns, with **deer, raccoons, and opossums** being the most likely culprits at most research stations. You could set up a motion-activated camera near the oak trees to identify which species is responsible, which would help us determine if it's affecting our local wildlife populations or forest regeneration.

You’d run this once, when you set the server up. After that, every program on the server that uses Auth.local() finds the connection. Auth has other ways to save one: auth.set_api_key() saves a key itself, in the same private file, and auth.login() signs in to an account, which is where we’re going next.

Some providers let LM15 run the sign-in itself. xAI is the one LM15 fully supports today. If you have a SuperGrok or X Premium plan, you sign in with a code:

from lm15.login import Auth, TerminalUI
auth = Auth.local()
auth.login("xai", "device", ui=TerminalUI())
In the terminal September 24, 2026
Open https://accounts.x.ai/oauth2/device?user_code=••••-••••
and enter this code:  ••••-••••
(the code is valid for about 30 minutes)

You open the address on any device, your phone included, and enter the code (we’ve hidden ours). The program waits until you’ve approved it, then saves the connection. Because the code works from another device, you can sign in this way on a server you reach over SSH, where no browser can open.

LM15 can run the sign-ins of other providers too, but it doesn’t offer them unless you ask. Each way to connect says where it stands:

for method in auth.methods("openai-codex"):
print(f"{method.availability:<11} {method.label}")
What it printed September 24, 2026
unverified  Sign in with ChatGPT (browser)
unverified  Sign in with ChatGPT (device code, for SSH/headless)
supported   Use your Codex CLI login (~/.codex/auth.json)

Supported ways are the ones LM15 offers by default. Unverified ones are written, and may have worked in our own tests, but we haven’t yet confirmed that the provider allows them, or how they’re billed; LM15 uses one only if you pass allow_unverified=True. Even a sign-in that works doesn’t tell you what your plan covers. That’s between you and the provider.

Auth can tell you what’s saved without contacting anyone:

for connection in auth.connections():
status = auth.status(connection.provider)
print(connection.label)
print(" ", status.usability, "until", status.expires_at)
What it printed September 24, 2026
anthropic key from $ANTHROPIC_API_KEY
    ready until never
openai-codex via your Codex CLI login (~/.codex/auth.json)
    ready until unknown

Each connection has a name that says what it is, and whether it’s ready to use. The Anthropic connection never expires, since it reads its key from the environment. The Codex connection says unknown because the sign-in belongs to the Codex app, and LM15 only checks it when it uses it.

Signing out removes a connection from the file:

auth.logout("anthropic")
print(auth.status("anthropic").detail)
router.complete(request)
What it printed September 24, 2026
signed out; sign in again or pass a key explicitly
What LM15 raised
AuthOperationError: anthropic: signed out; sign in again (Auth.login) or pass a key explicitly (api_keys)

The key is still in ANTHROPIC_API_KEY, and the router still won’t use it. This is the other half of the rule from On a server: a connection that’s missing, expired, or signed out is an error, never a reason to switch to another account. Signing out only forgets the connection on this computer; it doesn’t contact the provider. For a sign-in that belongs to another app, such as Codex, LM15 forgets its own record and leaves the app’s file alone.

  • No key found. The error names the environment variable the router looked for. Set it in the terminal where you run your program, or give the key in your code.
  • A request used the wrong account. Run the doctor with your router’s settings, as in Which key a request uses. It shows every place the router looked, and which one won.
  • “No saved connection” or “signed out”, with a key in your environment. Your router has an Auth attached, so it doesn’t read environment variables. Save a connection, or give the key in your code.
  • A subscription stopped working. Its sign-in has expired or been revoked. Sign in again with the app: codex login for Codex, or /login in Claude Code.
  • xAI ignores XAI_API_KEY. Even without Auth, an xAI sign-in saved on your computer comes before the variable, because the subscription is already paid for. If that sign-in has expired or been signed out, xAI requests fail rather than fall back to the key. To use the key, give it in your code.

Predict what will happen before you run each one.

  1. Attach an Auth with nothing saved for Anthropic, and also give the router an Anthropic key in your code. Does a request go through?
  2. In A key that changes, write a different key into the file between the two requests. Which key does the second request use?
  3. Save the environment variable as a connection, as in On a server, then unset ANTHROPIC_API_KEY and send the request. What happens?
What to expect
  1. Yes. A key in your code comes first, with or without Auth; the doctor marks it with => and says nothing is saved.
  2. The new one. The router calls your function for every request and keeps nothing between them.
  3. An error before anything is sent. The connection is saved, but it only says where the key lives, and the variable is empty. LM15 doesn’t look for a key anywhere else.

Now that the station’s assistant knows whose account it’s using, it can hold a longer exchange: Keep a conversation shows how. For every option this page leaves out, including cloud identities and each provider’s sign-in, see the Python guides to authentication and managed login.