Skip to content

Commit f1b20a7

Browse files
committed
The Claude Code release is a setting; its refusal names it; Claude's default max_tokens is the model's ceiling
client_version is a backend setting on the claude-code and openai-codex doors: RouterConfig settings, then LM15_CLAUDE_CODE_VERSION / LM15_CODEX_CLIENT_VERSION (router only), then the table; claude-code now claims 2.1.285. The minimum-version refusal adds the setting to change; explain_auth prints the release and its origin; a settings entry for a door that reads none raises. An unset max_tokens on a Claude model is its output ceiling (128000 from 4.6, 64000 for 4.5; on the manual class it covers the thinking budget), 16384 on other Anthropic-dialect servers. lm15-contract changes/2026-09-30-claude-code-client-version.md (bd9586d). Signed-off-by: Maxime Rivest <mrive052@gmail.com>
1 parent 2a85094 commit f1b20a7

20 files changed

Lines changed: 726 additions & 59 deletions

‎CHANGELOG.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
Claude Code and Claude defaults (lm15-contract
6+
`changes/2026-09-30-claude-code-client-version.md`; live receipts
7+
2026-09-30):
8+
9+
- **The Claude Code release lm15 claims is current and settable.** The
10+
`claude-code` door now says it is Claude Code 2.1.285 (it said 2.1.170,
11+
which `claude-opus-5-5` refuses: "version 2.1.280 or newer is required").
12+
When a model needs a newer release before lm15's next one, set
13+
`RouterConfig(settings={"claude-code": {"client_version": "..."}})` or
14+
`LM15_CLAUDE_CODE_VERSION` (the router reads it), or pass
15+
`ClaudeCodeLM(settings={"client_version": ...})`; `claude_code_version=`
16+
still works. The Codex door's `client_version` is the same setting
17+
(`LM15_CODEX_CLIENT_VERSION`). `explain_auth` prints the release and where
18+
it came from.
19+
- **The refusal says what to change**: the server's "run claude update"
20+
does not move what lm15 sends, so the error now names the setting.
21+
- **A settings entry nothing reads raises** `NotConfiguredError` (it was
22+
ignored), naming the settings that door does take.
23+
- **Claude's default `max_tokens` is the model's own output ceiling**:
24+
128000 for the 4.6 generation and later, 64000 for 4.5 (it was 16384,
25+
which replies with reasoning on routinely ran out of). On the manual
26+
class the ceiling covers the thinking budget too. Other models on
27+
Anthropic-dialect servers keep 16384. Recorded as `defaulted`, as before.
28+
- The note for a `thinking_budget` dropped on an adaptive-class Claude says
29+
what bounds thinking there: `max_tokens` (thinking and answer together).
30+
331
## 1.1.0 — 2026-09-26
432

533
New providers and a Google Cloud pass, both live-verified in Python,

‎CONTRACT_PIN‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
307925aa6f95100b09edbe0b2cb4a9872bf622a3
1+
bd9586d80f4d641ded4dcd23887c5171aeaa0513

‎conformance/auth_resolution.json‎

Lines changed: 222 additions & 2 deletions
Large diffs are not rendered by default.

