Merge branch 'litellm_internal_staging' into litellm_fix_midstream_mockvalser_logprobs

This commit is contained in:
Devin AI 2026-07-16 19:49:23 +00:00
commit c887efb452
200 changed files with 4646 additions and 3819 deletions

View file

@ -113,6 +113,10 @@ class PagerDutyAlerting(SlackAlerting):
user_api_key_spend=_meta.get("user_api_key_spend"),
user_api_key_max_budget=_meta.get("user_api_key_max_budget"),
user_api_key_budget_reset_at=_meta.get("user_api_key_budget_reset_at"),
user_api_key_user_spend=_meta.get("user_api_key_user_spend"),
user_api_key_user_max_budget=_meta.get("user_api_key_user_max_budget"),
user_api_key_team_spend=_meta.get("user_api_key_team_spend"),
user_api_key_team_max_budget=_meta.get("user_api_key_team_max_budget"),
user_api_key_org_id=_meta.get("user_api_key_org_id"),
user_api_key_org_alias=_meta.get("user_api_key_org_alias"),
user_api_key_team_id=_meta.get("user_api_key_team_id"),
@ -196,6 +200,10 @@ class PagerDutyAlerting(SlackAlerting):
if user_api_key_dict.budget_reset_at
else None
),
user_api_key_user_spend=user_api_key_dict.user_spend,
user_api_key_user_max_budget=user_api_key_dict.user_max_budget,
user_api_key_team_spend=user_api_key_dict.team_spend,
user_api_key_team_max_budget=user_api_key_dict.team_max_budget,
user_api_key_org_id=user_api_key_dict.org_id,
user_api_key_org_alias=user_api_key_dict.organization_alias,
user_api_key_team_id=user_api_key_dict.team_id,

View file

@ -46,4 +46,9 @@ Reminders:
- gateway.config.proxy_config (rendered into a ConfigMap and mounted at
/app/config/config.yaml; gateway reads it via
CONFIG_FILE_PATH)
- {component}.pdb.{enabled,minAvailable,maxUnavailable} (per-component PodDisruptionBudget; disabled by
default — with hpa.minReplicas of 1, minAvailable: 1
would block node drains)
- {component}.topologySpreadConstraints (standard k8s list, e.g. spread replicas across
topology.kubernetes.io/zone)
- Enable ingress.enabled=true to dispatch / → ui, gateway data-plane prefixes → gateway, and the catch-all → backend.

View file

