mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge branch 'litellm_internal_staging' into litellm_fix_midstream_mockvalser_logprobs
This commit is contained in:
commit
c887efb452
200 changed files with 4646 additions and 3819 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -98,4 +98,8 @@ spec:
|
|||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
|
|
|||
6
helm/litellm/templates/backend/poddisruptionbudget.yaml
Normal file
6
helm/litellm/templates/backend/poddisruptionbudget.yaml
Normal 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" .)) }}
|
||||
|
|
@ -100,4 +100,8 @@ spec:
|
|||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.gateway.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
|
|
|||
6
helm/litellm/templates/gateway/poddisruptionbudget.yaml
Normal file
6
helm/litellm/templates/gateway/poddisruptionbudget.yaml
Normal 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" .)) }}
|
||||
|
|
@ -76,4 +76,8 @@ spec:
|
|||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.ui.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
|
|
|||
6
helm/litellm/templates/ui/poddisruptionbudget.yaml
Normal file
6
helm/litellm/templates/ui/poddisruptionbudget.yaml
Normal 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" .)) }}
|
||||
188
helm/litellm/tests/pdb_topology_spread_tests.yaml
Normal file
188
helm/litellm/tests/pdb_topology_spread_tests.yaml
Normal 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
|
||||
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "issuer" TEXT;
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("\n❌ Team 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"\n✅ Selected 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("\n❌ Team 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"\n✅ Automatically 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"\n✅ Selected 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("\n❌ Team selection cancelled.")
|
||||
click.echo("\nTeam selection cancelled.")
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -628,7 +626,7 @@ def login(ctx: click.Context):
|
|||
}
|
||||
)
|
||||
|
||||
click.echo("\n✅ Login 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("\n❌ Authentication 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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
86
tests/e2e/claude_code/_compat_models.py
Normal file
86
tests/e2e/claude_code/_compat_models.py
Normal 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)
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
74
tests/e2e/claude_code/_env.py
Normal file
74
tests/e2e/claude_code/_env.py
Normal 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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
166
tests/e2e/claude_code/_pr_gate_unit_tests/test_compat_models.py
Normal file
166
tests/e2e/claude_code/_pr_gate_unit_tests/test_compat_models.py
Normal 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}"
|
||||
)
|
||||
162
tests/e2e/claude_code/_pr_gate_unit_tests/test_env_resolution.py
Normal file
162
tests/e2e/claude_code/_pr_gate_unit_tests/test_env_resolution.py
Normal 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 == []
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue