feat(proxy): expose reversible Claude Code model listing aliases (#40515)

Encode complete non-Claude source names and include source_model in the
Claude Code listing. Preserve configured route and alias precedence,
normalize once before model policy checks, and select CLI models using
explicit source identity instead of name stripping or positional joins.

Resolves LIT-7360


Claude-Session: https://claude.ai/code/session_01WyqeRhfZGm26zAnHx9P3kq

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
tin-berri 2026-09-09 22:04:37 -07:00 committed by GitHub
parent 00b631883d
commit c14e782810
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 647 additions and 41 deletions

View file

@ -73,8 +73,13 @@ _CLAUDE_CODE_OBJECT_MAPPING_ADAPTER: Final = TypeAdapter(dict[object, object])
_CLAUDE_CODE_OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object])
_CLAUDE_CODE_USER_AGENT_PREFIXES: Final = ("claude-cli/", "claude-code/")
def is_claude_code_user_agent(user_agent: str) -> bool:
return user_agent.startswith("claude-cli/")
"""Claude Code sends its API calls through the Anthropic SDK as `claude-cli/<version>` and its own
fetches, such as gateway model discovery, as `claude-code/<version>`"""
return user_agent.startswith(_CLAUDE_CODE_USER_AGENT_PREFIXES)
def _validated_claude_code_mapping(value: object) -> dict[object, object] | None:
@ -1656,11 +1661,16 @@ def process_anthropic_headers(headers: httpx.Headers | dict) -> dict:
def _anthropic_model_entry(
model: ModelInfoResponse, created_at: str, display_names: Mapping[str, str]
model: ModelInfoResponse, created_at: str, display_names: Mapping[str, str], listed_ids: Mapping[str, str]
) -> Mapping[str, object]:
listed_id: Final = listed_ids.get(model["id"])
source: Final[Mapping[str, object]] = (
MappingProxyType({"source_model": model["id"]}) if listed_id is not None else MappingProxyType({})
)
return { # mutable-ok: JSON response body, serialized by the route and never mutated
"type": "model",
"id": model["id"],
"id": listed_id or model["id"],
**source,
"display_name": display_names.get(model["id"], model["id"]),
"created_at": created_at,
"max_input_tokens": model.get("max_input_tokens"),
@ -1671,6 +1681,7 @@ def _anthropic_model_entry(
def create_anthropic_model_list_response(
models: Sequence[ModelInfoResponse],
display_names: Mapping[str, str] = MappingProxyType({}),
listed_ids: Mapping[str, str] = MappingProxyType({}),
) -> Mapping[str, object]:
"""Build the Anthropic-native /v1/models envelope.
@ -1680,17 +1691,19 @@ def create_anthropic_model_list_response(
over from the OpenAI-shaped listing, named as the Messages API names them, and
are always present because the vendor shape declares them nullable, not optional.
display_names maps a listed model id to a configured human-readable name; ids
without an entry fall back to the id itself, matching the vendor behavior
without an entry fall back to the id itself, matching the vendor behavior.
listed_ids maps a model id to the id the caller should see it under (the Claude
Code view); ids without an entry are listed as they are
"""
created_at: Final = (
datetime.fromtimestamp(DEFAULT_MODEL_CREATED_AT_TIME, tz=timezone.utc).isoformat().replace("+00:00", "Z")
)
data: Final = [ # mutable-ok: JSON response body, serialized by the route and never mutated
_anthropic_model_entry(model, created_at, display_names) for model in models
_anthropic_model_entry(model, created_at, display_names, listed_ids) for model in models
]
return { # mutable-ok: JSON response body, serialized by the route and never mutated
"data": data,
"has_more": False,
"first_id": models[0]["id"] if models else None,
"last_id": models[-1]["id"] if models else None,
"first_id": data[0]["id"] if data else None,
"last_id": data[-1]["id"] if data else None,
}

View file

@ -92,6 +92,7 @@ from litellm.proxy.common_utils.http_parsing_utils import (
_safe_set_request_parsed_body,
populate_request_with_path_params,
)
from litellm.proxy.common_utils.model_listing_utils import claude_code_requested_group
from litellm.proxy.common_utils.realtime_utils import _realtime_request_body
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
@ -183,6 +184,44 @@ def _get_model_from_request_context(
)
_CLAUDE_MODEL_ROUTES: Final = frozenset(
f"/{prefix}{endpoint}" for prefix in ("", "v1/") for endpoint in ("messages", "chat/completions", "responses")
)
_CLAUDE_MODEL_NORMALIZED: Final = "litellm.claude_model_normalized"
async def _normalize_claude_model(
request_data: dict, valid_token: UserAPIKeyAuth, request: Request | None, route: str
) -> None:
from litellm.proxy.proxy_server import llm_router, prisma_client, proxy_config, proxy_logging_obj
if route not in _CLAUDE_MODEL_ROUTES or llm_router is None:
return
if request is not None and request.scope.get(_CLAUDE_MODEL_NORMALIZED) is True:
return
requested: Final = _get_model_from_request_context(request_data, route, request, llm_router)
if not isinstance(requested, str) or requested != request_data.get("model"):
return
if not requested.startswith("claude-router-") and not requested.lower().endswith("[1m]"):
return
settings: Final = await proxy_config.get_hierarchical_router_settings(
user_api_key_dict=valid_token, prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj
)
aliases: Final = settings.get("model_group_alias") if isinstance(settings, Mapping) else None
source: Final = claude_code_requested_group(
requested, llm_router, valid_token.team_id, (valid_token.aliases, valid_token.team_model_aliases, aliases)
)
if request is not None:
request.scope[_CLAUDE_MODEL_NORMALIZED] = True
if source is None:
return
request_data["model"] = source
_safe_set_request_parsed_body(request=request, parsed_body=request_data)
if request is not None:
request._json = request_data
request._body = orjson.dumps(request_data)
def _get_model_names_for_budget_checks(
model: str | list[str] | None,
) -> list[str]:
@ -2768,6 +2807,7 @@ async def _authorize_authenticated_request(
"""
## ENSURE DISABLE ROUTE WORKS ACROSS ALL USER AUTH FLOWS ##
RouteChecks.should_call_route(route=route, valid_token=user_api_key_auth_obj, request=request)
await _normalize_claude_model(request_data, user_api_key_auth_obj, request, route)
# Single authorization point. Builder paths MUST NOT call common_checks.
# Route through the same exception handler the builder uses so
@ -3131,6 +3171,7 @@ async def _enforce_key_and_fallback_model_access(
Key-level model allowlist and client fallbacks (same as standard auth).
Not included in common_checks common_checks enforces team/user/project model access only.
"""
await _normalize_claude_model(request_data, valid_token, request, route)
config: Final = valid_token.config
if config != {}:

View file

@ -490,7 +490,7 @@ lite codex exec "summarize the repo"
Each command resolves your LiteLLM key (logging in via SSO when none is stored and you are at a terminal; otherwise it expects `LITELLM_PROXY_API_KEY` or `--api-key`), checks the key against the proxy so bad credentials fail immediately instead of deep inside the agent, exports the environment variables the agent reads, then replaces itself with the agent process.
The right variables are picked per agent. Claude Code gets `ANTHROPIC_BASE_URL` (the proxy root, so it appends `/v1/messages`) and `ANTHROPIC_AUTH_TOKEN`, with any stray `ANTHROPIC_API_KEY` cleared so the proxy token wins, and `ENABLE_TOOL_SEARCH=true` (unless you already set it) so Claude Code keeps tool search on even though the base URL is a proxy rather than a first-party Anthropic host. It also gets `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` (again unless you already set it) so Claude Code v2.1.129+ fills its `/model` picker from the proxy's `/v1/models`; Claude Code only lists entries whose id contains `claude` or `anthropic`, and older versions ignore the variable. Export it as `0` to turn discovery off. Codex and OpenCode get `OPENAI_BASE_URL` (the proxy plus `/v1`) and `OPENAI_API_KEY`. Codex ignores `OPENAI_BASE_URL`, so it is additionally pointed at the proxy through a custom provider passed as `-c` config overrides (HTTP/SSE Responses transport, since the proxy does not speak the Responses WebSocket protocol). OpenCode additionally gets `OPENCODE_CONFIG_CONTENT` holding a generated `litellm` provider (`@ai-sdk/openai-compatible`, the proxy `/v1` URL, `{env:OPENAI_API_KEY}`) with one model entry per chat model your key can see on `/v1/models`, so its model picker mirrors the proxy without a hand-maintained `opencode.json`; OpenCode merges that over your own config files, and if you already export `OPENCODE_CONFIG_CONTENT` yours is left alone. When the list cannot be fetched, `lite opencode` says so on stderr and launches anyway.
The right variables are picked per agent. Claude Code gets `ANTHROPIC_BASE_URL` (the proxy root, so it appends `/v1/messages`) and `ANTHROPIC_AUTH_TOKEN`, with any stray `ANTHROPIC_API_KEY` cleared so the proxy token wins, and `ENABLE_TOOL_SEARCH=true` (unless you already set it) so Claude Code keeps tool search on even though the base URL is a proxy rather than a first-party Anthropic host. It also gets `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` (again unless you already set it) so Claude Code v2.1.129+ fills its `/model` picker from the proxy's `/v1/models`; Claude Code only lists entries whose id contains `claude` or `anthropic`, so the proxy lists every other group to Claude Code as `claude-router-<UTF-8 hex of the group name>` and marks a group whose input window reaches 1M with `[1m]`, and a request on such an id is served by the group. Older Claude Code versions ignore the variable. Export it as `0` to turn discovery off. Codex and OpenCode get `OPENAI_BASE_URL` (the proxy plus `/v1`) and `OPENAI_API_KEY`. Codex ignores `OPENAI_BASE_URL`, so it is additionally pointed at the proxy through a custom provider passed as `-c` config overrides (HTTP/SSE Responses transport, since the proxy does not speak the Responses WebSocket protocol). OpenCode additionally gets `OPENCODE_CONFIG_CONTENT` holding a generated `litellm` provider (`@ai-sdk/openai-compatible`, the proxy `/v1` URL, `{env:OPENAI_API_KEY}`) with one model entry per chat model your key can see on `/v1/models`, so its model picker mirrors the proxy without a hand-maintained `opencode.json`; OpenCode merges that over your own config files, and if you already export `OPENCODE_CONFIG_CONTENT` yours is left alone. When the list cannot be fetched, `lite opencode` says so on stderr and launches anyway.
pi ignores base-URL environment variables entirely, so `lite pi` (kept out of the `lite --help` command listing for now, but fully functional) wires it up differently: before handoff it fetches the models your key can use from the proxy's `/v1/models` (plus each model's context window and output cap from `/model_group/info`, when available) and syncs them into a `litellm` provider entry in pi's `~/.pi/agent/models.json` (honoring `PI_CODING_AGENT_DIR`), then starts pi on that provider's first model via an injected `--model litellm/<id>`. Only that one provider entry is rewritten; the rest of the file, including any other custom providers, is left alone. The entry references the key as `$LITELLM_PROXY_API_KEY`, which the wrapper exports for the session, so the token itself never lands on disk and plain `pi` outside the wrapper simply shows the litellm models as unavailable. Your own flags come after the injected pin, so `lite pi --model litellm/<other-id>` wins, and inside the TUI the `/model` picker lists every synced litellm model.
@ -548,7 +548,7 @@ lite --base-url https://your-proxy.example.com configure claude --api-key sk-...
claude
```
With `--api-key` (or `lite --api-key` / `LITELLM_PROXY_API_KEY`) the key is written into `env.ANTHROPIC_AUTH_TOKEN`. Without one, your `lite login` credential is used the way `--config-claude` uses it, through `apiKeyHelper`, so a later `lite login` (or a `--pkce` renewal) picks up on its own and nothing secret lands in the file; a missing or stale login is refreshed first. Either way the command checks the key against `GET /v1/models`, then patches `~/.claude/settings.json`: `env.ANTHROPIC_BASE_URL`, the credential, and `env.ENABLE_TOOL_SEARCH` and `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` when those are missing, so Claude Code's `/model` picker lists the proxy's models (the ones whose id contains `claude` or `anthropic`) and you pick between them as usual. Claude Code keeps its own default model until you switch, so that id has to exist on the proxy for the first message to go through; `--model` (or the interactive prompt below) sets the model Claude Code starts on instead, as the top-level `model` key, which has to be on `/v1/models` for the key. Nothing forces Claude Code's sub-agent or background tiers onto a proxy model, so those built-in ids need to exist on the proxy too; `lite autoroute up` is the mode that pins every tier to one group. Claude Code treats a name it does not know as an unknown model: it prints a one-line `unrecognized_model` note, assumes a 200k context window and sends no thinking parameters for it, so either name the group like a Claude model id or append `[1m]` to opt into the 1M window. The other credential slots (`env.ANTHROPIC_API_KEY`, a stale `env.ANTHROPIC_AUTH_TOKEN` or `apiKeyHelper`) are removed so they cannot fight the one written. Every other setting is preserved and the file is written atomically with owner-only permissions; if `settings.json` is a symlink into a dotfiles repository, the key is written through to that target and the command says so, so keep it out of version control
With `--api-key` (or `lite --api-key` / `LITELLM_PROXY_API_KEY`) the key is written into `env.ANTHROPIC_AUTH_TOKEN`. Without one, your `lite login` credential is used the way `--config-claude` uses it, through `apiKeyHelper`, so a later `lite login` (or a `--pkce` renewal) picks up on its own and nothing secret lands in the file; a missing or stale login is refreshed first. Either way the command checks the key against `GET /v1/models`, then patches `~/.claude/settings.json`: `env.ANTHROPIC_BASE_URL`, the credential, and `env.ENABLE_TOOL_SEARCH` and `env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` when those are missing, so Claude Code's `/model` picker lists the proxy's models (under `claude-router-<UTF-8 hex of the group name>` for a group whose id contains neither `claude` nor `anthropic`, since Claude Code lists only those) and you pick between them as usual. Claude Code keeps its own default model until you switch, so that id has to exist on the proxy for the first message to go through; `--model` (or the interactive prompt below) sets the model Claude Code starts on instead, as the top-level `model` key, which has to be on `/v1/models` for the key. Nothing forces Claude Code's sub-agent or background tiers onto a proxy model, so those built-in ids need to exist on the proxy too; `lite autoroute up` is the mode that pins every tier to one group. Claude Code treats a name it does not know as an unknown model: it prints a one-line `unrecognized_model` note, assumes a 200k context window (the proxy appends `[1m]` for a group whose configured or known input window reaches 1M) and sends no thinking parameters for it, so name the group like a Claude model id to change that. The other credential slots (`env.ANTHROPIC_API_KEY`, a stale `env.ANTHROPIC_AUTH_TOKEN` or `apiKeyHelper`) are removed so they cannot fight the one written. Every other setting is preserved and the file is written atomically with owner-only permissions; if `settings.json` is a symlink into a dotfiles repository, the key is written through to that target and the command says so, so keep it out of version control
Plain `lite configure`, with no agent named, asks the same things interactively: which agents to wire (Claude Code today) and which of the proxy's models to start on, picked from `/v1/models` with a type-to-filter prompt

View file

@ -1,16 +1,23 @@
"""`lite configure claude` and `lite unconfigure claude`: persistent Claude Code wiring, undoable."""
import os
import re
import sys
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Final
import click
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from litellm.proxy.common_utils.model_listing_utils import (
CLAUDE_CODE_CLIENT,
CLAUDE_CODE_PICKER_PATTERN,
GATEWAY_CLIENT_HEADER,
)
from .auth import CliContextObj, context_secret_vault, get_stored_api_key
from .claude_settings import (
STARTING_MODEL_ROLE,
@ -30,14 +37,16 @@ from .claude_settings import (
settings_file_owners,
unconfigure_claude_settings,
)
from .pi import ListingFailure, PiSyncError, fetch_model_ids
from .pi import ListedModel, ListingFailure, PiSyncError, fetch_model_listing
from .up import ensure_fresh_login
_LISTED_MODELS_SHOWN: Final = 20
_CLAUDE_TARGET: Final = "claude"
_TARGETS: Final = ((_CLAUDE_TARGET, "Claude Code (CLI)"),)
_KEEP_DEFAULT_MODEL: Final = "Keep Claude Code's own default"
_CLAUDE_CODE_PICKER_FILTER: Final = re.compile(r"claude|anthropic", re.IGNORECASE)
_CLAUDE_CODE_VIEW: Final = MappingProxyType(
{"anthropic-version": "2023-06-01", GATEWAY_CLIENT_HEADER: CLAUDE_CODE_CLIENT}
)
_MODEL_OPTION_HELP: Final = (
f"Proxy model to set as {STARTING_MODEL_ROLE}. Must be listed on /v1/models for the key; without it, "
"Claude Code keeps its own default and a pin an earlier configure made is let go of. Nothing pins Claude "
@ -65,7 +74,16 @@ def resolve_credential(ctx: click.Context, api_key: str | None) -> tuple[ClaudeC
return ApiKeyHelper(resolve_api_key_helper(base_url)), stored
def _start(ctx: click.Context, api_key: str | None) -> tuple[ClaudeCredential, tuple[str, ...]]:
@dataclass(frozen=True, slots=True)
class _Listing:
models: tuple[ListedModel, ...]
@property
def ids(self) -> tuple[str, ...]:
return tuple(model.id for model in self.models)
def _start(ctx: click.Context, api_key: str | None) -> tuple[ClaudeCredential, _Listing]:
"""Every configure path begins the same way: the local ownership check first, so a `lite up`
session is refused before any login prompt or request, then the credential, then the listing."""
settings_path: Final = claude_settings_path(os.environ)
@ -88,21 +106,28 @@ def _listing_error(base_url: str, error: PiSyncError) -> str:
return f"{error.message} The proxy at {base_url} answered, so check that it is a LiteLLM proxy and is healthy."
def _listed_models(base_url: str, key: str) -> tuple[str, ...]:
listed: Final = fetch_model_ids(base_url, key)
def _listed_models(base_url: str, key: str) -> _Listing:
listed: Final = fetch_model_listing(base_url, key, headers=_CLAUDE_CODE_VIEW)
if isinstance(listed, PiSyncError):
raise click.ClickException(_listing_error(base_url, listed))
return listed
return _Listing(listed)
def _starting_model(model: str, listing: _Listing) -> str | None:
source: Final = next((listed.id for listed in listing.models if listed.source_model == model), None)
return source or next((listed.id for listed in listing.models if listed.id == model), None)
def _model_choice(model: str | None) -> ModelChoice:
return StartOn(model) if model is not None else UnpinModel()
def _apply_claude(ctx: click.Context, credential: ClaudeCredential, listed: Sequence[str], model: str | None) -> None:
def _apply_claude(ctx: click.Context, credential: ClaudeCredential, listing: _Listing, model: str | None) -> None:
ctx_obj: Final[CliContextObj] = ctx.obj
base_url: Final = ctx_obj["base_url"]
if model is not None and model not in listed:
listed: Final = listing.ids
starting: Final = _starting_model(model, listing) if model is not None else None
if model is not None and starting is None:
shown: Final = ", ".join(listed[:_LISTED_MODELS_SHOWN])
more: Final = f", and {len(listed) - _LISTED_MODELS_SHOWN} more" if len(listed) > _LISTED_MODELS_SHOWN else ""
raise click.ClickException(
@ -113,29 +138,32 @@ def _apply_claude(ctx: click.Context, credential: ClaudeCredential, listed: Sequ
configure_claude_settings(
base_url,
credential,
_model_choice(model),
_model_choice(starting),
settings_path,
configure_state_path(settings_path),
settings_file_owners(settings_path),
)
except ClaudeSettingsError as e:
raise click.ClickException(str(e))
in_picker: Final = sum(1 for listed_model in listed if _CLAUDE_CODE_PICKER_FILTER.search(listed_model))
in_picker: Final = sum(1 for listed_model in listed if CLAUDE_CODE_PICKER_PATTERN.search(listed_model))
click.echo(f"Configured Claude Code: {settings_path} now routes through {base_url}.")
click.echo(
"Credential: your virtual key, stored in the file as ANTHROPIC_AUTH_TOKEN."
if isinstance(credential, StaticToken)
else "Credential: your `lite login`, read through apiKeyHelper on every request, so a later login renews it."
)
click.echo(
f"Starting model: {model} ({STARTING_MODEL_ROLE}); switch any time with /model."
if model is not None
f"Starting model: {starting} ({STARTING_MODEL_ROLE}); switch any time with /model."
if starting is not None
else "Starting model: not pinned (Claude Code's default, or a model you set yourself); switch with /model, or "
"pass --model to start on a proxy model."
)
click.echo(
f"/model will list {in_picker} of the proxy's {len(listed)} models (Claude Code shows only ids containing "
"'claude' or 'anthropic')."
f"/model will list all {len(listed)} of the proxy's models."
if in_picker == len(listed)
else f"/model will list {in_picker} of the proxy's {len(listed)} models: Claude Code shows only ids containing "
"'claude' or 'anthropic', and this proxy does not list the rest under such names."
)
click.echo("Start `claude` from any terminal. Undo with `lite unconfigure claude`.")
if isinstance(credential, StaticToken) and settings_path.is_symlink():
@ -173,8 +201,10 @@ def interactive_configure(
targets: Final = pick_targets()
if _CLAUDE_TARGET not in targets:
return
credential, listed = _start(ctx, None)
_apply_claude(ctx, credential, listed, pick_model(listed))
credential, listing = _start(ctx, None)
_apply_claude(
ctx, credential, listing, pick_model(tuple(model.source_model or model.id for model in listing.models))
)
@click.group(name="configure", invoke_without_command=True)
@ -218,8 +248,8 @@ def configure_claude(ctx: click.Context, api_key: str | None, model: str | None)
setting is kept, and what changed is recorded so `lite unconfigure claude` can put it back.
Assumes the proxy is already running.
"""
credential, listed = _start(ctx, api_key)
_apply_claude(ctx, credential, listed, model)
credential, listing = _start(ctx, api_key)
_apply_claude(ctx, credential, listing, model)
@unconfigure_group.command(name="claude")

View file

@ -13,10 +13,11 @@ from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from types import MappingProxyType
from typing import Final
from typing import Annotated, Final
import requests
from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError, model_validator
from pydantic.types import StringConstraints
PI_CONFIG_DIR_ENV: Final = "PI_CODING_AGENT_DIR"
PI_PROVIDER_NAME: Final = "litellm"
@ -51,12 +52,25 @@ class ModelLimits:
max_tokens: int | None
class _Model(BaseModel):
id: str
_NonEmptyString = Annotated[str, StringConstraints(min_length=1)]
class ListedModel(BaseModel):
model_config = ConfigDict(frozen=True)
id: _NonEmptyString
source_model: _NonEmptyString | None = None
class _ModelList(BaseModel):
data: tuple[_Model, ...]
data: tuple[ListedModel, ...]
@model_validator(mode="after")
def unique_id_mappings(self) -> "_ModelList":
mappings: Final = frozenset((model.id, model.source_model or model.id) for model in self.data)
if len(frozenset(model.id for model in self.data)) != len(mappings):
raise ValueError("model ids must not map to multiple source models")
return self
class _ModelGroup(BaseModel):
@ -69,17 +83,18 @@ class _ModelGroupList(BaseModel):
data: tuple[_ModelGroup, ...]
def fetch_model_ids(
def fetch_model_listing(
base_url: str,
api_key: str,
*,
get: Callable[..., requests.Response] = requests.get,
) -> tuple[str, ...] | PiSyncError:
headers: Mapping[str, str] = MappingProxyType({}),
) -> tuple[ListedModel, ...] | PiSyncError:
url: Final = base_url.rstrip("/") + "/v1/models"
try:
resp: Final = get(
url,
headers={"Authorization": f"Bearer {api_key}"}, # mutable-ok: requests headers require a dict
headers={"Authorization": f"Bearer {api_key}", **headers}, # mutable-ok: requests headers require a dict
timeout=10,
)
except requests.RequestException as e:
@ -94,10 +109,21 @@ def fetch_model_ids(
listing: Final = _ModelList.model_validate(resp.json())
except (ValueError, ValidationError) as e:
return PiSyncError(f"Unexpected /v1/models response from the proxy: {e}", kind=ListingFailure.BAD_BODY)
ids: Final = tuple(dict.fromkeys(model.id for model in listing.data))
if not ids:
models: Final = tuple(dict.fromkeys(listing.data))
if not models:
return PiSyncError("The proxy returned no models for your key.", kind=ListingFailure.EMPTY)
return ids
return models
def fetch_model_ids(
base_url: str,
api_key: str,
*,
get: Callable[..., requests.Response] = requests.get,
headers: Mapping[str, str] = MappingProxyType({}),
) -> tuple[str, ...] | PiSyncError:
listed: Final = fetch_model_listing(base_url, api_key, get=get, headers=headers)
return listed if isinstance(listed, PiSyncError) else tuple(dict.fromkeys(model.id for model in listed))
_NO_LIMITS: Final[Mapping[str, ModelLimits]] = MappingProxyType({})
@ -222,11 +248,13 @@ __all__ = (
"LITELLM_PROXY_API_KEY_ENV",
"PI_CONFIG_DIR_ENV",
"PI_PROVIDER_NAME",
"ListedModel",
"ListingFailure",
"ModelLimits",
"PiSyncError",
"fetch_model_ids",
"fetch_model_limits",
"fetch_model_listing",
"models_json_path",
"provider_block",
"sync_models_json",

View file

@ -10,12 +10,24 @@ legacy internal names with `general_settings.use_team_public_model_name: false`.
from __future__ import annotations
from collections.abc import Mapping, Sequence
import re
from collections.abc import Container, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, cast
import litellm
if TYPE_CHECKING:
from litellm.router import Router
from litellm.types.proxy.model_listing import ModelInfoResponse
CLAUDE_CODE_PICKER_PATTERN: Final = re.compile(r"claude|anthropic", re.IGNORECASE)
GATEWAY_CLIENT_HEADER: Final = "x-gateway-client"
CLAUDE_CODE_CLIENT: Final = "claude-code"
_CLAUDE_CODE_ALIAS_PREFIX: Final = "claude-router-"
_ONE_MILLION_SUFFIX: Final = "[1m]"
_ONE_MILLION_TOKENS: Final = 1_000_000
def configured_display_names(
@ -40,6 +52,115 @@ def configured_display_names(
)
def _unmarked(name: str) -> str:
return name[: -len(_ONE_MILLION_SUFFIX)] if name.lower().endswith(_ONE_MILLION_SUFFIX) else name
def _compatibility_id(model_id: str) -> str:
return f"{_CLAUDE_CODE_ALIAS_PREFIX}{model_id.encode().hex()}"
def _decoded_compatibility_id(view_id: str) -> str | None:
encoded: Final = _unmarked(view_id).removeprefix(_CLAUDE_CODE_ALIAS_PREFIX)
if encoded == _unmarked(view_id):
return None
try:
model_id: Final = bytes.fromhex(encoded).decode()
except (ValueError, UnicodeDecodeError):
return None
return model_id if _compatibility_id(model_id) == _unmarked(view_id) else None
def claude_code_model_id(
model_id: str,
max_input_tokens: float | None,
routing_names: Container[str],
) -> str:
"""The collision-free id Claude Code's picker lists a model under."""
if "*" in model_id:
return model_id
shaped: Final = model_id if CLAUDE_CODE_PICKER_PATTERN.search(model_id) else _compatibility_id(model_id)
one_million: Final = max_input_tokens is not None and max_input_tokens >= _ONE_MILLION_TOKENS
marked: Final = (
f"{shaped}{_ONE_MILLION_SUFFIX}" if one_million and not shaped.lower().endswith(_ONE_MILLION_SUFFIX) else shaped
)
return next(
(
name
for name in (marked, shaped)
if name == model_id or claude_code_group_name(name, routing_names) == model_id
),
model_id,
)
def claude_code_group_name(view_id: str, routing_names: Container[str]) -> str | None:
"""Decode a canonical compatibility id only when no configured route claims it."""
if view_id in routing_names:
return None
unmarked: Final = _unmarked(view_id)
if unmarked != view_id and unmarked in routing_names:
return unmarked
model_id: Final = _decoded_compatibility_id(view_id)
return model_id if model_id and model_id in routing_names else None
def is_claude_code_client(headers: Mapping[str, str]) -> bool:
"""Claude Code itself, or a client asking for its view of the listing the way Ramp Router's does"""
from litellm.llms.anthropic.common_utils import is_claude_code_user_agent
return (
is_claude_code_user_agent(headers.get("user-agent", ""))
or headers.get(GATEWAY_CLIENT_HEADER, "").lower() == CLAUDE_CODE_CLIENT
)
def claude_code_view_ids(
rows: Sequence[ModelInfoResponse],
headers: Mapping[str, str],
routing_names: Container[str],
) -> Mapping[str, str]:
"""served id -> Claude Code id for the requested listing view"""
if not is_claude_code_client(headers):
return MappingProxyType({})
return MappingProxyType(
{row["id"]: claude_code_model_id(row["id"], row.get("max_input_tokens"), routing_names) for row in rows}
)
@dataclass(frozen=True, slots=True)
class ClaudeCodeRoutingNames:
"""Existing routes always own their names, including aliases and wildcard routes."""
llm_router: Router | None
team_id: str | None = None
alias_maps: tuple[object, ...] = ()
def __contains__(self, name: object) -> bool:
if not isinstance(name, str):
return False
if name in litellm.model_alias_map or any(
isinstance(aliases, Mapping) and name in aliases for aliases in self.alias_maps
):
return True
if self.llm_router is None:
return False
return (
name in self.llm_router.model_group_alias
or self.llm_router.has_model_id(name)
or bool(self.llm_router.get_candidate_model_ids_for_route(name, self.team_id))
)
def claude_code_requested_group(
requested: str,
llm_router: Router,
team_id: str | None,
alias_maps: tuple[object, ...] = (),
) -> str | None:
return claude_code_group_name(requested, ClaudeCodeRoutingNames(llm_router, team_id, alias_maps))
class TeamModelNameTranslator:
"""Translates internal team routing keys to their public names for the model
listing/retrieve responses. Stateless; the live router and general_settings

View file

@ -366,8 +366,11 @@ from litellm.proxy.common_utils.load_config_utils import (
)
from litellm.proxy.common_utils.model_deprecation import collect_model_deprecations
from litellm.proxy.common_utils.model_listing_utils import (
ClaudeCodeRoutingNames,
TeamModelNameTranslator,
claude_code_view_ids,
configured_display_names,
is_claude_code_client,
)
from litellm.proxy.common_utils.openai_endpoint_utils import (
remove_sensitive_info_from_deployment,
@ -6639,6 +6642,14 @@ class ProxyConfig:
return parsed
return None
async def get_hierarchical_router_settings(
self,
user_api_key_dict: UserAPIKeyAuth | None,
prisma_client: PrismaClient | None,
proxy_logging_obj: ProxyLogging | None = None,
) -> dict | None:
return await self._get_hierarchical_router_settings(user_api_key_dict, prisma_client, proxy_logging_obj)
async def _get_hierarchical_router_settings(
self,
user_api_key_dict: Optional["UserAPIKeyAuth"],
@ -8632,6 +8643,7 @@ _STREAM_KEEPALIVE: Final = object()
_KEEPALIVE_MIN_SECONDS: Final = 1.0
_KEEPALIVE_MAX_SECONDS: Final = 300.0
_EMPTY_MAPPING: Final[Mapping[str, object]] = MappingProxyType({})
_EMPTY_HEADERS: Final[Mapping[str, str]] = MappingProxyType({})
async def _iter_with_keepalive(
@ -10469,6 +10481,24 @@ async def model_list(
wants_anthropic_format: Final = (
http_request is not None and http_request.headers.get("anthropic-version") is not None
)
client_headers: Final[Mapping[str, str]] = http_request.headers if http_request is not None else _EMPTY_HEADERS
view_router_settings: Final = (
await proxy_config.get_hierarchical_router_settings(user_api_key_dict, prisma_client, proxy_logging_obj)
if wants_anthropic_format and is_claude_code_client(client_headers)
else None
)
view_aliases: Final = (
view_router_settings.get("model_group_alias") if isinstance(view_router_settings, Mapping) else None
)
routing_names: Final = ClaudeCodeRoutingNames(
llm_router,
team_id or user_api_key_dict.team_id,
(
user_api_key_dict.aliases,
user_api_key_dict.team_model_aliases,
view_aliases,
),
)
# Validate scope parameter if provided
if scope is not None and scope != "expand":
@ -10555,6 +10585,11 @@ async def model_list(
return create_anthropic_model_list_response(
admin_listing,
display_names=configured_display_names(admin_entries, llm_router),
listed_ids=claude_code_view_ids(
admin_listing,
client_headers,
routing_names,
),
)
return dict(
@ -10603,6 +10638,11 @@ async def model_list(
return create_anthropic_model_list_response(
listing,
display_names=configured_display_names(entries, llm_router),
listed_ids=claude_code_view_ids(
listing,
client_headers,
routing_names,
),
)
return dict(

View file

@ -2267,3 +2267,25 @@ def test_create_anthropic_model_list_response_empty():
assert response["has_more"] is False
assert response["first_id"] is None
assert response["last_id"] is None
def test_create_anthropic_model_list_response_lists_ids_as_told():
"""listed_ids renames an entry for the caller while display_name and every other field stay keyed to the served
id, and the envelope's first/last ids follow the renamed entries."""
from litellm.llms.anthropic.common_utils import (
create_anthropic_model_list_response,
)
response = create_anthropic_model_list_response(
[
{"id": "gpt-4o", "object": "model", "created": 0, "owned_by": "openai", "max_input_tokens": 1000000},
{"id": "claude-haiku-4-5", "object": "model", "created": 0, "owned_by": "openai"},
],
display_names={"gpt-4o": "GPT 4o"},
listed_ids={"gpt-4o": "claude-router-gpt-4o[1m]"},
)
gpt, haiku = response["data"]
assert (gpt["id"], gpt["display_name"], gpt["max_input_tokens"]) == ("claude-router-gpt-4o[1m]", "GPT 4o", 1000000)
assert (haiku["id"], haiku["display_name"]) == ("claude-haiku-4-5", "claude-haiku-4-5")
assert (response["first_id"], response["last_id"]) == ("claude-router-gpt-4o[1m]", "claude-haiku-4-5")

View file

@ -7249,3 +7249,46 @@ async def test_jwt_builder_returns_every_team_grant_the_key_path_gets(is_proxy_a
assert token.team_member == Member(user_id="jwt-user", role="admin")
assert token.team_member_spend == 1.5
assert token.jwt_claims == {"sub": "jwt-user"}
@pytest.mark.asyncio
@pytest.mark.parametrize("route", ["/v1/messages", "/messages", "/v1/chat/completions", "/chat/completions", "/v1/responses", "/responses"])
async def test_claude_view_normalizes_before_model_access(monkeypatch, route):
from starlette.requests import Request
from litellm.proxy.auth.user_api_key_auth import _enforce_key_and_fallback_model_access
source = "foo[1m]"
encoded = "claude-router-" + source.encode().hex() + "[1m]"
router = litellm.Router(model_list=[{"model_name": source, "litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-fake"}}])
monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", router)
data = {"model": encoded, "messages": [{"role": "user", "content": "hi"}]}
request = Request({"type": "http", "method": "POST", "path": route, "headers": [], "query_string": b""})
token = UserAPIKeyAuth(models=[source])
await _enforce_key_and_fallback_model_access(valid_token=token, request_data=data, route=route, request=request, llm_model_list=router.model_list, llm_router=router)
assert data["model"] == source
assert (await request.json())["model"] == source
assert json.loads(await request.body())["model"] == source
assert request.scope["parsed_body"][1]["model"] == source
with pytest.raises(ProxyException):
await _enforce_key_and_fallback_model_access(valid_token=UserAPIKeyAuth(models=["other"]), request_data=data, route=route, request=request, llm_model_list=router.model_list, llm_router=router)
@pytest.mark.asyncio
@pytest.mark.parametrize("layer", ["literal", "global", "router", "key", "hierarchical", "unclaimed"])
async def test_claude_view_never_reinterprets_explicit_names(monkeypatch, layer):
from starlette.requests import Request
from litellm.proxy.auth.user_api_key_auth import _normalize_claude_model
encoded = "claude-router-666f6f"
names = ("foo", "other", encoded) if layer == "literal" else ("foo", "other")
alias = {encoded: "other"}
router = litellm.Router(model_list=[{"model_name": name, "litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-fake"}} for name in names], model_group_alias=alias if layer == "router" else None)
monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", router)
monkeypatch.setattr(litellm, "model_alias_map", alias if layer == "global" else {})
token = UserAPIKeyAuth(aliases=alias if layer == "key" else {}, router_settings={"model_group_alias": alias} if layer == "hierarchical" else None)
data = {"model": encoded}
request = Request({"type": "http", "method": "POST", "path": "/v1/messages", "headers": [], "query_string": b""})
await _normalize_claude_model(data, token, request, "/v1/messages")
assert data["model"] == ("foo" if layer == "unclaimed" else encoded)
await _normalize_claude_model(data, token, request, "/v1/messages")
assert data["model"] == ("foo" if layer == "unclaimed" else encoded)

View file

@ -89,7 +89,7 @@ class TestConfigureClaudeWithAVirtualKey:
assert "Starting model: claude-auto" in result.output
assert "1 of the proxy's 2 models" in result.output
assert "lite unconfigure claude" in result.output
assert len(responses.calls) == 1
assert [call.request.headers.get("x-gateway-client") for call in responses.calls] == ["claude-code"]
@responses.activate
def test_takes_the_key_from_the_global_option_and_keeps_claude_codes_default(self, runner, paths):
@ -358,3 +358,74 @@ class TestUnconfigureClaude:
result = runner.invoke(cli, ["unconfigure", "claude"])
assert result.exit_code != 0
assert "nothing to undo" in result.output
class TestClaudeCodeView:
VIEW = {"anthropic-version": "2023-06-01", "x-gateway-client": "claude-code"}
def _mock(self, rows):
responses.get(
f"{PROXY}/v1/models",
json={"data": rows},
match=[responses.matchers.header_matcher({"Authorization": f"Bearer {VALID_KEY}", **self.VIEW})],
)
@responses.activate
@pytest.mark.parametrize(
"model, pinned",
[
("literal-claude-router-source", "emitted-literal"),
("marked-sibling", "emitted-marked[1m]"),
("emitted-collision", "emitted-source-priority"),
("emitted-only", "emitted-only"),
],
)
def test_pins_source_identity_before_emitted_id(self, runner, paths, model, pinned):
self._mock(
[
{"id": "emitted-collision", "source_model": "other-source"},
{"id": "emitted-source-priority", "source_model": "emitted-collision"},
{"id": "emitted-marked[1m]", "source_model": "marked-sibling"},
{"id": "emitted-literal", "source_model": "literal-claude-router-source"},
{"id": "emitted-only"},
]
)
settings_path, _ = paths
result = _configure(runner, "--api-key", VALID_KEY, "--model", model)
assert result.exit_code == 0, result.output
assert json.loads(settings_path.read_text())["model"] == pinned
assert f"Starting model: {pinned}" in result.output
assert len(responses.calls) == 1
@responses.activate
def test_refuses_unknown_short_suffix(self, runner, paths):
self._mock([{"id": "emitted-router-source", "source_model": "literal-router-source"}])
settings_path, _ = paths
result = _configure(runner, "--api-key", VALID_KEY, "--model", "source")
assert result.exit_code != 0
assert "'source' is not served" in result.output
assert not settings_path.exists()
@responses.activate
def test_interactive_picker_uses_source_names(self, paths):
self._mock([{"id": "emitted", "source_model": "source"}])
settings_path, _ = paths
asked = {}
ctx = click.Context(
configure_group, obj={"base_url": PROXY, "api_key": VALID_KEY, "api_key_from_token_file": False}
)
def pick_model(listed):
asked["listed"] = tuple(listed)
return "source"
interactive_configure(ctx, pick_targets=lambda: ("claude",), pick_model=pick_model)
assert asked["listed"] == ("source",)
assert json.loads(settings_path.read_text())["model"] == "emitted"
@responses.activate
def test_counts_what_an_older_proxy_lets_the_picker_show(self, runner, paths):
_mock_models()
result = _configure(runner, "--api-key", VALID_KEY)
assert result.exit_code == 0, result.output
assert "/model will list 1 of the proxy's 2 models: Claude Code shows only ids containing" in result.output

View file

@ -13,6 +13,7 @@ from litellm.proxy.client.cli.commands.pi import (
PiSyncError,
fetch_model_ids,
fetch_model_limits,
fetch_model_listing,
models_json_path,
provider_block,
sync_models_json,
@ -50,6 +51,43 @@ class TestFetchModelIds:
assert captured["url"] == "http://localhost:4000/v1/models"
assert captured["headers"] == {"Authorization": "Bearer sk-key"}
def test_returns_rows_with_optional_source_model_and_dedups_identical_rows(self):
result = fetch_model_listing(
"http://localhost:4000",
"sk-key",
get=lambda *a, **k: _FakeResponse(
200,
{"data": [{"id": "emitted", "source_model": "source"}, {"id": "emitted", "source_model": "source"}]},
),
)
assert not isinstance(result, PiSyncError)
assert tuple((model.id, model.source_model) for model in result) == (("emitted", "source"),)
@pytest.mark.parametrize(
"entry",
[
{"id": ""},
{"id": "emitted", "source_model": ""},
{"id": "emitted", "source_model": 1},
],
)
def test_rejects_invalid_model_identity(self, entry):
result = fetch_model_listing(
"http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(200, {"data": [entry]})
)
assert isinstance(result, PiSyncError) and result.kind is ListingFailure.BAD_BODY
def test_rejects_conflicting_emitted_id_mappings(self):
result = fetch_model_listing(
"http://localhost:4000",
"sk-key",
get=lambda *a, **k: _FakeResponse(
200,
{"data": [{"id": "emitted", "source_model": "one"}, {"id": "emitted", "source_model": "two"}]},
),
)
assert isinstance(result, PiSyncError) and result.kind is ListingFailure.BAD_BODY
def test_network_error_is_a_value(self):
def boom(*a, **k):
raise requests.ConnectionError("refused")

View file

@ -0,0 +1,109 @@
"""Model identity survives Claude Code presentation, filtering and configured alias precedence."""
from itertools import combinations
import pytest
from litellm import Router
from litellm.proxy.common_utils.model_listing_utils import (
ClaudeCodeRoutingNames,
claude_code_group_name,
claude_code_model_id,
claude_code_requested_group,
claude_code_view_ids,
)
def _encoded(name):
return "claude-router-" + name.encode().hex()
def _marked(name):
return f"{_encoded(name)}[1m]"
def _row(name, limit=1000000):
return {"id": name, "object": "model", "created": 0, "owned_by": "openai", "max_input_tokens": limit}
def _router(*names, aliases=None):
return Router(
model_list=[
{"model_name": name, "litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-fake"}}
for name in names
],
model_group_alias=aliases,
)
@pytest.mark.parametrize("limit", [None, 999999, 1000000])
@pytest.mark.parametrize("name", ["foo", "foo[1m]", "foo[1M]", "a/b: 世界", "claude-router-foo", "claude-opus-5", "claude-opus-5[1m]"])
def test_listing_round_trips_entire_source_name(name, limit):
names = frozenset({name})
view = claude_code_model_id(name, limit, names)
assert (claude_code_group_name(view, names) or view) == name
if "claude" not in name:
assert view.startswith(_encoded(name))
assert ("[1m]" in view.lower()) == (limit == 1000000 or "[1m]" in name.lower() and "claude" in name)
def test_collision_matrix_round_trips_without_duplicate_ids():
universe = ("foo", "foo[1m]", "claude-router-foo", _encoded("foo"), _encoded("foo") + "[1m]", "claude-opus-5", "claude-opus-5[1m]")
for pair in combinations(universe, 2):
for visible in (pair, pair[:1], pair[1:]):
names = frozenset(pair)
view = claude_code_view_ids(tuple(_row(n) for n in visible), {"user-agent": "claude-code/2.1.267"}, names)
assert len(set(view.values())) == len(visible)
assert all((claude_code_group_name(shown, names) or shown) == source for source, shown in view.items())
@pytest.mark.parametrize("spelling", ["claude-router-foo", "claude-router-ff", "claude-router-66 6f6f", "claude-router-666F6F", "claude-router-", _encoded("missing")])
def test_unknown_or_noncanonical_ids_are_never_guessed(spelling):
assert claude_code_group_name(spelling, frozenset({"foo"})) is None
@pytest.mark.parametrize("headers,enabled", [
({"user-agent": "claude-code/2.1.267"}, True),
({"user-agent": "claude-cli/2.1.267 (external, sdk-cli)"}, True),
({"x-gateway-client": "Claude-Code"}, True),
({"user-agent": "anthropic-sdk-python/0.40"}, False),
({}, False),
])
def test_only_claude_code_gets_the_view(headers, enabled):
rows = (_row("foo"), _row("claude-opus-5"))
view = claude_code_view_ids(rows, headers, frozenset(row["id"] for row in rows))
assert dict(view) == ({"foo": _encoded("foo") + "[1m]", "claude-opus-5": "claude-opus-5[1m]"} if enabled else {})
@pytest.mark.parametrize("layer", ["literal", "global", "router", "key", "team", "wildcard"])
def test_configured_names_outrank_generated_ids_even_when_hidden_from_listing(monkeypatch, layer):
import litellm
encoded = _encoded("foo")
alias = {encoded: "other"}
monkeypatch.setattr(litellm, "model_alias_map", alias if layer == "global" else {})
router = _router("foo", "other", *( (encoded,) if layer == "literal" else ("*",) if layer == "wildcard" else ()), aliases=alias if layer == "router" else None)
maps = (alias,) if layer in ("key", "team") else ()
names = ClaudeCodeRoutingNames(router, None, maps)
assert claude_code_requested_group(encoded, router, None, maps) is None
assert claude_code_view_ids((_row("foo", None),), {"x-gateway-client": "claude-code"}, names)["foo"] == "foo"
@pytest.mark.parametrize("source", ["foo", "foo[1m]", "世界"])
def test_mutation_breaking_the_hex_name_cannot_route_to_the_source(source):
router = _router(source)
encoded = _encoded(source)
malformed = encoded[:-1] + ("0" if encoded[-1] != "0" else "1")
assert claude_code_requested_group(malformed, router, None) is None
assert claude_code_requested_group(_marked(source), router, None) == source
def test_team_public_name_uses_the_same_scope_at_list_and_request():
router = Router(model_list=[{
"model_name": "model_name_team-a_id",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-fake"},
"model_info": {"team_id": "team-a", "team_public_model_name": "shared"},
}])
shown = claude_code_view_ids((_row("shared"),), {"user-agent": "claude-code/2.1.267"}, ClaudeCodeRoutingNames(router, "team-a"))["shared"]
assert claude_code_requested_group(shown, router, "team-a") == "shared"
assert claude_code_requested_group(shown, router, "team-b") is None

View file

@ -343,3 +343,53 @@ def test_anthropic_format_returns_public_team_model_name(
assert response.status_code == 200
assert [m["id"] for m in response.json()["data"]] == ["gpt-4-team"]
assert internal_name not in response.text
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
@pytest.mark.parametrize(
"caller_headers",
[
{"anthropic-version": "2023-06-01", "user-agent": "claude-code/2.1.267"},
{"anthropic-version": "2023-06-01", "user-agent": "claude-cli/2.1.267 (external, sdk-cli)"},
{"anthropic-version": "2023-06-01", "x-gateway-client": "claude-code"},
],
)
def test_anthropic_format_lists_claude_code_view_ids_for_claude_code(
client, auth_as, patched_models, monkeypatch, path, caller_headers
):
"""Claude Code drops every id without claude/anthropic in it and reads [1m] as its 1M marker, so for Claude
Code (its discovery fetch's own user agent, its SDK's, or the gateway-client header a launcher sends) every
group is listed under a Claude-shaped id with the marker where the window reaches 1M; the display name stays
the served name."""
def _create_model_info_response(model_id, provider="openai", **kwargs):
if model_id != "claude-sonnet":
return _stub_model_info_response(model_id=model_id, provider=provider)
return {**_stub_model_info_response(model_id=model_id, provider=provider), "max_input_tokens": 1000000}
patched_models.model_group_alias = {}
patched_models.has_model_id.return_value = False
patched_models.get_candidate_model_ids_for_route.side_effect = lambda name, team_id=None: frozenset({name}) if name in ("gpt-4", "claude-sonnet") else frozenset()
monkeypatch.setattr(proxy_utils, "create_model_info_response", _create_model_info_response)
with auth_as():
response = client.get(path, headers=caller_headers)
assert response.status_code == 200
body = response.json()
assert [(m["id"], m["display_name"]) for m in body["data"]] == [
("claude-router-6770742d34", "gpt-4"),
("claude-sonnet[1m]", "claude-sonnet"),
]
assert (body["first_id"], body["last_id"]) == ("claude-router-6770742d34", "claude-sonnet[1m]")
assert [row["source_model"] for row in body["data"]] == ["gpt-4", "claude-sonnet"]
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
def test_anthropic_format_keeps_served_ids_for_other_anthropic_clients(client, auth_as, patched_models, path):
"""An Anthropic SDK asking for the vendor shape gets the served ids: the view is Claude Code's alone."""
with auth_as():
response = client.get(path, headers={"anthropic-version": "2023-06-01", "user-agent": "anthropic-sdk-python/0.40"})
assert response.status_code == 200
assert [m["id"] for m in response.json()["data"]] == ["gpt-4", "claude-sonnet"]