@ -295,6 +295,52 @@ harmless no-op for the Job and authoritative for the app pods.
{{- end }}
{{- end -}}
{{/*
PodDisruptionBudget shared by gateway, backend, and ui.
Invoke with a dict:
(dict "root" $ "component" .Values.gateway "componentName" "gateway"
"fullname" (include "litellm.gateway.fullname" .)
"selectorLabels" (include "litellm.gateway.selectorLabels" .))
Renders nothing unless both the component and its `pdb.enabled` are on.
Only one of minAvailable / maxUnavailable should be set; if both are,
minAvailable wins. If neither is set, falls back to `maxUnavailable: 1` so
an enabled-but-unconfigured PDB still permits node drains.
"Set" means non-nil and non-empty-string, so an explicit 0 (e.g.
`maxUnavailable: 0` to forbid all voluntary disruptions) is honored rather
than silently replaced by the fallback.
*/}}
{{- define "litellm.pdb" -}}
{{- $root := .root -}}
{{- $component := .component -}}
{{- $min := $component.pdb.minAvailable -}}
{{- $max := $component.pdb.maxUnavailable -}}
{{- $minSet := not (or (kindIs "invalid" $min) (eq (printf "%v" $min) "")) -}}
{{- $maxSet := not (or (kindIs "invalid" $max) (eq (printf "%v" $max) "")) -}}
{{- if and $component.enabled $component.pdb $component.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ .fullname }}
labels:
{{- include "litellm.commonLabels" $root | nindent 4 }}
app.kubernetes.io/component: {{ .componentName }}
spec:
selector:
matchLabels:
{{- .selectorLabels | nindent 6 }}
{{- if $minSet }}
minAvailable: {{ $min }}
{{- else if $maxSet }}
maxUnavailable: {{ $max }}
{{- else }}
maxUnavailable: 1
{{- end }}
{{- end }}
{{- end -}}
{{/*
Renders `envFrom:` block for a component's `envConfigMaps` / `envSecrets`
lists. Each entry is a resource name; the chart wires the whole ConfigMap /

View file

@ -98,4 +98,8 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.backend.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,6 @@
{{- include "litellm.pdb" (dict
"root" $
"component" .Values.backend
"componentName" "backend"
"fullname" (include "litellm.backend.fullname" .)
"selectorLabels" (include "litellm.backend.selectorLabels" .)) }}

View file

@ -100,4 +100,8 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.gateway.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,6 @@
{{- include "litellm.pdb" (dict
"root" $
"component" .Values.gateway
"componentName" "gateway"
"fullname" (include "litellm.gateway.fullname" .)
"selectorLabels" (include "litellm.gateway.selectorLabels" .)) }}

View file

@ -76,4 +76,8 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.ui.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,6 @@
{{- include "litellm.pdb" (dict
"root" $
"component" .Values.ui
"componentName" "ui"
"fullname" (include "litellm.ui.fullname" .)
"selectorLabels" (include "litellm.ui.selectorLabels" .)) }}

View file

@ -0,0 +1,188 @@
suite: test pod disruption budgets and topology spread constraints
templates:
- gateway/poddisruptionbudget.yaml
- backend/poddisruptionbudget.yaml
- ui/poddisruptionbudget.yaml
- gateway/deployment.yaml
- gateway/configmap.yaml
- backend/deployment.yaml
- ui/deployment.yaml
values:
- ./values/required.yaml
tests:
- it: renders no PDB by default
templates:
- gateway/poddisruptionbudget.yaml
- backend/poddisruptionbudget.yaml
- ui/poddisruptionbudget.yaml
asserts:
- hasDocuments:
count: 0
- it: gateway PDB uses minAvailable and matches the gateway selector labels
template: gateway/poddisruptionbudget.yaml
set:
gateway.pdb.enabled: true
gateway.pdb.minAvailable: 1
asserts:
- isKind:
of: PodDisruptionBudget
- equal:
path: apiVersion
value: policy/v1
- equal:
path: metadata.name
value: RELEASE-NAME-litellm-gateway
- equal:
path: spec.minAvailable
value: 1
- notExists:
path: spec.maxUnavailable
- equal:
path: spec.selector.matchLabels
value:
app.kubernetes.io/name: litellm
app.kubernetes.io/instance: RELEASE-NAME
app.kubernetes.io/component: gateway
- it: backend PDB uses maxUnavailable when minAvailable is unset
template: backend/poddisruptionbudget.yaml
set:
backend.pdb.enabled: true
backend.pdb.maxUnavailable: 25%
asserts:
- equal:
path: spec.maxUnavailable
value: 25%
- notExists:
path: spec.minAvailable
- equal:
path: spec.selector.matchLabels
value:
app.kubernetes.io/name: litellm
app.kubernetes.io/instance: RELEASE-NAME
app.kubernetes.io/component: backend
- it: minAvailable wins when both minAvailable and maxUnavailable are set
template: gateway/poddisruptionbudget.yaml
set:
gateway.pdb.enabled: true
gateway.pdb.minAvailable: 2
gateway.pdb.maxUnavailable: 1
asserts:
- equal:
path: spec.minAvailable
value: 2
- notExists:
path: spec.maxUnavailable
- it: an explicit maxUnavailable 0 is honored instead of the fallback
template: backend/poddisruptionbudget.yaml
set:
backend.pdb.enabled: true
backend.pdb.maxUnavailable: 0
asserts:
- equal:
path: spec.maxUnavailable
value: 0
- notExists:
path: spec.minAvailable
- it: an explicit minAvailable 0 is honored and beats a set maxUnavailable
template: gateway/poddisruptionbudget.yaml
set:
gateway.pdb.enabled: true
gateway.pdb.minAvailable: 0
gateway.pdb.maxUnavailable: 1
asserts:
- equal:
path: spec.minAvailable
value: 0
- notExists:
path: spec.maxUnavailable
- it: enabled PDB with neither knob set falls back to maxUnavailable 1
template: ui/poddisruptionbudget.yaml
set:
ui.pdb.enabled: true
asserts:
- equal:
path: spec.maxUnavailable
value: 1
- notExists:
path: spec.minAvailable
- equal:
path: spec.selector.matchLabels
value:
app.kubernetes.io/name: litellm
app.kubernetes.io/instance: RELEASE-NAME
app.kubernetes.io/component: ui
- it: renders no PDB for a disabled component even when its pdb is enabled
template: gateway/poddisruptionbudget.yaml
set:
gateway.enabled: false
gateway.pdb.enabled: true
asserts:
- hasDocuments:
count: 0
- it: deployments omit topologySpreadConstraints by default
templates:
- gateway/deployment.yaml
- backend/deployment.yaml
- ui/deployment.yaml
asserts:
- notExists:
path: spec.template.spec.topologySpreadConstraints
- it: gateway deployment renders configured topologySpreadConstraints
template: gateway/deployment.yaml
set:
gateway.topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/component: gateway
asserts:
- equal:
path: spec.template.spec.topologySpreadConstraints
value:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/component: gateway
- it: backend deployment renders configured topologySpreadConstraints
template: backend/deployment.yaml
set:
backend.topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/component: backend
asserts:
- equal:
path: spec.template.spec.topologySpreadConstraints[0].topologyKey
value: kubernetes.io/hostname
- equal:
path: spec.template.spec.topologySpreadConstraints[0].whenUnsatisfiable
value: DoNotSchedule
- it: ui deployment renders configured topologySpreadConstraints
template: ui/deployment.yaml
set:
ui.topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
asserts:
- equal:
path: spec.template.spec.topologySpreadConstraints[0].topologyKey
value: topology.kubernetes.io/zone

View file

@ -190,10 +190,28 @@ gateway:
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
# PodDisruptionBudget for the gateway pods. Set exactly one of
# `minAvailable` / `maxUnavailable` (minAvailable wins if both are set;
# enabling without either falls back to `maxUnavailable: 1`). Disabled by
# default: with the default hpa.minReplicas of 1, a `minAvailable: 1` PDB
# would block node drains entirely.
pdb:
enabled: false
minAvailable: ""
maxUnavailable: ""
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# Standard k8s topologySpreadConstraints for the gateway pods, e.g. to
# spread replicas across zones:
# - maxSkew: 1
# topologyKey: topology.kubernetes.io/zone
# whenUnsatisfiable: ScheduleAnyway
# labelSelector:
# matchLabels:
# app.kubernetes.io/component: gateway
topologySpreadConstraints: []
# ---------- backend (UI / management API) ----------
backend:
@ -233,10 +251,17 @@ backend:
minReplicas: 1
maxReplicas: 4
targetCPUUtilizationPercentage: 70
# Same shape as gateway.pdb.
pdb:
enabled: false
minAvailable: ""
maxUnavailable: ""
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# Same shape as gateway.topologySpreadConstraints.
topologySpreadConstraints: []
# ---------- ui (Next.js static dashboard) ----------
ui:
@ -279,7 +304,14 @@ ui:
minReplicas: 1
maxReplicas: 3
targetCPUUtilizationPercentage: 80
# Same shape as gateway.pdb.
pdb:
enabled: false
minAvailable: ""
maxUnavailable: ""
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
# Same shape as gateway.topologySpreadConstraints.
topologySpreadConstraints: []

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "issuer" TEXT;

View file

@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable {
command String?
args String[] @default([])
env Json? @default("{}")
issuer String?
authorization_url String?
token_url String?
registration_url String?

View file

@ -966,11 +966,15 @@ class BudgetExceededError(Exception):
max_budget: float,
message: Optional[str] = None,
llm_provider: Optional[str] = None,
entity_type: Optional[str] = None,
entity_id: Optional[str] = None,
):
self.current_cost = current_cost
self.max_budget = max_budget
self.status_code = 429
self.llm_provider = llm_provider or ""
self.entity_type = entity_type
self.entity_id = entity_id
# Surface unified rate-limit fields without joining the RateLimitError
# hierarchy so existing `except BudgetExceededError:` handlers keep
# working; custom callbacks reading StandardLoggingPayload pick these

View file

@ -38,6 +38,7 @@ from litellm import (
)
from litellm._logging import _is_debugging_on, _redact_string, verbose_logger
from litellm.exceptions import (
BudgetExceededError,
validate_rate_limit_category,
validate_rate_limit_type,
)
@ -4597,6 +4598,10 @@ class StandardLoggingPayloadSetup:
user_api_key_spend=None,
user_api_key_max_budget=None,
user_api_key_budget_reset_at=None,
user_api_key_user_spend=None,
user_api_key_user_max_budget=None,
user_api_key_team_spend=None,
user_api_key_team_max_budget=None,
user_api_key_team_id=None,
user_api_key_org_id=None,
user_api_key_org_alias=None,
@ -4943,6 +4948,7 @@ class StandardLoggingPayloadSetup:
rate_limit_category = validate_rate_limit_category(getattr(original_exception, "category", None))
rate_limit_type = validate_rate_limit_type(getattr(original_exception, "rate_limit_type", None))
budget_error = original_exception if isinstance(original_exception, BudgetExceededError) else None
return StandardLoggingPayloadErrorInformation(
error_code=error_status,
@ -4952,6 +4958,10 @@ class StandardLoggingPayloadSetup:
error_message=error_message,
error_rate_limit_category=rate_limit_category,
error_rate_limit_type=rate_limit_type,
error_budget_entity_type=budget_error.entity_type if budget_error else None,
error_budget_entity_id=budget_error.entity_id if budget_error else None,
error_budget_limit=budget_error.max_budget if budget_error else None,
error_budget_spend=budget_error.current_cost if budget_error else None,
)
@staticmethod
@ -5428,6 +5438,10 @@ def get_standard_logging_metadata(
user_api_key_spend=None,
user_api_key_max_budget=None,
user_api_key_budget_reset_at=None,
user_api_key_user_spend=None,
user_api_key_user_max_budget=None,
user_api_key_team_spend=None,
user_api_key_team_max_budget=None,
user_api_key_team_id=None,
user_api_key_org_id=None,
user_api_key_org_alias=None,
@ -5527,6 +5541,10 @@ def create_dummy_standard_logging_payload() -> StandardLoggingPayload:
user_api_key_team_id=str("test_team"),
user_api_key_user_id=str("test_user"),
user_api_key_team_alias=str("test_team_alias"),
user_api_key_user_spend=None,
user_api_key_user_max_budget=None,
user_api_key_team_spend=None,
user_api_key_team_max_budget=None,
user_api_key_org_id=None,
spend_logs_metadata=None,
requester_ip_address=str("127.0.0.1"),

View file

@ -467,6 +467,7 @@ class ChunkProcessor:
cache_read_input_tokens: Optional[int] = None
completion_tokens_details: Optional[CompletionTokensDetails] = None
prompt_tokens_details: Optional[PromptTokensDetailsWrapper] = None
cost: Optional[float] = None
if "prompt_tokens" in usage_chunk:
prompt_tokens = usage_chunk.get("prompt_tokens", 0) or 0
@ -476,6 +477,8 @@ class ChunkProcessor:
cache_creation_input_tokens = usage_chunk.get("cache_creation_input_tokens")
if "cache_read_input_tokens" in usage_chunk:
cache_read_input_tokens = usage_chunk.get("cache_read_input_tokens")
if "cost" in usage_chunk:
cost = usage_chunk.get("cost")
if hasattr(usage_chunk, "completion_tokens_details"):
if isinstance(usage_chunk.completion_tokens_details, dict):
completion_tokens_details = CompletionTokensDetails(**usage_chunk.completion_tokens_details)
@ -494,6 +497,7 @@ class ChunkProcessor:
"cache_read_input_tokens": cache_read_input_tokens,
"completion_tokens_details": completion_tokens_details,
"prompt_tokens_details": prompt_tokens_details,
"cost": cost,
}
def count_reasoning_tokens(self, response: ModelResponse) -> Optional[int]:
@ -512,6 +516,22 @@ class ChunkProcessor:
return reasoning_tokens
@staticmethod
def _extract_usage_chunk(chunk: dict[str, Any] | ModelResponse | ModelResponseStream) -> Usage | None:
usage_chunk: Usage | dict[str, Any] | None = None
if hasattr(chunk, "usage") and chunk.usage is not None:
usage_chunk = chunk.usage
elif "usage" in chunk:
usage_chunk = chunk["usage"]
elif (isinstance(chunk, ModelResponse) or isinstance(chunk, ModelResponseStream)) and hasattr(
chunk, "_hidden_params"
):
usage_chunk = chunk._hidden_params.get("usage", None)
if isinstance(usage_chunk, dict):
return Usage(**usage_chunk)
return usage_chunk
def _calculate_usage_per_chunk(
self,
chunks: List[Union[Dict[str, Any], ModelResponse]],
@ -548,18 +568,12 @@ class ChunkProcessor:
# is last-wins, so without preserving this separately the 1h breakdown is
# lost and 1h cache writes get billed at the 5m rate.
cache_creation_token_details: Optional[CacheCreationTokenDetails] = None
cost: Optional[float] = None
for chunk in chunks:
usage_chunk: Optional[Usage] = None
if "usage" in chunk:
usage_chunk = chunk["usage"]
elif (isinstance(chunk, ModelResponse) or isinstance(chunk, ModelResponseStream)) and hasattr(
chunk, "_hidden_params"
):
usage_chunk = chunk._hidden_params.get("usage", None)
usage_chunk = self._extract_usage_chunk(chunk)
if usage_chunk is not None:
if isinstance(usage_chunk, dict):
usage_chunk = Usage(**usage_chunk)
usage_chunk_dict = self._usage_chunk_calculation_helper(usage_chunk)
if usage_chunk_dict["prompt_tokens"] is not None and usage_chunk_dict["prompt_tokens"] > 0:
prompt_tokens = usage_chunk_dict["prompt_tokens"]
@ -610,6 +624,9 @@ class ChunkProcessor:
prompt_tokens_details, cache_creation_token_details
)
if usage_chunk_dict["cost"] is not None:
cost = usage_chunk_dict["cost"]
prompt_tokens_details = self._attach_cache_creation_token_details(
prompt_tokens_details, cache_creation_token_details
)
@ -629,6 +646,7 @@ class ChunkProcessor:
web_search_requests=web_search_requests,
completion_tokens_details=completion_tokens_details,
prompt_tokens_details=prompt_tokens_details,
cost=cost,
)
@staticmethod
@ -727,6 +745,7 @@ class ChunkProcessor:
prompt_tokens_details: Optional[PromptTokensDetailsWrapper] = calculated_usage_per_chunk[
"prompt_tokens_details"
]
cost: Optional[float] = calculated_usage_per_chunk["cost"]
try:
returned_usage.prompt_tokens = prompt_tokens or token_counter(model=model, messages=messages)
@ -784,6 +803,9 @@ class ChunkProcessor:
else:
returned_usage.prompt_tokens_details.web_search_requests = web_search_requests
if cost is not None:
setattr(returned_usage, "cost", cost)
# Return a new usage object with the new values
returned_usage = Usage(**returned_usage.model_dump())

View file

@ -963,10 +963,11 @@ class CustomStreamWrapper:
if self.custom_llm_provider == "bedrock" and "trace" in model_response:
return model_response
# Default - return StopIteration
if hasattr(model_response, "usage"):
self.chunks.append(model_response)
raise StopIteration
# Don't raise StopIteration here - some providers (like OpenRouter)
# send usage/cost data in chunks after the finish_reason chunk
if hasattr(model_response, "usage") and model_response.usage is not None:
return model_response
return
# flush any remaining holding chunk
if len(self.holding_chunk) > 0:
if model_response.choices[0].delta.content is None:
@ -1486,12 +1487,16 @@ class CustomStreamWrapper:
self.tool_call = True
if hasattr(chunk, "usage") and chunk.usage is not None:
model_response.usage = chunk.usage
## RETURN ARG
return self.return_processed_chunk_logic(
result = self.return_processed_chunk_logic(
completion_obj=completion_obj,
model_response=model_response, # type: ignore
response_obj=response_obj,
)
return result
except StopIteration:
raise StopIteration
@ -1698,6 +1703,21 @@ class CustomStreamWrapper:
model_response.choices[0].finish_reason = "tool_calls"
return model_response
@staticmethod
def _propagate_usage_cost_to_hidden_params(
response: "ModelResponse",
) -> None:
"""
If the assembled response carries a provider-reported cost on
usage.cost, copy it into _hidden_params so litellm's cost
calculator uses it instead of a token-based estimate.
"""
_usage = getattr(response, "usage", None)
if _usage is not None and hasattr(_usage, "cost") and _usage.cost is not None:
if "additional_headers" not in response._hidden_params:
response._hidden_params["additional_headers"] = {}
response._hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"] = float(_usage.cost)
def __next__(self) -> "ModelResponseStream":
cache_hit = False
if self.custom_llm_provider is not None and self.custom_llm_provider == "cached_response":
@ -1753,6 +1773,10 @@ class CustomStreamWrapper:
# hasattr(response, "usage") is always True — must check
# `is not None` to avoid running this path on every chunk.
if getattr(response, "usage", None) is not None:
usage_to_preserve = response.usage
if usage_to_preserve:
response._hidden_params["usage"] = usage_to_preserve
obj_dict = response.model_dump()
if "usage" in obj_dict:
@ -1801,6 +1825,8 @@ class CustomStreamWrapper:
response = self.model_response_creator()
if complete_streaming_response is not None:
self._propagate_usage_cost_to_hidden_params(complete_streaming_response)
setattr(
response,
"usage",
@ -1986,97 +2012,7 @@ class CustomStreamWrapper:
self.chunks.append(processed_chunk)
return processed_chunk
except (StopAsyncIteration, StopIteration):
if self.sent_last_chunk is True:
# log the final chunk with accurate streaming values
try:
complete_streaming_response = litellm.stream_chunk_builder(
chunks=self.chunks,
messages=self.messages,
logging_obj=self.logging_obj,
)
except Exception as e:
# see sync __next__: a raise from stream_chunk_builder inside this
# except handler escapes __anext__ and drops the request from SpendLogs.
# Recover best-effort usage from the raw chunks so cost is still tracked
verbose_logger.warning(
"stream_chunk_builder raised at end-of-stream (%s); logging best-effort usage from chunks.",
str(e),
)
try:
complete_streaming_response = self.model_response_creator(
chunk={"usage": calculate_total_usage(chunks=self.chunks)}
)
except Exception:
complete_streaming_response = None
response = self.model_response_creator()
if complete_streaming_response is not None:
setattr(
response,
"usage",
getattr(complete_streaming_response, "usage"),
)
try:
_copy = complete_streaming_response.model_copy(deep=True)
except RuntimeError:
_copy = complete_streaming_response.model_copy()
asyncio.create_task(
self.async_cache_streaming_response(
processed_chunk=_copy,
cache_hit=cache_hit,
)
)
# Update hidden_params with final usage from
# stream_chunk_builder (see sync __next__ for full comment).
if (
self.stream_options is None
and complete_streaming_response is not None
and self._last_returned_hidden_params is not None
):
final_usage = getattr(complete_streaming_response, "usage", None)
if final_usage is not None:
self._last_returned_hidden_params["usage"] = final_usage
if self.sent_stream_usage is False and self.send_stream_usage is True:
self.sent_stream_usage = True
return response
_deferred_cb = getattr(
self.logging_obj,
"_on_deferred_stream_complete",
None,
)
if _deferred_cb is not None:
# Proxy has post-call guardrails. Store the assembled
# response so the outer streaming consumer
# (ProxyLogging.async_post_call_streaming_iterator_hook)
# can fire the deferred callback AFTER all guardrail
# end-of-stream blocks complete. Scheduling here via
# create_task would race with unified_guardrail's
# end-of-stream block for short-stream providers.
self.logging_obj._deferred_stream_complete_args = ( # type: ignore[attr-defined]
complete_streaming_response,
cache_hit,
)
else:
# prefer_async_handlers routes CustomLogger to async_success_handler
# when consumers use ``async for`` on sync-SDK streams. Legacy string
# callbacks still run via executor.submit inside dispatch_success_handlers.
asyncio.create_task(
self.logging_obj.dispatch_success_handlers(
complete_streaming_response,
cache_hit=cache_hit,
start_time=None,
end_time=None,
prefer_async_handlers=True,
)
)
raise StopAsyncIteration # Re-raise StopIteration
else:
self.sent_last_chunk = True
processed_chunk = self.finish_reason_handler()
return processed_chunk
return await self._finalize_completed_stream(cache_hit=cache_hit)
except httpx.TimeoutException as e: # if httpx read timeout error occues
traceback_exception = traceback.format_exc()
## ADD DEBUG INFORMATION - E.G. LITELLM REQUEST TIMEOUT
@ -2091,20 +2027,122 @@ class CustomStreamWrapper:
# Handle any exceptions that might occur during streaming
asyncio.create_task(self.logging_obj.async_failure_handler(e, traceback_exception))
self._handle_stream_fallback_error(e)
except (httpx.ReadError, httpx.RemoteProtocolError) as e:
if self.received_finish_reason is None:
self._log_stream_failure_and_raise(e)
return await self._finalize_completed_stream(cache_hit=cache_hit)
except Exception as e:
traceback_exception = traceback.format_exc()
if self.logging_obj is not None:
self._record_partial_usage_for_failure()
## LOGGING
threading.Thread(
target=self.logging_obj.failure_handler,
args=(e, traceback_exception),
).start() # log response
# Handle any exceptions that might occur during streaming
asyncio.create_task(
self.logging_obj.async_failure_handler(e, traceback_exception) # type: ignore
self._log_stream_failure_and_raise(e)
async def _finalize_completed_stream(self, cache_hit: bool) -> "ModelResponseStream":
if self.sent_last_chunk is True:
# log the final chunk with accurate streaming values
try:
complete_streaming_response = litellm.stream_chunk_builder(
chunks=self.chunks,
messages=self.messages,
logging_obj=self.logging_obj,
)
self._handle_stream_fallback_error(e)
except Exception as e:
# see sync __next__: a raise from stream_chunk_builder inside this
# except handler escapes __anext__ and drops the request from SpendLogs.
# Recover best-effort usage from the raw chunks so cost is still tracked
verbose_logger.warning(
"stream_chunk_builder raised at end-of-stream (%s); logging best-effort usage from chunks.",
str(e),
)
try:
complete_streaming_response = self.model_response_creator(
chunk={"usage": calculate_total_usage(chunks=self.chunks)}
)
except Exception:
complete_streaming_response = None
response = self.model_response_creator()
if complete_streaming_response is not None:
self._propagate_usage_cost_to_hidden_params(complete_streaming_response)
setattr(
response,
"usage",
getattr(complete_streaming_response, "usage"),
)
try:
_copy = complete_streaming_response.model_copy(deep=True)
except RuntimeError:
_copy = complete_streaming_response.model_copy()
asyncio.create_task(
self.async_cache_streaming_response(
processed_chunk=_copy,
cache_hit=cache_hit,
)
)
# Update hidden_params with final usage from
# stream_chunk_builder (see sync __next__ for full comment).
if (
self.stream_options is None
and complete_streaming_response is not None
and self._last_returned_hidden_params is not None
):
final_usage = getattr(complete_streaming_response, "usage", None)
if final_usage is not None:
self._last_returned_hidden_params["usage"] = final_usage
if self.sent_stream_usage is False and self.send_stream_usage is True:
self.sent_stream_usage = True
return response
_deferred_cb = getattr(
self.logging_obj,
"_on_deferred_stream_complete",
None,
)
if _deferred_cb is not None:
# Proxy has post-call guardrails. Store the assembled
# response so the outer streaming consumer
# (ProxyLogging.async_post_call_streaming_iterator_hook)
# can fire the deferred callback AFTER all guardrail
# end-of-stream blocks complete. Scheduling here via
# create_task would race with unified_guardrail's
# end-of-stream block for short-stream providers.
self.logging_obj._deferred_stream_complete_args = ( # type: ignore[attr-defined]
complete_streaming_response,
cache_hit,
)
else:
# prefer_async_handlers routes CustomLogger to async_success_handler
# when consumers use ``async for`` on sync-SDK streams. Legacy string
# callbacks still run via executor.submit inside dispatch_success_handlers.
asyncio.create_task(
self.logging_obj.dispatch_success_handlers(
complete_streaming_response,
cache_hit=cache_hit,
start_time=None,
end_time=None,
prefer_async_handlers=True,
)
)
raise StopAsyncIteration # Re-raise StopIteration
else:
self.sent_last_chunk = True
processed_chunk = self.finish_reason_handler()
return processed_chunk
def _log_stream_failure_and_raise(self, e: Exception) -> NoReturn:
traceback_exception = traceback.format_exc()
if self.logging_obj is not None:
self._record_partial_usage_for_failure()
## LOGGING
threading.Thread(
target=self.logging_obj.failure_handler,
args=(e, traceback_exception),
).start() # log response
# Handle any exceptions that might occur during streaming
asyncio.create_task(
self.logging_obj.async_failure_handler(e, traceback_exception) # type: ignore
)
self._handle_stream_fallback_error(e)
def _record_partial_usage_for_failure(self) -> None:
"""
@ -2240,12 +2278,16 @@ def calculate_total_usage(chunks: List[ModelResponse]) -> Usage:
"""Assume most recent usage chunk has total usage uptil then."""
prompt_tokens: int = 0
completion_tokens: int = 0
latest_usage_chunk = None
for chunk in chunks:
if "usage" in chunk and chunk["usage"] is not None:
if "prompt_tokens" in chunk["usage"]:
prompt_tokens = chunk["usage"].get("prompt_tokens", 0) or 0
if "completion_tokens" in chunk["usage"]:
completion_tokens = chunk["usage"].get("completion_tokens", 0) or 0
usage = chunk["usage"]
latest_usage_chunk = usage
if "prompt_tokens" in usage:
prompt_tokens = usage.get("prompt_tokens", 0) or 0
if "completion_tokens" in usage:
completion_tokens = usage.get("completion_tokens", 0) or 0
returned_usage_chunk = Usage(
prompt_tokens=prompt_tokens,
@ -2253,6 +2295,15 @@ def calculate_total_usage(chunks: List[ModelResponse]) -> Usage:
total_tokens=prompt_tokens + completion_tokens,
)
if latest_usage_chunk is not None:
latest_cost = (
latest_usage_chunk.get("cost")
if isinstance(latest_usage_chunk, dict)
else getattr(latest_usage_chunk, "cost", None)
)
if latest_cost is not None:
returned_usage_chunk.cost = latest_cost
return returned_usage_chunk

View file

@ -85,29 +85,12 @@ class AiohttpResponseStream(httpx.AsyncByteStream):
try:
async for chunk in self._aiohttp_response.content.iter_chunked(self.CHUNK_SIZE):
yield chunk
except (
aiohttp.ClientPayloadError,
aiohttp.client_exceptions.ClientPayloadError,
) as e:
# Handle incomplete transfers more gracefully
# Log the error but don't re-raise if we've already yielded some data
verbose_logger.debug(f"Transfer incomplete, but continuing: {e}")
# If the error is due to incomplete transfer encoding, we can still
# return what we've received so far, similar to how httpx handles it
return
except RuntimeError as e:
# Some providers (e.g., SSE streams) may close the connection
# causing aiohttp StreamReader to raise a generic RuntimeError
# with message "Connection closed.". Treat this as a graceful
# end-of-stream so downstream consumers don't error.
if "Connection closed" in str(e):
verbose_logger.debug("Upstream closed streaming connection; ending iterator gracefully")
return
raise
if "Connection closed" not in str(e):
raise
raise httpx.ReadError(str(e)) from e
except aiohttp.http_exceptions.TransferEncodingError as e:
# Handle transfer encoding errors gracefully
verbose_logger.debug(f"Transfer encoding error, but continuing: {e}")
return
raise httpx.ReadError(str(e)) from e
except Exception:
# For other exceptions, use the normal mapping
with map_aiohttp_exceptions():

View file

@ -79,6 +79,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
command: Optional[str] = None
args: List[str] = Field(default_factory=list)
env: Dict[str, str] = Field(default_factory=dict)
issuer: Optional[str] = None
authorization_url: Optional[str] = None
token_url: Optional[str] = None
registration_url: Optional[str] = None

View file

@ -48,6 +48,7 @@ if TYPE_CHECKING:
_AUTH_FLOW_SCOPED_FIELDS: frozenset = frozenset(
{
"issuer",
"authorization_url",
"token_url",
"registration_url",
@ -60,6 +61,13 @@ _AUTH_FLOW_SCOPED_FIELDS: frozenset = frozenset(
}
)
def _blank_to_none(value: Optional[str]) -> Optional[str]:
if not isinstance(value, str):
return None
return value.strip() or None
# Token-exchange settings with dedicated columns that also exist on
# ``MCPCredentials`` as a legacy shape (rows and REST callers that predate the
# columns). Every write lifts blob values into the columns and strips them from
@ -697,13 +705,15 @@ async def update_mcp_server(
# of being reset to a schema default (transport=sse, allow_all_keys=False...).
data_dict = _prepare_mcp_server_data(data, exclude_unset=True, fields_set=fields_set)
# Pre-fetch existing record once if we need it for auth_type or credential logic
# Pre-fetch existing record once if we need it for auth_type, url, or credential logic
existing = None
has_credentials = "credentials" in data_dict and data_dict["credentials"] is not None
# An explicit token-exchange column write (set or clear) also migrates the
# legacy blob copies below, so the existing row is needed for those updates.
explicit_te_write = bool(_TOKEN_EXCHANGE_COLUMN_FIELDS & data_dict.keys())
if data.auth_type or has_credentials or explicit_te_write:
url_provided = "url" in data_dict and data_dict["url"] is not None
issuer_provided = "issuer" in data_dict
if data.auth_type or has_credentials or explicit_te_write or url_provided or issuer_provided:
existing = await MCPServerRepository(prisma_client).table.find_unique(where={"server_id": data.server_id})
auth_type_changed = bool(
@ -711,13 +721,30 @@ async def update_mcp_server(
and existing
and _credential_auth_class(existing.auth_type) != _credential_auth_class(data.auth_type)
)
# A url change re-points the server at a potentially different upstream, so any discovered or
# trust-on-first-use OAuth endpoints/issuer belong to the old upstream and must re-discover.
url_changed = bool(url_provided and existing and existing.url != data_dict["url"])
old_issuer = _blank_to_none(getattr(existing, "issuer", None)) if existing else None
issuer_changed = bool(
issuer_provided and old_issuer is not None and _blank_to_none(data_dict.get("issuer")) != old_issuer
)
# Clear stale credentials when auth_type changes but no new credentials provided
if auth_type_changed and "credentials" not in data_dict:
data_dict["credentials"] = None
if auth_type_changed:
data_dict.update({field: None for field in _AUTH_FLOW_SCOPED_FIELDS if field not in data_dict})
if auth_type_changed or url_changed or issuer_changed:
# Clear each auth-flow-scoped field that the caller either omitted (partial update) or
# resubmitted unchanged. The edit form re-sends every field, so a stale issuer/endpoint
# belonging to the old upstream would otherwise survive a url/auth_type change and win in the
# resolution merge; only a genuinely new submitted value is kept.
data_dict.update(
{
field: None
for field in _AUTH_FLOW_SCOPED_FIELDS
if field not in data_dict or data_dict[field] == getattr(existing, field, None)
}
)
# An explicit column write that does not touch credentials must still migrate
# the row's legacy blob copies: lift values for columns the caller left
@ -1181,6 +1208,7 @@ def mcp_oauth_token_identity(server: object) -> tuple[object, ...]:
getattr(server, "spec_path", None),
getattr(server, "auth_type", None),
getattr(server, "oauth2_flow", None),
getattr(server, "issuer", None),
getattr(server, "authorization_url", None),
getattr(server, "token_url", None),
getattr(server, "registration_url", None),

View file

@ -201,6 +201,38 @@ def _blank_to_none(value: str | None) -> str | None:
return value.strip() or None
def _uses_issuer_anchor(manual_issuer: str | None, is_discovery_auth_type: bool) -> bool:
"""Whether the endpoints are authoritatively anchored to an admin-pinned issuer (RFC 8414 §3.3).
This is the trust/provenance property, distinct from whether the ``issuer`` field is merely
populated: a trust-on-first-use discovered issuer sets ``issuer`` for token identity but is NOT
anchored, so its endpoints stay resource-rooted. Anchoring holds only when the issuer was pinned
(present on the row/config) on a discovery auth type. Every consumer of "is this anchored" reads
this one definition, so the answer cannot diverge across build paths.
"""
return _blank_to_none(manual_issuer) is not None and is_discovery_auth_type
def _endpoints_yield_to_issuer(
issuer: str | None,
is_discovery_auth_type: bool,
authorization_url: str | None,
token_url: str | None,
registration_url: str | None,
) -> tuple[str | None, str | None, str | None]:
"""The single rule that makes an admin-configured ``issuer`` the sole authoritative endpoint
source (RFC 8414 §3.3): when it is set for a discovery auth type, the stored/manual
``authorization_url``/``token_url``/``registration_url`` do not apply. They neither anchor nor
short-circuit discovery, never override the issuer document in the merge, and never substitute for
it when the issuer fetch fails (fail-closed). Returns the endpoint values that remain in force,
i.e. all ``None`` when issuer-anchored, else the inputs unchanged. Called at every resolution site
so the invariant holds in one place instead of being re-derived per merge.
"""
if issuer is not None and is_discovery_auth_type:
return None, None, None
return authorization_url, token_url, registration_url
def _normalized_authorize_endpoint(url: str) -> str:
"""Compare authorize endpoints on scheme, host, and path only. The default port is elided and
the host is lowercased so ``https://IDP.example.com:443/authorize/`` and
@ -217,6 +249,17 @@ def _normalized_authorize_endpoint(url: str) -> str:
return f"{scheme}://{authority}{parsed.path.rstrip('/')}"
def _issuer_matches(claimed_issuer: object, configured_issuer: str) -> bool:
"""RFC 8414 §3.3 issuer equality between the metadata document's self-attested ``issuer`` and the
admin-configured issuer, tolerant only of URL-insignificant differences (scheme/host case, the
default port, a trailing slash). A non-string or empty claimed issuer never matches, so a
document that omits ``issuer`` fails closed under issuer-anchored discovery.
"""
if not isinstance(claimed_issuer, str) or not claimed_issuer:
return False
return _normalized_authorize_endpoint(claimed_issuer) == _normalized_authorize_endpoint(configured_issuer)
def _endpoints_corroborate_authorization_url(
source_authorization_url: str | None,
trusted_authorization_url: str | None,
@ -260,11 +303,27 @@ def _carry_forward_resolved_oauth_endpoints(new_server: MCPServer, previous_serv
incoming build has no pinned authorize endpoint (``None`` -> we adopt the previous one too, a
consistent group) or pins the same one. An admin re-pointing ``authorization_url`` to a different
server must not keep serving the old server's token endpoint or granted scopes.
When the server is issuer-anchored (``issuer_is_anchored`` -- a pinned issuer on a discovery auth
type), the endpoints come solely from the §3.3-validated issuer document, so carry-forward is
skipped entirely for its endpoints: a failed issuer fetch leaves them ``None`` and must stay
``None`` (fail-closed), never resurrected from the previous registry entry. A merely discovered
(trust-on-first-use) issuer is NOT anchored -- ``issuer`` is set for token identity but the
endpoints are resource-rooted, so they still carry forward as last-known-good, gated by the
corroboration check below like any other resource-rooted server. Scopes stay resource-driven and
can carry either way.
"""
if previous_server is None:
return
if previous_server.url != new_server.url or previous_server.auth_type != new_server.auth_type:
return
if new_server.issuer_is_anchored:
# Endpoints come solely from the §3.3-validated issuer document; a failed fetch stays
# fail-closed and must not be resurrected from the previous entry. Only the resource-driven
# scopes carry as last-known-good.
if not new_server.scopes and previous_server.scopes:
new_server.scopes = previous_server.scopes
return
may_carry = _endpoints_corroborate_authorization_url(
previous_server.authorization_url, new_server.authorization_url
)
@ -1137,34 +1196,48 @@ class MCPServerManager:
)
auth_type = server_config.get("auth_type", None)
manual_issuer = _blank_to_none(server_config.get("issuer"))
manual_authorization_url = _blank_to_none(server_config.get("authorization_url"))
manual_token_url = _blank_to_none(server_config.get("token_url"))
manual_registration_url = _blank_to_none(server_config.get("registration_url"))
if server_url and (
auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
is_discovery_auth_type = auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
use_issuer_anchor = _uses_issuer_anchor(manual_issuer, is_discovery_auth_type)
manual_authorization_url, manual_token_url, manual_registration_url = _endpoints_yield_to_issuer(
manual_issuer,
is_discovery_auth_type,
manual_authorization_url,
manual_token_url,
manual_registration_url,
)
should_discover = bool(server_url) and (
is_discovery_auth_type
or self._obo_needs_endpoint_discovery(
auth_type,
server_config.get("token_exchange_endpoint"),
manual_token_url,
)
):
)
if not should_discover:
mcp_oauth_metadata = None
elif manual_issuer is not None and is_discovery_auth_type:
mcp_oauth_metadata = await self._fetch_issuer_anchored_oauth_metadata(manual_issuer, server_url)
else:
mcp_oauth_metadata = await self._descovery_metadata(
server_url=server_url,
allow_origin_fallback=auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES,
allow_origin_fallback=is_discovery_auth_type,
)
else:
mcp_oauth_metadata = None
gated_oauth_metadata = (
_restrict_discovery_to_corroborated_authorization_server(
if use_issuer_anchor:
gated_oauth_metadata = mcp_oauth_metadata
elif is_discovery_auth_type:
gated_oauth_metadata = _restrict_discovery_to_corroborated_authorization_server(
mcp_oauth_metadata,
manual_authorization_url,
server_name or server_id,
bool(server_config.get("dcr_bridge")),
)
if auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
else mcp_oauth_metadata
)
else:
gated_oauth_metadata = mcp_oauth_metadata
# Filter blank scopes (e.g. YAML ``scopes: [""]``) the same way the DB-build path does, so
# an all-blank list normalizes to None rather than a ``("",)`` tuple that skips the
@ -1179,6 +1252,12 @@ class MCPServerManager:
resolved_registration_url = manual_registration_url or (
gated_oauth_metadata.registration_url if gated_oauth_metadata else None
)
discovered_issuer = (
gated_oauth_metadata.discovered_issuer
if gated_oauth_metadata and not gated_oauth_metadata.from_origin_fallback
else None
)
effective_issuer = manual_issuer or discovered_issuer
config_oauth2_flow = server_config.get("oauth2_flow", None)
if auth_type == MCPAuth.oauth2 and config_oauth2_flow not in (
@ -1227,6 +1306,8 @@ class MCPServerManager:
client_secret=server_config.get("client_secret", None),
oauth2_flow=self._explicit_oauth2_flow(config_oauth2_flow),
scopes=resolved_scopes,
issuer=effective_issuer,
issuer_is_anchored=use_issuer_anchor,
authorization_url=resolved_authorization_url,
token_url=resolved_token_url,
registration_url=resolved_registration_url,
@ -1487,6 +1568,52 @@ class MCPServerManager:
decrypt_global_env_var_values(env_vars_list)
return env_vars_list
async def _resolve_table_oauth_metadata(
self,
*,
mcp_server: LiteLLM_MCPServerTable,
auth_type: MCPAuthType,
server_url: Optional[str],
manual_issuer: Optional[str],
manual_authorization_url: Optional[str],
manual_token_url: Optional[str],
is_discovery_auth_type: bool,
use_issuer_anchor: bool,
scopes: Optional[list[str]],
token_exchange_endpoint: Optional[str],
) -> Optional[MCPOAuthMetadata]:
has_all_upstream_oauth_fields = bool(manual_authorization_url and manual_token_url and scopes)
needs_discovery = bool(server_url) and (
(is_discovery_auth_type and not has_all_upstream_oauth_fields)
or self._obo_needs_endpoint_discovery(auth_type, token_exchange_endpoint, manual_token_url)
)
if not needs_discovery:
mcp_oauth_metadata: Optional[MCPOAuthMetadata] = None
elif use_issuer_anchor and manual_issuer is not None:
mcp_oauth_metadata = await self._fetch_issuer_anchored_oauth_metadata(manual_issuer, server_url)
else:
mcp_oauth_metadata = await self._descovery_metadata(
server_url=server_url, # type: ignore[arg-type]
allow_origin_fallback=is_discovery_auth_type,
)
if needs_discovery and not use_issuer_anchor and mcp_oauth_metadata is None:
verbose_logger.warning(
"MCP OAuth discovery yielded no metadata for server %s (%s); "
"OAuth endpoints/scopes stay unresolved until a rebuild succeeds",
mcp_server.server_id,
server_url,
)
if use_issuer_anchor:
return mcp_oauth_metadata
if is_discovery_auth_type:
return _restrict_discovery_to_corroborated_authorization_server(
mcp_oauth_metadata,
manual_authorization_url,
mcp_server.server_id,
bool(getattr(mcp_server, "dcr_bridge", None)),
)
return mcp_oauth_metadata
async def build_mcp_server_from_table(
self,
mcp_server: LiteLLM_MCPServerTable,
@ -1570,46 +1697,38 @@ class MCPServerManager:
auth_type = cast(MCPAuthType, mcp_server.auth_type)
server_url = mcp_server.url
manual_issuer = _blank_to_none(mcp_server.issuer)
manual_authorization_url = _blank_to_none(mcp_server.authorization_url)
manual_token_url = _blank_to_none(mcp_server.token_url)
manual_registration_url = _blank_to_none(mcp_server.registration_url)
has_all_upstream_oauth_fields = bool(manual_authorization_url and manual_token_url and scopes)
needs_discovery = bool(server_url) and (
(auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and not has_all_upstream_oauth_fields)
or self._obo_needs_endpoint_discovery(
auth_type,
mcp_server.token_exchange_endpoint
or (credentials_dict.get("token_exchange_endpoint") if credentials_dict else None),
manual_token_url,
)
is_discovery_auth_type = auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
use_issuer_anchor = _uses_issuer_anchor(manual_issuer, is_discovery_auth_type)
manual_authorization_url, manual_token_url, manual_registration_url = _endpoints_yield_to_issuer(
manual_issuer, is_discovery_auth_type, manual_authorization_url, manual_token_url, manual_registration_url
)
mcp_oauth_metadata = (
await self._descovery_metadata(
server_url=server_url, # type: ignore[arg-type]
allow_origin_fallback=auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES,
)
if needs_discovery
else None
token_exchange_endpoint = mcp_server.token_exchange_endpoint or (
credentials_dict.get("token_exchange_endpoint") if credentials_dict else None
)
if needs_discovery and mcp_oauth_metadata is None:
verbose_logger.warning(
"MCP OAuth discovery yielded no metadata for server %s (%s); "
"OAuth endpoints/scopes stay unresolved until a rebuild succeeds",
mcp_server.server_id,
server_url,
)
gated_oauth_metadata = (
_restrict_discovery_to_corroborated_authorization_server(
mcp_oauth_metadata,
manual_authorization_url,
mcp_server.server_id,
bool(getattr(mcp_server, "dcr_bridge", None)),
)
if auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
else mcp_oauth_metadata
gated_oauth_metadata = await self._resolve_table_oauth_metadata(
mcp_server=mcp_server,
auth_type=auth_type,
server_url=server_url,
manual_issuer=manual_issuer,
manual_authorization_url=manual_authorization_url,
manual_token_url=manual_token_url,
is_discovery_auth_type=is_discovery_auth_type,
use_issuer_anchor=use_issuer_anchor,
scopes=scopes,
token_exchange_endpoint=token_exchange_endpoint,
)
resolved_scopes = scopes or (gated_oauth_metadata.scopes if gated_oauth_metadata else None)
discovered_issuer = (
gated_oauth_metadata.discovered_issuer
if gated_oauth_metadata and not gated_oauth_metadata.from_origin_fallback
else None
)
effective_issuer = manual_issuer or discovered_issuer
new_server = MCPServer(
server_id=mcp_server.server_id,
@ -1629,6 +1748,8 @@ class MCPServerManager:
client_secret=client_secret_value or getattr(mcp_server, "client_secret", None),
oauth2_flow=self._explicit_oauth2_flow(getattr(mcp_server, "oauth2_flow", None)),
scopes=resolved_scopes,
issuer=effective_issuer,
issuer_is_anchored=use_issuer_anchor,
authorization_url=manual_authorization_url or getattr(gated_oauth_metadata, "authorization_url", None),
token_url=manual_token_url or getattr(gated_oauth_metadata, "token_url", None),
registration_url=manual_registration_url or getattr(gated_oauth_metadata, "registration_url", None),
@ -1688,10 +1809,12 @@ class MCPServerManager:
await self._persist_discovered_oauth_endpoints(
server_id=mcp_server.server_id,
auth_type=auth_type,
existing_issuer=manual_issuer,
existing_authorization_url=manual_authorization_url,
existing_token_url=manual_token_url,
existing_scopes=scopes,
metadata=gated_oauth_metadata,
is_issuer_anchored=use_issuer_anchor,
)
return new_server
@ -1735,10 +1858,12 @@ class MCPServerManager:
*,
server_id: str,
auth_type: MCPAuthType | None,
existing_issuer: str | None,
existing_authorization_url: str | None,
existing_token_url: str | None,
existing_scopes: list[str] | None,
metadata: MCPOAuthMetadata | None,
is_issuer_anchored: bool = False,
) -> None:
"""Write freshly discovered OAuth endpoints back onto the DB row.
@ -1752,19 +1877,37 @@ class MCPServerManager:
because ``_dcr_bridge_relays_client_registration`` keys off that column. Best-effort: a
failed write re-discovers on the next build. Scopes go through ``update_mcp_server`` so
they merge into the credentials blob without touching the stored client credentials.
For an issuer-anchored server (``is_issuer_anchored``) the endpoints are re-derived from the
§3.3-validated issuer document on every build, so they are NOT persisted into the endpoint
columns: persisting them would make the next build see populated endpoints and treat them as
authoritative stored values, defeating the "endpoints come solely from the issuer" invariant.
Only the resource-driven scopes are persisted for such servers.
"""
if auth_type not in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES:
return
if metadata is None or metadata.from_origin_fallback:
return
issuer_update = (
{"issuer": metadata.discovered_issuer} if metadata.discovered_issuer and not existing_issuer else {}
)
authorization_url_update = (
{"authorization_url": metadata.authorization_url}
if metadata.authorization_url and not existing_authorization_url
if metadata.authorization_url and not existing_authorization_url and not is_issuer_anchored
else {}
)
token_url_update = (
{"token_url": metadata.token_url}
if metadata.token_url and not existing_token_url and not is_issuer_anchored
else {}
)
token_url_update = {"token_url": metadata.token_url} if metadata.token_url and not existing_token_url else {}
scopes_update = {"credentials": {"scopes": metadata.scopes}} if metadata.scopes and not existing_scopes else {}
updates: dict[str, object] = {**authorization_url_update, **token_url_update, **scopes_update}
updates: dict[str, object] = {
**issuer_update,
**authorization_url_update,
**token_url_update,
**scopes_update,
}
if not updates:
return
from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 # db.py imports this module at load
@ -3337,8 +3480,41 @@ class MCPServerManager:
return metadata
return None
async def _fetch_issuer_anchored_oauth_metadata(
self, issuer: str, server_url: Optional[str]
) -> Optional[MCPOAuthMetadata]:
"""RFC 8414 issuer-anchored discovery for the OAuth endpoints, with resource-driven scopes.
Fetch authorization-server metadata from the admin-configured issuer's own origin and adopt
its ``token_endpoint``/``registration_endpoint`` only when the document self-attests that same
issuer (RFC 8414 §3.3). Because the trust anchor is the pinned issuer rather than anything the
MCP resource advertises, the endpoints are authoritative for that issuer and cannot be
substituted by a compromised resource. Fails closed (returns None) on a §3.3 mismatch or a
fetch failure. The issuer is passed as its own ``server_url`` so the endpoint fetch is treated
as same-authority and is not subject to the resource-scoped SSRF shortcut.
Scopes are NOT taken from the issuer document. Per the MCP authorization spec Scope Selection
Strategy and RFC 9728, the scopes a client requests are resource-driven (the WWW-Authenticate
challenge or the protected-resource ``scopes_supported``), so the resource's advertised scopes
are fetched separately and used; the resource can influence only the requested scope, which
the authorization server and user consent bound (RFC 6749 §3.3), never the token endpoint.
"""
metadata = await self._fetch_single_authorization_server_metadata(issuer, issuer, require_issuer=issuer)
if metadata is None:
verbose_logger.warning(
"MCP OAuth issuer-anchored discovery for issuer %s yielded no metadata whose issuer "
"matched (RFC 8414 §3.3); OAuth endpoints stay unresolved until a rebuild succeeds",
issuer,
)
return None
resource_metadata = (
await self._descovery_metadata(server_url, allow_origin_fallback=False) if server_url else None
)
resource_scopes = resource_metadata.scopes if resource_metadata else None
return metadata.model_copy(update={"scopes": resource_scopes})
async def _fetch_single_authorization_server_metadata(
self, issuer_url: str, server_url: str
self, issuer_url: str, server_url: str, require_issuer: Optional[str] = None
) -> Optional[MCPOAuthMetadata]:
try:
parsed = urlparse(issuer_url)
@ -3382,20 +3558,33 @@ class MCPServerManager:
)
continue
scopes = self._extract_scopes(data.get("scopes_supported"))
claimed_issuer = data.get("issuer")
verbose_logger.debug(
"Authorization server metadata from %s: issuer=%s grant_types_supported=%s "
"token_endpoint_auth_methods_supported=%s",
url,
data.get("issuer"),
claimed_issuer,
data.get("grant_types_supported"),
data.get("token_endpoint_auth_methods_supported"),
)
if require_issuer is not None and not _issuer_matches(claimed_issuer, require_issuer):
verbose_logger.warning(
"MCP OAuth issuer-anchored discovery: metadata at %s self-attests issuer %r, which "
"does not match the configured issuer %r (RFC 8414 §3.3); rejecting so a compromised "
"resource cannot substitute an attacker authorization server",
url,
claimed_issuer,
require_issuer,
)
continue
scopes = self._extract_scopes(data.get("scopes_supported"))
metadata = MCPOAuthMetadata(
scopes=scopes,
authorization_url=data.get("authorization_endpoint"),
token_url=data.get("token_endpoint"),
registration_url=data.get("registration_endpoint"),
discovered_issuer=claimed_issuer if isinstance(claimed_issuer, str) and claimed_issuer else None,
)
if any(
@ -5116,6 +5305,7 @@ class MCPServerManager:
command=getattr(server, "command", None),
args=getattr(server, "args", None) or [],
env=getattr(server, "env", None) or {},
issuer=server.issuer,
authorization_url=server.authorization_url,
token_url=server.token_url,
registration_url=server.registration_url,
@ -5225,6 +5415,7 @@ class MCPServerManager:
command=getattr(server, "command", None),
args=getattr(server, "args", None) or [],
env=getattr(server, "env", None) or {},
issuer=server.issuer,
authorization_url=server.authorization_url,
token_url=server.token_url,
registration_url=server.registration_url,

View file

@ -1138,6 +1138,7 @@ if MCP_AVAILABLE:
static_headers=request.static_headers,
client_id=client_id,
client_secret=client_secret,
issuer=request.issuer,
token_url=request.token_url,
scopes=scopes,
authorization_url=request.authorization_url,

View file

@ -4,6 +4,7 @@ Semantic MCP Tool Filtering using semantic-router
Filters MCP tools semantically for /chat/completions and /responses endpoints.
"""
import asyncio
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from litellm._logging import verbose_logger
@ -76,6 +77,7 @@ class SemanticMCPToolFilter:
self.tool_router: Optional["SemanticRouter"] = None
self.context_window_error: Optional[str] = None
self._tool_map: Dict[str, Any] = {} # MCPTool objects or OpenAI function dicts
self._index_sync_lock = asyncio.Lock()
async def build_router_from_mcp_registry(self) -> None:
"""Build semantic router from all MCP tools in the registry (no auth checks)."""
@ -182,6 +184,81 @@ class SemanticMCPToolFilter:
return
raise
def _has_tools_missing_from_index(self, tools: list[Any]) -> bool:
"""Allocation-free check for any named tool not yet in the semantic index."""
return any(name and name not in self._tool_map for name in (self._extract_tool_info(t)[0] for t in tools))
def _tools_missing_from_index(self, tools: list[Any]) -> dict[str, Any]:
"""Map name -> tool for every named tool not yet in the semantic index."""
return {
name: tool
for name, tool in ((self._extract_tool_info(t)[0], t) for t in tools)
if name and name not in self._tool_map
}
async def _ensure_tools_indexed(self, available_tools: list[Any]) -> None:
"""
Index request-time tools the startup build never saw.
The startup index lists every registered MCP server WITHOUT per-user
credentials, so servers requiring per-user auth (interactive OAuth
tokens, user-scoped env vars) contribute zero routes. Tools reaching
the filter came through an authenticated expansion; without indexing
them here they can never be selected, so requests either bypass
filtering entirely (N->N) or lose every tool to unrelated matches.
Runs async-only (no synchronous embedding on the request path) and
never writes shared error state: an embedding failure here raises and
is scoped to the requesting call, so one request's oversized tool
description cannot poison the filter for other users on the worker.
"""
from semantic_router.routers import SemanticRouter
from semantic_router.routers.base import Route
from litellm.router_strategy.auto_router.litellm_encoder import (
LiteLLMRouterEncoder,
)
if not self._has_tools_missing_from_index(available_tools):
return
async with self._index_sync_lock:
missing = self._tools_missing_from_index(available_tools)
if not missing:
return
descriptions = {name: self._extract_tool_info(tool)[1] for name, tool in missing.items()}
routes = [
Route(
name=name,
description=description,
utterances=[description],
score_threshold=self.similarity_threshold,
)
for name, description in descriptions.items()
]
if self.tool_router is None:
router = SemanticRouter(
routes=[],
encoder=LiteLLMRouterEncoder(
litellm_router_instance=self.router_instance,
model_name=self.embedding_model,
score_threshold=self.similarity_threshold,
),
auto_sync="local",
top_k=self.top_k,
)
await router.aadd(routes)
self.tool_router = router
else:
await self.tool_router.aadd(routes)
self._tool_map.update(missing)
verbose_logger.info(
f"Semantic tool filter indexed {len(routes)} request-time tools missing from the startup index"
)
async def filter_tools(
self,
query: str,
@ -216,22 +293,34 @@ class SemanticMCPToolFilter:
if not query or not query.strip():
return available_tools
# Router should be built on startup - if not, something went wrong
if self.tool_router is None:
verbose_logger.warning("Router not initialized - was build_router_from_mcp_registry() called on startup?")
return available_tools
# Run semantic filtering
try:
await self._ensure_tools_indexed(available_tools)
if self.tool_router is None:
verbose_logger.warning("Semantic router could not be built from the request's tools")
return available_tools
available_names = [name for name in (self._extract_tool_info(t)[0] for t in available_tools) if name]
if not available_names:
return available_tools
limit = top_k or self.top_k
matches = self.tool_router(text=query, limit=limit)
if self.tool_router.top_k < limit:
self.tool_router.top_k = limit
matches = self.tool_router(text=query, limit=limit, route_filter=available_names)
matched_tool_names = self._extract_tool_names_from_matches(matches)
if not matched_tool_names:
return available_tools
return self._get_tools_by_names(matched_tool_names, available_tools)
filtered_tools = self._get_tools_by_names(matched_tool_names, available_tools)
if not filtered_tools:
return available_tools
return filtered_tools
except SemanticToolFilterContextWindowError:
raise
except Exception as e:
if _is_context_window_error(e):
verbose_logger.error(
@ -240,7 +329,7 @@ class SemanticMCPToolFilter:
)
raise SemanticToolFilterContextWindowError(
embedding_model=self.embedding_model,
stage="the user query",
stage="the user query or the MCP tool descriptions being indexed",
original_error=str(e),
) from e
verbose_logger.error(f"Semantic tool filter failed: {e}", exc_info=True)

View file

@ -1263,6 +1263,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
command: Optional[str] = None
args: List[str] = Field(default_factory=list)
env: Dict[str, str] = Field(default_factory=dict)
issuer: Optional[str] = None
authorization_url: Optional[str] = None
token_url: Optional[str] = None
registration_url: Optional[str] = None
@ -1368,6 +1369,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
command: Optional[str] = None
args: List[str] = Field(default_factory=list)
env: Dict[str, str] = Field(default_factory=dict)
issuer: Optional[str] = None
authorization_url: Optional[str] = None
token_url: Optional[str] = None
registration_url: Optional[str] = None

View file

@ -361,7 +361,11 @@ def _global_proxy_budget_check(global_proxy_spend: Optional[float], skip_budget_
and route != "/models"
):
if math.isfinite(litellm.max_budget) and global_proxy_spend > litellm.max_budget:
raise litellm.BudgetExceededError(current_cost=global_proxy_spend, max_budget=litellm.max_budget)
raise litellm.BudgetExceededError(
current_cost=global_proxy_spend,
max_budget=litellm.max_budget,
entity_type=Litellm_EntityType.PROXY.value,
)
_GUARDRAIL_MODIFICATION_KEYS: tuple = (
@ -648,6 +652,8 @@ async def common_checks(
current_cost=user_spend,
max_budget=user_budget,
message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}",
entity_type=Litellm_EntityType.USER.value,
entity_id=user_object.user_id,
)
# Each scope reads a distinct counter key with no cross-scope ordering
@ -1093,6 +1099,8 @@ async def _check_end_user_budget(
current_cost=end_user_spend,
max_budget=end_user_budget,
message=f"ExceededBudget: End User={end_user_obj.user_id} over budget. Spend={end_user_spend}, Budget={end_user_budget}",
entity_type=Litellm_EntityType.END_USER.value,
entity_id=end_user_obj.user_id,
)
@ -3552,6 +3560,8 @@ async def _virtual_key_max_budget_check(
current_cost=spend,
max_budget=valid_token.max_budget,
message=f"Budget has been exceeded! Key={key_descriptor} Current cost: {spend}, Max budget: {valid_token.max_budget}",
entity_type=Litellm_EntityType.KEY.value,
entity_id=valid_token.token,
)
@ -3593,6 +3603,8 @@ async def _virtual_key_multi_budget_check(
f"ExceededBudget: Key over {w['budget_duration']} budget. "
f"Spend=${window_spend:.4f}, Limit=${w['max_budget']:.2f}"
),
entity_type=Litellm_EntityType.KEY.value,
entity_id=valid_token.token,
)
@ -3824,6 +3836,8 @@ async def _check_team_member_budget(
current_cost=team_member_spend,
max_budget=team_member_budget,
message=f"Budget has been exceeded! User={valid_token.user_id} in Team={team_object.team_id} Current cost: {team_member_spend}, Max budget: {team_member_budget}",
entity_type=Litellm_EntityType.TEAM_MEMBER.value,
entity_id=f"{valid_token.user_id}:{team_object.team_id}",
)
@ -3923,6 +3937,8 @@ async def _team_max_budget_check(
current_cost=spend,
max_budget=team_object.max_budget,
message=f"Budget has been exceeded! Team={team_object.team_id} Current cost: {spend}, Max budget: {team_object.max_budget}",
entity_type=Litellm_EntityType.TEAM.value,
entity_id=team_object.team_id,
)
@ -3960,6 +3976,8 @@ async def _team_multi_budget_check(
f"ExceededBudget: Team={team_object.team_id} over {w['budget_duration']} budget. "
f"Spend=${window_spend:.4f}, Limit=${w['max_budget']:.2f}"
),
entity_type=Litellm_EntityType.TEAM.value,
entity_id=team_object.team_id,
)
@ -4081,6 +4099,8 @@ async def _project_max_budget_check(
current_cost=project_object.spend,
max_budget=max_budget,
message=f"Budget has been exceeded! Project={project_object.project_id} Current cost: {project_object.spend}, Max budget: {max_budget}",
entity_type=Litellm_EntityType.PROJECT.value,
entity_id=project_object.project_id,
)
@ -4269,6 +4289,8 @@ async def _organization_max_budget_check(
current_cost=org_spend,
max_budget=org_max_budget,
message=f"Budget has been exceeded! Organization={org_id} Current cost: {org_spend}, Max budget: {org_max_budget}",
entity_type=Litellm_EntityType.ORGANIZATION.value,
entity_id=org_id,
)
@ -4326,6 +4348,8 @@ async def _tag_max_budget_check(
current_cost=tag_spend,
max_budget=tag_object.litellm_budget_table.max_budget,
message=f"Budget has been exceeded! Tag={tag_name} Current cost: {tag_spend}, Max budget: {tag_object.litellm_budget_table.max_budget}",
entity_type=Litellm_EntityType.TAG.value,
entity_id=tag_name,
)

View file

@ -1797,6 +1797,8 @@ async def _user_api_key_auth_builder(
raise litellm.BudgetExceededError(
current_cost=team_member_spend,
max_budget=team_member_budget,
entity_type=Litellm_EntityType.TEAM_MEMBER.value,
entity_id=f"{valid_token.user_id}:{valid_token.team_id}",
)
# Check 3. If token is expired

View file

@ -72,7 +72,7 @@ def display_teams_table(teams: List[Dict[str, Any]]) -> None:
console = Console()
if not teams:
console.print("No teams found for your user.")
console.print("No teams found for your user.")
return
table = Table(title="Available Teams")
@ -162,7 +162,7 @@ def display_interactive_team_selection(teams: List[Dict[str, Any]], selected_ind
# Clear the screen using Rich's method
console.clear()
console.print("🎯 Select a Team (Use ↑↓ arrows, Enter to select, 'q' to skip):\n")
console.print("Select a Team (Use up/down arrows, Enter to select, 'q' to skip):\n")
for i, team in enumerate(teams):
team_alias = team.get("team_alias") or "N/A"
@ -184,7 +184,7 @@ def display_interactive_team_selection(teams: List[Dict[str, Any]], selected_ind
# Highlight the selected item
if i == selected_index:
console.print(f" [bold cyan]{team_alias}[/bold cyan] ({team_id})")
console.print(f"> [bold cyan]{team_alias}[/bold cyan] ({team_id})")
console.print(f" Models: [yellow]{models_str}[/yellow]")
console.print(f" Budget: [blue]{budget_str}[/blue]\n")
else:
@ -220,15 +220,13 @@ def prompt_team_selection(teams: List[Dict[str, Any]]) -> Optional[Dict[str, Any
# Clear screen and show selection
console = Console()
console.clear()
click.echo(
f"✅ Selected team: {selected_team.get('team_alias', 'N/A')} ({selected_team.get('team_id')})"
)
click.echo(f"Selected team: {selected_team.get('team_alias', 'N/A')} ({selected_team.get('team_id')})")
return selected_team
elif key == "quit" or key == "escape":
# Clear screen
console = Console()
console.clear()
click.echo(" Team selection skipped.")
click.echo("Team selection skipped.")
return None
elif key is None:
# If we can't get key input, fall back to simple selection
@ -237,7 +235,7 @@ def prompt_team_selection(teams: List[Dict[str, Any]]) -> Optional[Dict[str, Any
except KeyboardInterrupt:
console = Console()
console.clear()
click.echo("\nTeam selection cancelled.")
click.echo("\nTeam selection cancelled.")
return None
except Exception:
# If interactive mode fails, fall back to simple selection
@ -265,15 +263,15 @@ def prompt_team_selection_fallback(
if 0 <= index < len(teams):
selected_team = teams[index]
click.echo(
f"\nSelected team: {selected_team.get('team_alias', 'N/A')} ({selected_team.get('team_id')})"
f"\nSelected team: {selected_team.get('team_alias', 'N/A')} ({selected_team.get('team_id')})"
)
return selected_team
else:
click.echo(f"Invalid selection. Please enter a number between 1 and {len(teams)}")
click.echo(f"Invalid selection. Please enter a number between 1 and {len(teams)}")
except ValueError:
click.echo("Invalid input. Please enter a number or 'skip'")
click.echo("Invalid input. Please enter a number or 'skip'")
except KeyboardInterrupt:
click.echo("\nTeam selection cancelled.")
click.echo("\nTeam selection cancelled.")
return None
@ -437,7 +435,7 @@ def _poll_for_authentication(base_url: str, key_id: str, poll_secret: str) -> Op
user_id = data.get("user_id")
normalized_teams: List[Dict[str, Any]] = _normalize_teams(teams, team_details)
if not normalized_teams:
click.echo("⚠️ No teams available for selection.")
click.echo("Warning: No teams available for selection.")
return None
# User has multiple teams - let them select
@ -457,7 +455,7 @@ def _poll_for_authentication(base_url: str, key_id: str, poll_secret: str) -> Op
"team_id": None, # Set by server in JWT
}
click.echo("Team selection cancelled or JWT generation failed.")
click.echo("Team selection cancelled or JWT generation failed.")
return None
# JWT is ready (single team or team already selected)
@ -468,7 +466,7 @@ def _poll_for_authentication(base_url: str, key_id: str, poll_secret: str) -> Op
# Show which team was assigned
if team_id and len(teams) == 1:
click.echo(f"\nAutomatically assigned to team: {team_id}")
click.echo(f"\nAutomatically assigned to team: {team_id}")
if api_key:
return {
@ -494,19 +492,19 @@ def _handle_team_selection_during_polling(
The JWT token with the selected team, or None if selection was skipped
"""
if not teams:
click.echo(" No teams found. You can create or join teams using the web interface.")
click.echo("No teams found. You can create or join teams using the web interface.")
return None
click.echo("\n" + "=" * 60)
click.echo("📋 Select a team for your CLI session...")
click.echo("Select a team for your CLI session...")
team_id = _render_and_prompt_for_team_selection(teams)
if not team_id:
click.echo(" No team selected.")
click.echo("No team selected.")
return None
click.echo(f"\n🔄 Generating JWT for team: {team_id}")
click.echo(f"\nGenerating JWT for team: {team_id}")
poll_url = f"{base_url}/sso/cli/poll/{key_id}?team_id={team_id}"
data = _poll_for_ready_data(
@ -520,7 +518,7 @@ def _handle_team_selection_during_polling(
return None
jwt_token = data.get("key")
if jwt_token:
click.echo(f"Successfully generated JWT for team: {team_id}")
click.echo(f"Successfully generated JWT for team: {team_id}")
return jwt_token
return None
@ -568,14 +566,14 @@ def _render_and_prompt_for_team_selection(teams: List[Dict[str, Any]]) -> Option
selected_team = teams[index]
team_id = str(selected_team.get("team_id"))
team_alias = selected_team.get("team_alias") or team_id
click.echo(f"\nSelected team: {team_alias} ({team_id})")
click.echo(f"\nSelected team: {team_alias} ({team_id})")
return team_id
click.echo(f"Invalid selection. Please enter a number between 1 and {len(teams)}")
click.echo(f"Invalid selection. Please enter a number between 1 and {len(teams)}")
except ValueError:
click.echo("Invalid input. Please enter a number or 'skip'")
click.echo("Invalid input. Please enter a number or 'skip'")
except KeyboardInterrupt:
click.echo("\nTeam selection cancelled.")
click.echo("\nTeam selection cancelled.")
return None
@ -628,7 +626,7 @@ def login(ctx: click.Context):
}
)
click.echo("\nLogin successful!")
click.echo("\nLogin successful!")
click.echo(f"JWT Token: {api_key[:20]}...")
click.echo("You can now use the CLI without specifying --api-key")
@ -637,7 +635,7 @@ def login(ctx: click.Context):
show_commands()
return
else:
click.echo("Authentication timed out. Please try again.")
click.echo("Authentication timed out. Please try again.")
click.echo(
"The proxy never reported the browser sign-in as finished. If you did complete it, "
"check the proxy logs for /sso/callback errors and confirm SSO is configured on the proxy."
@ -645,10 +643,10 @@ def login(ctx: click.Context):
return
except KeyboardInterrupt:
click.echo("\nAuthentication cancelled by user.")
click.echo("\nAuthentication cancelled by user.")
return
except Exception as e:
click.echo(f"Authentication failed: {e}")
click.echo(f"Authentication failed: {e}")
return
@ -656,7 +654,7 @@ def login(ctx: click.Context):
def logout():
"""Logout and clear stored authentication"""
clear_token()
click.echo("Logged out successfully. Authentication token cleared.")
click.echo("Logged out successfully. Authentication token cleared.")
@click.command(name="print-token")
@ -703,10 +701,10 @@ def whoami():
token_data = load_token()
if not token_data:
click.echo("Not authenticated. Run 'lite login' to authenticate.")
click.echo("Not authenticated. Run 'lite login' to authenticate.")
return
click.echo("Authenticated")
click.echo("Authenticated")
click.echo(f"User Email: {token_data.get('user_email', 'Unknown')}")
click.echo(f"User ID: {token_data.get('user_id', 'Unknown')}")
click.echo(f"User Role: {token_data.get('user_role', 'Unknown')}")
@ -717,7 +715,7 @@ def whoami():
click.echo(f"Token age: {age_hours:.1f} hours")
if age_hours > CLI_JWT_EXPIRATION_HOURS:
click.echo(f"⚠️ Warning: Token is more than {CLI_JWT_EXPIRATION_HOURS} hours old and may have expired.")
click.echo(f"Warning: Token is more than {CLI_JWT_EXPIRATION_HOURS} hours old and may have expired.")
@click.group(name="auth")

View file

@ -150,7 +150,7 @@ def chat(
f"Max Tokens: [yellow]{max_tokens or 'unlimited'}[/yellow]\n\n"
f"Type your messages and press Enter. Type '/quit' or '/exit' to end the session.\n"
f"Type '/help' for more commands.",
title="🤖 Chat Session",
title="Chat Session",
)
)

View file

@ -32,7 +32,7 @@ def migrate(ctx: click.Context, check_only: bool, dry_run: bool):
Requires the proxy to be started with
``general_settings.encryption_algorithm: aes-256-gcm``. Idempotent and
resumable safe to re-run after an interruption.
resumable; safe to re-run after an interruption.
Examples:
litellm-proxy encryption migrate --check # attestation scan, no writes

View file

@ -309,12 +309,12 @@ def _import_keys_to_destination(
imported_count += 1
key_alias = key.get("key_alias", "N/A")
click.echo(f"Imported key: {key_alias}")
click.echo(f"Imported key: {key_alias}")
except Exception as e:
failed_count += 1
key_alias = key.get("key_alias", "N/A")
click.echo(f"Failed to import key {key_alias}: {str(e)}", err=True)
click.echo(f"Failed to import key {key_alias}: {str(e)}", err=True)
return imported_count, failed_count

View file

@ -21,7 +21,7 @@ def display_teams_table(teams: List[Dict[str, Any]]) -> None:
console = Console()
if not teams:
console.print("No teams found for your user.")
console.print("No teams found for your user.")
return
table = Table(title="Available Teams")
@ -91,10 +91,10 @@ def available(ctx: click.Context):
teams = client.teams.get_available()
if teams:
console = Console()
console.print("\n🎯 Available Teams to Join:")
console.print("\nAvailable Teams to Join:")
display_teams_table(teams)
else:
click.echo(" No available teams to join.")
click.echo("No available teams to join.")
except requests.exceptions.HTTPError as e:
click.echo(f"Error: HTTP {e.response.status_code}", err=True)
error_body = e.response.json()
@ -113,7 +113,7 @@ def assign_key(ctx: click.Context, team_id: Optional[str]):
api_key = ctx.obj["api_key"]
if not api_key:
click.echo("No API key found. Please login first using 'litellm login'")
click.echo("No API key found. Please login first using 'litellm login'")
raise click.Abort()
try:
@ -122,7 +122,7 @@ def assign_key(ctx: click.Context, team_id: Optional[str]):
teams = client.teams.list()
if not teams:
click.echo("No teams found for your user.")
click.echo("No teams found for your user.")
return
# Use interactive selection from auth module
@ -133,14 +133,14 @@ def assign_key(ctx: click.Context, team_id: Optional[str]):
if selected_team:
team_id = selected_team.get("team_id")
else:
click.echo("Operation cancelled.")
click.echo("Operation cancelled.")
return
# Update the key with the selected team
if team_id:
click.echo(f"\n🔄 Assigning your key to team: {team_id}")
click.echo(f"\nAssigning your key to team: {team_id}")
client.keys.update(key=api_key, team_id=team_id)
click.echo(f"Successfully assigned key to team: {team_id}")
click.echo(f"Successfully assigned key to team: {team_id}")
# Show team details if available
teams = client.teams.list()
@ -148,9 +148,9 @@ def assign_key(ctx: click.Context, team_id: Optional[str]):
if team.get("team_id") == team_id:
models = team.get("models", [])
if models:
click.echo(f"🎯 You can now access models: {', '.join(models)}")
click.echo(f"You can now access models: {', '.join(models)}")
else:
click.echo("🎯 You can now access all available models")
click.echo("You can now access all available models")
break
except requests.exceptions.HTTPError as e:

View file

@ -27,13 +27,13 @@ def styled_prompt():
verbose_logger.debug(f"Error getting terminal size: {e}")
click.echo("\n" * 3)
# Unicode box drawing characters
top_left = ""
top_right = ""
bottom_left = ""
bottom_right = ""
horizontal = ""
vertical = ""
# ASCII box drawing characters
top_left = "+"
top_right = "+"
bottom_left = "+"
bottom_right = "+"
horizontal = "-"
vertical = "|"
# Create the box with increased width
width = 80

View file

@ -27,7 +27,7 @@ from typing import (
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.caching import DualCache, RedisCache
from litellm.caching import RedisCache
from litellm.constants import (
DB_SPEND_UPDATE_JOB_NAME,
DB_DAILY_TAG_SPEND_UPDATE_JOB_NAME,
@ -44,7 +44,6 @@ from litellm.proxy._types import (
DailyUserSpendTransaction,
DBSpendUpdateTransactions,
Litellm_EntityType,
LiteLLM_UserTable,
SpendLogsMetadata,
SpendLogsPayload,
SpendUpdateQueueItem,
@ -137,7 +136,6 @@ class DBSpendUpdateWriter:
disable_spend_logs,
litellm_proxy_budget_name,
prisma_client,
user_api_key_cache,
)
from litellm.proxy.utils import ProxyUpdateSpend, hash_token
@ -195,7 +193,6 @@ class DBSpendUpdateWriter:
org_id=org_id,
end_user_id=end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
litellm_proxy_budget_name=litellm_proxy_budget_name,
payload=payload,
)
@ -326,7 +323,6 @@ class DBSpendUpdateWriter:
org_id: Optional[str],
end_user_id: Optional[str],
prisma_client: Optional[PrismaClient],
user_api_key_cache: DualCache,
litellm_proxy_budget_name: Optional[str],
payload: SpendLogsPayload,
):
@ -345,7 +341,6 @@ class DBSpendUpdateWriter:
response_cost=response_cost,
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
litellm_proxy_budget_name=litellm_proxy_budget_name,
end_user_id=end_user_id,
)
@ -510,7 +505,6 @@ class DBSpendUpdateWriter:
response_cost: Optional[float],
user_id: Optional[str],
prisma_client: Optional[PrismaClient],
user_api_key_cache: DualCache,
litellm_proxy_budget_name: Optional[str],
end_user_id: Optional[str] = None,
):
@ -518,10 +512,6 @@ class DBSpendUpdateWriter:
- Update that user's row
- Update litellm-proxy-budget row (global proxy spend)
"""
## if an end-user is passed in, do an upsert - we can't guarantee they already exist in db
existing_user_obj = await user_api_key_cache.async_get_cache(key=user_id)
if existing_user_obj is not None and isinstance(existing_user_obj, dict):
existing_user_obj = LiteLLM_UserTable(**existing_user_obj)
try:
if prisma_client is not None: # update
user_ids = [user_id]

View file

@ -5,7 +5,7 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm.caching.caching import DualCache
from litellm.integrations.custom_logger import Span
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy._types import Litellm_EntityType, UserAPIKeyAuth
from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import (
@ -76,6 +76,8 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting):
message=f"LiteLLM Virtual Key: {user_api_key_dict.token}, key_alias: {user_api_key_dict.key_alias}, exceeded budget for model={model}",
current_cost=_current_spend,
max_budget=_current_model_budget_info.max_budget,
entity_type=Litellm_EntityType.KEY.value,
entity_id=user_api_key_dict.token,
)
return True
@ -140,6 +142,8 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting):
message=f"LiteLLM End User: {end_user_id}, exceeded budget for model={model}",
current_cost=_current_spend,
max_budget=_current_model_budget_info.max_budget,
entity_type=Litellm_EntityType.END_USER.value,
entity_id=end_user_id,
)
return True

View file

@ -949,6 +949,10 @@ class LiteLLMProxyRequestSetup:
user_api_key_alias=user_api_key_dict.key_alias,
user_api_key_spend=user_api_key_dict.spend,
user_api_key_max_budget=user_api_key_dict.max_budget,
user_api_key_user_spend=user_api_key_dict.user_spend,
user_api_key_user_max_budget=user_api_key_dict.user_max_budget,
user_api_key_team_spend=user_api_key_dict.team_spend,
user_api_key_team_max_budget=user_api_key_dict.team_max_budget,
user_api_key_team_id=user_api_key_dict.team_id,
user_api_key_project_id=user_api_key_dict.project_id,
user_api_key_project_alias=user_api_key_dict.project_alias,

View file

@ -536,6 +536,7 @@ if MCP_AVAILABLE:
sanitized.env = {}
sanitized.command = None
sanitized.args = []
sanitized.issuer = None
sanitized.authorization_url = None
sanitized.token_url = None
sanitized.registration_url = None
@ -581,6 +582,7 @@ if MCP_AVAILABLE:
sanitized.teams = []
sanitized.env_vars = None
sanitized.issuer = None
sanitized.authorization_url = None
sanitized.token_url = None
sanitized.registration_url = None
@ -686,6 +688,7 @@ if MCP_AVAILABLE:
command=payload.command,
args=payload.args,
env=payload.env,
issuer=payload.issuer,
authorization_url=payload.authorization_url,
token_url=payload.token_url,
registration_url=payload.registration_url,

View file

@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable {
command String?
args String[] @default([])
env Json? @default("{}")
issuer String?
authorization_url String?
token_url String?
registration_url String?

View file

@ -4,7 +4,7 @@ import asyncio
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Sequence, cast
from typing import Any, Dict, List, Mapping, Optional, Sequence, cast
import litellm
from litellm._logging import verbose_proxy_logger
@ -12,6 +12,7 @@ from litellm.caching import DualCache
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.litellm_core_utils.llm_cost_calc.tiered_pricing import select_tier_for_input, tier_rate
from litellm.proxy._types import (
Litellm_EntityType,
LiteLLM_TeamMembership,
LiteLLM_TeamTable,
LiteLLM_UserTable,
@ -36,6 +37,17 @@ class _BudgetCounter:
window_start: Optional[datetime] = None
_COUNTER_ENTITY_TYPES: Mapping[str, str] = {
"Key": Litellm_EntityType.KEY.value,
"Team": Litellm_EntityType.TEAM.value,
"TeamMember": Litellm_EntityType.TEAM_MEMBER.value,
"User": Litellm_EntityType.USER.value,
"EndUser": Litellm_EntityType.END_USER.value,
"Tag": Litellm_EntityType.TAG.value,
"Organization": Litellm_EntityType.ORGANIZATION.value,
}
class _CounterReservationUnavailable(Exception):
def __init__(
self,
@ -108,6 +120,8 @@ async def _apply_over_budget_reservation_policy(
f"Current cost: {current_spend}, "
f"Max budget: {counter.max_budget}"
),
entity_type=_COUNTER_ENTITY_TYPES.get(counter.entity_type),
entity_id=counter.spend_log_entity_id or counter.entity_id,
)

View file

@ -718,6 +718,12 @@ class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator):
except StopAsyncIteration:
# Normal end of stream - don't log as failure
raise
except (httpx.ReadError, httpx.RemoteProtocolError) as e:
self.finished = True
if self.completed_response is None:
self._handle_failure(e)
raise
raise StopAsyncIteration from e
except httpx.HTTPError as e:
# Handle HTTP errors
self.finished = True
@ -794,6 +800,12 @@ class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator):
except StopIteration:
# Normal end of stream - don't log as failure
raise
except (httpx.ReadError, httpx.RemoteProtocolError) as e:
self.finished = True
if self.completed_response is None:
self._handle_failure(e)
raise
raise StopIteration from e
except httpx.HTTPError as e:
# Handle HTTP errors
self.finished = True

View file

@ -251,6 +251,15 @@ else:
PreRoutingHookResponse = Any
def _cost_value_as_float(value: Union[str, int, float, None]) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
class RoutingArgs(enum.Enum):
ttl = 60 # 1min (RPM/TPM expire key)
@ -8750,8 +8759,8 @@ class Router:
# Get mode from database model_info if available, otherwise default to "chat"
db_model_info = model.get("model_info", {})
mode = db_model_info.get("mode", "chat")
input_cost_per_token = db_model_info.get("input_cost_per_token")
output_cost_per_token = db_model_info.get("output_cost_per_token")
input_cost_per_token = _cost_value_as_float(db_model_info.get("input_cost_per_token"))
output_cost_per_token = _cost_value_as_float(db_model_info.get("output_cost_per_token"))
model_info = ModelMapInfo(
key=model_group,
@ -8802,16 +8811,18 @@ class Router:
)
):
model_group_info.max_output_tokens = model_info["max_output_tokens"]
if model_info.get("input_cost_per_token", None) is not None and (
_input_cost_per_token = _cost_value_as_float(model_info.get("input_cost_per_token"))
if _input_cost_per_token is not None and (
model_group_info.input_cost_per_token is None
or (model_info["input_cost_per_token"] or 0.0) > (model_group_info.input_cost_per_token or 0.0)
or _input_cost_per_token > (model_group_info.input_cost_per_token or 0.0)
):
model_group_info.input_cost_per_token = model_info["input_cost_per_token"]
if model_info.get("output_cost_per_token", None) is not None and (
model_group_info.input_cost_per_token = _input_cost_per_token
_output_cost_per_token = _cost_value_as_float(model_info.get("output_cost_per_token"))
if _output_cost_per_token is not None and (
model_group_info.output_cost_per_token is None
or (model_info["output_cost_per_token"] or 0.0) > (model_group_info.output_cost_per_token or 0.0)
or _output_cost_per_token > (model_group_info.output_cost_per_token or 0.0)
):
model_group_info.output_cost_per_token = model_info["output_cost_per_token"]
model_group_info.output_cost_per_token = _output_cost_per_token
if (
model_info.get("supports_parallel_function_calling", None) is not None
and model_info["supports_parallel_function_calling"] is True # type: ignore

View file

@ -14,3 +14,4 @@ class UsagePerChunk(TypedDict):
web_search_requests: Optional[int]
completion_tokens_details: Optional[CompletionTokensDetails]
prompt_tokens_details: Optional[PromptTokensDetailsWrapper]
cost: Optional[float]

View file

@ -26,6 +26,11 @@ class MCPOAuthMetadata(BaseModel):
authorization_url: Optional[str] = None
token_url: Optional[str] = None
registration_url: Optional[str] = None
discovered_issuer: Optional[str] = None
"""The ``issuer`` the authorization-server metadata document self-attests (RFC 8414). Persisted
trust-on-first-use as the server's ``issuer`` when none is configured, so that later rebuilds
anchor discovery on it (RFC 8414 §3.3) and a subsequently compromised resource cannot re-point
it. Never overwrites an admin-configured issuer."""
from_origin_fallback: bool = False
"""True when the metadata came from guessing the resource origin as its authorization
server rather than from an RFC 9728/8414-advertised document. Guessed endpoints are
@ -60,6 +65,8 @@ class MCPServer(BaseModel):
# OAuth-specific fields
client_id: Optional[str] = None
client_secret: Optional[str] = None
issuer: Optional[str] = None
issuer_is_anchored: bool = False
scopes: Optional[List[str]] = None
authorization_url: Optional[str] = None
token_url: Optional[str] = None

View file

@ -1798,14 +1798,17 @@ class ModelResponseStream(ModelResponseBase):
else:
created = created
usage_to_set = None
if "usage" in kwargs and kwargs["usage"] is not None:
if isinstance(kwargs["usage"], dict):
kwargs["usage"] = Usage(**kwargs["usage"])
usage_to_set = Usage(**kwargs["usage"])
kwargs["usage"] = usage_to_set
elif isinstance(kwargs["usage"], BaseModel):
dump = (
kwargs["usage"].model_dump() if hasattr(kwargs["usage"], "model_dump") else kwargs["usage"].dict()
)
kwargs["usage"] = Usage(**dump)
usage_to_set = Usage(**dump)
kwargs["usage"] = usage_to_set
kwargs["id"] = id
kwargs["created"] = created
@ -1814,6 +1817,9 @@ class ModelResponseStream(ModelResponseBase):
super().__init__(**kwargs)
if usage_to_set is not None:
self.usage = usage_to_set
def __contains__(self, key):
# Define custom behavior for the 'in' operator
return hasattr(self, key)
@ -2469,6 +2475,10 @@ class StandardLoggingUserAPIKeyMetadata(TypedDict):
user_api_key_spend: Optional[float]
user_api_key_max_budget: Optional[float]
user_api_key_budget_reset_at: Optional[str]
user_api_key_user_spend: Optional[float]
user_api_key_user_max_budget: Optional[float]
user_api_key_team_spend: Optional[float]
user_api_key_team_max_budget: Optional[float]
user_api_key_org_id: Optional[str]
user_api_key_org_alias: Optional[str]
user_api_key_team_id: Optional[str]
@ -2687,6 +2697,10 @@ class StandardLoggingPayloadErrorInformation(TypedDict, total=False):
# hints). Lets dashboards split rate-limit failures by cause without
# parsing free-text error messages.
error_rate_limit_type: Optional[str]
error_budget_entity_type: Optional[str]
error_budget_entity_id: Optional[str]
error_budget_limit: Optional[float]
error_budget_spend: Optional[float]
class GuardrailMode(TypedDict, total=False):

View file

@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable {
command String?
args String[] @default([])
env Json? @default("{}")
issuer String?
authorization_url String?
token_url String?
registration_url String?

View file

@ -171,3 +171,16 @@ other.<area>.<case>.<assertion>
e.g. other.auth.jwt.valid_token_allows
other.lifecycle.readiness.reports_db
```
## Hard Rules
- no monkeypatching, mock tests or unit tests of any kind. if a contributor asks you to write an end to end test, do NOT stage a unit test with it. if you find a product gap, call it out in the PR description
- use model management endpoints to create new models for a test. this could be in a conftest / inline for each test. ask the user what they want.
- do not overengineer a test, i need you to write readable, clean code of what would look like a natural user scenario
- when it comes to typing an input schema for an api endpoint, have it type X = A | B | C ... where X = exhaustive union of all supported input schemas and A, B, C typically are composed by a base type. types are only pretty for a api request / response body. make sure to compose types instead of repeating the same base attributes over and over again.
- use the docker-compose to your advantage and spin up a local proxy, make sure all tests pass. if a test fails due to an internally found issue, let users know to create a linear ticket for it.
- do not use xfail markers, tests should be written in a form that the end user expects it to pass

View file

@ -27,19 +27,20 @@ collecting this module as a test file.
from __future__ import annotations
import os
from typing import Any, Mapping, Sequence
from typing import Any, Callable, Mapping, Sequence
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
DriverResult,
failure_diagnostic,
run_claude_models_parallel,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
ClaudeRunner = Callable[..., Mapping[str, DriverResult | ClaudeCLIError]]
# Floor on the number of `stream_event` records (with delta payloads)
# we expect to see when the proxy actually streams. With
@ -79,6 +80,8 @@ def run_basic_messaging_cell(
models: Sequence[str],
prompt: str,
verify_streaming: bool = False,
env: Mapping[str, str] | None = None,
runner: ClaudeRunner = run_claude_models_parallel,
) -> None:
"""Run the shared `basic_messaging_*` × <provider> cell body.
@ -99,28 +102,13 @@ def run_basic_messaging_cell(
streamed reply to a single ``assistant`` event in
``--print --output-format stream-json`` mode).
"""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
base_url, api_key = require_proxy(compat_result, env=env)
extra_args: Sequence[str] = (
("--include-partial-messages",) if verify_streaming else ()
)
outcomes = run_claude_models_parallel(
outcomes = runner(
models=models,
prompt=prompt,
base_url=base_url,

View file

@ -26,7 +26,7 @@
"providers": {
"anthropic": {
"status": "fail",
"error": "[claude-sonnet-4-6] tool call dropped"
"error": "[claude-sonnet-4-5] tool call dropped"
},
"bedrock_invoke": {
"status": "not_applicable",

View file

@ -10,7 +10,7 @@
{
"feature_id": "basic_messaging_non_streaming",
"provider": "anthropic",
"nodeid": "tests/e2e/claude_code/basic_messaging_non_streaming/test_anthropic.py::test_basic_messaging_non_streaming_anthropic[claude-sonnet-4-6]",
"nodeid": "tests/e2e/claude_code/basic_messaging_non_streaming/test_anthropic.py::test_basic_messaging_non_streaming_anthropic[claude-sonnet-4-5]",
"result": {"status": "pass"}
},
{
@ -28,8 +28,8 @@
{
"feature_id": "tool_use",
"provider": "anthropic",
"nodeid": "tests/e2e/claude_code/tool_use/test_anthropic.py::test_x[claude-sonnet-4-6]",
"result": {"status": "fail", "error": "[claude-sonnet-4-6] tool call dropped"}
"nodeid": "tests/e2e/claude_code/tool_use/test_anthropic.py::test_x[claude-sonnet-4-5]",
"result": {"status": "fail", "error": "[claude-sonnet-4-5] tool call dropped"}
},
{
"feature_id": "tool_use",

View file

@ -393,7 +393,7 @@ def test_build_matrix_6x5_grid_matches_published_sample():
feature_ids = [feature["id"] for feature in manifest["features"]]
providers = manifest["providers"]
models = ["claude-haiku-4-5", "claude-sonnet-4-6", "claude-opus-4-7"]
models = ["claude-haiku-4-5", "claude-sonnet-4-5", "claude-opus-4-7"]
results = []
for feature_id in feature_ids:

View file

@ -152,7 +152,7 @@ def test_per_provider_test_file_imports_and_parametrizes_three_models(
`claude-opus-4-7-bedrock-invoke`), so we check for the tier
substrings rather than exact alias names."""
text = (SUITE_ROOT / feature_id / f"test_{provider}.py").read_text()
for tier in ("haiku-4-5", "sonnet-4-6", "opus-4-7"):
for tier in ("haiku-4-5", "sonnet-4-5", "opus-4-7"):
assert (
tier in text
), f"{feature_id}/test_{provider}.py does not reference {tier}"

View file

@ -0,0 +1,86 @@
"""Load the claude_code compat matrix's deployment list from
``test_config.yaml``.
``test_config.yaml`` is the ground-truth config the stage deployment
uses; parsing it at fixture time means a change there (new tier, tier
retirement, provider swap, endpoint rename) reaches the fixture with
no extra edit. A drift-check test asserts every ``*_MODELS`` list
referenced by the compat cells is covered by the yaml, so a cell that
adds a probe for a name the yaml doesn't know about fails loudly at
collection instead of at 400-time.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Mapping
import yaml
from models import LiteLLMParamsBody
CONFIG_PATH = Path(__file__).resolve().parent / "test_config.yaml"
@dataclass(frozen=True, slots=True)
class CompatDeployment:
model_name: str
litellm_params: LiteLLMParamsBody
# The yaml uses ``vertex_ai_*`` for the vertex project/location fields
# (that is the spelling the proxy config file historically standardized
# on), while ``LiteLLMParamsBody`` names them without the ``_ai`` infix
# (matching the proxy's DB column). Both spellings resolve at call time
# on the proxy side, but pydantic silently drops unknown fields, so a
# raw ``LiteLLMParamsBody(**entry)`` would produce a body with the
# vertex project stripped - the resulting deployment 400s at
# ``/v1/messages`` with "Invalid model name". Normalize the yaml keys
# to the pydantic names in one place.
_YAML_TO_PYDANTIC_ALIASES = {
"vertex_ai_project": "vertex_project",
"vertex_ai_location": "vertex_location",
"vertex_ai_credentials": "vertex_credentials",
}
def _normalize_params(raw: Mapping[str, object]) -> dict[str, object]:
return {_YAML_TO_PYDANTIC_ALIASES.get(k, k): v for k, v in raw.items()}
ConfigReader = Callable[[Path], str]
def _default_reader(path: Path) -> str:
return path.read_text()
def load_all_deployments(
config_path: Path = CONFIG_PATH,
reader: ConfigReader = _default_reader,
) -> tuple[CompatDeployment, ...]:
"""Every deployment declared in the yaml, in file order."""
doc = yaml.safe_load(reader(config_path))
model_list = doc.get("model_list") or []
return tuple(
CompatDeployment(
model_name=entry["model_name"],
litellm_params=LiteLLMParamsBody(
**_normalize_params(entry["litellm_params"])
),
)
for entry in model_list
)
def all_expected_model_names(
*,
config_path: Path = CONFIG_PATH,
reader: ConfigReader = _default_reader,
) -> frozenset[str]:
"""Every virtual name the compat matrix declares - the ground truth
the cells are supposed to probe. Used by the drift-check test."""
return frozenset(
d.model_name for d in load_all_deployments(config_path, reader)
)

View file

@ -1,20 +1,19 @@
"""Unit tests for the shared `run_basic_messaging_cell` helper.
These tests mock `run_claude_models_parallel` so they exercise the
helper's branching (env-missing guard, per-model pass/fail/empty-text,
streaming wire check) without spawning the real CLI. The streaming
check is the regression we care about: a proxy that buffers the
upstream stream must turn the cell red, not green.
These tests inject a fake ``ClaudeRunner`` and a fake env mapping so
they exercise the helper's branching (env-missing guard, per-model
pass/fail/empty-text, streaming wire check) without spawning the real
CLI or touching ``os.environ``. The streaming check is the regression
we care about: a proxy that buffers the upstream stream must turn the
cell red, not green.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Mapping, Optional, Sequence
import pytest
from claude_code import _basic_messaging
from claude_code._basic_messaging import (
MIN_STREAM_DELTA_EVENTS,
_count_stream_event_deltas,
@ -23,6 +22,12 @@ from claude_code._basic_messaging import (
from claude_code.cli_driver import DriverResult
_PROXY_ENV: Mapping[str, str] = {
"LITELLM_PROXY_URL": "http://localhost:4000",
"LITELLM_MASTER_KEY": "sk-test",
}
class _FakeResult:
"""Stand-in for the test's `compat_result` fixture.
@ -82,16 +87,17 @@ def _buffered_events() -> List[Dict[str, Any]]:
]
def _install_fake_runner(monkeypatch, *, outcomes_by_model):
"""Patch `run_claude_models_parallel` to return canned outcomes.
def _make_fake_runner(*, outcomes_by_model):
"""Build an injectable runner that returns canned outcomes and
records the kwargs the helper passed in.
Captures the kwargs the cell passed in so tests can assert on
`extra_args` (which is how the streaming variant opts into
`--include-partial-messages`).
"""
Returns a ``(runner, captured)`` pair; ``captured`` is a dict the
test can assert against without any global mutation, which is why
we prefer DI over ``monkeypatch.setattr``: the helper takes a
``runner=`` kwarg, so tests bind their fake directly."""
captured: Dict[str, Any] = {}
def fake(*, models, prompt, base_url, api_key, extra_args=None, **_kwargs):
def runner(*, models, prompt, base_url, api_key, extra_args=None, **_kwargs):
captured["models"] = list(models)
captured["prompt"] = prompt
captured["base_url"] = base_url
@ -99,14 +105,7 @@ def _install_fake_runner(monkeypatch, *, outcomes_by_model):
captured["extra_args"] = list(extra_args) if extra_args else []
return {model: outcomes_by_model[model] for model in models}
monkeypatch.setattr(_basic_messaging, "run_claude_models_parallel", fake)
return captured
@pytest.fixture(autouse=True)
def _proxy_env(monkeypatch):
monkeypatch.setenv("LITELLM_PROXY_BASE_URL", "http://localhost:4000")
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test")
return runner, captured
def test_count_stream_event_deltas_only_counts_records_with_event_payload():
@ -123,28 +122,30 @@ def test_count_stream_event_deltas_only_counts_records_with_event_payload():
assert _count_stream_event_deltas(events) == 2
def test_verify_streaming_passes_when_proxy_streams(monkeypatch):
def test_verify_streaming_passes_when_proxy_streams():
fake_result = _FakeResult()
model = "claude-haiku-4-5"
outcome = DriverResult(text="1\n2\n3", events=_streamed_events(n_deltas=5))
captured = _install_fake_runner(monkeypatch, outcomes_by_model={model: outcome})
runner, captured = _make_fake_runner(outcomes_by_model={model: outcome})
run_basic_messaging_cell(
compat_result=fake_result,
models=[model],
prompt="Count from 1 to 5, one number per line.",
verify_streaming=True,
env=_PROXY_ENV,
runner=runner,
)
assert captured["extra_args"] == ["--include-partial-messages"]
assert fake_result.rows == [{"status": "pass"}]
def test_verify_streaming_fails_when_proxy_buffers(monkeypatch):
def test_verify_streaming_fails_when_proxy_buffers():
fake_result = _FakeResult()
model = "claude-haiku-4-5"
outcome = DriverResult(text="1\n2\n3", events=_buffered_events())
_install_fake_runner(monkeypatch, outcomes_by_model={model: outcome})
runner, _captured = _make_fake_runner(outcomes_by_model={model: outcome})
with pytest.raises(pytest.fail.Exception):
run_basic_messaging_cell(
@ -152,7 +153,9 @@ def test_verify_streaming_fails_when_proxy_buffers(monkeypatch):
models=[model],
prompt="Count from 1 to 5, one number per line.",
verify_streaming=True,
)
env=_PROXY_ENV,
runner=runner,
)
assert len(fake_result.rows) == 1
row = fake_result.rows[0]
@ -161,33 +164,35 @@ def test_verify_streaming_fails_when_proxy_buffers(monkeypatch):
assert f"< {MIN_STREAM_DELTA_EVENTS}" in row["error"]
def test_non_streaming_variant_omits_partial_messages_flag(monkeypatch):
def test_non_streaming_variant_omits_partial_messages_flag():
"""Default `verify_streaming=False` keeps the non-streaming wire identical."""
fake_result = _FakeResult()
model = "claude-haiku-4-5"
outcome = DriverResult(text="pong", events=_buffered_events())
captured = _install_fake_runner(monkeypatch, outcomes_by_model={model: outcome})
runner, captured = _make_fake_runner(outcomes_by_model={model: outcome})
run_basic_messaging_cell(
compat_result=fake_result,
models=[model],
prompt="Reply with the single word 'pong' and nothing else.",
env=_PROXY_ENV,
runner=runner,
)
assert captured["extra_args"] == []
assert fake_result.rows == [{"status": "pass"}]
def test_verify_streaming_requires_all_models_to_stream(monkeypatch):
def test_verify_streaming_requires_all_models_to_stream():
"""If any one tier buffers, the cell fails — same all-must-pass shape as
the non-streaming check."""
fake_result = _FakeResult()
outcomes = {
"claude-haiku-4-5": DriverResult(text="ok", events=_streamed_events(5)),
"claude-sonnet-4-6": DriverResult(text="ok", events=_buffered_events()),
"claude-sonnet-4-5": DriverResult(text="ok", events=_buffered_events()),
"claude-opus-4-7": DriverResult(text="ok", events=_streamed_events(5)),
}
_install_fake_runner(monkeypatch, outcomes_by_model=outcomes)
runner, _captured = _make_fake_runner(outcomes_by_model=outcomes)
with pytest.raises(pytest.fail.Exception):
run_basic_messaging_cell(
@ -195,7 +200,30 @@ def test_verify_streaming_requires_all_models_to_stream(monkeypatch):
models=list(outcomes.keys()),
prompt="Count from 1 to 5, one number per line.",
verify_streaming=True,
)
env=_PROXY_ENV,
runner=runner,
)
statuses = [row["status"] for row in fake_result.rows]
assert statuses == ["pass", "fail", "pass"]
def test_missing_proxy_env_hard_fails_regardless_of_runner():
"""The env guard fires before the runner is called, and takes the
env from the injected mapping (not os.environ). Passing an empty
env dict must hard-fail even if a happy runner is bound."""
fake_result = _FakeResult()
runner, captured = _make_fake_runner(outcomes_by_model={})
with pytest.raises(pytest.fail.Exception):
run_basic_messaging_cell(
compat_result=fake_result,
models=["claude-haiku-4-5"],
prompt="whatever",
env={},
runner=runner,
)
assert captured == {}, "runner must not be called when env resolution fails"

View file

@ -140,7 +140,7 @@ def test_run_claude_inherits_only_allowlisted_os_environ(monkeypatch):
monkeypatch.setenv("HOME", "/home/runner")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "totally-secret")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-proxy-only")
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "azure-secret")
monkeypatch.setenv("AZURE_AI_API_KEY", "azure-secret")
monkeypatch.setenv("VERTEXAI_CREDENTIALS", '{"private_key": "leak"}')
monkeypatch.setenv("GITHUB_TOKEN", "ghs_xxx")
@ -156,7 +156,7 @@ def test_run_claude_inherits_only_allowlisted_os_environ(monkeypatch):
assert env["PATH"] == "/usr/bin:/usr/local/bin"
assert "AWS_SECRET_ACCESS_KEY" not in env
assert "ANTHROPIC_API_KEY" not in env
assert "AZURE_FOUNDRY_API_KEY" not in env
assert "AZURE_AI_API_KEY" not in env
assert "VERTEXAI_CREDENTIALS" not in env
assert "GITHUB_TOKEN" not in env

View file

@ -20,6 +20,10 @@ from typing import Any, Dict, List, Mapping, Optional
import pytest
from claude_code._env import (
PRIMARY_API_KEY_ENV,
PRIMARY_BASE_URL_ENV,
)
from claude_code._passthrough import (
ANTHROPIC_PASSTHROUGH_BASE_PATH,
CLIENT_SIDE_AWS_REGION,
@ -33,8 +37,8 @@ from claude_code._passthrough import (
from claude_code.cli_driver import ClaudeCLIError, DriverResult
PROXY_ENV = {
"LITELLM_PROXY_BASE_URL": "http://localhost:4000",
"LITELLM_PROXY_API_KEY": "sk-test",
PRIMARY_BASE_URL_ENV: "http://localhost:4000",
PRIMARY_API_KEY_ENV: "sk-test",
}
@ -73,7 +77,29 @@ def test_env_missing_guard_reports_fail_and_aborts():
)
assert fake_result.single is not None
assert fake_result.single["status"] == "fail"
assert "LITELLM_PROXY_BASE_URL" in fake_result.single["error"]
assert PRIMARY_BASE_URL_ENV in fake_result.single["error"]
assert PRIMARY_API_KEY_ENV in fake_result.single["error"]
def test_suite_wide_env_reaches_the_proxy():
"""Passthrough cells resolve via the same suite-wide env names as
every other e2e cell, so EKS wiring that only exports
LITELLM_PROXY_URL + LITELLM_MASTER_KEY reaches the ALB."""
fake_result = _FakeResult()
captured: Dict[str, Any] = {}
outcome = DriverResult(text="pong")
run_passthrough_cell(
compat_result=fake_result,
models=["claude-haiku-4-5"],
prompt="ping",
run_models=_fake_run_models({"claude-haiku-4-5": outcome}, captured),
env=PROXY_ENV,
)
assert captured["base_url"] == "http://localhost:4000"
assert captured["api_key"] == "sk-test"
assert fake_result.rows == [{"status": "pass"}]
def test_anthropic_base_path_appended_to_normalized_proxy_url():
@ -87,7 +113,7 @@ def test_anthropic_base_path_appended_to_normalized_proxy_url():
prompt="ping",
passthrough_base_path=ANTHROPIC_PASSTHROUGH_BASE_PATH,
run_models=_fake_run_models({"claude-haiku-4-5": outcome}, captured),
env={**PROXY_ENV, "LITELLM_PROXY_BASE_URL": "http://localhost:4000/"},
env={**PROXY_ENV, PRIMARY_BASE_URL_ENV: "http://localhost:4000/"},
)
assert captured["base_url"] == "http://localhost:4000/anthropic"
@ -111,7 +137,7 @@ def test_extra_env_builder_receives_normalized_base_and_is_forwarded():
prompt="ping",
build_extra_env=build,
run_models=_fake_run_models({"claude-haiku-4-5": outcome}, captured),
env={**PROXY_ENV, "LITELLM_PROXY_BASE_URL": "http://localhost:4000/"},
env={**PROXY_ENV, PRIMARY_BASE_URL_ENV: "http://localhost:4000/"},
)
assert seen_bases == ["http://localhost:4000"]
@ -124,7 +150,7 @@ def test_per_model_failures_reported_individually():
captured: Dict[str, Any] = {}
outcomes = {
"claude-haiku-4-5": DriverResult(text="pong"),
"claude-sonnet-4-6": ClaudeCLIError("claude CLI timed out after 120s"),
"claude-sonnet-4-5": ClaudeCLIError("claude CLI timed out after 120s"),
"claude-opus-4-7": DriverResult(text="", exit_code=1),
}

View file

@ -60,10 +60,10 @@ from claude_code.rate_limiter import (
"model, expected",
[
("claude-haiku-4-5", PROVIDER_ANTHROPIC),
("claude-sonnet-4-6", PROVIDER_ANTHROPIC),
("claude-sonnet-4-5", PROVIDER_ANTHROPIC),
("claude-opus-4-7", PROVIDER_ANTHROPIC),
("claude-haiku-4-5-azure", PROVIDER_AZURE),
("claude-sonnet-4-6-azure", PROVIDER_AZURE),
("claude-sonnet-4-5-azure", PROVIDER_AZURE),
("claude-opus-4-7-vertex", PROVIDER_VERTEX_AI),
("claude-haiku-4-5-bedrock-converse", PROVIDER_BEDROCK_CONVERSE),
("claude-haiku-4-5-bedrock-invoke", PROVIDER_BEDROCK_INVOKE),

View file

@ -0,0 +1,74 @@
"""Proxy-env resolution for the claude_code compat cells.
Uses the same ``LITELLM_PROXY_URL`` / ``LITELLM_MASTER_KEY`` names as
``e2e_config.py`` and the rest of ``tests/e2e/``. Everything under
``claude_code/`` goes through ``resolve_proxy`` / ``require_proxy`` here
so the naming lives in one place.
"""
from __future__ import annotations
import os
from typing import Mapping, NamedTuple
import pytest
class ProxyConfig(NamedTuple):
base_url: str
api_key: str
PRIMARY_BASE_URL_ENV = "LITELLM_PROXY_URL"
PRIMARY_API_KEY_ENV = "LITELLM_MASTER_KEY"
def resolve_proxy_from(mapping: Mapping[str, str]) -> ProxyConfig | None:
"""Pure resolver: takes an env mapping, returns a ProxyConfig if
both a base URL and an API key are present, else None. Extracted so
tests can exercise it without mutating ``os.environ``."""
base_url = mapping.get(PRIMARY_BASE_URL_ENV) or None
api_key = mapping.get(PRIMARY_API_KEY_ENV) or None
if not base_url or not api_key:
return None
return ProxyConfig(base_url=base_url, api_key=api_key)
def resolve_proxy(env: Mapping[str, str] | None = None) -> ProxyConfig | None:
"""Convenience wrapper that defaults to ``os.environ``. Prefer
calling ``resolve_proxy_from(env)`` from tests so nothing has to
reach into the process environment."""
return resolve_proxy_from(os.environ if env is None else env)
def _fail_missing_proxy_env(compat_result) -> None:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PRIMARY_BASE_URL_ENV} and "
f"{PRIMARY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PRIMARY_BASE_URL_ENV} / {PRIMARY_API_KEY_ENV} not configured",
pytrace=False,
)
def require_proxy(
compat_result,
*,
env: Mapping[str, str] | None = None,
) -> ProxyConfig:
"""Return the proxy config (base URL + master key), or hard-fail
the test.
``env`` is injected for tests; production callers pass nothing and
the process env is used. This keeps tests off ``monkeypatch.setenv``
for a check that is a pure function of its inputs."""
cfg = resolve_proxy(env)
if cfg is None:
_fail_missing_proxy_env(compat_result)
return cfg

View file

@ -54,20 +54,17 @@ them unset.
from __future__ import annotations
import os
from typing import Any, Callable, Dict, Mapping, Optional, Sequence
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
failure_diagnostic,
run_claude_models_parallel,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
ANTHROPIC_PASSTHROUGH_BASE_PATH = "/anthropic"
CLIENT_SIDE_AWS_REGION = "us-east-1"
@ -140,32 +137,15 @@ def run_passthrough_cell(
the trailing-slash-normalized proxy base URL and returns the
provider-mode env for the CLI subprocess.
"""
environ = env if env is not None else os.environ
base_url = environ.get(PROXY_BASE_URL_ENV)
api_key = environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
proxy_base = base_url.rstrip("/")
proxy = require_proxy(compat_result, env=env)
proxy_base = proxy.base_url.rstrip("/")
extra_env = dict(build_extra_env(proxy_base)) if build_extra_env else None
outcomes = run_models(
models=models,
prompt=prompt,
base_url=proxy_base + passthrough_base_path,
api_key=api_key,
api_key=proxy.api_key,
extra_env=extra_env,
)

View file

@ -35,8 +35,7 @@ from typing import Iterable
import pytest
REPO_ROOT = Path(__file__).resolve().parents[4]
CLAUDE_CODE_DIR = REPO_ROOT / "tests" / "e2e" / "claude_code"
CLAUDE_CODE_DIR = Path(__file__).resolve().parents[1]
# Feature directories whose cells drive the `Bash` built-in tool. Add
# new entries here when a new Bash-using feature is added; the test
@ -74,14 +73,14 @@ def _has_bare_bash_token(text: str) -> bool:
@pytest.mark.parametrize(
"cell", list(_bash_cells()), ids=lambda p: str(p.relative_to(REPO_ROOT))
"cell", list(_bash_cells()), ids=lambda p: str(p.relative_to(CLAUDE_CODE_DIR))
)
def test_bash_allow_rule_is_pinned_to_exact_echo_pong(cell: Path) -> None:
"""The cell must pass `Bash(echo pong)` as the allow rule, not the
unrestricted `Bash` value that was originally flagged."""
text = cell.read_text()
assert '"Bash(echo pong)"' in text, (
f"{cell.relative_to(REPO_ROOT)} must restrict `--allowed-tools` to "
f"{cell.relative_to(CLAUDE_CODE_DIR)} must restrict `--allowed-tools` to "
f'`Bash(echo pong)` (exact-match pattern). Unrestricted `"Bash"` '
f"grants arbitrary host command execution to model-controlled "
f"tool_use blocks, which can read `docker inspect compat-proxy` "
@ -95,7 +94,7 @@ def test_bash_allow_rule_is_pinned_to_exact_echo_pong(cell: Path) -> None:
# in text` short-circuits to True and lets a stray bare `"Bash"`
# slip through silently.
assert not _has_bare_bash_token(text), (
f"{cell.relative_to(REPO_ROOT)} still references the unrestricted "
f"{cell.relative_to(CLAUDE_CODE_DIR)} still references the unrestricted "
f'`"Bash"` value outside the `"Bash(echo pong)"` allow rule — '
f"sweep it out before merging."
)
@ -130,7 +129,7 @@ def test_has_bare_bash_token_ignores_unrelated_substrings():
@pytest.mark.parametrize(
"cell", list(_bash_cells()), ids=lambda p: str(p.relative_to(REPO_ROOT))
"cell", list(_bash_cells()), ids=lambda p: str(p.relative_to(CLAUDE_CODE_DIR))
)
def test_bash_cell_uses_dontask_permission_mode(cell: Path) -> None:
"""The cell must pair the allow rule with `--permission-mode dontAsk`
@ -139,9 +138,25 @@ def test_bash_cell_uses_dontask_permission_mode(cell: Path) -> None:
succeed without ever surfacing the security issue)."""
text = cell.read_text()
assert '"--permission-mode"' in text and '"dontAsk"' in text, (
f"{cell.relative_to(REPO_ROOT)} must pass `--permission-mode dontAsk` "
f"{cell.relative_to(CLAUDE_CODE_DIR)} must pass `--permission-mode dontAsk` "
f"alongside the `Bash(echo pong)` allow rule. Without dontAsk, "
f"commands outside the allow rule fall back to the default ask-"
f"mode behavior, which in `--print` (headless) mode is non-"
f"interactive — defeating the explicit-allow contract."
)
def test_claude_code_dir_anchor_is_layout_independent() -> None:
"""CLAUDE_CODE_DIR must resolve to the `claude_code/` directory that
contains this test file, regardless of how deep the repository is
mounted. The previous anchor `Path(__file__).resolve().parents[4]`
baked in the host layout (repo root sits four levels up) and broke
when the suite runs inside the stage container, where tests/e2e/ is
mounted at /app/e2e/ so `parents[4]` resolves to filesystem root and
the BASH_FEATURE_DIRS assertion looks for `/tests/e2e/claude_code/
tool_use`. Anchoring at `parents[1]` (the sibling of this file's
parent) is the same directory in both layouts.
"""
assert CLAUDE_CODE_DIR.name == "claude_code"
assert CLAUDE_CODE_DIR.is_dir()
assert (CLAUDE_CODE_DIR / "_pr_gate_unit_tests" / Path(__file__).name).is_file()

View file

@ -0,0 +1,166 @@
"""Regression tests for the compat-model registration loader.
The compat cells hardcode virtual model names like ``claude-sonnet-4-5``
and expect them to be registered on the proxy before the cell runs. The
session fixture in ``conftest.py`` reads ``test_config.yaml`` and POSTs
those deployments via ``/model/new``. These tests pin the invariants
that make that safe:
- The yaml declares an entry for every virtual name a cell references -
otherwise a cell probes a name the fixture never registered, and the
cell hits an ``Invalid model name`` 400 that is much harder to trace.
- The ``vertex_ai_*`` yaml keys get normalized to the ``vertex_*``
pydantic-body names before ``LiteLLMParamsBody(**)`` sees them, so
the vertex project/location aren't silently dropped by pydantic's
``extra="ignore"`` default.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from claude_code._compat_models import (
all_expected_model_names,
load_all_deployments,
)
CLAUDE_CODE_DIR = Path(__file__).resolve().parents[1]
def _cell_declared_model_names() -> frozenset[str]:
"""Every ``"claude-*"`` model name a compat cell hardcodes in a
``*_MODELS`` list. Uses a simple regex rather than importing every
cell because the cells depend on the harness which depends on env
the unit-test run does not have."""
pattern = re.compile(r'"(claude-[a-zA-Z0-9._-]+)"')
found: set[str] = set()
for path in CLAUDE_CODE_DIR.glob("*/test_*.py"):
if path.parent.name.startswith("_"):
continue
for match in pattern.finditer(path.read_text()):
name = match.group(1)
# Skip upstream model references (they carry a version
# suffix or the ``anthropic/`` provider prefix - we only
# want proxy-side virtual names here).
if "/" in name or "@" in name:
continue
found.add(name)
return frozenset(found)
def test_yaml_covers_every_cell_declared_model_name() -> None:
"""Every ``"claude-..."`` string a cell probes must have a
corresponding ``model_list`` entry in ``test_config.yaml``. A new
cell that adds a probe for a name the yaml doesn't know fails this
test - the alternative is a 400 at runtime that is much harder to
diagnose."""
yaml_names = all_expected_model_names()
cell_names = _cell_declared_model_names()
missing = cell_names - yaml_names
assert not missing, (
f"compat cells reference model names not declared in "
f"test_config.yaml: {sorted(missing)}. Add a matching "
f"model_list entry so the session fixture can register them."
)
def test_yaml_has_no_unused_declarations() -> None:
"""Every declaration in ``test_config.yaml`` is referenced by at
least one cell. A yaml entry no test exercises is dead
configuration and drift-prone; delete it or add the cell."""
yaml_names = all_expected_model_names()
cell_names = _cell_declared_model_names()
unused = yaml_names - cell_names
assert not unused, (
f"test_config.yaml declares model names no cell references: "
f"{sorted(unused)}. Delete them or add the cell."
)
def test_load_returns_fifteen_deployments() -> None:
"""The compat matrix is 3 tiers x 5 provider surfaces = 15. Pin the
count so a future edit to the yaml can't silently drop a tier."""
assert len(load_all_deployments()) == 15
def test_deployments_are_hashable_and_frozen() -> None:
"""``CompatDeployment`` is frozen so tests cannot accidentally
mutate the shared list mid-session."""
d = load_all_deployments()[0]
with pytest.raises((AttributeError, TypeError)):
d.model_name = "mutated" # type: ignore[misc]
def test_vertex_yaml_keys_populate_pydantic_body() -> None:
"""The yaml spells vertex fields ``vertex_ai_project`` /
``vertex_ai_location`` but ``LiteLLMParamsBody`` names them
``vertex_project`` / ``vertex_location``. Without the alias
normalization the pydantic body silently drops the yaml keys, and
the deployment gets registered with no vertex project - a real
incident the drift regressed twice historically."""
all_deployments = load_all_deployments()
vertex = [
d for d in all_deployments if d.model_name.endswith("-vertex")
]
assert vertex, "no vertex deployments found in yaml"
for d in vertex:
assert d.litellm_params.vertex_project, (
f"{d.model_name} lost its vertex_project after normalization"
)
assert d.litellm_params.vertex_location, (
f"{d.model_name} lost its vertex_location after normalization"
)
def test_vertex_deployments_keep_use_in_pass_through() -> None:
"""Vertex passthrough cells need the deployment registered with
``use_in_pass_through: true`` so the proxy wires project/location
credentials into the /vertex_ai passthrough router. ``LiteLLMParamsBody``
defaults to ``extra="ignore"``, so a missing field on the body silently
strips the yaml flag and every vertex passthrough cell fails at runtime
with "No credentials found on proxy for project_name=..."."""
vertex = [
d
for d in load_all_deployments()
if d.model_name.endswith("-vertex")
]
assert vertex, "no vertex deployments found in yaml"
for d in vertex:
assert d.litellm_params.use_in_pass_through is True, (
f"{d.model_name} lost use_in_pass_through after load; "
f"serialized body would be "
f"{d.litellm_params.model_dump(exclude_none=True)}"
)
def test_yaml_litellm_params_are_all_known_body_fields() -> None:
"""Every key under ``litellm_params`` in ``test_config.yaml`` must map
to a ``LiteLLMParamsBody`` field (after the vertex alias rewrite).
Without this pin, a new yaml flag can land in the fixture config and
be silently dropped by pydantic before ``/model/new`` ever sees it."""
import yaml
from models import LiteLLMParamsBody
from claude_code._compat_models import (
CONFIG_PATH,
_YAML_TO_PYDANTIC_ALIASES,
)
known = frozenset(LiteLLMParamsBody.model_fields)
doc = yaml.safe_load(CONFIG_PATH.read_text())
model_list = doc.get("model_list") or []
unknown = tuple(
(entry["model_name"], key)
for entry in model_list
for key in entry["litellm_params"]
if _YAML_TO_PYDANTIC_ALIASES.get(key, key) not in known
)
assert not unknown, (
f"test_config.yaml litellm_params keys not on LiteLLMParamsBody "
f"(will be silently dropped at register time): {unknown}"
)

View file

@ -0,0 +1,162 @@
"""Regression tests for ``claude_code/_env.py``.
Pin the resolution rules so a future edit cannot silently reintroduce
a private spelling that makes every ``claude_code`` cell fail with
"not configured" even when the surrounding e2e suite has a live proxy
configured under the suite-wide ``LITELLM_PROXY_URL`` /
``LITELLM_MASTER_KEY`` names.
"""
from __future__ import annotations
import pytest
from claude_code._env import (
PRIMARY_API_KEY_ENV,
PRIMARY_BASE_URL_ENV,
ProxyConfig,
require_proxy,
resolve_proxy_from,
)
class _CompatResultStub:
"""Minimal stand-in for the compat_result fixture used by cells."""
def __init__(self) -> None:
self.calls: list[dict[str, str]] = []
def set(self, payload: dict[str, str]) -> None:
self.calls.append(payload)
def test_primary_env_names_match_suite_wide_config() -> None:
"""The names claude_code reads must exactly match the ones
``e2e_config.py`` reads for the rest of the suite. Anything else
silently reintroduces the drift this refactor cleaned up."""
assert PRIMARY_BASE_URL_ENV == "LITELLM_PROXY_URL"
assert PRIMARY_API_KEY_ENV == "LITELLM_MASTER_KEY"
def test_returns_none_when_no_env_is_set() -> None:
assert resolve_proxy_from({}) is None
def test_returns_none_when_only_url_is_set() -> None:
assert (
resolve_proxy_from({PRIMARY_BASE_URL_ENV: "http://localhost:4000"}) is None
)
def test_returns_none_when_only_key_is_set() -> None:
assert resolve_proxy_from({PRIMARY_API_KEY_ENV: "sk-1234"}) is None
def test_primary_pair_resolves() -> None:
cfg = resolve_proxy_from(
{
PRIMARY_BASE_URL_ENV: "http://localhost:4000",
PRIMARY_API_KEY_ENV: "sk-1234",
}
)
assert cfg == ProxyConfig("http://localhost:4000", "sk-1234")
def test_legacy_pair_is_ignored() -> None:
"""``LITELLM_PROXY_BASE_URL`` / ``LITELLM_PROXY_API_KEY`` are not
accepted. A runner that only exports those must fail closed rather
than silently use a private spelling that the rest of the suite
does not know about."""
assert (
resolve_proxy_from(
{
"LITELLM_PROXY_BASE_URL": "http://legacy:4000",
"LITELLM_PROXY_API_KEY": "sk-legacy",
}
)
is None
)
def test_empty_string_env_is_treated_as_unset() -> None:
"""``os.environ.get`` on an exported-but-empty var returns "" which
is falsy. The resolver must treat that as unset so a shell that
accidentally exports ``LITELLM_PROXY_URL=`` doesn't turn into a
"" base_url that hits the wrong endpoint."""
assert (
resolve_proxy_from(
{PRIMARY_BASE_URL_ENV: "", PRIMARY_API_KEY_ENV: "sk-1234"}
)
is None
)
def test_require_proxy_fails_with_helpful_message_when_env_empty() -> None:
"""The error the user sees must name the suite-wide env vars so
they know exactly what to export."""
compat = _CompatResultStub()
with pytest.raises(pytest.fail.Exception) as excinfo:
require_proxy(compat, env={})
assert PRIMARY_BASE_URL_ENV in str(excinfo.value)
assert PRIMARY_API_KEY_ENV in str(excinfo.value)
assert compat.calls and compat.calls[0]["status"] == "fail"
assert PRIMARY_BASE_URL_ENV in compat.calls[0]["error"]
assert PRIMARY_API_KEY_ENV in compat.calls[0]["error"]
assert "LITELLM_PROXY_BASE_URL" not in compat.calls[0]["error"]
def test_require_proxy_returns_config_when_primary_env_supplied() -> None:
cfg = require_proxy(
_CompatResultStub(),
env={
PRIMARY_BASE_URL_ENV: "http://localhost:4000",
PRIMARY_API_KEY_ENV: "sk-1234",
},
)
assert cfg == ProxyConfig("http://localhost:4000", "sk-1234")
class TestControlGatewayFollowsResolvedProxy:
"""The session fixture that registers the compat deployments must talk
to the *same* proxy the cells do.
Building its Gateway off ``e2e_config``'s own env read instead of the
resolved ``ProxyConfig`` would send ``/model/new`` to whatever
``e2e_config`` defaults to when the process env is empty, while the
cells drive a different host so registration silently lands
somewhere else and every cell 400s with "Invalid model name".
"""
RESOLVED = ProxyConfig("http://eks-alb.internal:4000", "sk-eks")
def _gateway(self):
from claude_code.conftest import _build_control_gateway
return _build_control_gateway(self.RESOLVED)
def test_management_calls_go_to_the_resolved_host_and_key(self) -> None:
control = self._gateway().transport.control
assert control.base_url == self.RESOLVED.base_url
assert control.master_key == self.RESOLVED.api_key
def test_both_planes_share_the_one_address_the_cells_use(self) -> None:
"""The deployment is fronted by a single address that routes
management and LLM paths itself, so a resolved proxy pins both."""
transport = self._gateway().transport
assert transport.data.base_url == self.RESOLVED.base_url
assert transport.data.master_key == self.RESOLVED.api_key
assert transport.control.base_url == transport.data.base_url
def test_require_proxy_leaves_compat_result_untouched_on_success() -> None:
"""A successful resolution must NOT append a spurious fail entry.
Would have silently poisoned every compat cell's result rows."""
compat = _CompatResultStub()
require_proxy(
compat,
env={
PRIMARY_BASE_URL_ENV: "http://localhost:4000",
PRIMARY_API_KEY_ENV: "sk-1234",
},
)
assert compat.calls == []

View file

@ -1,95 +0,0 @@
"""Pin: the cron `pytest` invocation must run under `env -i`.
The systemd service `litellm-compat-matrix.service` loads provider
credentials (`ANTHROPIC_API_KEY`, `AWS_BEARER_TOKEN_BEDROCK`,
`AZURE_FOUNDRY_API_KEY`, `VERTEXAI_*`) and the agent-shin GitHub token
(`AGENT_SHIN_GITHUB_TOKEN`) into `run_daily.sh`'s environment from
`/etc/litellm-compat-matrix.env`. Pytest only needs to talk to the
loopback proxy at `127.0.0.1:${PROXY_PORT}` and has no legitimate reason
to see provider creds in its own `os.environ`. Leaving them in would
let a test under `tests/e2e/claude_code/` read them via `os.environ` and
exfiltrate them, and would also let a model-directed `Read` tool call
during a PDF/vision cell reach `/proc/<pytest-pid>/environ`. The
PR-gate's pytest step in `.circleci/config.yml` already runs under
`env -i`; this pin enforces the same scrub on the cron path.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[4]
RUN_DAILY = REPO_ROOT / "tests" / "e2e" / "claude_code" / "cron_vm" / "run_daily.sh"
def _pytest_invocation_block() -> str:
"""Return only the executable lines around the pytest invocation.
Comment text in run_daily.sh explains *why* certain credential
names must not appear, so a naïve substring scan over the whole
region would false-positive on the rationale itself. Strip lines
whose first non-space character is `#`.
"""
body = RUN_DAILY.read_text()
start = body.index('log "running pytest"')
end = body.index("PYTEST_EXIT=$?", start)
return "\n".join(
line for line in body[start:end].splitlines()
if line.lstrip()[:1] != "#"
)
def test_pytest_invocation_wraps_in_env_i() -> None:
block = _pytest_invocation_block()
assert "env -i" in block, (
"run_daily.sh: the pytest invocation must run under `env -i` so "
"PR-controlled test code under tests/e2e/claude_code/ cannot read "
"provider/agent-shin credentials out of the systemd service "
"environment, and so a model-directed `Read` tool call cannot "
"reach /proc/<pytest-pid>/environ to pull them out."
)
assert block.index("env -i") < block.index('"${WORKTREE_UV}" run pytest'), (
"run_daily.sh: `env -i` must precede the pytest invocation; "
"otherwise pytest inherits the full credential-bearing env."
)
def test_pytest_invocation_env_i_excludes_provider_secrets() -> None:
block = _pytest_invocation_block()
for forbidden in (
"ANTHROPIC_API_KEY",
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"VERTEXAI_CREDENTIALS",
"VERTEXAI_PROJECT",
"VERTEXAI_LOCATION",
"AZURE_FOUNDRY_API_KEY",
"AZURE_FOUNDRY_API_BASE",
"GITHUB_TOKEN",
"AGENT_SHIN_GITHUB_TOKEN",
):
assert forbidden not in block, (
f"run_daily.sh: the pytest-step `env -i` allowlist must not "
f"pass {forbidden} through. Found it inside the pytest "
f"invocation block."
)
def test_pytest_invocation_passes_proxy_url_and_key_explicitly() -> None:
block = _pytest_invocation_block()
assert "LITELLM_PROXY_BASE_URL=" in block, (
"run_daily.sh: the pytest `env -i` block must still pass "
"LITELLM_PROXY_BASE_URL so the test suite knows where to find "
"the loopback proxy."
)
assert "LITELLM_PROXY_API_KEY=" in block, (
"run_daily.sh: the pytest `env -i` block must still pass "
"LITELLM_PROXY_API_KEY so the test suite can authenticate to "
"the loopback proxy."
)
assert "COMPAT_RESULTS_PATH=" in block, (
"run_daily.sh: the pytest `env -i` block must still pass "
"COMPAT_RESULTS_PATH so the conftest writes the per-cell "
"tagged-union artifact to the script-managed path."
)

View file

@ -1,289 +0,0 @@
"""Regression tests for the GitHub release pagination in `run_daily.sh`.
The cron job resolves "newest LiteLLM v*-stable" via the GitHub Releases
API. A previous version of the loop broke as soon as the current page
contained ANY v*-stable tag. The Releases endpoint orders by
`created_at`, NOT by semver, so a backport on an older series cut today
(e.g. v1.80.1-stable) can land on an earlier page than a higher-version
release cut two weeks ago (e.g. v1.83.0-stable). The early-break would
silently pin the cron to a stale tag because the higher-version release
on a later page never made it into the merged set the final `sort_by`
consumed.
These tests pin two things:
1. The buggy early-break-on-first-stable pattern must not return.
2. The loop still terminates early on the standard "empty page" guard
so a quiet release feed doesn't burn API quota.
The shell loop itself is exercised end-to-end with a fake `curl` that
serves canned page JSON, demonstrating that the resolved tag is the
highest-semver stable across all pages even when the highest tag lives
on page 2+.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[4]
RUN_DAILY = REPO_ROOT / "tests" / "e2e" / "claude_code" / "cron_vm" / "run_daily.sh"
# The extracted snippet starts AFTER `log`/`die` are defined in run_daily.sh,
# so the test harness has to provide its own stubs. Without them, a failure
# inside the snippet (e.g. jq returning an empty LITELLM_VERSION) would crash
# with `bash: die: command not found` (exit 127) instead of the intended
# diagnostic, making test failures unnecessarily hard to debug.
_PREAMBLE = (
"set -Eeuo pipefail\n"
"log() { printf '==> %s\\n' \"$*\" >&2; }\n"
"die() { printf 'ERROR: %s\\n' \"$*\" >&2; exit 1; }\n"
)
def test_run_daily_does_not_early_break_on_first_stable_page() -> None:
"""The regex pattern `select(test("...stable$"))] | length > 0` followed
by `break` is exactly the buggy early-stop. If it ever returns the
cron will silently start testing against a stale stable tag.
"""
body = RUN_DAILY.read_text()
assert (
"length > 0" not in body
or "break" not in body
or (
# If both substrings exist, make sure they aren't both inside the
# same release-pagination loop. The current loop only contains
# a `break` for the empty-page guard, not for any "length > 0"
# condition.
not _shares_loop_body(body, "length > 0", "break")
)
), (
"run_daily.sh contains the old early-break-on-stable pattern. The "
"Releases endpoint orders by created_at, not semver, so breaking "
"on first-stable-seen can miss higher-versioned releases sitting "
"on later pages."
)
def _shares_loop_body(body: str, needle_a: str, needle_b: str) -> bool:
"""Heuristic: do both needles live inside a `for page in ...; do ... done`
block? Used as a defensive guard for the static check above."""
in_loop = False
saw_a = False
saw_b = False
for line in body.splitlines():
stripped = line.strip()
if stripped.startswith("for page in"):
in_loop = True
saw_a = False
saw_b = False
continue
if in_loop and stripped == "done":
if saw_a and saw_b:
return True
in_loop = False
continue
if in_loop:
if needle_a in line:
saw_a = True
if needle_b in line:
saw_b = True
return False
def test_run_daily_keeps_empty_page_break_guard() -> None:
"""The empty-page break is the only break that should remain in the
pagination loop without it a quiet release feed wastes API quota
walking past the last real page."""
body = RUN_DAILY.read_text()
assert "jq 'length' \"${PAGE_JSON}\"" in body, (
"run_daily.sh must still detect empty pages via `jq 'length' "
"${PAGE_JSON}`; without this the loop walks the full 5-page cap "
"even when there are no more releases."
)
assert (
'== "0"' in body
), 'The empty-page guard must compare jq\'s length output to "0".'
def _make_fake_curl(scratch: Path, pages: dict[int, str]) -> Path:
"""Build a fake `curl` shim that serves the canned page JSON for
each `page=N` request and an empty array for any page past the
last canned one.
The shim mimics just enough of curl's CLI surface for the cron
script: it accepts the headers + URL we pass, ignores everything
we don't need, and writes the canned body to either stdout or the
--output target if one is given.
"""
pages_dir = scratch / "pages"
pages_dir.mkdir()
for page_num, body in pages.items():
(pages_dir / f"page{page_num}.json").write_text(body)
curl_path = scratch / "curl"
curl_path.write_text(
textwrap.dedent(
f"""\
#!/usr/bin/env bash
# Fake curl for run_daily.sh release pagination tests. Serves
# page JSON from {pages_dir} keyed by the `page=` query value,
# and returns "[]" for pages past the last canned one (which
# is exactly how the real GitHub API behaves past the end).
url=""
output=""
while [[ $# -gt 0 ]]; do
case "$1" in
-fsS|-fsSL|-H|-o|--output)
if [[ "$1" == "-o" || "$1" == "--output" ]]; then
output="$2"; shift 2
elif [[ "$1" == "-H" ]]; then
shift 2
else
shift
fi
;;
http*)
url="$1"; shift
;;
*)
shift
;;
esac
done
page="$(printf '%s' "$url" | sed -n 's/.*[?&]page=\\([0-9]*\\).*/\\1/p')"
[[ -z "$page" ]] && page=1
file="{pages_dir}/page${{page}}.json"
if [[ -f "$file" ]]; then
if [[ -n "$output" ]]; then cp "$file" "$output"; else cat "$file"; fi
else
if [[ -n "$output" ]]; then printf '[]' > "$output"; else printf '[]'; fi
fi
"""
)
)
curl_path.chmod(0o755)
return curl_path
def _extract_resolution_snippet() -> str:
"""Pull the pagination + sort_by + assignment block out of run_daily.sh
so the test exercises the actual production code path (not a copy).
The block is everything from the GH_AUTH_HEADER setup down through
the LITELLM_VERSION emission.
"""
body = RUN_DAILY.read_text()
start = body.index("GH_AUTH_HEADER=()")
end = body.index('log "resolved litellm:')
return body[start:end]
@pytest.mark.skipif(shutil.which("jq") is None, reason="jq not available")
def test_run_daily_resolves_highest_semver_across_pages(tmp_path: Path) -> None:
"""End-to-end: drive the actual run_daily.sh pagination loop with a
fake curl whose page 1 contains a freshly-cut LOW-version backport
(v1.80.1-stable) and page 2 contains a two-weeks-old HIGH-version
release (v1.83.0-stable). The correct behavior is to resolve
v1.83.0-stable. The pre-fix behavior would resolve v1.80.1-stable
because the early-break consumed only page 1.
"""
pages = {
# Page 1: most-recently-created releases. The order here matches
# what /releases?page=1 returns: created-at descending. The
# freshly-cut v1.80.1-stable backport sits at the top, plus a
# bunch of non-stable releases.
1: """[
{"tag_name": "v1.84.0-nightly.1"},
{"tag_name": "v1.80.1-stable"},
{"tag_name": "v1.84.0-nightly.0"}
]""",
# Page 2: older releases. The HIGHER-version stable lives here
# because it was cut two weeks ago, before the v1.80.1 backport.
2: """[
{"tag_name": "v1.83.0-rc.5"},
{"tag_name": "v1.83.0-stable"},
{"tag_name": "v1.82.4-stable"}
]""",
# Page 3+: empty -> the loop's empty-page guard fires here.
}
fake_curl_dir = tmp_path / "shim"
fake_curl_dir.mkdir()
_make_fake_curl(fake_curl_dir, pages)
workdir = tmp_path / "work"
workdir.mkdir()
snippet = _extract_resolution_snippet()
script = (
_PREAMBLE
+ f"WORKDIR={workdir!s}\n"
+ snippet
+ 'printf "%s" "${LITELLM_VERSION}"\n'
)
env = {
**os.environ,
"PATH": f"{fake_curl_dir}:{os.environ.get('PATH', '')}",
}
# Make sure the loop hits the fake curl, not the system one.
env.pop("GITHUB_TOKEN", None)
result = subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
env=env,
check=True,
)
assert result.stdout == "v1.83.0-stable", (
f"Expected the highest-semver stable across pages 1-2, got "
f"{result.stdout!r}. stderr={result.stderr!r}"
)
@pytest.mark.skipif(shutil.which("jq") is None, reason="jq not available")
def test_run_daily_terminates_on_empty_page(tmp_path: Path) -> None:
"""The empty-page guard must fire so we don't always walk all 5
pages. With a single populated page and an empty page 2 we should
stop after fetching page 2 (the first empty response)."""
pages = {1: '[{"tag_name": "v1.50.0-stable"}]'}
fake_curl_dir = tmp_path / "shim"
fake_curl_dir.mkdir()
_make_fake_curl(fake_curl_dir, pages)
workdir = tmp_path / "work"
workdir.mkdir()
snippet = _extract_resolution_snippet()
script = (
_PREAMBLE
+ f"WORKDIR={workdir!s}\n"
+ snippet
+ 'printf "%s" "${LITELLM_VERSION}"\n'
)
env = {
**os.environ,
"PATH": f"{tmp_path}/shim:{os.environ.get('PATH', '')}",
}
env.pop("GITHUB_TOKEN", None)
result = subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
env=env,
check=True,
)
assert result.stdout == "v1.50.0-stable"
# Only pages 1 and 2 should have been fetched (2 is empty -> break).
assert (workdir / "releases.page2.json").exists()
assert not (workdir / "releases.page3.json").exists(), (
"Empty-page guard didn't fire — the loop kept walking past the "
"first empty response."
)