‎docs/adaptations.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ response = router.complete(Request(
2121

2222
for a in response.adaptations:
2323
print(a.field, a.action, a.asked, "->", a.applied)
24-
# config.max_tokens defaulted None -> 16384
24+
# config.max_tokens defaulted None -> 64000
2525
# config.seed dropped 42 -> None
2626
# config.temperature clamped 1.5 -> 1.0
2727
```
@@ -42,7 +42,7 @@ thinking budget — is the adapter's job and is never recorded.
4242
| `substituted` | the closest spelling went instead | `summary="concise"` → `"auto"`; `reasoning="off"` → the lowest level where no off switch exists |
4343
| `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 |
4444
| `satisfied` | the provider's default already is what you asked | `store=False` on Anthropic, which keeps no retrievable copy |
45-
| `defaulted` | the wire requires a value you did not set | Anthropic `max_tokens` (the class ceiling, not a silent 1024) |
45+
| `defaulted` | the wire requires a value you did not set | Anthropic `max_tokens` (a Claude model's own output ceiling — 128000 for the 4.6 generation and later, 64000 for 4.5; 16384 on other servers — not a silent 1024) |
4646

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

‎docs/authentication.md‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,29 @@ another process, or by lm15's own expiry refresh — are picked up
161161
without rebuilding the client. Both adapters are also routable
162162
(`claude-code:...`, `openai-codex:...`).
163163

164+
**Which Claude Code release lm15 claims.** The Claude Code door tells
165+
Anthropic which Claude Code release it is (`user-agent:
166+
claude-cli/<version>`), and a new model can require a newer one:
167+
168+
```text
169+
InvalidRequestError: Claude Code 2.1.170 does not support this model; version 2.1.280 or newer is required. ...
170+
```
171+
172+
Updating the `claude` program does not change what lm15 sends. lm15 ships
173+
with the latest release it has tested (`lm15.access.DEFAULT_CLAUDE_CODE_VERSION`);
174+
when a model needs a newer one before lm15's next release, set it:
175+
176+
```python
177+
router = lm15.LMRouter(lm15.RouterConfig(settings={"claude-code": {"client_version": "2.1.290"}}))
178+
```
179+
180+
or, with no code change, `LM15_CLAUDE_CODE_VERSION=2.1.290` in the
181+
environment (the router reads it; an adapter you build yourself takes
182+
`ClaudeCodeLM(settings={"client_version": "2.1.290"})`). The Codex door's
183+
`client_version` works the same way (`LM15_CODEX_CLIENT_VERSION`).
184+
`lm15.doctor.explain_auth("claude-code")` prints the release in use and
185+
where it came from.
186+
164187
xAI sells subscription access too (SuperGrok / X Premium), but ships
165188
no CLI credential file — so lm15 runs the device-code login itself,
166189
once, and stores the credential locally:
@@ -222,8 +245,9 @@ print(lm.access.provider, lm.supports.files) # 'openai-codex' False
222245
This is what lets a Go or Rust port carry the same facts as a table
223246
instead of a class hierarchy. The policy table and its consult points are
224247
normative (contract `spec/auth.md` AUTH-10). Custom policies are ordinary
225-
values too — `CLAUDE_CODE.with_headers({...})` is how `claude_code_version`
226-
is applied.
248+
values too. A door's `backend_settings` (today `client_version` on the two
249+
subscription doors) are the options a caller may change; the router fills
250+
them from `settings`, then the environment, then the table.
227251

228252
## Keyless local servers
229253

‎lm15/access.py‎

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@
3535
from __future__ import annotations
3636

3737
import os
38+
import re
3839
from dataclasses import dataclass, field
39-
from typing import Callable
40+
from typing import Callable, Mapping
4041

4142
from .auth import (
4243
CLAUDE_CODE_LOGIN_HINT,
@@ -87,8 +88,17 @@
8788
auth_scheme=("bearer",),
8889
)
8990

90-
DEFAULT_CLAUDE_CODE_VERSION = "2.1.170"
91+
# The Claude Code release this door says it is (``user-agent:
92+
# claude-cli/<version>``). Anthropic's server reads it: a model can require
93+
# a newer release (claude-opus-5-5 refuses anything before 2.1.280, live
94+
# 2026-09-23 and 2026-09-30). The latest release when last receipted
95+
# (changes/2026-09-30-claude-code-client-version.md); callers move it
96+
# without a release through the ``client_version`` setting or
97+
# LM15_CLAUDE_CODE_VERSION (AUTH-10 backend settings).
98+
DEFAULT_CLAUDE_CODE_VERSION = "2.1.285"
9199
DEFAULT_CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."
100+
CLAUDE_CODE_VERSION_ENV = "LM15_CLAUDE_CODE_VERSION"
101+
CODEX_CLIENT_VERSION_ENV = "LM15_CODEX_CLIENT_VERSION"
92102

93103
# models=True: the Anthropic /v1/models endpoint answers to the OAuth
94104
# headers (validated live 2026-08-31, HTTP 200). Files and batch are
@@ -107,6 +117,8 @@
107117
),
108118
login_hint=CLAUDE_CODE_LOGIN_HINT,
109119
backend="claude-code",
120+
backend_options={"client_version": DEFAULT_CLAUDE_CODE_VERSION},
121+
backend_settings=(HostSetting("client_version", env=(CLAUDE_CODE_VERSION_ENV,)),),
110122
system_prefix=DEFAULT_CLAUDE_CODE_SYSTEM_PROMPT,
111123
)
112124

@@ -140,6 +152,7 @@
140152
login_hint=OPENAI_CODEX_LOGIN_HINT,
141153
backend="chatgpt-codex",
142154
backend_options={"client_version": DEFAULT_CODEX_CLIENT_VERSION},
155+
backend_settings=(HostSetting("client_version", env=(CODEX_CLIENT_VERSION_ENV,)),),
143156
system_prefix=DEFAULT_CODEX_INSTRUCTIONS,
144157
base_url=DEFAULT_CODEX_BASE_URL,
145158
)
@@ -672,6 +685,92 @@
672685
)
673686

674687

