|
35 | 35 | from __future__ import annotations |
36 | 36 |
|
37 | 37 | import os |
| 38 | +import re |
38 | 39 | from dataclasses import dataclass, field |
39 | | -from typing import Callable |
| 40 | +from typing import Callable, Mapping |
40 | 41 |
|
41 | 42 | from .auth import ( |
42 | 43 | CLAUDE_CODE_LOGIN_HINT, |
|
87 | 88 | auth_scheme=("bearer",), |
88 | 89 | ) |
89 | 90 |
|
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" |
91 | 99 | 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" |
92 | 102 |
|
93 | 103 | # models=True: the Anthropic /v1/models endpoint answers to the OAuth |
94 | 104 | # headers (validated live 2026-08-31, HTTP 200). Files and batch are |
|
107 | 117 | ), |
108 | 118 | login_hint=CLAUDE_CODE_LOGIN_HINT, |
109 | 119 | backend="claude-code", |
| 120 | + backend_options={"client_version": DEFAULT_CLAUDE_CODE_VERSION}, |
| 121 | + backend_settings=(HostSetting("client_version", env=(CLAUDE_CODE_VERSION_ENV,)),), |
110 | 122 | system_prefix=DEFAULT_CLAUDE_CODE_SYSTEM_PROMPT, |
111 | 123 | ) |
112 | 124 |
|
|
140 | 152 | login_hint=OPENAI_CODEX_LOGIN_HINT, |
141 | 153 | backend="chatgpt-codex", |
142 | 154 | backend_options={"client_version": DEFAULT_CODEX_CLIENT_VERSION}, |
| 155 | + backend_settings=(HostSetting("client_version", env=(CODEX_CLIENT_VERSION_ENV,)),), |
143 | 156 | system_prefix=DEFAULT_CODEX_INSTRUCTIONS, |
144 | 157 | base_url=DEFAULT_CODEX_BASE_URL, |
145 | 158 | ) |
|
672 | 685 | ) |
673 | 686 |
|
674 | 687 |
|
| 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 | + |
675 | 774 | # ─── Credential loading, keyed by provider ─────────────────────────── |
676 | 775 |
|
677 | 776 |
|
|
0 commit comments