View file

@ -1,92 +0,0 @@
"""Pin: the cron `claude --version` probe must run under `env -i`.
The systemd service `litellm-compat-matrix.service` loads provider
credentials (`ANTHROPIC_API_KEY`, `AWS_BEARER_TOKEN_BEDROCK`,
`AZURE_FOUNDRY_API_KEY`) and the agent-shin GitHub token
(`AGENT_SHIN_GITHUB_TOKEN`) into `run_daily.sh`'s environment from
`/etc/litellm-compat-matrix.env`. Running the npm-installed `claude`
binary directly there would hand that full env to package code, so a
compromised `@anthropic-ai/claude-code` release could read those
secrets out of `os.environ` before the proxy or test harness ever
starts. The version probe must be wrapped in `env -i` with a minimal
PATH/HOME/USER/TERM/LANG/LC_ALL/TMPDIR allowlist matching the
PR-gate's resolver/npm-install/pytest scrubs.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[4]
RUN_DAILY = REPO_ROOT / "tests" / "e2e" / "claude_code" / "cron_vm" / "run_daily.sh"
def _version_probe_block() -> str:
body = RUN_DAILY.read_text()
start = body.index("CLAUDE_CODE_VERSION=")
end = body.index('[[ -n "${CLAUDE_CODE_VERSION}" ]]', start)
return body[start:end]
def test_version_probe_wraps_claude_in_env_i() -> None:
block = _version_probe_block()
assert "env -i" in block, (
"run_daily.sh: the `claude --version` probe must run under "
"`env -i` so a compromised @anthropic-ai/claude-code package "
"cannot read provider/GitHub credentials out of the systemd "
"service environment."
)
assert block.index("env -i") < block.index("claude --version"), (
"run_daily.sh: `env -i` must precede `claude --version`; "
"otherwise the binary inherits the full credential-bearing env."
)
def test_version_probe_env_i_excludes_provider_secrets() -> None:
block = _version_probe_block()
for forbidden in (
"ANTHROPIC_API_KEY",
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"VERTEXAI_CREDENTIALS",
"AZURE_FOUNDRY_API_KEY",
"GITHUB_TOKEN",
"AGENT_SHIN_GITHUB_TOKEN",
):
assert forbidden not in block, (
f"run_daily.sh: the version-probe `env -i` allowlist must "
f"not pass {forbidden} through. Found it inside the probe "
f"block."
)
def test_version_probe_uses_isolated_home_not_runtime_user_home() -> None:
"""Pin: the `claude --version` probe runs under a fresh empty HOME.
`ProtectHome=read-only` in the systemd unit allows reads of the
runtime user's real home directory. If the probe's `env -i`
block forwards `HOME=${HOME}`, a compromised `claude` package
can `os.path.expanduser("~/.config/gh/hosts.yml")` or
`os.path.expanduser("~/.ssh/...")` and exfiltrate the contents
before the proxy or test harness ever starts. The probe must
point HOME at a per-run tmpdir under `${WORKDIR}` so the CLI
sees an empty HOME instead.
"""
block = _version_probe_block()
body = RUN_DAILY.read_text()
assert "CLAUDE_PROBE_HOME=" in body, (
"run_daily.sh: must define a `CLAUDE_PROBE_HOME` per-run tmpdir "
"for the `claude --version` probe so the CLI never sees the "
"runtime user's real $HOME."
)
assert 'HOME="${CLAUDE_PROBE_HOME}"' in block, (
"run_daily.sh: the probe's `env -i` block must set HOME to "
"the per-run isolated tmpdir, not to the runtime user's $HOME."
)
assert 'HOME="${HOME}"' not in block, (
"run_daily.sh: the probe's `env -i` block must not forward the "
"runtime user's $HOME to `claude --version`. Use the isolated "
"$CLAUDE_PROBE_HOME tmpdir instead."
)

View file

@ -1,104 +0,0 @@
"""Pin: the cron systemd unit hides credential-bearing dotdirs.
`ProtectHome=read-only` blocks writes to /home/mateo but still allows
reads. A model-directed `Read` tool call (the PDF cells pass
`--allowed-tools Read` to the `claude` CLI) or a compromised
`@anthropic-ai/claude-code` package can read absolute paths under
the runtime user's home and exfiltrate the contents — even with the
per-`claude`-invocation HOME isolation in place, because absolute
paths bypass `~`-expansion.
This file pins the second line of defense: the systemd unit lists
the credential-bearing dotdirs (`~/.config/gh`, `~/.ssh`, `~/.aws`,
`~/.docker`, `~/.kube`, `~/.gnupg`) under `InaccessiblePaths=` so
the kernel hides them from every process in the unit's mount
namespace, including any child of `claude --version` or the pytest
run. It also pins that `~/.config/gh` is *not* in `ReadWritePaths=`
we pass `GH_TOKEN` inline to every `gh` invocation in
`run_daily.sh`, so the host gh-cli config is unused.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[4]
SERVICE = (
REPO_ROOT / "tests" / "e2e" / "claude_code" / "cron_vm" / "litellm-compat-matrix.service"
)
def _service_text() -> str:
return SERVICE.read_text()
def _directive(name: str) -> str:
"""Return the value of a single-line systemd directive (or empty)."""
text = _service_text()
match = re.search(rf"^\s*{re.escape(name)}\s*=\s*(.*)$", text, re.MULTILINE)
return match.group(1).strip() if match else ""
def test_inaccessible_paths_hides_credential_dotdirs() -> None:
"""Every credential-bearing dotdir must be under `InaccessiblePaths=`."""
inaccessible = _directive("InaccessiblePaths")
assert inaccessible, (
"litellm-compat-matrix.service: must declare `InaccessiblePaths=` "
"to hide credential dotdirs from the `claude` subprocess and the "
"model-directed Read tool. Without this, an absolute-path read "
"like `Read('/home/mateo/.config/gh/hosts.yml')` exfiltrates "
"the gh-cli token despite the per-invocation HOME isolation."
)
for path in (
"/home/mateo/.config/gh",
"/home/mateo/.ssh",
"/home/mateo/.aws",
"/home/mateo/.docker",
"/home/mateo/.kube",
"/home/mateo/.gnupg",
):
# Tolerated `-` prefix means "ignore if missing on host".
assert path in inaccessible, (
f"litellm-compat-matrix.service: `{path}` must appear in "
f"`InaccessiblePaths=` so the cron `claude` subprocess can "
f"never read it (even via an absolute path that bypasses "
f"the per-invocation HOME override)."
)
def test_gh_config_is_not_writeable() -> None:
"""`~/.config/gh` is not whitelisted under `ReadWritePaths=`.
We pass `GH_TOKEN` inline to every `gh` invocation in
`run_daily.sh` (`gh repo clone`, `gh pr create`, `gh pr edit`).
The host `~/.config/gh/hosts.yml` is therefore never consulted
or written to. Keeping it out of `ReadWritePaths=` is the second
line of defense: a future regression that drops the inline-token
convention will fail loudly (gh writes a new login config and
hits a read-only filesystem) rather than silently re-introduce
the credential exfiltration surface that
`InaccessiblePaths=/home/mateo/.config/gh` is closing.
"""
rw = _directive("ReadWritePaths")
assert ".config/gh" not in rw, (
"litellm-compat-matrix.service: `/home/mateo/.config/gh` must "
"*not* appear in `ReadWritePaths=`. We pass `GH_TOKEN` inline "
"to every `gh` invocation in run_daily.sh, so the host gh-cli "
"config is never consulted or written to. Keeping the path out "
"of ReadWritePaths means a future regression that drops the "
"inline-token convention will fail loudly instead of silently "
"re-opening the credential exfiltration surface that "
"`InaccessiblePaths=` is closing."
)
def test_protect_home_is_read_only_or_stricter() -> None:
"""`ProtectHome=` must be at least `read-only`."""
value = _directive("ProtectHome")
assert value in ("read-only", "tmpfs", "yes", "true"), (
f"litellm-compat-matrix.service: `ProtectHome=` must be `read-only`, "
f"`tmpfs`, or `yes`. Got: {value!r}. Without this, the unit can "
f"write anywhere under /home/mateo, including overwriting "
f"~/.config/gh/hosts.yml."
)