688+
# ─── Backend settings (AUTH-10, amended 2026-09-30) ──────────────────
689+
690+
691+
def resolve_backend_settings(
692+
policy: AccessPolicy,
693+
given: "Mapping[str, str] | None",
694+
env: "Mapping[str, str] | None" = None,
695+
*,
696+
sources: dict[str, str] | None = None,
697+
) -> dict[str, str]:
698+
"""The door's backend settings: the caller's value, then ``env`` (when
699+
given — the router passes the environment, an adapter built by hand
700+
does not), then the table's ``backend_options`` value. ``sources``
701+
receives each origin in the AUTH-10 ``from`` vocabulary (``explicit``,
702+
``env:<VAR>``, ``default``). A name the door does not declare is a
703+
configuration error that lists the names it does: a setting nothing
704+
reads would otherwise be dropped with nothing said."""
705+
given = {str(k): str(v) for k, v in (given or {}).items()}
706+
known = [setting.name for setting in policy.backend_settings]
707+
unknown = sorted(set(given) - set(known))
708+
if unknown:
709+
hint = f"known: {', '.join(known)}" if known else "this door takes no settings"
710+
raise NotConfiguredError(
711+
f"{policy.provider}: unknown setting(s) {', '.join(repr(n) for n in unknown)}; {hint}",
712+
provider=policy.provider,
713+
credential_hint=(f"Pass only {', '.join(known)} for {policy.provider}" if known
714+
else f"Remove the settings entry for {policy.provider}"),
715+
)
716+
out: dict[str, str] = {}
717+
record = sources if sources is not None else {}
718+
for setting in policy.backend_settings:
719+
value = given.get(setting.name) or ""
720+
origin = "explicit" if value else ""
721+
if not value and env is not None:
722+
for var in setting.env:
723+
candidate = env.get(var)
724+
if candidate:
725+
value, origin = candidate, f"env:{var}"
726+
break
727+
if not value:
728+
value, origin = policy.backend_options[setting.name], "default"
729+
out[setting.name] = value
730+
record[setting.name] = origin
731+
return out
732+
733+
734+
def with_backend_settings(policy: AccessPolicy, values: "Mapping[str, str]") -> AccessPolicy:
735+
"""The policy with these resolved backend settings in ``backend_options``.
736+
737+
``client_version`` on the ``claude-code`` backend is also the version the
738+
``user-agent`` header claims (``claude-cli/<client_version>``): the one
739+
backend setting that reaches a header. On ``chatgpt-codex`` it is the
740+
``/models`` query parameter, read from ``backend_options``."""
741+
from dataclasses import replace
742+
743+
if not values:
744+
return policy
745+
options = {**policy.backend_options, **values}
746+
if options == dict(policy.backend_options):
747+
return policy
748+
out = replace(policy, backend_options=options)
749+
if policy.backend == "claude-code" and "client_version" in values:
750+
out = out.with_headers({"user-agent": f"claude-cli/{options['client_version']}"})
751+
return out
752+
753+
754+
# Anthropic's refusal when the claimed Claude Code release is older than a
755+
# model requires (live 2026-09-23, 2026-09-30; errors/cases/claude-code.json).
756+
_CLAUDE_CODE_FLOOR = re.compile(r"Claude Code (\S+) does not support this model; version (\S+) or newer is required")
757+
758+
759+
def claude_code_version_guidance(message: str) -> str:
760+
"""The claude-code door's minimum-version refusal, with what an lm15
761+
caller changes. The server says "run 'claude update'", which does not
762+
move the version lm15 claims; the guidance names the setting that does
763+
(AUTH-10 backend settings). Any other message is returned unchanged."""
764+
match = _CLAUDE_CODE_FLOOR.search(message)
765+
if match is None or "\n\n To fix:" in message:
766+
return message
767+
required = match.group(2)
768+
return (f"{message}\n\n To fix:\n"
769+
" - lm15 sends this version itself; updating Claude Code does not change it\n"
770+
f" - Set the claude-code setting client_version to {required} or newer "
771+
f"(or {CLAUDE_CODE_VERSION_ENV}={required})\n")
772+
773+
675774
# ─── Credential loading, keyed by provider ───────────────────────────
676775

677776

