Bounding boxes (lm15-contract 6dc1c5c, 1,921 of 1,921 checks).
- Ask where something is in a picture.
bounding_box("the coffee mug")declares a judgment whose answer is four numbers,top,bottom,leftandright, each a fraction of the picture from its top-left corner (r.data["mug"]). On TypeSafe it is Jev 1.14's Box question (model="jev-preview"or"jev-1.14.0"; lm15 sends theTypeSafe-Danger-Zone-Images: trueheader with it); on OpenAI, Anthropic, Gemini and others it is ordinary structured output, so the same program runs anywhere (how close the box is depends on the model). A box is always four edges: ask ayes_nobeside it for "is it there". A box has no probabilities, soconfig.probabilitiesconcerns only the other judgments; a request whose judgments are all boxes records and refuses nothing. A hand-written object of the fournumberedges qualifies too.docs/cookbooks/20-judgments.md; lm15-contractchanges/2026-10-15-bounding-box.md.
A saved cloud login's settings are used (lm15-contract 1cbdc12, 1,912 of 1,912 checks).
- A saved cloud connection's settings are used. A connection saved with
auth.configure("vertex-anthropic", method="cloud", answers={"named": …}, settings={"project": …, "location": …})now sends that project and location; before, the router ignored them and fell back to the environment and the defaults. ARouterConfig(settings=…)entry for the same provider changes only the settings it names. Found and fixed by Rolf Fredheim (#21). lm15-contract1cbdc12. settingsaccepts either spelling of a provider name ("vertex_anthropic"as well as"vertex-anthropic"), likeapi_keysandbase_urlsalready did, on every route and inexplain_auth.
Jev reads pictures (lm15-contract 10645e5, 1,911 of 1,911 checks).
Includes 1.3.0, which was tagged but never published.
- Jev reads pictures. Put a PNG or JPEG in the one user message, on
its own (
Message.user(image(path="door.jpg"))) or beside text and data (Message.user(["Listing: a red mug.", image(path="a.png"), image(path="b.jpg")]), sent as an ordered list), and ask judgments about it. lm15 sends the bytes where TypeSafe expects them and adds itsTypeSafe-Danger-Zone-Images: trueopt-in header. TypeSafe calls images beta. A picture given by URL or file id, another format (WebP, GIF), audio and video are refused before the wire. Box questions are not supported yet. Tested live on jev-1.13.0; lm15-contractchanges/2026-10-14-jev-images.md.
The Muse Code subscription, Muse Wonder Full included (lm15-contract
88bb560, 1,905 of 1,905 checks).
- The Meta login reaches the Muse Code subscription.
auth.login("meta", "device", allow_unverified=True)was tested live on 2026-10-12 with a Power Usage plan: login, key, renewal, thencompleteandstreamonmeta:muse-wonder-full-1.0. Requests now go to the address Meta names when it hands out the key, and only to ameta.aiaddress; any other address is refused before the key is sent. The connection'saccount_labelnames the plan. The method staysunverifiedbecause Meta's terms reserve this key for Muse Code (docs/managed-login.mdsays what that risks). lm15-contract88bb560.
Two conveniences that make a first program work, and clearer messages
(lm15-contract 8eedbae, 1,904 of 1,904 checks).
-
A structured answer reads through
response.textandresponse.json. A JSON-schema request whose schema has a boolean or enum property is answered as aDataPart(judgments, MAP-14), andresponse.textandresponse.jsonwere empty for it whileresponse.dataheld the answer. A plain extraction withvegetarian: boolean, or an image question withside: {"enum": ["left", "right"]}, met this without asking for a judgment..textnow gives that answer's compact JSON, so.json,.parse_json()andjson.loads(response.text)work too. A reply with both text and data keeps.textasNone. -
Reasoning(thinking_budget=1024)works withouteffort=. The effort is filled from the same table budget-only providers use (minimal 1024, low 2048, medium 8192, high 16384, xhigh 24576, max 32768): the highest level at or below the budget,"minimal"below 1024.Reasoning()with neither is still refused. -
Messages that name the fix.
effort="none"(OpenAI's word) says lm15 spells it"off". An adapter built by hand with no key no longer says "set ANTHROPIC_API_KEY", which could not help (adapters read no environment; onlyLMRouterdoes): it says to passapi_key=os.environ["ANTHROPIC_API_KEY"]or to useLMRouter().
Two fixes, no new API (lm15-contract 880f72c, 1,904 of 1,904 checks; MAP-18
and an AUTH-1/AUTH-5 amendment, with live receipts from 18 providers).
-
A wrong or expired key is
AuthErroron every provider. Gemini and xAI answer a key they refuse with HTTP 400, not 401, and lm15 raisedInvalidRequestError, so code catchingAuthErrormissed them; the Gemini SDK and LiteLLM call it an authentication error. Gemini is recognized by the reason its error carries (API_KEY_INVALID, which also covers an expired key), not by the wording; xAI by its exact sentence. The other sixteen providers probed already answered 401. A Gemini 400 about anything else is stillInvalidRequestError. lm15-contract MAP-18 (changes/2026-10-10-bad-key-and-misplaced-key.md), table inlm15.errors.AUTH_FAILED_FORMS. -
A key passed as
credential=is no longer printed.AnthropicLM(credential=os.environ["ANTHROPIC_API_KEY"]), an easy slip sincecredential=takes the name of a cloud identity ("platform","workload","environment","cli"), raised aNotConfiguredErrorthat repeated the key in its message, and so in tracebacks and logs. The same was true of an unknown name on a cloud door and ofRouterConfig(credentials={...}). The refusal now says the value is not shown because it may be a key, and that a key goes inapi_key=. A value that is one of the four names is still repeated. -
Reasoning(thinking_budget=0), Gemini's own spelling of "thinking off", still raisesValueError, and the message now says how lm15 spells it:Reasoning(effort="off").help(lm15)points to the tested recipes at https://lm15.dev/skill.md.
Two fixes, no new API (lm15-contract 0f3ea82, 1,901 of 1,901 checks; INV-056
and MAP-17, both with live receipts).
-
Long streamed replies no longer fail at the last moment. The stream reader refused any line over 64 KB and any event over 1 MB (
TransportError: SSE line exceeds limit (68021 > 65536)), and real streams are larger: OpenAI Responses repeats the whole response, system prompt included, in its first and last events, so a long answer or a long system prompt failed after the answer had been generated and billed; its image tool sends a 4.2 MB line, and Gemini sends a 4K image as one 29.7 MB line. Streams now take an event of any size, as a non-streamed reply always did;lm15.sse.parse_sse()keepsmax_line_bytes=/max_event_bytes=as opt-in caps. Splitting a long line out of many network reads is now linear (a 30 MB line: 0.2 s, was 3.5 s, growing with the square of its length). lm15-contract INV-056 (changes/2026-10-06-sse-event-bound.md), live caseopenai.streaming_long_line(three 1.1 MB lines). -
A tool with no description works on every provider. A
FunctionToolbuilt with only a name and parameters was sent with"description": null, which Anthropic and Groq refuse with a 400 ("Input should be a valid string", "Value is not nullable"); OpenAI, Gemini and the other servers tolerated it. The description key is now left out when the tool has none (""counts as none, as it already does in lm15's own JSON), on every wire: Messages, Responses, Chat Completions, Gemini (including cached prefixes and Live), and OpenAI Realtime. A tool that has a description is sent exactly as before. lm15-contract MAP-17 (changes/2026-10-02-tool-description-absent.md), live receipts on 11 HTTP wires and both live sessions; contractf6465c8.
Claude Code and Claude defaults (lm15-contract
changes/2026-09-30-claude-code-client-version.md, ratified 2026-09-30; contract
57e33d1, 1,838 of 1,838 checks; live receipts 2026-09-30). Also since 1.1.0:
input_audio in Chat Completions ingest reads ogg, opus, flac, aac, aiff, webm and
mpeg as their true media types (#16, contract 307925a).
- The Claude Code release lm15 claims is current and settable. The
claude-codedoor now says it is Claude Code 2.1.285 (it said 2.1.170, whichclaude-opus-5-5refuses: "version 2.1.280 or newer is required"). When a model needs a newer release before lm15's next one, setRouterConfig(settings={"claude-code": {"client_version": "..."}})orLM15_CLAUDE_CODE_VERSION(the router reads it), or passClaudeCodeLM(settings={"client_version": ...});claude_code_version=still works. The Codex door'sclient_versionis the same setting (LM15_CODEX_CLIENT_VERSION).explain_authprints the release and where it came from. - The refusal says what to change: the server's "run claude update" does not move what lm15 sends, so the error now names the setting.
- A settings entry nothing reads raises
NotConfiguredError(it was ignored), naming the settings that door does take. - Claude's default
max_tokensis the model's own output ceiling: 128000 for the 4.6 generation and later, 64000 for 4.5 (it was 16384, which replies with reasoning on routinely ran out of). On the manual class the ceiling covers the thinking budget too. Other models on Anthropic-dialect servers keep 16384. Recorded asdefaulted, as before. - The note for a
thinking_budgetdropped on an adaptive-class Claude says what bounds thinking there:max_tokens(thinking and answer together).
New providers and a Google Cloud pass, both live-verified in Python,
TypeScript, Rust and Go (contract fe5cdf9; 1,788 of 1,788 checks in each).
Google Cloud (lm15-contract changes/2026-09-26-vertex-live.md, ratified
2026-09-26):
- The Vertex project is found where Google's own tools find it, first found
wins:
settings={"vertex": {"project": ...}},GOOGLE_CLOUD_PROJECTthenGCLOUD_PROJECT, theGOOGLE_APPLICATION_CREDENTIALSfile'sproject_id, gcloud's active configuration (CLOUDSDK_CORE_PROJECT, the savedgcloud config set project, named configurations), the ADC file'squota_project_id, then the metadata server on Google Cloud (NO_GCE_CHECK=1skips it). Before, a project set only in gcloud or only on the VM was not found. - A Vertex API key works on the
vertexdoor: a plain string goes inx-goog-api-key; token-shaped strings (ya29., a JWT) and aBearerTokenstill go asAuthorization: Bearer. lm15 doctor/explain_authnames where each setting came from (setting project: my-project (from gcloud's active configuration)).- Each Google failure names its fix: a stale ADC login, impersonation or federation that is not set up, an IAM 403, a 401 on a non-token string, nothing configured. No secret appears in any of them (AUTH-5, AUTH-21).
- Live, through lm15's own chain: 13 setups (gcloud login, service-account
key, impersonation, workload identity federation, a plain token, API keys
on
vertexandvertex-express, a VM with only its attached account), all 200. docs/cloud-hosts.md § Google Cloud, start to finish.
Four new providers, each live-tested (lm15-contract
changes/2026-09-26-inference-hosts-live.md): deepinfra, together,
fireworks, parasail — open-model hosts on the Chat Completions wire,
keys DEEPINFRA_API_KEY, TOGETHER_API_KEY, FIREWORKS_API_KEY,
PARASAIL_API_KEY, and litellm's deepinfra/, together_ai/,
fireworks_ai/, parasail/ prefixes. Reasoning is sent back as
reasoning_content, never pasted into the visible text. Per-model rules
with receipts: DeepInfra sends a forced tool call only
to the 14 models a survey showed honour it, and refuses it elsewhere; Together refuses one on gpt-oss (the server answers
500) and clamps gpt-oss efforts to low|medium|high; reasoning off becomes
the lowest level, recorded, where the server accepts none and reasons
anyway (Together gpt-oss and GLM-5.3, DeepInfra gpt-oss). Images in tool
results reach the model on Fireworks and Parasail.
New compat knob OpenAIChatCompat.reasoning_off ("send" | "lowest",
keyword-only, overridable per model).
Two fixes on the Chat Completions dialect, found on these hosts:
list_models()reads a catalog that is a bare JSON array (Together). Before, it returned no models without an error; a reply that is neither that nor{"data": [...]}now raisesProviderError("malformed provider reply").usage.cache_read_tokensfalls back to a flatusage.cached_tokenswhenprompt_tokens_detailshas none (Together's non-reasoning models). Before, a reported 0 read as "not reported" (None).
Behaviour change: a RouterConfig(providers=...) that declares one of the
four ids now fails at construction ("already names lm15's … door"); delete
the declaration. The router guide's example now declares Nebius.
Managed sign-in (provisional), two corrections found while TypeScript, Rust and Go implemented the same component against the shared contract runs:
- A pasted sign-in return that fails validation (wrong state, a URL that is not the registered return, a bare code the provider's profile refuses) is rejected with a notice and asked for again; the loopback listener, when there is one, keeps listening. Before, it ended the sign-in. AUTH-18 says a wrong return "does not terminate the legitimate wait".
- A request that finds another process renewing the same token now waits
for that renewal and uses its result. Before, it failed
indeterminate("sign in again") while the other process was still renewing. A renewal marker still present once the lock is held (the renewing process died) remainsindeterminate.
The vet shim answers the contract's new managed_run op. Contract:
lm15-contract 96bb1f4.
Gemini: a schema now reaches Gemini in the field that can carry it
(MAP-16). A tool whose parameters use JSON Schema that Gemini's OpenAPI
parameters field refuses — additionalProperties, const,
$defs/$ref, a list type, an integer enum, a boolean schema — is
sent as parametersJsonSchema (generateContent, cached prefixes, Live);
before, Gemini answered 400. The same rule now picks between
responseSchema and responseJsonSchema for structured output, which
before switched only on additionalProperties: a Pydantic-shaped schema
($defs/$ref) works. Schemas that worked keep working; the schema is
sent verbatim either way.
The first stable release. pip install lm15 now installs it (no --pre).
It is 1.0.0rc3 plus the changes below.
Why 1.0.1 and not 1.0.0: a 1.0.0 was uploaded to PyPI on 2026-06-11 by
mistake and removed seven hours later (see "Re-stage 1.0.0 as 1.0.0a1"
below). PyPI never accepts a removed version number again. There is no
1.0.0 of lm15; 1.0.1 is the first stable release. Contract: lm15-contract b0ff3c0.
What 1.0 promises. The chat core is frozen for 1.x: the canonical types,
their JSON form, errors, request and response mapping, streaming, credential
resolution and model listing. Removing or changing any of it needs a 2.0.
Provisional, and allowed to change during 1.x with a contract change entry:
files, batches, image/speech/video generation, stored-cache resources, live
sessions, Chat Completions ingest, and managed sign-in (lm15.login,
connect()), whose provider permission and billing are unverified except
for xAI. Pin an exact version if you use those. See docs/roadmap.md.
- Type checkers accept the documented calls.
Request(messages=[...]),tools=[...],Config(stop=[...]),Message(parts=[...])and the other value types that store a tuple but accept a list now say so to mypy and pyright (generated__init__signatures; runtime unchanged). Before, every documentation example failedmypy --stricton its firstRequest. JSON inputs (response_format, toolparameters,extensions,DataPartvalues) accept an ordinarydict, since their contents are checked at runtime;Message.toolaccepts any mapping. The transportreleasecallbacks and three annotations naming unimported types are corrected. - No silent content loss (MAP-10, MAP-6; behaviour change). A media part
a provider's format has no place for now raises
UnsupportedFeatureErrornaming the part (feature="messages[i].parts[j]") before anything is sent. Before, 1.0.0rc3 sent audio, video or binary parts to Anthropic as an empty text block, and dropped media in assistant turns on the OpenAI formats, without a word. Likewiseconfig.cache.resourceon a provider with no stored caches (Groq, DeepSeek, Z.AI, Ollama, xAI, Meta's Anthropic door) raises instead of being ignored: the resource holds the start of the prompt. - Windows. A local path in canonical JSON (
FileUploadRequest.path, media parts'path) is written with/on every OS, as INV-009 now says; on Windows it was\data\clip.mp4, so the same request serialized differently there. A cloud CLI's output (az,gcloud) is decoded as UTF-8 leniently, so an unusual character in its messages cannot crash a sign-in. - CI: tests on Python 3.10–3.14 × Linux, macOS, Windows, with deprecations and text I/O that does not name its encoding as errors; a type-checking gate.
Third release candidate for 1.0, and the first published since 1.0.0rc1:
1.0.0rc2 was prepared on 2026-09-15 but never uploaded, so everything in the
rc2 section below also ships here for the first time. Install with
pip install --pre lm15 (or pin lm15==1.0.0rc3); plain pip install lm15
still selects 0.9.9.post1. Contract: lm15-contract fc0c460.
-
Managed authentication (contract
spec/auth-managed.md, AUTH-12–26, ratified 2026-09-22).lm15.login.Auth/AsyncAuthover one private store (credentials.jsonplus a non-secret_lm15block), sign-in flows for xAI, Claude, Codex, OpenRouter, Meta, Kimi Code and GitHub Copilot, renewal under the store lock, andAuthOperationErrorwith fifteen closed reasons (ErrorCodegainsauth_operation).lm15.interactive.connect()offers saved connections first and subscriptions before keys, saves a sign-in before the model picker, and returns a client pinned to one connection.RouterConfig(auth=...)never falls back to an environment key or a foreign CLI file. Claude's browser method uses Claude's hosted code page, so the browser may run on another machine; auth HTTP identifies itself aslm15/<version>. An interrupted sign-in releases its reservation. Seedocs/managed-login.md. Provider permission and billing for these logins are unverified except for xAI. -
A failed xAI subscription blocks
$XAI_API_KEY(R3, behaviour change). An expired login that cannot be renewed, or a signed-out one, now raisesMissingCredentialErrorsaying so, instead of silently spending the environment key. Pass the key explicitly to use it. Callers who never signed in are unchanged. -
"No such model" is
UnsupportedModelErroron every provider (contract MAP-15, behaviour change). Anthropic, Claude Code, DeepSeek (both wires), Z.AI, OpenRouter, xAI and Bedrock used to raiseInvalidRequestErrorfor a model name they do not have; oneexcept UnsupportedModelErrornow covers every provider. Matching uses the providers' own recorded wording, never looser, so "this model does not support images" stays anInvalidRequestError. -
Judgments (contract MAP-14).
DataPart(structured data in, a judged answer out),Config.probabilities(off/if_available/required),lm15.judgments(choice,score,yes_no,judgments), thetypesafeprovider (jev-*models), and a candidate-likelihood driver for servers that honourlogprob_token_ids(vLLM preset). -
A cache key or long cache retention a provider's wire has no place for is dropped with an adaptation record (MAP-13), no longer silently.
-
Codex: an output cap or
store=Trueis refused, not silently stripped. The ChatGPT Codex backend accepts no max-token field; lm15 used to dropconfig.max_tokenswithout a note, leaving the caller's spending limit unapplied. It now raisesUnsupportedFeatureError(feature="config.max_tokens", or"config.store"), as MAP-13 rule 4 requires and as the Rust and R SDKs already did. Remove the cap for this provider, or use one that honours it. -
Removed tool derivation from functions (breaking, before 1.0):
tool,derive_tool,ToolConfig,ToolDerivation,DerivedParam,ToolDerivationErrorand thelm15.toolsmodule. A tool is written as aFunctionTool(name, description, JSON Schema), as in every other lm15 language. A function passed inRequest.toolsorLiveConfig.toolsis refused with that fix named. Why: docs/design-rationale.md, "Why no tools from functions?". Code that calledtool(fn)builds the same tool by writing itsFunctionToolout; the schema it derived is printable with lm15 1.0.0rc1–rc2 if needed. -
Provider errors retain bounded, immutable
rate_limit_headersevidence and display advisory retry/limit details without changing the provider message. Add millisecond retry-header fallback and Azureapim-request-id; no automatic retry or endpoint switching.ErrorDetail.http_responsepreserves handshake diagnostics through streamed errors and canonical JSON replay (omitted empty). Explicit Responsesno_capacity/too_many_requestscodes map to rate limits, not an inferred failure of endpoint compatibility. Shared rules and fixtures: contractchanges/2026-09-19-rate-limit-diagnostics.md. -
Provider builders inherit the configured transport budget, including Jev, tokenization/scoring, models, files, batches, caches and media endpoints. Removed fixed 30/60/120/300/600-second builder deadlines; absent a caller setting, the shared 600-second read default applies. Slow/stalled auxiliary operations may now wait longer. Credential exchanges retain their distinct deadlines; no canonical Request/Config transport fields were added.
-
TypeSafe rejects missing/malformed measurements, undeclared answers/choices, incomplete distributions and non-finite/out-of-range probabilities with a non-retryable
ProviderError. Unknown usage staysNone; explicit zero stays zero. Reported distributions are not normalized and their totals are not validated, as INV-052 requires; DataPart's existing behavior here was correct, not a defect. Previously tolerated malformed replies now fail. Token-scoring replies also reject malformed token/index/log-probability shapes, preserve missing usage, and reject an all-zero-likelihood key set instead of manufacturing a normalized distribution. Missing requested token ids still follow MAP-14's recorded fallback/refusal rule. -
FetchTransport bounds the initial fetch/header wait and each body read, aborts on timeout/cancellation (including before headers), and releases reader ownership on completion/early close. Closing the transport aborts active requests. Visible unsupported content codings raise
ProtocolError; host-decoded gzip/deflate bytes are never inflated twice. Browsers control socket connect/write deadlines, pools, compression negotiation and CORS header visibility; hidden codings cannot be checked. These changes have regression tests added but not executed in this repair pass. -
Non-JSON reply errors retain all supported request-id header spellings on the pure
HttpResponse.json()path, including TypeSafe and Azure. Sync and async auxiliary decoders (models, files, batches, caches and media) now use the same non-retryableProviderErrorboundary for malformed JSON and invalid reply shapes, retaining status, request id, retry advice and rate-limit evidence. Batch JSONL faults retain the offending result fetch's diagnostics, not the job-status reply's; blank lines remain accepted. Binary downloads/speech and empty delete acknowledgements are not forced through JSON parsing; local pending-job and unsupported-feature errors retain their existing types. Regression sources were added, not executed. -
Client-side stop filtering preserves original events, their order and token scores. A cut inside a token omits that token's score and marks
TextDelta.logprobs_complete/Response.logprobs_completefalse; the flag survives canonical JSON. No probability is invented for shortened text. Keeping whole events may delay delivery until an event boundary is safe. -
Live turn collection defaults to 16 MiB of compact ASCII JSON event data or 10,000 events.
session.turn(max_bytes=..., max_events=...)configures either budget.CollectionLimitError(collection_limit, non-retryable) preservespartial_eventsand anyrejected_event, without closing the session or pretending the turn completed.error.partialassembles the incomplete turn on demand. These are data budgets, not exact process-memory limits; long turns can use larger budgets or raw session iteration. -
Compressed replies handle deflate headers split across reads and every gzip member, including split subsequent headers, empty members and zero padding. Corrupt/truncated later members and trailing nonmember data raise
ProtocolErrorinstead of losing response bytes. Standard zlib headers win over legacy raw-deflate interpretation; corruption is not retried as a different format after output. No new runtime dependency. -
Cloud identity and endpoints (spec/auth.md AUTH-1/AUTH-2/AUTH-7/AUTH-10, amended 2026-09-19;
changes/2026-09-19-cloud-identity-and-endpoints.md):RouterConfig(credentials={"azure": "platform"})(also"workload","environment","cli"; the same four names on Azure, AWS and Google) runs one named identity on a cloud door and never walks the chain; an absent identity isNotConfiguredErrornaming what was probed. Bare adapters takecredential="platform". A key and a name for one door is refused.- Every
AuthErrorfrom the wire names where the credential came from (the chain rung, the env variable, "an explicit api_key", "an application-supplied callable") — never the value. Cloud chain providers expose.source; adapters exposecredential_origin(). RouterConfig(base_urls=...)is accepted on cloud doors as the endpoint root; the door appends its path (/openai/v1,/anthropic/v1) unless present and keeps its auth scheme, error mapping and doctor. The vendor's variables are read first:AZURE_OPENAI_ENDPOINT,ANTHROPIC_FOUNDRY_BASE_URL,AWS_ENDPOINT_URL_BEDROCK_RUNTIME/AWS_ENDPOINT_URL_BEDROCK_MANTLE/AWS_ENDPOINT_URL. With an endpointresourceis optional;regionstays required on AWS (it signs). Before this abase_urlsentry for a cloud door was refused, and the only way to reach a Foundry root was to leave theazuredoor.- A plain JWT string on a key-header-first door (Azure
api-key, Foundryx-api-key) is sent as a bearer token instead of refused;BearerToken(...)stays accepted. explain_authreadscredentialsandbase_urlsfromconfig=, walks only the named rungs in named mode, and prints the base URL and where it came from.- The Azure OpenAI template stays
{resource}.openai.azure.com: the only host a classicOpenAI-kind resource answers on (DNS-verified 2026-09-19). The Foundry root the console shows goes inAZURE_OPENAI_ENDPOINT.
-
RouterConfig(providers=(ProviderDefinition.chat(access, compat=...), ...))declares a provider the registry does not list — a gateway, a service lm15 has not receipted — as the same pure-data triple a registry entry is. It routes by id and aliases (id:model,id/model, either spelling), takesapi_keys/base_urlsentries, shares the router's pool and timeouts, and plans without a key.Resolution.declaredis true anddescribe()says "no lm15 receipts": a declaration is the caller's word, not a capture. A spelling a registry entry or litellm prefix already uses is refused at config time.ProviderDefinitiongains.chat(),.responses(),.anthropic()constructors, a compat object for declared entries, andaliases; a declared entry is key-based (the OAuth policies need their own adapter class).Resolutioncarriescredential_policyandplaceholder_key, sodescribe()reads the resolution, not the module tables. Before this, an unlisted server meant constructing the dialect LM directly and losing the router's credential, address and connection rules (found by the DSPy Fireworks custom engine, 2026-09-16).
Shared score, timeout and live-collection rules were ratified on 2026-09-15. Other language implementations must adopt their new contract pin separately; this release does not claim they already implement the additions.
Second release candidate. Install explicitly with pip install lm15==1.0.0rc2;
a prerelease, not the stable 1.0. Two things changed what lm15 is since rc1
and are stated first: MAP-13 (a setting a wire cannot take as asked is adapted
and recorded, refused only when a guess could hurt — contract pin d1293f7,
ratified 2026-09-14) and the connection budget (provider-SDK timeouts, a
shared pool per router). Both came out of the DSPy gauntlet
(cmpnd-ai/breaka-your-lm, 2026-09-13). The rest are the rc1-era entries that
had not been released.
MAP-13: adapt freely, never invisibly; refuse only when a guess could
hurt (lm15-contract/docs/mapping-rules.md MAP-13, ratified
2026-09-14; audit in changes/2026-09-14-adapt-visibly.md; guide in
docs/adaptations.md). A setting a wire cannot take as asked is now
adapted — dropped, clamped, substituted, applied client-side, noted as
already satisfied, or defaulted — and RECORDED on Response.adaptations
and StreamStartEvent.adaptations (Adaptation(field, action, reason, asked, applied)); lm.plan(request) / router.plan(request) preview
the record with no network and no credential invoked (a route with no
key still plans). adaptations="note" | "silent" | "refuse"
on every LM constructor and on RouterConfig; "refuse" is the
pre-change strictness. Roughly twenty refusals became adaptations:
top_k on the OpenAI wires, seed/penalties on Anthropic, `temperature
1
on Anthropic (clamped to 1.0),stopon the Responses wire (honoured by streaming and closing at the cut, oncomplete()too; usage is then not reported — whether the provider stops generating on a closed connection is its own behaviour), thinking-summary levels, an effort word with no level (nearest), a budget beside an effort,reasoning=offon Gemini 3 and Grok (the lowest level), tool allowlists on Anthropic/xAI/Z.AI (only those tools are sent),parallel=Falseon Gemini,cache.key/cache.retentionwhere absent,storeon Anthropic (falsesatisfied,truedropped),user_idon Gemini,logprobson Anthropic/xAI, a cache breakpoint on an ineligible message (walks back), and every "this server silently ignores X" refusal. Refusals that stay (a part with no wire slot,cache.resourcewhere no stored tier exists, a builtin tool the wire cannot run,n > 1, a budget with no effort) carryUnsupportedFeatureError.feature` = the config path.
Config.seed, Config.frequency_penalty, Config.presence_penalty
are canonical (promoted from extensions): openai_chat and every
OpenAI-compatible preset verbatim, Gemini generationConfig.seed / frequencyPenalty / presencePenalty (as extensions they reached Gemini at
the wrong path), OpenAI Responses and Anthropic drop with a record.
Config.temperature is now [0, 2] (was >= 0). The OpenAI-chat ingest
reads them, reads top_k into Config.top_k, translates the deprecated
functions / function_call shape to tools / tool_choice, and
passes prediction through as an extension.
Anthropic max_tokens when unset is the class ceiling (16384 for
the 4.x class and unknown names; 8192 for claude-3-5; 4096 for
claude-3-*), recorded as defaulted. Until now a silent 1024 cut
ordinary answers off with nothing said.
The ollama preset carries the reasoning dial (thinking_format= "reasoning_effort", with Ollama's own clamps declared): Ollama's
OpenAI endpoint maps reasoning_effort to think
(openai/openai.go thinkFromReasoningEffort). The 2026-09-11 refusal
rested on a preset line written without a receipt and is reversed;
lmstudio is now its own preset (unreceipted hypothesis: no dial →
dropped with a record, never refused on it).
The connection budget is a first-class setting, and its defaults are
the provider SDKs'. RouterConfig(timeouts=Timeouts(connect=, read=, write=, pool=), max_connections=) shapes the one transport a router now
builds and shares across every LM it constructs (before: one pool per
provider, no way to reach its timeouts short of building the transport by
hand). Defaults moved from 60 s read/write and 10 connections to 600 s
read/write/pool and 100 connections — what OpenAI, Anthropic and litellm
wait, because a model that thinks for minutes before its first byte is
ordinary and a 60 s client limit reported it as a network failure (and,
under a retry loop, restarted the generation each time). Every timeout is
per operation (the next byte), not per request. The read-timeout message
now says it is lm15's limit and names the knob. LMRouter.close() /
AsyncLMRouter.aclose() (and with) release every connection; a
transport that is simply dropped closes its idle sockets on collection
(no ResourceWarning under -W error). StdlibTransport(pool_timeout=)
replaces the fixed 5 × connect_timeout slot wait. Found by the DSPy
gauntlet (cmpnd-ai/breaka-your-lm, 2026-09-13).
Compressed replies are decoded. A reply that arrives Content-Encoding: gzip/x-gzip/deflate (gateways and CDNs ignore lm15's Accept-Encoding: identity) is inflated incrementally through the stdlib zlib, so a
compressed stream still streams; before, the compressed bytes reached the
JSON parser and failed as a utf-8 decode error. br and zstd raise a
ProtocolError naming the coding.
Two input/reply faults stop masquerading as network faults. A 200 whose
body is not JSON (a gateway's HTML error page, a truncated reply) raises
ProviderError (code provider, the contract's "a reply that cannot become
a Response without inventing one") carrying the status, content-type and
the first bytes, instead of a bare JSONDecodeError that escaped every
except LM15Error. Text containing a lone surrogate (not valid Unicode; no
UTF-8 form exists) raises ValueError before the wire, naming the code
point, instead of a UnicodeEncodeError deep in the transport.
HttpResponse gained an optional provider field for that error's name.
import lm15 no longer assumes ssl, fcntl, or a home directory. Each
platform check moves out of module scope and into the code path that needs it,
so lm15 imports on CPython for wasm32-wasip2 (componentize-py) as it already
did on Pyodide. Plain HTTP works on a build without ssl; the first https
request on such a build raises ConnectError naming FetchTransport. Where
neither fcntl nor msvcrt exists, or no home directory can be found for the
lock, refreshing a stored credential raises NotConfiguredError (the class
lm15-ts uses for the same case) with the way out named; reading still works.
The credential-path constants keep their ~/... form unexpanded instead of
failing the import. Contributed by Adriaan Moors (#8).
Deprecated: ProviderProfile / EndpointProfile, OpenAILM.from_profile,
OpenAILM(profile=...), and the compat guessed from base_url — removed in
1.0.0. Say it with compat= (a preset name supplies its address), base_url=,
OpenAIChatCompat.model_overrides, or Config.extensions["openai_responses_compat"].
Each emits a DeprecationWarning naming the replacement. ModelInfo,
ModelRegistry, the compat policies and the request-level hatch stay.
Decision: lm15-contract/changes/2026-09-11-job-handles-live-turns-profiles.md § 3
(which also makes BatchJob / VideoJob and live turn() family surface —
Python already has them — and states the wait-deadline rule: wait(timeout=)
elapsing raises the builtin TimeoutError, the caller's own deadline, not an
lm15 error).
A reasoning dial on a wire with no reasoning field is a refusal — reversed
in this same release by MAP-13, above: Ollama has the dial (reasoning_effort
→ think, from its source), the preset line it rested on had no receipt, and
the lmstudio preset now drops a set dial with a record rather than refusing
(THEORY.md §3.17). Kept here as the history of how the rule was found wanting.
OpenAIChatLM raises UnsupportedFeatureError before the wire when
config.reasoning is set and the bound compat's thinking_format is
none (the ollama and lmstudio presets). Until now the request went
out with the dial silently dropped — a test even pinned that — which is
the omission the family forbids (MAP-5, MAP-7 rule 2): on a model that
reasons by default the caller paid for tokens they asked to limit. Found
by building one request in Python, TypeScript and Rust side by side in
the lm15-ts playground: Rust already refused. Pinned by
cases/ollama/reasoning_effort_refused.json
(changes/2026-09-11-reasoning-dial-without-a-field.md, pending
ratification). Stated: a caller who set reasoning on an ollama model as
a harmless default now gets an error naming the two ways out.
lm15 in a page: FetchTransport. lm15.transports.FetchTransport is the
async transport over the host's fetch, for Pyodide (CPython compiled to
WebAssembly: a browser page, a worker, or Node hosting Pyodide), where there
is no socket. The async adapters take it as transport= and are otherwise
unchanged; the request bytes are the stdlib transport's. Streams chunk by
chunk; dropping the response before its end aborts the request. Under
CPython, constructing it is a TransportError naming Pyodide. Stated: a
page's CORS refusal and a network failure arrive the same way (fetch
says nothing), there is no separate connect timeout, no proxy.
Two import-time faults found by loading the wheel into Pyodide, fixed:
_authlock imported fcntl at module level (absent on Pyodide, and on
any POSIX build without it — the lock now refuses by name when used),
and the socket transports imported ssl at module level (unvendored on
Pyodide — now optional; the socket transports refuse at connect time,
naming FetchTransport). import lm15 works everywhere the stdlib does.
Evidence: lm15-ts/tests/pyodide.test.ts runs this package under Pyodide
and builds every contract request identically to the TypeScript port
(342 identical, 23 refused identically).
First release candidate for 1.0. Install explicitly with
pip install lm15==1.0.0rc1; this is a prerelease, not the stable 1.0 release.
The changes below include breaking changes since the earlier public releases.
The contract pin is unchanged; publishing this candidate does not ratify draft
contract material or freeze provisional endpoints.
- Add a tested tutorial for unlisted OpenAI-compatible servers: default policy,
custom
OpenAIChatCompatobjects, adapting presets, and offline request checks. - Clarify custom router addresses and the difference between Chat Completions and Responses compatibility policies.
- Publish from the single source version in
lm15/_version.py; check release artifacts and the installed wheel's version before upload. - A named server never resolves to the OpenAI cloud.
compat="lmstudio"is its own preset (ollama's wire policy, LM Studio's documented addresshttp://localhost:1234/v1); before, the name missed the address table and the request went toapi.openai.com. The Responses door gains the local engines' roots (ollama,lmstudio,vllm,sglang). A preset name with no known address in the chosen dialect (qwenon Chat;qwen,deepseek,zaion Responses; the host-templatedbedrock*outside the registry) now raisesNotConfiguredErrorat construction unlessbase_url=is given.preset_base_urlinlm15.compatis the one lookup all three dialect adapters use.
Error and completion hardening. HTTP errors retain request IDs from headers
when the provider body did not supply one; invalid/non-finite retry hints are
ignored. stream_assembly now maps back to StreamAssemblyError consistently.
Stream-to-response helpers require a final end event and reject trailing events
(both StreamAssemblyError, with partial), close their sources, and preserve
the primary error or cancellation if cleanup also fails. A failure that follows
the end event — the source raising while it drains, or close() raising — never
withholds the completed Response: it is returned, the failure is emitted as a
StreamCleanupWarning and recorded on ResponseStream.cleanup_errors /
AsyncResponseStream.cleanup_errors. A stream closed by the caller before its
end event raises StreamAssemblyError (with partial) from .response, not a
bare RuntimeError (an earlier revision on main re-raised it
as a StreamAssemblyError carrying the complete response in partial; that
withheld a billed answer over a connection's afterlife and is gone). Use
StreamAccumulator.response() explicitly when inspecting an unfinished stream;
materializers no longer report it as success. Contract:
lm15-contract/changes/2026-09-11-stream-completion-and-error-metadata.md.
Three defects found by the Rust port (2026-09-07). Config.stop and ToolChoice.allowed given as a bare string were iterated into characters by from_dict (INV-020 says one element); response.usage: null crashed response_from_dict (INV-042 says a telemetry nest reads as absent); the vet shim could not build the Vertex adapter (it passed compat=None to the Gemini dialect). All three fixed; no fixture changed.
Baseline review (2026-09-06). These changes are not a frozen release.
The shared contract still contains draft amendments and draft canonical
expectations. CONTRACT_PIN must move only after that review is complete.
Ratified 2026-09-06 (lm15-contract/lm15-contract/changes/2026-09-06-decisions.md, changes/2026-09-06-ratification.md). The paragraphs below implement the decisions by number.
D1 — AUTH-2 scheme selection. select_scheme implements the ratified table: an ApiKey takes the policy's first header-carrying scheme in policy order (bearer, x-api-key, api-key, query-key); a BearerToken takes bearer if the policy lists it, else x-api-key if it lists it; AwsCredentials take sigv4 only. Anything else raises NotConfiguredError, and the message now names the schemes the credential kind accepts next to the schemes the door offers. Cost, stated: a token handed to first-party anthropic goes in x-api-key and gets the provider's 401, not a local error.
D5 — hidden thinking. BREAKING: ThinkingPart.redacted is removed, and thinking(content, *, continuation=None) loses the redacted keyword; serde neither reads nor writes the key. Hidden thinking is a ThinkingPart with empty text and its replay state in continuation (MAP-7 rule 11): Anthropic redacted_thinking becomes ThinkingPart(text="", continuation=[anthropic:redacted_thinking {"data": blob}]); in a stream, ThinkingDelta(text="") opens the block and the ContinuationDelta carries the blob. The [redacted] placeholder text is gone everywhere. Replay is unchanged: the blob goes back as redacted_thinking.
D6 — FileReadiness fold. One table for every OpenAI-shaped file object (openai, azure, meta), in lm15.providers.common.openai_file_readiness: uploaded | pending → pending, error | failed → failed, processed | absent | unknown → ready. failed is new; the rest is unchanged.
D7 — continuation namespace. Verified: every adapter names the dialect (openai, anthropic, gemini) as ContinuationState.provider, never the door; a Meta, Azure or Moonshot reasoning item is openai:reasoning_item. Pinned by tests; no code change.
D8 — message-level id continuation. BREAKING: no dialect emits openai:response_id, gemini:response_id or anthropic:message_id continuation state on the assistant message, on either the complete or the stream path. Response.id and StreamStartEvent.id carry the id, as before. Nothing in lm15 consumed those states. INV-051 (stream/complete parity) is pinned by a test over every contract stream body: the Response materialized from the stream equals the complete parse of the body the frames fold into, except the fields the wire withholds on one path — the chat dialect's per-chunk id and served model snapshot, and Gemini's per-chunk responseId (no start frame, MAP-4/MAP-9.6). One known gap is recorded as a strict xfail, not repaired: on gemini.streaming_tool_call the complete path mints fc_0 for the id-less call and the assembler mints tool_call_0 (MAP-9).
D9 — StreamEndEvent.provider_data. Every dialect's end event carries, verbatim, the wire frame that supplied usage, else the frame that supplied finish_reason; bare terminators ([DONE], message_stop) contribute nothing. New on the chat dialect and its bound providers (the usage chunk, else the finish chunk), on xAI, and on the Anthropic dialect (the message_delta frame); unchanged on Responses (response.completed's response object) and Gemini (the last chunk). The MAP-3 coalescer now ranks adapter end events so the usage frame's provider_data wins over a finish-only frame in either order. It is an escape hatch, not a canonical fact: the harness compares it by presence and type.
D10 — no delimiter parsing. Verified: no adapter parses <think> / <reasoning> tags out of provider text; such text stays literal on every dialect, pinned by tests. Where a server knob separates reasoning (Groq reasoning_format: parsed), the preset sends the knob.
D16 — reference-only. Async adapters run a credential provider callable through asyncio.to_thread, so a cloud-chain refresh does not block the event loop; a static credential is read inline with no thread. docs/cloud-hosts.md names the mitigation for the stdlib RSA signer's timing limit: an external token provider (BearerToken from your own signer or the cloud CLI) for high-value keys. The reasoning cookbook describes hidden thinking in the D5 shape.
- Preserve old
AccessPolicyand compatibility-policy constructor calls. New options are keyword-only. An explicit legacyauth_headeroverridesauth_scheme, including indataclasses.replace. - Fix Vertex routing, direct Responses compatibility binding, and unsupported live-endpoint checks.
- Preserve empty thinking items and final replay state in Responses streams
under MAP-7.8–9. The independent correction covers Meta and Moonshot;
changes/2026-09-06-streamed-reasoning-state.mdrecords the fixture evidence. - Keep credentials out of redirects, error messages, object displays, and unintended subprocess environments. Validate cloud host names, metadata URLs, credential response types, and cached expiry.
- Use the configured HOME for both cloud resolution and the doctor. Follow AUTH-1: continue through Azure developer-command failures, but stop on a failed deployed credential.
- Check out the pinned contract before CI test collection. Reject a changed contract checkout, including untracked files.
Bedrock mantle Chat Completions (lm15-contract/changes/2026-09-04-bedrock-mantle-chat-live.md). New door bedrock-mantle-chat: https://bedrock-mantle.{region}.api.aws/v1, SigV4 service bedrock-mantle, un-versioned model ids, list_models() (55 ids, live). Not a rename of bedrock-chat (runtime host). gpt-oss returns message.reasoning (a thinking part). Gemma honours tool_choice here (it ignores it on runtime). gpt-oss still ignores tool_choice and json_schema — refused per model. Claude on this door is a loud 400 (wrong API); Nova is 404. Trade-off, stated: a tenth cloud door; the original design named nine.
Bedrock short-term keys (lm15-contract/changes/2026-09-04-bedrock-bearer.md). Fix: with AWS_BEARER_TOKEN_BEDROCK set, bedrock-anthropic and aws-anthropic raised NotConfiguredError ("cannot travel") because a BearerToken could only go under Authorization; those doors carry it as x-api-key. A BearerToken now travels under bearer where the door offers it, else under x-api-key; an Entra token on azure-anthropic still goes as bearer. Trade-off, stated: a token handed to a key-header-only door that does not take tokens (first-party anthropic) now gets the provider's 401 instead of a local error. Live: the bearer rung on bedrock-chat (HTTP 200, pinned as cases/bedrock-chat/bearer_basic_text.json); GET /openai/v1/models is 404 under a key too, so supports.models stays false.
Azure OpenAI live (lm15-contract/changes/2026-09-04-azure-live.md, …-azure-chat-live.md). azure and azure-chat are live-verified against a real resource: text, streaming, tools, strict schema, gpt-5-mini reasoning with nonzero hidden-token usage, warm prompt-cache hits, both prompt/completion content-filter paths, async parity, and model listing. azure also gains live-pinned Files, Batch, WAV speech, and GA Realtime text WebSockets (supports.files/batches/speech/live = true); Azure's 201 + pending file state now maps honestly and file_wait_ready() waits before a useful download. Realtime auth no longer hard-codes OpenAI bearer: Azure API keys use api-key, Entra uses bearer. The azure-chain passed live by client secret, certificate (standard-library RS256 accepted by Entra), token-provider callable, both Entra scopes, and its actual az rung. GET /openai/v1/models answers 200 and lists the resource catalog, so supports.models is true on both doors. DeploymentNotFound maps to UnsupportedModelError. explain_auth(provider, config=router.config) now describes the router's own env, keys and settings; a JWT handed to a key-first door as a plain string names the required BearerToken wrap before the wire. Two quota-gated proofs remain: images (gpt-image-1-mini request pending) and successful azure-anthropic inference (eastus2 capacity request denied; host, corrected x-api-key, all Entra paths, /models refusal and errors are live-proven in changes/2026-09-04-azure-anthropic-partial.md). Sora/video is deliberately excluded. The batch capture proves submission, status, and cancellation, not a completed result.
Cloud hosts — AWS, Azure, Google Cloud (lm15-contract/changes/2026-09-03-cloud-hosts.md, documentation-evidenced; no live receipt yet). Nine doors to existing dialects: azure, azure-chat, azure-anthropic, aws-anthropic (Claude Platform on AWS), bedrock-anthropic (Bedrock mantle, Opus 4.7+), bedrock-chat, vertex, vertex-express, vertex-anthropic. A credential is now a value (lm15.credentials.ApiKey / BearerToken / AwsCredentials); a plain string still reads as an API key. Three credential chains in the cloud SDKs' own order (aws-chain, azure-chain, gcp-chain) with an offline doctor that reports unprobed for network rungs and prints the resolved host settings. Standard-library SigV4 (all ten AWS test-suite vectors) and RS256 (Google service accounts, Entra certificates; ~75 ms per signature). AccessPolicy.auth_header becomes auth_scheme (the old constructor argument and property remain supported); every adapter takes settings= and clock=; RouterConfig.settings. Behaviour change, stated: the auth header is applied once per request in BaseProviderLM._emit for every dialect — a credential provider callable is now invoked exactly once per request on every surface, including multipart uploads. Stated gaps, each a typed error naming the fix: aws login refresh, Azure Service Fabric identity, PKCS#12/encrypted keys, Google external_account with an AWS source, Bedrock Converse (phase 2). See docs/cloud-hosts.md.
Moonshot AI (Kimi) (moonshotai: prefix, MOONSHOTAI_API_KEY then MOONSHOT_API_KEY — the second is the name Moonshot's own docs use; both are vendor-named so both are read — https://api.moonshot.ai/v1) joins as a registry provider on the Chat Completions wire, live-verified: 15 cases, 1 pinned refusal, 4 error envelopes, 19 probes (lm15-contract/changes/2026-09-03-moonshotai-live.md). New OpenAIChatThinkingFormat value "kimi": an effort word goes out as top-level reasoning_effort alone (what kimi-k3 documents) and effort="off" as thinking: {type: disabled} alone (what kimi-k2.6 documents; kimi-k3 honours it too, contrary to its docs). New OpenAIChatCompat.reasoning_efforts (a tuple of native levels, default None): when a server accepts any effort word without validating it — kimi-k3 answered 200 to medium and to bogus alike — a word outside the list raises UnsupportedFeatureError before the wire (MAP-7 rule 2); the moonshotai preset lists low, high, max. Moonshot's exceeded_current_quota_error (insufficient balance, HTTP 429) maps to BillingError, not the retryable RateLimitError.
Moonshot's other two wires (lm15-contract/changes/2026-09-03-moonshotai-wires.md). moonshotai-responses (Responses wire, same root and key): a moonshotai Responses preset — stateless server, reasoning replayed as summary text (already what the dialect sends), web_search built-in through builtin_tools="verbatim" (renamed from "meta" the same day: the canonical name is the wire type on both servers, and a shape is not named after its first server), prompt_cache_key forwarded; 11 cases, 9 error envelopes. moonshotai-anthropic (Anthropic Messages wire at /anthropic, bearer token): 10 cases, 3 pinned refusals, 3 error envelopes. Four new AnthropicCompat fields, all from that wire's live behaviour: thinking_format="effort" (no thinking object exists there — output_config.effort alone; off is sent as disabled, which the server honours), thinking_replay="unsigned" (the server returns signature: "" and accepts unsigned thinking blocks back; the dialect's default still replays unsigned thinking as text), sampling_params="reject" (temperature/top_p/top_k are swallowed silently there while the same server's chat wire refuses them), and reasoning_efforts (the allowlist, as on the chat compat — medium and bogus are HTTP 200 there). Anthropic-dialect error mapping learns Moonshot's resource_not_found_error (model → UnsupportedModelError, as on the chat wire) and invalid_authentication_error.
Meta Model API (dev.meta.ai) joins as three provider strings on one key (META_API_KEY; Meta's vendor-less MODEL_API_KEY is deliberately not read), all at https://api.meta.ai/v1: meta is the Responses wire (Muse Spark with reasoning replay, the web_search built-in, and the account surfaces — Files, image generation and editing with muse-image-1.0, model listing); meta-chat is the Chat Completions wire; meta-anthropic the Anthropic Messages wire (bearer token, thinking: adaptive on every model). The Responses dialect now binds like the other two: OpenAILM(compat=…) takes a preset name or an OpenAIResponsesCompat, OpenAIResponsesCompat.preset reads the new OPENAI_RESPONSES_PRESETS table, and lm15.registry can bind that dialect. Three new Responses compat fields: commentary_phase ("tag" replays assistant text that precedes a function_call with Meta's phase: "commentary"), edit_image_field ("indexed" sends image[0], image[1] on /images/edits; Meta rejects OpenAI's image[]), and builtin_tools ("verbatim": the canonical web_search goes out unchanged, not as OpenAI's web_search_preview). OpenAICacheControl gains "openai_implicit" (both OpenAI dialects): prompt_cache_key and prompt_cache_retention are forwarded, the explicit breakpoint mark is never placed — Meta caches every prefix automatically and swallows the mark silently, and lm15 does not send a field a server ignores. OpenAIChatCompat.user_field gains "safety_identifier"; AnthropicCompat.thinking_format gains "adaptive" (every model is the adaptive class; an explicit off is sent as disabled so the server refuses it loudly). 33 cases across the three wires, 19 error envelopes, images pixel-verified (lm15-contract/changes/2026-09-03-meta-live.md). Speech-to-text (Muse Voice Transcribe) is not wired: lm15 has no transcription surface, and Meta's is its own WebSocket protocol.
One provider registry. lm15.registry.PROVIDERS is the one table of named providers: id, wire dialect, access policy (lm15.access), Chat Completions compat preset, keyless placeholder, console URL. ADAPTERS, ASYNC_ADAPTERS, and CHAT_PRESET_ROUTES are now read-only views of it (same keys, same values, now MappingProxyType); OpenAIChatCompat.preset reads the OPENAI_CHAT_PRESETS table instead of an if-chain; the doctor and the vet surface dump walk the registry, so the contract's support matrix now pins every routable provider, not only the seven adapter classes. Behavior change for routed chat presets: router.lm("groq:…") binds the groq access policy, so the LM's provider is "groq" (was "openai_chat") in errors and ModelInfo.provider, and lm.access.env_keys names GROQ_API_KEY. Constructing OpenAIChatLM(compat="groq") directly is unchanged.
DeepSeek over the Anthropic wire (deepseek-anthropic: prefix, same DEEPSEEK_API_KEY, https://api.deepseek.com/anthropic/v1) is the registry's first binding on a second dialect. New AnthropicCompat (small: thinking_format anthropic | deepseek, cache_control, structured_output, parallel_tool_calls, model_prefixes), AnthropicLM(compat=…), presets anthropic and deepseek. On this endpoint an explicit effort="off" is sent as thinking: {type: disabled} (absence means on there), effort rides output_config.effort (budget_tokens is ignored, so a thinking_budget raises), a JSON schema and tool_choice.parallel raise (silently ignored server-side), and claude-* model names raise UnsupportedModelError because the endpoint silently serves them with DeepSeek models (live 2026-09-03). Plain AnthropicLM is unchanged. 9 cases, 4 pinned refusals, 3 error envelopes (lm15-contract/changes/2026-09-03-deepseek-anthropic-live.md).
Z.AI (GLM) (zai: prefix, ZAI_API_KEY, https://api.z.ai/api/paas/v4) is live-verified: 10 cases, 2 pinned refusals, 4 error envelopes (lm15-contract/changes/2026-09-03-zai-live.md). The zai compat preset was wrong and is rewritten from the wire: Z.AI's thinking switch is thinking: {type} + reasoning_effort (the deepseek shape), not Qwen's enable_thinking; the never-validated "zai" value is removed from OpenAIChatThinkingFormat. Two new compat fields, both "send" | "reject": forced_tool_choice (Z.AI honours auto only and silently ignores the rest — required answered text, none called the tool) and json_schema (Z.AI returns 200 with free-form fenced JSON). The zai preset rejects both, so lm15 raises UnsupportedFeatureError before the wire. Z.AI code 1113 ("insufficient balance", sent on HTTP 429) now maps to BillingError; it was RateLimitError, which is retryable.
DeepSeek (deepseek: prefix, DEEPSEEK_API_KEY, https://api.deepseek.com) joins as the first registry-only provider: a declaration plus a compat preset, no class. The preset now replays reasoning_content natively and sends it on every assistant turn when tools are present — DeepSeek answers 400 otherwise (captured verbatim, errors/cases/deepseek.json). Live-verified 2026-09-03: eleven cases, four error envelopes, thirteen probes (lm15-contract/changes/ 2026-09-03-deepseek-live.md). New OpenAIChatCompat.user_field ("user" | "user_id", default user): Config.user_id rides the server's documented field name; the deepseek preset sends user_id.
One truth per support fact. The adapter-level Capabilities object (lm.capabilities: free-text features, adapter-wide modalities) is removed. Nothing in lm15 read it, it was not in the contract, and it had already drifted (Gemini gained reasoning under MAP-7; its features set said otherwise). Endpoint support lives in lm.supports / lm.manifest, pinned by spec/support-matrix.json; modalities and prices are per model on ModelInfo. Callers reading lm.capabilities get AttributeError.
Three follow-ups from the independent review.
LiveServerUsageEvent(type: "usage"): billed tokens of a live response that does not end the turn — a tool-call response (75 tokens were vanishing in the pinned transcript) or a cancelled one (143).Turn.usagenow sums everyusageandturn_endevent it saw; an interrupted turn keeps its usage instead ofNone.- Gemini modality breakdowns (
promptTokensDetails,candidatesTokensDetails,responseTokensDetails, modalityAUDIO) now fillinput_audio_tokens/output_audio_tokens, as OpenAI's do. - Usage counters are declared provider-verbatim in the contract, with a per-provider table of what
input_tokensandoutput_tokensinclude. Nothing changed in the numbers; the rule that they differ is now text.
Caching on gpt-5.6+: two amendments from live probes (MAP-6 rules 4 and 5). retention="long" no longer raises on the 5.6 class; it sends prompt_cache_retention: "24h" like every other class (the server accepts and echoes it; every 5.6 body already echoed 24h as default). A placed breakpoint (prefix="stable", prefix_until_index) now travels with prompt_cache_options: {mode: "explicit"} on 5.6+: without it the warm call still wrote the volatile suffix at 1.25x; with it the warm call writes 0 and the cold write is exactly the marked prefix.
Anthropic streamed tool calls assembled an unparseable input. The content_block_start.input: {} placeholder was serialised and glued in front of the input_json_delta fragments. Fixed; the first streaming tool-call body in the corpus caught it.
Auth by composition (contract AUTH-10, proposed). An adapter is a dialect bound to an AccessPolicy value (lm15.access; ProviderManifest is the same class under its earlier name): credential policy, auth header, static headers, login hint, endpoint surfaces, backend variant, system prefix, base URL. ClaudeCodeLM and OpenAICodexLM are now names for AnthropicLM(access=CLAUDE_CODE) and OpenAILM(access=OPENAI_CODEX) and define nothing but constructors; XaiLM composes its credential path and keeps its provider wire (images, video, refusals). Every dialect and async mirror takes access= and credentials_path=; api_key is optional and a key policy with no key raises NotConfiguredError naming the env keys (was TypeError). Endpoint surfaces are gated on the bound policy in the shared drivers, so a subscription login that lacks files/batch raises before any hook. Wire output is byte-identical (harness 13/13).
Stream assembly never invents a tool-call name (MAP-9, ErrorCode stream_assembly). When a streamed tool call's fragments never carried a name, the accumulator used to guess one from Request.tools by position and could dispatch the wrong function silently. It now raises StreamAssemblyError, carrying partial (the Response assembled from everything else) and part_index. ResponseStream raises at the end of iteration; text already yielded stays yielded. No shipped dialect triggers this — every one names a call on its first fragment — so the change is visible only to code that fed hand-built events into the accumulator.
Honest usage counters and a silent [DONE] (INV-029, MAP-3):
- Adapters no longer write
0forinput_tokens/output_tokensthe provider did not report; absent staysNoneandtotal_tokensis summed only when both primaries are present. Callers that summed usage across calls and relied on the invented zeros will now seeNonewhere the provider said nothing. Gemini is the one stated exception: proto3-JSON omits zero-valued fields, so an absent primary inside a presentusageMetadatais a reported0(pinned by the reviewed goldengemini.max_output_tokens); a missingusageMetadatais allNone. - The Responses dialect's bare
[DONE]terminator no longer claimsfinish_reason="stop". Before, on the Codex backend it overwrote thetool_callfromresponse.completedin the coalesced end event, so the event trace contradicted the materializedResponse.
Auth hardening + login primitives + doctor (contract lm15-contract/spec/auth.md, ratified 2026-08-31; fixtures auth/resolution.json):
lm15.auth: credential-file writes are now atomic (temp + rename, 0600) and serialized by a cross-process advisory lock; token refresh is double-checked under the lock, so a refresh completed by another process is used instead of repeated (repeating it loses rotated refresh tokens). Lock contention raises the newCredentialLockTimeout(aTimeoutError, deliberately notAuthError). Locks live in$XDG_CACHE_HOME/lm15/locks($LM15_LOCK_DIRoverrides), never inside~/.claude/~/.codex. Stated trade-offs: the lock is advisory and lm15-cooperative only (foreign CLIs do not take it; the double-checked re-read is the mitigation), and refresh holds the lock across the network call (a slow refresh can stall sibling lm15 processes; the alternative double-spends rotated refresh tokens).- New
lm15.authkit: login-flow primitives for apps that own a login UX — PKCE (S256 only, RFC 7636 vector pinned), the RFC 8628 device-code polling state machine (injectable clock/sleep),OAuthCallbackListener(one-shot loopback listener, 127.0.0.1 only), andCredentialFileStore(locked, atomic, 0600, keyed by provider, serializedmutate; default$XDG_CONFIG_HOME/lm15/credentials.json,$LM15_CREDENTIALS_PATHoverrides). - New
lm15.doctor.explain_auth: rung-by-rung credential-resolution report (selected / shadowed / absent) mirroring the router's exact chain; no network, secret values never rendered. Purity trade-off vsresolve(): it tests env vars for presence, so values transit memory but are never retained or shown. - Contract:
spec/auth.md(AUTH-1..9, ratified 2026-08-31), language-neutral resolution fixtures (mirrored atconformance/auth_resolution.json, run bytests/test_auth_resolution_contract.py), and a corpus-wide secrecy CI gate (tools/check_secrecy.py). Ports are not yet updated; they are formally behind the contract on this surface until they implement AUTH-1..AUTH-9 againstauth/resolution.json.
The API review (breaking — the alpha's one-time window). A four-lens fresh-eyes panel reviewed the public surface; findings in architecture-review/api-review-2026-07-13.md. The breaking set, batched here so the alpha churns exactly once:
Result→ResponseStream, constructor positional:ResponseStream(router.stream(req), req)— no more keyword-only double threading..events()yields canonicalStreamEvents (theStreamChunksecond vocabulary is gone); accessors mirror Response's minimal set. New:StreamAccumulator(the shared push-based engine),AsyncResponseStream(a true async mirror — the oldAsyncResult, which could not consume async streams, is deleted),amaterialize_response.lm15/stream.py(which aliasedResultasStream) is gone; both MAP-3 coalescer twins live inlm15.result.- Namespace curation, 161 → 107 top-level exports. Serde pairs →
lm15.serde; adapter machinery (BaseProviderLM, transports protocols, Credential, HttpResponse) →lm15.providers; error machinery →lm15.errors; router data tables (DEFAULT_RULES, ADAPTERS, CHAT_PRESET_ROUTES, RouteRule, PresetRoute) →lm15.router; profile/compat/SSE machinery → their modules. Promoted:RETRYABLE_ERRORSandtool_resultto the top level.deriveis exported asderive_tool(collision doctrine). ProviderLMnow names the callable surface (complete/stream/…); the wire-mapping protocol formerly exported under that name islm15.providers.ProviderDialect.- Provider strings are hyphenated:
openai-chatis canonical,openai_chatremains a permanent alias everywhere;Resolution.providerreports the canonical spelling.
Additive, same review:
Message.tool(call_id, output, is_error=False)positional spelling; every wrong shape now raises a TypeError listing the accepted forms.Request.toolsaccepts a bare tool (1-tuple coercion).- Errors state their cure: the messages TypeError names
Message.user;ProviderError.__str__appends(provider, HTTP status, request id);UnknownModelErroruses a neutral prefix example and hints near-miss provider heads ("did you mean…"). lm15.testing:FakeLM(canonical-level double),FakeTransport/FakeResponse(wire-level, promoted from the test suite).RouterConfig(transport=...)injects a transport into every LM the router builds.Retry-Afteris parsed (delta-seconds and HTTP-date) intoerror.retry_afteron bothcomplete()andstream()paths; provider-body values win.
One front door: credential providers + universal routing.
- Credential providers.
api_keyon every adapter (sync and async) now accepts a zero-argument callable as well as a string, resolved at request-build time, once per request — Azure Entraget_bearer_token_provider(...)output plugs in verbatim; rotating keys never go stale in long-lived clients. Acquisition stays the caller's job: lm15 gains no auth dependencies.RouterConfig.api_keysvalues may be credential providers too. - Subscription freshness.
ClaudeCodeLM/OpenAICodexLM(and async mirrors) validate the local CLI credential at construction, then re-resolve it per request — tokens refreshed on disk are picked up without rebuilding the client. - Credential hygiene.
api_keyis repr-suppressed on all adapters (previously the plain adapters' dataclass repr included it). - Router rung 0 (object attribute). A model value carrying a non-empty string
providerattribute (catalog packages ship these — aimo's model objects) resolves directly when the provider is routable; duck-typed, no package named. Bare-id ambiguity disappears when the model object knows its provider. - Router preset routes.
groq,openrouter,ollama,vllm, andsglangare now routable provider strings — prefix, catalog, object attribute, or rules — landing onOpenAIChatLM(compat=<preset>)with the preset's pinned base_url, the server's own env-key convention (GROQ_API_KEY,OPENROUTER_API_KEY), and keyless placeholders for local servers.Resolutiongains acompatfield;describe()narrates the new rungs. - New exports:
Credential,resolve_credential,PresetRoute,CHAT_PRESET_ROUTES.
The stability promise. The chat core — canonical types, serde, errors, request building, response parsing, streaming — is frozen; all future changes to it are additive (enforced mechanically by the surface ratchet and spec drift gate). Non-chat endpoints and live sessions remain provisional; see lm15-contract/spec/SCOPE.md.
What backs the promise:
- Four independent implementations — Python (this package), Rust, Go, TypeScript — each passing the identical 304-check conformance corpus (
lm15-dev/lm15-contract), each live-tested against real providers including the full tool-calling round-trip. - A written, ratified spec: 61 types, 25 vocabularies, 49 numbered invariants, mapping rules MAP-1..3, one omission rule, one number rule.
- Every fixture carries provenance; wire fixtures change only with live receipts; the reference implementation holds no oracle authority.
- Measured, regenerable benchmarks: 0 dependencies, 0.5 MiB installed, 171 ms cold import, and faster than raw stdlib HTTP at steady state (connection pooling).
Changes since 0.3.0: prompt-caching fixtures recaptured (GA, no beta header); OpenAI file inputs send filename (provider drift caught by the live sweep); FunctionTool.parameters always emitted, {} round-trips verbatim; malformed nested config objects reject instead of silently dropping; Result and live sessions no longer contain any automatic tool-execution machinery.
Ground-up rewrite. lm15 is now a low-level foundation library: one canonical representation, exact serde, provider adapters — and nothing opinionated. The 0.2.x high-level API (lm15.call(), Model, Conversation, cost tracking, middleware, REPL) is gone by design; build it (or your own take) on top. Pin lm15==0.2.* if you depend on the old surface.
- Typed, frozen, immutable canonical model:
Request/Response,Message, typedParts (text, thinking, media, tool calls/results, citations),Config,Usage, stream events. - Exact canonical JSON serde with written rules: one omission rule, opaque payloads never mutated, declared number types (
serde-rules.md). - Normalized error hierarchy (
AuthErrorwith key/credential guidance,RateLimitError.retry_after,ContextLengthError, ...). - Mapping invariants written and pinned: provider-executed tools are not parts (MAP-1), response messages are never empty (MAP-2), a stream yields exactly one end event carrying finish_reason and usage (MAP-3).
- First-party adapters: OpenAI (Responses), Anthropic, Gemini.
OpenAIChatLM: the Chat Completions dialect with compat presets for ollama, Groq, OpenRouter, vLLM, SGLang — live-validated against Groq, ollama, vLLM, and SGLang.- Native async mirrors of every adapter (
AsyncOpenAILM, ...): same constructor, same canonical types, no thread-wrapping. - Local subscription adapters:
ClaudeCodeLM(Claude Code OAuth) andOpenAICodexLM(Codex/ChatGPT OAuth). - Stdlib-only HTTP/1.1 sync + async transports;
websocketsis the single optional extra (live sessions).
- Behavior is pinned by the cross-language
lm15-contractcorpus: 108 request cases, 108 reviewed response/stream goldens, error and serde vectors, all live-captured or hand-authored with provenance, verified by a language-neutral harness (python -m lm15.vet). - A written spec (types, vocabularies, 48 numbered invariants) with a reflection-based drift gate.
ModelRegistry.discover()hydrates advisory pricing/context metadata from installed catalogs (entry-point grouplm15.model_catalogs); never affects what adapters send.
The previous-generation high-level SDK, developed in the lm15-python repository. See its history there.