View file

@ -20,6 +20,7 @@ the matrix builder still sees three rows for this (feature, provider).
from __future__ import annotations
import pytest
from claude_code._basic_messaging import run_basic_messaging_cell
# Per the PRD: each cell is exercised against three Claude tiers via the
@ -27,11 +28,12 @@ from claude_code._basic_messaging import run_basic_messaging_cell
# routing config; the driver only sends the alias.
ANTHROPIC_MODELS = [
"claude-haiku-4-5",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-opus-4-7",
]
@pytest.mark.covers("llm.messages.anthropic.basic.nonstream.works")
def test_basic_messaging_non_streaming_anthropic(compat_result):
"""Drive the `claude` CLI against the LiteLLM proxy and assert a reply.

View file

@ -25,6 +25,7 @@ the matrix builder still sees three rows for this (feature, provider).
from __future__ import annotations
import pytest
from claude_code._basic_messaging import run_basic_messaging_cell
# Per-model aliases registered in the LiteLLM proxy's routing config to
@ -33,11 +34,12 @@ from claude_code._basic_messaging import run_basic_messaging_cell
# resource URL and API key.
AZURE_MODELS = [
"claude-haiku-4-5-azure",
"claude-sonnet-4-6-azure",
"claude-sonnet-4-5-azure",
"claude-opus-4-7-azure",
]
@pytest.mark.covers("llm.messages.azure_foundry.basic.nonstream.works")
def test_basic_messaging_non_streaming_azure(compat_result):
"""Drive the `claude` CLI against the LiteLLM proxy and assert a reply.

View file

@ -20,6 +20,7 @@ the matrix builder still sees three rows for this (feature, provider).
from __future__ import annotations
import pytest
from claude_code._basic_messaging import run_basic_messaging_cell
# Per-model aliases registered in the LiteLLM proxy's routing config to
@ -28,11 +29,12 @@ from claude_code._basic_messaging import run_basic_messaging_cell
# strategy.
BEDROCK_CONVERSE_MODELS = [
"claude-haiku-4-5-bedrock-converse",
"claude-sonnet-4-6-bedrock-converse",
"claude-sonnet-4-5-bedrock-converse",
"claude-opus-4-7-bedrock-converse",
]
@pytest.mark.covers("llm.messages.bedrock_converse.basic.nonstream.works")
def test_basic_messaging_non_streaming_bedrock_converse(compat_result):
"""Drive the `claude` CLI against the LiteLLM proxy and assert a reply."""
run_basic_messaging_cell(

View file

@ -20,6 +20,7 @@ the matrix builder still sees three rows for this (feature, provider).
from __future__ import annotations
import pytest
from claude_code._basic_messaging import run_basic_messaging_cell
# Per-model aliases registered in the LiteLLM proxy's routing config to
@ -28,11 +29,12 @@ from claude_code._basic_messaging import run_basic_messaging_cell
# routing strategy.
BEDROCK_INVOKE_MODELS = [
"claude-haiku-4-5-bedrock-invoke",
"claude-sonnet-4-6-bedrock-invoke",
"claude-sonnet-4-5-bedrock-invoke",
"claude-opus-4-7-bedrock-invoke",
]
@pytest.mark.covers("llm.messages.bedrock_invoke.basic.nonstream.works")
def test_basic_messaging_non_streaming_bedrock_invoke(compat_result):
"""Drive the `claude` CLI against the LiteLLM proxy and assert a reply."""
run_basic_messaging_cell(

View file

@ -20,6 +20,7 @@ the matrix builder still sees three rows for this (feature, provider).
from __future__ import annotations
import pytest
from claude_code._basic_messaging import run_basic_messaging_cell
# Per-model aliases registered in the LiteLLM proxy's routing config to
@ -28,11 +29,12 @@ from claude_code._basic_messaging import run_basic_messaging_cell
# model id and the GCP region.
VERTEX_AI_MODELS = [
"claude-haiku-4-5-vertex",
"claude-sonnet-4-6-vertex",
"claude-sonnet-4-5-vertex",
"claude-opus-4-7-vertex",
]
@pytest.mark.covers("llm.messages.vertex.basic.nonstream.works")
def test_basic_messaging_non_streaming_vertex_ai(compat_result):
"""Drive the `claude` CLI against the LiteLLM proxy and assert a reply."""
run_basic_messaging_cell(

View file

@ -25,15 +25,17 @@ sees three rows for this (feature, provider).
from __future__ import annotations
import pytest
from claude_code._basic_messaging import run_basic_messaging_cell
ANTHROPIC_MODELS = [
"claude-haiku-4-5",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-opus-4-7",
]
@pytest.mark.covers("llm.messages.anthropic.basic.stream.works")
def test_basic_messaging_streaming_anthropic(compat_result):
"""Drive the `claude` CLI against the LiteLLM proxy and assert a
non-empty streamed reply (one row per Claude tier).

View file

@ -19,15 +19,17 @@ The (feature, provider) for this cell is inferred from the file path by
from __future__ import annotations
import pytest
from claude_code._basic_messaging import run_basic_messaging_cell
AZURE_MODELS = [
"claude-haiku-4-5-azure",
"claude-sonnet-4-6-azure",
"claude-sonnet-4-5-azure",
"claude-opus-4-7-azure",
]
@pytest.mark.covers("llm.messages.azure_foundry.basic.stream.works")
def test_basic_messaging_streaming_azure(compat_result):
"""Drive the `claude` CLI against the LiteLLM proxy and assert a
non-empty streamed reply (one row per Claude tier).

View file

@ -15,15 +15,17 @@ The (feature, provider) for this cell is inferred from the file path by
from __future__ import annotations
import pytest
from claude_code._basic_messaging import run_basic_messaging_cell
BEDROCK_CONVERSE_MODELS = [
"claude-haiku-4-5-bedrock-converse",
"claude-sonnet-4-6-bedrock-converse",
"claude-sonnet-4-5-bedrock-converse",
"claude-opus-4-7-bedrock-converse",
]
@pytest.mark.covers("llm.messages.bedrock_converse.basic.stream.works")
def test_basic_messaging_streaming_bedrock_converse(compat_result):
"""Drive the `claude` CLI against the LiteLLM proxy and assert a
non-empty streamed reply (one row per Claude tier).

View file

@ -15,15 +15,17 @@ The (feature, provider) for this cell is inferred from the file path by
from __future__ import annotations
import pytest
from claude_code._basic_messaging import run_basic_messaging_cell
BEDROCK_INVOKE_MODELS = [
"claude-haiku-4-5-bedrock-invoke",
"claude-sonnet-4-6-bedrock-invoke",
"claude-sonnet-4-5-bedrock-invoke",
"claude-opus-4-7-bedrock-invoke",
]
@pytest.mark.covers("llm.messages.bedrock_invoke.basic.stream.works")
def test_basic_messaging_streaming_bedrock_invoke(compat_result):
"""Drive the `claude` CLI against the LiteLLM proxy and assert a
non-empty streamed reply (one row per Claude tier).

View file

@ -15,15 +15,17 @@ The (feature, provider) for this cell is inferred from the file path by
from __future__ import annotations
import pytest
from claude_code._basic_messaging import run_basic_messaging_cell
VERTEX_AI_MODELS = [
"claude-haiku-4-5-vertex",
"claude-sonnet-4-6-vertex",
"claude-sonnet-4-5-vertex",
"claude-opus-4-7-vertex",
]
@pytest.mark.covers("llm.messages.vertex.basic.stream.works")
def test_basic_messaging_streaming_vertex_ai(compat_result):
"""Drive the `claude` CLI against the LiteLLM proxy and assert a
non-empty streamed reply (one row per Claude tier).

View file

@ -169,8 +169,9 @@ def _manifest_feature_ids() -> FrozenSet[str]:
Used as a positive filter so only directories that correspond to a
real matrix row contribute results utility/support directories
(e.g. `cron_vm`, `_driver_unit_tests`) are dropped regardless of
naming convention, and the rate-limit summary stays clean.
(e.g. `_driver_unit_tests`, `_builder_unit_tests`) are dropped
regardless of naming convention, and the rate-limit summary stays
clean.
Returns an empty set if the manifest is missing or malformed; the
caller treats that as "no path is a feature path", which is the
@ -199,11 +200,10 @@ def _infer_feature_and_provider(node_path: Path) -> Optional[tuple]:
Path shape: tests/e2e/claude_code/<feature_id>/test_<provider>.py
Returns None if the file is not a per-feature test (e.g. unit tests
under `_driver_unit_tests/` or support code under `cron_vm/`), so
those don't pollute the matrix artifact. We positively filter the
parent directory against `manifest.yaml` rather than relying on
naming conventions, because non-feature siblings don't all share
an underscore prefix.
under `_driver_unit_tests/`), so those don't pollute the matrix
artifact. We positively filter the parent directory against
`manifest.yaml` rather than relying on naming conventions, because
non-feature siblings don't all share an underscore prefix.
"""
name = node_path.name
if not name.startswith("test_") or not name.endswith(".py"):
@ -548,3 +548,117 @@ def pytest_sessionfinish(session, exitstatus):
)
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True))
_print_rate_limit_summary(summary)
# ---------------------------------------------------------------------------
# Session-scoped compat model registration.
#
# The compat cells probe hardcoded virtual names like ``claude-sonnet-4-5``
# and ``claude-sonnet-4-5-bedrock-invoke``. On stage those live in the
# gateway's model_list at deploy time; locally the docker-config.yaml
# under tests/e2e/ only declares one of them, so every non-haiku cell
# 400s with ``Invalid model name``. The fixture here reconciles the two:
# it reads ``test_config.yaml`` (the ground-truth compat matrix config)
# and POSTs ``/model/new`` for the subset whose provider credentials are
# actually set in the current environment, then tears them all down at
# session end.
#
# Kept below the rest of the conftest so the compat-artifact hooks stay
# grouped up top. The fixture is opt-in via autouse=True on the session
# scope, so a cell that hits the proxy sees the deployment ready without
# any per-cell wiring, and pure unit tests that never reach the proxy
# pay only one skipped-liveness check.
# ---------------------------------------------------------------------------
from claude_code._env import ProxyConfig, resolve_proxy # noqa: E402
from claude_code._compat_models import ( # noqa: E402
CompatDeployment,
load_all_deployments,
)
def _build_control_gateway(proxy: ProxyConfig):
"""Local import of the shared harness so the pure-unit-test tree
under ``_driver_unit_tests/`` etc. never has to pull it in. The
control plane transport is what /model/new lives on; SplitTransport
routes it correctly for both monolithic and split deployments.
The endpoints come from the *resolved* proxy, not from a second
independent env read, so registration and the cells always hit the
same host and key. Both planes get the one URL the cells use; the
deployment is fronted by a single address that routes management
and LLM paths itself."""
from e2e_gateway import build_gateway
return build_gateway(
base_url=proxy.base_url,
master_key=proxy.api_key,
control_plane_base_url=proxy.base_url,
)
def _register_deployment(gateway, deployment: CompatDeployment) -> str:
"""Register one deployment and return its proxy-assigned model_id
once it is servable on the data plane."""
return gateway.create_model(
deployment.model_name,
deployment.litellm_params,
)
@pytest.fixture(scope="session", autouse=True)
def _compat_models_registered() -> Any:
"""Register every compat deployment against the running proxy, then
tear them all down on session exit.
Skips silently if the proxy env is not configured (no
``LITELLM_PROXY_URL``/``LITELLM_MASTER_KEY``) so unit-test runs
stay hermetic.
Design note: we always attempt to register all 15 deployments,
regardless of what credentials are exported in the test-runner's
shell. The credentials live in the proxy container's environment
(via docker-compose ``env_file``), not the shell running pytest -
so gating on shell env would filter out deployments the proxy can
actually serve. Per-deployment ``/model/new`` failures are printed
but do not abort the session: the cells that need that specific
deployment will 400 with "Invalid model name" and fail loudly,
which is the right signal (missing cred on the proxy side)."""
proxy = resolve_proxy()
if proxy is None:
yield
return
from requests import RequestException
gateway = _build_control_gateway(proxy)
registered_ids: list[str] = []
failures: list[tuple[str, str]] = []
try:
for deployment in load_all_deployments():
try:
model_id = _register_deployment(gateway, deployment)
registered_ids.append(model_id)
except (AssertionError, RequestException) as exc:
failures.append((deployment.model_name, str(exc)))
if failures:
summary = "\n".join(
f" - {name}: {reason}" for name, reason in failures
)
print(
f"[compat fixture] {len(failures)} of "
f"{len(failures) + len(registered_ids)} deployments "
f"failed to register (proxy likely missing that provider's "
f"credentials); cells that target them will fail loudly:\n"
f"{summary}"
)
yield
finally:
for model_id in registered_ids:
try:
gateway.delete_model(model_id)
except (AssertionError, RequestException):
# Best-effort — teardown surfaces via warnings inside
# ``delete_model`` already; swallowing here so one flaky
# delete does not mask real test failures.
pass