‎lm15/doctor.py‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,8 @@ def explain_auth(
269269
raise ValueError(f"Unknown provider {provider!r}. Known providers: {', '.join(known)}")
270270

271271
if config is not None and config.auth is not None:
272-
return _explain_managed(canonical, config, api_keys=api_keys, env=env)
272+
return _with_backend_settings(_explain_managed(canonical, config, api_keys=api_keys, env=env),
273+
settings, env)
273274

274275
policy = _credential_policy(canonical)
275276
if policy in ("aws-chain", "azure-chain", "gcp-chain") or (
@@ -285,7 +286,8 @@ def explain_auth(
285286
if policy == "oauth":
286287
override = claude_credentials_path if canonical == "claude-code" else codex_auth_path
287288
step = _oauth_step(canonical, override)
288-
return AuthReport(provider=canonical, steps=(step,), configured=step.state == "selected")
289+
return _with_backend_settings(AuthReport(provider=canonical, steps=(step,), configured=step.state == "selected"),
290+
settings, env)
289291

290292
config = RouterConfig(env=env, api_keys=api_keys)
291293
environment = env if env is not None else os.environ
@@ -360,7 +362,29 @@ def explain_auth(
360362
)
361363
selected = True
362364

363-
return AuthReport(provider=canonical, steps=tuple(steps), configured=selected)
365+
return _with_backend_settings(AuthReport(provider=canonical, steps=tuple(steps), configured=selected),
366+
settings, env)
367+
368+
369+
def _with_backend_settings(report: AuthReport, settings: Mapping[str, str] | None,
370+
env: Mapping[str, str] | None) -> AuthReport:
371+
"""A door without a host prints its backend settings the way a cloud
372+
door prints its host settings (AUTH-7; AUTH-10 amended 2026-09-30):
373+
the Claude Code release the claude-code door claims, and where that
374+
came from — the value a model's minimum-version refusal is about."""
375+
import os
376+
from dataclasses import replace
377+
378+
from .access import resolve_backend_settings
379+
380+
definition = _bound_definition(report.provider)
381+
policy = (definition.access if definition is not None and definition.bound
382+
else getattr(ADAPTERS.get(report.provider), "manifest", None))
383+
if policy is None or policy.host is not None or (not policy.backend_settings and not settings):
384+
return report
385+
sources: dict[str, str] = {}
386+
values = resolve_backend_settings(policy, settings, env if env is not None else os.environ, sources=sources)
387+
return replace(report, settings=tuple(values.items()), setting_sources=tuple(sources.items()))
364388

365389

366390
def _explain_managed(provider: str, config: RouterConfig, *, api_keys, env) -> AuthReport:

‎lm15/features.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,12 @@ class AccessPolicy:
272272
prompt / instructions (Claude Code, Codex).
273273
``base_url`` this access path's default base URL, when it is
274274
not the dialect's.
275+
``backend_settings`` the ``backend_options`` a caller may set on a door
276+
without a host (spec/auth.md AUTH-10, amended
277+
2026-09-30): each names a ``backend_options`` key and
278+
the env variables the router consults for it; its
279+
default is the table's ``backend_options`` value.
280+
The subscription doors declare ``client_version``.
275281
"""
276282

277283
provider: str
@@ -288,6 +294,7 @@ class AccessPolicy:
288294
backend_options: Mapping[str, str] = field(default_factory=dict)
289295
system_prefix: str | None = None
290296
base_url: str | None = None
297+
backend_settings: tuple[HostSetting, ...] = field(default=(), kw_only=True)
291298

292299
# Keep the original positional API, including pattern matching. The legacy
293300
# header is a constructor input/property, NOT a second stored authority.
@@ -315,6 +322,7 @@ def __init__(
315322
*,
316323
auth_scheme: AuthScheme | Sequence[AuthScheme] = ("bearer",),
317324
host: HostSpec | None = None,
325+
backend_settings: Sequence[HostSetting] = (),
318326
) -> None:
319327
# None means the legacy spelling was omitted (the effective default
320328
# remains bearer). An explicit legacy header overrides the scheme input:
@@ -339,6 +347,7 @@ def __init__(
339347
object.__setattr__(self, "backend_options", {} if backend_options is None else backend_options)
340348
object.__setattr__(self, "system_prefix", system_prefix)
341349
object.__setattr__(self, "base_url", base_url)
350+
object.__setattr__(self, "backend_settings", backend_settings)
342351
self.__post_init__()
343352

344353
def __post_init__(self) -> None:
@@ -365,6 +374,17 @@ def __post_init__(self) -> None:
365374
object.__setattr__(self, "enterprise_variants", tuple(self.enterprise_variants))
366375
object.__setattr__(self, "headers", tuple((str(k), str(v)) for k, v in self.headers))
367376
object.__setattr__(self, "backend_options", dict(self.backend_options))
377+
object.__setattr__(self, "backend_settings", tuple(self.backend_settings))
378+
for setting in self.backend_settings:
379+
# A backend setting's default is the table's backend_options
380+
# value: one authority for the value a door sends by default.
381+
if setting.default is not None:
382+
raise ValueError(f"{self.provider}: backend setting {setting.name!r} takes its default "
383+
"from backend_options, not HostSetting.default")
384+
if setting.name not in self.backend_options:
385+
raise ValueError(f"{self.provider}: backend setting {setting.name!r} has no backend_options default")
386+
if self.backend_settings and self.host is not None:
387+
raise ValueError(f"{self.provider}: a door with a host declares its settings on the host")
368388

369389
@property
370390
def auth_header(self) -> AuthHeader:

0 commit comments

Comments
 (0)