Skip to content

Adaptations: switch providers, keep your program

lm15 makes two promises. First: change the model or provider string and your program keeps working. Second: never change what you asked for. The first comes first. The second is kept by telling you, not by stopping you.

When a provider cannot take a setting exactly as you wrote it, lm15 does the obvious thing and records it on the response:

import lm15
from lm15 import Config, Message, Request

router = lm15.LMRouter()
response = router.complete(Request(
    model="anthropic:claude-sonnet-4-5",
    messages=(Message.user("Pick a number."),),
    config=Config(seed=42, temperature=1.5),
))

for a in response.adaptations:
    print(a.field, a.action, a.asked, "->", a.applied)
# config.max_tokens  defaulted  None -> 16384
# config.seed        dropped    42   -> None
# config.temperature clamped    1.5  -> 1.0

Nothing printed on its own. The record is data on the response; look at it when you care, ignore it when you don't.

What counts as an adaptation

Only something the wire got that differs from what you asked. Ordinary translation — stop becoming stop_sequences, an effort word becoming a thinking budget — is the adapter's job and is never recorded.

Action Meaning Example
dropped no home on this wire; left out seed on Anthropic; top_k on OpenAI
clamped a dial moved to its nearest level temperature=1.51.0 on Anthropic; effort="xhigh""high" on Gemini 3
substituted the closest spelling went instead summary="concise""auto"; reasoning="off" → the lowest level where no off switch exists
client_side lm15 does it after the wire stop on the OpenAI Responses API (streamed and cut at the sequence, even on a plain call); a tool allowlist sent as only those tools
satisfied the provider's default already is what you asked store=False on Anthropic, which keeps no retrievable copy
defaulted the wire requires a value you did not set Anthropic max_tokens (the class ceiling, not a silent 1024)

Each record carries field (the config path), action, asked, applied, and a one-sentence reason naming the provider fact.

What is still refused

lm15 refuses — UnsupportedFeatureError before anything is sent — only when adapting would be a guess that could hurt:

  • a real choice is needed — a thinking budget with no effort word: which effort is 4,000 tokens?
  • your program depends on it — an image the wire cannot carry (the model would answer without seeing it), a stored cache object that does not exist on this provider, a tool the provider cannot run, n > 1;
  • nothing sensible to adapt to;
  • a wrong guess would cost money, leak data, or go unnoticed.

Every refusal names its field: error.feature is "config.top_k", "messages[*].parts[image]", "tools[web_search]", so a policy layer can drop that one thing and retry without parsing the message.

Preview without sending

plan = router.plan(request)      # tuple[Adaptation, ...]; no network

plan() raises exactly what the call would raise. It is offline like resolve(): no network, and no credential is read or invoked — a route with no key still plans. Use it to decide between routes before spending a request.

The switch

lm15.RouterConfig(adaptations="note")     # default: adapt and record
lm15.RouterConfig(adaptations="silent")   # adapt, record nothing
lm15.RouterConfig(adaptations="refuse")   # the strict mode

The same keyword exists on every LM constructor (AnthropicLM(..., adaptations="refuse")). Under "refuse", every deviation — dropped, clamped, substituted, client_side — is an error before the wire. satisfied and defaulted change nothing you asked for and are recorded, not refused.

If you log adaptations, log each distinct one once per process. A note that nags gets switched off, and then the drop is silent again.

Streaming

The record is known before the first byte, so it rides the stream's start event (StreamStartEvent.adaptations) and the assembled response alike.

A client-side stop is honoured by streaming and closing the connection at the cut — on a plain complete() call too, under the hood. Whether the provider stops generating (and billing) when the connection closes is the provider's behaviour, not something lm15 can promise; OpenAI does for an ordinary foreground stream. What lm15 does promise: the usage report only rides the provider's final frame, which is never read when the cut happens, so usage is "not reported" on those calls — never estimated. A call whose text never reaches the sequence completes normally, usage included.

The "silent" policy hides the record on the response and changes nothing else: the cut still happens, a narrowed tool list is still narrowed. plan() returns the full record under every policy.

Where the rule lives

lm15-contract/docs/mapping-rules.md MAP-13 is the normative text; the audit that applied it to every refusal in the reference is lm15-contract/changes/2026-09-14-adapt-visibly.md.