View file

@ -37,44 +37,27 @@ CLI rows isn't useful here.
from __future__ import annotations
import os
import pytest
from claude_code._env import require_proxy
from claude_code.http_probe import (
assert_count_tokens_shape,
probe_count_tokens,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
ANTHROPIC_MODELS = [
"claude-haiku-4-5",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-opus-4-7",
]
@pytest.mark.covers("llm.messages.anthropic.count_tokens.nonstream.works")
def test_count_tokens_anthropic(compat_result):
"""Probe `/v1/messages/count_tokens` for each Anthropic tier and
assert the response shape."""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
base_url, api_key = require_proxy(compat_result)
failures = []
for model in ANTHROPIC_MODELS:

View file

@ -37,44 +37,27 @@ CLI rows isn't useful here.
from __future__ import annotations
import os
import pytest
from claude_code._env import require_proxy
from claude_code.http_probe import (
assert_count_tokens_shape,
probe_count_tokens,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
AZURE_MODELS = [
"claude-haiku-4-5-azure",
"claude-sonnet-4-6-azure",
"claude-sonnet-4-5-azure",
"claude-opus-4-7-azure",
]
@pytest.mark.covers("llm.messages.azure_foundry.count_tokens.nonstream.works")
def test_count_tokens_azure(compat_result):
"""Probe `/v1/messages/count_tokens` for each Azure (Microsoft Foundry) tier and
assert the response shape."""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
base_url, api_key = require_proxy(compat_result)
failures = []
for model in AZURE_MODELS:

View file

@ -37,44 +37,27 @@ CLI rows isn't useful here.
from __future__ import annotations
import os
import pytest
from claude_code._env import require_proxy
from claude_code.http_probe import (
assert_count_tokens_shape,
probe_count_tokens,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
BEDROCK_CONVERSE_MODELS = [
"claude-haiku-4-5-bedrock-converse",
"claude-sonnet-4-6-bedrock-converse",
"claude-sonnet-4-5-bedrock-converse",
"claude-opus-4-7-bedrock-converse",
]
@pytest.mark.covers("llm.messages.bedrock_converse.count_tokens.nonstream.works")
def test_count_tokens_bedrock_converse(compat_result):
"""Probe `/v1/messages/count_tokens` for each Bedrock (Converse) tier and
assert the response shape."""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
base_url, api_key = require_proxy(compat_result)
failures = []
for model in BEDROCK_CONVERSE_MODELS:

View file

@ -37,44 +37,27 @@ CLI rows isn't useful here.
from __future__ import annotations
import os
import pytest
from claude_code._env import require_proxy
from claude_code.http_probe import (
assert_count_tokens_shape,
probe_count_tokens,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
BEDROCK_INVOKE_MODELS = [
"claude-haiku-4-5-bedrock-invoke",
"claude-sonnet-4-6-bedrock-invoke",
"claude-sonnet-4-5-bedrock-invoke",
"claude-opus-4-7-bedrock-invoke",
]
@pytest.mark.covers("llm.messages.bedrock_invoke.count_tokens.nonstream.works")
def test_count_tokens_bedrock_invoke(compat_result):
"""Probe `/v1/messages/count_tokens` for each Bedrock (Invoke) tier and
assert the response shape."""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
base_url, api_key = require_proxy(compat_result)
failures = []
for model in BEDROCK_INVOKE_MODELS:

View file

@ -37,44 +37,27 @@ CLI rows isn't useful here.
from __future__ import annotations
import os
import pytest
from claude_code._env import require_proxy
from claude_code.http_probe import (
assert_count_tokens_shape,
probe_count_tokens,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
VERTEX_AI_MODELS = [
"claude-haiku-4-5-vertex",
"claude-sonnet-4-6-vertex",
"claude-sonnet-4-5-vertex",
"claude-opus-4-7-vertex",
]
@pytest.mark.covers("llm.messages.vertex.count_tokens.nonstream.works")
def test_count_tokens_vertex_ai(compat_result):
"""Probe `/v1/messages/count_tokens` for each Vertex AI tier and
assert the response shape."""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
base_url, api_key = require_proxy(compat_result)
failures = []
for model in VERTEX_AI_MODELS:

View file

@ -1,50 +0,0 @@
"""Tiny CLI wrapper around `claude_code.matrix_builder.build_from_paths`.
Exists only so `run_daily.sh` can hand the version metadata + paths into
the matrix builder without re-implementing it in bash. All real logic
lives in `matrix_builder.py`, which has its own unit tests under
`_builder_unit_tests/`.
Invoked from the cron worktree (where `uv sync` has installed pyyaml),
not the dev checkout the bash script `cd`s into the worktree before
`uv run python`-ing this file.
"""
from __future__ import annotations
import argparse
import datetime
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from claude_code.matrix_builder import build_from_paths # noqa: E402 # import needs the sys.path bootstrap above
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--results", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--litellm-version", required=True)
parser.add_argument("--claude-code-version", required=True)
args = parser.parse_args()
generated_at = datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
build_from_paths(
manifest_path=args.manifest,
results_path=args.results,
litellm_version=args.litellm_version,
claude_code_version=args.claude_code_version,
generated_at=generated_at,
output_path=args.output,
)
print(f"wrote {args.output}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,59 +0,0 @@
# Environment file consumed by `litellm-compat-matrix.service`.
#
# Install at `/etc/litellm-compat-matrix.env` and chmod 0600.
# `EnvironmentFile=-` in the unit means the service is allowed to start
# even if this file is missing, but the populator will fail at the
# first provider request without these credentials.
# Anthropic
ANTHROPIC_API_KEY=
# Bedrock (invoke + converse columns).
# Use Anthropic's Bedrock API-key passthrough (long-lived bearer token).
# No AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY required for the matrix --
# both the LiteLLM invoke and converse routes pick up
# AWS_BEARER_TOKEN_BEDROCK when present.
AWS_BEARER_TOKEN_BEDROCK=
AWS_REGION_NAME=us-east-1
# Vertex AI.
# On the GCP VM, the default service-account ADC from the metadata server
# is used -- no JSON key file is needed. If you ever need to run outside
# GCP, also export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json.
VERTEXAI_PROJECT=
VERTEXAI_LOCATION=global
# Microsoft Foundry (Azure column)
AZURE_FOUNDRY_API_KEY=
AZURE_FOUNDRY_API_BASE=
# Azure cell of the `passthrough` row. Foundry-mode Claude Code sends
# the model in the request body, so the proxy's /azure passthrough
# cannot resolve a router alias and falls back to these env vars.
# AZURE_API_BASE is the Foundry resource's Anthropic surface, i.e.
# https://<resource>.services.ai.azure.com/anthropic ; AZURE_API_KEY
# is the same key as AZURE_FOUNDRY_API_KEY.
AZURE_API_BASE=
AZURE_API_KEY=
# REQUIRED for publishing: PAT for the `agent-shin` user, used to push
# the daily compat-matrix branch to its fork (agent-shin/litellm-docs)
# and open the cross-repo PR against BerriAI/litellm-docs. Scopes:
# classic `repo` + `workflow`, or fine-grained on agent-shin/litellm-docs
# with Contents:RW + Pull requests:RW + Workflows:RW.
# Skip by setting SKIP_PUBLISH=1 (publishes nothing; only writes the
# matrix JSON locally).
AGENT_SHIN_GITHUB_TOKEN=
# Optional: lifts the unauthenticated rate limit on the GitHub Releases
# API used by `resolver.py`. Any token works (read-only). Not required.
# GITHUB_TOKEN=
# Optional overrides; defaults are sensible for the cron VM.
# PROXY_PORT=4100
# LITELLM_WORKTREE=/home/mateo/litellm-cron-worktree
# DOCS_REPO=BerriAI/litellm-docs
# DOCS_BRANCH=main
# DOCS_TARGET_PATH=src/data/compatibility-matrix.json
# FORK_OWNER=agent-shin
# FORK_REPO=agent-shin/litellm-docs

View file

@ -1,141 +0,0 @@
# systemd service for the Claude Code compatibility-matrix populator.
#
# Triggered by `litellm-compat-matrix.timer`; not started directly. The
# unit is a `Type=oneshot` so the timer's `OnCalendar=` semantics
# describe "run once per day" cleanly — there's no long-lived daemon to
# supervise; each invocation runs the populator end-to-end and exits.
#
# Install
# -------
#
# sudo cp tests/e2e/claude_code/cron_vm/litellm-compat-matrix.service /etc/systemd/system/
# sudo cp tests/e2e/claude_code/cron_vm/litellm-compat-matrix.timer /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now litellm-compat-matrix.timer
#
# Paths are hard-coded to /home/mateo rather than using systemd's %h
# specifier. Why: in *system* units (this one), %h is expanded at
# parse time against the *manager's* home -- which is /root for PID 1
# -- and *not* against the User= directive. That mismatch makes
# ReadWritePaths point at /root/.cache (which doesn't exist), causing
# the namespace setup to fail with status=226/NAMESPACE before the
# script ever runs. The runtime user (`User=mateo`) must:
#
# * have a checkout of `BerriAI/litellm` at `~/litellm/litellm` so the
# publisher module is importable;
# * have a uv venv at `~/litellm/litellm/.venv` (created by
# `uv sync --frozen` inside that checkout once);
# * have `gh` already authenticated against an account with
# `pull-requests: write` on `BerriAI/litellm-docs`;
# * have provider credentials exported in `/etc/litellm-compat-matrix.env`
# (see `litellm-compat-matrix.env.example` in this directory).
[Unit]
Description=Claude Code compatibility-matrix populator (oneshot)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=mateo
Group=mateo
# Provider credentials + any gh/PROXY_PORT overrides live here. Format
# is the standard `KEY=value` one line per env var.
EnvironmentFile=-/etc/litellm-compat-matrix.env
# systemd starts with a minimal PATH (~/usr/local/bin:/usr/bin:/bin).
# `uv` and `claude` are installed under the runtime user's `~/.local/bin`
# so we have to prepend it explicitly; otherwise run_daily.sh fails at
# the up-front command-presence check.
Environment=PATH=/home/mateo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# `HOME` is auto-set to /home/mateo when User=mateo is honored, but be
# explicit so anything that reads $HOME (e.g. uv's cache lookup, the
# claude CLI's per-session dir) sees the right value even if a future
# refactor flips DynamicUser= or PrivateUsers= on.
Environment=HOME=/home/mateo
WorkingDirectory=/home/mateo/litellm/litellm
ExecStart=/home/mateo/litellm/litellm/tests/e2e/claude_code/cron_vm/run_daily.sh
# 90 minutes is generous: cold runs do `git clone` + `uv sync` of a new
# tag's lockfile, which can take a couple of minutes on a 2-vCPU VM,
# plus 30 cells of pytest hitting four cloud providers.
TimeoutStartSec=90min
# A failed run shouldn't restart automatically — the next timer fire is
# the right retry. Reruns of the same day's matrix are idempotent.
Restart=no
# Security hardening: the populator only reads the litellm checkout and
# the env-file; everything else it writes lives in either the worktree
# (managed) or `/tmp` (cleaned up by tempfile).
#
# `ProtectHome=read-only` blocks writes to /home/mateo but still
# allows reads. That's safe for the trusted run_daily.sh script
# itself, but unsafe for any subprocess we don't control: a
# compromised npm-installed `claude` package, or a model-directed
# `Read` tool call during a PDF/vision cell, could read sensitive
# host files like `~/.config/gh/hosts.yml` (gh-host token),
# `~/.ssh/`, or `~/.bash_history`. We mitigate that at the call
# boundary: every `claude` subprocess (the up-front `claude --version`
# probe in run_daily.sh, plus every CLI invocation routed through
# tests/e2e/claude_code/cli_driver.py) runs with `HOME` pointed at a
# fresh empty per-invocation tmpdir, not at /home/mateo. The CLI
# never sees the runtime user's real dotfiles. `gh` invocations in
# run_daily.sh pass `GH_TOKEN` inline, so they never need to read
# ~/.config/gh either; that path is intentionally NOT in the
# whitelist below — keeping it out is the second line of defense if
# the inline-token convention is ever accidentally regressed.
#
# ReadWritePaths whitelist:
# * litellm-cron-worktree - the long-lived stable-tag checkout +
# its `.venv` (`uv sync` rewrites every
# run) + `.uv-bin` (pinned `uv` binary
# cache).
# * .cache - uv's wheel cache (~/.cache/uv) so we
# don't redownload pinned deps each
# run. Used only by the trusted `uv`
# process; not exposed to `claude`.
# * /tmp - mktemp -d workdir, proxy logs, and
# the per-`claude`-invocation isolated
# HOME tmpdirs. PrivateTmp=true below
# gives the service its own tmpfs view
# so these don't escape to the host.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/mateo/litellm-cron-worktree /home/mateo/.cache /tmp
PrivateTmp=true
# Filesystem-level hiding for credential-bearing dotdirs/files. Even
# though `ProtectHome=read-only` prevents writes, a model-directed
# `Read` tool call (the PDF cells pass `--allowed-tools Read`) or a
# compromised `claude` package can read absolute paths under
# /home/mateo and exfiltrate the contents. `InaccessiblePaths=` makes
# the listed paths look like empty/missing to every process in the
# unit's mount namespace -- including the trusted populator script,
# which is fine because it doesn't need any of these:
#
# * .config/gh - gh CLI host token; we pass GH_TOKEN inline to
# every `gh` invocation (clone/PR/reviewer) so the
# host config is never consulted.
# * .ssh - never used by the populator.
# * .aws - upstream AWS credentials are passed to the proxy
# via the EnvironmentFile (provider env vars), not
# via shared SDK config files.
# * .docker - the populator never talks to a docker socket.
# * .kube - the populator never talks to a k8s API.
# * .gnupg - no GPG signing on the bot's commits.
#
# Leading `-` makes systemd tolerant if a path doesn't exist on the
# host (the unit is portable across VMs that may not have all of
# them set up). Anything else under /home/mateo (the litellm
# checkout, the cron worktree, the uv cache, .local/bin for the
# claude/uv/gh binaries on PATH) stays read-accessible.
InaccessiblePaths=-/home/mateo/.config/gh -/home/mateo/.ssh -/home/mateo/.aws -/home/mateo/.docker -/home/mateo/.kube -/home/mateo/.gnupg
[Install]
WantedBy=multi-user.target

View file

@ -1,25 +0,0 @@
# Daily timer for the compatibility-matrix populator.
#
# 06:00 UTC matches the original GitHub Actions cron schedule; chosen so
# operators in US/EU timezones see fresh PRs at the start of their work
# day.
#
# `Persistent=true` causes a missed run (VM was off / suspended) to
# fire the next time the timer is started, which is the property we
# want for a once-a-day job: the matrix should refresh as soon as the
# VM is reachable again, not wait another 24h.
#
# `RandomizedDelaySec=10min` smears load if multiple matrix-style
# pipelines are ever colocated on the same VM in the future.
[Unit]
Description=Run the Claude Code compatibility-matrix populator daily
[Timer]
OnCalendar=*-*-* 06:00:00 UTC
Persistent=true
RandomizedDelaySec=10min
Unit=litellm-compat-matrix.service
[Install]
WantedBy=timers.target

View file

@ -1,590 +0,0 @@
#!/usr/bin/env bash
# Daily Claude Code compatibility-matrix populator.
#
# Runs from the GCP VM `litellm-compatibility-matrix-populator` via the
# systemd timer in this directory. The flow is:
#
# 1. Resolve the latest LiteLLM v*-stable tag from the GitHub Releases API.
# 2. Update a long-lived worktree at $WORKTREE to that tag and `uv sync` it.
# 3. Boot the proxy as a background subprocess on $PROXY_PORT (default
# 4100; a separate port from the human-tended :4000 proxy).
# 4. Run `pytest tests/e2e/claude_code/` against the proxy. Test failures
# become `fail` cells in the JSON, not script errors.
# 5. Hand the per-test results artifact + manifest to a small Python
# CLI (`build_matrix.py`) that wraps the existing
# `matrix_builder.build_from_paths` to produce the published
# compatibility-matrix.json.
# 6. `gh repo clone` litellm-docs, write the JSON to a deterministic
# branch (`compat-matrix/<litellm>-<claude>-<UTC-date>`), commit,
# `git push --force`, and `gh pr create`.
#
# Same-day reruns land on the same branch so they update the existing PR
# rather than spawning a new one. If the JSON is byte-identical to the
# docs branch, we skip the push entirely.
#
# Required commands on $PATH: git, uv, gh, jq, curl, claude.
# Required state: ~/litellm/litellm checked out (this file lives in it),
# $WORKTREE is created on first run, gh is already authenticated.
#
# Override any default by setting the matching env var; see the systemd
# unit for the production wiring.
set -Eeuo pipefail
LITELLM_REPO="${LITELLM_REPO:-${HOME}/litellm/litellm}"
WORKTREE="${LITELLM_WORKTREE:-${HOME}/litellm-cron-worktree}"
PROXY_PORT="${PROXY_PORT:-4100}"
PROXY_API_KEY="${PROXY_API_KEY:-sk-cron-matrix}"
DOCS_REPO="${DOCS_REPO:-BerriAI/litellm-docs}"
DOCS_BRANCH="${DOCS_BRANCH:-main}"
DOCS_TARGET_PATH="${DOCS_TARGET_PATH:-src/data/compatibility-matrix.json}"
SKIP_PUBLISH="${SKIP_PUBLISH:-0}"
PYTEST_K="${PYTEST_K:-}"
# Comma-separated GitHub usernames to request a review from on every PR.
# Reviewers must have at least read access to ${DOCS_REPO}. PR-author
# (agent-shin) has implicit rights to request reviews from anyone with
# read access, so no extra token scope is needed. Set to empty to skip.
PR_REVIEWERS="${PR_REVIEWERS:-mateo-berri}"
POPULATOR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKDIR="$(mktemp -d -t litellm-compat-matrix.XXXXXX)"
PROXY_PID_FILE="${WORKDIR}/proxy.pid"
# Cleanup is intentionally aggressive: it can run on normal exit, on a
# signal received by the script, or after a partial failure where the
# proxy is up but ${PROXY_PID_FILE} is stale. We try four things in
# order and stop as soon as the proxy port is free:
#
# 1. SIGTERM the pid recorded in proxy.pid.
# 2. SIGKILL anything from `pgrep -f "litellm.*--port ${PROXY_PORT}"`
# that survived. This catches the common case where the recorded
# pid was the sh wrapper, not the long-lived python child.
# 3. ss -K on the port (kernel kills sockets but not processes;
# mostly useful for catching lingering CLOSE_WAITs).
# 4. wipe ${WORKDIR}.
cleanup() {
local rc=$?
set +e
local proxy_pid
if [[ -f "${PROXY_PID_FILE}" ]]; then
proxy_pid="$(cat "${PROXY_PID_FILE}")"
if [[ -n "${proxy_pid}" ]]; then
kill -TERM "-${proxy_pid}" 2>/dev/null || kill -TERM "${proxy_pid}" 2>/dev/null || true
for _ in 1 2 3 4 5; do
kill -0 "${proxy_pid}" 2>/dev/null || break
sleep 1
done
fi
fi
# Belt-and-braces: any python or uv talking to ${PROXY_PORT} that
# survived the SIGTERM gets SIGKILL'd by name.
pgrep -f "litellm.*--port[ =]?${PROXY_PORT}([^0-9]|$)" 2>/dev/null \
| xargs -r kill -KILL 2>/dev/null || true
pgrep -f "${WORKTREE}/.uv-bin/uv.*run litellm" 2>/dev/null \
| xargs -r kill -KILL 2>/dev/null || true
rm -rf "${WORKDIR}"
exit "${rc}"
}
trap cleanup EXIT INT TERM
log() { printf '==> %s\n' "$*" >&2; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
for cmd in git uv gh jq curl claude; do
command -v "${cmd}" >/dev/null 2>&1 || die "missing required command: ${cmd}"
done
# Publishing is from a fork (agent-shin/litellm-docs) so neither the cron
# host nor the bot identity needs write access to BerriAI/litellm-docs. We
# require the fork token up front -- failing 30 minutes into a run because
# the env file is missing one line is a waste of CI quota.
if [[ "${SKIP_PUBLISH}" != "1" ]]; then
[[ -n "${AGENT_SHIN_GITHUB_TOKEN:-}" ]] \
|| die "AGENT_SHIN_GITHUB_TOKEN required to open PRs from agent-shin/litellm-docs (or set SKIP_PUBLISH=1)"
fi
# ---------------------------------------------------------------------------
# 1. Resolve versions
# ---------------------------------------------------------------------------
# Newest v*-stable release on BerriAI/litellm. The `select(...)` filter
# drops drafts/non-stable, the version_key sort handles 1.10 > 1.9.
#
# Paginate through the releases endpoint instead of grabbing only page 1
# (default page_size=30). LiteLLM ships multiple non-stable releases per
# day, so it's common to need to walk past 30+ entries before hitting
# the most recent v*-stable. We cap at 5 pages (500 releases) which is
# conservatively beyond the worst observed gap.
#
# We deliberately do NOT short-circuit on the first page that contains a
# v*-stable tag. The /releases endpoint orders by `created_at`, not by
# semver, so a backport on an older series (e.g. v1.80.1-stable cut
# today) can show up on an earlier page than a higher-versioned release
# (v1.83.0-stable cut two weeks ago). Breaking early on first-stable-seen
# would silently pin the cron to the stale tag because the
# higher-versioned release still on a later page would never make it
# into the merged set the `sort_by` below consumes. The only break we
# keep is the empty-page guard, which means a quiet period in the
# release feed doesn't waste API quota — we just always walk far enough
# to be confident we've seen the highest stable tag.
GH_AUTH_HEADER=()
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
GH_AUTH_HEADER=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
fi
RELEASES_JSON="${WORKDIR}/releases.json"
echo "[]" >"${RELEASES_JSON}"
for page in 1 2 3 4 5; do
PAGE_JSON="${WORKDIR}/releases.page${page}.json"
curl -fsS \
-H 'Accept: application/vnd.github+json' \
-H 'User-Agent: litellm-compat-matrix' \
"${GH_AUTH_HEADER[@]}" \
"https://api.github.com/repos/BerriAI/litellm/releases?per_page=100&page=${page}" \
>"${PAGE_JSON}"
jq -s '.[0] + .[1]' "${RELEASES_JSON}" "${PAGE_JSON}" >"${RELEASES_JSON}.merged"
mv "${RELEASES_JSON}.merged" "${RELEASES_JSON}"
# No more pages? GitHub returns an empty array past the last page.
if [[ "$(jq 'length' "${PAGE_JSON}")" == "0" ]]; then
break
fi
done
LITELLM_VERSION="$(
jq -r '
[ .[] | .tag_name // empty
| select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+-stable$"))
]
| sort_by(
capture("^v(?<a>[0-9]+)\\.(?<b>[0-9]+)\\.(?<c>[0-9]+)-stable$")
| [(.a|tonumber), (.b|tonumber), (.c|tonumber)]
)
| last // empty
' "${RELEASES_JSON}"
)"
[[ -n "${LITELLM_VERSION}" ]] || die "could not resolve latest v*-stable tag in 5 pages of releases"
log "resolved litellm: ${LITELLM_VERSION}"
# The systemd unit loads provider credentials and the agent-shin GitHub
# token from /etc/litellm-compat-matrix.env into this script's
# environment. Running the npm-installed `claude` binary directly here
# would hand that full env to package code -- a compromised
# @anthropic-ai/claude-code release could read ANTHROPIC_API_KEY /
# AWS_BEARER_TOKEN_BEDROCK / AZURE_FOUNDRY_API_KEY /
# AGENT_SHIN_GITHUB_TOKEN from os.environ and exfiltrate them before
# the proxy or test harness ever starts. Probe under `env -i` with the
# same minimal allowlist the PR-gate uses (the matrix run itself goes
# through cli_driver.py, which already scrubs the CLI env).
#
# The probe also runs under a fresh empty HOME instead of the runtime
# user's real $HOME. `ProtectHome=read-only` in the systemd unit
# blocks *writes* to /home/mateo but still allows reads, so a
# compromised claude package invoked here with HOME=/home/mateo could
# read ~/.config/gh/hosts.yml (the gh-host token), ~/.bash_history,
# or ~/.ssh/. Pointing HOME at a per-run dir under ${WORKDIR} hides
# those entirely from the subprocess; ${WORKDIR} is rm -rf'd by the
# script-wide cleanup() trap regardless of probe outcome.
CLAUDE_PROBE_HOME="${WORKDIR}/claude-probe-home"
mkdir -p "${CLAUDE_PROBE_HOME}"
CLAUDE_CODE_VERSION="$(env -i \
PATH="${PATH}" \
HOME="${CLAUDE_PROBE_HOME}" \
USER="${USER:-mateo}" \
TERM="${TERM:-dumb}" \
LANG="${LANG:-C.UTF-8}" \
LC_ALL="${LC_ALL:-}" \
TMPDIR="${TMPDIR:-/tmp}" \
claude --version 2>/dev/null \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?' \
| head -n1 || true)"
# `|| true` above keeps `set -Eeuo pipefail` from aborting silently when
# `grep` finds no match (exit 1) — without it the assignment inherits the
# pipeline's non-zero exit, `set -e` kills the script, and the operator
# never sees the helpful diagnostic below.
[[ -n "${CLAUDE_CODE_VERSION}" ]] || die "could not parse semver from 'claude --version'"
log "local claude code: ${CLAUDE_CODE_VERSION}"
# ---------------------------------------------------------------------------
# 2. Update the worktree to that tag
# ---------------------------------------------------------------------------
if [[ ! -d "${WORKTREE}/.git" ]]; then
log "first run: cloning litellm into ${WORKTREE}"
mkdir -p "$(dirname "${WORKTREE}")"
git clone https://github.com/BerriAI/litellm.git "${WORKTREE}"
fi
log "updating worktree to ${LITELLM_VERSION}"
git -C "${WORKTREE}" fetch --tags --force
git -C "${WORKTREE}" reset --hard
# Keep the venv and the .uv-bin cache around — uv sync will reconcile
# the venv on every run, and we don't want to re-download the pinned
# uv binary each time. Drop everything else (including any prior
# tests/e2e/claude_code/ shim) so each run starts clean before the shim
# below rewrites it from the dev checkout.
git -C "${WORKTREE}" clean -fdx -e .venv -e .uv-bin
git -C "${WORKTREE}" checkout --force "${LITELLM_VERSION}"
# Always overwrite tests/e2e/claude_code/ in the worktree with the copy
# from the dev checkout, regardless of whether the resolved
# ${LITELLM_VERSION} tag already ships a tests/e2e/claude_code/ tree of
# its own. Rationale: the matrix populator's job is to exercise
# today's tests against the latest stable proxy. The dev checkout
# carries the most recent test fixes (e.g. the stream-json vision
# rewrite, the --effort thinking knob, the WebSearch tool_use
# assertion) that haven't yet rolled into a v*-stable, and we want
# every cron run to pick those up the moment they land on
# ${LITELLM_REPO}, not whenever the next stable release happens.
#
# Concretely this means a fresh `rm -rf` + `cp -r` every run so the
# tree is byte-identical to ${LITELLM_REPO}/tests/e2e/claude_code (no
# stale files left over from the tag's own checkout, no drift across
# runs).
if [[ ! -d "${LITELLM_REPO}/tests/e2e/claude_code" ]]; then
die "no shim source at ${LITELLM_REPO}/tests/e2e/claude_code"
fi
log "shimming tests/e2e/claude_code/ from ${LITELLM_REPO} (always-overwrite)"
rm -rf "${WORKTREE}/tests/e2e/claude_code"
mkdir -p "${WORKTREE}/tests/e2e"
cp -r "${LITELLM_REPO}/tests/e2e/claude_code" "${WORKTREE}/tests/e2e/"
# litellm pins an exact uv version in pyproject.toml's [tool.uv]
# `required-version` field, so a system uv that's newer or older
# refuses to sync. We pin our own local copy at the version the
# checked-out tag asks for, cached under .uv-bin/ inside the worktree
# so subsequent runs skip the download.
PINNED_UV_VERSION="$(
awk -F'"' '
/^required-version[[:space:]]*=/ {
# Field 2 is the value between the quotes, e.g. ">=0.10.9" or
# "0.10.9". Strip any leading specifier prefix so we end up with
# the bare version string, which is what /releases/download/<v>/
# expects.
v = $2
sub(/^[[:space:]=<>!~]+/, "", v)
if (v != "") { print v; exit }
}
' "${WORKTREE}/pyproject.toml"
)"
if [[ -z "${PINNED_UV_VERSION}" ]]; then
log "no uv version pin in pyproject.toml; using system uv"
WORKTREE_UV="$(command -v uv)"
else
WORKTREE_UV="${WORKTREE}/.uv-bin/uv-${PINNED_UV_VERSION}"
if [[ ! -x "${WORKTREE_UV}" ]]; then
log "downloading uv ${PINNED_UV_VERSION} for the worktree"
mkdir -p "${WORKTREE}/.uv-bin"
# Detect host arch so the same script works on x86_64 GCP VMs and on
# aarch64 hosts (Astral publishes both `uv-x86_64-unknown-linux-gnu`
# and `uv-aarch64-unknown-linux-gnu` tarballs under the same release
# tag, and `uname -m` already returns the exact token uv uses).
UV_ARCH="$(uname -m)"
UV_TRIPLE="uv-${UV_ARCH}-unknown-linux-gnu"
UV_TARBALL_NAME="${UV_TRIPLE}.tar.gz"
UV_DOWNLOAD_URL="https://github.com/astral-sh/uv/releases/download/${PINNED_UV_VERSION}/${UV_TARBALL_NAME}"
UV_TMPDIR="$(mktemp -d -t uv-download.XXXXXX)"
# Download the tarball and Astral's official .sha256 sidecar to disk
# and verify the digest before extracting/executing anything. This
# closes the supply-chain trust gap of piping a remote binary
# straight into `tar -xzO ... > file ; chmod +x` (see CLAUDE.md
# "CI Supply-Chain Safety").
curl -fsSL --output "${UV_TMPDIR}/${UV_TARBALL_NAME}" "${UV_DOWNLOAD_URL}"
curl -fsSL --output "${UV_TMPDIR}/${UV_TARBALL_NAME}.sha256" "${UV_DOWNLOAD_URL}.sha256"
(cd "${UV_TMPDIR}" && sha256sum -c "${UV_TARBALL_NAME}.sha256") \
|| { rm -rf "${UV_TMPDIR}"; die "uv ${PINNED_UV_VERSION} sha256 mismatch — refusing to install"; }
tar -xzf "${UV_TMPDIR}/${UV_TARBALL_NAME}" -C "${UV_TMPDIR}" "${UV_TRIPLE}/uv"
mv "${UV_TMPDIR}/${UV_TRIPLE}/uv" "${WORKTREE_UV}.tmp"
chmod +x "${WORKTREE_UV}.tmp"
mv "${WORKTREE_UV}.tmp" "${WORKTREE_UV}"
rm -rf "${UV_TMPDIR}"
fi
fi
# `--extra proxy` pulls fastapi/uvicorn/etc. so `uv run litellm` can
# actually serve. `--group proxy-dev` brings in pytest and the rest of
# what tests/e2e/claude_code/ needs.
log "uv sync --frozen --group proxy-dev --extra proxy (uv ${PINNED_UV_VERSION:-system})"
(cd "${WORKTREE}" && "${WORKTREE_UV}" sync --frozen --group proxy-dev --extra proxy)
PROXY_CONFIG="${WORKTREE}/tests/e2e/claude_code/test_config.yaml"
[[ -f "${PROXY_CONFIG}" ]] || die "proxy config not found at ${PROXY_CONFIG} (does ${LITELLM_VERSION} predate the compat matrix work?)"
# ---------------------------------------------------------------------------
# 3. Boot the proxy
# ---------------------------------------------------------------------------
log "starting proxy on 127.0.0.1:${PROXY_PORT}"
# Bind the proxy to loopback only. The populator proxy is talked to
# exclusively by the pytest run on the same host (the health check and
# the test env set `LITELLM_PROXY_BASE_URL=http://127.0.0.1:...`),
# so there's no reason to expose it on the VM's external interfaces.
# Without `--host`, `litellm` defaults to 0.0.0.0, which combined with
# the predictable default `LITELLM_MASTER_KEY=sk-cron-matrix` would
# allow anything that can reach :${PROXY_PORT} on the VM to authenticate
# and burn upstream provider credentials.
#
# `setsid` puts the proxy in its own session+pgroup so cleanup() can
# SIGTERM the whole tree by passing the pgid as a negative pid. We
# write that pid to a file so cleanup() doesn't need to remember a
# variable that might be stale by the time the trap fires.
#
# Pass the master key as a shell-prefix assignment on `setsid` (inherited
# via the environment) rather than as `env KEY=VAL ...` argv. The argv
# form would land the literal key in /proc/<setsid-pid>/cmdline, where
# any local reader (a model-directed `Read` tool call, another user on
# the VM, a crash dump) could pick it up before the process execs into
# the litellm child. The shell-prefix form keeps the key out of argv at
# every layer (setsid → bash → uv → litellm).
LITELLM_MASTER_KEY="${PROXY_API_KEY}" setsid bash -c '
echo "$$" > "$0"
cd "$1"
exec "$2" run litellm --config "$3" --host 127.0.0.1 --port "$4"
' "${PROXY_PID_FILE}" "${WORKTREE}" "${WORKTREE_UV}" "${PROXY_CONFIG}" "${PROXY_PORT}" \
>"${WORKDIR}/proxy.log" 2>&1 &
disown
HEALTH_URL="http://127.0.0.1:${PROXY_PORT}/health/liveliness"
for _ in $(seq 1 45); do
if curl -fsS "${HEALTH_URL}" >/dev/null 2>&1; then
break
fi
sleep 2
done
curl -fsS "${HEALTH_URL}" >/dev/null \
|| { tail -50 "${WORKDIR}/proxy.log" >&2; die "proxy did not become healthy"; }
# ---------------------------------------------------------------------------
# 4. Run pytest
# ---------------------------------------------------------------------------
RESULTS_JSON="${WORKDIR}/compat-results.json"
PYTEST_ARGS=(
tests/e2e/claude_code/
--ignore=tests/e2e/claude_code/_driver_unit_tests
--ignore=tests/e2e/claude_code/_builder_unit_tests
--ignore=tests/e2e/claude_code/_publisher_unit_tests
--ignore=tests/e2e/claude_code/_pr_gate_unit_tests
)
if [[ -n "${PYTEST_K}" ]]; then
log "PYTEST_K set; narrowing to: ${PYTEST_K}"
PYTEST_ARGS+=(-k "${PYTEST_K}")
fi
log "running pytest"
set +e
# Pytest only needs to talk to the loopback proxy at 127.0.0.1:${PROXY_PORT}
# — it has no legitimate reason to see ANTHROPIC_API_KEY /
# AWS_BEARER_TOKEN_BEDROCK / VERTEXAI_* / AZURE_FOUNDRY_* /
# AGENT_SHIN_GITHUB_TOKEN / GITHUB_TOKEN in its own env. The systemd
# unit's EnvironmentFile injects all of those into this script for the
# proxy to consume, and pytest inherits them by default. Wrap the
# invocation in `env -i` so:
#
# 1. test code under tests/e2e/claude_code/ (or anything it imports)
# cannot read provider/agent-shin creds out of `os.environ` and
# exfiltrate them via an outbound call from inside a conftest hook
# or a fixture (a sibling vector to the model-controlled Bash/Read
# concern handled by `cli_driver.py`'s own env scrub);
# 2. a model-directed `Read` tool call during a PDF/vision cell
# cannot reach /proc/<pytest-pid>/environ and pull the creds out
# of the parent process the way it can today;
# 3. this matches the PR-gate pytest step in `.circleci/config.yml`,
# which already runs under `env -i` with the same minimal
# allowlist.
#
# `cli_driver.py` re-allowlists its own subset (PATH/USER/LOGNAME/etc.)
# when spawning the `claude` binary, so the CLI still finds Node + the
# claude shim on PATH and gets a fresh isolated HOME per invocation.
(
cd "${WORKTREE}" \
&& env -i \
PATH="${PATH}" \
HOME="${HOME}" \
USER="${USER:-mateo}" \
TERM="${TERM:-dumb}" \
LANG="${LANG:-C.UTF-8}" \
LC_ALL="${LC_ALL:-}" \
TMPDIR="${TMPDIR:-/tmp}" \
LITELLM_PROXY_BASE_URL="http://127.0.0.1:${PROXY_PORT}" \
LITELLM_PROXY_API_KEY="${PROXY_API_KEY}" \
COMPAT_RESULTS_PATH="${RESULTS_JSON}" \
"${WORKTREE_UV}" run pytest "${PYTEST_ARGS[@]}"
)
PYTEST_EXIT=$?
set -e
log "pytest exit code: ${PYTEST_EXIT} (failures become 'fail' cells, not script errors)"
[[ -f "${RESULTS_JSON}" ]] || die "pytest did not produce ${RESULTS_JSON}"
# ---------------------------------------------------------------------------
# 5. Build the matrix JSON
# ---------------------------------------------------------------------------
MATRIX_JSON="${WORKDIR}/compatibility-matrix.json"
log "building ${MATRIX_JSON}"
(
cd "${WORKTREE}" \
&& "${WORKTREE_UV}" run python "${POPULATOR_DIR}/build_matrix.py" \
--manifest "${WORKTREE}/tests/e2e/claude_code/manifest.yaml" \
--results "${RESULTS_JSON}" \
--output "${MATRIX_JSON}" \
--litellm-version "${LITELLM_VERSION}" \
--claude-code-version "${CLAUDE_CODE_VERSION}"
)
# ---------------------------------------------------------------------------
# 6. Open a docs-repo PR
# ---------------------------------------------------------------------------
if [[ "${SKIP_PUBLISH}" == "1" ]]; then
cp "${MATRIX_JSON}" "${LITELLM_REPO}/compatibility-matrix.json"
log "SKIP_PUBLISH=1; matrix written to ${LITELLM_REPO}/compatibility-matrix.json"
exit 0
fi
DATE_UTC="$(date -u +%Y-%m-%d)"
BRANCH_NAME="compat-matrix/${LITELLM_VERSION}-${CLAUDE_CODE_VERSION}-${DATE_UTC}"
DOCS_CLONE="${WORKDIR}/litellm-docs"
FORK_OWNER="${FORK_OWNER:-agent-shin}"
FORK_REPO="${FORK_REPO:-${FORK_OWNER}/litellm-docs}"
log "cloning ${DOCS_REPO}@${DOCS_BRANCH}"
# Use the agent-shin token inline rather than the host gh-cli config.
# `BerriAI/litellm-docs` is a public repo so unauthenticated clone
# would also work, but passing the token explicitly means the systemd
# unit can hide `~/.config/gh` (`InaccessiblePaths=`) without breaking
# this clone — closing the model-directed `Read("/home/mateo/.config/gh/...")`
# exfiltration path on the cron VM.
GH_TOKEN="${AGENT_SHIN_GITHUB_TOKEN}" \
gh repo clone "${DOCS_REPO}" "${DOCS_CLONE}" -- --depth 1 --branch "${DOCS_BRANCH}"
cd "${DOCS_CLONE}"
git config user.email "litellm-bot@berri.ai"
git config user.name "litellm-compat-matrix-bot"
git checkout -b "${BRANCH_NAME}"
mkdir -p "$(dirname "${DOCS_TARGET_PATH}")"
cp "${MATRIX_JSON}" "${DOCS_TARGET_PATH}"
git add "${DOCS_TARGET_PATH}"
if git diff --cached --quiet; then
log "matrix JSON unchanged from ${DOCS_BRANCH}; skipping PR"
exit 0
fi
GENERATED_AT="$(jq -r '.generated_at' "${MATRIX_JSON}")"
COMMIT_MSG="$(cat <<EOF
Update Claude Code compatibility matrix
litellm_version: ${LITELLM_VERSION}
claude_code_version: ${CLAUDE_CODE_VERSION}
generated_at: ${GENERATED_AT}
EOF
)"
git commit -m "${COMMIT_MSG}"
# Push to the fork (agent-shin/litellm-docs), not to BerriAI/litellm-docs.
# The cron host has no write access to BerriAI/litellm-docs by design --
# only agent-shin's PAT does, and only over its own fork. The temp remote
# carries the token in its URL, so we add it, push, then immediately
# remove it so the token never lingers in ${DOCS_CLONE}/.git/config.
# (${DOCS_CLONE} is also rm -rf'd by the cleanup trap on exit.)
#
# Plain --force (not --force-with-lease) is acceptable here: the fork
# branch is bot-owned, only this script ever writes to it, and runs are
# serialized by the systemd timer. --force-with-lease would require a
# fetch to populate the remote-tracking ref before each push and adds
# no safety in this single-writer setup.
FORK_PUSH_URL="https://x-access-token:${AGENT_SHIN_GITHUB_TOKEN}@github.com/${FORK_REPO}.git"
git remote remove fork 2>/dev/null || true
git remote add fork "${FORK_PUSH_URL}"
git push --force --set-upstream fork "${BRANCH_NAME}"
git remote remove fork
unset FORK_PUSH_URL
# Per-feature status table for the PR body. Reviewers triage from this.
PR_FEATURE_TABLE="$(jq -r '
.features[] as $f
| "- **\($f.name)**: " +
([ .providers[] as $p
| "\($p)=\($f.providers[$p].status // "not_tested")"
] | join(", "))
' "${MATRIX_JSON}")"
PR_TITLE="chore(compat-matrix): refresh for ${LITELLM_VERSION} + claude-code ${CLAUDE_CODE_VERSION}"
PR_BODY="$(cat <<EOF
Automated daily refresh of the Claude Code compatibility matrix.
| Field | Value |
| --- | --- |
| litellm_version | \`${LITELLM_VERSION}\` |
| claude_code_version | \`${CLAUDE_CODE_VERSION}\` |
| generated_at | \`${GENERATED_AT}\` |
## Per-feature results
${PR_FEATURE_TABLE}
---
Generated by \`tests/e2e/claude_code/cron_vm/run_daily.sh\`. Close without merging if the diff looks wrong; the next cron run will reopen with fresh results.
EOF
)"
log "opening PR from ${FORK_OWNER}:${BRANCH_NAME} -> ${DOCS_REPO}:${DOCS_BRANCH}"
# GH_TOKEN here is scoped to this single subshell so we don't bleed the
# fork token into the rest of the script (release-listing earlier uses
# ${GITHUB_TOKEN}, which may be a different identity). gh's --head accepts
# `OWNER:BRANCH` for cross-repo PRs from a fork.
#
# Reviewer assignment is done in a *separate* call below: as the PR
# author from a fork, agent-shin has no write/triage access on
# ${DOCS_REPO} and the `RequestReviewsByLogin` GraphQL mutation
# (which backs `gh pr create --reviewer` and `gh pr edit --add-reviewer`)
# rejects with "does not have the correct permissions". We use the
# collaborator-scoped ${GITHUB_TOKEN} for that instead. Don't fold
# --reviewer into `gh pr create` here -- it would fail the whole
# create on the very first cron run.
set +e
PR_OUT="$(
GH_TOKEN="${AGENT_SHIN_GITHUB_TOKEN}" gh pr create \
--repo "${DOCS_REPO}" \
--base "${DOCS_BRANCH}" \
--head "${FORK_OWNER}:${BRANCH_NAME}" \
--title "${PR_TITLE}" \
--body "${PR_BODY}" 2>&1
)"
PR_EXIT=$?
set -e
echo "${PR_OUT}"
if [[ ${PR_EXIT} -ne 0 ]]; then
if grep -q "a pull request for branch.*already exists" <<<"${PR_OUT}"; then
log "PR already exists for ${FORK_OWNER}:${BRANCH_NAME}; updated branch in place"
else
die "gh pr create failed (exit ${PR_EXIT})"
fi
fi
# Request reviews from PR_REVIEWERS using the collaborator-scoped
# ${GITHUB_TOKEN} (mateo-berri's token, already provisioned for release
# listing). This is idempotent: `gh pr edit --add-reviewer` is a no-op
# on a user who's already in reviewRequests, and silently re-adds
# anyone whose prior review was dismissed -- so same-day reruns stay
# clean. Reviewer-add failures are non-fatal: the matrix JSON has
# already landed on the PR; the worst case is a manual ping.
if [[ -n "${PR_REVIEWERS}" ]]; then
if [[ -z "${GITHUB_TOKEN:-}" ]]; then
log "WARN: PR_REVIEWERS set but GITHUB_TOKEN missing -- cannot request reviews; skipping"
else
log "requesting reviews from: ${PR_REVIEWERS}"
set +e
GH_TOKEN="${GITHUB_TOKEN}" gh pr edit \
"${FORK_OWNER}:${BRANCH_NAME}" \
--repo "${DOCS_REPO}" \
--add-reviewer "${PR_REVIEWERS}" 2>&1 | sed 's/^/ /'
REVIEWER_EXIT=${PIPESTATUS[0]}
set -e
if [[ ${REVIEWER_EXIT} -ne 0 ]]; then
log "WARN: gh pr edit --add-reviewer exited ${REVIEWER_EXIT} (non-fatal)"
fi
fi
fi
log "done"

View file

@ -54,25 +54,23 @@ $10/day on this row.
from __future__ import annotations
import os
from typing import Sequence
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
failure_diagnostic,
run_claude_models_parallel,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
# Haiku 4.5 is excluded -- only Sonnet 4.6 and Opus 4.7 support the
# 1M-context beta. See module docstring for the per-cell-aggregator
# rationale.
ANTHROPIC_MODELS: Sequence[str] = (
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-opus-4-7",
)
@ -155,26 +153,12 @@ def _build_long_prompt(target_tokens: int = TARGET_INPUT_TOKENS) -> str:
return preamble + "".join(pad_lines) + closing
@pytest.mark.covers("llm.messages.anthropic.long_context_1m.nonstream.works")
def test_long_context_1m_anthropic(compat_result):
"""Drive the `claude` CLI with a ~210k-token prompt and the
`context-1m-2025-08-07` beta header; assert no 400 / 413 and a
non-empty reply for Sonnet + Opus."""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
base_url, api_key = require_proxy(compat_result)
long_prompt = _build_long_prompt()

View file

@ -54,25 +54,23 @@ $10/day on this row.
from __future__ import annotations
import os
from typing import Sequence
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
failure_diagnostic,
run_claude_models_parallel,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
# Haiku 4.5 is excluded -- only Sonnet 4.6 and Opus 4.7 support the
# 1M-context beta. See module docstring for the per-cell-aggregator
# rationale.
AZURE_MODELS: Sequence[str] = (
"claude-sonnet-4-6-azure",
"claude-sonnet-4-5-azure",
"claude-opus-4-7-azure",
)
@ -155,26 +153,12 @@ def _build_long_prompt(target_tokens: int = TARGET_INPUT_TOKENS) -> str:
return preamble + "".join(pad_lines) + closing
@pytest.mark.covers("llm.messages.azure_foundry.long_context_1m.nonstream.works")
def test_long_context_1m_azure(compat_result):
"""Drive the `claude` CLI (Azure (Microsoft Foundry)) with a ~210k-token prompt and the
`context-1m-2025-08-07` beta header; assert no 400 / 413 and a
non-empty reply for Sonnet + Opus."""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
base_url, api_key = require_proxy(compat_result)
long_prompt = _build_long_prompt()

View file

@ -54,25 +54,23 @@ $10/day on this row.
from __future__ import annotations
import os
from typing import Sequence
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
failure_diagnostic,
run_claude_models_parallel,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
# Haiku 4.5 is excluded -- only Sonnet 4.6 and Opus 4.7 support the
# 1M-context beta. See module docstring for the per-cell-aggregator
# rationale.
BEDROCK_CONVERSE_MODELS: Sequence[str] = (
"claude-sonnet-4-6-bedrock-converse",
"claude-sonnet-4-5-bedrock-converse",
"claude-opus-4-7-bedrock-converse",
)
@ -155,26 +153,12 @@ def _build_long_prompt(target_tokens: int = TARGET_INPUT_TOKENS) -> str:
return preamble + "".join(pad_lines) + closing
@pytest.mark.covers("llm.messages.bedrock_converse.long_context_1m.nonstream.works")
def test_long_context_1m_bedrock_converse(compat_result):
"""Drive the `claude` CLI (Bedrock (Converse)) with a ~210k-token prompt and the
`context-1m-2025-08-07` beta header; assert no 400 / 413 and a
non-empty reply for Sonnet + Opus."""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
base_url, api_key = require_proxy(compat_result)
long_prompt = _build_long_prompt()

View file

@ -54,25 +54,23 @@ $10/day on this row.
from __future__ import annotations
import os
from typing import Sequence
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
failure_diagnostic,
run_claude_models_parallel,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
# Haiku 4.5 is excluded -- only Sonnet 4.6 and Opus 4.7 support the
# 1M-context beta. See module docstring for the per-cell-aggregator
# rationale.
BEDROCK_INVOKE_MODELS: Sequence[str] = (
"claude-sonnet-4-6-bedrock-invoke",
"claude-sonnet-4-5-bedrock-invoke",
"claude-opus-4-7-bedrock-invoke",
)
@ -155,26 +153,12 @@ def _build_long_prompt(target_tokens: int = TARGET_INPUT_TOKENS) -> str:
return preamble + "".join(pad_lines) + closing
@pytest.mark.covers("llm.messages.bedrock_invoke.long_context_1m.nonstream.works")
def test_long_context_1m_bedrock_invoke(compat_result):
"""Drive the `claude` CLI (Bedrock (Invoke)) with a ~210k-token prompt and the
`context-1m-2025-08-07` beta header; assert no 400 / 413 and a
non-empty reply for Sonnet + Opus."""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
base_url, api_key = require_proxy(compat_result)
long_prompt = _build_long_prompt()

View file

@ -54,25 +54,23 @@ $10/day on this row.
from __future__ import annotations
import os
from typing import Sequence
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
failure_diagnostic,
run_claude_models_parallel,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
# Haiku 4.5 is excluded -- only Sonnet 4.6 and Opus 4.7 support the
# 1M-context beta. See module docstring for the per-cell-aggregator
# rationale.
VERTEX_AI_MODELS: Sequence[str] = (
"claude-sonnet-4-6-vertex",
"claude-sonnet-4-5-vertex",
"claude-opus-4-7-vertex",
)
@ -155,26 +153,12 @@ def _build_long_prompt(target_tokens: int = TARGET_INPUT_TOKENS) -> str:
return preamble + "".join(pad_lines) + closing
@pytest.mark.covers("llm.messages.vertex.long_context_1m.nonstream.works")
def test_long_context_1m_vertex_ai(compat_result):
"""Drive the `claude` CLI (Vertex AI) with a ~210k-token prompt and the
`context-1m-2025-08-07` beta header; assert no 400 / 413 and a
non-empty reply for Sonnet + Opus."""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured",
pytrace=False,
)
base_url, api_key = require_proxy(compat_result)
long_prompt = _build_long_prompt()

View file

@ -183,7 +183,7 @@ def build_from_paths(
generated_at: str,
output_path: Optional[Path] = None,
) -> Dict[str, Any]:
"""I/O wrapper around build_matrix used by the publisher script."""
"""I/O wrapper around ``build_matrix``: reads the manifest and per-test results from disk, calls ``build_matrix``, and (optionally) writes the compat-matrix JSON to ``output_path``. Whatever orchestrator publishes the matrix (currently the ECR image) invokes this."""
manifest = load_manifest(manifest_path)
results = load_results(results_path)
matrix = build_matrix(

View file

@ -29,7 +29,7 @@ from claude_code._passthrough import (
ANTHROPIC_MODELS = [
"claude-haiku-4-5",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-opus-4-7",
]

View file

@ -44,7 +44,7 @@ from claude_code._passthrough import foundry_extra_env, run_passthrough_cell
AZURE_MODELS = [
"claude-haiku-4-5",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-opus-4-7",
]

View file

@ -27,7 +27,7 @@ from claude_code._passthrough import bedrock_extra_env, run_passthrough_cell
BEDROCK_INVOKE_MODELS = [
"claude-haiku-4-5-bedrock-invoke",
"claude-sonnet-4-6-bedrock-invoke",
"claude-sonnet-4-5-bedrock-invoke",
"claude-opus-4-7-bedrock-invoke",
]

View file

@ -30,7 +30,7 @@ from claude_code._passthrough import run_passthrough_cell, vertex_extra_env
VERTEX_MODELS = [
"claude-haiku-4-5-vertex",
"claude-sonnet-4-6-vertex",
"claude-sonnet-4-5-vertex",
"claude-opus-4-7-vertex",
]

View file

@ -22,22 +22,19 @@ The (feature, provider) for this cell is inferred from the file path by
from __future__ import annotations
import os
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
failure_diagnostic,
run_claude_models_parallel,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
ANTHROPIC_MODELS = [
"claude-haiku-4-5",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-opus-4-7",
]
@ -108,24 +105,11 @@ def _build_minimal_pdf(marker: str) -> bytes:
return bytes(out)
@pytest.mark.covers("llm.messages.anthropic.pdf_input.nonstream.works")
def test_pdf_input_anthropic(compat_result, tmp_path):
"""Drive the `claude` CLI against the LiteLLM proxy with a PDF
attached via the Read tool and assert the reply references it."""
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured", pytrace=False
)
base_url, api_key = require_proxy(compat_result)
pdf_path = tmp_path / "marker.pdf"
pdf_path.write_bytes(_build_minimal_pdf(PDF_MARKER))

View file

@ -15,22 +15,19 @@ The (feature, provider) for this cell is inferred from the file path by
from __future__ import annotations
import os
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
failure_diagnostic,
run_claude_models_parallel,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
AZURE_MODELS = [
"claude-haiku-4-5-azure",
"claude-sonnet-4-6-azure",
"claude-sonnet-4-5-azure",
"claude-opus-4-7-azure",
]
@ -85,22 +82,9 @@ def _build_minimal_pdf(marker: str) -> bytes:
return bytes(out)
@pytest.mark.covers("llm.messages.azure_foundry.pdf_input.nonstream.works")
def test_pdf_input_azure(compat_result, tmp_path):
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured", pytrace=False
)
base_url, api_key = require_proxy(compat_result)
pdf_path = tmp_path / "marker.pdf"
pdf_path.write_bytes(_build_minimal_pdf(PDF_MARKER))

View file

@ -21,22 +21,19 @@ The (feature, provider) for this cell is inferred from the file path by
from __future__ import annotations
import os
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
failure_diagnostic,
run_claude_models_parallel,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
BEDROCK_CONVERSE_MODELS = [
"claude-haiku-4-5-bedrock-converse",
"claude-sonnet-4-6-bedrock-converse",
"claude-sonnet-4-5-bedrock-converse",
"claude-opus-4-7-bedrock-converse",
]
@ -91,22 +88,9 @@ def _build_minimal_pdf(marker: str) -> bytes:
return bytes(out)
@pytest.mark.covers("llm.messages.bedrock_converse.pdf_input.nonstream.works")
def test_pdf_input_bedrock_converse(compat_result, tmp_path):
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured", pytrace=False
)
base_url, api_key = require_proxy(compat_result)
pdf_path = tmp_path / "marker.pdf"
pdf_path.write_bytes(_build_minimal_pdf(PDF_MARKER))

View file

@ -20,22 +20,19 @@ The (feature, provider) for this cell is inferred from the file path by
from __future__ import annotations
import os
import pytest
from claude_code._env import require_proxy
from claude_code.cli_driver import (
ClaudeCLIError,
failure_diagnostic,
run_claude_models_parallel,
)
PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"
PROXY_API_KEY_ENV = "LITELLM_PROXY_API_KEY"
BEDROCK_INVOKE_MODELS = [
"claude-haiku-4-5-bedrock-invoke",
"claude-sonnet-4-6-bedrock-invoke",
"claude-sonnet-4-5-bedrock-invoke",
"claude-opus-4-7-bedrock-invoke",
]
@ -90,22 +87,9 @@ def _build_minimal_pdf(marker: str) -> bytes:
return bytes(out)
@pytest.mark.covers("llm.messages.bedrock_invoke.pdf_input.nonstream.works")
def test_pdf_input_bedrock_invoke(compat_result, tmp_path):
base_url = os.environ.get(PROXY_BASE_URL_ENV)
api_key = os.environ.get(PROXY_API_KEY_ENV)
if not base_url or not api_key:
compat_result.set(
{
"status": "fail",
"error": (
f"missing required env: set {PROXY_BASE_URL_ENV} and "
f"{PROXY_API_KEY_ENV} to point at a running LiteLLM proxy"
),
}
)
pytest.fail(
f"{PROXY_BASE_URL_ENV} / {PROXY_API_KEY_ENV} not configured", pytrace=False
)
base_url, api_key = require_proxy(compat_result)
pdf_path = tmp_path / "marker.pdf"
pdf_path.write_bytes(_build_minimal_pdf(PDF_MARKER))

Some files were not shown because too many files have changed in this diff Show more