diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..51d489459d9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +/ui/ @yuneng-jiang @ryan-crabbe-berri +/litellm/proxy/_experimental/out/ @yuneng-jiang @ryan-crabbe-berri +/ui/litellm-dashboard/src/lib/http/schema.d.ts diff --git a/.github/actions/setup-uv-with-retries/action.yml b/.github/actions/setup-uv-with-retries/action.yml new file mode 100644 index 00000000000..1627038dc3d --- /dev/null +++ b/.github/actions/setup-uv-with-retries/action.yml @@ -0,0 +1,47 @@ +name: "Set up uv with retries" +description: >- + Install uv via astral-sh/setup-uv, retrying on transient failures. Even with + an exact pinned version, the action resolves the artifact URL by fetching + https://raw.githubusercontent.com/astral-sh/versions/main/v1/uv.ndjson in a + single request with no retry, timeout, or fallback, so one connection-level + network error ("fetch failed") fails the whole job before any test runs. + Retrying the full step covers the manifest fetch and the binary download. + +inputs: + version: + description: "uv version to install" + required: true + +runs: + using: composite + steps: + - name: Set up uv (attempt 1) + id: attempt-1 + continue-on-error: true + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + version: ${{ inputs.version }} + + - name: Wait before attempt 2 + if: steps.attempt-1.outcome == 'failure' + shell: bash + run: sleep 15 + + - name: Set up uv (attempt 2) + id: attempt-2 + if: steps.attempt-1.outcome == 'failure' + continue-on-error: true + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + version: ${{ inputs.version }} + + - name: Wait before attempt 3 + if: steps.attempt-2.outcome == 'failure' + shell: bash + run: sleep 30 + + - name: Set up uv (attempt 3) + if: steps.attempt-2.outcome == 'failure' + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + version: ${{ inputs.version }} diff --git a/.github/workflows/_test-unit-base.yml b/.github/workflows/_test-unit-base.yml index 9fd81b27f3b..92230fc8892 100644 --- a/.github/workflows/_test-unit-base.yml +++ b/.github/workflows/_test-unit-base.yml @@ -63,7 +63,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/auto_update_price_and_context_window.yml b/.github/workflows/auto_update_price_and_context_window.yml index 1c6c318c717..1a638a4a331 100644 --- a/.github/workflows/auto_update_price_and_context_window.yml +++ b/.github/workflows/auto_update_price_and_context_window.yml @@ -18,7 +18,7 @@ jobs: with: persist-credentials: false - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" - name: Update JSON Data diff --git a/.github/workflows/check-ui-api-types.yml b/.github/workflows/check-ui-api-types.yml index 439126aa1ee..9c24bad00f1 100644 --- a/.github/workflows/check-ui-api-types.yml +++ b/.github/workflows/check-ui-api-types.yml @@ -31,7 +31,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index a1772102b89..54a8e53d7a3 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -37,7 +37,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/mutation-test.yml b/.github/workflows/mutation-test.yml index 183f12f969c..6684952b998 100644 --- a/.github/workflows/mutation-test.yml +++ b/.github/workflows/mutation-test.yml @@ -39,7 +39,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/oss_daily_guardrails.yml b/.github/workflows/oss_daily_guardrails.yml index 950c51c9b60..f9dc746ee05 100644 --- a/.github/workflows/oss_daily_guardrails.yml +++ b/.github/workflows/oss_daily_guardrails.yml @@ -35,7 +35,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/test-code-quality.yml b/.github/workflows/test-code-quality.yml index 872a1799d98..9d28ca211cf 100644 --- a/.github/workflows/test-code-quality.yml +++ b/.github/workflows/test-code-quality.yml @@ -38,7 +38,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/test-linting.yml b/.github/workflows/test-linting.yml index 0b5b0e9b976..09406d77634 100644 --- a/.github/workflows/test-linting.yml +++ b/.github/workflows/test-linting.yml @@ -33,7 +33,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" @@ -172,7 +172,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/test-mcp.yml b/.github/workflows/test-mcp.yml index 5b5290880c1..a5a4e722133 100644 --- a/.github/workflows/test-mcp.yml +++ b/.github/workflows/test-mcp.yml @@ -32,7 +32,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/test-semgrep.yml b/.github/workflows/test-semgrep.yml index f0dcb9887be..6e9f5e42fa2 100644 --- a/.github/workflows/test-semgrep.yml +++ b/.github/workflows/test-semgrep.yml @@ -31,7 +31,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/test-terraform-provider.yml b/.github/workflows/test-terraform-provider.yml index 03d8ff3461c..058a2538c15 100644 --- a/.github/workflows/test-terraform-provider.yml +++ b/.github/workflows/test-terraform-provider.yml @@ -74,7 +74,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/test-unit-documentation.yml b/.github/workflows/test-unit-documentation.yml index 03f9f0a510b..c12a289ce9f 100644 --- a/.github/workflows/test-unit-documentation.yml +++ b/.github/workflows/test-unit-documentation.yml @@ -42,7 +42,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/test-unit-proxy-legacy.yml b/.github/workflows/test-unit-proxy-legacy.yml index 0068e80e584..bcbf365babf 100644 --- a/.github/workflows/test-unit-proxy-legacy.yml +++ b/.github/workflows/test-unit-proxy-legacy.yml @@ -59,7 +59,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.gitignore b/.gitignore index e3ccf50508f..0c976a1a226 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,13 @@ STABILIZATION_TODO.md **/coverage test-config +# Claude Code compatibility-matrix pytest artifact (CI-only output). +compat-results.json +compat-results.json.shards/ +compat-rate-limit-summary.json +# Matrix JSON produced by the daily-cron publisher (pushed to litellm-docs). +compatibility-matrix.json + # ---------- Terraform ---------- # Provider binaries + module cache — regenerated by `terraform init`. **/.terraform/ diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index 85ccbef752f..97571a4576d 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-enterprise" -version = "0.1.49" +version = "0.1.50" description = "Package for LiteLLM Enterprise features" readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.1.49" +version = "0.1.50" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-enterprise==", diff --git a/helm/litellm-helm/README.md b/helm/litellm-helm/README.md index 74e70f4aeb4..0edc4d2504b 100644 --- a/helm/litellm-helm/README.md +++ b/helm/litellm-helm/README.md @@ -54,6 +54,12 @@ If `db.useStackgresOperator` is used (not yet implemented): | `pdb.annotations` | Extra metadata annotations to add to the PDB | `{}` | | `pdb.labels` | Extra metadata labels to add to the PDB | `{}` | +| `billingMetrics.enabled` | Enable enterprise billable-request metering. Requires an enterprise license. | `false` | +| `billingMetrics.endpoint` | Collector that the billable-request counter is pushed to. | `https://telemetry.litellm.ai` | +| `billingMetrics.secretName` | Name of an existing Secret holding the mTLS client certificate, under the keys `tls.crt` and `tls.key`. | `litellm-billing-metrics-mtls` | +| `billingMetrics.caSecretName` | Name of an existing Secret holding a CA bundle under the key `ca.crt`. Only needed for a private or test collector whose server certificate is not on the public web PKI. | `""` | +| `billingMetrics.exportIntervalMs` | How often the counter is pushed, in milliseconds. The proxy defaults to `60000` when unset. | `""` | + #### Example `proxy_config` ConfigMap from values (default): ``` @@ -94,6 +100,21 @@ data: type: Opaque ``` +#### Enterprise billable-request metering + +Enterprise licenses meter billable requests by pushing a counter to LiteLLM's collector over mutual TLS. The chart does not create the client certificate; it mounts one you already hold, read-only, so the private key is never exposed through the environment. Create the Secret under the name the chart expects, then turn the block on: + +``` +kubectl create secret tls litellm-billing-metrics-mtls --cert=client.crt --key=client.key +``` + +``` +billingMetrics: + enabled: true +``` + +Set `billingMetrics.caSecretName` only when the collector is a private or test one whose server certificate is not on the public web PKI; the production collector needs no CA override. The chart fails the render rather than deploying a proxy that silently never exports, so a missing `secretName` or an emptied `endpoint` surfaces at `helm install` time. + ### Database Settings | Name | Description | Value | diff --git a/helm/litellm-helm/templates/_helpers.tpl b/helm/litellm-helm/templates/_helpers.tpl index 469d52c03a7..387bc3d5dc4 100644 --- a/helm/litellm-helm/templates/_helpers.tpl +++ b/helm/litellm-helm/templates/_helpers.tpl @@ -50,6 +50,53 @@ app.kubernetes.io/name: {{ include "litellm.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} +{{/* +Enterprise billable-request metering. The client certificate identifies the +deployment to LiteLLM's collector, so it is mounted read-only from an existing +Secret rather than passed through the environment. +*/}} +{{- define "litellm.billingMetrics.certDir" -}}/etc/litellm/billing-mtls{{- end -}} +{{- define "litellm.billingMetrics.caDir" -}}/etc/litellm/billing-mtls-ca{{- end -}} + +{{- define "litellm.billingMetricsEnv" -}} +- name: LITELLM_BILLING_METRICS_ENDPOINT + value: {{ required "billingMetrics.endpoint is required when billingMetrics.enabled is true" .Values.billingMetrics.endpoint | quote }} +- name: LITELLM_BILLING_METRICS_CLIENT_CERT + value: {{ printf "%s/tls.crt" (include "litellm.billingMetrics.certDir" .) | quote }} +- name: LITELLM_BILLING_METRICS_CLIENT_KEY + value: {{ printf "%s/tls.key" (include "litellm.billingMetrics.certDir" .) | quote }} +{{- if .Values.billingMetrics.caSecretName }} +- name: LITELLM_BILLING_METRICS_CA_CERT + value: {{ printf "%s/ca.crt" (include "litellm.billingMetrics.caDir" .) | quote }} +{{- end }} +{{- with .Values.billingMetrics.exportIntervalMs }} +- name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS + value: {{ . | quote }} +{{- end }} +{{- end -}} + +{{- define "litellm.billingMetricsVolumes" -}} +- name: billing-metrics-mtls + secret: + secretName: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key)" .Values.billingMetrics.secretName }} +{{- if .Values.billingMetrics.caSecretName }} +- name: billing-metrics-mtls-ca + secret: + secretName: {{ .Values.billingMetrics.caSecretName }} +{{- end }} +{{- end -}} + +{{- define "litellm.billingMetricsVolumeMounts" -}} +- name: billing-metrics-mtls + mountPath: {{ include "litellm.billingMetrics.certDir" . }} + readOnly: true +{{- if .Values.billingMetrics.caSecretName }} +- name: billing-metrics-mtls-ca + mountPath: {{ include "litellm.billingMetrics.caDir" . }} + readOnly: true +{{- end }} +{{- end -}} + {{/* Create the name of the service account to use */}} diff --git a/helm/litellm-helm/templates/deployment.yaml b/helm/litellm-helm/templates/deployment.yaml index b9cd1be06ec..32bfa4b2647 100644 --- a/helm/litellm-helm/templates/deployment.yaml +++ b/helm/litellm-helm/templates/deployment.yaml @@ -142,6 +142,9 @@ spec: {{- with .Values.extraEnvVars }} {{- toYaml . | nindent 12 }} {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsEnv" . | nindent 12 }} + {{- end }} {{- if .Values.migrationJob.enabled }} # Schema updates are owned by the dedicated migrations Job; skip # the proxy's startup `prisma db push` so N replicas don't race @@ -220,6 +223,9 @@ spec: - name: npm mountPath: /.npm {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }} + {{- end }} {{- with .Values.volumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} @@ -252,6 +258,9 @@ spec: items: - key: {{ .Values.proxyConfigMap.key | default "config.yaml" }} path: "config.yaml" + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsVolumes" . | nindent 8 }} + {{- end }} {{- with .Values.volumes }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/helm/litellm-helm/tests/billing_metrics_tests.yaml b/helm/litellm-helm/tests/billing_metrics_tests.yaml new file mode 100644 index 00000000000..71803df378c --- /dev/null +++ b/helm/litellm-helm/tests/billing_metrics_tests.yaml @@ -0,0 +1,297 @@ +suite: test billingMetrics wiring on the proxy deployment +templates: + - deployment.yaml + - configmap-litellm.yaml + - migrations-job.yaml +tests: + - it: is off by default, adding no env, volume, or mount + template: deployment.yaml + asserts: + - notContains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls + secret: + secretName: litellm-billing-metrics-mtls + - notContains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: billing-metrics-mtls + mountPath: /etc/litellm/billing-mtls + readOnly: true + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_ENDPOINT + value: https://telemetry.litellm.ai + + - it: renders the endpoint and the mounted cert paths when enabled + template: deployment.yaml + set: + billingMetrics: + enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_ENDPOINT + value: https://telemetry.litellm.ai + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_CLIENT_CERT + value: /etc/litellm/billing-mtls/tls.crt + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_CLIENT_KEY + value: /etc/litellm/billing-mtls/tls.key + + # The conventional Secret name is the default, so enabling the block is enough. + - it: mounts the default cert secret read-only alongside the config volume + template: deployment.yaml + set: + billingMetrics: + enabled: true + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls + secret: + secretName: litellm-billing-metrics-mtls + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: billing-metrics-mtls + mountPath: /etc/litellm/billing-mtls + readOnly: true + + - it: honours a secretName override + template: deployment.yaml + set: + billingMetrics: + enabled: true + secretName: my-billing-mtls + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls + secret: + secretName: my-billing-mtls + - notContains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls + secret: + secretName: litellm-billing-metrics-mtls + + - it: honours an endpoint override + template: deployment.yaml + set: + billingMetrics: + enabled: true + endpoint: https://collector.internal:4318 + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_ENDPOINT + value: https://collector.internal:4318 + + # The production collector presents a public web-PKI certificate, so the CA + # override must stay absent unless a private collector is configured. + - it: omits the CA env, volume, and mount when no caSecretName is set + template: deployment.yaml + set: + billingMetrics: + enabled: true + asserts: + - notContains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls-ca + secret: + secretName: billing-ca + - notContains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: billing-metrics-mtls-ca + mountPath: /etc/litellm/billing-mtls-ca + readOnly: true + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_CA_CERT + value: /etc/litellm/billing-mtls-ca/ca.crt + + - it: mounts the CA secret when caSecretName is set + template: deployment.yaml + set: + billingMetrics: + enabled: true + caSecretName: billing-ca + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_CA_CERT + value: /etc/litellm/billing-mtls-ca/ca.crt + - contains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls-ca + secret: + secretName: billing-ca + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: billing-metrics-mtls-ca + mountPath: /etc/litellm/billing-mtls-ca + readOnly: true + + - it: passes the export interval through only when set + template: deployment.yaml + set: + billingMetrics: + enabled: true + exportIntervalMs: 5000 + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS + value: "5000" + + - it: omits the export interval when unset + template: deployment.yaml + set: + billingMetrics: + enabled: true + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS + value: "60000" + + # Kubernetes resolves duplicate env names last-wins, so the chart-owned billing + # entries must render after .Values.envVars or a user could silently redirect + # the metering export. The three billing entries are the last ones emitted here + # (migrationJob, which appends DISABLE_SCHEMA_UPDATE, is off for this case). + - it: renders the billing endpoint after envVars so it cannot be shadowed + template: deployment.yaml + set: + migrationJob: + enabled: false + billingMetrics: + enabled: true + envVars: + LITELLM_BILLING_METRICS_ENDPOINT: https://shadowed.example + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_ENDPOINT + value: https://shadowed.example + - equal: + path: spec.template.spec.containers[0].env[-3] + value: + name: LITELLM_BILLING_METRICS_ENDPOINT + value: https://telemetry.litellm.ai + - equal: + path: spec.template.spec.containers[0].env[-2].name + value: LITELLM_BILLING_METRICS_CLIENT_CERT + - equal: + path: spec.template.spec.containers[0].env[-1].name + value: LITELLM_BILLING_METRICS_CLIENT_KEY + + - it: keeps user-supplied volumes and mounts alongside the billing secret + template: deployment.yaml + set: + billingMetrics: + enabled: true + volumes: + - name: custom-callbacks + configMap: + name: my-callbacks + volumeMounts: + - name: custom-callbacks + mountPath: /app/callbacks + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: custom-callbacks + configMap: + name: my-callbacks + - contains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls + secret: + secretName: litellm-billing-metrics-mtls + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: custom-callbacks + mountPath: /app/callbacks + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: billing-metrics-mtls + mountPath: /etc/litellm/billing-mtls + readOnly: true + + - it: still mounts the proxy config when enabled + template: deployment.yaml + set: + billingMetrics: + enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: litellm-config + mountPath: /etc/litellm/config.yaml + subPath: config.yaml + + # Only the proxy serves billable traffic. The migrations Job must never mount + # the client certificate, and it renders its own env and volumes, so nothing + # stops a future edit from wiring the billing include into it by mistake. + - it: does not touch the migrations job when enabled + template: migrations-job.yaml + set: + billingMetrics: + enabled: true + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_ENDPOINT + value: https://telemetry.litellm.ai + - notExists: + path: spec.template.spec.containers[0].volumeMounts + - notExists: + path: spec.template.spec.volumes + + - it: fails loudly when enabled with an emptied secretName + template: deployment.yaml + set: + billingMetrics: + enabled: true + secretName: "" + asserts: + - failedTemplate: + errorMessage: billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key) + + - it: fails loudly when enabled without an endpoint + template: deployment.yaml + set: + billingMetrics: + enabled: true + endpoint: "" + asserts: + - failedTemplate: + errorMessage: billingMetrics.endpoint is required when billingMetrics.enabled is true diff --git a/helm/litellm-helm/values.yaml b/helm/litellm-helm/values.yaml index d3821a547e5..0529e74d6e4 100644 --- a/helm/litellm-helm/values.yaml +++ b/helm/litellm-helm/values.yaml @@ -139,6 +139,20 @@ masterkeySecretName: "" # if set, use this secret key for the master key; otherwise, use the default key masterkeySecretKey: "" +# Optional: enterprise billable-request metering. When enabled, the proxy counts +# successful requests to inference, MCP, and A2A endpoints and pushes them to +# LiteLLM's collector over mutual TLS. Requires an enterprise license. +# The client certificate identifies the deployment, so it is mounted read-only +# from an existing Secret and never passed through the environment. +billingMetrics: + enabled: false + endpoint: https://telemetry.litellm.ai # collector to push the counter to + secretName: litellm-billing-metrics-mtls # existing Secret holding tls.crt and tls.key + # Only for private or test collectors whose server certificate is not on the + # public web PKI. The production collector needs no CA override. + caSecretName: "" # existing Secret holding ca.crt + exportIntervalMs: "" # push cadence; the proxy defaults to 60000 + proxyConfigMap: # when true, creates a new configmap create: true diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl index 7c281aa158b..043a0afc173 100644 --- a/helm/litellm/templates/_helpers.tpl +++ b/helm/litellm/templates/_helpers.tpl @@ -34,6 +34,57 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} {{- end -}} +{{/* +Enterprise billable-request metering. Wired into gateway and backend, not the +migrations job. The gateway serves nearly all billable traffic, but the backend +keeps the named-server MCP transport (/{mcp_server_name}/mcp), which writes a +SpendLogs row, so metering only the gateway would silently drop that traffic. +The client certificate identifies the deployment to LiteLLM's collector, so it is +mounted read-only from an existing Secret rather than passed through the +environment. +*/}} +{{- define "litellm.billingMetrics.certDir" -}}/etc/litellm/billing-mtls{{- end -}} +{{- define "litellm.billingMetrics.caDir" -}}/etc/litellm/billing-mtls-ca{{- end -}} + +{{- define "litellm.billingMetricsEnv" -}} +- name: LITELLM_BILLING_METRICS_ENDPOINT + value: {{ required "billingMetrics.endpoint is required when billingMetrics.enabled is true" .Values.billingMetrics.endpoint | quote }} +- name: LITELLM_BILLING_METRICS_CLIENT_CERT + value: {{ printf "%s/tls.crt" (include "litellm.billingMetrics.certDir" .) | quote }} +- name: LITELLM_BILLING_METRICS_CLIENT_KEY + value: {{ printf "%s/tls.key" (include "litellm.billingMetrics.certDir" .) | quote }} +{{- if .Values.billingMetrics.caSecretName }} +- name: LITELLM_BILLING_METRICS_CA_CERT + value: {{ printf "%s/ca.crt" (include "litellm.billingMetrics.caDir" .) | quote }} +{{- end }} +{{- with .Values.billingMetrics.exportIntervalMs }} +- name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS + value: {{ . | quote }} +{{- end }} +{{- end -}} + +{{- define "litellm.billingMetricsVolumes" -}} +- name: billing-metrics-mtls + secret: + secretName: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key)" .Values.billingMetrics.secretName }} +{{- if .Values.billingMetrics.caSecretName }} +- name: billing-metrics-mtls-ca + secret: + secretName: {{ .Values.billingMetrics.caSecretName }} +{{- end }} +{{- end -}} + +{{- define "litellm.billingMetricsVolumeMounts" -}} +- name: billing-metrics-mtls + mountPath: {{ include "litellm.billingMetrics.certDir" . }} + readOnly: true +{{- if .Values.billingMetrics.caSecretName }} +- name: billing-metrics-mtls-ca + mountPath: {{ include "litellm.billingMetrics.caDir" . }} + readOnly: true +{{- end }} +{{- end -}} + {{/* Per-component selector labels — used in both Service selectors and Deployment matchLabels. */}} diff --git a/helm/litellm/templates/backend/deployment.yaml b/helm/litellm/templates/backend/deployment.yaml index 8b4552bf302..9d056167fe1 100644 --- a/helm/litellm/templates/backend/deployment.yaml +++ b/helm/litellm/templates/backend/deployment.yaml @@ -44,14 +44,20 @@ spec: - name: CONFIG_FILE_PATH value: /app/config/config.yaml {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsEnv" . | nindent 12 }} + {{- end }} {{- include "litellm.envFrom" .Values.backend | nindent 10 }} - {{- if or .Values.gateway.config.create .Values.backend.volumeMounts }} + {{- if or .Values.gateway.config.create .Values.backend.volumeMounts .Values.billingMetrics.enabled }} volumeMounts: {{- if .Values.gateway.config.create }} - name: gateway-config mountPath: /app/config/config.yaml subPath: config.yaml {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }} + {{- end }} {{- with .Values.backend.volumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} @@ -66,13 +72,16 @@ spec: {{- end }} resources: {{- toYaml .Values.backend.resources | nindent 12 }} - {{- if or .Values.gateway.config.create .Values.backend.volumes }} + {{- if or .Values.gateway.config.create .Values.backend.volumes .Values.billingMetrics.enabled }} volumes: {{- if .Values.gateway.config.create }} - name: gateway-config configMap: name: {{ include "litellm.gateway.fullname" . }}-config {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsVolumes" . | nindent 8 }} + {{- end }} {{- with .Values.backend.volumes }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/helm/litellm/templates/gateway/deployment.yaml b/helm/litellm/templates/gateway/deployment.yaml index bd491b69e0f..4c80d784156 100644 --- a/helm/litellm/templates/gateway/deployment.yaml +++ b/helm/litellm/templates/gateway/deployment.yaml @@ -46,14 +46,20 @@ spec: - name: NUM_WORKERS value: {{ .Values.gateway.numWorkers | quote }} {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsEnv" . | nindent 12 }} + {{- end }} {{- include "litellm.envFrom" .Values.gateway | nindent 10 }} - {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts }} + {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.billingMetrics.enabled }} volumeMounts: {{- if .Values.gateway.config.create }} - name: gateway-config mountPath: /app/config/config.yaml subPath: config.yaml {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }} + {{- end }} {{- with .Values.gateway.volumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} @@ -68,13 +74,16 @@ spec: {{- end }} resources: {{- toYaml .Values.gateway.resources | nindent 12 }} - {{- if or .Values.gateway.config.create .Values.gateway.volumes }} + {{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.billingMetrics.enabled }} volumes: {{- if .Values.gateway.config.create }} - name: gateway-config configMap: name: {{ include "litellm.gateway.fullname" . }}-config {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsVolumes" . | nindent 8 }} + {{- end }} {{- with .Values.gateway.volumes }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/helm/litellm/tests/billing_metrics_tests.yaml b/helm/litellm/tests/billing_metrics_tests.yaml new file mode 100644 index 00000000000..ceba0bd1430 --- /dev/null +++ b/helm/litellm/tests/billing_metrics_tests.yaml @@ -0,0 +1,249 @@ +suite: test billingMetrics wiring on gateway and backend +templates: + - gateway/deployment.yaml + - gateway/configmap.yaml + - backend/deployment.yaml + - migrations-job.yaml +values: + - ./values/required.yaml +tests: + - it: is off by default, adding no env, volume, or mount + template: gateway/deployment.yaml + asserts: + - notContains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls + secret: + secretName: billing-mtls + - equal: + path: spec.template.spec.containers[0].volumeMounts + value: + - name: gateway-config + mountPath: /app/config/config.yaml + subPath: config.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_ENDPOINT + value: https://telemetry.litellm.ai + + - it: renders the endpoint and the mounted cert paths when enabled + template: gateway/deployment.yaml + set: + billingMetrics: + enabled: true + secretName: billing-mtls + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_ENDPOINT + value: https://telemetry.litellm.ai + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_CLIENT_CERT + value: /etc/litellm/billing-mtls/tls.crt + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_CLIENT_KEY + value: /etc/litellm/billing-mtls/tls.key + + - it: mounts the cert secret read-only alongside the config volume + template: gateway/deployment.yaml + set: + billingMetrics: + enabled: true + secretName: billing-mtls + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls + secret: + secretName: billing-mtls + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: billing-metrics-mtls + mountPath: /etc/litellm/billing-mtls + readOnly: true + + # The production collector presents a public web-PKI certificate, so the CA + # override must stay absent unless a private collector is configured. + - it: omits the CA env, volume, and mount when no caSecretName is set + template: gateway/deployment.yaml + set: + billingMetrics: + enabled: true + secretName: billing-mtls + asserts: + - notContains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls-ca + secret: + secretName: billing-ca + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_CA_CERT + value: /etc/litellm/billing-mtls-ca/ca.crt + + - it: mounts the CA secret when caSecretName is set + template: gateway/deployment.yaml + set: + billingMetrics: + enabled: true + secretName: billing-mtls + caSecretName: billing-ca + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_CA_CERT + value: /etc/litellm/billing-mtls-ca/ca.crt + - contains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls-ca + secret: + secretName: billing-ca + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: billing-metrics-mtls-ca + mountPath: /etc/litellm/billing-mtls-ca + readOnly: true + + - it: passes the export interval through only when set + template: gateway/deployment.yaml + set: + billingMetrics: + enabled: true + secretName: billing-mtls + exportIntervalMs: 5000 + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS + value: "5000" + + - it: keeps user-supplied gateway volumes alongside the billing secret + template: gateway/deployment.yaml + set: + billingMetrics: + enabled: true + secretName: billing-mtls + gateway.volumes: + - name: custom-callbacks + configMap: + name: my-callbacks + gateway.volumeMounts: + - name: custom-callbacks + mountPath: /app/callbacks + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: custom-callbacks + configMap: + name: my-callbacks + - contains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls + secret: + secretName: billing-mtls + + # The backend keeps the named-server MCP transport (/{mcp_server_name}/mcp), + # which writes a SpendLogs row, so it must meter too or that traffic is lost. + - it: meters the backend as well, since it serves the MCP transport + template: backend/deployment.yaml + set: + billingMetrics: + enabled: true + secretName: billing-mtls + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_ENDPOINT + value: https://telemetry.litellm.ai + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: billing-metrics-mtls + mountPath: /etc/litellm/billing-mtls + readOnly: true + - contains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls + secret: + secretName: billing-mtls + + - it: leaves the backend alone when metering is off + template: backend/deployment.yaml + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_ENDPOINT + value: https://telemetry.litellm.ai + + # The migrations job runs prisma and serves no traffic; it must never receive + # the client key. + - it: never mounts the billing cert on the migrations job + template: migrations-job.yaml + set: + billingMetrics: + enabled: true + secretName: billing-mtls + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_ENDPOINT + value: https://telemetry.litellm.ai + - isNull: + path: spec.template.spec.volumes + + # The conventional Secret name is the default, so enabling metering needs no + # secretName at all; the guard below only fires on an explicitly blanked one. + - it: uses the conventional secret name by default + template: gateway/deployment.yaml + set: + billingMetrics: + enabled: true + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls + secret: + secretName: litellm-billing-metrics-mtls + + - it: fails loudly when the secretName is explicitly blanked + template: gateway/deployment.yaml + set: + billingMetrics: + enabled: true + secretName: "" + asserts: + - failedTemplate: + errorMessage: billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key) + + - it: fails loudly when enabled without an endpoint + template: gateway/deployment.yaml + set: + billingMetrics: + enabled: true + endpoint: "" + secretName: billing-mtls + asserts: + - failedTemplate: + errorMessage: billingMetrics.endpoint is required when billingMetrics.enabled is true diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml index a8f2d39663e..74d02a25b7a 100644 --- a/helm/litellm/values.yaml +++ b/helm/litellm/values.yaml @@ -73,6 +73,25 @@ masterKey: secretName: litellm-master-key-secret # name of a Secret containing the master key secretKey: master-key +# Optional: enterprise billable-request metering. When enabled, the gateway and +# backend count successful requests to inference, MCP, and A2A endpoints and push +# them to LiteLLM's collector over mutual TLS. Both components serve billable +# routes: the backend keeps the named-server MCP transport. Requires an +# enterprise license. The client certificate identifies the deployment, so it is +# mounted read-only from an existing Secret and never passed through the env. +billingMetrics: + enabled: false + endpoint: https://telemetry.litellm.ai # collector to push the counter to + # An existing Secret holding the client certificate under tls.crt and its key + # under tls.key, usually created from the onboarding artifact. The default is + # the conventional name, so the common path is to create that Secret and set + # enabled: true. Override only if yours is named differently. + secretName: litellm-billing-metrics-mtls + # Only for private or test collectors whose server certificate is not on the + # public web PKI. The production collector needs no CA override. + caSecretName: "" # existing Secret holding ca.crt + exportIntervalMs: "" # push cadence; the proxy defaults to 60000 + # External Postgres connection. database: writer: diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260713230852_add_key_type_to_litellm_verification_token/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260713230852_add_key_type_to_litellm_verification_token/migration.sql new file mode 100644 index 00000000000..708b7601346 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260713230852_add_key_type_to_litellm_verification_token/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN "key_type" TEXT; + +-- AlterTable +ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN "key_type" TEXT; + diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index fb4d8d0b5a3..a23cecc3911 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -422,6 +422,7 @@ model LiteLLM_VerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") @@ -516,6 +517,7 @@ model LiteLLM_DeletedVerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index a54db2ace65..b67d9d8570a 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-proxy-extras" -version = "0.4.76" +version = "0.4.77" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.4.76" +version = "0.4.77" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-proxy-extras==", diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index c8bfcabc64e..856556f7c56 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -515,6 +515,22 @@ class CustomGuardrail(CustomLogger): return True return False + def uses_apply_guardrail_interface(self) -> bool: + return type(self).apply_guardrail is not CustomGuardrail.apply_guardrail + + def _deployment_pre_call_target(self) -> "CustomLogger": + if not self.uses_apply_guardrail_interface(): + return self + try: + from litellm.proxy.utils import unified_guardrail + except ImportError as e: + raise ImportError( + f"Guardrail {self.guardrail_name or type(self).__name__} implements apply_guardrail, which needs " + "the litellm proxy dependencies to run at the deployment level. " + "Install them with: pip install 'litellm[proxy]'" + ) from e + return unified_guardrail + async def async_pre_call_deployment_hook( self, kwargs: Dict[str, Any], call_type: Optional[CallTypes] ) -> Optional[dict]: @@ -533,7 +549,10 @@ class CustomGuardrail(CustomLogger): # CHECK IF GUARDRAIL REJECTS THE REQUEST if call_type == CallTypes.completion or call_type == CallTypes.acompletion: - result = await self.async_pre_call_hook( + target = self._deployment_pre_call_target() + if target is not self: + kwargs["guardrail_to_apply"] = self + result = await target.async_pre_call_hook( user_api_key_dict=UserAPIKeyAuth( user_id=kwargs.get("user_api_key_user_id"), team_id=kwargs.get("user_api_key_team_id"), @@ -543,7 +562,7 @@ class CustomGuardrail(CustomLogger): ), cache=dc, data=kwargs, - call_type=call_type.value or "acompletion", # type: ignore + call_type="completion" if call_type == CallTypes.completion else "acompletion", ) if result is not None and isinstance(result, dict): diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index f575372fc3d..64d4dd578b2 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -239,6 +239,18 @@ class PrometheusLogger(CustomLogger): labelnames=self.get_labels_for_metric("litellm_output_audio_tokens_metric"), ) + self.litellm_video_duration_seconds_metric = self._counter_factory( + "litellm_video_duration_seconds_metric", + "Seconds of video generated, from usage.duration_seconds on video generation calls", + labelnames=self.get_labels_for_metric("litellm_video_duration_seconds_metric"), + ) + + self.litellm_images_generated_metric = self._counter_factory( + "litellm_images_generated_metric", + "Number of images generated, from the image generation response", + labelnames=self.get_labels_for_metric("litellm_images_generated_metric"), + ) + # Remaining Budget for Team self.litellm_remaining_team_budget_metric = self._gauge_factory( "litellm_remaining_team_budget_metric", @@ -1336,6 +1348,12 @@ class PrometheusLogger(CustomLogger): label_context=label_context, ) + self._increment_media_generation_metrics( + standard_logging_payload=standard_logging_payload, + enum_values=enum_values, + label_context=label_context, + ) + # MCP tool call metrics self._increment_mcp_tool_call_metrics( standard_logging_payload=standard_logging_payload, @@ -1459,8 +1477,65 @@ class PrometheusLogger(CustomLogger): ), ] - for counter, metric_name, value in detail_metrics: - if not isinstance(value, (int, float)) or value <= 0: + PrometheusLogger._inc_sparse_usage_counters( + self, + detail_metrics, + enum_values=enum_values, + label_context=label_context, + ) + + def _increment_media_generation_metrics( + self, + standard_logging_payload: StandardLoggingPayload, + enum_values: UserAPIKeyLabelValues, + label_context: PrometheusLabelFactoryContext | None = None, + ) -> None: + """ + Increment video-seconds and images-generated counters from + ``standard_logging_payload["metadata"]["usage_object"]``. Video + providers report ``duration_seconds`` there; image generation calls + report ``output_image_count``. Both are sparse: only emitted when the + value is present and > 0, so token-only call types are unaffected. + """ + metadata = standard_logging_payload.get("metadata") or {} + usage_object = metadata.get("usage_object") if isinstance(metadata, dict) else None + if not isinstance(usage_object, dict): + return + + media_metrics: list[tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]] = [ + ( + self.litellm_video_duration_seconds_metric, + "litellm_video_duration_seconds_metric", + usage_object.get("duration_seconds"), + ), + ( + self.litellm_images_generated_metric, + "litellm_images_generated_metric", + usage_object.get("output_image_count"), + ), + ] + + PrometheusLogger._inc_sparse_usage_counters( + self, + media_metrics, + enum_values=enum_values, + label_context=label_context, + ) + + def _inc_sparse_usage_counters( + self, + counters_with_values: list[tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]], + enum_values: UserAPIKeyLabelValues, + label_context: PrometheusLabelFactoryContext | None = None, + ) -> None: + """ + Increment each ``(counter, metric_name, value)`` entry whose value is + a positive number. Non-numeric values (including booleans from + malformed provider usage dicts) and values <= 0 are skipped, keeping + scrape output sparse. + """ + for counter, metric_name, value in counters_with_values: + if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0: continue PrometheusLogger._inc_labeled_counter( self, @@ -1716,6 +1791,35 @@ class PrometheusLogger(CustomLogger): amount=float(response_cost), ) + @staticmethod + def _get_remaining_from_v3_rate_limit_headers( + standard_logging_payload: StandardLoggingPayload | None, + rate_limit_type: Literal["requests", "tokens"], + ) -> int | None: + """ + Read the per-(key, model) remaining value emitted by the v3 rate + limiter (``parallel_request_limiter_v3.py``), which writes + ``x-ratelimit-model_per_key-remaining-{requests,tokens}`` into + ``standard_logging_object.hidden_params.additional_headers`` instead + of the ``litellm-key-remaining-*`` metadata keys the legacy limiter + sets. The header carries no model group; it always refers to this + request's model group, which is what the gauges are labeled with. + Values are written in-process as plain ints (never HTTP-serialized + strings), so anything else is rejected rather than coerced. + """ + if standard_logging_payload is None: + return None + hidden_params = standard_logging_payload.get("hidden_params") + if hidden_params is None: + return None + additional_headers = hidden_params.get("additional_headers") + if additional_headers is None: + return None + value = dict(additional_headers).get(f"x-ratelimit-model_per_key-remaining-{rate_limit_type}") + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + def _set_virtual_key_rate_limit_metrics( self, user_api_key: Optional[str], @@ -1733,11 +1837,20 @@ class PrometheusLogger(CustomLogger): model_group = get_model_group_from_litellm_kwargs(kwargs) remaining_requests_variable_name = f"litellm-key-remaining-requests-{model_group}" remaining_tokens_variable_name = f"litellm-key-remaining-tokens-{model_group}" + standard_logging_payload: StandardLoggingPayload | None = kwargs.get("standard_logging_object") remaining_requests = metadata.get(remaining_requests_variable_name) + if remaining_requests is None: + remaining_requests = self._get_remaining_from_v3_rate_limit_headers( + standard_logging_payload=standard_logging_payload, rate_limit_type="requests" + ) if remaining_requests is None: remaining_requests = sys.maxsize remaining_tokens = metadata.get(remaining_tokens_variable_name) + if remaining_tokens is None: + remaining_tokens = self._get_remaining_from_v3_rate_limit_headers( + standard_logging_payload=standard_logging_payload, rate_limit_type="tokens" + ) if remaining_tokens is None: remaining_tokens = sys.maxsize diff --git a/litellm/integrations/s3.py b/litellm/integrations/s3.py index 53a982cd2c4..e8252d87572 100644 --- a/litellm/integrations/s3.py +++ b/litellm/integrations/s3.py @@ -161,8 +161,13 @@ def get_s3_object_key( start_time: datetime, s3_file_name: str, ) -> str: + sanitized_s3_file_name = s3_file_name.replace("/", "_") s3_object_key = ( - (s3_path.rstrip("/") + "/" if s3_path else "") + prefix + start_time.strftime("%Y-%m-%d") + "/" + s3_file_name + (s3_path.rstrip("/") + "/" if s3_path else "") + + prefix + + start_time.strftime("%Y-%m-%d") + + "/" + + sanitized_s3_file_name ) # we need the s3 key to include the time, so we log cache hits too s3_object_key += ".json" return s3_object_key diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 00c67e9f0fb..21d990e8e60 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -19,9 +19,11 @@ from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.websearch_interception.tools import ( get_litellm_web_search_tool, get_litellm_web_search_tool_openai, + get_litellm_web_search_tool_responses, is_anthropic_native_web_search_tool, is_web_search_tool, is_web_search_tool_chat_completion, + is_web_search_tool_responses, ) from litellm.integrations.websearch_interception.transformation import ( WebSearchTransformation, @@ -32,11 +34,12 @@ from litellm.types.integrations.websearch_interception import ( ) from litellm.types.integrations.custom_logger import ( CHAT_COMPLETION_AGENTIC_SURFACE, + RESPONSES_AGENTIC_SURFACE, AgenticLoopPlan, AgenticLoopRequestPatch, ) from litellm.types.llms.openai import AllMessageValues -from litellm.types.utils import LlmProviders +from litellm.types.utils import CallTypes, LlmProviders from litellm.utils import ProviderConfigManager # Key used to flag, on per-request kwargs, that the originating client sent @@ -251,6 +254,9 @@ class WebSearchInterceptionLogger(CustomLogger): if not tools: return None + if call_type in (CallTypes.responses, CallTypes.aresponses): + return self._convert_responses_tools(kwargs=kwargs, tools=tools) + # Check if any tool is a web search tool (native or already LiteLLM standard) has_websearch = any(is_web_search_tool(t) for t in tools) @@ -291,6 +297,26 @@ class WebSearchInterceptionLogger(CustomLogger): return kwargs + def _convert_responses_tools(self, kwargs: dict[str, Any], tools: list[dict[str, Any]]) -> dict | None: + """Convert Responses API web search tools to the LiteLLM standard function tool.""" + if not any(is_web_search_tool_responses(tool) for tool in tools): + return None + + verbose_logger.debug("WebSearchInterception: Converting Responses web_search tools to LiteLLM standard") + + converted_tools = [ + get_litellm_web_search_tool_responses() if is_web_search_tool_responses(tool) else tool for tool in tools + ] + + converted_kwargs = {**kwargs, "tools": converted_tools} + + if kwargs.get("stream"): + verbose_logger.debug("WebSearchInterception: deployment hook converting stream=True to stream=False") + converted_kwargs["stream"] = False + converted_kwargs["_websearch_interception_converted_stream"] = True + + return converted_kwargs + @classmethod def from_config_yaml(cls, config: WebSearchInterceptionConfig) -> "WebSearchInterceptionLogger": """ @@ -461,6 +487,17 @@ class WebSearchInterceptionLogger(CustomLogger): kwargs=kwargs, ) + if kwargs.get("_agentic_loop_api_surface") == RESPONSES_AGENTIC_SURFACE: + return await self.async_should_run_responses_agentic_loop( + response=response, + model=model, + messages=messages, + tools=tools, + stream=stream, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + ) + verbose_logger.debug(f"WebSearchInterception: Hook called! provider={custom_llm_provider}, stream={stream}") verbose_logger.debug(f"WebSearchInterception: Response type: {type(response)}") @@ -597,6 +634,54 @@ class WebSearchInterceptionLogger(CustomLogger): } return True, tools_dict + async def async_should_run_responses_agentic_loop( + self, + response: Any, + model: str, + messages: list[dict], + tools: list[dict] | None, + stream: bool, + custom_llm_provider: str, + kwargs: dict, + ) -> tuple[bool, dict]: + """Check if WebSearch interception is needed for the Responses API.""" + verbose_logger.debug( + f"WebSearchInterception: Responses hook called! provider={custom_llm_provider}, stream={stream}" + ) + + if self.enabled_providers is not None and custom_llm_provider not in self.enabled_providers: + verbose_logger.debug( + f"WebSearchInterception: Skipping provider {custom_llm_provider} (not in enabled list: {self.enabled_providers})" + ) + return False, {} + + has_websearch_tool = any(is_web_search_tool_responses(t) for t in (tools or [])) + if not has_websearch_tool: + verbose_logger.debug("WebSearchInterception: No litellm_web_search tool in responses request") + return False, {} + + should_intercept, tool_calls = WebSearchTransformation.transform_request( + response=response, + stream=stream, + response_format="responses", + ) + + if not should_intercept: + verbose_logger.debug("WebSearchInterception: No WebSearch function_call detected in responses output") + return False, {} + + verbose_logger.debug( + f"WebSearchInterception: Detected {len(tool_calls)} WebSearch function_call(s), executing agentic loop" + ) + + tools_dict = { + "tool_calls": tool_calls, + "tool_type": "websearch", + "provider": custom_llm_provider, + "response_format": "responses", + } + return True, tools_dict + async def async_run_agentic_loop( self, tools: Dict, @@ -655,6 +740,18 @@ class WebSearchInterceptionLogger(CustomLogger): kwargs=kwargs, ) + if kwargs.get("_agentic_loop_api_surface") == RESPONSES_AGENTIC_SURFACE: + return await self.async_build_responses_agentic_loop_plan( + tools=tools, + model=model, + messages=messages, + response=response, + optional_params=anthropic_messages_optional_request_params, + logging_obj=logging_obj, + stream=stream, + kwargs=kwargs, + ) + tool_calls = tools["tool_calls"] thinking_blocks = tools.get("thinking_blocks", []) request_patch, structured_results = await self._build_anthropic_request_patch( @@ -809,6 +906,133 @@ class WebSearchInterceptionLogger(CustomLogger): metadata={"tool_type": "websearch", "response_format": response_format}, ) + async def async_build_responses_agentic_loop_plan( + self, + tools: dict, + model: str, + messages: list[dict], + response: Any, + optional_params: dict, + logging_obj: Any, + stream: bool, + kwargs: dict, + ) -> AgenticLoopPlan: + tool_calls = tools["tool_calls"] + request_patch = await self._build_responses_request_patch( + model=model, + messages=messages, + tool_calls=tool_calls, + optional_params=optional_params, + kwargs=kwargs, + ) + return AgenticLoopPlan( + run_agentic_loop=True, + request_patch=request_patch, + metadata={"tool_type": "websearch", "response_format": "responses"}, + ) + + async def _build_responses_request_patch( + self, + model: str, + messages: Union[str, list[dict]], + tool_calls: list[dict], + optional_params: dict, + kwargs: dict, + ) -> AgenticLoopRequestPatch: + """Execute litellm.asearch() and build a Responses API rerun patch.""" + search_tasks = [ + ( + self._execute_search(tool_call["input"]["query"], kwargs=kwargs) + if isinstance(tool_call.get("input"), dict) and tool_call["input"].get("query") + else self._create_empty_search_result() + ) + for tool_call in tool_calls + ] + + verbose_logger.debug(f"WebSearchInterception: Executing {len(search_tasks)} responses search(es) in parallel") + search_results = await asyncio.gather(*search_tasks, return_exceptions=True) + + search_texts = [self._extract_search_text(result) for result in search_results] + + followup_items = [ + item + for tool_call, search_text in zip(tool_calls, search_texts) + for item in ( + { + "type": "function_call", + "call_id": tool_call.get("call_id"), + "name": LITELLM_WEB_SEARCH_TOOL_NAME, + "arguments": tool_call.get("arguments", ""), + }, + { + "type": "function_call_output", + "call_id": tool_call.get("call_id"), + "output": search_text, + }, + ) + ] + + input_list = self._normalize_responses_input(messages) + followup_items + + tools_param = optional_params.get("tools") + optional_params_clean = { + k: v + for k, v in optional_params.items() + if k not in {"tools", "tool_choice", "stream", "model_alias_map", "stream_response", "custom_prompt_dict"} + } + + kwargs_for_followup = { + k: v + for k, v in kwargs.items() + if not k.startswith("_websearch_interception") + and k + not in { + "_agentic_loop_api_surface", + "litellm_logging_obj", + "acompletion", + "custom_llm_provider", + "model_alias_map", + } + } + + full_model_name = model + if "/" not in model and isinstance(kwargs.get("custom_llm_provider"), str): + full_model_name = f"{kwargs['custom_llm_provider']}/{model}" + + verbose_logger.debug( + "WebSearchInterception: Built responses request patch model=%s input_items=%d searches=%d", + full_model_name, + len(input_list), + len(search_texts), + ) + + return AgenticLoopRequestPatch( + model=full_model_name, + messages=input_list, + tools=tools_param if isinstance(tools_param, list) else None, + optional_params=optional_params_clean, + kwargs=kwargs_for_followup, + ) + + @staticmethod + def _normalize_responses_input(messages: Union[str, list[dict]]) -> list[dict]: + if isinstance(messages, str): + return [{"role": "user", "content": messages}] + if isinstance(messages, list): + return list(messages) + return [] + + @staticmethod + def _extract_search_text(result: Any) -> str: + if isinstance(result, Exception): + verbose_logger.error(f"WebSearchInterception: Responses search failed with error: {str(result)}") + return f"Search failed: {str(result)}" + if isinstance(result, tuple) and len(result) == 2: + text_value, _ = result + return text_value if isinstance(text_value, str) else str(text_value) + verbose_logger.debug(f"WebSearchInterception: Unexpected search result type {type(result)}") + return str(result) + @staticmethod def _resolve_max_tokens( optional_params: Dict, diff --git a/litellm/integrations/websearch_interception/tools.py b/litellm/integrations/websearch_interception/tools.py index b29372af9ed..14c8aea0908 100644 --- a/litellm/integrations/websearch_interception/tools.py +++ b/litellm/integrations/websearch_interception/tools.py @@ -82,6 +82,75 @@ def get_litellm_web_search_tool_openai() -> Dict[str, Any]: } +def get_litellm_web_search_tool_responses() -> dict[str, Any]: + """ + Get the standard LiteLLM web search tool definition in Responses API format. + + Used by async_pre_call_deployment_hook on the Responses API path, where a + function tool is a flat object (``type: "function"`` with a top-level + ``name`` and ``parameters``) rather than the nested ``function`` wrapper + used by Chat Completions. + + Returns: + Dict containing the Responses-style function tool definition. + """ + return { + "type": "function", + "name": LITELLM_WEB_SEARCH_TOOL_NAME, + "description": ( + "Search the web for information. Use this when you need current " + "information or answers to questions that require up-to-date data." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query to execute", + } + }, + "required": ["query"], + }, + } + + +def is_web_search_tool_responses(tool: dict[str, Any]) -> bool: + """ + Check if a tool is a web search tool for the Responses API. + + Detects: + - OpenAI native Responses web search tools, whose ``type`` is one of + ``web_search``, ``web_search_2025_08_26``, ``web_search_preview``, + ``web_search_preview_2025_03_11`` (matched by the ``web_search`` prefix) + - The LiteLLM standard function tool in Responses shape: + ``{"type": "function", "name": "litellm_web_search"}`` + + Args: + tool: Tool dictionary to check + + Returns: + True if tool is a Responses-API web search tool + + Example: + >>> is_web_search_tool_responses({"type": "web_search"}) + True + >>> is_web_search_tool_responses({"type": "web_search_preview"}) + True + >>> is_web_search_tool_responses({"type": "function", "name": "litellm_web_search"}) + True + >>> is_web_search_tool_responses({"type": "function", "name": "get_weather"}) + False + """ + tool_type = tool.get("type", "") + if not isinstance(tool_type, str): + return False + + if tool_type == "function": + return tool.get("name") == LITELLM_WEB_SEARCH_TOOL_NAME + + return tool_type == "web_search" or tool_type.startswith("web_search_") + + def is_web_search_tool_chat_completion(tool: Dict[str, Any]) -> bool: """ Check if a tool is a web search tool for Chat Completions API (strict check). diff --git a/litellm/integrations/websearch_interception/transformation.py b/litellm/integrations/websearch_interception/transformation.py index 7bbcd7ebff6..282d75d3d4d 100644 --- a/litellm/integrations/websearch_interception/transformation.py +++ b/litellm/integrations/websearch_interception/transformation.py @@ -59,9 +59,73 @@ class WebSearchTransformation: # Parse non-streaming response based on format if response_format == "openai": return WebSearchTransformation._detect_from_openai_response(response) + elif response_format == "responses": + return WebSearchTransformation._detect_from_responses_response(response) else: return WebSearchTransformation._detect_from_non_streaming_response(response) + @staticmethod + def _detect_from_responses_response( + response: Any, + ) -> tuple[bool, list[dict]]: + """Parse a Responses API response for ``litellm_web_search`` function calls. + + After pre-request conversion the native web search tool is replaced by a + ``litellm_web_search`` function tool, so the model emits ``function_call`` + items in ``response.output`` instead of a native ``web_search_call``. + """ + if isinstance(response, dict): + output = response.get("output", []) + else: + output = getattr(response, "output", None) or [] + + if not isinstance(output, list): + return False, [] + + tool_calls: list[dict] = [] + for item in output: + if isinstance(item, dict): + item_type = item.get("type") + item_name = item.get("name") + call_id = item.get("call_id") + arguments = item.get("arguments", "") + else: + item_type = getattr(item, "type", None) + item_name = getattr(item, "name", None) + call_id = getattr(item, "call_id", None) + arguments = getattr(item, "arguments", "") + + if item_type != "function_call" or item_name != LITELLM_WEB_SEARCH_TOOL_NAME: + continue + + if isinstance(arguments, str): + try: + parsed_input = json.loads(arguments) if arguments else {} + except json.JSONDecodeError: + verbose_logger.warning( + f"WebSearchInterception: Failed to parse function_call arguments: {arguments}" + ) + parsed_input = {} + elif isinstance(arguments, dict): + parsed_input = arguments + else: + parsed_input = {} + + arguments_str = arguments if isinstance(arguments, str) else json.dumps(parsed_input) + tool_calls.append( + { + "id": call_id, + "call_id": call_id, + "type": "function_call", + "name": item_name, + "arguments": arguments_str, + "input": parsed_input, + } + ) + verbose_logger.debug(f"WebSearchInterception: Found {item_name} function_call with call_id={call_id}") + + return len(tool_calls) > 0, tool_calls + @staticmethod def _detect_from_non_streaming_response( response: Any, diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index 352e55e9c23..b8ef9d8cca7 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -2,26 +2,8 @@ from typing import Optional from litellm.llms.openai.data_residency import infer_openai_data_residency -# Pre-define optional kwargs keys as frozenset for O(1) lookups -# These are extracted from kwargs only if present, avoiding unnecessary .get() calls -OPTIONAL_KWARGS_KEYS = frozenset( +AWS_CREDENTIAL_KWARGS_KEYS = frozenset( { - "azure_ad_token", - "tenant_id", - "client_id", - "client_secret", - "azure_username", - "azure_password", - "azure_scope", - "timeout", - "gcs_bucket_name", - "bucket_name", - "vertex_credentials", - "vertex_project", - "vertex_location", - "vertex_ai_project", - "vertex_ai_location", - "vertex_ai_credentials", "aws_region_name", "aws_access_key_id", "aws_secret_access_key", @@ -34,14 +16,40 @@ OPTIONAL_KWARGS_KEYS = frozenset( "aws_external_id", "aws_bedrock_runtime_endpoint", "aws_bedrock_project_id", - "tpm", - "rpm", - "itpm", - "otpm", - "use_xai_oauth", } ) +# Pre-define optional kwargs keys as frozenset for O(1) lookups +# These are extracted from kwargs only if present, avoiding unnecessary .get() calls +OPTIONAL_KWARGS_KEYS = ( + frozenset( + { + "azure_ad_token", + "tenant_id", + "client_id", + "client_secret", + "azure_username", + "azure_password", + "azure_scope", + "timeout", + "gcs_bucket_name", + "bucket_name", + "vertex_credentials", + "vertex_project", + "vertex_location", + "vertex_ai_project", + "vertex_ai_location", + "vertex_ai_credentials", + "tpm", + "rpm", + "itpm", + "otpm", + "use_xai_oauth", + } + ) + | AWS_CREDENTIAL_KWARGS_KEYS +) + # Backward-compatible alias for existing imports/tests. _OPTIONAL_KWARGS_KEYS = OPTIONAL_KWARGS_KEYS diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 936d79b22d6..461ab62b815 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -73,6 +73,7 @@ from litellm.litellm_core_utils.model_param_helper import ModelParamHelper from litellm.litellm_core_utils.redact_messages import ( redact_message_input_output_from_custom_logger, redact_message_input_output_from_logging, + redact_streaming_responses_for_custom_logger, ) from litellm.llms.base_llm.ocr.transformation import OCRResponse from litellm.llms.base_llm.search.transformation import SearchResponse @@ -2576,6 +2577,9 @@ class Logging(LiteLLMLoggingBaseClass): model_call_details = callback.redact_standard_logging_payload_from_model_call_details( model_call_details=model_call_details ) + model_call_details = redact_streaming_responses_for_custom_logger( + model_call_details=model_call_details, custom_logger=callback + ) ################################## if self.stream is True: if "async_complete_streaming_response" in model_call_details: @@ -5208,10 +5212,15 @@ def get_standard_logging_object_payload( call_type = kwargs.get("call_type") cache_hit = kwargs.get("cache_hit", False) # Extract usage as a plain dict, avoiding Pydantic round-trip - usage_dict = StandardLoggingPayloadSetup.get_usage_as_dict( + raw_usage_dict = StandardLoggingPayloadSetup.get_usage_as_dict( response_obj=response_obj, combined_usage_object=cast(Optional[Usage], kwargs.get("combined_usage_object")), ) + usage_dict = ( + {**raw_usage_dict, "output_image_count": len(init_response_obj.data)} + if isinstance(init_response_obj, ImageResponse) and init_response_obj.data + else raw_usage_dict + ) id = response_obj.get("id", kwargs.get("litellm_call_id")) diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index c039f0f43ee..33bf546c239 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -445,6 +445,7 @@ class PromptTokensDetailsResult(TypedDict): text_tokens: int audio_tokens: int image_tokens: int + video_tokens: int character_count: int image_count: int video_length_seconds: float @@ -473,6 +474,7 @@ def _parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult: ) audio_tokens = cast(Optional[int], getattr(usage.prompt_tokens_details, "audio_tokens", 0)) or 0 image_tokens = cast(Optional[int], getattr(usage.prompt_tokens_details, "image_tokens", 0)) or 0 + video_tokens = _coerce_token_count(getattr(usage.prompt_tokens_details, "video_tokens", 0)) character_count = ( cast( Optional[int], @@ -503,6 +505,7 @@ def _parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult: text_tokens=text_tokens, audio_tokens=audio_tokens, image_tokens=image_tokens, + video_tokens=video_tokens, character_count=character_count, image_count=image_count, video_length_seconds=float(video_length_seconds), @@ -515,6 +518,7 @@ class CompletionTokensDetailsResult(TypedDict): text_tokens: int reasoning_tokens: int image_tokens: int + video_tokens: int def _parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResult: @@ -546,12 +550,14 @@ def _parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsRes ) or 0 ) + video_tokens = _coerce_token_count(getattr(usage.completion_tokens_details, "video_tokens", 0)) return CompletionTokensDetailsResult( audio_tokens=audio_tokens, text_tokens=text_tokens, reasoning_tokens=reasoning_tokens, image_tokens=image_tokens, + video_tokens=video_tokens, ) @@ -586,6 +592,13 @@ def _calculate_input_cost( image_token_cost_key = "input_cost_per_token" prompt_cost += calculate_cost_component(model_info, image_token_cost_key, prompt_tokens_details["image_tokens"]) + ### VIDEO TOKEN COST + if prompt_tokens_details["video_tokens"]: + video_token_cost_key = "input_cost_per_video_token" + if model_info.get(video_token_cost_key) is None: + video_token_cost_key = "input_cost_per_token" + prompt_cost += calculate_cost_component(model_info, video_token_cost_key, prompt_tokens_details["video_tokens"]) + ### CACHE WRITING COST - Now uses tiered pricing if ( prompt_tokens_details["cache_creation_tokens"] @@ -698,6 +711,7 @@ def generic_cost_per_token( text_tokens=usage.prompt_tokens, audio_tokens=0, image_tokens=0, + video_tokens=0, character_count=0, image_count=0, video_length_seconds=0.0, @@ -716,13 +730,14 @@ def generic_cost_per_token( audio_tokens = prompt_tokens_details["audio_tokens"] cache_creation = prompt_tokens_details["cache_creation_tokens"] image_tokens = prompt_tokens_details["image_tokens"] + video_tokens = prompt_tokens_details["video_tokens"] # Check for double-counting: sum of details > prompt_tokens means overlap - total_details = text_tokens + cache_hit + audio_tokens + cache_creation + image_tokens + total_details = text_tokens + cache_hit + audio_tokens + cache_creation + image_tokens + video_tokens has_double_counting = cache_hit > 0 and total_details > usage.prompt_tokens if (text_tokens == 0 and prompt_tokens_details["image_count"] == 0) or has_double_counting: - text_tokens = usage.prompt_tokens - cache_hit - audio_tokens - cache_creation - image_tokens + text_tokens = usage.prompt_tokens - cache_hit - audio_tokens - cache_creation - image_tokens - video_tokens # Clamp to zero: inconsistent streaming usage if text_tokens < 0: text_tokens = 0 @@ -751,6 +766,7 @@ def generic_cost_per_token( audio_tokens = 0 reasoning_tokens = 0 image_tokens = 0 + video_tokens = 0 is_text_tokens_total = False if usage.completion_tokens_details is not None: completion_tokens_details = _parse_completion_tokens_details(usage) @@ -758,19 +774,20 @@ def generic_cost_per_token( text_tokens = completion_tokens_details["text_tokens"] reasoning_tokens = completion_tokens_details["reasoning_tokens"] image_tokens = completion_tokens_details["image_tokens"] + video_tokens = completion_tokens_details["video_tokens"] # Handle text_tokens calculation: # 1. If text_tokens is explicitly provided and > 0, use it - # 2. If there's a breakdown (reasoning/audio/image tokens), calculate text_tokens as the remainder + # 2. If there's a breakdown (reasoning/audio/image/video tokens), calculate text_tokens as the remainder # 3. If no breakdown at all, assume all completion_tokens are text_tokens - has_token_breakdown = image_tokens > 0 or audio_tokens > 0 or reasoning_tokens > 0 + has_token_breakdown = image_tokens > 0 or audio_tokens > 0 or reasoning_tokens > 0 or video_tokens > 0 if text_tokens == 0: if has_token_breakdown: # Calculate text tokens as remainder when we have a breakdown # This handles cases like OpenAI's reasoning models where text_tokens isn't provided text_tokens = max( 0, - usage.completion_tokens - reasoning_tokens - audio_tokens - image_tokens, + usage.completion_tokens - reasoning_tokens - audio_tokens - image_tokens - video_tokens, ) else: # No breakdown at all, all tokens are text tokens @@ -803,6 +820,14 @@ def generic_cost_per_token( ) completion_cost += float(image_tokens) * _output_cost_per_image_token + ## VIDEO COST + if not is_text_tokens_total and video_tokens and video_tokens > 0: + _output_cost_per_video_token = _get_cost_per_unit(model_info, "output_cost_per_video_token", None) + _output_cost_per_video_token = ( + _output_cost_per_video_token if _output_cost_per_video_token is not None else completion_base_cost + ) + completion_cost += float(video_tokens) * _output_cost_per_video_token + ## REGIONAL DATA-RESIDENCY UPLIFT # Applied as a flat multiplier across all token costs for the request # when the upstream is a regionalized OpenAI host (eu./us.api.openai.com). diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index cc9264e93f8..6e8429839ad 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -38,10 +38,45 @@ def redact_message_input_output_from_custom_logger( litellm_logging_obj: LiteLLMLoggingObject, result, custom_logger: CustomLogger ): if hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True: - return perform_redaction(litellm_logging_obj.model_call_details, result) + return perform_redaction(litellm_logging_obj.model_call_details, result, redact_streaming_responses=False) return result +def redact_streaming_responses_for_custom_logger(model_call_details: dict, custom_logger: CustomLogger) -> dict: + """ + Returns a copy of model_call_details whose streaming response entries are redacted deepcopies + when the custom logger has opted out of message logging. The shared model_call_details is left + untouched so other callbacks still receive the unredacted response. + """ + if not (hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True): + return model_call_details + redacted_entries = { + streaming_key: _redacted_streaming_response_copy(model_call_details[streaming_key]) + for streaming_key in ("complete_streaming_response", "async_complete_streaming_response") + if model_call_details.get(streaming_key) is not None + } + if not redacted_entries: + return model_call_details + return {**model_call_details, **redacted_entries} + + +def _redacted_streaming_response_copy(streaming_response): + redacted_response = copy.deepcopy(streaming_response) + _redact_streaming_response(redacted_response) + return redacted_response + + +def _redact_streaming_response(streaming_response): + if hasattr(streaming_response, "choices"): + for choice in streaming_response.choices: + _redact_choice_content(choice) + redact_vertex_ai_metadata_from_logged_object(streaming_response) + elif hasattr(streaming_response, "output"): + _redact_responses_api_output(streaming_response.output) + if hasattr(streaming_response, "reasoning") and streaming_response.reasoning is not None: + streaming_response.reasoning = None + + def _redact_choice_content(choice): """Helper to redact content in a choice (message or delta).""" if isinstance(choice, litellm.Choices): @@ -150,9 +185,13 @@ def _redact_model_response_dict_choices(choices, redacted_str: str): _redact_choice_content(choice) -def perform_redaction(model_call_details: dict, result): +def perform_redaction(model_call_details: dict, result, redact_streaming_responses: bool = True): """ Performs the actual redaction on the logging object and result. + + redact_streaming_responses=False skips the in-place redaction of the shared streaming + response entries; per-callback redaction hands each opted-out callback its own redacted + copy via redact_streaming_responses_for_custom_logger instead. """ # Redact model_call_details model_call_details["messages"] = [{"role": "user", "content": "redacted-by-litellm"}] @@ -162,17 +201,9 @@ def perform_redaction(model_call_details: dict, result): redact_vertex_ai_metadata_from_litellm_params(model_call_details) # Redact streaming response - if model_call_details.get("stream", False) is True and "complete_streaming_response" in model_call_details: - _streaming_response = model_call_details["complete_streaming_response"] - if hasattr(_streaming_response, "choices"): - for choice in _streaming_response.choices: - _redact_choice_content(choice) - redact_vertex_ai_metadata_from_logged_object(_streaming_response) - elif hasattr(_streaming_response, "output"): - _redact_responses_api_output(_streaming_response.output) - # Redact reasoning field in ResponsesAPIResponse - if hasattr(_streaming_response, "reasoning") and _streaming_response.reasoning is not None: - _streaming_response.reasoning = None + if redact_streaming_responses and model_call_details.get("stream", False) is True: + for _streaming_key in ("complete_streaming_response", "async_complete_streaming_response"): + _redact_streaming_response(model_call_details.get(_streaming_key)) # Redact result if result is not None: diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 1f5b76f3d0a..0ec1f3eae13 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1441,24 +1441,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): output_key=param, ) elif param == "response_format" and isinstance(value, dict): - if any( - substring in model - for substring in { - "sonnet-4.5", - "sonnet-4-5", - "opus-4.1", - "opus-4-1", - "opus-4.5", - "opus-4-5", - "opus-4.6", - "opus-4-6", - "opus-4.7", - "opus-4-7", - "sonnet-4.6", - "sonnet-4-6", - "sonnet_4.6", - "sonnet_4_6", - } + if AnthropicConfig._supports_model_capability( + model, + "supports_native_structured_output", + self._resolved_provider, ): _output_format = self.map_response_format_to_anthropic_output_format(value) if _output_format is not None: diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 0bcf34a45d6..e006662ec4d 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -340,11 +340,15 @@ class AnthropicModelInfo(BaseLLMModelInfo): def _get_model_capability(model: str, key: str) -> Optional[bool]: """Read boolean capability ``key`` from the model map, or None when no entry declares it.""" + from litellm.utils import _get_bundled_model_cost_map + try: - for cand in AnthropicModelInfo._model_map_lookup_candidates(model): - value = litellm.model_cost.get(cand, {}).get(key) - if isinstance(value, bool): - return value + candidates = AnthropicModelInfo._model_map_lookup_candidates(model) + for model_cost in (litellm.model_cost, _get_bundled_model_cost_map()): + for cand in candidates: + value = model_cost.get(cand, {}).get(key) + if isinstance(value, bool): + return value except Exception: pass return None diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index 44c367ee805..f02333c34c8 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -13,14 +13,18 @@ from typing import ( List, Literal, Optional, + get_args, ) +from typing_extensions import assert_never + from litellm._logging import verbose_logger from litellm._uuid import uuid from litellm.types.llms.anthropic import ( AppliedEdit, CompactionBlock, ContextManagementResponse, + StreamingContentBlockDeltaType, UsageDelta, UsageIteration, ) @@ -30,6 +34,23 @@ if TYPE_CHECKING: from litellm.types.utils import ModelResponseStream +_STREAMING_DELTA_TYPES = frozenset(get_args(StreamingContentBlockDeltaType)) + + +def _delta_payload_field(delta_type: StreamingContentBlockDeltaType) -> str: + match delta_type: + case "text_delta": + return "text" + case "input_json_delta": + return "partial_json" + case "thinking_delta": + return "thinking" + case "signature_delta": + return "signature" + case _: + assert_never(delta_type) + + class _CombinedChunkSplitter: """ Splits a streaming chunk that carries BOTH response content and a @@ -458,12 +479,15 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # 3. If the trigger chunk carries delta content, queue it # so the first delta of the new block is not silently dropped. - if self._trigger_delta_has_content(processed_chunk): + if self._delta_has_content(processed_chunk): self.chunk_queue.append(processed_chunk) self.sent_content_block_finish = False return self.chunk_queue.popleft() + if processed_chunk["type"] == "content_block_delta" and not self._delta_has_content(processed_chunk): + continue + if processed_chunk["type"] == "message_delta" and self.sent_content_block_finish is False: # Queue both the content_block_stop and the message_delta self.chunk_queue.append( @@ -670,13 +694,18 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # 3. If the trigger chunk carries delta content, queue it # so the first delta of the new block is not silently dropped. - if self._trigger_delta_has_content(processed_chunk): + if self._delta_has_content(processed_chunk): self.chunk_queue.append(processed_chunk) # Reset state for new block self.sent_content_block_finish = False return self.chunk_queue.popleft() + if processed_chunk["type"] == "content_block_delta" and not self._delta_has_content( + processed_chunk + ): + continue + if processed_chunk["type"] == "message_delta" and self.sent_content_block_finish is False: # Queue both the content_block_stop and the holding chunk self.chunk_queue.append( @@ -808,20 +837,33 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): self.current_content_block_index += 1 @staticmethod - def _trigger_delta_has_content(processed_chunk: Dict[str, Any]) -> bool: - """Return True if a translated trigger chunk carries a non-empty - ``content_block_delta`` payload that must be re-emitted after a - block transition. + def _delta_has_content(processed_chunk: Dict[str, Any]) -> bool: + """Return True if a translated chunk carries a non-empty + ``content_block_delta`` payload. - When an upstream chunk both *triggers* a new content block (its type - differs from the active block) and *carries* delta content, that - content belongs to the new block. The synthesized - ``content_block_start`` only ever carries an empty body — see + Gates every ``content_block_delta`` emission. An empty delta carries + no information, and the translate fallback types empty deltas as + ``text_delta`` regardless of the active block's type — emitting one + into an open ``thinking`` block (e.g. Bedrock Converse sends an empty + reasoning delta mid-block) crashes strict Anthropic SDK clients with + "Content block is not a text block". + + Also gates re-emission after a block transition: when an upstream + chunk both *triggers* a new content block (its type differs from the + active block) and *carries* delta content, that content belongs to + the new block. The synthesized ``content_block_start`` only ever + carries an empty body — see ``_translate_streaming_openai_chunk_to_anthropic_content_block``, which returns an empty ``TextBlock``/``ToolUseBlock``/thinking block — so the trigger chunk's delta must be re-queued or the first token of the new block (the first non-empty text/thinking delta, or bundled tool arguments) is silently dropped. + + Delta types outside ``StreamingContentBlockDeltaType`` — the closed + set the translate layer can produce — are treated as empty. The + per-type payload lookup is exhaustively matched against that set in + ``_delta_payload_field``, so extending the translate layer with a new + delta type fails type-checking here until it is handled. """ if processed_chunk.get("type") != "content_block_delta": return False @@ -829,15 +871,9 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): if not isinstance(delta, dict): return False delta_type = delta.get("type") - if delta_type == "text_delta": - return bool(delta.get("text")) - if delta_type == "input_json_delta": - return bool(delta.get("partial_json")) - if delta_type == "thinking_delta": - return bool(delta.get("thinking")) - if delta_type == "signature_delta": - return bool(delta.get("signature")) - return False + if delta_type not in _STREAMING_DELTA_TYPES: + return False + return bool(delta.get(_delta_payload_field(delta_type))) def _should_start_new_content_block(self, chunk: "ModelResponseStream") -> bool: """ diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 4c981dd36b3..cd75eed2e6e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -104,6 +104,7 @@ from litellm.types.llms.anthropic import ( ContextManagementResponse, MessageBlockDelta, MessageDelta, + StreamingContentBlockDeltaType, UsageDelta, UsageIteration, ) @@ -1423,7 +1424,7 @@ class LiteLLMAnthropicMessagesAdapter: def _translate_streaming_openai_chunk_to_anthropic( self, choices: List[Union[OpenAIStreamingChoice, StreamingChoices]] ) -> Tuple[ - Literal["text_delta", "input_json_delta", "thinking_delta", "signature_delta"], + StreamingContentBlockDeltaType, Union[ ContentTextBlockDelta, ContentJsonBlockDelta, diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 00941587753..05679bf39ab 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -375,6 +375,36 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): else: optional_params.pop("output_config", None) + @staticmethod + def _drop_incompatible_temperature_for_thinking( + model: str, optional_params: dict, custom_llm_provider: str + ) -> None: + """Anthropic rejects any ``temperature`` other than 1 while extended thinking + is enabled ("temperature may only be set to 1 when thinking is enabled"). + + Clients like Claude Code send ``thinking``/``output_config.effort`` together + with a pinned ``temperature`` (e.g. the safety classifier uses ``temperature=0`` + for determinism). When the request lands on a non-adaptive model, the effort + interface is reshaped above into legacy ``thinking={type: enabled}`` (or kept + as ``output_config.effort`` on Opus 4.5), and the leftover ``temperature`` would + 400. Preserving the thinking the caller asked for wins over an unhonorable + sampling value (Anthropic forces ``temperature=1`` under thinking regardless), + so drop it and let the API default apply. + + Adaptive models (4.6+) own this natively and are left untouched. + """ + if AnthropicModelInfo._is_adaptive_thinking_model(model, custom_llm_provider): + return + temperature = optional_params.get("temperature") + if temperature is None or temperature == 1: + return + thinking = optional_params.get("thinking") + output_config = optional_params.get("output_config") + thinking_enabled = isinstance(thinking, dict) and thinking.get("type") == "enabled" + effort_enabled = isinstance(output_config, dict) and output_config.get("effort") is not None + if thinking_enabled or effort_enabled: + optional_params.pop("temperature", None) + def transform_anthropic_messages_request( self, model: str, @@ -415,6 +445,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): custom_llm_provider=self._resolved_provider, ) + self._drop_incompatible_temperature_for_thinking( + model=model, + optional_params=anthropic_messages_optional_request_params, + custom_llm_provider=self._resolved_provider, + ) + system_param = anthropic_messages_optional_request_params.get("system") if self.should_strip_billing_metadata() and system_param is not None: filtered_system = self._filter_billing_headers_from_system(system_param) diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index 1a052f457c5..172e54de98e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -198,14 +198,16 @@ class LiteLLMAnthropicToResponsesAPIAdapter: @staticmethod def translate_tool_choice_to_responses_api( tool_choice: AnthropicMessagesToolChoice, - ) -> Dict[str, Any]: + ) -> Union[str, dict[str, Any]]: """Convert Anthropic tool_choice to Responses API tool_choice.""" tc_type = tool_choice.get("type") if tc_type == "any": - return {"type": "required"} + return "required" elif tc_type == "tool": return {"type": "function", "name": tool_choice.get("name", "")} - return {"type": "auto"} + elif tc_type == "none": + return "none" + return "auto" @staticmethod def translate_context_management_to_responses_api( diff --git a/litellm/llms/base_llm/guardrail_translation/base_translation.py b/litellm/llms/base_llm/guardrail_translation/base_translation.py index 6c41b46cfa0..bed06832386 100644 --- a/litellm/llms/base_llm/guardrail_translation/base_translation.py +++ b/litellm/llms/base_llm/guardrail_translation/base_translation.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Dict, List, Optional if TYPE_CHECKING: @@ -11,10 +12,30 @@ if TYPE_CHECKING: from litellm.types.llms.openai import AllMessageValues +@dataclass(slots=True) +class StreamTransformSink: + """Out-parameter used by ``process_output_streaming_response`` to hand the + guardrailed streaming state back to the caller. + + The streaming text-transform path must not mutate ``responses_so_far`` (it is + the raw accumulator the guardrail re-reads every round), so the guardrailed + accumulated text per choice (``mutated_text_per_choice``, keyed by + ``StreamingChoices.index``) and the per-choice trailing holdback the guardrail + requested (``holdback_per_choice``, from ``stream_holdback_chars``) are + reported here instead of in place. Only the OpenAI chat handler populates this + today; the hook passes a fresh sink per round and reads it afterwards. A + mutable dataclass is deliberate: it is a write-once output parameter for a + single call, not shared state. + """ + + mutated_text_per_choice: dict[int, str] = field(default_factory=dict) + holdback_per_choice: dict[int, int] = field(default_factory=dict) + + class BaseTranslation(ABC): @staticmethod def transform_user_api_key_dict_to_metadata( - user_api_key_dict: Optional[Any], + user_api_key_dict: Any | None, ) -> Dict[str, Any]: """ Transform user_api_key_dict to a metadata dict with prefixed keys. @@ -73,7 +94,7 @@ class BaseTranslation(ABC): guardrail_to_apply: "CustomGuardrail", litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, user_api_key_dict: Optional["UserAPIKeyAuth"] = None, - request_data: Optional[dict] = None, + request_data: dict | None = None, ) -> Any: """ Process output response with guardrails. @@ -92,12 +113,15 @@ class BaseTranslation(ABC): guardrail_to_apply: "CustomGuardrail", litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, user_api_key_dict: Optional["UserAPIKeyAuth"] = None, - request_data: Optional[dict] = None, + request_data: dict | None = None, + stream_transform_sink: StreamTransformSink | None = None, ) -> Any: """ Process output streaming response with guardrails. - Optional to override in subclasses. + Optional to override in subclasses. ``stream_transform_sink`` is the + out-parameter used by handlers that support streaming text + transformations (see ``StreamTransformSink``); base handlers ignore it. """ return responses_so_far @@ -105,8 +129,8 @@ class BaseTranslation(ABC): self, exc: "ModifyResponseException", stream_started: bool = False, - responses_so_far: Optional[list[Any]] = None, - ) -> Optional[list[bytes]]: + responses_so_far: list[Any] | None = None, + ) -> list[bytes] | None: """ Build the streaming chunks that deliver a guardrail block message and cleanly terminate the stream in this provider's wire format. @@ -125,7 +149,7 @@ class BaseTranslation(ABC): """ return None - def get_structured_messages(self, data: dict) -> Optional[List["AllMessageValues"]]: + def get_structured_messages(self, data: dict) -> List["AllMessageValues"] | None: """ Convert request data to OpenAI-spec structured messages. diff --git a/litellm/llms/bedrock/base_aws_llm.py b/litellm/llms/bedrock/base_aws_llm.py index f449851b76f..df811f8d262 100644 --- a/litellm/llms/bedrock/base_aws_llm.py +++ b/litellm/llms/bedrock/base_aws_llm.py @@ -877,6 +877,15 @@ class BaseAWSLLM: "Resource": "*", "Condition": {"Bool": {"aws:SecureTransport": "true"}}, }, + { + "Sid": "BedrockMantleLiteLLM", + "Effect": "Allow", + "Action": [ + "bedrock-mantle:CreateInference", + ], + "Resource": "*", + "Condition": {"Bool": {"aws:SecureTransport": "true"}}, + }, ], } assume_role_params = { diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index c426714a1bd..96d0ad48b79 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -2657,9 +2657,10 @@ class BaseLLMHTTPHandler: ) result = final_response if final_response is not None else initial_response - if litellm_params.get("_code_interpreter_interception_converted_stream") and not litellm_params.get( - "_agentic_loop_depth" - ): + interception_converted_stream = litellm_params.get( + "_code_interpreter_interception_converted_stream" + ) or litellm_params.get("_websearch_interception_converted_stream") + if interception_converted_stream and not litellm_params.get("_agentic_loop_depth"): return self._wrap_responses_response_as_fake_stream( result=result, model=model, @@ -5224,6 +5225,8 @@ class BaseLLMHTTPHandler: tools = anthropic_messages_optional_request_params.get("tools", []) depth, max_loops, fingerprints = self._get_agentic_loop_settings(kwargs=kwargs) + hook_kwargs = {**kwargs, "_agentic_loop_api_surface": api_surface} + for callback in callbacks: if not isinstance(callback, CustomLogger): continue @@ -5244,7 +5247,7 @@ class BaseLLMHTTPHandler: tools=tools, stream=stream, custom_llm_provider=custom_llm_provider, - kwargs=kwargs, + kwargs=hook_kwargs, ) except Exception as e: _call_id = getattr(logging_obj, "litellm_call_id", "unknown") @@ -5270,7 +5273,7 @@ class BaseLLMHTTPHandler: ) try: - kwargs_with_provider = kwargs.copy() if kwargs else {} + kwargs_with_provider = hook_kwargs.copy() kwargs_with_provider["custom_llm_provider"] = custom_llm_provider build_plan_overridden = ( callback.__class__.async_build_agentic_loop_plan is not CustomLogger.async_build_agentic_loop_plan diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index a6b6a6267c3..fd2c9339248 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -14,11 +14,14 @@ Pattern Overview: This pattern can be replicated for other message formats (e.g., Anthropic). """ -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Union, cast import litellm from litellm._logging import verbose_proxy_logger -from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation +from litellm.llms.base_llm.guardrail_translation.base_translation import ( + BaseTranslation, + StreamTransformSink, +) from litellm.llms.base_llm.guardrail_translation.utils import ( effective_skip_system_message_for_guardrail, effective_skip_tool_message_for_guardrail, @@ -27,6 +30,9 @@ from litellm.llms.base_llm.guardrail_translation.utils import ( ) from litellm.main import stream_chunk_builder from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam +from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( + coerce_stream_holdback_value, +) from litellm.types.utils import ( Choices, GenericGuardrailAPIInputs, @@ -50,7 +56,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): Methods can be overridden to customize behavior for different message formats. """ - def get_structured_messages(self, data: dict) -> Optional[List[AllMessageValues]]: + def get_structured_messages(self, data: dict) -> List[AllMessageValues] | None: """ Convert chat completions request data to OpenAI-spec structured messages. @@ -65,7 +71,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, data: dict, guardrail_to_apply: "CustomGuardrail", - litellm_logging_obj: Optional[Any] = None, + litellm_logging_obj: Any | None = None, ) -> Any: """ Process input messages by applying guardrails to text content. @@ -80,7 +86,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): texts_to_check: List[str] = [] images_to_check: List[str] = [] tool_calls_to_check: List[ChatCompletionToolParam] = [] - text_task_mappings: List[Tuple[int, Optional[int]]] = [] + text_task_mappings: List[Tuple[int, int | None]] = [] tool_call_task_mappings: List[Tuple[int, int]] = [] # Step 1: Extract all text content, images, and tool calls @@ -184,7 +190,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): texts_to_check: List[str], images_to_check: List[str], tool_calls_to_check: List[ChatCompletionToolParam], - text_task_mappings: List[Tuple[int, Optional[int]]], + text_task_mappings: List[Tuple[int, int | None]], tool_call_task_mappings: List[Tuple[int, int]], skip_system_message: bool = False, skip_tool_message: bool = False, @@ -239,7 +245,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, messages: List[Dict[str, Any]], responses: List[str], - task_mappings: List[Tuple[int, Optional[int]]], + task_mappings: List[Tuple[int, int | None]], ) -> None: """ Apply guardrail responses back to input message text content. @@ -249,7 +255,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): for task_idx, guardrail_response in enumerate(responses): mapping = task_mappings[task_idx] msg_idx = cast(int, mapping[0]) - content_idx_optional = cast(Optional[int], mapping[1]) + content_idx_optional = cast(int | None, mapping[1]) # Handle content content = messages[msg_idx].get("content", None) @@ -291,9 +297,9 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, response: "ModelResponse", guardrail_to_apply: "CustomGuardrail", - litellm_logging_obj: Optional[Any] = None, - user_api_key_dict: Optional[Any] = None, - request_data: Optional[dict] = None, + litellm_logging_obj: Any | None = None, + user_api_key_dict: Any | None = None, + request_data: dict | None = None, ) -> Any: """ Process output response by applying guardrails to text content. @@ -320,7 +326,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): texts_to_check: List[str] = [] images_to_check: List[str] = [] tool_calls_to_check: List[Dict[str, Any]] = [] - text_task_mappings: List[Tuple[int, Optional[int]]] = [] + text_task_mappings: List[Tuple[int, int | None]] = [] tool_call_task_mappings: List[Tuple[int, int]] = [] # text_task_mappings: Track (choice_index, content_index) for each text # content_index is None for string content, int for list content @@ -402,9 +408,10 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, responses_so_far: List["ModelResponseStream"], guardrail_to_apply: "CustomGuardrail", - litellm_logging_obj: Optional[Any] = None, - user_api_key_dict: Optional[Any] = None, - request_data: Optional[dict] = None, + litellm_logging_obj: Any | None = None, + user_api_key_dict: Any | None = None, + request_data: dict | None = None, + stream_transform_sink: StreamTransformSink | None = None, ) -> List["ModelResponseStream"]: """ Process output streaming responses by applying guardrails to text content. @@ -414,14 +421,50 @@ class OpenAIChatCompletionsHandler(BaseTranslation): guardrail_to_apply: The guardrail instance to apply litellm_logging_obj: Optional logging object user_api_key_dict: User API key metadata to pass to guardrails + stream_transform_sink: Optional out-parameter for the streaming text + transformation path. When provided, the guardrail runs over the raw + accumulated text (``responses_so_far`` is left untouched so it stays + a correct raw accumulator across rounds) and the guardrailed text + plus requested holdback are reported per choice on the sink. Returns: - Modified list of responses with guardrail applied to content + The (unmodified) list of responses. Response Format Support: - String content: choice.message.content = "text here" - List content: choice.message.content = [{"type": "text", "text": "text here"}, ...] """ + if stream_transform_sink is not None: + await self._process_streaming_transform( + responses_so_far=responses_so_far, + guardrail_to_apply=guardrail_to_apply, + litellm_logging_obj=litellm_logging_obj, + user_api_key_dict=user_api_key_dict, + request_data=request_data, + sink=stream_transform_sink, + ) + return responses_so_far + + return await self._process_streaming_block_only( + responses_so_far=responses_so_far, + guardrail_to_apply=guardrail_to_apply, + litellm_logging_obj=litellm_logging_obj, + user_api_key_dict=user_api_key_dict, + request_data=request_data, + ) + + async def _process_streaming_block_only( + self, + *, + responses_so_far: list["ModelResponseStream"], + guardrail_to_apply: "CustomGuardrail", + litellm_logging_obj: Any | None, + user_api_key_dict: Any | None, + request_data: dict | None, + ) -> list["ModelResponseStream"]: + """Block-only streaming path: run the guardrail so an in-flight BLOCK can + terminate the stream. Text rewrites are not propagated to the client here + (see ``_process_streaming_transform`` for the incremental_diff path).""" # check if the stream has ended has_stream_ended = False for chunk in responses_so_far: @@ -467,7 +510,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): # Step 2: Create lists for guardrail processing texts_to_check: List[str] = [] images_to_check: List[str] = [] - task_mappings: List[Tuple[int, Optional[int]]] = [] + task_mappings: List[Tuple[int, int | None]] = [] # Track (choice_index, content_index) for each combined text for (map_choice_idx, map_content_idx), combined_text in combined_texts.items(): @@ -520,9 +563,109 @@ class OpenAIChatCompletionsHandler(BaseTranslation): return responses_so_far + @staticmethod + def _accumulate_string_content_by_choice_index( + responses_so_far: list["ModelResponseStream"], + ) -> dict[int, str]: + """Accumulate raw string ``delta.content`` per choice, keyed by + ``StreamingChoices.index`` (not enumerate position, which collapses to 0 + when each chunk carries a single non-zero-indexed choice for ``n > 1``). + + Only string content participates; list-of-blocks content is out of scope + for the incremental transform path. Reads ``responses_so_far`` without + mutating it so it stays a correct raw accumulator across rounds. + """ + accumulated: dict[int, str] = {} + for response in responses_so_far: + for choice in response.choices: + if isinstance(choice, litellm.StreamingChoices): + content = choice.delta.content + elif isinstance(choice, litellm.Choices): + content = choice.message.content + else: + continue + if isinstance(content, str) and content: + idx = getattr(choice, "index", 0) or 0 + accumulated[idx] = accumulated.get(idx, "") + content + return accumulated + + async def _process_streaming_transform( + self, + *, + responses_so_far: list["ModelResponseStream"], + guardrail_to_apply: "CustomGuardrail", + litellm_logging_obj: Any | None, + user_api_key_dict: Any | None, + request_data: dict | None, + sink: StreamTransformSink, + ) -> None: + """Run the guardrail over the raw accumulated text and report the + guardrailed text plus requested holdback per choice on ``sink``. + + Unlike the block-only path this never mutates ``responses_so_far``: it + re-derives the raw accumulated text every round (so a rewrite guardrail + always sees consistent input) and hands the result back out of band. + """ + raw_by_index = self._accumulate_string_content_by_choice_index(responses_so_far) + if not raw_by_index: + sink.mutated_text_per_choice = {} + sink.holdback_per_choice = {} + return + + # Fix #2 — sort by StreamingChoices.index so an n>1 stream that emits + # choice 1 before choice 0 still hands the guardrail texts in a + # deterministic index order. Without this, the guardrail's returned + # texts (aligned to the input order it received) would map back to the + # wrong choice indices when we rebuild the sink dicts by + # ``enumerate(indices)``. + indices = sorted(raw_by_index.keys()) + texts_to_check = [raw_by_index[i] for i in indices] + + if request_data is None: + request_data = {"responses": responses_so_far} + elif "responses" not in request_data: + request_data["responses"] = responses_so_far + if "litellm_metadata" not in request_data: + user_metadata = self.transform_user_api_key_dict_to_metadata(user_api_key_dict) + if user_metadata: + request_data["litellm_metadata"] = user_metadata + + inputs = GenericGuardrailAPIInputs(texts=texts_to_check) + if responses_so_far and getattr(responses_so_far[0], "model", None): + inputs["model"] = responses_so_far[0].model + guardrailed_inputs = await guardrail_to_apply.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="response", + logging_obj=litellm_logging_obj, + ) + + returned_texts = guardrailed_inputs.get("texts") + # No "texts" key means the guardrail made no change (action NONE): the raw + # accumulated text is the guardrailed text. A present-but-shorter list is a + # guardrail contract violation; those choices are omitted below (withheld, + # not emitted raw) so a malformed response fails closed instead of leaking. + if returned_texts is None: + returned_texts = texts_to_check + elif len(returned_texts) < len(texts_to_check): + verbose_proxy_logger.warning( + "OpenAI Chat Completions: guardrail returned %s transformed texts for %s inputs on the " + "streaming transform path; withholding the unmatched choices to fail closed.", + len(returned_texts), + len(texts_to_check), + ) + + holdback = guardrailed_inputs.get("stream_holdback_chars") or [] + sink.mutated_text_per_choice = { + idx: returned_texts[i] for i, idx in enumerate(indices) if i < len(returned_texts) + } + sink.holdback_per_choice = { + indices[i]: coerce_stream_holdback_value(holdback[i]) for i in range(len(indices)) if i < len(holdback) + } + def _combine_streaming_texts( self, responses_so_far: List["ModelResponseStream"] - ) -> Dict[Tuple[int, Optional[int]], str]: + ) -> Dict[Tuple[int, int | None], str]: """ Combine all streaming chunks into complete text per choice. @@ -534,7 +677,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): Returns: Dict mapping (choice_idx, content_idx) to combined text string """ - combined_texts: Dict[Tuple[int, Optional[int]], str] = {} + combined_texts: Dict[Tuple[int, int | None], str] = {} for response_idx, response in enumerate(responses_so_far): for choice_idx, choice in enumerate(response.choices): @@ -550,7 +693,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): if isinstance(content, str): # String content - accumulate for this choice - str_key: Tuple[int, Optional[int]] = (choice_idx, None) + str_key: Tuple[int, int | None] = (choice_idx, None) if str_key not in combined_texts: combined_texts[str_key] = "" combined_texts[str_key] += content @@ -560,7 +703,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): for content_idx, content_item in enumerate(content): text_str = content_item.get("text") if text_str: - list_key: Tuple[int, Optional[int]] = ( + list_key: Tuple[int, int | None] = ( choice_idx, content_idx, ) @@ -607,7 +750,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): texts_to_check: List[str], images_to_check: List[str], tool_calls_to_check: List[Dict[str, Any]], - text_task_mappings: List[Tuple[int, Optional[int]]], + text_task_mappings: List[Tuple[int, int | None]], tool_call_task_mappings: List[Tuple[int, int]], ) -> None: """ @@ -619,7 +762,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): # Determine content source and tool calls based on choice type content = None - tool_calls: Optional[List[Any]] = None + tool_calls: List[Any] | None = None if isinstance(choice, litellm.Choices): content = choice.message.content tool_calls = choice.message.tool_calls @@ -662,7 +805,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): tool_calls_to_check.append(tool_call_dict) tool_call_task_mappings.append((choice_idx, int(tool_call_idx))) - def _convert_tool_call_to_dict(self, tool_call: Union[Dict[str, Any], Any]) -> Optional[Dict[str, Any]]: + def _convert_tool_call_to_dict(self, tool_call: Union[Dict[str, Any], Any]) -> Dict[str, Any] | None: """ Convert a tool call object to dictionary format. @@ -691,7 +834,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, response: "ModelResponse", responses: List[str], - task_mappings: List[Tuple[int, Optional[int]]], + task_mappings: List[Tuple[int, int | None]], ) -> None: """ Apply guardrail text responses back to output response. @@ -701,7 +844,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): for task_idx, guardrail_response in enumerate(responses): mapping = task_mappings[task_idx] choice_idx = cast(int, mapping[0]) - content_idx_optional = cast(Optional[int], mapping[1]) + content_idx_optional = cast(int | None, mapping[1]) choice = cast(Choices, response.choices[choice_idx]) @@ -755,7 +898,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, responses: List["ModelResponseStream"], guardrailed_texts: List[str], - task_mappings: List[Tuple[int, Optional[int]]], + task_mappings: List[Tuple[int, int | None]], ) -> None: """ Apply guardrail responses back to output streaming responses. @@ -771,16 +914,16 @@ class OpenAIChatCompletionsHandler(BaseTranslation): Override this method to customize how responses are applied to streaming responses. """ # Build a mapping of what guardrailed text to use for each (choice_idx, content_idx) - guardrail_map: Dict[Tuple[int, Optional[int]], str] = {} + guardrail_map: Dict[Tuple[int, int | None], str] = {} for task_idx, guardrail_response in enumerate(guardrailed_texts): mapping = task_mappings[task_idx] choice_idx = cast(int, mapping[0]) - content_idx_optional = cast(Optional[int], mapping[1]) + content_idx_optional = cast(int | None, mapping[1]) guardrail_map[(choice_idx, content_idx_optional)] = guardrail_response # Track which choices we've already set the guardrailed text for # Key: (choice_idx, content_idx), Value: boolean (True if already set) - already_set: Dict[Tuple[int, Optional[int]], bool] = {} + already_set: Dict[Tuple[int, int | None], bool] = {} # Iterate through all responses and update content for response_idx, response in enumerate(responses): @@ -797,7 +940,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): if isinstance(content, str): # String content - str_key: Tuple[int, Optional[int]] = (choice_idx_in_response, None) + str_key: Tuple[int, int | None] = (choice_idx_in_response, None) if str_key in guardrail_map: if str_key not in already_set: # First chunk - set the complete guardrailed text @@ -817,7 +960,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): # List content - handle each content item for content_idx, content_item in enumerate(content): if "text" in content_item: - list_key: Tuple[int, Optional[int]] = ( + list_key: Tuple[int, int | None] = ( choice_idx_in_response, content_idx, ) diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index d107ca7a0d7..3c2ae238a0b 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -20,6 +20,8 @@ from litellm.types.utils import LlmProviders from ..common_utils import OpenAIError +OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS = 16 + if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -59,6 +61,19 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): key="supports_none_reasoning_effort", ) + @staticmethod + def _enforce_min_max_output_tokens(max_output_tokens: "int | None") -> "int | None": + """Raise sub-minimum max_output_tokens up to the OpenAI Responses API minimum. + + OpenAI's Responses API rejects max_output_tokens below 16 for every model + (not gpt-5 specific), so a client like Claude Code that sends a max_tokens=1 + warmup probe on model switch would otherwise 400. Values that are None or + already at/above the minimum are returned unchanged. + """ + if isinstance(max_output_tokens, int) and max_output_tokens < OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS: + return OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS + return max_output_tokens + def get_supported_openai_params(self, model: str) -> list: """ All OpenAI Responses API params are supported @@ -92,6 +107,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): """ params = dict(response_api_optional_params) + if "max_output_tokens" in params: + params["max_output_tokens"] = self._enforce_min_max_output_tokens(params.get("max_output_tokens")) + if self._is_gpt_5_model(model=model): temperature = params.get("temperature") if temperature is not None and temperature != 1: diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index f9ed8cea9b5..8c4bb1aa0c5 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -998,6 +998,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): response_modalities.append("IMAGE") elif modality == "audio": response_modalities.append("AUDIO") + elif modality == "video": + response_modalities.append("VIDEO") else: response_modalities.append("MODALITY_UNSPECIFIED") return response_modalities diff --git a/litellm/main.py b/litellm/main.py index 7d457d9cdd1..6fd68921fb0 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -92,7 +92,10 @@ from litellm.litellm_core_utils.completion_timeout import CompletionTimeout from litellm.litellm_core_utils.request_timeout_resolver import ( get_configured_request_timeout, ) -from litellm.litellm_core_utils.get_litellm_params import OPTIONAL_KWARGS_KEYS +from litellm.litellm_core_utils.get_litellm_params import ( + AWS_CREDENTIAL_KWARGS_KEYS, + OPTIONAL_KWARGS_KEYS, +) from litellm.litellm_core_utils.dd_tracing import tracer from litellm.litellm_core_utils.get_provider_specific_headers import ( ProviderSpecificHeaderUtils, @@ -5322,7 +5325,7 @@ def completion( # type: ignore tpm=kwargs.get("tpm"), rpm=kwargs.get("rpm"), use_xai_oauth=kwargs.get("use_xai_oauth", False), - aws_bedrock_project_id=kwargs.get("aws_bedrock_project_id"), + **{key: kwargs[key] for key in AWS_CREDENTIAL_KWARGS_KEYS if key in kwargs}, ) cast(LiteLLMLoggingObj, logging).update_environment_variables( model=model, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index a6f59abc140..dedb9bbf40a 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -11331,6 +11331,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true }, @@ -11362,6 +11363,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true @@ -11424,6 +11426,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, @@ -11479,6 +11482,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true }, @@ -11506,6 +11510,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true }, @@ -11559,6 +11564,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true @@ -11586,6 +11592,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true @@ -11614,6 +11621,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "provider_specific_entry": { @@ -11648,6 +11656,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "provider_specific_entry": { @@ -11682,6 +11691,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, @@ -11718,6 +11728,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, @@ -11788,6 +11799,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, @@ -19600,6 +19612,39 @@ }, "web_search_billing_unit": "per_query" }, + "gemini/gemini-omni-flash-preview": { + "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "gemini", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "output_cost_per_video_token": 1.75e-05, + "rpm": 2000, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "video" + ], + "supports_audio_input": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_video_input": true, + "supports_vision": true, + "tpm": 800000 + }, "gemini/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, @@ -19764,6 +19809,37 @@ }, "web_search_billing_unit": "per_query" }, + "gemini-omni-flash-preview": { + "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "output_cost_per_video_token": 1.75e-05, + "source": "https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/omni-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "video" + ], + "supports_audio_input": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_video_input": true, + "supports_vision": true + }, "gemini-3.5-flash": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_audio_token": 1e-06, @@ -44185,6 +44261,90 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "bedrock_mantle/openai.gpt-5.6-sol": { + "input_cost_per_token": 5.5e-06, + "cache_creation_input_token_cost": 6.875e-06, + "cache_read_input_token_cost": 5.5e-07, + "output_cost_per_token": 3.3e-05, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/openai.gpt-5.6-terra": { + "input_cost_per_token": 2.75e-06, + "cache_creation_input_token_cost": 3.4375e-06, + "cache_read_input_token_cost": 2.75e-07, + "output_cost_per_token": 1.65e-05, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/openai.gpt-5.6-luna": { + "input_cost_per_token": 1.1e-06, + "cache_creation_input_token_cost": 1.375e-06, + "cache_read_input_token_cost": 1.1e-07, + "output_cost_per_token": 6.6e-06, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "bedrock_mantle/openai.gpt-5.5": { "input_cost_per_token": 5.5e-06, "cache_read_input_token_cost": 5.5e-07, @@ -44297,6 +44457,7 @@ "supports_vision": true }, "bedrock_mantle/xai.grok-4.3": { + "use_openai_responses_path": true, "input_cost_per_token": 1.25e-06, "output_cost_per_token": 2.5e-06, "cache_read_input_token_cost": 2e-07, diff --git a/litellm/models/verification_token.py b/litellm/models/verification_token.py index d67726be584..519066b8266 100644 --- a/litellm/models/verification_token.py +++ b/litellm/models/verification_token.py @@ -36,6 +36,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase): budget_reset_at: Optional[datetime] = None allowed_cache_controls: Optional[list] = [] allowed_routes: Optional[list] = [] + key_type: str | None = None permissions: Dict = {} model_spend: Dict = {} model_max_budget: Dict = {} diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index e300a22e5db..421f1dcfbea 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -18,6 +18,9 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credenti is_bridge_envelope_shaped, resolve_bridge_envelope, ) +from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( + EnvelopeIdentity, +) from litellm.proxy._types import ( UI_TEAM_ID, LiteLLM_TeamTable, @@ -543,7 +546,7 @@ class MCPRequestHandler: header_key = server.alias or server.server_name if header_key is None: raise HTTPException(status_code=500, detail="Server misconfigured: MCP server has no routable name") - admitted = await MCPRequestHandler._reload_admitted_key(result.identity.key_hash) + admitted = await MCPRequestHandler._reload_admitted_principal(result.identity) await MCPRequestHandler._enforce_admitted_live_policy(admitted=admitted, request=request, route=route) injected = {header_key: {"Authorization": result.upstream_authorization.get_secret_value()}} new_headers = {**(mcp_server_auth_headers or {}), **injected} @@ -572,6 +575,89 @@ class MCPRequestHandler: route=route, ) + @staticmethod + async def _reload_admitted_principal(identity: EnvelopeIdentity) -> UserAPIKeyAuth: + """Reload the live litellm record the envelope's subject references. + + Dispatches on the sealed subject type: a ``key_hash`` reloads the virtual key that + minted the envelope (the scripted two-header client that presents a litellm key at the + token endpoint), a ``user_id`` reloads the user that authenticated interactively (the + DCR client, whose SSO login at the bridged authorize yields a user, not a key). Both + return a ``UserAPIKeyAuth`` the caller runs through the centralized policy gate, so + team/project/org/budget/SCIM enforcement is identical to the principal presenting + itself directly.""" + match identity.subject_type: + case "key_hash": + return await MCPRequestHandler._reload_admitted_key(identity.subject) + case "user_id": + return await MCPRequestHandler._reload_admitted_user(identity.subject) + case _: + assert_never(identity.subject_type) + + @staticmethod + async def _reload_admitted_user(user_id: str) -> UserAPIKeyAuth: + """Reload the live user an interactively-minted envelope references and admit them as + themselves. + + The DCR client authenticates via SSO at the bridged authorize, which yields a user + subject rather than a virtual key, so the envelope admits under the user's own + identity: the reloaded ``user_id`` and the user's own MCP object permission ride on the + returned ``UserAPIKeyAuth``, and the SAME ``get_allowed_mcp_servers`` the key path uses then + computes which servers the user may reach, so the user's litellm MCP grants and access groups + gate the request exactly as a key's do. Only the user's OWN object permission is bound: a + ``UserAPIKeyAuth`` carries a single ``team_id`` while a user may belong to many teams, so + team-inherited MCP grants for a user are a follow-up (they need a many-teams union + ``get_allowed_mcp_servers`` does not do off one auth object). The caller's centralized policy + gate enforces the user's live budget and org state, and a SCIM-deactivated owner fails closed. + + Error handling mirrors the key path's retryable-503 contract, but ``get_user_object`` defeats a + type-based check: where ``get_key_object`` raises a typed ``ProxyException`` for a missing key + and lets a DB outage propagate raw, ``get_user_object`` catches every DB failure and re-raises a + bare ``ValueError``, so a missing user and a real outage look identical and the original error + survives only as ``__context__``. ``_raise_503_if_db_unavailable`` therefore walks the cause + chain: a transient DB outage still surfaces as a retryable 503, while a missing user, or any + other non-outage resolution failure, fails closed as a 401 rather than an opaque 500. The + object-permission load shares this one boundary, so an outage there is classified the same + way (``get_object_permission`` itself swallows a failed load to ``None``, matching how + ``get_key_object`` best-effort-loads a key's object permission).""" + from litellm.proxy.auth.auth_checks import get_object_permission, get_user_object + from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Server misconfigured: no database connection") + try: + user_object = await get_user_object( + user_id=user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + ) + # Resolve the user's own MCP object permission (get_user_object does not load it) so the shared + # get_allowed_mcp_servers can grant the user their litellm-granted servers. Reuses the same + # get_object_permission resolver the key and team paths use; no permission logic is duplicated. + object_permission = user_object.object_permission if user_object is not None else None + if user_object is not None and object_permission is None and user_object.object_permission_id: + object_permission = await get_object_permission( + object_permission_id=user_object.object_permission_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + ) + except (ProxyException, HTTPException): + raise HTTPException(status_code=401, detail="Invalid or expired credential") from None + except Exception as e: # noqa: BLE001 # a DB outage anywhere in the resolution is a retryable 503, not an opaque 500; anything else fails closed as 401 + MCPRequestHandler._raise_503_if_db_unavailable(e) + raise HTTPException(status_code=401, detail="Invalid or expired credential") from None + if user_object is None: + raise HTTPException(status_code=401, detail="Invalid or expired credential") + if isinstance(user_object.metadata, dict) and user_object.metadata.get("scim_active") is False: + raise HTTPException(status_code=401, detail="Invalid or expired credential") + return UserAPIKeyAuth( + user_id=user_object.user_id, + user_role=user_object.user_role, + object_permission=object_permission, + object_permission_id=user_object.object_permission_id, + ) + @staticmethod async def _reload_admitted_key(key_hash: str) -> UserAPIKeyAuth: """Reload the live key record an admitted envelope references and re-check live policy. @@ -615,10 +701,14 @@ class MCPRequestHandler: """Raise a retryable 503 when ``e`` means the auth database is unreachable, else return so the caller applies its own fail-closed mapping. A DB outage must not masquerade as an auth failure (401) or surface as an opaque 500; the caller retries. Mirrors ``UserAPIKeyAuthExceptionHandler``, - which renders a service-unavailable database error as 503 on the standard pipeline.""" + which renders a service-unavailable database error as 503 on the standard pipeline. + + Classifies across the ``__cause__``/``__context__`` chain, not just ``e`` itself: ``get_user_object`` + re-raises every DB failure as a bare ``ValueError``, so a type-based check on the top exception + would miss a real outage wrapped inside it.""" from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler - if PrismaDBExceptionHandler.is_database_service_unavailable_error(e): + if PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(e): raise HTTPException( status_code=503, detail="Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly.", diff --git a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py new file mode 100644 index 00000000000..19048e2eb7c --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py @@ -0,0 +1,694 @@ +"""Bridge token flow: litellm identity resolution and the DCR-bridge oauth_delegate mint/refresh pipeline.""" + +import math +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Literal, Optional + +from fastapi import HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import SecretStr +from typing_extensions import assert_never + +from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.oauth_utils import TOKEN_NO_CACHE_HEADERS +from litellm.types.mcp_server.mcp_server_manager import MCPServer + +if TYPE_CHECKING: + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import _BridgeAuthorizationCode + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( + EnvelopeIdentity, + EnvelopeKeys, + RefreshCredential, + UpstreamTokenGrant, + ) + from litellm.proxy._types import UserAPIKeyAuth + + +def _litellm_key_from_request(request: Request) -> Optional[str]: + """Return the LiteLLM API key presented on the request, or ``None``. + + Accepts the key from ``x-litellm-api-key`` (what MCP clients such as Claude Desktop/Code + send) as well as ``Authorization``; either may carry a bare token or ``Bearer ``. + ``x-litellm-api-key`` wins when both are present, since ``Authorization`` may instead carry + an OAuth/upstream bearer. + """ + for header_value in ( + request.headers.get("x-litellm-api-key"), + request.headers.get("Authorization") or request.headers.get("authorization"), + ): + if not header_value: + continue + value = header_value.strip() + if value.lower().startswith("bearer "): + value = value[7:].strip() + if value: + return value + return None + + +def _key_is_active(key_obj: "UserAPIKeyAuth") -> bool: + """``True`` when the presented key is neither blocked nor past its expiry. + + The OAuth token endpoint is unauthenticated, so the presented key is validated here before it is + trusted; a revoked or expired key must not mint a bridge envelope or write a stored credential. + ``get_key_object`` resolves a row without these checks (the main ``user_api_key_auth`` pipeline + enforces them downstream, which this endpoint bypasses), so they are applied here. Deleted keys + are already rejected upstream, where ``get_key_object`` raises on a row that no longer exists. + + This is an active-state gate only; it deliberately does not require a ``user_id``. A valid + team-scoped or service-account key has no ``user_id`` yet is a legitimate credential, so gating + on ``user_id`` presence would wrongly reject it. Callers that need the user (the per-user token + store) derive it separately via :func:`_active_key_user_id`. + + Total by design: ``expires`` is typed ``str | datetime``, and an unparseable string would make + ``datetime.fromisoformat`` raise. Since the callers run this outside their key-resolution + ``try``, an uncaught parse error would surface as a 500 instead of the endpoint's fail-closed + behavior, so a malformed expiry is treated as inactive (return ``False``) rather than raising. + """ + if key_obj.blocked is True: + return False + expires = key_obj.expires + if expires is not None: + if isinstance(expires, datetime): + expiry = expires + else: + try: + expiry = datetime.fromisoformat(expires) + except (ValueError, TypeError): + return False + if expiry.tzinfo is None or expiry.tzinfo.utcoffset(expiry) is None: + expiry = expiry.replace(tzinfo=timezone.utc) + if expiry < datetime.now(timezone.utc): + return False + return True + + +def _active_key_user_id(key_obj: "UserAPIKeyAuth") -> str | None: + """The active key's ``user_id``, or ``None`` when the key is blocked/expired or simply has no + ``user_id`` (a team-scoped or service-account key). Used only by the per-user token store, which + needs a user to key the stored credential; the bridge mint uses the key hash and does not.""" + return key_obj.user_id if _key_is_active(key_obj) else None + + +@dataclass(frozen=True, slots=True) +class _ResolvedKey: + """An active litellm key resolved from the token request: its hash (the value ``get_key_object`` + and the cache/DB layer key the record by) and the live record.""" + + key_hash: str + key: "UserAPIKeyAuth" + + +_KeyResolutionFailure = Literal["no_active_key", "unavailable", "unresolvable"] +"""Why a token request yielded no active litellm key, kept distinct so a caller statuses each truthfully +instead of blaming the client for a gateway problem: +- ``no_active_key``: none was presented, or the presented key is unknown / blocked / expired (the + caller's request is at fault) +- ``unavailable``: the auth database was transiently unreachable while resolving (retryable) +- ``unresolvable``: the gateway cannot resolve identity right now (no DB connection, or an unexpected + error) -- a gateway fault, not the caller's +The classification mirrors admission's ``_reload_admitted_key`` so the mint (ingress) and admission +(egress) never disagree on the status of the same outage.""" + + +async def _resolve_active_litellm_key(request: Request) -> "_ResolvedKey | _KeyResolutionFailure": + """Resolve the presented litellm key to an active key record, or say precisely why not. + + Single resolution path the OAuth token endpoint reuses, resolving authoritatively via + ``get_key_object`` (cache first, then DB). The failure is a value, not a bare ``None``, so a caller + can tell "the client sent no usable credential" (a request error) apart from "the gateway could not + check" (an infrastructure error) and status each truthfully; collapsing both to ``None`` is what let + a DB outage read as a 400. A resolved key is still gated by ``_key_is_active``, so a blocked or + expired key is ``no_active_key`` while a valid team-scoped or service-account key (no ``user_id``) + resolves. Classification mirrors admission's ``_reload_admitted_key``: no DB connection is a gateway + fault, a ``ProxyException`` / ``HTTPException`` from ``get_key_object`` is an unknown or invalid key, + a database-service-unavailable error is a retryable outage, and anything else is an unexpected + gateway fault.""" + token = _litellm_key_from_request(request) + if not token: + return "no_active_key" + from litellm.proxy._types import hash_token # noqa: PLC0415 # inline import avoids a module-load circular import + + return await _reload_active_key_by_hash(hash_token(token)) + + +async def _reload_active_key_by_hash(key_hash: str) -> "_ResolvedKey | _KeyResolutionFailure": + """Reload the live key record for ``key_hash`` (cache first, then DB) and gate it on active state, + returning the resolved key or a precise failure. Shared by the token request's presented-key + resolution (:func:`_resolve_active_litellm_key`, which hashes the presented key) and the refresh + path (which already holds the hash sealed in the refresh envelope), so both re-validate identity + through one active-key gate and one failure classification. Classification mirrors admission's + ``_reload_admitted_key``: no DB connection is a gateway fault, a ``ProxyException`` / ``HTTPException`` + from ``get_key_object`` is an unknown or invalid key, a database-service-unavailable error is a + retryable outage, and anything else is an unexpected gateway fault. A blocked or expired key is + ``no_active_key``, so a revoked key can neither mint nor refresh a bridge envelope.""" + from litellm.proxy._types import ( + ProxyException, # noqa: PLC0415 # inline import avoids a module-load circular import + ) + from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import + get_key_object, + ) + from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import + PrismaDBExceptionHandler, + ) + from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import + prisma_client, + user_api_key_cache, + ) + + if prisma_client is None: + return "unresolvable" + try: + key_obj = await get_key_object( + hashed_token=key_hash, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + ) + except (ProxyException, HTTPException): + return "no_active_key" + except Exception as exc: # noqa: BLE001 # classify: a DB outage is retryable, anything else is an opaque gateway fault + if PrismaDBExceptionHandler.is_database_service_unavailable_error(exc): + return "unavailable" + verbose_logger.debug( + "_reload_active_key_by_hash: unexpected key-resolution error (%s)", + type(exc).__name__, + ) + return "unresolvable" + if not _key_is_active(key_obj): + return "no_active_key" + return _ResolvedKey(key_hash=key_hash, key=key_obj) + + +async def _reload_active_user_by_id(user_id: str) -> "_KeyResolutionFailure | None": + """Re-validate a live litellm user by id, returning ``None`` when the user is active or a precise + failure otherwise. The interactive DCR client authenticates via SSO, so its refresh envelope seals a + user subject; renewing it must re-check the user is still live (present and not SCIM-deactivated) so a + deactivated user cannot keep refreshing, mirroring how admission re-validates the same user subject on + the egress side. No DB connection is a gateway fault (``unresolvable``) and a + database-service-unavailable error is a retryable outage (``unavailable``). Everything else fails + closed as ``no_active_key`` (the caller maps it to invalid_grant): a ``ProxyException`` / + ``HTTPException``, a SCIM-deactivated user, and, unlike the key path, a missing user. ``get_user_object`` + catches every DB failure and re-raises a bare ``ValueError`` (a deleted user and a real outage look + identical, the original error surviving only as ``__context__``), so the outage check walks the cause + chain, and a missing user falls through to ``no_active_key`` rather than an opaque gateway fault.""" + from litellm.proxy._types import ( + ProxyException, # noqa: PLC0415 # inline import avoids a module-load circular import + ) + from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import + get_user_object, + ) + from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import + PrismaDBExceptionHandler, + ) + from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import + prisma_client, + user_api_key_cache, + ) + + if prisma_client is None: + return "unresolvable" + try: + user_object = await get_user_object( + user_id=user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + ) + except (ProxyException, HTTPException): + return "no_active_key" + except Exception as exc: # noqa: BLE001 # a DB outage is retryable; a missing user (get_user_object's wrapped ValueError) or any other resolution failure fails closed as no_active_key, never a 500 + if PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(exc): + return "unavailable" + verbose_logger.debug("_reload_active_user_by_id: user-resolution error (%s)", type(exc).__name__) + return "no_active_key" + if user_object is None: + return "no_active_key" + if isinstance(user_object.metadata, dict) and user_object.metadata.get("scim_active") is False: + return "no_active_key" + return None + + +async def _key_owner_scim_deactivated(key: "UserAPIKeyAuth") -> bool: + """True only when the key's owning user was explicitly SCIM-deactivated, so a refresh revokes an + offboarded owner's key exactly as admission does via ``_reject_if_admitted_owner_scim_deactivated``. + A key with no owner, a missing owner record, or a failed lookup fails OPEN (returns ``False``), + matching admission and the standard builder: a key may outlive its owner record, and a transient DB + blip must not revoke a live key. Only an explicit ``scim_active`` of ``False`` gates renewal.""" + if key.user_id is None: + return False + from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import + get_user_object, + ) + from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import + prisma_client, + user_api_key_cache, + ) + + if prisma_client is None: + return False + try: + owner = await get_user_object( + user_id=key.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + ) + except Exception as exc: # noqa: BLE001 # fail open: a missing owner (get_user_object's wrapped ValueError) or a DB blip must not revoke a live key + verbose_logger.debug("refresh: key-owner SCIM lookup failed, not revoking (%s)", type(exc).__name__) + return False + return owner is not None and isinstance(owner.metadata, dict) and owner.metadata.get("scim_active") is False + + +async def _revalidate_active_subject(identity: "EnvelopeIdentity") -> "_KeyResolutionFailure | None": + """Re-validate that the subject sealed in a refresh envelope is still live, dispatching on its type: + a key_hash reloads the virtual key, a user_id reloads the user. Returns ``None`` when the subject is + active or a precise failure otherwise, so revocation gates renewal for either identity source the same + way admission gates the egress: a blocked or expired key, a SCIM-deactivated key owner (mirroring + admission's owner check, so an offboarded user cannot keep renewing a still-active key), and a + deactivated or deleted user all fail closed to ``no_active_key``.""" + match identity.subject_type: + case "key_hash": + reloaded = await _reload_active_key_by_hash(identity.subject) + if not isinstance(reloaded, _ResolvedKey): + return reloaded + if await _key_owner_scim_deactivated(reloaded.key): + return "no_active_key" + return None + case "user_id": + return await _reload_active_user_by_id(identity.subject) + case _: + assert_never(identity.subject_type) + + +async def _extract_user_id_from_request(request: Request) -> str | None: + """The litellm ``user_id`` for the token request, so a per-user token is stored under the same + identity the egress later reads it by. Storage is best-effort, so every non-resolved outcome + (including a transient DB outage) collapses to ``None`` here and the caller simply skips the store; + the bridge mint, which must status those outcomes differently, consumes + :func:`_resolve_active_litellm_key` directly.""" + resolved = await _resolve_active_litellm_key(request) + if not isinstance(resolved, _ResolvedKey): + return None + return _active_key_user_id(resolved.key) + + +_UpstreamGrantRejection = Literal["no_access_token", "expired_lifetime"] +"""Why an upstream token response cannot back a bridge envelope: +- ``no_access_token``: the response carries no usable ``access_token`` +- ``expired_lifetime``: the response reports a parseable, non-positive ``expires_in``, i.e. an upstream + token that is already dead, so sealing it would forward a bearer the edge cannot use +An absent or unparseable ``expires_in`` is NOT a rejection; the lifetime is merely unknown and the +envelope caps it, the by-design behaviour for an upstream that omits the field.""" + + +def _classify_upstream_lifetime(raw_expires_in: object) -> "int | Literal['unspecified', 'expired']": + """Classify an upstream ``expires_in`` into a positive number of seconds, ``"unspecified"`` (absent + or unparseable, so the envelope caps it), or ``"expired"`` (a non-positive value the upstream reports + as already elapsed). Telling "we do not know the lifetime" apart from "the upstream says it is + already dead" is what stops an explicitly-expired token from silently receiving the envelope's 1h + cap. The expired decision is made on the parsed numeric value, not on ``int(...)`` of it, so a + positive sub-second lifetime in ``(0, 1)`` is not truncated to ``0`` and misread as elapsed; the + envelope works in whole seconds, so such a lifetime clamps up to its 1s floor. ``bool`` is excluded + (an ``int`` subclass but never a real lifetime), and the conversions can raise on ``NaN`` / + ``Infinity`` / oversized input, which reads as unparseable rather than surfacing as a 500.""" + if raw_expires_in is None or isinstance(raw_expires_in, bool) or not isinstance(raw_expires_in, (int, float, str)): + return "unspecified" + try: + numeric = float(raw_expires_in) + seconds = int(numeric) + except (ValueError, TypeError, OverflowError): + return "unspecified" + if numeric <= 0: + return "expired" + return max(1, seconds) + + +def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenGrant | _UpstreamGrantRejection": + """Validate an upstream OAuth token response into a typed grant, or say why it cannot back an + envelope. Each field is isinstance-checked so nothing untyped from ``response.json()`` reaches the + grant. ``expires_in`` is read three ways (see :func:`_classify_upstream_lifetime`): an unknown + lifetime leaves the grant ``expires_in`` ``None`` for the envelope to cap, a positive value is + honoured, and an explicit already-elapsed value is a rejection rather than a silent fall-through to + the cap.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import + UpstreamTokenGrant, + ) + + if not isinstance(token_response, dict): + return "no_access_token" + access = token_response.get("access_token") + if not isinstance(access, str) or not access: + return "no_access_token" + lifetime = _classify_upstream_lifetime(token_response.get("expires_in")) + if lifetime == "expired": + return "expired_lifetime" + token_type = token_response.get("token_type") + scope = token_response.get("scope") + return UpstreamTokenGrant( + access_token=SecretStr(access), + token_type=token_type if isinstance(token_type, str) and token_type else "Bearer", + # The upstream refresh_token is deliberately NOT sealed: the edge never consumes it (it forwards + # only token_type + access_token), so it would be dead weight embedding a long-lived upstream + # credential in the client-held bearer, and it enlarges the envelope. Refresh support is a + # follow-up (a dedicated refresh-envelope); the client re-runs authorization_code at the cap. + refresh_token=None, + scope=scope if isinstance(scope, str) and scope else None, + expires_in=lifetime if isinstance(lifetime, int) else None, + ) + + +# --------------------------------------------------------------------------- +# DCR-bridge oauth_delegate mint: a three-phase pipeline whose failures are values. +# +# prepare (before the upstream exchange) -> validate every precondition and resolve identity+keys +# exchange (the single-use upstream code is consumed here, in exchange_token_with_server) +# finish (after the exchange) -> seal the upstream grant into the client-held envelope +# +# Every precondition lives in ``prepare``, which runs BEFORE the exchange, so no failure can burn the +# single-use code or rotate a refresh token, for either grant type -- that whole class of bug is gone +# by construction rather than guarded case by case. Failures are values mapped to an OAuth-shaped +# response in one place (``_bridge_mint_error_response``), so status codes and the RFC 6749 §5.2 body +# shape are uniform. Adding a failure mode is a new literal plus a match arm the type checker forces. +# --------------------------------------------------------------------------- + +_BridgeMintError = Literal[ + "no_identity", + "invalid_refresh", + "identity_unavailable", + "identity_unresolvable", + "not_configured", + "no_upstream_token", + "upstream_token_expired", + "too_large", +] + + +@dataclass(frozen=True, slots=True) +class _BridgeMintReady: + """Everything the seal needs, resolved once before the exchange: the identity to bind the envelope + to and the master-key-derived envelope keys. The identity is a key_hash subject for the scripted + two-header client (resolved from the litellm key it presents) or a user_id subject for the + interactive SSO client (the user recovered from the gateway authorization code), so one phase-3 seal + serves both. Resolving identity here means ``_finish_bridge_mint`` has no preconditions left to + fail.""" + + identity: "EnvelopeIdentity" + keys: "EnvelopeKeys" + + +def _bridge_mint_error_response(error: _BridgeMintError) -> JSONResponse: + """Map a bridge-mint failure value to its token-endpoint response: one place, RFC 6749 §5.2 shape + (top-level ``error``, no-store headers) for every case, with a status truthful about where the + failure is. The caller's request is 400, a transient gateway outage is 503, a gateway + misconfiguration is 500, and an upstream problem is 502. The identity-resolution statuses match how + admission statuses the same conditions on the egress side, so mint and admit never disagree under + one outage.""" + match error: + case "no_identity": + status, code, desc = ( + 400, + "invalid_request", + "this server issues a gateway-bound credential; complete the interactive sign-in, or " + "send a litellm credential (x-litellm-api-key or Authorization) on the token request", + ) + case "invalid_refresh": + status, code, desc = ( + 400, + "invalid_grant", + "the refresh credential is not a valid, live refresh envelope for this server; " + "re-run authorization_code to obtain a new one", + ) + case "identity_unavailable": + status, code, desc = ( + 503, + "temporarily_unavailable", + "the authentication database is temporarily unreachable; retry shortly", + ) + case "identity_unresolvable": + status, code, desc = ( + 500, + "server_error", + "the gateway could not resolve the litellm identity for this request", + ) + case "not_configured": + status, code, desc = ( + 500, + "server_error", + "the gateway is not configured to mint a gateway-bound credential (master_key is not set)", + ) + case "no_upstream_token": + status, code, desc = ( + 502, + "server_error", + "the upstream token response has no usable access_token", + ) + case "upstream_token_expired": + status, code, desc = ( + 502, + "server_error", + "the upstream token response reports an already-expired lifetime", + ) + case "too_large": + status, code, desc = ( + 502, + "server_error", + "the upstream token is too large to seal into a gateway-bound credential", + ) + case _: + assert_never(error) + return JSONResponse( + status_code=status, content={"error": code, "error_description": desc}, headers=TOKEN_NO_CACHE_HEADERS + ) + + +def _key_resolution_failure_to_mint_error(failure: _KeyResolutionFailure) -> _BridgeMintError: + """Lift an identity-resolution failure into the mint taxonomy, preserving origin so the status stays + truthful: the caller's missing credential is 400, a transient DB outage is 503, and a gateway that + cannot resolve identity is 500.""" + match failure: + case "no_active_key": + return "no_identity" + case "unavailable": + return "identity_unavailable" + case "unresolvable": + return "identity_unresolvable" + case _: + assert_never(failure) + + +def _upstream_rejection_to_mint_error(rejection: _UpstreamGrantRejection) -> _BridgeMintError: + """Lift an upstream-response rejection into the mint taxonomy; both are upstream faults (502).""" + match rejection: + case "no_access_token": + return "no_upstream_token" + case "expired_lifetime": + return "upstream_token_expired" + case _: + assert_never(rejection) + + +async def _prepare_bridge_mint( + request: Request, + mcp_server: MCPServer, + bridge_identity: "_BridgeAuthorizationCode | None" = None, +) -> "_BridgeMintReady | _BridgeMintError": + """Phase 1 for the authorization_code grant, BEFORE the upstream exchange: confirm the gateway can + mint (master_key set), resolve the litellm identity, and derive the envelope keys. Returns a ready + context or a precise failure value. Running before the exchange is what makes every failure here fail + closed without consuming the single-use code. + + Two identity sources, one envelope. The interactive DCR client authenticates via SSO at the bridged + authorize, so its identity arrives as ``bridge_identity`` (the user recovered from the gateway + authorization code) and mints a user subject. The scripted two-header client presents a litellm key + on the token request instead, so its identity is the active key's hash and mints a key_hash subject. + A missing or invalid presented key keeps its resolution origin so the mapper statuses it truthfully; + neither source present is ``no_identity``. The refresh_token grant has its own phase-1 + (:func:`_prepare_bridge_refresh`), which recovers identity from the presented refresh envelope.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import + envelope_keys_from_master_key, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import + key_hash_identity, + user_identity, + ) + from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import + master_key, + ) + + if not master_key: + return "not_configured" + keys = envelope_keys_from_master_key(master_key) + if bridge_identity is not None: + identity = user_identity(server_id=mcp_server.server_id, user_id=bridge_identity.litellm_user_id) + return _BridgeMintReady(identity=identity, keys=keys) + resolved = await _resolve_active_litellm_key(request) + if not isinstance(resolved, _ResolvedKey): + return _key_resolution_failure_to_mint_error(resolved) + identity = key_hash_identity(server_id=mcp_server.server_id, key_hash=resolved.key_hash) + return _BridgeMintReady(identity=identity, keys=keys) + + +@dataclass(frozen=True, slots=True) +class _BridgeRefreshReady: + """A validated refresh request: the identity+keys to mint the renewed pair under, the upstream refresh + token (unwrapped from the client's refresh envelope) to exchange with the upstream IdP, and the scope + sealed alongside it at mint. The upstream refresh token is a ``SecretStr`` like every other credential + in this layer, so a repr or a traceback that captures this value never exposes the raw upstream refresh + token in plaintext. ``upstream_scope`` carries the originally-granted scope so the renewal re-requests + it when the client (a DCR/MCP client that typically omits scope on refresh) sends none, keeping the + renewed token's scope stable against an upstream that would otherwise narrow or drop it.""" + + ready: "_BridgeMintReady" + upstream_refresh_token: SecretStr + upstream_scope: str | None = None + + +def _refresh_key_failure_to_mint_error(failure: _KeyResolutionFailure) -> _BridgeMintError: + """Lift an identity-resolution failure on the refresh path into the mint taxonomy. Unlike the mint + path, a resolved-but-inactive (or unknown) key is ``invalid_grant`` rather than ``invalid_request``: + the client did present an identity (sealed in the refresh envelope), but it is no longer live, so the + refresh is invalid and the client must re-authenticate. A transient outage is still 503 and a gateway + fault still 500, matching the mint path and admission.""" + match failure: + case "no_active_key": + return "invalid_refresh" + case "unavailable": + return "identity_unavailable" + case "unresolvable": + return "identity_unresolvable" + case _: + assert_never(failure) + + +async def _prepare_bridge_refresh( + mcp_server: MCPServer, refresh_value: str | None +) -> "_BridgeRefreshReady | _BridgeMintError": + """Phase 1 for the refresh_token grant, BEFORE the upstream exchange: open the client's refresh + envelope, re-validate the sealed litellm identity so a revoked key cannot keep refreshing, and + recover the upstream refresh token to exchange. Identity comes entirely from the sealed envelope, not + the HTTP request, so the request object is not needed here. The client presents a refresh envelope, + never a raw upstream refresh token, so a missing value, a non-envelope, an unopenable envelope, or one + minted for another server is ``invalid_grant``. Running before the exchange means a rejected refresh + never consumes or rotates the upstream refresh token.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import + BridgeRefreshOpened, + envelope_keys_from_master_key, + open_bridge_refresh_envelope, + ) + from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import + master_key, + ) + + if not master_key: + return "not_configured" + if not refresh_value: + return "invalid_refresh" + keys = envelope_keys_from_master_key(master_key) + opened = open_bridge_refresh_envelope(refresh_value, keys, datetime.now(timezone.utc), mcp_server.server_id) + if not isinstance(opened, BridgeRefreshOpened): + return "invalid_refresh" + failure = await _revalidate_active_subject(opened.identity) + if failure is not None: + return _refresh_key_failure_to_mint_error(failure) + return _BridgeRefreshReady( + ready=_BridgeMintReady(identity=opened.identity, keys=keys), + upstream_refresh_token=opened.refresh.refresh_token, + upstream_scope=opened.refresh.scope, + ) + + +def _finish_bridge_mint( + ready: "_BridgeMintReady", mcp_server: MCPServer, token_response: object, now: datetime +) -> "JSONResponse | _BridgeMintError": + """Phase 3, AFTER the upstream exchange: seal the upstream grant into the client-held access envelope + using the pre-resolved identity and keys, and, when the upstream returned a refresh token, seal a + long-lived refresh envelope alongside it so the client can renew without re-authenticating. Shared by + the authorization_code and refresh_token paths, so a renewal that the upstream rotates re-issues a + fresh refresh envelope. The only hard failures here are properties of the upstream access token (no + usable token, an already-expired lifetime, or a token too large to seal); a refresh token that cannot + be sealed degrades to an access-only response rather than failing the whole exchange.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import + build_bridge_token_response, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import + SealedEnvelope, + UpstreamTokenGrant, + ) + + grant = _bridge_grant_from_token_response(token_response) + if not isinstance(grant, UpstreamTokenGrant): + return _upstream_rejection_to_mint_error(grant) + sealed = build_bridge_token_response(ready.identity, grant, ready.keys, now) + if not isinstance(sealed, SealedEnvelope): + return "too_large" + # Report expires_in from the JWT's own second-truncated exp, rounding the elapsed portion up, so the + # client is never told the bearer lives past the point admission (which uses that exp) rejects it. + expires_in = max(0, int(sealed.expires_at.timestamp()) - math.ceil(now.timestamp())) + refresh_envelope = _mint_refresh_envelope_value(ready.identity, token_response, ready.keys, now, mcp_server) + body = { + "access_token": sealed.token.get_secret_value(), + "token_type": "Bearer", + "expires_in": expires_in, + # A refresh envelope rides along only when the upstream returned a refresh token to seal; when it + # rotates on renewal, the client receives the new one and the old envelope's upstream token dies. + **({"refresh_token": refresh_envelope} if refresh_envelope is not None else {}), + } + return JSONResponse(body, headers=TOKEN_NO_CACHE_HEADERS) + + +def _upstream_refresh_credential(token_response: object) -> "RefreshCredential | None": + """Extract the upstream refresh grant from a token response, or ``None`` when there is none to seal. + Each field is isinstance-checked so nothing untyped reaches the refresh envelope; ``refresh_expires_in`` + (the refresh token's own lifetime, when the upstream reports it) is classified like ``expires_in`` and + bounds the refresh envelope's TTL. An upstream that reports the refresh token itself as already elapsed + (``refresh_expires_in`` non-positive) yields ``None`` rather than a refresh envelope: sealing a dead + token would hand the client a full-TTL-capped envelope the IdP will reject, so the exchange degrades to + an access-only response (the client re-authenticates at access expiry), mirroring how + :func:`_bridge_grant_from_token_response` refuses an already-elapsed access token instead of capping it.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import + RefreshCredential, + ) + + if not isinstance(token_response, dict): + return None + refresh = token_response.get("refresh_token") + if not isinstance(refresh, str) or not refresh: + return None + lifetime = _classify_upstream_lifetime(token_response.get("refresh_expires_in")) + if lifetime == "expired": + return None + scope = token_response.get("scope") + return RefreshCredential( + refresh_token=SecretStr(refresh), + scope=scope if isinstance(scope, str) and scope else None, + expires_in=lifetime if isinstance(lifetime, int) else None, + ) + + +def _mint_refresh_envelope_value( + identity: "EnvelopeIdentity", token_response: object, keys: "EnvelopeKeys", now: datetime, mcp_server: MCPServer +) -> str | None: + """Seal the upstream refresh grant (if any) into a refresh envelope and return its bearer string, or + ``None`` when the upstream returned no refresh token or the refresh token is too large to seal. A + too-large refresh token degrades to an access-only response (logged) rather than failing an exchange + that already succeeded upstream: the client simply re-authenticates when the access envelope expires.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import + build_bridge_refresh_token_response, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import + SealedEnvelope, + ) + + refresh_credential = _upstream_refresh_credential(token_response) + if refresh_credential is None: + return None + sealed = build_bridge_refresh_token_response(identity, refresh_credential, keys, now) + if isinstance(sealed, SealedEnvelope): + return sealed.token.get_secret_value() + verbose_logger.warning( + "bridge mint: the upstream refresh token is too large to seal into a refresh envelope for " + "server=%s; issuing an access-only response, so the client re-authenticates at access expiry", + mcp_server.server_id, + ) + return None diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index ebceb320906..54aff86aab2 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -10,7 +10,7 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse import httpx from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError from litellm._logging import verbose_logger from litellm.llms.custom_httpx.http_handler import ( @@ -21,6 +21,24 @@ from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import ( TokenEndpointAuthConfigError, build_token_endpoint_client_auth, ) +from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( + _bridge_mint_error_response, + _BridgeMintReady, + _BridgeRefreshReady, + _extract_user_id_from_request, + _finish_bridge_mint, + _prepare_bridge_mint, + _prepare_bridge_refresh, +) +from litellm.proxy._experimental.mcp_server.faults import ( + CallerRejected, + CredentialSource, + UpstreamProtocolFault, + classify_upstream_dcr_rejection, + classify_upstream_token_rejection, + dcr_fault_detail, + render_token_fault, +) from litellm.proxy._experimental.mcp_server.oauth_utils import ( TOKEN_NO_CACHE_HEADERS, get_request_base_url, @@ -37,7 +55,7 @@ from litellm.types.mcp import MCPAuth, MCPCredentials from litellm.types.mcp_server.mcp_server_manager import MCPServer if TYPE_CHECKING: - from litellm.proxy._types import LiteLLM_MCPServerTable, UserAPIKeyAuth + from litellm.proxy._types import LiteLLM_MCPServerTable # TTL cache for upstream OAuth metadata fetched from pass-through MCP servers. # Keeps us from hammering the upstream IdP on each discovery request. @@ -91,6 +109,8 @@ def encode_state_with_base_url( code_challenge: Optional[str] = None, code_challenge_method: Optional[str] = None, client_redirect_uri: Optional[str] = None, + litellm_user_id: str | None = None, + mcp_server_id: str | None = None, ) -> str: """ Encode the base_url, original state, and PKCE parameters using encryption. @@ -101,6 +121,11 @@ def encode_state_with_base_url( code_challenge: PKCE code challenge from client code_challenge_method: PKCE code challenge method from client client_redirect_uri: Original redirect_uri from client + litellm_user_id: The SSO-authenticated litellm user captured at the bridge authorize + (interactive dcr_bridge oauth_delegate only); the callback seals it into the gateway + authorization code so the token mint can bind the envelope to this user + mcp_server_id: The bridge server the interactive flow targets, sealed alongside + litellm_user_id so the gateway code cannot be replayed against another server Returns: An encrypted string that encodes all values @@ -111,6 +136,8 @@ def encode_state_with_base_url( "code_challenge": code_challenge, "code_challenge_method": code_challenge_method, "client_redirect_uri": client_redirect_uri, + "litellm_user_id": litellm_user_id, + "mcp_server_id": mcp_server_id, } state_json = json.dumps(state_data, sort_keys=True) encrypted_state = encrypt_value_helper(state_json) @@ -138,6 +165,68 @@ def decode_state_hash(encrypted_state: str) -> dict: return state_data +_BRIDGE_AUTH_CODE_PREFIX = "llm_bcode_" + + +class _BridgeAuthorizationCode(BaseModel): + """The identity and upstream code the gateway seals into the authorization code it hands a DCR + client for an interactive dcr_bridge oauth_delegate sign-in, recovered at the token endpoint.""" + + model_config = ConfigDict(frozen=True) + upstream_code: str = Field(min_length=1) + litellm_user_id: str = Field(min_length=1) + mcp_server_id: str = Field(min_length=1) + + +def is_bridge_authorization_code(code: str) -> bool: + """Cheap prefix check that ``code`` is a gateway-sealed bridge authorization code rather than a + raw upstream code, so the token endpoint can route without decrypting.""" + return code.startswith(_BRIDGE_AUTH_CODE_PREFIX) + + +def seal_bridge_authorization_code(upstream_code: str, litellm_user_id: str, mcp_server_id: str) -> str: + """Seal the upstream authorization code and the SSO-captured litellm user into a gateway + authorization code. The DCR client only echoes this opaque value back at the token endpoint; the + gateway decrypts it there to recover the user (to bind the envelope) and the upstream code (to + exchange with the upstream), so a litellm identity captured in the browser at authorize survives + to the back-channel token call with nothing stored server-side. Encrypted with the repo's + authenticated symmetric helper (the same family the OAuth state uses), so the client can neither + read nor forge it.""" + payload = json.dumps( + {"upstream_code": upstream_code, "litellm_user_id": litellm_user_id, "mcp_server_id": mcp_server_id}, + sort_keys=True, + ) + return _BRIDGE_AUTH_CODE_PREFIX + encrypt_value_helper(payload) + + +def open_bridge_authorization_code(code: str) -> _BridgeAuthorizationCode | None: + """Recover the sealed identity and upstream code, or ``None`` when ``code`` is not a gateway + bridge code or does not decrypt / validate. Total over hostile input: a raw upstream code (the + scripted two-header path) returns ``None`` and the caller falls through to the existing + behavior.""" + if not is_bridge_authorization_code(code): + return None + decrypted = decrypt_value_helper( + code[len(_BRIDGE_AUTH_CODE_PREFIX) :], "bridge_authorization_code", return_original_value=False + ) + if not isinstance(decrypted, str): + return None + try: + return _BridgeAuthorizationCode.model_validate_json(decrypted) + except ValidationError: + return None + + +def _redirect_to_litellm_login(request: Request) -> RedirectResponse: + """Send an unauthenticated browser through litellm login before the interactive bridge authorize + can capture its identity. The bridge oauth_delegate flow seals the SSO user into the gateway code, + so a session is required; without one there is nothing to bind. After login the user re-initiates + the connection, which then finds the session cookie (the seamless return-to round-trip, which is + origin-validated against the control-plane URL, is a follow-up).""" + base_url = get_request_base_url(request) + return RedirectResponse(f"{base_url}/sso/key/generate") + + # LIT-4197: some upstream authorization servers reject an over-long ``state`` # (the encrypted OAuth session blob routinely exceeds their limit). The upstream # only needs an opaque value it echoes back on ``/callback``, so we forward a @@ -304,90 +393,6 @@ def _validate_token_response( ) -def _litellm_key_from_request(request: Request) -> Optional[str]: - """Return the LiteLLM API key presented on the request, or ``None``. - - Accepts the key from ``x-litellm-api-key`` (what MCP clients such as Claude Desktop/Code - send) as well as ``Authorization``; either may carry a bare token or ``Bearer ``. - ``x-litellm-api-key`` wins when both are present, since ``Authorization`` may instead carry - an OAuth/upstream bearer. - """ - for header_value in ( - request.headers.get("x-litellm-api-key"), - request.headers.get("Authorization") or request.headers.get("authorization"), - ): - if not header_value: - continue - value = header_value.strip() - if value.lower().startswith("bearer "): - value = value[7:].strip() - if value: - return value - return None - - -def _active_key_user_id(key_obj: "UserAPIKeyAuth") -> Optional[str]: - """The key's ``user_id``, or ``None`` if the key is blocked or expired. - - The OAuth token endpoint is unauthenticated, so the presented key is validated here before its - identity is trusted to key a stored credential; a revoked or expired key must not be able to - write or overwrite the per-user OAuth token. ``get_key_object`` resolves a row without these - checks (the main ``user_api_key_auth`` pipeline enforces them downstream, which this endpoint - bypasses), so they are applied here. Deleted keys are already rejected upstream, where - ``get_key_object`` raises on a row that no longer exists. - """ - if key_obj.blocked is True: - return None - expires = key_obj.expires - if expires is not None: - expiry = expires if isinstance(expires, datetime) else datetime.fromisoformat(expires) - if expiry.tzinfo is None or expiry.tzinfo.utcoffset(expiry) is None: - expiry = expiry.replace(tzinfo=timezone.utc) - if expiry < datetime.now(timezone.utc): - return None - return key_obj.user_id - - -async def _extract_user_id_from_request(request: Request) -> Optional[str]: - """Resolve the LiteLLM ``user_id`` at the OAuth token endpoint so a per-user token is stored - under the same identity the egress later reads it by (``user_api_key_auth.user_id``). - - Resolves authoritatively via ``get_key_object`` (cache first, then DB) instead of a raw cache - peek. On a multi-replica gateway the token-exchange request can land on a worker whose in-memory - cache never saw the key, and a cross-replica Redis hit deserializes to a plain ``dict`` rather - than a ``UserAPIKeyAuth``; the previous code read only ``Authorization`` and did - ``getattr(cached, "user_id")`` with no ``model_type`` rehydration and no DB fallback, so it - silently returned ``None`` and the token was never persisted, which makes the egress 401 on every - reconnect. The resolved key is validated (``_active_key_user_id``) before its identity is trusted, - so a blocked or expired key cannot write. Returns ``None`` when no key is present, the key cannot - be resolved, or it is blocked/expired. - """ - token = _litellm_key_from_request(request) - if not token: - return None - try: - from litellm.proxy._types import hash_token # noqa: PLC0415 - from litellm.proxy.auth.auth_checks import get_key_object # noqa: PLC0415 - from litellm.proxy.proxy_server import ( # noqa: PLC0415 - prisma_client, - user_api_key_cache, - ) - - key_obj = await get_key_object( - hashed_token=hash_token(token), - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - ) - return _active_key_user_id(key_obj) - except Exception as exc: - verbose_logger.debug( - "_extract_user_id_from_request: could not resolve a LiteLLM user_id for the presented " - "key (%s); per-user token will not be stored server-side.", - type(exc).__name__, - ) - return None - - async def _store_per_user_token_server_side( server: MCPServer, user_id: str, @@ -620,12 +625,31 @@ async def authorize_with_server( parsed = urlparse(redirect_uri) base_url = urlunparse(parsed._replace(query="")) request_base_url = get_request_base_url(request) + + # Interactive dcr_bridge oauth_delegate sign-in: this arm runs the gateway /callback and /token in + # the loop, so the gateway can capture the litellm user here (from the browser's UI session) and + # carry it to the back-channel token mint. Seal the SSO user and the target server into the state; + # the callback reads them back to mint the gateway authorization code. A DCR client cannot present a + # litellm key, so the browser session is the only identity source; without one there is nothing to + # bind, so send the user through login first. Every other oauth2 server keeps the identity-less state. + litellm_user_id: str | None = None + if mcp_server.is_dcr_bridge and mcp_server.is_oauth_delegate: + from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( # noqa: PLC0415 # inline import avoids a module-load circular import + _user_id_from_session_cookie, + ) + + litellm_user_id = _user_id_from_session_cookie(request) + if litellm_user_id is None: + return _redirect_to_litellm_login(request) + encoded_state = encode_state_with_base_url( base_url=base_url, original_state=state, code_challenge=code_challenge, code_challenge_method=code_challenge_method, client_redirect_uri=redirect_uri, + litellm_user_id=litellm_user_id, + mcp_server_id=mcp_server.server_id if litellm_user_id else None, ) relay_state = secrets.token_urlsafe(_OAUTH_STATE_HANDLE_BYTES) @@ -654,6 +678,13 @@ async def authorize_with_server( return response +def _token_credential_source(mcp_server: MCPServer) -> CredentialSource: + """Mirrors the resolved-client rule in :func:`exchange_token_with_server`: when the server has a + stored client_id the gateway presents its own credentials upstream, so a credential rejection is + the operator's fault, not the caller's.""" + return "gateway_stored" if mcp_server.client_id else "caller_supplied" + + async def exchange_token_with_server( request: Request, mcp_server: MCPServer, @@ -688,25 +719,61 @@ async def exchange_token_with_server( except TokenEndpointAuthConfigError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + bridge_identity: _BridgeAuthorizationCode | None = None + bridge_mint_ready: _BridgeMintReady | None = None + bridge_upstream_refresh: SecretStr | None = None + bridge_upstream_scope: str | None = None + refresh_request_scope: str | None = None + is_bridge = mcp_server.is_oauth_delegate and mcp_server.is_dcr_bridge + if grant_type == "refresh_token": - if not refresh_token: + # Phase 1 for a bridge refresh: open the client's refresh envelope, re-validate the sealed + # identity, and unwrap the real upstream refresh token BEFORE building token_data, so the exchange + # sends the upstream token and never the envelope. A failure returns without touching the upstream. + if is_bridge: + prepared_refresh = await _prepare_bridge_refresh(mcp_server, refresh_token) + if not isinstance(prepared_refresh, _BridgeRefreshReady): + return _bridge_mint_error_response(prepared_refresh) + bridge_mint_ready = prepared_refresh.ready + bridge_upstream_refresh = prepared_refresh.upstream_refresh_token + bridge_upstream_scope = prepared_refresh.upstream_scope + # A bridge server sends the unwrapped upstream refresh token recovered from the client's refresh + # envelope above; every other server sends the client's own refresh token verbatim. + upstream_refresh_token = ( + bridge_upstream_refresh.get_secret_value() if bridge_upstream_refresh is not None else refresh_token + ) + if not upstream_refresh_token: raise HTTPException( status_code=400, detail="refresh_token is required for refresh_token grant", ) token_data: dict = { "grant_type": "refresh_token", - "refresh_token": refresh_token, + "refresh_token": upstream_refresh_token, **client_auth.body, } - if scope: - token_data["scope"] = scope + refresh_request_scope = scope or bridge_upstream_scope + if refresh_request_scope: + token_data["scope"] = refresh_request_scope else: if not code: raise HTTPException( status_code=400, detail="code is required for authorization_code grant", ) + # Interactive dcr_bridge oauth_delegate: the client presents the gateway authorization code the + # callback sealed. Recover the SSO user and the real upstream code from it; the upstream exchange + # below uses the upstream code, and the mint binds the envelope to the recovered user. Bind the + # sealed server to this request so a code minted for one bridge server cannot be spent at another. + # A raw upstream code (scripted path) opens to None and the code is used as-is. + bridge_identity = open_bridge_authorization_code(code) + if bridge_identity is not None: + if bridge_identity.mcp_server_id != mcp_server.server_id: + raise HTTPException( + status_code=400, + detail="Authorization code was issued for a different MCP server", + ) + code = bridge_identity.upstream_code bridge_token_relay = _dcr_bridge_relays_client_registration(mcp_server) if bridge_token_relay and not redirect_uri: raise HTTPException( @@ -726,32 +793,49 @@ async def exchange_token_with_server( } if code_verifier: token_data["code_verifier"] = code_verifier - + # Phase 1 for a bridge authorization_code mint: resolve identity (the SSO user recovered above, or + # the presented litellm key) and the envelope keys BEFORE the exchange consumes the single-use code. + if is_bridge: + prepared = await _prepare_bridge_mint(request, mcp_server, bridge_identity) + if not isinstance(prepared, _BridgeMintReady): + return _bridge_mint_error_response(prepared) + bridge_mint_ready = prepared async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) - response = await async_client.post( - mcp_server.token_url, - headers={"Accept": "application/json", **client_auth.headers}, - data=token_data, - ) + try: + response = await async_client.post( + mcp_server.token_url, + headers={"Accept": "application/json", **client_auth.headers}, + data=token_data, + ) + if response is not None: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + fault = classify_upstream_token_rejection( + exc.response, + credential_source=_token_credential_source(mcp_server), + log_context=mcp_server.server_id, + ) + upstream_rejected_bridge_refresh = ( + is_bridge + and grant_type == "refresh_token" + and isinstance(fault, CallerRejected) + and fault.code == "invalid_grant" + ) + if upstream_rejected_bridge_refresh: + verbose_logger.info( + "bridge refresh: the upstream rejected the sealed refresh token for server=%s with " + "invalid_grant (revoked or expired at the IdP); returning invalid_grant so the client " + "re-runs authorization_code rather than an opaque upstream error", + mcp_server.server_id, + ) + return _bridge_mint_error_response("invalid_refresh") + return render_token_fault(fault) if response is None: raise HTTPException( status_code=502, detail="MCP upstream token endpoint returned no response", ) - - try: - response.raise_for_status() - except httpx.HTTPStatusError as exc: - if "invalid_target" in exc.response.text: - verbose_logger.warning( - "MCP server %s: the upstream authorization server rejected the token request with " - "invalid_target; it may require RFC 8707 resource indicators, which the gateway " - "does not send yet (tracked as LIT-4339)", - mcp_server.server_id, - ) - raise token_response = response.json() - access_token = token_response["access_token"] # Validate token response against server-configured rules before any storage. # This rejects tokens from wrong Slack workspaces, Atlassian orgs, etc. @@ -791,8 +875,23 @@ async def exchange_token_with_server( mcp_server.server_id, ) + # A DCR-bridge oauth_delegate server hands the client a gateway-bound envelope (identity plus the + # upstream token) instead of the raw upstream token, so the one bearer both admits the caller and + # forwards the upstream credential. Only this mode mints; every other server returns the raw token. + if bridge_mint_ready is not None: + if refresh_request_scope and isinstance(token_response, dict) and not token_response.get("scope"): + token_response = {**token_response, "scope": refresh_request_scope} + # Phase 3: seal the upstream grant into the client-held envelope; failures map through the same + # OAuth-shaped response as the phase-1 preconditions. + minted = _finish_bridge_mint(bridge_mint_ready, mcp_server, token_response, datetime.now(timezone.utc)) + return minted if isinstance(minted, JSONResponse) else _bridge_mint_error_response(minted) + + raw_access_token = token_response.get("access_token") if isinstance(token_response, dict) else None + if not isinstance(raw_access_token, str) or not raw_access_token: + return render_token_fault(UpstreamProtocolFault(note="the upstream token response has no usable access_token")) + result = { - "access_token": access_token, + "access_token": raw_access_token, "token_type": token_response.get("token_type", "Bearer"), } @@ -1048,21 +1147,6 @@ async def _persist_dcr_client_registration( return "failed" -_MAX_UPSTREAM_ERROR_CHARS = 500 - - -def _safe_upstream_error_detail(response: httpx.Response) -> str: - """Bounded plaintext summary of an upstream registration failure for the client. - - RFC 7591 error bodies are small JSON objects (``error`` / ``error_description``); relaying the - text lets the client read the real reason instead of a bare 500, and the length bound keeps a - hostile or oversized upstream body from bloating the gateway response.""" - body = response.text - if not body: - return response.reason_phrase or "upstream registration failed" - return body[:_MAX_UPSTREAM_ERROR_CHARS] - - async def register_client_with_server( request: Request, mcp_server: MCPServer, @@ -1122,19 +1206,24 @@ async def register_client_with_server( } async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Register) - response = await async_client.post( - mcp_server.registration_url, - headers=headers, - json=register_data, - ) + try: + response = await async_client.post( + mcp_server.registration_url, + headers=headers, + json=register_data, + ) + if response is not None: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + status_code, detail = dcr_fault_detail( + classify_upstream_dcr_rejection(exc.response, log_context=mcp_server.server_id) + ) + raise HTTPException(status_code=status_code, detail=detail) from exc if response is None: raise HTTPException( status_code=502, detail="MCP upstream registration endpoint returned no response", ) - if bridge_relay and response.status_code >= 400: - raise HTTPException(status_code=response.status_code, detail=_safe_upstream_error_detail(response)) - response.raise_for_status() token_response = response.json() @@ -1362,7 +1451,20 @@ async def callback( # states while permitting same-origin / allowlisted clients. redirect_uri = _get_validated_client_redirect_uri(request, state_data) - params = {"code": code, "state": original_state} + # Interactive dcr_bridge oauth_delegate: the state carries the litellm user the authorize step + # captured. Instead of forwarding the raw upstream code (which the client would present at the + # token endpoint with no way to prove who signed in), seal the user and the upstream code into a + # gateway authorization code and forward THAT. The token endpoint decrypts it to bind the + # envelope to this user. Every other flow forwards the raw code unchanged. + litellm_user_id = state_data.get("litellm_user_id") + mcp_server_id = state_data.get("mcp_server_id") + forwarded_code = code + if isinstance(litellm_user_id, str) and litellm_user_id and isinstance(mcp_server_id, str) and mcp_server_id: + forwarded_code = seal_bridge_authorization_code( + upstream_code=code, litellm_user_id=litellm_user_id, mcp_server_id=mcp_server_id + ) + + params = {"code": forwarded_code, "state": original_state} complete_returned_url = _append_query_params(redirect_uri, params) response = RedirectResponse(url=complete_returned_url, status_code=302) _clear_oauth_state_cookie(response, request, state) diff --git a/litellm/proxy/_experimental/mcp_server/faults/__init__.py b/litellm/proxy/_experimental/mcp_server/faults/__init__.py new file mode 100644 index 00000000000..da078f0e242 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/faults/__init__.py @@ -0,0 +1,38 @@ +"""Typed fault values for upstream OAuth/DCR failures (phase 1 of the MCP error-handling framework). + +The invariant this package exists to enforce: an upstream failure is classified ONCE into a single +fault value, and the response status, wire error code, and prose are all derived from that value. +Deriving all three from one classification makes contradictory pairings (a caller-fault error code on +a server-fault status) unrepresentable, and gives the trust-boundary rule one enforcement point: +spec-defined machine fields may cross to callers, upstream prose and raw bodies go to server logs. +""" + +from litellm.proxy._experimental.mcp_server.faults.classify import ( + classify_upstream_dcr_rejection, + classify_upstream_token_rejection, +) +from litellm.proxy._experimental.mcp_server.faults.render_oauth import ( + dcr_fault_detail, + render_token_fault, +) +from litellm.proxy._experimental.mcp_server.faults.types import ( + CallerRejected, + CredentialSource, + GatewayRejected, + UpstreamOAuthFault, + UpstreamProtocolFault, + UpstreamReportedFault, +) + +__all__ = [ + "CallerRejected", + "CredentialSource", + "GatewayRejected", + "UpstreamOAuthFault", + "UpstreamProtocolFault", + "UpstreamReportedFault", + "classify_upstream_dcr_rejection", + "classify_upstream_token_rejection", + "dcr_fault_detail", + "render_token_fault", +] diff --git a/litellm/proxy/_experimental/mcp_server/faults/classify.py b/litellm/proxy/_experimental/mcp_server/faults/classify.py new file mode 100644 index 00000000000..8b3a09f8d8d --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/faults/classify.py @@ -0,0 +1,133 @@ +"""The single place that reads upstream OAuth/DCR failure responses. + +Every accessor here is total: an upstream that lies about its content encoding, sends an undecodable +body, or omits the spec fields yields a classified fault, never an exception. Nothing outside this +module should touch a failed upstream response's body. +""" + +from __future__ import annotations + +import httpx + +from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.faults.types import ( + GATEWAY_CAPABILITY_CODES, + GATEWAY_CREDENTIAL_CODES, + MAX_WIRE_FIELD_CHARS, + CallerRejected, + CredentialSource, + GatewayRejected, + UpstreamOAuthFault, + UpstreamProtocolFault, + UpstreamReportedFault, +) + + +def _safe_text(response: httpx.Response) -> str: + try: + return response.text + except Exception: + return "" + + +def _safe_json(response: httpx.Response) -> object: + try: + return response.json() + except Exception: + return None + + +def _bounded_field(value: object) -> str | None: + if not isinstance(value, str) or not value: + return None + return value[:MAX_WIRE_FIELD_CHARS] + + +def _log_out_of_contract(endpoint_kind: str, response: httpx.Response, log_context: str) -> None: + verbose_logger.warning( + "MCP upstream %s endpoint (%s) returned HTTP %s outside the OAuth error contract (first %s chars): %s", + endpoint_kind, + log_context, + response.status_code, + MAX_WIRE_FIELD_CHARS, + _safe_text(response)[:MAX_WIRE_FIELD_CHARS], + ) + + +def _classify_oauth_error_code( + code: str, + description: str | None, + error_uri: str | None, + credential_source: CredentialSource, + log_context: str, +) -> UpstreamOAuthFault: + """Blame assignment for a contract-conformant OAuth error code, shared by the token and DCR + classifiers. Codes by which the upstream blames itself keep that blame; ``invalid_target`` is a + gateway capability gap (RFC 8707 resource indicators, LIT-4339) no matter whose credentials were + presented; credential-indicting codes follow the credential source; everything else, including + codes we do not recognize, is the caller's to act on. The upstream's HTTP status is deliberately + never consulted: status derives from this classification at render time, which is what keeps + status and code from contradicting each other.""" + if code == "server_error" or code == "temporarily_unavailable": + return UpstreamReportedFault(code=code) + if code in GATEWAY_CAPABILITY_CODES: + verbose_logger.warning( + "MCP server %s: the upstream authorization server rejected the request with " + "invalid_target; it may require RFC 8707 resource indicators, which the gateway " + "does not send yet (tracked as LIT-4339)", + log_context, + ) + return GatewayRejected(code=code) + if credential_source == "gateway_stored" and code in GATEWAY_CREDENTIAL_CODES: + verbose_logger.warning( + "MCP server %s: upstream authorization server rejected the gateway's configured client " + "credentials (%s): %s", + log_context, + code, + description or "", + ) + return GatewayRejected(code=code) + return CallerRejected(code=code, description=description, error_uri=error_uri) + + +def classify_upstream_token_rejection( + response: httpx.Response, + credential_source: CredentialSource, + log_context: str, +) -> UpstreamOAuthFault: + """Classify a token-endpoint rejection into exactly one fault: a body with an RFC 6749 §5.2 + ``error`` field goes through blame assignment (:func:`_classify_oauth_error_code`); anything + without a usable ``error`` field is an upstream protocol fault.""" + parsed = _safe_json(response) + fields = parsed if isinstance(parsed, dict) else {} + code = _bounded_field(fields.get("error")) + if code is None: + _log_out_of_contract("token", response, log_context) + return UpstreamProtocolFault(note=f"upstream token endpoint returned HTTP {response.status_code}") + return _classify_oauth_error_code( + code, + description=_bounded_field(fields.get("error_description")), + error_uri=_bounded_field(fields.get("error_uri")), + credential_source=credential_source, + log_context=log_context, + ) + + +def classify_upstream_dcr_rejection(response: httpx.Response, log_context: str) -> UpstreamOAuthFault: + """Classify a dynamic-client-registration rejection. RFC 7591 §3.2.2 errors carry + ``error`` / ``error_description`` and go through the same blame assignment as token errors + (registration sends no client credentials, so credential codes stay caller-actionable); anything + without a usable ``error`` field is an upstream protocol fault.""" + parsed = _safe_json(response) + fields = parsed if isinstance(parsed, dict) else {} + code = _bounded_field(fields.get("error")) + if code is None: + _log_out_of_contract("registration", response, log_context) + return UpstreamProtocolFault(note=f"upstream registration failed with HTTP {response.status_code}") + return _classify_oauth_error_code( + code, + description=_bounded_field(fields.get("error_description")), + error_uri=None, + credential_source="caller_supplied", + log_context=log_context, + ) diff --git a/litellm/proxy/_experimental/mcp_server/faults/render_oauth.py b/litellm/proxy/_experimental/mcp_server/faults/render_oauth.py new file mode 100644 index 00000000000..89ce5011830 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/faults/render_oauth.py @@ -0,0 +1,89 @@ +"""Render upstream OAuth/DCR faults onto the wire. The only place that chooses statuses and bodies +for these faults, so every consumer emits the same contract: RFC 6749 §5.2-shaped JSON with the §5.1 +no-store headers on token endpoints, HTTPException details on registration. Status, code, and prose +all derive from the fault tag; exhaustive matches keep a new fault arm from shipping unrendered. +""" + +from __future__ import annotations + +from fastapi.responses import JSONResponse +from typing_extensions import assert_never + +from litellm.proxy._experimental.mcp_server.faults.types import UpstreamOAuthFault +from litellm.proxy._experimental.mcp_server.oauth_utils import TOKEN_NO_CACHE_HEADERS + + +def _gateway_rejected_description(code: str) -> str: + if code == "invalid_target": + return ( + "the upstream authorization server rejected the request (invalid_target); " + "it may require RFC 8707 resource indicators, which the gateway does not send yet" + ) + return ( + f"the upstream authorization server rejected the gateway's configured client credentials " + f"({code}); verify the MCP server's client_id and client_secret" + ) + + +def _upstream_reported_status_and_description(code: str) -> tuple[int, str]: + if code == "temporarily_unavailable": + return 503, "the upstream authorization server is temporarily unavailable; retry shortly" + return 502, "the upstream authorization server reported an internal error" + + +def render_token_fault(fault: UpstreamOAuthFault) -> JSONResponse: + """RFC 6749 §5.2 response for a token-endpoint fault. Caller-actionable rejections relay the + upstream's code on the status that code implies (401 for invalid_client per §5.2, else 400); + gateway-side faults are 502 ``server_error`` with gateway-authored prose so a caller is never + blamed for, or shown the internals of, a failure only the operator can fix.""" + match fault.tag: + case "caller_rejected": + content = { + "error": fault.code, + **({"error_description": fault.description} if fault.description else {}), + **({"error_uri": fault.error_uri} if fault.error_uri else {}), + } + status_code = 401 if fault.code == "invalid_client" else 400 + return JSONResponse(status_code=status_code, content=content, headers=TOKEN_NO_CACHE_HEADERS) + case "gateway_rejected": + return JSONResponse( + status_code=502, + content={ + "error": "server_error", + "error_description": _gateway_rejected_description(fault.code), + }, + headers=TOKEN_NO_CACHE_HEADERS, + ) + case "upstream_reported_fault": + status_code, description = _upstream_reported_status_and_description(fault.code) + return JSONResponse( + status_code=status_code, + content={"error": fault.code, "error_description": description}, + headers=TOKEN_NO_CACHE_HEADERS, + ) + case "upstream_protocol_fault": + return JSONResponse( + status_code=502, + content={"error": "server_error", "error_description": fault.note}, + headers=TOKEN_NO_CACHE_HEADERS, + ) + case _: + assert_never(fault.tag) + + +def dcr_fault_detail(fault: UpstreamOAuthFault) -> tuple[int, str]: + """Status and detail string for a registration fault, raised as HTTPException by the caller. + RFC 7591 §3.2.2 defines registration errors as 400, so a contract-conformant rejection is 400 + regardless of the status the upstream chose; everything else is a 502 upstream fault.""" + match fault.tag: + case "caller_rejected": + detail = f"{fault.code}: {fault.description}" if fault.description else fault.code + return 400, detail + case "gateway_rejected": + return 502, _gateway_rejected_description(fault.code) + case "upstream_reported_fault": + return _upstream_reported_status_and_description(fault.code) + case "upstream_protocol_fault": + return 502, fault.note + case _: + assert_never(fault.tag) diff --git a/litellm/proxy/_experimental/mcp_server/faults/types.py b/litellm/proxy/_experimental/mcp_server/faults/types.py new file mode 100644 index 00000000000..128b5e3e6cf --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/faults/types.py @@ -0,0 +1,79 @@ +"""Fault taxonomy for upstream OAuth token and DCR registration failures. + +Each fault is a frozen model on a ``tag`` literal. The tag alone decides the HTTP status, the wire +error code, and whose prose the caller sees, so those three facts can never disagree the way they can +when an upstream's status and error code are relayed independently. +""" + +from __future__ import annotations + +from typing import Literal, TypeAlias + +from pydantic import BaseModel, ConfigDict + +MAX_WIRE_FIELD_CHARS = 500 +"""Bound on every upstream-derived string that crosses to a caller or into a log line.""" + +CredentialSource: TypeAlias = Literal["gateway_stored", "caller_supplied"] +"""Whose client credentials the gateway presented upstream: the MCP server's stored configuration or +credentials the caller supplied on the request. Decides whether a credential rejection is the +caller's problem to fix or the gateway operator's.""" + +GATEWAY_CREDENTIAL_CODES: frozenset[str] = frozenset({"invalid_client", "unauthorized_client"}) +"""RFC 6749 error codes that indict the OAuth client's credentials or grant authorization. When the +gateway presented its own stored credentials, these are gateway-side faults the caller cannot act on; +when the caller supplied the credentials, they are the caller's to fix.""" + +GATEWAY_CAPABILITY_CODES: frozenset[str] = frozenset({"invalid_target"}) +"""Codes that indict a gateway capability regardless of whose credentials were presented: +``invalid_target`` means the upstream wants RFC 8707 resource indicators, which the gateway does not +send yet (LIT-4339). Never the caller's fault.""" + +UPSTREAM_FAULT_CODES: frozenset[str] = frozenset({"server_error", "temporarily_unavailable"}) +"""Codes by which the upstream blames itself. Relaying them as caller faults would invert blame, so +they classify as upstream-reported faults and render on the 5xx their meaning implies.""" + + +class CallerRejected(BaseModel): + """The upstream spoke the OAuth error contract and the failure is actionable by our caller + (e.g. ``invalid_grant``: re-run authorization). The code and its bounded prose relay on the + 4xx status the code itself implies.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["caller_rejected"] = "caller_rejected" + code: str + description: str | None = None + error_uri: str | None = None + + +class GatewayRejected(BaseModel): + """The upstream rejected the request for a cause only the gateway operator can address: the + server's stored client credentials or a gateway capability gap. Not actionable by the caller: + rendered as 502 with gateway-authored prose naming the code; the upstream's prose goes to + server logs only.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["gateway_rejected"] = "gateway_rejected" + code: str + + +class UpstreamReportedFault(BaseModel): + """The upstream blamed itself in the OAuth vocabulary. Rendered on the 5xx the code implies + (``server_error`` 502, ``temporarily_unavailable`` 503) so blame and status agree.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["upstream_reported_fault"] = "upstream_reported_fault" + code: Literal["server_error", "temporarily_unavailable"] + + +class UpstreamProtocolFault(BaseModel): + """The upstream broke the error contract: no JSON ``error`` field, an undecodable body, or a + success response without a usable token. Rendered as 502 with a gateway-authored note; the + upstream body never crosses to the caller.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["upstream_protocol_fault"] = "upstream_protocol_fault" + note: str + + +UpstreamOAuthFault: TypeAlias = CallerRejected | GatewayRejected | UpstreamReportedFault | UpstreamProtocolFault diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 1d681b43b9e..e6e265abb61 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -186,6 +186,33 @@ _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: tuple[MCPAuth, ...] = ( ) +def _carry_forward_resolved_oauth_endpoints(new_server: MCPServer, previous_server: MCPServer | None) -> None: + """Keep the last known good OAuth endpoints when a rebuild's re-discovery comes back empty. + + A rebuild wholesale-replaces the registry entry, so without this a transient upstream outage + during re-discovery downgrades a working server (``authorization_url`` set) to a broken one + (``None``, /authorize 400s) with no configuration change. Mirrors the ``short_prefix`` + carry-forward. Skipped when the server's ``url`` or ``auth_type`` changed, since the previous + endpoints may then belong to a different upstream. ``registration_url`` IS carried here even + though ``_persist_discovered_oauth_endpoints`` refuses to write it to the row: carrying only + restores the same in-memory value the previous build already ran with, while persisting it + would flip ``_dcr_bridge_relays_client_registration`` (which keys off the stored column) for + dcr_bridge servers that never had one configured. + """ + 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.authorization_url is None and previous_server.authorization_url: + new_server.authorization_url = previous_server.authorization_url + if new_server.token_url is None and previous_server.token_url: + new_server.token_url = previous_server.token_url + if new_server.registration_url is None and previous_server.registration_url: + new_server.registration_url = previous_server.registration_url + if not new_server.scopes and previous_server.scopes: + new_server.scopes = previous_server.scopes + + def invalidate_user_env_vars_cache(user_id: str, server_id: str) -> None: """Drop a cached entry after the user stores or clears their env var values so the next request reads the fresh value instead of a stale one.""" @@ -1343,6 +1370,7 @@ class MCPServerManager: *, credentials_are_encrypted: bool = True, env_vars_are_encrypted: Optional[bool] = None, + persist_discovered_endpoints: bool = True, ) -> MCPServer: _mcp_info: MCPInfo = mcp_server.mcp_info or {} env_dict = _deserialize_json_dict(getattr(mcp_server, "env", None)) @@ -1436,6 +1464,13 @@ class MCPServerManager: if needs_discovery 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 stay unresolved until a rebuild succeeds", + mcp_server.server_id, + server_url, + ) resolved_scopes = scopes or (mcp_oauth_metadata.scopes if mcp_oauth_metadata else None) @@ -1506,12 +1541,21 @@ class MCPServerManager: max_concurrent_requests=getattr(mcp_server, "max_concurrent_requests", None), ) _warn_internal_delegate_pkce_if_applicable(new_server, source="database") - await self._persist_discovered_obo_token_url( - server_id=mcp_server.server_id, - auth_type=auth_type, - existing_token_url=mcp_server.token_url, - discovered_token_url=new_server.token_url, - ) + if persist_discovered_endpoints: + await self._persist_discovered_obo_token_url( + server_id=mcp_server.server_id, + auth_type=auth_type, + existing_token_url=mcp_server.token_url, + discovered_token_url=new_server.token_url, + ) + await self._persist_discovered_oauth_endpoints( + server_id=mcp_server.server_id, + auth_type=auth_type, + existing_authorization_url=mcp_server.authorization_url, + existing_token_url=mcp_server.token_url, + existing_scopes=scopes, + metadata=mcp_oauth_metadata, + ) return new_server async def _persist_discovered_obo_token_url( @@ -1549,6 +1593,69 @@ class MCPServerManager: except Exception as exc: # noqa: BLE001 - best-effort; a failed write re-discovers next build verbose_logger.warning("Failed to persist discovered OBO token_url for MCP server %s: %s", server_id, exc) + async def _persist_discovered_oauth_endpoints( + self, + *, + server_id: str, + auth_type: MCPAuthType | None, + existing_authorization_url: str | None, + existing_token_url: str | None, + existing_scopes: list[str] | None, + metadata: MCPOAuthMetadata | None, + ) -> None: + """Write freshly discovered OAuth endpoints back onto the DB row. + + Same rationale as ``_persist_discovered_obo_token_url`` but for the interactive oauth2 + family: discovered ``authorization_url``/``token_url``/``scopes`` otherwise live only on + the in-memory registry entry, which is rebuilt on every client connect (the DCR reuse path + calls ``update_server``) and on every post-write DB reload, so one failed re-discovery + serves 400 "authorization url is not set" from /authorize until a later rebuild succeeds. + Only fills row fields that are currently empty, never persists origin-fallback guesses + (RFC 9728/8414-advertised metadata only), and deliberately skips ``registration_url`` + 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. + """ + if auth_type not in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: + return + if metadata is None or metadata.from_origin_fallback: + return + authorization_url_update = ( + {"authorization_url": metadata.authorization_url} + if metadata.authorization_url and not existing_authorization_url + 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} + if not updates: + return + from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 # db.py imports this module at load + update_mcp_server, + ) + from litellm.proxy._types import UpdateMCPServerRequest # noqa: PLC0415 # heavy module; import at call time + from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 # runtime value, set after startup + + if prisma_client is None: + return + try: + await update_mcp_server( + prisma_client=prisma_client, + data=UpdateMCPServerRequest.model_validate({"server_id": server_id, **updates}), + touched_by="mcp_oauth_discovery", + ) + verbose_logger.info( + "Persisted discovered OAuth endpoints for MCP server %s: %s", + server_id, + sorted(updates), + ) + except Exception as exc: # noqa: BLE001 - best-effort; a failed write re-discovers next build + verbose_logger.warning( + "Failed to persist discovered OAuth endpoints for MCP server %s: %s", + server_id, + exc, + ) + async def _maybe_register_openapi_tools(self, server: MCPServer, *, initialize_mapping: bool = True): """Register OpenAPI tools if the server has a spec_path configured.""" if server.spec_path: @@ -1607,6 +1714,10 @@ class MCPServerManager: existing_prefix = self.registry[mcp_server.server_id].short_prefix if existing_prefix and not new_server.short_prefix: new_server.short_prefix = existing_prefix + _carry_forward_resolved_oauth_endpoints( + new_server=new_server, + previous_server=self.registry[mcp_server.server_id], + ) self._assign_unique_short_prefix(new_server) self.registry[mcp_server.server_id] = new_server await self._maybe_register_openapi_tools(new_server) @@ -2969,16 +3080,20 @@ class MCPServerManager: ) = await self._attempt_well_known_discovery(server_url) metadata = None + used_origin_fallback = False if allow_origin_fallback and not authorization_servers: try: parsed_url = urlparse(server_url) if parsed_url.scheme and parsed_url.netloc: authorization_servers = [f"{parsed_url.scheme}://{parsed_url.netloc}"] + used_origin_fallback = True except Exception: authorization_servers = [] if authorization_servers: metadata = await self._fetch_authorization_server_metadata(authorization_servers, server_url) + if metadata is not None and used_origin_fallback: + metadata.from_origin_fallback = True preferred_scopes = scopes or resource_scopes if metadata is None and preferred_scopes: @@ -4489,6 +4604,7 @@ class MCPServerManager: # (if any) so the prefix is stable across reloads. if existing_server is not None and existing_server.short_prefix: new_server.short_prefix = existing_server.short_prefix + _carry_forward_resolved_oauth_endpoints(new_server=new_server, previous_server=existing_server) new_registry[server.server_id] = new_server except Exception as e: verbose_logger.exception( diff --git a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py index 33f0641b732..43fe3999291 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py +++ b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py @@ -175,16 +175,18 @@ mcp_oauth2_token_cache = MCPOAuth2TokenCache() def _compute_per_user_token_ttl(server: "MCPServer", expires_in: Optional[int]) -> int: """Compute Redis TTL for a per-user token. - Uses server.token_storage_ttl_seconds when configured; otherwise derives - TTL from expires_in minus the expiry buffer; falls back to the default TTL. + Uses server.token_storage_ttl_seconds when configured, capped at the token's + remaining lifetime (expires_in minus the expiry buffer) so a cached entry never + outlives the token itself; otherwise derives TTL from expires_in minus the + expiry buffer; falls back to the default TTL. """ + lifetime_bound = expires_in - MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS if expires_in is not None else None if server.token_storage_ttl_seconds is not None: - return max(server.token_storage_ttl_seconds, 1) - if expires_in is not None: - return max( - expires_in - MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS, - 1, - ) + if lifetime_bound is None: + return max(server.token_storage_ttl_seconds, 1) + return max(min(server.token_storage_ttl_seconds, lifetime_bound), 1) + if lifetime_bound is not None: + return max(lifetime_bound, 1) return MCP_PER_USER_TOKEN_DEFAULT_TTL diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py index 5530fbc46fd..c352f3a683e 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py @@ -21,11 +21,16 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import EnvelopeKeys, EnvelopeMintError, OpenedEnvelope, + OpenedRefreshEnvelope, + RefreshCredential, SealedEnvelope, UpstreamTokenGrant, is_envelope, + is_refresh_envelope, mint_envelope, + mint_refresh_envelope, open_envelope, + open_refresh_envelope, ) _SIGNING_KEY_DOMAIN = b"litellm-mcp-bridge:envelope-signing:" @@ -92,6 +97,67 @@ def build_bridge_token_response( return mint_envelope(identity, grant, keys, now) +def build_bridge_refresh_token_response( + identity: EnvelopeIdentity, + refresh: RefreshCredential, + keys: EnvelopeKeys, + now: datetime, +) -> SealedEnvelope | EnvelopeMintError: + """Seal ``refresh`` for ``identity`` into the long-lived refresh envelope the token endpoint returns + alongside the access envelope, so the client can renew without re-authenticating. A thin, pure + wrapper over :func:`mint_refresh_envelope`; returns the mint error as a value for the caller to map. + """ + return mint_refresh_envelope(identity, refresh, keys, now) + + +class BridgeRefreshOpened(BaseModel): + """A valid refresh envelope presented to the token endpoint: the identity to re-validate and renew + under, and the upstream refresh grant to exchange.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["opened"] = "opened" + identity: EnvelopeIdentity + refresh: RefreshCredential + + +class BridgeRefreshInvalid(BaseModel): + """The presented refresh grant is not a valid refresh envelope for this server (not refresh-shaped, + will not open, or minted for a different server); the token endpoint fails the refresh closed.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["invalid"] = "invalid" + + +BridgeRefreshResult: TypeAlias = BridgeRefreshOpened | BridgeRefreshInvalid + + +def open_bridge_refresh_envelope( + refresh_value: str, + keys: EnvelopeKeys, + now: datetime, + expected_server_id: str, +) -> BridgeRefreshResult: + """Open a refresh envelope a bridge ``oauth_delegate`` client presented on a refresh_token grant. + + The token-endpoint mirror of :func:`resolve_bridge_envelope`: strips an optional ``Bearer`` scheme, + then returns ``BridgeRefreshOpened`` with the recovered identity and upstream refresh grant, or + ``BridgeRefreshInvalid`` for anything that is not a valid refresh envelope for this server. Never + raises; total over hostile input via :func:`open_refresh_envelope`. ``expected_server_id`` binds the + envelope to the server the request targets, so a refresh envelope minted for one server cannot renew + against another. A raw upstream refresh token (not envelope-shaped) is ``BridgeRefreshInvalid``: this + mode never hands the client a bare upstream refresh token, so it must never accept one. + """ + candidate = _strip_bearer(refresh_value) + if not is_refresh_envelope(candidate): + return BridgeRefreshInvalid() + opened = open_refresh_envelope(candidate, keys, now) + if not isinstance(opened, OpenedRefreshEnvelope): + return BridgeRefreshInvalid() + if opened.identity.server_id != expected_server_id: + return BridgeRefreshInvalid() + return BridgeRefreshOpened(identity=opened.identity, refresh=opened.refresh) + + class NotBridgeEnvelope(BaseModel): """The bearer is not an envelope; admission continues on its normal path.""" @@ -128,10 +194,12 @@ def _strip_bearer(value: str) -> str: def is_bridge_envelope_shaped(authorization_value: str) -> bool: - """Cheap, keyless test that an ``Authorization`` value carries an envelope (optional - ``Bearer`` scheme stripped). The admission edge engages the bridge arm only for an - envelope, so a plain upstream bearer falls through to normal oauth2 admission.""" - return is_envelope(_strip_bearer(authorization_value)) + """Cheap, keyless test that an ``Authorization`` value carries an envelope of either kind (optional + ``Bearer`` scheme stripped). The admission edge engages the bridge arm for an access envelope (to + admit) and for a refresh envelope (to reject it explicitly, since a refresh credential is never + usable at the tool-call edge); a plain upstream bearer falls through to normal oauth2 admission.""" + candidate = _strip_bearer(authorization_value) + return is_envelope(candidate) or is_refresh_envelope(candidate) def resolve_bridge_envelope( @@ -148,6 +216,10 @@ def resolve_bridge_envelope( envelope, and ``BridgeEnvelopeInvalid`` for an envelope-shaped bearer that will not open. Never raises: it is total over hostile input via :func:`open_envelope`. + A refresh envelope is ``BridgeEnvelopeInvalid`` here: it is a valid gateway credential but only ever + presented back to the token endpoint, never usable to authenticate a tool call, so admission must + fail it closed rather than let it fall through to another arm. + ``expected_server_id`` is the ``server_id`` of the MCP server the request targets; an opened envelope whose sealed ``server_id`` does not match is rejected as ``BridgeEnvelopeInvalid``. Binding here (rather than leaving it to the caller) prevents @@ -157,6 +229,8 @@ def resolve_bridge_envelope( unlike ``hmac.compare_digest`` on ``str``, does not raise on a non-ASCII server_id. """ candidate = _strip_bearer(authorization_value) + if is_refresh_envelope(candidate): + return BridgeEnvelopeInvalid() if not is_envelope(candidate): return NotBridgeEnvelope() opened = open_envelope(candidate, keys, now) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py index 517c2ef5c8f..9118a3e129d 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py @@ -44,18 +44,33 @@ from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value, encrypt_value ENVELOPE_PREFIX = "llm_env_" -"""Marker prefix on every serialized envelope so the edge can cheaply tell an envelope +"""Marker prefix on every serialized ACCESS envelope so the edge can cheaply tell an envelope from a raw upstream token before doing any cryptography.""" +REFRESH_ENVELOPE_PREFIX = "llm_refresh_" +"""Marker prefix on every serialized REFRESH envelope. A distinct prefix keeps the two credentials +routable without crypto and, together with the signed ``kind`` claim, stops one from being presented +where the other is expected: a refresh envelope carries a long-lived upstream refresh token and is only +ever presented back to the token endpoint, never forwarded upstream on a tool call.""" + ENVELOPE_ISSUER = "litellm-mcp-bridge" """``iss`` claim stamped into every envelope and required back on open.""" MAX_ENVELOPE_TTL_SECONDS = 3600 -"""Hard ceiling on envelope lifetime. ``exp`` is ``min(upstream expires_in, this cap)`` +"""Hard ceiling on ACCESS envelope lifetime. ``exp`` is ``min(upstream expires_in, this cap)`` (the cap alone when the upstream omits ``expires_in``), matching the 1h lifetime of the BYOK session bearer this module's signing approach is borrowed from: a client-held credential should never outlive a bounded window even when the upstream token does.""" +MAX_REFRESH_ENVELOPE_TTL_SECONDS = 1209600 +"""Hard ceiling on REFRESH envelope lifetime (14 days). A refresh envelope only renews the short-lived +access envelope, and each renewal re-validates the sealed litellm key (revocation gates it) and is +re-minted with a fresh window, so the practical bound is idle time, not a fixed session. ``exp`` is +``min(upstream refresh_expires_in, this cap)`` (the cap alone when the upstream omits it); if the +upstream refresh token dies first, the next renewal simply fails at the upstream and the client +re-authenticates. The value is deliberately far shorter than a typical upstream refresh-token lifetime +so a leaked refresh envelope is bounded even if the upstream would have honoured it for longer.""" + MAX_ENVELOPE_BYTES = 12288 """Size cap on the final serialized envelope (prefix + JWT, in bytes). Upstream JWTs commonly run 2-4KB; base64 plus encryption overhead roughly doubles that inside the @@ -66,21 +81,48 @@ typed error, never truncated.""" _ENVELOPE_JWT_ALGORITHM = "HS256" +EnvelopeKind = Literal["access", "refresh"] +"""Which credential an envelope is. Stamped into the signed claims and required to match on open, so a +signature-valid envelope of one kind cannot be replayed as the other even if its wire prefix is swapped +(the prefix is not part of the signed payload; this claim is).""" + + +EnvelopeSubjectType: TypeAlias = Literal["key_hash", "user_id"] +"""Discriminator for what litellm principal the envelope binds the grant to. + +``key_hash`` is a hashed virtual key (the scripted two-header client mints under the key it +presents at the token endpoint); ``user_id`` is a litellm user subject (the interactive DCR +client mints under the SSO-authenticated user, which is the only identity that browser login +yields). Admission reloads a key record for the first and a user record for the second, then +runs both through the same live-policy gate, so team/org/budget/revocation enforcement is +identical either way.""" + class EnvelopeIdentity(BaseModel): - """The litellm identity the envelope binds the inner grant to. + """The litellm principal the envelope binds the inner grant to. - ``key_hash`` is the hashed litellm key that authorized the mint, never a raw - credential (and the edge rejects a bare hash presented as a bearer). Admission - reloads the live key record by it, so the key's current team/org/object-permission - restrictions and its revocation state are enforced at use time rather than frozen at - mint time. ``server_id`` binds the envelope to one MCP server so it cannot be replayed - across a server boundary. + ``subject`` is the principal identifier and ``subject_type`` says how to resolve it: a + hashed litellm key (``key_hash``) or a litellm user id (``user_id``), never a raw + credential (and the edge rejects a bare hash or id presented as a bearer). Admission + reloads the live record by it, so the principal's current team/org restrictions and its + revocation state are enforced at use time rather than frozen at mint time. ``server_id`` + binds the envelope to one MCP server so it cannot be replayed across a server boundary. """ model_config = ConfigDict(frozen=True) server_id: str = Field(min_length=1) - key_hash: str = Field(min_length=1) + subject_type: EnvelopeSubjectType + subject: str = Field(min_length=1) + + +def key_hash_identity(server_id: str, key_hash: str) -> EnvelopeIdentity: + """The identity for the scripted client that mints under a presented virtual key.""" + return EnvelopeIdentity(server_id=server_id, subject_type="key_hash", subject=key_hash) + + +def user_identity(server_id: str, user_id: str) -> EnvelopeIdentity: + """The identity for the interactive DCR client that mints under its SSO user subject.""" + return EnvelopeIdentity(server_id=server_id, subject_type="user_id", subject=user_id) class UpstreamTokenGrant(BaseModel): @@ -99,6 +141,21 @@ class UpstreamTokenGrant(BaseModel): expires_in: int | None = Field(default=None, gt=0) +class RefreshCredential(BaseModel): + """The upstream refresh grant sealed inside a refresh envelope. + + Only the refresh token (plus the scope to re-request and the refresh token's own lifetime, when the + upstream reports it) is sealed; the access token is never in a refresh envelope. ``refresh_token`` is + a ``SecretStr`` so reprs never leak it, and ``expires_in`` (the refresh token's lifetime, not the + access token's) must be positive when present. + """ + + model_config = ConfigDict(frozen=True) + refresh_token: SecretStr = Field(min_length=1) + scope: str | None = None + expires_in: int | None = Field(default=None, gt=0) + + class EnvelopeKeys(BaseModel): """Injected key material: the HS256 signing key and the symmetric encryption key. @@ -121,13 +178,21 @@ class SealedEnvelope(BaseModel): class OpenedEnvelope(BaseModel): - """A validated envelope: the identity it was minted for and the recovered grant.""" + """A validated access envelope: the identity it was minted for and the recovered grant.""" model_config = ConfigDict(frozen=True) identity: EnvelopeIdentity grant: UpstreamTokenGrant +class OpenedRefreshEnvelope(BaseModel): + """A validated refresh envelope: the identity it was minted for and the recovered refresh grant.""" + + model_config = ConfigDict(frozen=True) + identity: EnvelopeIdentity + refresh: RefreshCredential + + class EnvelopeTooLarge(BaseModel): """The serialized envelope exceeded ``MAX_ENVELOPE_BYTES``; carries sizes only.""" @@ -199,8 +264,10 @@ class _EnvelopeClaims(BaseModel): iss: str iat: int exp: int + kind: EnvelopeKind server_id: str = Field(min_length=1) - key_hash: str = Field(min_length=1) + subject_type: EnvelopeSubjectType + subject: str = Field(min_length=1) grant: str = Field(min_length=1) @@ -213,11 +280,25 @@ class _GrantWire(BaseModel): expires_in: int | None = None +class _RefreshWire(BaseModel): + model_config = ConfigDict(frozen=True) + refresh_token: str + scope: str | None = None + expires_in: int | None = None + + def is_envelope(candidate: str) -> bool: - """Cheap prefix check so the edge can route envelopes vs raw tokens without crypto.""" + """Cheap prefix check for an ACCESS envelope so the edge can route envelopes vs raw tokens without + crypto. A refresh envelope has a different prefix and is not an access envelope.""" return candidate.startswith(ENVELOPE_PREFIX) +def is_refresh_envelope(candidate: str) -> bool: + """Cheap prefix check for a REFRESH envelope so the token endpoint can route a refresh grant that + carries an envelope vs a raw upstream refresh token without crypto.""" + return candidate.startswith(REFRESH_ENVELOPE_PREFIX) + + def mint_envelope( identity: EnvelopeIdentity, grant: UpstreamTokenGrant, @@ -231,23 +312,15 @@ def mint_envelope( serialized envelope exceeds ``MAX_ENVELOPE_BYTES``. """ expires_at = now + timedelta(seconds=_envelope_ttl_seconds(grant.expires_in)) - claims = _EnvelopeClaims( - iss=ENVELOPE_ISSUER, - iat=int(now.timestamp()), - exp=int(expires_at.timestamp()), - server_id=identity.server_id, - key_hash=identity.key_hash, - grant=_encrypt_grant_blob(_grant_plaintext(grant), keys.encryption_key), + return _seal( + kind="access", + prefix=ENVELOPE_PREFIX, + identity=identity, + grant_blob=_encrypt_grant_blob(_grant_plaintext(grant), keys.encryption_key), + expires_at=expires_at, + signing_key=keys.signing_key, + now=now, ) - token = ENVELOPE_PREFIX + jwt.encode( - claims.model_dump(), - keys.signing_key.get_secret_value(), - algorithm=_ENVELOPE_JWT_ALGORITHM, - ) - size_bytes = len(token.encode("utf-8")) - if size_bytes > MAX_ENVELOPE_BYTES: - return EnvelopeTooLarge(size_bytes=size_bytes, max_bytes=MAX_ENVELOPE_BYTES) - return SealedEnvelope(token=SecretStr(token), expires_at=expires_at) def open_envelope( @@ -263,35 +336,136 @@ def open_envelope( re-derived, so it is stale by up to the envelope's lifetime; callers that need a live remaining lifetime should use ``now`` against the upstream, not this field. """ - if not is_envelope(candidate): - return NotAnEnvelope() - # UTF-8 byte length is never below character length, so a character count already over the - # cap rejects an oversize candidate in O(1) without encoding it; the exact byte check then - # runs only on candidates already bounded to <= MAX_ENVELOPE_BYTES characters. - if len(candidate) > MAX_ENVELOPE_BYTES: - return MalformedPayload() - if len(candidate.encode("utf-8", "surrogatepass")) > MAX_ENVELOPE_BYTES: - return MalformedPayload() - claims = _decode_claims(candidate.removeprefix(ENVELOPE_PREFIX), keys.signing_key) + claims = _open_claims(candidate, prefix=ENVELOPE_PREFIX, expected_kind="access", keys=keys, now=now) if not isinstance(claims, _EnvelopeClaims): return claims - if now.timestamp() >= claims.exp: - return Expired() grant = _decrypt_grant(claims.grant, keys.encryption_key) if not isinstance(grant, UpstreamTokenGrant): return grant return OpenedEnvelope( - identity=EnvelopeIdentity(server_id=claims.server_id, key_hash=claims.key_hash), + identity=EnvelopeIdentity(server_id=claims.server_id, subject_type=claims.subject_type, subject=claims.subject), grant=grant, ) +def mint_refresh_envelope( + identity: EnvelopeIdentity, + refresh: RefreshCredential, + keys: EnvelopeKeys, + now: datetime, +) -> SealedEnvelope | EnvelopeMintError: + """Seal ``refresh`` for ``identity`` into a long-lived, client-held refresh envelope. + + ``exp`` is ``min(refresh.expires_in, MAX_REFRESH_ENVELOPE_TTL_SECONDS)`` seconds from ``now`` (the + cap alone when the upstream omits the refresh lifetime). Sealing a distinct ``kind="refresh"`` claim + is what keeps a refresh envelope from ever opening as an access credential at the MCP edge. Returns + ``EnvelopeTooLarge`` when the serialized envelope exceeds ``MAX_ENVELOPE_BYTES``. + """ + expires_at = now + timedelta(seconds=_refresh_ttl_seconds(refresh.expires_in)) + return _seal( + kind="refresh", + prefix=REFRESH_ENVELOPE_PREFIX, + identity=identity, + grant_blob=_encrypt_grant_blob(_refresh_plaintext(refresh), keys.encryption_key), + expires_at=expires_at, + signing_key=keys.signing_key, + now=now, + ) + + +def open_refresh_envelope( + candidate: str, + keys: EnvelopeKeys, + now: datetime, +) -> OpenedRefreshEnvelope | EnvelopeOpenError: + """Validate a refresh ``candidate`` and recover the identity and inner refresh grant. + + Total over hostile input exactly like :func:`open_envelope`: every invalid, expired, tampered, + wrong-kind, or undecryptable candidate maps to a distinct ``EnvelopeOpenError`` variant, never a + raise. The ``kind="refresh"`` claim is required, so an access envelope re-prefixed as a refresh one + is rejected as ``MalformedPayload``. + """ + claims = _open_claims(candidate, prefix=REFRESH_ENVELOPE_PREFIX, expected_kind="refresh", keys=keys, now=now) + if not isinstance(claims, _EnvelopeClaims): + return claims + refresh = _decrypt_refresh(claims.grant, keys.encryption_key) + if not isinstance(refresh, RefreshCredential): + return refresh + return OpenedRefreshEnvelope( + identity=EnvelopeIdentity(server_id=claims.server_id, subject_type=claims.subject_type, subject=claims.subject), + refresh=refresh, + ) + + +def _seal( + kind: EnvelopeKind, + prefix: str, + identity: EnvelopeIdentity, + grant_blob: str, + expires_at: datetime, + signing_key: SecretStr, + now: datetime, +) -> SealedEnvelope | EnvelopeTooLarge: + """Sign the claims for either envelope kind and enforce the size cap. Shared by both mints so the + JWT shape, issuer, and size guard cannot drift between access and refresh envelopes.""" + claims = _EnvelopeClaims( + iss=ENVELOPE_ISSUER, + iat=int(now.timestamp()), + exp=int(expires_at.timestamp()), + kind=kind, + server_id=identity.server_id, + subject_type=identity.subject_type, + subject=identity.subject, + grant=grant_blob, + ) + token = prefix + jwt.encode(claims.model_dump(), signing_key.get_secret_value(), algorithm=_ENVELOPE_JWT_ALGORITHM) + size_bytes = len(token.encode("utf-8")) + if size_bytes > MAX_ENVELOPE_BYTES: + return EnvelopeTooLarge(size_bytes=size_bytes, max_bytes=MAX_ENVELOPE_BYTES) + return SealedEnvelope(token=SecretStr(token), expires_at=expires_at) + + +def _open_claims( + candidate: str, + prefix: str, + expected_kind: EnvelopeKind, + keys: EnvelopeKeys, + now: datetime, +) -> _EnvelopeClaims | EnvelopeOpenError: + """Prefix-route, size-bound, signature-verify, kind-check, and expiry-check an attacker-controlled + candidate, shared by both openers so the security gate is identical for access and refresh. Returns + the validated claims or a distinct ``EnvelopeOpenError``; never raises.""" + if not candidate.startswith(prefix): + return NotAnEnvelope() + # UTF-8 byte length is never below character length, so a character count already over the cap + # rejects an oversize candidate in O(1) without encoding it; the exact byte check then runs only on + # candidates already bounded to <= MAX_ENVELOPE_BYTES characters. + if len(candidate) > MAX_ENVELOPE_BYTES: + return MalformedPayload() + if len(candidate.encode("utf-8", "surrogatepass")) > MAX_ENVELOPE_BYTES: + return MalformedPayload() + claims = _decode_claims(candidate.removeprefix(prefix), keys.signing_key) + if not isinstance(claims, _EnvelopeClaims): + return claims + if claims.kind != expected_kind: + return MalformedPayload() + if now.timestamp() >= claims.exp: + return Expired() + return claims + + def _envelope_ttl_seconds(upstream_expires_in: int | None) -> int: if upstream_expires_in is None: return MAX_ENVELOPE_TTL_SECONDS return min(upstream_expires_in, MAX_ENVELOPE_TTL_SECONDS) +def _refresh_ttl_seconds(upstream_refresh_expires_in: int | None) -> int: + if upstream_refresh_expires_in is None: + return MAX_REFRESH_ENVELOPE_TTL_SECONDS + return min(upstream_refresh_expires_in, MAX_REFRESH_ENVELOPE_TTL_SECONDS) + + def _grant_plaintext(grant: UpstreamTokenGrant) -> str: wire = _GrantWire( access_token=grant.access_token.get_secret_value(), @@ -303,6 +477,15 @@ def _grant_plaintext(grant: UpstreamTokenGrant) -> str: return wire.model_dump_json(exclude_none=True) +def _refresh_plaintext(refresh: RefreshCredential) -> str: + wire = _RefreshWire( + refresh_token=refresh.refresh_token.get_secret_value(), + scope=refresh.scope, + expires_in=refresh.expires_in, + ) + return wire.model_dump_json(exclude_none=True) + + def _decode_claims( compact: str, signing_key: SecretStr, @@ -364,3 +547,22 @@ def _decrypt_grant( return UpstreamTokenGrant.model_validate_json(plaintext) except ValidationError: return MalformedPayload() + + +def _decrypt_refresh( + blob: str, + encryption_key: SecretStr, +) -> RefreshCredential | DecryptFailed | MalformedPayload: + from nacl.exceptions import CryptoError + + try: + plaintext = decrypt_value( + value=base64.urlsafe_b64decode(blob), + signing_key=encryption_key.get_secret_value(), + ) + except (CryptoError, ValueError): + return DecryptFailed() + try: + return RefreshCredential.model_validate_json(plaintext) + except ValidationError: + return MalformedPayload() diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 5090aa7d7d5..68a61b85175 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -3719,8 +3719,15 @@ if MCP_AVAILABLE: headers={"www-authenticate": upstream_www_authenticate}, ) + def _get_authorization_header_from_scope(scope: Scope) -> Optional[str]: + """First ``Authorization`` header value in the ASGI scope, or None.""" + for key, value in scope.get("headers", []): + if key.lower() == b"authorization": + return value.decode("latin-1") + return None + def _scope_has_authorization_header(scope: Scope) -> bool: - return any(key.lower() == b"authorization" for key, _ in scope.get("headers", [])) + return _get_authorization_header_from_scope(scope) is not None def _get_forwarded_auth_from_scope(scope: Scope) -> Optional[str]: """Return the upstream-bound ``Authorization`` header value, or None. @@ -3733,17 +3740,24 @@ if MCP_AVAILABLE: ``MCPRequestHandler.process_mcp_request``), and forwarding it upstream would leak the proxy key to a third-party MCP server. """ - authorization = None - has_litellm_key_header = False - for key, value in scope.get("headers", []): - key_lower = key.lower() - if key_lower == b"authorization": - authorization = value.decode("latin-1") - elif key_lower == b"x-litellm-api-key": - has_litellm_key_header = True + has_litellm_key_header = any(key.lower() == b"x-litellm-api-key" for key, _ in scope.get("headers", [])) if not has_litellm_key_header: return None - return authorization + return _get_authorization_header_from_scope(scope) + + def _is_delegate_upstream_probe_target(server: MCPServer) -> bool: + """Whether ``server`` is an interactive delegate-auth server whose client-supplied + token should be preflighted upstream. + + Mirrors the anonymous-delegate gate in ``get_allowed_mcp_servers``: the flow is + resolved via ``effective_oauth2_flow`` so an unstamped M2M-shape row fails closed + (its stored client credentials drive egress; the caller's bearer is irrelevant). + """ + return ( + server.auth_type == MCPAuth.oauth2 + and server.delegate_auth_to_upstream is True + and MCPServerManager.effective_oauth2_flow(server) != "client_credentials" + ) async def _probe_upstream_auth( url: str, @@ -3805,7 +3819,7 @@ if MCP_AVAILABLE: mcp_servers: Optional[List[str]], client_ip: Optional[str], ) -> None: - """Probe pass-through upstream servers in parallel before the MCP session starts. + """Probe pass-through and delegate-auth upstream servers in parallel before the MCP session starts. Only servers the caller's key is already authorized to reach are probed — the list is derived from _get_allowed_mcp_servers so that a user cannot @@ -3813,11 +3827,42 @@ if MCP_AVAILABLE: The MCP SDK commits HTTP 200 headers before invoking handlers, so a 401 can only be returned before that point. This function raises HTTPException(401) - with a WWW-Authenticate header if any upstream rejects the client token. + with a WWW-Authenticate header if any upstream rejects the client token, or 403 + if the upstream accepts it but forbids the caller. Fails-open: network errors are logged and the request is allowed through. + + Delegate-auth servers (``auth_type=oauth2`` + ``delegate_auth_to_upstream``) + are probed with the caller's bare ``Authorization`` bearer. That bearer is only + an upstream token (never a LiteLLM key) when admission took the delegate bypass, + so the delegate target is resolved through ``get_mcp_server_by_name`` -- the same + resolver admission used -- rather than the wider allowed-server prefix/access-group + matching. A name that only reaches a delegate server via server_id or an access + group would have been admitted as a real LiteLLM key, so probing it would leak that + key upstream; requiring the admission-resolver match closes that gap. Without the + probe a rejected token is absorbed by the tools/list handler and masked as an empty + tool list. Gated to single-server routes so one rejected token cannot 401 a + multi-server aggregate connect, matching the OBO preflight gating; the challenge + echoes the requested name so aliased routes get the same resource_metadata URL as + the tokenless preemptive challenge. """ forwarded_auth = _get_forwarded_auth_from_scope(scope) - if not forwarded_auth: + requested_single_target = mcp_servers[0] if mcp_servers is not None and len(mcp_servers) == 1 else None + # The bare Authorization header (no x-litellm-api-key) is a valid upstream token + # only when admission classified it as one, i.e. the single requested name resolves + # to a delegate server under admission's own resolver. Resolve it the same way here + # so a server_id- or access-group-named delegate (which admission would have treated + # as a LiteLLM key) is never probed with that key. + delegate_server = ( + global_mcp_server_manager.get_mcp_server_by_name(requested_single_target, client_ip=client_ip) + if requested_single_target + else None + ) + delegate_auth = ( + _get_authorization_header_from_scope(scope) + if delegate_server is not None and _is_delegate_upstream_probe_target(delegate_server) + else None + ) + if not forwarded_auth and not delegate_auth: return # Use the authorized server set, not the raw user-supplied names, so that @@ -3827,33 +3872,49 @@ if MCP_AVAILABLE: mcp_servers=mcp_servers, client_ip=client_ip, ) - passthrough_servers = [ - srv - for srv in allowed_servers - # Restrict to genuine OAuth pass-through servers (auth_type none + - # Authorization in extra_headers). Gateway-managed OAuth2 servers - # must not receive the ``resource_metadata=`` challenge emitted - # below — they require ``authorization_uri=`` pointing at the - # gateway AS metadata. ``is_oauth_passthrough`` already requires - # ``auth_type in (None, MCPAuth.none)``, which is mutually - # exclusive with ``has_client_credentials`` (oauth2 + M2M flow), - # so M2M servers are implicitly excluded here. - if srv.is_oauth_passthrough - ] - if not passthrough_servers: + passthrough_targets: Tuple[Tuple[MCPServer, str, str], ...] = ( + tuple( + (srv, forwarded_auth, srv.name) + for srv in allowed_servers + # Restrict to genuine OAuth pass-through servers (auth_type none + + # Authorization in extra_headers). Gateway-managed OAuth2 servers + # must not receive the ``resource_metadata=`` challenge emitted + # below — they require ``authorization_uri=`` pointing at the + # gateway AS metadata. ``is_oauth_passthrough`` already requires + # ``auth_type in (None, MCPAuth.none)``, which is mutually + # exclusive with ``has_client_credentials`` (oauth2 + M2M flow), + # so M2M servers are implicitly excluded here. + if srv.is_oauth_passthrough + ) + if forwarded_auth + else () + ) + # Probe the admission-resolved delegate server only when the caller is actually + # authorized for it (present in the IP-filtered allowed set), keyed by server_id. + delegate_targets: Tuple[Tuple[MCPServer, str, str], ...] = ( + tuple( + (srv, delegate_auth, requested_single_target) + for srv in allowed_servers + if delegate_server is not None and srv.server_id == delegate_server.server_id + ) + if delegate_auth and requested_single_target + else () + ) + probe_targets = passthrough_targets + delegate_targets + if not probe_targets: return probe_results = await asyncio.gather( - *[_probe_upstream_auth(srv.url or "", forwarded_auth) for srv in passthrough_servers] + *[_probe_upstream_auth(srv.url or "", auth_header) for srv, auth_header, _ in probe_targets] ) - for srv, (probe_status, _) in zip(passthrough_servers, probe_results): + for (srv, _, challenge_server_name), (probe_status, _) in zip(probe_targets, probe_results): if probe_status == 401: # Token is missing or expired: keep pass-through clients on the # protected-resource discovery flow so they re-authorize against # the upstream IdP metadata proxied by LiteLLM. www_authenticate = _get_passthrough_www_authenticate( scope=scope, - server_name=srv.name, + server_name=challenge_server_name, invalid_token=True, ) raise HTTPException( diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 23fe7730c17..5e3ea4b7dcb 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1118,6 +1118,7 @@ class GenerateKeyRequest(KeyRequestBase): class GenerateKeyResponse(KeyRequestBase): key: str # type: ignore key_name: Optional[str] = None + key_type: str | None = None expires: Optional[datetime] = None user_id: Optional[str] = None token_id: Optional[str] = None @@ -2421,6 +2422,16 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): "is active as a reminder that hard enforcement is relaxed." ), ) + skip_user_budget_on_team_key: bool | None = Field( + None, + description=( + "If True, restores the legacy behavior where a user's personal " + "max_budget is NOT enforced when their key belongs to a team; only " + "the team (and team-member) budgets apply. Defaults to False, meaning " + "the user's personal max_budget is always enforced regardless of " + "whether the key belongs to a team (see GitHub issue #12905)." + ), + ) user_url_validation: Optional[bool] = Field( None, description=( diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 93811812901..00f6d44e25a 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -626,26 +626,29 @@ async def common_checks( ) async def _user_max_budget_check() -> None: - # 4.1 personal budget, if personal key - if ( - (team_object is None or team_object.team_id is None) - and user_object is not None - and user_object.max_budget is not None - ): - from litellm.proxy.proxy_server import get_current_spend + if user_object is None or user_object.max_budget is None: + return + skip_for_team = ( + general_settings.get("skip_user_budget_on_team_key") is True + and team_object is not None + and team_object.team_id is not None + ) + if skip_for_team: + return + from litellm.proxy.proxy_server import get_current_spend - user_budget = user_object.max_budget - user_spend = await get_current_spend( - counter_key=f"spend:user:{user_object.user_id}", - fallback_spend=user_object.spend or 0.0, + user_budget = user_object.max_budget + user_spend = await get_current_spend( + counter_key=f"spend:user:{user_object.user_id}", + fallback_spend=user_object.spend or 0.0, + max_budget=user_budget, + ) + if math.isfinite(user_budget) and user_spend >= user_budget: + raise litellm.BudgetExceededError( + current_cost=user_spend, max_budget=user_budget, + message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}", ) - if math.isfinite(user_budget) and user_spend >= user_budget: - raise litellm.BudgetExceededError( - current_cost=user_spend, - max_budget=user_budget, - message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}", - ) # Each scope reads a distinct counter key with no cross-scope ordering # dependency, so the per-scope Redis-first reads run concurrently instead diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 2613510bd0c..b402212fb2e 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1191,13 +1191,15 @@ async def _user_api_key_auth_builder( return await handle_oauth2_proxy_request(request=request) if general_settings.get("enable_jwt_auth", False) is True: - from litellm.proxy.proxy_server import premium_user - - if premium_user is not True: - raise ValueError(f"JWT Auth is an enterprise only feature. {CommonProxyErrors.not_premium_user.value}") is_jwt = jwt_handler.is_jwt(token=api_key) verbose_proxy_logger.debug("is_jwt: %s", is_jwt) if is_jwt: + from litellm.proxy.proxy_server import premium_user + + if premium_user is not True: + raise ValueError( + f"JWT Auth is an enterprise only feature. {CommonProxyErrors.not_premium_user.value}" + ) # Try JWT-to-Virtual-Key mapping first to avoid # unnecessary DB queries in auth_builder do_standard_jwt_auth = True @@ -2442,6 +2444,7 @@ async def _reserve_budget_after_common_checks( proxy_logging_obj=proxy_logging_obj, end_user_id=end_user_id, end_user_object=end_user_object, + skip_user_budget_on_team_key=general_settings.get("skip_user_budget_on_team_key") is True, ) diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index 4eda6817252..785d3b1e37b 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -277,6 +277,28 @@ def prompt_team_selection_fallback( return None +def _response_error_detail(response: requests.Response) -> str | None: + try: + body = response.json() + except ValueError: + return None + detail = body.get("detail") if isinstance(body, dict) else None + if isinstance(detail, str) and detail: + return detail + return None + + +def _polling_error_message(response: requests.Response) -> str: + detail = _response_error_detail(response) + if detail: + return f"Polling error: HTTP {response.status_code}: {detail}" + return f"Polling error: HTTP {response.status_code}" + + +def _is_permanent_polling_error(status_code: int) -> bool: + return 400 <= status_code < 500 and status_code != 429 + + # Polling-based authentication - no local server needed def _poll_for_ready_data( url: str, @@ -308,8 +330,14 @@ def _poll_for_ready_data( click.echo(pending_message) elif other_status_message and other_status_log_every > 0 and attempt % other_status_log_every == 0: click.echo(other_status_message) + elif _is_permanent_polling_error(response.status_code): + detail = _response_error_detail(response) + raise ValueError( + f"The proxy rejected the login session with HTTP {response.status_code}" + + (f": {detail}" if detail else f" and no error detail (from {url})") + ) elif http_error_log_every > 0 and attempt % http_error_log_every == 0: - click.echo(f"Polling error: HTTP {response.status_code}") + click.echo(_polling_error_message(response)) except requests.RequestException as e: if connection_error_log_every > 0 and attempt % connection_error_log_every == 0: click.echo(f"Connection error (will retry): {e}") @@ -342,12 +370,45 @@ def _normalize_teams(teams, team_details): def _start_cli_sso_flow(base_url: str) -> Dict[str, Any]: - response = requests.post(f"{base_url}/sso/cli/start", timeout=10) - response.raise_for_status() - data = response.json() + start_url = f"{base_url}/sso/cli/start" + try: + response = requests.post(start_url, timeout=10) + except requests.RequestException as e: + raise ValueError( + f"Could not reach the proxy at {start_url}: {e}. " + "Check that the proxy is running and that --base-url points at it." + ) from e + + if response.status_code in (404, 405): + raise ValueError( + f"POST {start_url} returned HTTP {response.status_code}. " + "Either --base-url is wrong, or the proxy is older than this CLI and does not support " + "the CLI SSO login flow; upgrade the proxy or use a CLI version that matches it." + ) + if response.status_code != 200: + detail = _response_error_detail(response) + raise ValueError( + f"Starting CLI login failed: HTTP {response.status_code} from {start_url}" + + (f": {detail}" if detail else "") + ) + + try: + data = response.json() + except ValueError: + content_type = response.headers.get("content-type", "unknown") + raise ValueError( + f"The proxy returned a non-JSON response from {start_url} (content-type: {content_type}). " + "A proxy, load balancer, or auth gateway in front of LiteLLM may be intercepting the request. " + f"Response starts with: {response.text[:200]!r}" + ) + required_fields = ("login_id", "poll_secret", "user_code") - if not all(isinstance(data.get(field), str) for field in required_fields): - raise ValueError("Invalid CLI SSO start response") + missing_fields = tuple(field for field in required_fields if not isinstance(data.get(field), str)) + if missing_fields: + raise ValueError( + f"The response from {start_url} is missing required field(s): {', '.join(missing_fields)}. " + "The proxy version may not match this CLI; upgrade whichever is older." + ) return data @@ -577,6 +638,10 @@ def login(ctx: click.Context): return else: 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." + ) return except KeyboardInterrupt: diff --git a/litellm/proxy/db/exception_handler.py b/litellm/proxy/db/exception_handler.py index 3a93896a206..e4c565e4464 100644 --- a/litellm/proxy/db/exception_handler.py +++ b/litellm/proxy/db/exception_handler.py @@ -8,6 +8,10 @@ from litellm.proxy._types import ( ) from litellm.secret_managers.main import str_to_bool +# Bounds the __cause__/__context__ walk in is_database_service_unavailable_error_in_chain. +# Real exception chains are a few links deep; the cap also makes the walk cycle-safe. +_MAX_EXCEPTION_CHAIN_DEPTH = 20 + class PrismaDBExceptionHandler: """ @@ -218,6 +222,32 @@ class PrismaDBExceptionHandler: ), ) + @staticmethod + def is_database_service_unavailable_error_in_chain(e: BaseException) -> bool: + """Like ``is_database_service_unavailable_error`` but also walks the + ``__cause__`` / ``__context__`` chain. + + ``is_database_service_unavailable_error`` classifies a single exception + by type, which a caller that catches a raw DB failure and re-raises a + domain exception of a different type defeats. ``get_user_object`` in + ``litellm/proxy/auth/auth_checks.py`` is the concrete case: it wraps + every DB error, a genuine outage included, in a bare ``ValueError`` + whose original error survives only as ``__context__``. A type check on + the ``ValueError`` misses the outage, so the caller would mistake an + infrastructure fault for an auth failure. Walking the chain recovers the + real signal, which is the PEP 3134 way to inspect a wrapped cause. + + The walk is depth-bounded, which also makes it cycle-safe. + """ + current: BaseException | None = e + for _ in range(_MAX_EXCEPTION_CHAIN_DEPTH): + if not isinstance(current, Exception): + return False + if PrismaDBExceptionHandler.is_database_service_unavailable_error(current): + return True + current = current.__cause__ or current.__context__ + return False + @staticmethod def handle_db_exception(e: Exception): """ diff --git a/litellm/proxy/enterprise_billing/__init__.py b/litellm/proxy/enterprise_billing/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/proxy/enterprise_billing/billing_metrics.py b/litellm/proxy/enterprise_billing/billing_metrics.py new file mode 100644 index 00000000000..f9f8ceaf721 --- /dev/null +++ b/litellm/proxy/enterprise_billing/billing_metrics.py @@ -0,0 +1,323 @@ +""" +Push-based OTLP metering for enterprise litellm deployments. + +Owns a dedicated OpenTelemetry meter provider and an OTLP/HTTP exporter +authenticated to our global collector with a TLS client certificate. The +collector front end terminates mutual TLS: the client certificate presented +here is validated against our CA at the edge, and the verified subject is +what identifies the deployment. It is intentionally isolated from the global +meter provider so the customer's own OTEL metrics are untouched and ours +never leak into their backend. + +The deployment's identity rides on the TLS client certificate, not on the +payload; the secret license key is never sent as an attribute or header. +""" + +import os +import tempfile +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional, Union + +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter +from opentelemetry.metrics import Counter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import Resource + +from litellm._logging import verbose_proxy_logger +from litellm.proxy.middleware.billable_request_metrics_middleware import ( + BillableCategory, +) + +if TYPE_CHECKING: + from litellm.proxy._types import EnterpriseLicenseData + +ENDPOINT_ENV = "LITELLM_BILLING_METRICS_ENDPOINT" +CLIENT_CERT_ENV = "LITELLM_BILLING_METRICS_CLIENT_CERT" +CLIENT_KEY_ENV = "LITELLM_BILLING_METRICS_CLIENT_KEY" +CA_CERT_ENV = "LITELLM_BILLING_METRICS_CA_CERT" +EXPORT_INTERVAL_ENV = "LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS" +DEFAULT_EXPORT_INTERVAL_MS = 60_000 +SHUTDOWN_FLUSH_TIMEOUT_MS = 5_000 +_METRICS_PATH = "/v1/metrics" + +# The cert env vars take a path or the PEM itself. Secret stores that inject +# values as env content cannot mount them as files, so inline PEM is written out. +_PEM_PREFIX = "-----BEGIN" +_PEM_DIR_PREFIX = "litellm-billing-mtls-" +_PEM_FILE_MODE = 0o600 +_CLIENT_CERT_FILENAME = "client.crt" +_CLIENT_KEY_FILENAME = "client.key" +_CA_CERT_FILENAME = "ca.crt" + +METRIC_NAME = "litellm.enterprise.billable_requests" +METER_NAME = "litellm.enterprise.billing" + +AttributeValue = Union[str, int] + + +@dataclass(frozen=True, slots=True) +class BillingMetricsConfig: + endpoint: str + client_cert_path: str + client_key_path: str + ca_cert_path: Optional[str] + export_interval_ms: int + litellm_version: str + license_id: Optional[str] + + +def _metrics_endpoint(endpoint: str) -> str: + """The OTLP/HTTP metric exporter wants the full URL including the signal path.""" + trimmed = endpoint.rstrip("/") + return trimmed if trimmed.endswith(_METRICS_PATH) else f"{trimmed}{_METRICS_PATH}" + + +def _resource_attributes(config: BillingMetricsConfig) -> dict[str, AttributeValue]: + base: dict[str, AttributeValue] = { + "service.name": "litellm-proxy", + "litellm.version": config.litellm_version, + } + license_attr: dict[str, AttributeValue] = {"litellm.license.id": config.license_id} if config.license_id else {} + return {**base, **license_attr} + + +def _billable_attributes( + category: BillableCategory, route: str, status_code: int, model_id: Optional[str] +) -> dict[str, AttributeValue]: + base: dict[str, AttributeValue] = { + "litellm.endpoint.category": category.value, + "http.route": route, + "http.response.status_code": status_code, + } + model_attr: dict[str, AttributeValue] = {"litellm.model_id": model_id} if model_id else {} + return {**base, **model_attr} + + +def build_mtls_meter_provider(config: BillingMetricsConfig) -> MeterProvider: + """OTLP/HTTP exporter presenting a TLS client certificate. + + The collector's load balancer terminates mutual TLS and validates the client + certificate against our CA. Server verification uses the system trust store + (the collector presents a public web-PKI certificate); ca_cert_path overrides + it only for private/test collectors. + """ + exporter = OTLPMetricExporter( + endpoint=_metrics_endpoint(config.endpoint), + # None -> exporter falls back to the system trust store. + certificate_file=config.ca_cert_path, + client_certificate_file=config.client_cert_path, + client_key_file=config.client_key_path, + ) + reader = PeriodicExportingMetricReader(exporter, export_interval_millis=config.export_interval_ms) + return MeterProvider(metric_readers=[reader], resource=Resource.create(_resource_attributes(config))) + + +class BillingMetricsRecorder: + """Increments one OTLP counter per billable request. The meter provider is injected (see the factory).""" + + def __init__(self, provider: MeterProvider) -> None: + self._provider = provider + self._counter: Counter = provider.get_meter(METER_NAME).create_counter( + name=METRIC_NAME, + unit="{request}", + description="Count of 2xx HTTP requests to billable LLM/MCP/A2A endpoints", + ) + + def record(self, *, category: BillableCategory, route: str, status_code: int, model_id: Optional[str]) -> None: + self._counter.add(1, _billable_attributes(category, route, status_code, model_id)) + + def shutdown(self) -> None: + """Final flush + exporter-thread stop. Without this, up to one export + interval of billable counts is dropped on every proxy restart.""" + self._provider.shutdown(timeout_millis=SHUTDOWN_FLUSH_TIMEOUT_MS) + + +def _export_interval_ms() -> int: + raw = os.getenv(EXPORT_INTERVAL_ENV) + if raw is None: + return DEFAULT_EXPORT_INTERVAL_MS + try: + return int(raw) + except ValueError: + verbose_proxy_logger.warning( + "Invalid %s=%r, falling back to %d ms", EXPORT_INTERVAL_ENV, raw, DEFAULT_EXPORT_INTERVAL_MS + ) + return DEFAULT_EXPORT_INTERVAL_MS + + +@dataclass(frozen=True, slots=True) +class _CredentialPaths: + client_cert_path: str + client_key_path: str + ca_cert_path: Optional[str] + + +def _is_pem_content(value: str) -> bool: + return value.lstrip().startswith(_PEM_PREFIX) + + +def _write_pem(directory: str, filename: str, pem: str) -> str: + path = os.path.join(directory, filename) + with open(path, "w", encoding="utf-8") as handle: + handle.write(pem if pem.endswith("\n") else f"{pem}\n") + os.chmod(path, _PEM_FILE_MODE) + return path + + +def _resolve_credential_paths(*, client_cert: str, client_key: str, ca_cert: Optional[str]) -> _CredentialPaths: + """ + Accept either a filesystem path or inline PEM content for each credential. + + Secret stores that inject values as environment content rather than mounted + files (ECS tasks reading AWS Secrets Manager, Cloud Run reading Secret + Manager) can only deliver the certificate as a string. The OTLP exporter + takes paths, so inline PEM is written to a private directory once, when the + recorder is built. Raises OSError if that write fails; the caller disables + metering rather than propagating. + """ + inline = tuple(value for value in (client_cert, client_key, ca_cert) if value and _is_pem_content(value)) + if not inline: + return _CredentialPaths(client_cert, client_key, ca_cert) + + # mkdtemp is 0o700, so the 0o600 key file it holds is unreachable by other users. + directory = tempfile.mkdtemp(prefix=_PEM_DIR_PREFIX) + return _CredentialPaths( + client_cert_path=( + _write_pem(directory, _CLIENT_CERT_FILENAME, client_cert) if _is_pem_content(client_cert) else client_cert + ), + client_key_path=( + _write_pem(directory, _CLIENT_KEY_FILENAME, client_key) if _is_pem_content(client_key) else client_key + ), + ca_cert_path=( + _write_pem(directory, _CA_CERT_FILENAME, ca_cert) if ca_cert and _is_pem_content(ca_cert) else ca_cert + ), + ) + + +def load_billing_metrics_config( + *, license_data: Optional["EnterpriseLicenseData"], litellm_version: str +) -> Optional[BillingMetricsConfig]: + endpoint = os.getenv(ENDPOINT_ENV) + client_cert = os.getenv(CLIENT_CERT_ENV) + client_key = os.getenv(CLIENT_KEY_ENV) + # Optional: only for private/test collectors whose server cert is not on the + # public web PKI. The production collector needs no CA override. + ca_cert = os.getenv(CA_CERT_ENV) + + missing = [ + name + for name, value in ( + (ENDPOINT_ENV, endpoint), + (CLIENT_CERT_ENV, client_cert), + (CLIENT_KEY_ENV, client_key), + ) + if not value + ] + if not endpoint or not client_cert or not client_key: + verbose_proxy_logger.warning( + "Enterprise billing metrics disabled: licensed deployment missing config (%s)", + ", ".join(missing), + ) + return None + + try: + paths = _resolve_credential_paths(client_cert=client_cert, client_key=client_key, ca_cert=ca_cert) + except OSError as exc: + verbose_proxy_logger.warning( + "Enterprise billing metrics disabled: could not write inline certificate content to disk: %s", exc + ) + return None + + # Report the variable names, never their values. A value that is neither a + # readable path nor recognizable PEM is still secret material, and this + # warning would otherwise copy a client key straight into the proxy logs. + unreadable = [ + env_name + for env_name, path in ( + (CLIENT_CERT_ENV, paths.client_cert_path), + (CLIENT_KEY_ENV, paths.client_key_path), + (CA_CERT_ENV, paths.ca_cert_path), + ) + if path and not os.path.isfile(path) + ] + if unreadable: + verbose_proxy_logger.warning( + "Enterprise billing metrics disabled: %s did not resolve to a readable certificate file. " + "Set each to a file path, or to inline PEM content beginning with '%s'.", + ", ".join(unreadable), + _PEM_PREFIX, + ) + return None + + return BillingMetricsConfig( + endpoint=endpoint, + client_cert_path=paths.client_cert_path, + client_key_path=paths.client_key_path, + ca_cert_path=paths.ca_cert_path, + export_interval_ms=_export_interval_ms(), + litellm_version=litellm_version, + license_id=(license_data or {}).get("user_id"), + ) + + +class _ActiveRecorderRegistry: + """One-slot registry linking the factory-built recorder to the shutdown + hook; the middleware instance holding the recorder is not reachable from + proxy_shutdown_event.""" + + def __init__(self) -> None: + self._recorder: Optional[BillingMetricsRecorder] = None + + def set(self, recorder: BillingMetricsRecorder) -> None: + self._recorder = recorder + + def pop(self) -> Optional[BillingMetricsRecorder]: + recorder = self._recorder + self._recorder = None + return recorder + + +_ACTIVE_RECORDER = _ActiveRecorderRegistry() + + +def build_billing_metrics_recorder( + *, premium: bool, license_data: Optional["EnterpriseLicenseData"], litellm_version: str +) -> Optional[BillingMetricsRecorder]: + """Build the recorder, or None when the deployment is not licensed or metering is unconfigured.""" + if not premium: + # Debug, not warning: unlicensed is the common case and a warning here + # would be noise on every OSS proxy. Every other disable path warns. + verbose_proxy_logger.debug("Enterprise billing metrics disabled: deployment is not licensed") + return None + + config = load_billing_metrics_config(license_data=license_data, litellm_version=litellm_version) + if config is None: + return None + + try: + recorder = BillingMetricsRecorder(build_mtls_meter_provider(config)) + except Exception as exc: # noqa: BLE001 -- metering must never break proxy startup + verbose_proxy_logger.warning("Enterprise billing metrics disabled: failed to initialize exporter: %s", exc) + return None + _ACTIVE_RECORDER.set(recorder) + # The only positive signal that this component meters. Without it, a silent + # return above is indistinguishable from a working exporter in the logs, and + # a component that carries the cert but no license would look healthy. + verbose_proxy_logger.info( + "Enterprise billing metrics enabled: exporting to %s every %d ms", + config.endpoint, + config.export_interval_ms, + ) + return recorder + + +def shutdown_billing_metrics_recorder() -> None: + """Flush and stop the active recorder, if any. Idempotent; never raises.""" + recorder = _ACTIVE_RECORDER.pop() + if recorder is None: + return + try: + recorder.shutdown() + except Exception as exc: # noqa: BLE001 -- shutdown must never block or fail proxy exit + verbose_proxy_logger.warning("Enterprise billing metrics: final flush failed: %s", exc) diff --git a/litellm/proxy/guardrails/_content_utils.py b/litellm/proxy/guardrails/_content_utils.py index 6cdf49818e6..8861a9e3bbc 100644 --- a/litellm/proxy/guardrails/_content_utils.py +++ b/litellm/proxy/guardrails/_content_utils.py @@ -34,6 +34,12 @@ def is_text_content_call_type(call_type: str) -> bool: TEXT_PART_TYPES: FrozenSet[str] = frozenset({"text", "input_text", "output_text"}) +# Responses-API item types whose ``output`` field carries user/tool text +# that guardrails should inspect. ``function_call_output`` is the +# built-in shape; ``custom_tool_call_output`` is the custom-tool +# counterpart (see ``ChatCompletionCustomToolCallOutput``). +_OUTPUT_ITEM_TYPES: frozenset[str] = frozenset({"function_call_output", "custom_tool_call_output"}) + def _iter_text_parts_in_content(content: Any) -> Iterator[str]: """Yield text fragments from a ``message.content`` value (string or @@ -72,7 +78,7 @@ def _coerce_input_to_messages(input_value: Any) -> List[Dict[str, Any]]: messages.append({"role": item.get("role") or "user", "content": [item]}) elif "content" in item: messages.append({"role": item.get("role") or "user", "content": item["content"]}) - elif item.get("type") == "function_call_output" and "output" in item: + elif item.get("type") in _OUTPUT_ITEM_TYPES and "output" in item: messages.append({"role": item.get("role") or "tool", "content": item["output"]}) return messages @@ -157,7 +163,7 @@ def walk_user_text(data: Dict[str, Any], visit: Callable[[str], str]) -> int: input_value[idx] = {**item, "text": visit(item["text"])} elif "content" in item: item["content"] = _rewrite_content(item["content"]) - elif item.get("type") == "function_call_output" and "output" in item: + elif item.get("type") in _OUTPUT_ITEM_TYPES and "output" in item: item["output"] = _rewrite_content(item["output"]) return visited diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index c1538af325e..cfd5e022634 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -27,8 +27,13 @@ from typing import ( cast, ) +import copy +from collections.abc import Mapping +from datetime import datetime, timezone + import httpx from fastapi import HTTPException +from pydantic import TypeAdapter, ValidationError import litellm from litellm._logging import verbose_proxy_logger @@ -43,10 +48,13 @@ from litellm.llms.custom_httpx.http_handler import ( ) from litellm.proxy._types import UserAPIKeyAuth from litellm.secret_managers.main import get_secret_str -from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.guardrails import BedrockChecksConfigModel, GuardrailEventHooks from litellm.types.llms.openai import AllMessageValues, ChatCompletionUserMessage from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( + BedrockChecksMessage, + BedrockChecksViolation, BedrockContentItem, + BedrockGuardrailChecksResponse, BedrockGuardrailOutput, BedrockGuardrailQualifier, BedrockGuardrailResponse, @@ -56,6 +64,8 @@ from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( from litellm.types.utils import GenericGuardrailAPIInputs if TYPE_CHECKING: + from botocore.awsrequest import AWSPreparedRequest + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.types.utils import ( @@ -73,6 +83,22 @@ from litellm.types.utils import ( GUARDRAIL_NAME = "bedrock" _BEDROCK_DYNAMIC_BODY_DENYLIST = frozenset({"content", "source"}) +# Resource-less, detect-only InvokeGuardrailChecks API (no guardrail resource required). +_BEDROCK_INVOKE_GUARDRAIL_CHECKS_PATH = "/guardrail-checks/invoke" +# InvokeGuardrailChecks accepts at most 10 content blocks per message. A message with +# more text blocks is split across multiple messages so ALL content is scanned -- +# never truncated (truncation would let a user hide content past the limit). +_BEDROCK_CHECKS_MAX_CONTENT_BLOCKS = 10 +_BEDROCK_CHECKS_KNOWN_KEYS = frozenset({"contentFilter", "promptAttack", "sensitiveInformation"}) +# Keys in a sensitiveInformation result that pinpoint the PII location. They are +# stripped before the response is handed to standard logging / telemetry so the +# detected PII span cannot be reconstructed from logs. +_BEDROCK_CHECKS_PII_LOCATION_KEYS = ( + "beginOffset", + "endOffset", + "messageIndex", + "contentIndex", +) # Maps an OpenAI message content-block ``type`` to the Bedrock guardrail qualifier # it represents, so callers can drive contextual grounding by tagging their content. @@ -168,6 +194,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): guardrailIdentifier: Optional[str] = None, guardrailVersion: Optional[str] = None, disable_exception_on_block: Optional[bool] = False, + checks: BedrockChecksConfigModel | Mapping[str, object] | None = None, + content_filter_threshold: float | None = 0.5, + prompt_attack_threshold: float | None = 0.5, + pii_confidence_threshold: float | None = 0.5, **kwargs, ): self.async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback) @@ -176,6 +206,15 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): self.guardrail_provider = "bedrock" self.experimental_use_latest_role_message_only = bool(kwargs.get("experimental_use_latest_role_message_only")) + # Resource-less, detect-only InvokeGuardrailChecks mode. Present `checks` + # routes the guardrail to InvokeGuardrailChecks; absent => ApplyGuardrail. + self.checks: dict[str, Any] | None = self._normalize_checks(checks) + # Per-check block thresholds; a score >= threshold blocks. None => the + # check is detect-only (logged, never blocks). + self.content_filter_threshold = content_filter_threshold + self.prompt_attack_threshold = prompt_attack_threshold + self.pii_confidence_threshold = pii_confidence_threshold + # store kwargs as optional_params self.optional_params = kwargs @@ -184,16 +223,35 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): If True, will not raise an exception when the guardrail is blocked. """ + # `checks` (InvokeGuardrailChecks) and `guardrailIdentifier`/`guardrailVersion` + # (ApplyGuardrail) are two different APIs; configuring both is ambiguous. + if self.checks is not None and (self.guardrailIdentifier is not None or self.guardrailVersion is not None): + raise ValueError( + "Bedrock guardrail accepts either 'guardrailIdentifier'/'guardrailVersion' (ApplyGuardrail) " + "or 'checks' (InvokeGuardrailChecks), not both." + ) + # Set supported event hooks to include MCP hooks kwargs.setdefault("supported_event_hooks", list(self.get_supported_event_hooks())) super().__init__(**kwargs) BaseAWSLLM.__init__(self) + # InvokeGuardrailChecks is detect-only: it never returns rewritten content, + # so masking has no effect in checks mode. + if self.checks is not None and ( + getattr(self, "mask_request_content", False) or getattr(self, "mask_response_content", False) + ): + verbose_proxy_logger.warning( + "Bedrock Guardrail: mask_request_content/mask_response_content have no " + "effect with 'checks' (InvokeGuardrailChecks is detect-only)." + ) + verbose_proxy_logger.debug( - "Bedrock Guardrail initialized with guardrailIdentifier: %s, guardrailVersion: %s", + "Bedrock Guardrail initialized with guardrailIdentifier: %s, guardrailVersion: %s, checks: %s", self.guardrailIdentifier, self.guardrailVersion, + list(self.checks.keys()) if self.checks else None, ) @classmethod @@ -206,6 +264,34 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): GuardrailEventHooks.during_mcp_call, ] + @staticmethod + def _normalize_checks(checks: BedrockChecksConfigModel | Mapping[str, object] | None) -> dict[str, Any] | None: + """Normalize the configured `checks` into a plain dict for the API body. + + Accepts a pydantic ``BedrockChecksConfigModel`` or a raw dict; drops None / + unknown keys. Returns None when no usable check is configured (=> ApplyGuardrail). + """ + if checks is None: + return None + raw = checks.model_dump(exclude_none=True) if isinstance(checks, BedrockChecksConfigModel) else dict(checks) + unknown_keys = set(raw.keys()) - _BEDROCK_CHECKS_KNOWN_KEYS + if unknown_keys: + verbose_proxy_logger.warning( + "BedrockGuardrail: unrecognized check key(s) %s will be ignored; " + "recognized keys will still be used for InvokeGuardrailChecks. " + "Known keys: %s.", + sorted(unknown_keys), + sorted(_BEDROCK_CHECKS_KNOWN_KEYS), + ) + cleaned = {key: value for key, value in raw.items() if key in _BEDROCK_CHECKS_KNOWN_KEYS and value is not None} + if not cleaned and raw: + raise ValueError( + f"BedrockGuardrail: 'checks' block contained only unrecognized or empty keys {sorted(raw.keys())}. " + f"Known keys: {sorted(_BEDROCK_CHECKS_KNOWN_KEYS)}. " + "Fix the guardrail config or remove the 'checks' block to use ApplyGuardrail mode." + ) + return cleaned or None + def _create_bedrock_input_content_request(self, messages: Optional[List[AllMessageValues]]) -> BedrockRequest: """ Create a bedrock request for the input content - the LLM request. @@ -657,6 +743,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): aws_region_name: str, api_key: Optional[str] = None, extra_headers: Optional[dict] = None, + request_path: str | None = None, ): headers = {"Content-Type": "application/json"} if extra_headers is not None: @@ -668,10 +755,12 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): aws_bedrock_runtime_endpoint=aws_bedrock_runtime_endpoint, aws_region_name=aws_region_name, ) - proxy_endpoint_url = ( - f"{proxy_endpoint_url}/guardrail/{self.guardrailIdentifier}/version/{self.guardrailVersion}/apply" - ) - # api_base = f"https://bedrock-runtime.{aws_region_name}.amazonaws.com/guardrail/{self.guardrailIdentifier}/version/{self.guardrailVersion}/apply" + # Default to the ApplyGuardrail resource path. Callers pass an explicit + # request_path for the resource-less InvokeGuardrailChecks endpoint (where + # guardrailIdentifier/guardrailVersion are None and must not be interpolated). + if request_path is None: + request_path = f"/guardrail/{self.guardrailIdentifier}/version/{self.guardrailVersion}/apply" + proxy_endpoint_url = f"{proxy_endpoint_url}{request_path}" encoded_data = json.dumps(data).encode("utf-8") # first check api-key, if none, fall back to sigV4 @@ -718,14 +807,43 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): async def make_bedrock_api_request( self, source: Literal["INPUT", "OUTPUT"], - messages: Optional[List[AllMessageValues]] = None, - response: Optional[Union[Any, litellm.ModelResponse]] = None, - request_data: Optional[dict] = None, - logging_event_type: Optional[GuardrailEventHooks] = None, + messages: list[AllMessageValues] | None = None, + response: litellm.ModelResponse | None = None, + request_data: dict | None = None, + logging_event_type: GuardrailEventHooks | None = None, ) -> BedrockGuardrailResponse: - from datetime import datetime + """Dispatch to the configured Bedrock guardrail API. - start_time = datetime.now() + ``checks`` selects the resource-less, detect-only InvokeGuardrailChecks API; + otherwise the ApplyGuardrail API is used. Both return a ``BedrockGuardrailResponse`` + (the checks path returns an empty one on a pass, which downstream masking treats + as a no-op) and raise on a blocked request. + """ + if self.checks is not None: + return await self._make_invoke_guardrail_checks_request( + source=source, + messages=messages, + response=response, + request_data=request_data, + logging_event_type=logging_event_type, + ) + return await self._make_apply_guardrail_request( + source=source, + messages=messages, + response=response, + request_data=request_data, + logging_event_type=logging_event_type, + ) + + async def _make_apply_guardrail_request( + self, + source: Literal["INPUT", "OUTPUT"], + messages: list[AllMessageValues] | None = None, + response: litellm.ModelResponse | None = None, + request_data: dict | None = None, + logging_event_type: GuardrailEventHooks | None = None, + ) -> BedrockGuardrailResponse: + start_time = datetime.now(timezone.utc) credentials, aws_region_name = self._load_credentials() bedrock_request_data: dict = dict( self.convert_to_bedrock_format(source=source, messages=messages, response=response) @@ -766,51 +884,12 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): else: event_type = GuardrailEventHooks.pre_call if source == "INPUT" else GuardrailEventHooks.post_call - try: - httpx_response = await self.async_handler.post( - url=prepared_request.url, - data=prepared_request.body, # type: ignore - headers=prepared_request.headers, # type: ignore - ) - except HTTPException: - # Propagate HTTPException (e.g. from non-200 path) as-is - raise - except Exception as e: - # If this is an HTTP error with a response body (e.g. httpx.HTTPStatusError), - # extract the AWS error message and propagate it - response = getattr(e, "response", None) - if isinstance(response, httpx.Response): - try: - ( - status_code, - detail_message, - ) = self._parse_bedrock_guardrail_error_response(response) - self.add_standard_logging_guardrail_information_to_request_data( - guardrail_provider=self.guardrail_provider, - guardrail_json_response={"error": detail_message}, - request_data=request_data or {}, - guardrail_status="guardrail_failed_to_respond", - start_time=start_time.timestamp(), - end_time=datetime.now().timestamp(), - duration=(datetime.now() - start_time).total_seconds(), - event_type=event_type, - ) - raise HTTPException(status_code=status_code, detail=detail_message) from e - except HTTPException: - raise - # Endpoint down, timeout, or other HTTP/network errors - verbose_proxy_logger.error("Bedrock AI: failed to make guardrail request: %s", str(e)) - self.add_standard_logging_guardrail_information_to_request_data( - guardrail_provider=self.guardrail_provider, - guardrail_json_response={"error": str(e)}, - request_data=request_data or {}, - guardrail_status="guardrail_failed_to_respond", - start_time=start_time.timestamp(), - end_time=datetime.now().timestamp(), - duration=(datetime.now() - start_time).total_seconds(), - event_type=event_type, - ) - raise + httpx_response = await self._sign_and_post( + prepared_request=prepared_request, + request_data=request_data, + event_type=event_type, + start_time=start_time, + ) ######################################################### # Add guardrail information to request trace @@ -826,8 +905,8 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): request_data=request_data or {}, guardrail_status=self._get_bedrock_guardrail_response_status(response=httpx_response), start_time=start_time.timestamp(), - end_time=datetime.now().timestamp(), - duration=(datetime.now() - start_time).total_seconds(), + end_time=datetime.now(timezone.utc).timestamp(), + duration=(datetime.now(timezone.utc) - start_time).total_seconds(), event_type=event_type, tracing_detail=tracing_detail or None, ) @@ -854,6 +933,338 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): return bedrock_guardrail_response + async def _sign_and_post( + self, + prepared_request: "AWSPreparedRequest", + request_data: dict | None, + event_type: GuardrailEventHooks, + start_time: "datetime", + ) -> httpx.Response: + """POST a signed Bedrock request, logging+raising on network/HTTP errors. + + Shared by both the ApplyGuardrail and InvokeGuardrailChecks paths so their + transport-error handling cannot drift. Returns the raw ``httpx.Response`` on + success (including non-2xx that httpx did not raise on); the 200-path logging, + status and tracing stay with each caller because the two APIs report differently. + """ + try: + return await self.async_handler.post( + url=prepared_request.url, + data=prepared_request.body, + headers=prepared_request.headers, + ) + except HTTPException: + # Propagate HTTPException (e.g. from non-200 path) as-is + raise + except Exception as e: + # If this is an HTTP error with a response body (e.g. httpx.HTTPStatusError), + # extract the AWS error message and propagate it + err_response = getattr(e, "response", None) + if isinstance(err_response, httpx.Response): + try: + ( + status_code, + detail_message, + ) = self._parse_bedrock_guardrail_error_response(err_response) + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_provider=self.guardrail_provider, + guardrail_json_response={"error": detail_message}, + request_data=request_data or {}, + guardrail_status="guardrail_failed_to_respond", + start_time=start_time.timestamp(), + end_time=datetime.now(timezone.utc).timestamp(), + duration=(datetime.now(timezone.utc) - start_time).total_seconds(), + event_type=event_type, + ) + raise HTTPException(status_code=status_code, detail=detail_message) from e + except HTTPException: + raise + # Endpoint down, timeout, or other HTTP/network errors + verbose_proxy_logger.error("Bedrock AI: failed to make guardrail request: %s", str(e)) + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_provider=self.guardrail_provider, + guardrail_json_response={"error": str(e)}, + request_data=request_data or {}, + guardrail_status="guardrail_failed_to_respond", + start_time=start_time.timestamp(), + end_time=datetime.now(timezone.utc).timestamp(), + duration=(datetime.now(timezone.utc) - start_time).total_seconds(), + event_type=event_type, + ) + raise + + ########### InvokeGuardrailChecks (resource-less, detect-only) ############ + + @staticmethod + def _chunk_texts_into_checks_messages( + role: Literal["user", "assistant", "system"], texts: list[str] + ) -> list[BedrockChecksMessage]: + """Group ``texts`` into role-tagged messages of <= the API content-block cap. + + A source message with more text blocks than the per-message limit is split + across multiple messages so EVERY block is scanned. Truncating instead would + let a user hide prohibited content past the limit (guardrail bypass). + """ + cap = _BEDROCK_CHECKS_MAX_CONTENT_BLOCKS + return [ + BedrockChecksMessage( + role=role, + content=[{"text": text} for text in texts[start : start + cap]], + ) + for start in range(0, len(texts), cap) + ] + + def _build_invoke_guardrail_checks_messages( + self, + source: Literal["INPUT", "OUTPUT"], + messages: list[AllMessageValues] | None = None, + response: litellm.ModelResponse | None = None, + ) -> list[BedrockChecksMessage]: + """Build the role-tagged `messages` array for InvokeGuardrailChecks. + + INPUT scans the request messages, OUTPUT scans the model response as an + ``assistant`` turn. Every non-empty text block of every message is scanned; + messages exceeding the per-message content-block cap are split into multiple + messages rather than truncated. + + INPUT content is tagged ``user`` regardless of the caller-supplied role. + Bedrock excludes ``system`` content from prompt-attack evaluation, so + trusting a caller's ``system``/``developer`` label would let an injection + avoid the promptAttack check. At the proxy every INPUT message is + caller-controlled, so all of it is treated as untrusted user input, matching + AWS guidance to tag untrusted content as user input. + """ + if source == "OUTPUT": + # Reuse the ApplyGuardrail output extractor (single source of truth for + # pulling assistant text out of a ModelResponse), then re-tag as an + # assistant turn for the role-based InvokeGuardrailChecks payload. + output_request = self._create_bedrock_output_content_request(response=response) + output_texts = [ + text for item in output_request.get("content") or [] if (text := (item.get("text") or {}).get("text")) + ] + return self._chunk_texts_into_checks_messages("assistant", output_texts) + + return [ + checks_message + for message in messages or [] + for checks_message in self._chunk_texts_into_checks_messages( + "user", + [block.text for block in self.get_content_items_for_message(message) or [] if block.text], + ) + ] + + async def _make_invoke_guardrail_checks_request( + self, + source: Literal["INPUT", "OUTPUT"], + messages: list[AllMessageValues] | None = None, + response: litellm.ModelResponse | None = None, + request_data: dict | None = None, + logging_event_type: GuardrailEventHooks | None = None, + ) -> BedrockGuardrailResponse: + """Run the resource-less InvokeGuardrailChecks API and enforce thresholds. + + Detect-only: the API returns scores, never rewritten content. We map scores + to a block decision via the configured thresholds. On a pass we return an + empty ``BedrockGuardrailResponse`` (downstream masking treats it as a no-op). + """ + start_time = datetime.now(timezone.utc) + + checks_messages = self._build_invoke_guardrail_checks_messages( + source=source, messages=messages, response=response + ) + if not checks_messages: + # Nothing to scan (e.g. tool-only turn) -> allow, like ApplyGuardrail does. + return BedrockGuardrailResponse() + + credentials, aws_region_name = self._load_credentials() + body: dict[str, Any] = {"messages": checks_messages, "checks": self.checks} + api_key: str | None = request_data.get("api_key") if request_data else None + + prepared_request = self._prepare_request( + credentials=credentials, + data=body, + optional_params=self.optional_params, + aws_region_name=aws_region_name, + api_key=api_key, + request_path=_BEDROCK_INVOKE_GUARDRAIL_CHECKS_PATH, + ) + verbose_proxy_logger.debug("Bedrock InvokeGuardrailChecks request url: %s", prepared_request.url) + + event_type = logging_event_type or ( + GuardrailEventHooks.pre_call if source == "INPUT" else GuardrailEventHooks.post_call + ) + + httpx_response = await self._sign_and_post( + prepared_request=prepared_request, + request_data=request_data, + event_type=event_type, + start_time=start_time, + ) + + if httpx_response.status_code != 200: + status_code, detail_message = self._parse_bedrock_guardrail_error_response(httpx_response) + verbose_proxy_logger.error( + "Bedrock InvokeGuardrailChecks: error response. Status %s: %s", + httpx_response.status_code, + detail_message, + ) + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_provider=self.guardrail_provider, + guardrail_json_response={"error": detail_message}, + request_data=request_data or {}, + guardrail_status="guardrail_failed_to_respond", + start_time=start_time.timestamp(), + end_time=datetime.now(timezone.utc).timestamp(), + duration=(datetime.now(timezone.utc) - start_time).total_seconds(), + event_type=event_type, + ) + raise HTTPException(status_code=status_code, detail=detail_message) + + try: + json_response = TypeAdapter(BedrockGuardrailChecksResponse).validate_python(httpx_response.json()) + except (ValidationError, ValueError) as e: + verbose_proxy_logger.error("Bedrock InvokeGuardrailChecks: unparseable 200 response: %s", str(e)) + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_provider=self.guardrail_provider, + guardrail_json_response={"error": str(e)}, + request_data=request_data or {}, + guardrail_status="guardrail_failed_to_respond", + start_time=start_time.timestamp(), + end_time=datetime.now(timezone.utc).timestamp(), + duration=(datetime.now(timezone.utc) - start_time).total_seconds(), + event_type=event_type, + ) + raise HTTPException( + status_code=500, + detail={"error": "Bedrock InvokeGuardrailChecks returned an unexpected response shape"}, + ) from e + violations = self._collect_invoke_checks_violations(json_response) + + # Log a copy with PII location offsets stripped: offsets + the (separately + # logged) request messages would otherwise reconstruct the detected PII span. + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_provider=self.guardrail_provider, + guardrail_json_response=self._sanitize_invoke_checks_response_for_logging(json_response), + request_data=request_data or {}, + guardrail_status=self._get_invoke_checks_status(bool(violations)), + start_time=start_time.timestamp(), + end_time=datetime.now(timezone.utc).timestamp(), + duration=(datetime.now(timezone.utc) - start_time).total_seconds(), + event_type=event_type, + tracing_detail=self._build_invoke_checks_tracing_detail(violations) if violations else None, + ) + + if violations: + raise self._get_block_exception_for_checks(violations, request_data=request_data) + + return BedrockGuardrailResponse() + + def _collect_invoke_checks_violations( + self, response: BedrockGuardrailChecksResponse | None + ) -> list[BedrockChecksViolation]: + """Return the check results whose score meets/exceeds the configured threshold. + + Only checks present in the configured ``checks`` block are evaluated; a + threshold of ``None`` makes that check detect-only (never contributes a + violation). A truncated sensitiveInformation result counts as a violation + (fail closed: omitted detections were never scored). Only the non-sensitive + label (category/type) and the numeric score are kept -- never offsets or + matched text. + """ + results: dict[str, Any] = dict((response or {}).get("results") or {}) + # (results key, score field, label field, threshold). PII uses + # confidenceScore/type; the other two use severityScore/category. + check_specs = [ + ( + "contentFilter", + "severityScore", + "category", + self.content_filter_threshold, + ), + ("promptAttack", "severityScore", "category", self.prompt_attack_threshold), + ( + "sensitiveInformation", + "confidenceScore", + "type", + self.pii_confidence_threshold, + ), + ] + + configured_checks = self.checks or {} + violations: list[BedrockChecksViolation] = [] + for check_key, score_field, label_field, threshold in check_specs: + if threshold is None or check_key not in configured_checks: + continue + check_result = results.get(check_key) or {} + if check_key == "sensitiveInformation" and check_result.get("truncated"): + violations.append({"check": check_key, "truncated": True}) + for entry in check_result.get("results") or []: + score = entry.get(score_field) + if isinstance(score, (int, float)) and float(score) >= threshold: + violation: BedrockChecksViolation = ( + {"check": check_key, "category": entry.get("category"), "severityScore": float(score)} + if score_field == "severityScore" + else {"check": check_key, "type": entry.get("type"), "confidenceScore": float(score)} + ) + violations.append(violation) + return violations + + @staticmethod + def _sanitize_invoke_checks_response_for_logging( + response: BedrockGuardrailChecksResponse, + ) -> dict[str, Any]: + """Strip PII location offsets from a checks response before it is logged.""" + sanitized: dict[str, Any] = copy.deepcopy(dict(response)) + sensitive = (sanitized.get("results") or {}).get("sensitiveInformation") or {} + for entry in sensitive.get("results") or []: + if isinstance(entry, dict): + for key in _BEDROCK_CHECKS_PII_LOCATION_KEYS: + entry.pop(key, None) + return sanitized + + @staticmethod + def _get_invoke_checks_status(over_threshold: bool) -> GuardrailStatus: + return "guardrail_intervened" if over_threshold else "success" + + @staticmethod + def _build_invoke_checks_tracing_detail( + violations: list[BedrockChecksViolation], + ) -> GuardrailTracingDetail: + tracing_detail: GuardrailTracingDetail = {} + categories = [ + label + for label in (v.get("category") or v.get("type") for v in violations) + if isinstance(label, str) and label + ] + if categories: + tracing_detail["violation_categories"] = categories + tracing_detail["guardrail_action"] = "GUARDRAIL_INTERVENED" if violations else "NONE" + return tracing_detail + + def _get_block_exception_for_checks( + self, violations: list[BedrockChecksViolation], request_data: dict | None = None + ) -> Union[HTTPException, ModifyResponseException]: + """Build the block exception for an over-threshold InvokeGuardrailChecks result. + + Mirrors ``_get_http_exception_for_blocked_guardrail``'s return-type branching. + The detail carries only non-sensitive labels + scores (no offsets / raw input). + """ + if self.disable_exception_on_block is True: + _request_data = request_data or {} + return ModifyResponseException( + message="Violated guardrail policy", + model=_request_data.get("model", "bedrock-guardrail"), + request_data=_request_data, + guardrail_name=self.guardrail_name, + ) + return HTTPException( + status_code=400, + detail={ + "error": "Violated guardrail policy", + "bedrock_guardrail_checks": violations, + }, + ) + def _check_bedrock_response_for_exception(self, response) -> bool: """ Return True if the Bedrock ApplyGuardrail response indicates an exception. diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py index 63ead52baa6..a3ac1b94004 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py @@ -38,6 +38,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" default_on=litellm_params.default_on, streaming_end_of_stream_only=_get_config_value(litellm_params, optional_params, "streaming_end_of_stream_only"), streaming_sampling_rate=_get_config_value(litellm_params, optional_params, "streaming_sampling_rate"), + streaming_transform_mode=_get_config_value(litellm_params, optional_params, "streaming_transform_mode"), ) litellm.logging_callback_manager.add_litellm_callback(_generic_guardrail_api_callback) diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index e29d6e56353..6b1c9a8b2b3 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -58,7 +58,7 @@ _HEADER_PRESENT_PLACEHOLDER = "[present]" def _header_value_allowed( header_name: str, - extra_allowlist: Optional[Set[str]] = None, + extra_allowlist: Set[str] | None = None, ) -> bool: """Return True if this header's value may be forwarded (allowlist, including globs and extra_headers).""" lower = header_name.lower() @@ -74,8 +74,8 @@ def _header_value_allowed( def _sanitize_inbound_headers( headers: Any, - extra_allowlist: Optional[Set[str]] = None, -) -> Optional[Dict[str, str]]: + extra_allowlist: Set[str] | None = None, +) -> Dict[str, str] | None: """ Sanitize inbound headers before passing them to a 3rd party guardrail service. @@ -105,8 +105,8 @@ def _sanitize_inbound_headers( def _extract_inbound_headers( request_data: dict, logging_obj: Optional["LiteLLMLoggingObj"], - extra_allowlist: Optional[Set[str]] = None, -) -> Optional[Dict[str, str]]: + extra_allowlist: Set[str] | None = None, +) -> Dict[str, str] | None: """ Extract inbound headers from available request context. @@ -172,15 +172,16 @@ class GenericGuardrailAPI(CustomGuardrail): def __init__( self, - headers: Optional[Dict[str, Any]] = None, - api_base: Optional[str] = None, - api_key: Optional[str] = None, - additional_provider_specific_params: Optional[Dict[str, Any]] = None, + headers: Dict[str, Any] | None = None, + api_base: str | None = None, + api_key: str | None = None, + additional_provider_specific_params: Dict[str, Any] | None = None, unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_closed", - fail_on_error: Optional[bool] = True, - extra_headers: Optional[list] = None, - streaming_end_of_stream_only: Optional[bool] = None, - streaming_sampling_rate: Optional[int] = None, + fail_on_error: bool | None = True, + extra_headers: list | None = None, + streaming_end_of_stream_only: bool | None = None, + streaming_sampling_rate: int | None = None, + streaming_transform_mode: Literal["block_only", "incremental_diff"] | None = None, **kwargs, ): self.async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback) @@ -221,6 +222,13 @@ class GenericGuardrailAPI(CustomGuardrail): raise ValueError(f"streaming_sampling_rate must be >= 1 (got {streaming_sampling_rate})") self.streaming_sampling_rate: int = 5 if streaming_sampling_rate is None else streaming_sampling_rate + # Read by UnifiedLLMGuardrails.async_post_call_streaming_iterator_hook. + # "block_only" (default) drops text rewrites on the streaming path; + # "incremental_diff" emits them as synthetic deltas. + self.streaming_transform_mode: Literal["block_only", "incremental_diff"] = ( + "block_only" if streaming_transform_mode is None else streaming_transform_mode + ) + # Set supported event hooks kwargs.setdefault("supported_event_hooks", list(self.get_supported_event_hooks())) @@ -280,7 +288,7 @@ class GenericGuardrailAPI(CustomGuardrail): input_type: Literal["request", "response"], logging_obj: Optional["LiteLLMLoggingObj"], error: Exception, - http_status_code: Optional[int] = None, + http_status_code: int | None = None, ) -> GenericGuardrailAPIInputs: status_suffix = f" http_status_code={http_status_code}" if http_status_code else "" verbose_proxy_logger.critical( @@ -326,6 +334,8 @@ class GenericGuardrailAPI(CustomGuardrail): return_inputs["tools"] = guardrail_response.tools elif tools: return_inputs["tools"] = tools + if guardrail_response.stream_holdback_chars is not None: + return_inputs["stream_holdback_chars"] = guardrail_response.stream_holdback_chars return return_inputs def _handle_guardrail_request_error( @@ -479,7 +489,7 @@ class GenericGuardrailAPI(CustomGuardrail): return self._handle_guardrail_request_error(e, inputs, input_type, logging_obj, is_unreachable=False) @staticmethod - def get_config_model() -> Optional[type["GuardrailConfigModel"]]: + def get_config_model() -> type["GuardrailConfigModel"] | None: from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( GenericGuardrailAPIConfigModel, ) diff --git a/litellm/proxy/guardrails/guardrail_hooks/lasso/lasso.py b/litellm/proxy/guardrails/guardrail_hooks/lasso/lasso.py index 9c4cef2f06b..31ce0cc2214 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/lasso/lasso.py +++ b/litellm/proxy/guardrails/guardrail_hooks/lasso/lasso.py @@ -772,7 +772,13 @@ class LassoGuardrail(CustomGuardrail): data: Request data (used for conversation_id generation and tools extraction) cache: Cache instance for storing conversation_id (optional for post-call) """ - payload: Dict[str, Any] = {"messages": messages, "messageType": message_type} + payload: Dict[str, Any] = { + "messages": messages, + "messageType": message_type, + # Drives the "Used By" badge on Lasso Application API Keys: every call from this + # integration is attributed as "litellm" on the keys list. + "source": {"type": "litellm"}, + } # Add optional parameters if available if self.user_id: diff --git a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py index e0b387e92c0..8e3abfbf159 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py +++ b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py @@ -8,7 +8,7 @@ Unified Guardrail, leveraging LiteLLM's /applyGuardrail endpoint import copy import json -from typing import TYPE_CHECKING, Any, AsyncGenerator, List, Optional, Union +from typing import TYPE_CHECKING, Any, AsyncGenerator, List, Union from fastapi import HTTPException @@ -21,7 +21,13 @@ from litellm.litellm_core_utils.api_route_to_call_types import get_call_types_fo from litellm.llms import load_guardrail_translation_mappings from litellm.proxy._types import UserAPIKeyAuth from litellm.types.guardrails import GuardrailEventHooks -from litellm.types.utils import CallTypes, CallTypesLiteral +from litellm.types.utils import ( + CallTypes, + CallTypesLiteral, + Delta, + ModelResponseStream, + StreamingChoices, +) if TYPE_CHECKING: # Imported lazily at runtime (inside the streaming hook) to avoid a @@ -34,7 +40,12 @@ A2A_CALL_TYPES = (CallTypes.asend_message, CallTypes.send_message) GUARDRAIL_NAME = "unified_llm_guardrails" -def _get_a2a_request_id(responses_so_far: List[Any], request_data: dict) -> Optional[str]: +class _StreamTerminated(Exception): + """Internal signal that the incremental transform stream has already emitted + its terminal chunks (block message or in-stream error) and must stop.""" + + +def _get_a2a_request_id(responses_so_far: List[Any], request_data: dict) -> str | None: """Get JSON-RPC request id from first A2A chunk or request body for in-stream error reporting.""" for item in responses_so_far: if isinstance(item, dict) and "id" in item: @@ -216,7 +227,7 @@ class UnifiedLLMGuardrails(CustomLogger): verbose_proxy_logger.debug("async_post_call_success_hook response: %s", response) - call_type: Optional[CallTypesLiteral] = None + call_type: CallTypesLiteral | None = None if user_api_key_dict.request_route is not None: call_types = get_call_types_for_route(user_api_key_dict.request_route) if call_types is not None and len(call_types) > 0: # type: ignore @@ -292,6 +303,498 @@ class UnifiedLLMGuardrails(CustomLogger): for chunk in block_chunks: yield chunk + @staticmethod + def _resolve_transform_call_type( + user_api_key_dict: UserAPIKeyAuth, + mappings: dict, + ) -> str | None: + """Resolve the call type for the incremental_diff path, or None if the + route is unresolvable / unsupported. + + Incremental transformation needs a route we can resolve before the first + chunk and a handler that supports the streaming text-diff protocol (v1: + the OpenAI chat completions handler only). Returning None makes the caller + fall back to block_only. + """ + from litellm.llms.openai.chat.guardrail_translation.handler import ( + OpenAIChatCompletionsHandler, + ) + + if user_api_key_dict.request_route is None: + return None + call_types = get_call_types_for_route(user_api_key_dict.request_route) + if not call_types: + return None + call_type = call_types[0].value + try: + mapped = CallTypes(call_type) + except ValueError: + return None + handler_cls = mappings.get(mapped) + if handler_cls is None or not issubclass(handler_cls, OpenAIChatCompletionsHandler): + return None + return call_type + + async def _emit_streaming_http_error( + self, + exc: HTTPException, + call_type: str | None, + responses_so_far: list[Any], + request_data: dict, + ) -> AsyncGenerator[Any, None]: + """Surface a mid-stream HTTPException. For A2A (NDJSON) call types the + response has already started, so emit an in-stream JSON-RPC error chunk; + otherwise re-raise so the proxy can report it. + """ + if call_type is not None and CallTypes(call_type) in A2A_CALL_TYPES: + request_id = _get_a2a_request_id(responses_so_far, request_data) + detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)} + error_chunk = ( + json.dumps( + { + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32603, + "message": detail.get("error", detail.get("message", str(exc.detail))), + "data": {k: v for k, v in detail.items() if k not in ("error", "message")}, + }, + } + ) + + "\n" + ) + yield error_chunk + return + raise exc + + def _build_transform_chunk( + self, + *, + reference_chunk: Any, + mutated_text_per_choice: dict[int, str], + emitted_text_per_choice: dict[int, str], + holdback_per_choice: dict[int, int], + finish_reason_per_choice: dict[int, str | None], + is_final: bool, + ) -> ModelResponseStream | None: + """Build the synthetic chunk carrying the newly-guardrailed deltas. + + For each choice, the new delta is the mutated accumulated text past what + has already been emitted, minus a trailing holdback (forced to 0 on the + final flush). ``emitted_text_per_choice`` holds the exact bytes already + sent per choice and is extended in place. Returns None when there is no + text to emit (e.g. a tool-call-only turn) or nothing new and this is not + the final chunk. + + Raises HTTPException(400, stream_transform_underflow) when the guardrail's + transform is not a forward extension of what has already been streamed + (shorter than, or rewrites, the already-sent prefix), since emitted bytes + cannot be retracted. This makes the framework fail closed rather than + silently leave un-transformed text on the wire; a guardrail that needs to + rewrite recent output must withhold it first via ``stream_holdback_chars``. + """ + if not mutated_text_per_choice: + # Fix #4 — on the final flush a deferred finish_reason (from a mixed + # content+tool_calls chunk whose passthrough suppressed it) still + # needs to reach the client, even if the guardrail returned no text + # to emit. Build a terminator chunk carrying finish_reason per choice. + if is_final and finish_reason_per_choice: + terminator_choices: list[StreamingChoices] = [] + for choice_idx, finish_reason in finish_reason_per_choice.items(): + if finish_reason is None: + continue + terminator_choices.append( + StreamingChoices( + index=choice_idx, + delta=Delta(content="", role=None, tool_calls=None), + finish_reason=finish_reason, + ) + ) + if terminator_choices: + return ModelResponseStream( + id=getattr(reference_chunk, "id", None), + created=getattr(reference_chunk, "created", None), + model=getattr(reference_chunk, "model", None), + choices=terminator_choices, + ) + return None + + deltas: dict[int, str] = {} + for choice_idx, text in mutated_text_per_choice.items(): + already = emitted_text_per_choice.get(choice_idx, "") + if not text.startswith(already): + raise HTTPException( + status_code=400, + detail={ + "error": "stream_transform_underflow", + "message": ( + f"Guardrail streaming transform for choice {choice_idx} is not a forward " + f"extension of the {len(already)} chars already streamed to the client " + "(it is shorter than, or rewrites, the emitted prefix); emitted bytes " + "cannot be retracted. Withhold recent output via stream_holdback_chars " + "before rewriting it." + ), + }, + ) + holdback = 0 if is_final else max(0, holdback_per_choice.get(choice_idx, 0)) + end = max(len(already), len(text) - holdback) + deltas[choice_idx] = text[len(already) : end] + + # Iterate the mutated choices (not just those in reference_chunk) so a + # choice with pending text is never dropped for n > 1. finish_reason is + # taken per choice from the accumulated map (a choice can finish in an + # earlier chunk than the stream's last one); tool_calls are dropped since + # v1 does not transform streamed tool calls (they pass through raw). + synthetic_choices: list[StreamingChoices] = [] + for choice_idx in mutated_text_per_choice: + delta_text = deltas.get(choice_idx, "") + finish_reason = finish_reason_per_choice.get(choice_idx) if is_final else None + # Skip a choice with nothing to say: no new content and no + # finish_reason to deliver. This avoids emitting an empty delta for an + # already-finished choice (e.g. one that terminated via a passed-through + # tool-call chunk, which already carried its own finish_reason). + if not delta_text and finish_reason is None: + continue + # role="assistant" on this choice's first emitted delta only. + role = "assistant" if not emitted_text_per_choice.get(choice_idx) else None + synthetic_choices.append( + StreamingChoices( + index=choice_idx, + delta=Delta(content=delta_text, role=role, tool_calls=None), + finish_reason=finish_reason, + ) + ) + + if not synthetic_choices: + return None + + for choice_idx in mutated_text_per_choice: + emitted_text_per_choice[choice_idx] = emitted_text_per_choice.get(choice_idx, "") + deltas.get( + choice_idx, "" + ) + + return ModelResponseStream( + id=getattr(reference_chunk, "id", None), + created=getattr(reference_chunk, "created", None), + model=getattr(reference_chunk, "model", None), + choices=synthetic_choices, + ) + + async def _emit_transform_round( + self, + *, + endpoint_translation: Any, + guardrail_to_apply: CustomGuardrail, + request_data: dict, + user_api_key_dict: UserAPIKeyAuth, + call_type: str, + reference_chunk: Any, + responses_so_far: list[Any], + responses_yielded: list[Any], + emitted_text_per_choice: dict[int, str], + finish_reason_per_choice: dict[int, str | None], + is_final: bool, + ) -> AsyncGenerator[Any, None]: + """Run one guardrail processing round and emit the resulting diff chunk. + + Raises ``_StreamTerminated`` (after emitting the terminal block message or + in-stream error) when the guardrail blocks or an underflow occurs. + """ + from litellm.integrations.custom_guardrail import ModifyResponseException + from litellm.llms.base_llm.guardrail_translation.base_translation import ( + StreamTransformSink, + ) + + sink = StreamTransformSink() + try: + await endpoint_translation.process_output_streaming_response( + responses_so_far=responses_so_far, + guardrail_to_apply=guardrail_to_apply, + litellm_logging_obj=request_data.get("litellm_logging_obj"), + user_api_key_dict=user_api_key_dict, + request_data=request_data, + stream_transform_sink=sink, + ) + synthetic = self._build_transform_chunk( + reference_chunk=reference_chunk, + mutated_text_per_choice=sink.mutated_text_per_choice, + emitted_text_per_choice=emitted_text_per_choice, + holdback_per_choice=sink.holdback_per_choice, + finish_reason_per_choice=finish_reason_per_choice, + is_final=is_final, + ) + except ModifyResponseException as e: + if e.original_response is None: + e.original_response = responses_so_far + async for block_chunk in self._handle_streaming_block( + e, + endpoint_translation, + stream_started=bool(responses_yielded), + responses_so_far=responses_yielded, + ): + yield block_chunk + raise _StreamTerminated() + except HTTPException as e: + async for error_item in self._emit_streaming_http_error(e, call_type, responses_so_far, request_data): + yield error_item + raise _StreamTerminated() + + if synthetic is not None: + responses_yielded.append(synthetic) + yield synthetic + + async def _run_incremental_transform_stream( + self, + *, + guardrail_to_apply: CustomGuardrail, + response: Any, + request_data: dict, + user_api_key_dict: UserAPIKeyAuth, + call_type: str, + sampling_rate: int, + end_of_stream_only: bool, + mappings: dict, + ) -> AsyncGenerator[Any, None]: + """Emit guardrail text transformations as new deltas on the stream. + + Raw chunks are withheld and accumulated; on each sampled processing round + (and once at end of stream) the guardrailed accumulated text is diffed + against what has already been emitted and the new portion is sent as a + synthetic chunk. A BLOCK terminates the stream via the shared block + handler; an underflow surfaces as an HTTPException. + """ + endpoint_translation = mappings[CallTypes(call_type)]() + responses_so_far: list[Any] = [] + responses_yielded: list[Any] = [] + emitted_text_per_choice: dict[int, str] = {} + finish_reason_per_choice: dict[int, str | None] = {} + chunk_counter = 0 + last_chunk: Any | None = None + + def _round(reference_chunk: Any, is_final: bool) -> AsyncGenerator[Any, None]: + return self._emit_transform_round( + endpoint_translation=endpoint_translation, + guardrail_to_apply=guardrail_to_apply, + request_data=request_data, + user_api_key_dict=user_api_key_dict, + call_type=call_type, + reference_chunk=reference_chunk, + responses_so_far=responses_so_far, + responses_yielded=responses_yielded, + emitted_text_per_choice=emitted_text_per_choice, + finish_reason_per_choice=finish_reason_per_choice, + is_final=is_final, + ) + + saw_tool_calls = False + saw_text_content = False + + try: + async for item in response: + # v1 transforms only text. A chunk carrying tool_calls is passed + # through raw so function-calling turns are not dropped, but ONLY + # its tool-call fields are forwarded: content is stripped so any + # response text (in the same delta, or in another choice of an n>1 + # chunk) can never bypass the transform. The original chunk is kept + # in responses_so_far so its text is still accumulated + redacted + + # emitted as synthetic deltas, and so the guardrail inspects the + # assembled tool calls at end of stream (see the block inspection + # below), matching block_only. finish_reason rides on the raw + # tool-only chunk, so it is not recorded for the text flush. + if self._chunk_has_tool_calls(item): + saw_tool_calls = True + responses_so_far.append(item) + last_chunk = item + # Fix #3 — flush accumulated text BEFORE the tool-call + # passthrough. Without this, a stream of text chunks that + # hasn't yet hit a sampled round can be trailed by a + # tool-call chunk carrying finish_reason="tool_calls"; an + # SSE-compliant client stops reading at that finish_reason + # and drops the end-of-stream text flush that would follow. + if saw_text_content: + async for out in _round(item, is_final=False): + yield out + # Fix #1 — pass finish_reason_per_choice into the + # passthrough so a mixed content+tool_call chunk defers its + # finish_reason to the final text terminator (see the + # _tool_call_passthrough_chunk docstring). + tool_only = self._tool_call_passthrough_chunk( + item, finish_reason_per_choice=finish_reason_per_choice + ) + responses_yielded.append(tool_only) + yield tool_only + continue + + chunk_counter += 1 + responses_so_far.append(item) + last_chunk = item + self._record_finish_reasons(item, finish_reason_per_choice) + if self._chunk_carries_text(item): + saw_text_content = True + # Skip the sampled round for a terminal chunk: the end-of-stream + # flush below processes it once with holdback forced to 0, so a + # sampled round here would guardrail the same content twice. + if ( + not end_of_stream_only + and not self._chunk_has_finish_reason(item) + and chunk_counter % sampling_rate == 0 + ): + async for out in _round(item, is_final=False): + yield out + + # v1 does not transform streamed tool calls, but they must still go + # through the guardrail's block decision. Run the block_only inspection + # over the full assembled response so tool calls cannot bypass it. + # + # Pass a deep copy of responses_so_far — the block path routes through + # ``_process_streaming_block_only`` which mutates ``delta.content`` + # in-place on the chunk objects it receives. For an n>1 chunk carrying + # text on one choice and tool_calls (with finish_reason) on another, + # ``has_stream_ended`` reads ``choices[0]`` alone and can miss the + # terminal signal, letting the block path rewrite the raw accumulator. + # The subsequent final ``_round`` would then re-read the already-mutated + # text, producing double-application for a non-idempotent guardrail or a + # ``stream_transform_underflow`` 400 from mismatched prefixes. A shallow + # list copy wouldn't help — the mutation is on the chunk objects + # themselves — so we deepcopy. + if saw_tool_calls: + async for out in self._inspect_full_response_for_block( + endpoint_translation=endpoint_translation, + guardrail_to_apply=guardrail_to_apply, + request_data=request_data, + user_api_key_dict=user_api_key_dict, + responses_so_far=copy.deepcopy(responses_so_far), + responses_yielded=responses_yielded, + ): + yield out + + if last_chunk is not None: + async for out in _round(last_chunk, is_final=True): + yield out + except _StreamTerminated: + return + + async def _inspect_full_response_for_block( + self, + *, + endpoint_translation: Any, + guardrail_to_apply: CustomGuardrail, + request_data: dict, + user_api_key_dict: UserAPIKeyAuth, + responses_so_far: list[Any], + responses_yielded: list[Any], + ) -> AsyncGenerator[Any, None]: + """Run the block-only guardrail inspection over the full assembled + response (text + tool calls) so nothing bypasses the block decision. + + The guardrail's returned transforms are discarded here (v1 does not + transform tool calls); only its block decision matters. A block is + surfaced the same way as elsewhere: ModifyResponseException terminates the + stream via the shared block handler; a GenericGuardrailAPI block raises and + propagates, matching block_only. + """ + from litellm.integrations.custom_guardrail import ModifyResponseException + + try: + await endpoint_translation.process_output_streaming_response( + responses_so_far=responses_so_far, + guardrail_to_apply=guardrail_to_apply, + litellm_logging_obj=request_data.get("litellm_logging_obj"), + user_api_key_dict=user_api_key_dict, + request_data=request_data, + stream_transform_sink=None, + ) + except ModifyResponseException as e: + if e.original_response is None: + e.original_response = responses_so_far + async for block_chunk in self._handle_streaming_block( + e, + endpoint_translation, + stream_started=bool(responses_yielded), + responses_so_far=responses_yielded, + ): + yield block_chunk + raise _StreamTerminated() + + @staticmethod + def _chunk_has_tool_calls(item: Any) -> bool: + for choice in getattr(item, "choices", None) or []: + delta = getattr(choice, "delta", None) + if getattr(delta, "tool_calls", None): + return True + return False + + @staticmethod + def _chunk_carries_text(item: Any) -> bool: + """True if any choice in this chunk has non-empty string ``delta.content``.""" + for choice in getattr(item, "choices", None) or []: + delta = getattr(choice, "delta", None) + content = getattr(delta, "content", None) + if isinstance(content, str) and content != "": + return True + return False + + @staticmethod + def _tool_call_passthrough_chunk( + item: Any, + finish_reason_per_choice: "dict[int, str | None] | None" = None, + ) -> ModelResponseStream: + """Copy of a chunk carrying tool calls with all text content stripped. + + Only tool_calls, role and finish_reason are forwarded; content is set to + None so response text can never be delivered raw (it flows through the + transform instead). Applies per choice so an n>1 chunk mixing a text + choice and a tool-call choice does not leak the text choice. + + For a choice that carries BOTH text content AND tool_calls, ``finish_reason`` + is suppressed on the passthrough and recorded on + ``finish_reason_per_choice`` (when provided) so the final synthetic text + chunk delivers it. Emitting the passthrough's ``finish_reason`` before the + text flush would let a spec-compliant SSE client stop reading at + ``finish_reason`` and silently drop the guardrailed text, defeating the + redaction purpose. + """ + synthetic_choices: list[StreamingChoices] = [] + for choice in getattr(item, "choices", None) or []: + delta = getattr(choice, "delta", None) + idx = getattr(choice, "index", 0) or 0 + original_finish = getattr(choice, "finish_reason", None) + has_text = isinstance(getattr(delta, "content", None), str) and getattr(delta, "content", "") != "" + if has_text and original_finish is not None and finish_reason_per_choice is not None: + finish_reason_per_choice[idx] = original_finish + passthrough_finish: str | None = None + else: + passthrough_finish = original_finish + synthetic_choices.append( + StreamingChoices( + index=idx, + delta=Delta( + content=None, + role=getattr(delta, "role", None), + tool_calls=getattr(delta, "tool_calls", None), + ), + finish_reason=passthrough_finish, + ) + ) + return ModelResponseStream( + id=getattr(item, "id", None), + created=getattr(item, "created", None), + model=getattr(item, "model", None), + choices=synthetic_choices, + ) + + @staticmethod + def _record_finish_reasons(item: Any, finish_reason_per_choice: dict[int, str | None]) -> None: + for choice in getattr(item, "choices", None) or []: + finish_reason = getattr(choice, "finish_reason", None) + if finish_reason is not None: + finish_reason_per_choice[getattr(choice, "index", 0) or 0] = finish_reason + + @staticmethod + def _chunk_has_finish_reason(item: Any) -> bool: + choices = getattr(item, "choices", None) or [] + return any(getattr(choice, "finish_reason", None) is not None for choice in choices) + async def async_post_call_streaming_iterator_hook( self, user_api_key_dict: UserAPIKeyAuth, @@ -334,6 +837,10 @@ class UnifiedLLMGuardrails(CustomLogger): sampling_rate = _streaming_flag("streaming_sampling_rate", 5) # Only apply the guardrail at end of stream (not per chunk). end_of_stream_only = _streaming_flag("streaming_end_of_stream_only", False) + # "block_only" (default) drops guardrail text rewrites on the streaming + # path; "incremental_diff" emits them as synthetic deltas (see + # _run_incremental_transform_stream). + streaming_transform_mode = _streaming_flag("streaming_transform_mode", "block_only") # Withhold every chunk until end-of-stream moderation passes, then # release the original chunks (clean) or only the block message # (blocked) -- moderating the whole response *before* any content @@ -380,6 +887,35 @@ class UnifiedLLMGuardrails(CustomLogger): if endpoint_guardrail_translation_mappings is None: endpoint_guardrail_translation_mappings = load_guardrail_translation_mappings() + # Streaming text transformation (incremental_diff) diverges enough from the + # block_only path that it runs as its own iterator. It requires a route we + # can resolve up front to an OpenAI-chat handler (the only supported v1 + # surface); anything else falls back to the block_only behavior below. + if streaming_transform_mode == "incremental_diff": + transform_call_type = self._resolve_transform_call_type( + user_api_key_dict=user_api_key_dict, + mappings=endpoint_guardrail_translation_mappings, + ) + if transform_call_type is not None: + async for transformed_item in self._run_incremental_transform_stream( + guardrail_to_apply=guardrail_to_apply, + response=response, + request_data=request_data, + user_api_key_dict=user_api_key_dict, + call_type=transform_call_type, + sampling_rate=sampling_rate, + end_of_stream_only=end_of_stream_only, + mappings=endpoint_guardrail_translation_mappings, + ): + yield transformed_item + return + verbose_proxy_logger.warning( + "UnifiedLLMGuardrails: streaming_transform_mode=incremental_diff is only supported " + "for the OpenAI chat completions streaming path with a resolvable request route; " + "falling back to block_only for %s", + getattr(guardrail_to_apply, "guardrail_name", None), + ) + # Infer call type from first chunk call_type = None chunk_counter = 0 diff --git a/litellm/proxy/guardrails/guardrail_initializers.py b/litellm/proxy/guardrails/guardrail_initializers.py index e8abf66a6f7..14e76a21093 100644 --- a/litellm/proxy/guardrails/guardrail_initializers.py +++ b/litellm/proxy/guardrails/guardrail_initializers.py @@ -16,6 +16,10 @@ def initialize_bedrock(litellm_params: LitellmParams, guardrail: Guardrail): event_hook=litellm_params.mode, guardrailIdentifier=litellm_params.guardrailIdentifier, guardrailVersion=litellm_params.guardrailVersion, + checks=litellm_params.checks, + content_filter_threshold=litellm_params.content_filter_threshold, + prompt_attack_threshold=litellm_params.prompt_attack_threshold, + pii_confidence_threshold=litellm_params.pii_confidence_threshold, default_on=litellm_params.default_on, disable_exception_on_block=litellm_params.disable_exception_on_block, mask_request_content=litellm_params.mask_request_content, diff --git a/litellm/proxy/hooks/proxy_track_cost_callback.py b/litellm/proxy/hooks/proxy_track_cost_callback.py index b6342f4fa1a..b839426fcda 100644 --- a/litellm/proxy/hooks/proxy_track_cost_callback.py +++ b/litellm/proxy/hooks/proxy_track_cost_callback.py @@ -28,11 +28,20 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import ( ) from litellm.proxy.utils import ProxyUpdateSpend from litellm.types.utils import ( + CallTypes, StandardLoggingPayload, StandardLoggingPayloadErrorInformation, ) from litellm.utils import get_end_user_id_for_cost_tracking +_PASS_THROUGH_CALL_TYPES: frozenset[str] = frozenset( + { + CallTypes.pass_through.value, + CallTypes.llm_passthrough_route.value, + CallTypes.allm_passthrough_route.value, + } +) + class _ProxyDBLogger(CustomLogger): async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): @@ -219,11 +228,13 @@ class _ProxyDBLogger(CustomLogger): verbose_proxy_logger.debug( f"user_api_key {user_api_key}, user_id {user_id}, team_id {team_id}, end_user_id {end_user_id}" ) + call_type: Optional[str] = kwargs.get("call_type") if _should_track_cost_callback( user_api_key=user_api_key, user_id=user_id, team_id=team_id, end_user_id=end_user_id, + call_type=call_type, ): ## UPDATE DATABASE await _update_database_and_spend_counters( @@ -412,9 +423,15 @@ def _should_track_cost_callback( user_id: Optional[str], team_id: Optional[str], end_user_id: Optional[str], + call_type: Optional[str] = None, ) -> bool: """ Determine if the cost callback should be tracked based on the kwargs + + Pass-through endpoints can be configured with ``auth=false``, which leaves + the request with no key/user/team/end-user to attribute spend to. Those + requests still forward real provider traffic that operators expect to see + in request/usage logs, so they are tracked even when unauthenticated. """ # don't run track cost callback if user opted into disabling spend @@ -423,7 +440,7 @@ def _should_track_cost_callback( if user_api_key is not None or user_id is not None or team_id is not None or end_user_id is not None: return True - return False + return call_type in _PASS_THROUGH_CALL_TYPES def _get_budget_reservation_from_metadata(metadata: dict) -> Optional[dict]: diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index b128b0ea57e..df311bed7b2 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -42,7 +42,7 @@ from litellm.proxy._experimental.mcp_server.db import ( rotate_mcp_user_env_vars_master_key, ) from litellm.proxy._types import * -from litellm.proxy._types import LiteLLM_VerificationToken +from litellm.proxy._types import LiteLLM_VerificationToken, hash_token from litellm.proxy.auth.auth_checks import ( _delete_cache_key_object, can_team_access_model, @@ -468,7 +468,10 @@ def handle_key_type(data: GenerateKeyRequest, data_json: dict) -> dict: Handle the key type. """ key_type = data.key_type - data_json.pop("key_type", None) + if key_type is None: + data_json.pop("key_type", None) + return data_json + data_json["key_type"] = key_type.value if key_type == LiteLLMKeyType.LLM_API: data_json["allowed_routes"] = ["llm_api_routes"] elif key_type == LiteLLMKeyType.MANAGEMENT: @@ -3566,6 +3569,7 @@ async def generate_key_helper_fn( created_by: Optional[str] = None, updated_by: Optional[str] = None, allowed_routes: Optional[list] = None, + key_type: str | None = None, sso_user_id: Optional[str] = None, object_permission_id: Optional[str] = None, # object_permission_id <-> LiteLLM_ObjectPermissionTable object_permission: Optional[LiteLLM_ObjectPermissionBase] = None, @@ -3706,6 +3710,7 @@ async def generate_key_helper_fn( "created_by": created_by, "updated_by": updated_by, "allowed_routes": allowed_routes or [], + "key_type": key_type, "object_permission_id": object_permission_id, "router_settings": router_settings_json, "access_group_ids": access_group_ids or [], @@ -3772,7 +3777,10 @@ async def generate_key_helper_fn( return user_data ## CREATE KEY - verbose_proxy_logger.debug("prisma_client: Creating Key= %s", key_data) + verbose_proxy_logger.debug( + "prisma_client: Creating Key= %s", + {**key_data, "token": hash_token(token=token)}, + ) create_key_response = await prisma_client.insert_data(data=key_data, table_name="key") key_data["token_id"] = getattr(create_key_response, "token", None) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index cab2a51a8ca..288282dd08b 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -1475,6 +1475,7 @@ if MCP_AVAILABLE: temporary_server = await global_mcp_server_manager.build_mcp_server_from_table( temp_record, credentials_are_encrypted=False, + persist_discovered_endpoints=False, ) _cache_temporary_mcp_server( temporary_server, diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index f468f5ec30b..d267a3cac69 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -1621,6 +1621,7 @@ async def update_team( - allowed_passthrough_routes: Optional[List[str]] - List of allowed pass through routes for the team. - model_rpm_limit: Optional[Dict[str, int]] - The RPM (Requests Per Minute) limit per model for this team. Example: {"gpt-4": 100, "gpt-3.5-turbo": 200} - model_tpm_limit: Optional[Dict[str, int]] - The TPM (Tokens Per Minute) limit per model for this team. Example: {"gpt-4": 10000, "gpt-3.5-turbo": 20000} + - mcp_rpm_limit: Optional[Dict[str, int]] - Per-MCP-server RPM limit for this team, keyed by MCP server name (alias if set, else the configured name). Example: {"github": 100, "slack": 200}. Applied across all keys for this team. Example - update team TPM Limit - allowed_vector_store_indexes: Optional[List[dict]] - List of allowed vector store indexes for the key. Example - [{"index_name": "my-index", "index_permissions": ["write", "read"]}]. If specified, the key will only be able to use these specific vector store indexes. Create index, using `/v1/indexes` endpoint. - secret_manager_settings: Optional[dict] - Secret manager settings for the team. [Docs](https://docs.litellm.ai/docs/secret_managers/overview) @@ -3550,7 +3551,7 @@ async def team_info( try: team_info: Optional[BaseModel] = await TeamRepository(prisma_client).table.find_unique( where={"team_id": team_id}, - include={"object_permission": True}, + include={"litellm_model_table": True, "object_permission": True}, ) if team_info is None: raise Exception diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 065464aa565..0475566192e 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -241,13 +241,34 @@ def _check_cli_sso_start_rate_limit( def _get_cli_sso_flow_or_raise(login_id: Optional[str], cache: DualCache) -> dict: + if isinstance(login_id, str) and login_id.startswith("sk-"): + raise HTTPException( + status_code=400, + detail=( + "Your litellm CLI is out of date and uses a login flow this proxy no longer supports. " + "Upgrade it with `pip install -U 'litellm[proxy]'` and run `litellm-proxy login` again." + ), + ) if not _is_valid_cli_sso_login_id(login_id): - raise HTTPException(status_code=400, detail="Invalid CLI login session") + raise HTTPException(status_code=400, detail="Invalid CLI login session id") cache_key = _get_cli_sso_flow_cache_key(cast(str, login_id)) flow = cache.get_cache(key=cache_key) if not isinstance(flow, dict) or "poll_secret_hash" not in flow: - raise HTTPException(status_code=400, detail="Invalid CLI login session") + verbose_proxy_logger.warning( + "CLI SSO login session not found in cache for login_id=%s. If the proxy runs multiple replicas, " + "a shared Redis cache (enable_redis_auth_cache: true) is required for CLI login to work.", + login_id, + ) + raise HTTPException( + status_code=400, + detail=( + "CLI login session not found or expired. Run `litellm-proxy login` again. " + "If this happens immediately after starting a login, the proxy is likely running multiple " + "replicas without a shared cache; configure Redis with `enable_redis_auth_cache: true` " + "so every replica can see the login session." + ), + ) return flow @@ -4034,30 +4055,41 @@ class MicrosoftSSOHandler: base_url = MicrosoftSSOHandler.get_graph_api_base_url() # Endpoint to get app role assignments for the given service principal endpoint = f"/servicePrincipals/{service_principal_id}/appRoleAssignedTo" - url = base_url + endpoint + next_link: str | None = base_url + endpoint headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } - response = await async_client.get(url, headers=headers) - response_json = response.json() - verbose_proxy_logger.debug(f"Response from service principal app role assigned to: {response_json}") group_ids: List[str] = [] service_principal_teams: List[MicrosoftServicePrincipalTeam] = [] + page_count = 0 - for _object in response_json.get("value", []): - if _object.get("principalType") == "Group": - # Append the group ID to the list - group_ids.append(_object.get("principalId")) - # Append the service principal team to the list - service_principal_teams.append( - MicrosoftServicePrincipalTeam( - principalDisplayName=_object.get("principalDisplayName"), - principalId=_object.get("principalId"), + while next_link is not None and page_count < MicrosoftSSOHandler.MAX_GRAPH_API_PAGES: + response = await async_client.get(next_link, headers=headers) + response_json = response.json() + verbose_proxy_logger.debug(f"Response from service principal app role assigned to: {response_json}") + + for _object in response_json.get("value", []): + if _object.get("principalType") == "Group": + # Append the group ID to the list + group_ids.append(_object.get("principalId")) + # Append the service principal team to the list + service_principal_teams.append( + MicrosoftServicePrincipalTeam( + principalDisplayName=_object.get("principalDisplayName"), + principalId=_object.get("principalId"), + ) ) - ) + + next_link = response_json.get("@odata.nextLink") + page_count += 1 + + if next_link is not None and page_count >= MicrosoftSSOHandler.MAX_GRAPH_API_PAGES: + verbose_proxy_logger.warning( + f"Reached maximum page limit of {MicrosoftSSOHandler.MAX_GRAPH_API_PAGES}. Some service principal group assignments may not be included." + ) return group_ids, service_principal_teams diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py new file mode 100644 index 00000000000..72bac1bcd6f --- /dev/null +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -0,0 +1,234 @@ +""" +Counts billable HTTP requests on enterprise deployments. + +A billable request is an inbound request to an LLM inference, MCP, or A2A +endpoint that returns a 2xx status. The actual export happens in an injected +recorder (see litellm.proxy.enterprise_billing.billing_metrics); when no +recorder is injected (non-enterprise, or metering misconfigured) this +middleware is a transparent pass-through. +""" + +import re +import threading +from enum import Enum +from typing import Callable, Optional, Protocol, Sequence, runtime_checkable + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import LiteLLMRoutes + + +class BillableCategory(str, Enum): + LLM = "llm" + MCP = "mcp" + A2A = "a2a" + + +@runtime_checkable +class BillingRecorder(Protocol): + def record(self, *, category: BillableCategory, route: str, status_code: int, model_id: Optional[str]) -> None: ... + + +_MODEL_ID_HEADER = b"x-litellm-model-id" + +# Ordered: a longer suffix that shares an ending with a shorter one must come +# first, e.g. "/chat/completions" before "/completions". This is the POST +# inference surface that writes a SpendLogs row on success, so the exported +# count lines up with the admin UI usage page for inference traffic. Billing +# is a deliberate lower bound on SpendLogs rows: management writes that also +# log (batch/file/fine-tuning creation, interaction cancel) and non-POST calls +# that log (passthrough reads) never bill, so drift only ever undercounts. +_LLM_ROUTE_SUFFIXES: tuple[str, ...] = ( + "/chat/completions", + "/completions", + "/embeddings", + "/responses", + "/rerank", + "/moderations", + "/images/generations", + "/images/edits", + "/images/variations", + "/audio/transcriptions", + "/audio/translations", + "/audio/speech", + "/videos", # create; GET list is excluded by the POST gate + "/remix", # /v1/videos/{id}/remix + "/ocr", + "/search", # /v1/search and /v1/vector_stores/{id}/search + "/rag/query", + "/rag/ingest", + ":generateContent", # Gemini-native /v1beta/models/{model}:generateContent + ":streamGenerateContent", +) + +# Exact paths only: a suffix match would also catch non-inference resources that +# share the ending, e.g. the OpenAI Assistants route /v1/threads/{id}/messages +# writes no SpendLogs row and must not bill, unlike Anthropic /v1/messages. +_LLM_ROUTE_EXACT: tuple[str, ...] = ( + "/v1/messages", + "/interactions", # Google Interactions create; /{id} reads and /cancel do not match + "/v1beta/interactions", +) + +# Provider passthrough prefixes (e.g. /bedrock/..., /vertex-ai/...) carry real +# inference calls that write SpendLogs rows, so they bill. Anchored to the +# routes enum so new providers are picked up without touching this module. +# /langfuse forwards observability traffic, not inference: it writes no +# SpendLogs row and must not bill. +_NON_BILLABLE_PASSTHROUGH_PREFIXES = frozenset({"/langfuse"}) +_PASSTHROUGH_PREFIXES: tuple[str, ...] = tuple( + prefix + for prefix in LiteLLMRoutes.mapped_pass_through_routes.value + if prefix not in _NON_BILLABLE_PASSTHROUGH_PREFIXES +) + + +def _classify_llm_route(path: str) -> Optional[str]: + exact_match = next((route for route in _LLM_ROUTE_EXACT if path == route), None) + if exact_match is not None: + return exact_match + suffix_match = next((suffix for suffix in _LLM_ROUTE_SUFFIXES if path == suffix or path.endswith(suffix)), None) + if suffix_match is not None: + return suffix_match + # Deep passthrough paths only: the bare prefix itself is not an inference call. + return next((prefix for prefix in _PASSTHROUGH_PREFIXES if path.startswith(f"{prefix}/")), None) + + +_MCP_MANAGEMENT_PREFIX = "/v1/mcp" +_MCP_DYNAMIC_TRANSPORT = re.compile(r"/(?:toolset/)?[^/]+/mcp") +# The REST wrapper's tool-call endpoint executes a tool and fires the same MCP +# spend logging as the /mcp transport; its list/test siblings do not bill. +_MCP_REST_TOOL_CALL = "/mcp-rest/tools/call" + +_A2A_INVOKE_SUFFIX = "/message/send" +_A2A_TRANSPORT_PREFIXES: tuple[str, ...] = ("/v1/a2a/", "/a2a/") +# Bare POST /a2a/{agent_id} carries the JSON-RPC method in the body, not the +# path. Only message/send and message/stream write a SpendLogs row there; the +# task RPCs (tasks/get, tasks/cancel, tasks/pushNotificationConfig/*, ...) are +# forwarded upstream and write none. A path-only classifier cannot separate +# them, so the bare route does not bill: counting a task RPC would overcount, +# while missing a bare-path message/send only undercounts, and undercounting is +# the sole direction this metric is allowed to drift. The /mcp transport is +# method-agnostic by contrast because its list path logs a SpendLogs row too. + + +def _classify_mcp_route(path: str) -> Optional[str]: + if path == _MCP_MANAGEMENT_PREFIX or path.startswith(f"{_MCP_MANAGEMENT_PREFIX}/"): + return None + if path == "/mcp" or path.startswith("/mcp/"): + return "/mcp" + if path == _MCP_REST_TOOL_CALL: + return "/mcp" + if _MCP_DYNAMIC_TRANSPORT.fullmatch(path) is not None: + return "/mcp" + return None + + +def _classify_a2a_route(path: str) -> Optional[str]: + if path.endswith(_A2A_INVOKE_SUFFIX) and any(path.startswith(prefix) for prefix in _A2A_TRANSPORT_PREFIXES): + return "/a2a" + return None + + +def classify_billable_request(path: str, method: str = "POST") -> Optional[tuple[BillableCategory, str]]: + """Map a request path to its (category, normalized route), or None if not billable.""" + normalized = path.rstrip("/") or "/" + + mcp_route = _classify_mcp_route(normalized) + if mcp_route is not None: + return (BillableCategory.MCP, mcp_route) + + a2a_route = _classify_a2a_route(normalized) + if a2a_route is not None: + return (BillableCategory.A2A, a2a_route) + + # POST-only is a conservative gate: non-POST calls can still write a + # SpendLogs row (passthrough reads, resource GETs) but must not bill, so + # any classifier-vs-dashboard mismatch is an undercount, never an overcount. + if method.upper() != "POST": + return None + + llm_route = _classify_llm_route(normalized) + if llm_route is not None: + return (BillableCategory.LLM, llm_route) + return None + + +def _extract_model_id(headers: Sequence[tuple[bytes, bytes]]) -> Optional[str]: + return next( + (value.decode("latin-1") for name, value in headers if name.lower() == _MODEL_ID_HEADER and value), + None, + ) + + +class BillableRequestMetricsMiddleware: + """ + Pure ASGI middleware that records one billable request per 2xx response to a + billable endpoint. Modeled on InFlightRequestsMiddleware: it wraps `send`, + reads the final status and the x-litellm-model-id header off the + `http.response.start` message, and never blocks or fails the request path. + """ + + def __init__( + self, + app: ASGIApp, + recorder: Optional[BillingRecorder] = None, + recorder_factory: Optional[Callable[[], Optional[BillingRecorder]]] = None, + ) -> None: + self.app = app + self.recorder = recorder + # The factory defers recorder construction to the first request, AFTER the + # startup event has loaded the YAML config's environment_variables (license + # and cert env vars). Building at import time captured recorder=None for + # deployments configured that way. Resolved exactly once; the result + # (including None) is cached. + self._recorder_factory = recorder_factory + self._resolved = recorder_factory is None + self._resolve_lock = threading.Lock() + + def _resolve_recorder(self) -> Optional[BillingRecorder]: + if self._resolved: + return self.recorder + # The lock keeps concurrent first requests from each building their own + # MeterProvider (and leaking its background exporter thread). + with self._resolve_lock: + if not self._resolved: + factory = self._recorder_factory + self.recorder = factory() if factory is not None else self.recorder + self._resolved = True + return self.recorder + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + recorder = self._resolve_recorder() + if recorder is None: + await self.app(scope, receive, send) + return + + classification = classify_billable_request(scope.get("path", ""), scope.get("method", "POST")) + if classification is None: + await self.app(scope, receive, send) + return + + category, route = classification + status_code = 0 + model_id: Optional[str] = None + + async def send_wrapper(message: Message) -> None: + nonlocal status_code, model_id + if message["type"] == "http.response.start": + status_code = message["status"] + model_id = _extract_model_id(message.get("headers", [])) + await send(message) + + await self.app(scope, receive, send_wrapper) + + if 200 <= status_code < 300: + try: + recorder.record(category=category, route=route, status_code=status_code, model_id=model_id) + except Exception: # noqa: BLE001 -- metering must never fail a request that was already served + verbose_proxy_logger.warning("billable request metering failed for %s", route, exc_info=True) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 37f3d6e49e0..6661474d215 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -438,10 +438,30 @@ from litellm.proxy.management_helpers.audit_logs import ( create_object_audit_log, ) from litellm.proxy.memory.memory_endpoints import router as memory_router +from litellm.proxy.middleware.billable_request_metrics_middleware import ( + BillableRequestMetricsMiddleware, + BillingRecorder, +) from litellm.proxy.plugin_routes import ( - router as plugin_router, register_plugins_from_config, ) +from litellm.proxy.plugin_routes import ( + router as plugin_router, +) + +try: + from litellm.proxy.enterprise_billing.billing_metrics import ( + build_billing_metrics_recorder as _build_billing_metrics_recorder, + ) + from litellm.proxy.enterprise_billing.billing_metrics import ( + shutdown_billing_metrics_recorder as _shutdown_billing_metrics_recorder, + ) + + build_billing_metrics_recorder: Optional[Callable[..., Optional[BillingRecorder]]] = _build_billing_metrics_recorder + shutdown_billing_metrics_recorder: Optional[Callable[[], None]] = _shutdown_billing_metrics_recorder +except ImportError: + build_billing_metrics_recorder = None + shutdown_billing_metrics_recorder = None from litellm.proxy.middleware.in_flight_requests_middleware import ( InFlightRequestsMiddleware, ) @@ -461,13 +481,11 @@ from litellm.proxy.openai_files_endpoints.files_endpoints import ( ) from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( passthrough_endpoint_router, + vertex_ai_live_websocket_passthrough, ) from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( router as llm_passthrough_router, ) -from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( - vertex_ai_live_websocket_passthrough, -) from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( initialize_pass_through_endpoints, ) @@ -552,21 +570,19 @@ from litellm.types.proxy.management_endpoints.ui_sso import ( from litellm.types.realtime import RealtimeQueryParams from litellm.types.router import ( DeploymentTypedDict, -) -from litellm.types.router import ModelInfo as RouterModelInfo -from litellm.types.router import ( RouterGeneralSettings, + RoutingPlugin, SearchToolTypedDict, updateDeployment, ) +from litellm.types.router import ModelInfo as RouterModelInfo from litellm.types.scheduler import DefaultPriorities from litellm.types.secret_managers.main import ( KeyManagementSettings, KeyManagementSystem, ) -from litellm.types.utils import CredentialItem, CustomHuggingfaceTokenizer +from litellm.types.utils import CredentialItem, CustomHuggingfaceTokenizer, RawRequestTypedDict, StandardLoggingPayload from litellm.types.utils import ModelInfo as ModelMapInfo -from litellm.types.utils import RawRequestTypedDict, StandardLoggingPayload from litellm.utils import _add_custom_logger_callback_to_specific_event try: @@ -767,6 +783,11 @@ async def proxy_shutdown_event(): if db_writer_client is not None: await db_writer_client.close() # type: ignore[reportGeneralTypeIssues] + # final flush of billable-request counts: without it, up to one export + # interval of enterprise billing data is dropped on every restart + if shutdown_billing_metrics_recorder is not None: + shutdown_billing_metrics_recorder() + # flush remaining langfuse logs if "langfuse" in litellm.success_callback: try: @@ -972,11 +993,11 @@ async def proxy_startup_event(app: FastAPI): if is_otel_v2_enabled(): from opentelemetry import trace as _otel_trace - from litellm.litellm_core_utils.litellm_logging import _in_memory_loggers from litellm.integrations.otel.logger import ( OpenTelemetryV2, publish_global_otel_v2_provider, ) + from litellm.litellm_core_utils.litellm_logging import _in_memory_loggers registered = open_telemetry_logger if isinstance(open_telemetry_logger, OpenTelemetryV2) else None publish_global_otel_v2_provider( @@ -1780,6 +1801,31 @@ app.add_middleware( ) app.add_middleware(PrometheusAuthMiddleware) +# Added before InFlightRequestsMiddleware so it nests *inside* it: Starlette +# makes the last-added middleware outermost. The billable count is recorded +# after the inner app returns, so if this sat outside the in-flight tracker a +# request could be counted as drained while its record() had not yet run, and +# proxy_shutdown_event could flush and stop the exporter underneath it. +app.add_middleware( + BillableRequestMetricsMiddleware, + # Factory, not an instance: the recorder is resolved on the first request so + # it sees premium_user and the billing env vars AFTER proxy_startup_event has + # loaded the YAML config's environment_variables. Building it here at import + # time would permanently capture recorder=None for YAML-configured + # deployments. The lambda reads the module globals at call time. + recorder_factory=lambda: ( + build_billing_metrics_recorder( + premium=premium_user, + # Read from the license check, not the premium_user_data module + # global: that global is bound once at import and goes stale when + # the license arrives via the YAML config's environment_variables. + license_data=_license_check.airgapped_license_data, + litellm_version=version, + ) + if build_billing_metrics_recorder is not None + else None + ), +) app.add_middleware(InFlightRequestsMiddleware) app.add_middleware(SecurityHeadersMiddleware) @@ -3660,6 +3706,45 @@ def _attach_redis_usage_cache(redis_cache: RedisCache, enable_redis_auth_cache: litellm_config_cache.redis_cache = redis_cache +def resolve_complexity_router_plugins( + model_name: str, + complexity_router_config: dict, + config_file_path: str | None, +) -> None: + """ + Resolves `complexity_router_config["plugins"]` dotted-path strings to live + instances via `get_instance_fn` (the same convention `litellm_settings.callbacks` + uses), in place. Raises at config-load time if a path resolves to something that + doesn't implement `RoutingPlugin`, rather than deferring to a confusing + `AttributeError` on the first request that reaches the plugin pipeline. + """ + plugin_paths = complexity_router_config.get("plugins") + if not isinstance(plugin_paths, list): + return + + resolved_plugins = [ + get_instance_fn(value=plugin_path, config_file_path=config_file_path) + if isinstance(plugin_path, str) + else plugin_path + for plugin_path in plugin_paths + ] + for plugin_path, resolved_plugin in zip(plugin_paths, resolved_plugins): + # `@runtime_checkable` only checks that `run` exists as an attribute, not that + # it's a coroutine function -- a synchronous `def run(self, context)` would pass + # isinstance() here and only fail at request time with a confusing `TypeError: + # object RoutingContext can't be used in 'await' expression`. + if not isinstance(resolved_plugin, RoutingPlugin) or not inspect.iscoroutinefunction( + getattr(resolved_plugin, "run", None) + ): + raise ValueError( + f"complexity_router_config.plugins entry {plugin_path!r} on model {model_name!r} " + f"resolved to {resolved_plugin!r}, which does not implement the RoutingPlugin " + "interface (an async `run(context)` method). Fix the referenced module before " + "starting the proxy." + ) + complexity_router_config["plugins"] = resolved_plugins + + class ProxyConfig: """ Abstraction class on top of config loading/updating logic. Gives us one place to control all config updating logic. @@ -4720,6 +4805,13 @@ class ProxyConfig: for k, v in model["litellm_params"].items(): if isinstance(v, str) and v.startswith("os.environ/"): model["litellm_params"][k] = get_secret(v) + complexity_router_config = model["litellm_params"].get("complexity_router_config") + if isinstance(complexity_router_config, dict): + resolve_complexity_router_plugins( + model_name=model.get("model_name", ""), + complexity_router_config=complexity_router_config, + config_file_path=config_file_path, + ) print(f"\033[32m {model.get('model_name', '')}\033[0m") # noqa: T201 litellm_model_name = model["litellm_params"]["model"] litellm_model_api_base = model["litellm_params"].get("api_base", None) @@ -14805,6 +14897,7 @@ async def get_config_list( "forward_client_headers_to_llm_api": {"type": "Boolean"}, "mcp_required_fields": {"type": "List"}, "cancel_on_disconnect": {"type": "Boolean"}, + "skip_user_budget_on_team_key": {"type": "Boolean"}, } return_val = [] diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index 8c980f33b01..94871ff072d 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -6,6 +6,7 @@ from fastapi import HTTPException, status import litellm from litellm.proxy._types import UserAPIKeyAuth +from litellm.router_utils.common_utils import _is_proxy_admin_request # Router-internal mock_testing_* flag names — kept in sync with # ``litellm.types.router.MockRouterTestingParams`` by the test @@ -363,6 +364,7 @@ async def route_request( team_id = get_team_id_from_data(data) router_model_names = llm_router.model_names if llm_router is not None else [] + is_proxy_admin_without_team = team_id is None and _is_proxy_admin_request(data) # Preprocess Google GenAI generate content requests if route_type in ["agenerate_content", "agenerate_content_stream"]: @@ -517,6 +519,13 @@ async def route_request( data["model"] = team_model_name return getattr(llm_router, f"{route_type}")(**data) + elif ( + is_proxy_admin_without_team + and data["model"] not in router_model_names + and data["model"] in llm_router.team_public_model_names + ): + return getattr(llm_router, f"{route_type}")(**data) + elif data["model"] in router_model_names or llm_router.has_model_id(data["model"]): return getattr(llm_router, f"{route_type}")(**data) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index fb4d8d0b5a3..a23cecc3911 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -422,6 +422,7 @@ model LiteLLM_VerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") @@ -516,6 +517,7 @@ model LiteLLM_DeletedVerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py index 38fd0d3f343..e1a093a4a48 100644 --- a/litellm/proxy/spend_tracking/budget_reservation.py +++ b/litellm/proxy/spend_tracking/budget_reservation.py @@ -123,6 +123,7 @@ async def reserve_budget_for_request( proxy_logging_obj: ProxyLogging, end_user_id: Optional[str] = None, end_user_object: Optional[Any] = None, + skip_user_budget_on_team_key: bool = False, ) -> Optional[dict]: if valid_token is None or not RouteChecks.is_llm_api_route(route=route): return None @@ -141,6 +142,7 @@ async def reserve_budget_for_request( proxy_logging_obj=proxy_logging_obj, end_user_id=end_user_id, end_user_object=end_user_object, + skip_user_budget_on_team_key=skip_user_budget_on_team_key, ) if not counters: return None @@ -296,6 +298,7 @@ async def _get_budget_counters( proxy_logging_obj: ProxyLogging, end_user_id: Optional[str] = None, end_user_object: Optional[Any] = None, + skip_user_budget_on_team_key: bool = False, ) -> List[_BudgetCounter]: counters: List[_BudgetCounter] = [] @@ -344,8 +347,9 @@ async def _get_budget_counters( ) ) + is_team_key = team_object is not None and team_object.team_id is not None if ( - (team_object is None or team_object.team_id is None) + not (is_team_key and skip_user_budget_on_team_key) and user_object is not None and user_object.user_id is not None and user_object.max_budget is not None diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index d7649b524aa..62fc28256cd 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -3592,7 +3592,10 @@ class PrismaClient: """ start_time = time.time() try: - verbose_proxy_logger.debug("PrismaClient: insert_data: %s", data) + verbose_proxy_logger.debug( + "PrismaClient: insert_data: %s", + {**data, "token": self.hash_token(token=data["token"])} if data.get("token") is not None else data, + ) if table_name == "key": token = data["token"] hashed_token = self.hash_token(token=token) diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 92dd5a513b9..12f9be970c7 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -261,7 +261,7 @@ async def aresponses_api_with_mcp( pre_processed_mcp_tools=original_mcp_tools, ) - return LiteLLM_Proxy_MCP_Handler._create_mcp_streaming_response( + mcp_streaming_response = LiteLLM_Proxy_MCP_Handler._create_mcp_streaming_response( input=input, model=model, all_tools=all_tools, @@ -272,6 +272,10 @@ async def aresponses_api_with_mcp( tool_server_map=tool_server_map, **kwargs, ) + await mcp_streaming_response._create_initial_response_iterator() + if mcp_streaming_response._initial_creation_error is not None: + raise mcp_streaming_response._initial_creation_error + return mcp_streaming_response # Determine if we should auto-execute tools should_auto_execute = bool(mcp_tools_with_litellm_proxy) and LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools( diff --git a/litellm/responses/mcp/mcp_streaming_iterator.py b/litellm/responses/mcp/mcp_streaming_iterator.py index c705a04963c..d6b45855be5 100644 --- a/litellm/responses/mcp/mcp_streaming_iterator.py +++ b/litellm/responses/mcp/mcp_streaming_iterator.py @@ -5,6 +5,8 @@ from litellm._uuid import uuid from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator from litellm.types.llms.openai import ( BaseLiteLLMOpenAIResponseObject, + ErrorEvent, + ErrorEventError, MCPCallArgumentsDeltaEvent, MCPCallArgumentsDoneEvent, MCPCallCompletedEvent, @@ -316,6 +318,11 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): # Cache the response ID to ensure consistency across all events self._cached_response_id: Optional[str] = None + self._initial_creation_error: Exception | None = None + self._stream_error: Exception | None = None + self._error_event_emitted = False + self._last_sequence_number = 0 + def _extract_mcp_headers_from_params(self) -> None: """Extract MCP headers from original request params to pass to tool calls""" from typing import Dict, Optional @@ -380,10 +387,31 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): return LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools(self.mcp_tools_with_litellm_proxy) + def _make_stream_error_event(self) -> ResponsesAPIStreamingResponse: + err = self._stream_error + status_code = getattr(err, "status_code", None) + return ErrorEvent( + type=ResponsesAPIStreamEvents.ERROR, + sequence_number=self._last_sequence_number + 1, + error=ErrorEventError( + type="mcp_gateway_error", + code=str(status_code) if status_code is not None else "internal_error", + message=str(err) if err is not None else "MCP gateway stream failed", + param=None, + ), + ) + def __aiter__(self): return self async def __anext__(self) -> ResponsesAPIStreamingResponse: + chunk = await self._anext_impl() + sequence_number = getattr(chunk, "sequence_number", None) + if isinstance(sequence_number, int) and sequence_number > self._last_sequence_number: + self._last_sequence_number = sequence_number + return chunk + + async def _anext_impl(self) -> ResponsesAPIStreamingResponse: """ Phase-based streaming: 1. initial_response - Stream the first LLM response (includes response.created, response.in_progress, response.output_item.added) @@ -438,10 +466,16 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): self.phase = "continue_initial_response" return await self.__anext__() self.phase = "finished" + if self._stream_error is not None and not self._error_event_emitted: + self._error_event_emitted = True + return self._make_stream_error_event() raise StopAsyncIteration # Phase 6: Finished if self.phase == "finished": + if self._stream_error is not None and not self._error_event_emitted: + self._error_event_emitted = True + return self._make_stream_error_event() raise StopAsyncIteration # Should not reach here @@ -530,6 +564,12 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): chunk = await cast(Any, self.base_iterator).__anext__() # type: ignore[attr-defined] + if self._cached_response_id is None and hasattr(chunk, "response"): + new_response = getattr(chunk, "response", None) + new_response_id = getattr(new_response, "id", None) if new_response is not None else None + if new_response_id: + self._cached_response_id = new_response_id + # Ensure response ID consistency - update chunk if needed if self._cached_response_id and hasattr(chunk, "response"): response_obj = getattr(chunk, "response", None) @@ -589,6 +629,8 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): traceback.print_exc() self.base_iterator = None + self._initial_creation_error = e + self._stream_error = e # Don't set phase to "finished" here — let __anext__ emit any # pre-generated MCP discovery events before ending the iteration. @@ -761,6 +803,7 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): if hasattr(follow_up_response, "__aiter__"): self.base_iterator = follow_up_response self.collected_response = None + self._cached_response_id = None except Exception as e: verbose_logger.error(f"Error creating follow-up iterator: {e}") @@ -768,6 +811,7 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): traceback.print_exc() self.base_iterator = None + self._stream_error = e def __iter__(self): return self diff --git a/litellm/router.py b/litellm/router.py index 6539d3c0c43..46794ac7a24 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -26,6 +26,7 @@ from typing import ( AsyncGenerator, Callable, Dict, + FrozenSet, Generator, List, Literal, @@ -108,6 +109,7 @@ from litellm.router_utils.clientside_credential_handler import ( is_clientside_credential, ) from litellm.router_utils.common_utils import ( + _is_proxy_admin_request, filter_team_based_models, filter_web_search_deployments, ) @@ -494,6 +496,7 @@ class Router: self.model_name_to_deployment_indices: Dict[str, List[int]] = {} # Maps (team_id, team_public_model_name) -> list of indices in model_list self.team_model_to_deployment_indices: Dict[Tuple[str, str], List[int]] = {} + self.team_public_model_names: FrozenSet[str] = frozenset() # Initialize cache attributes that ``_invalidate_model_group_info_cache`` # touches *before* the first ``set_model_list`` below (which calls @@ -7284,6 +7287,14 @@ class Router: raise e return returned_healthy_deployments + @staticmethod + def _json_default_stable_id(value: object) -> str: + """json.dumps default= for _generate_model_id: plain str() on an arbitrary + object (e.g. a RoutingPlugin instance) falls back to object.__repr__'s + ``, so the hash -- and deployment id -- would + change every restart. Use the class name instead, stable across restarts.""" + return f"{type(value).__module__}.{type(value).__qualname__}" + def _generate_model_id(self, model_group: str, litellm_params: dict): """ Helper function to consistently generate the same id for a deployment @@ -7299,14 +7310,14 @@ class Router: if isinstance(k, str): parts.append(k) elif isinstance(k, dict): - parts.append(json.dumps(k)) + parts.append(json.dumps(k, default=self._json_default_stable_id)) else: parts.append(str(k)) if isinstance(v, str): parts.append(v) elif isinstance(v, dict): - parts.append(json.dumps(v)) + parts.append(json.dumps(v, default=self._json_default_stable_id)) else: parts.append(str(v)) @@ -7605,13 +7616,13 @@ class Router: model_name = entry.get("model_name") if isinstance(entry, dict) else entry.model_name if not model_name or not lp: continue - if model_name in self.adaptive_routers: - continue deployment = Deployment( model_name=model_name, litellm_params=(lp if not isinstance(lp, dict) else LiteLLM_Params(**lp)), model_info=(entry.get("model_info") if isinstance(entry, dict) else entry.model_info), ) + if model_name in self.adaptive_routers: + continue self.init_adaptive_router_deployment(deployment=deployment) for model_name, complexity_router in self.complexity_routers.items(): @@ -7792,6 +7803,7 @@ class Router: self.model_id_to_deployment_index_map = {} # Reset the index self.model_name_to_deployment_indices = {} # Reset the model_name index self.team_model_to_deployment_indices = {} # Reset the team_model index + self.team_public_model_names = frozenset() # Reset per-strategy router registries so hot-reload doesn't leave # stale routers pointing at the old model_list. self.quality_routers = {} @@ -8143,6 +8155,9 @@ class Router: self.team_model_to_deployment_indices[key] = updated_indices else: del self.team_model_to_deployment_indices[key] + self.team_public_model_names = frozenset( + public_model_name for _, public_model_name in self.team_model_to_deployment_indices + ) def _update_team_model_index(self, model: dict, idx: int) -> None: """ @@ -8156,6 +8171,7 @@ class Router: team_public_model_name = (model.get("model_info") or {}).get("team_public_model_name") if team_id and team_public_model_name: key = (team_id, team_public_model_name) + self.team_public_model_names = self.team_public_model_names | frozenset({team_public_model_name}) if key not in self.team_model_to_deployment_indices: self.team_model_to_deployment_indices[key] = [] if idx not in self.team_model_to_deployment_indices[key]: @@ -9110,6 +9126,7 @@ class Router: """ self.model_name_to_deployment_indices.clear() self.team_model_to_deployment_indices.clear() + self.team_public_model_names = frozenset() for idx, model in enumerate(model_list): model_name = model.get("model_name") @@ -10018,7 +10035,10 @@ class Router: return [m for m in self.model_list if m["litellm_params"]["model"] == model] def _try_early_resolve_deployments_for_model_not_in_names( - self, model: str, request_team_id: Optional[str] + self, + model: str, + request_team_id: Optional[str], + include_team_models: bool = False, ) -> Optional[Tuple[str, Union[List, Dict]]]: """ When ``model`` is not in ``self.model_names``, try team routes, pattern routes, @@ -10033,6 +10053,30 @@ class Router: team_deployments = self._get_all_deployments(model_name=model, team_id=request_team_id) if team_deployments: return model, team_deployments + elif include_team_models: + team_deployments = [ + self.model_list[index] + for (_, public_model_name), indices in self.team_model_to_deployment_indices.items() + if public_model_name == model + for index in indices + ] + team_ids = { + team_id + for deployment in team_deployments + for team_id in [(deployment.get("model_info") or {}).get("team_id")] + if team_id is not None + } + if len(team_ids) > 1: + raise litellm.BadRequestError( + message=( + f"Model name '{model}' matches deployments from multiple teams. " + "Specify the deployment ID directly to disambiguate." + ), + model=model, + llm_provider="", + ) + if team_deployments: + return model, team_deployments pattern_deployments = self.pattern_router.get_deployments_by_pattern( model=model, @@ -10097,7 +10141,11 @@ class Router: if _model_from_alias is not None: model = _model_from_alias - early = self._try_early_resolve_deployments_for_model_not_in_names(model=model, request_team_id=request_team_id) + early = self._try_early_resolve_deployments_for_model_not_in_names( + model=model, + request_team_id=request_team_id, + include_team_models=_is_proxy_admin_request(request_kwargs), + ) if early is not None: return early @@ -10707,56 +10755,39 @@ class Router: if self.routing_plugins: await self._run_routing_plugins(model=model, request_kwargs=request_kwargs, messages=messages) - ######################################################### - # Check if any auto-router should be used - ######################################################### - if model in self.auto_routers: - return await self.auto_routers[model].async_pre_routing_hook( - model=model, - request_kwargs=request_kwargs, - messages=messages, - input=input, - specific_deployment=specific_deployment, - ) + router_strategy = ( + self.auto_routers.get(model) + or self.complexity_routers.get(model) + or self.adaptive_routers.get(model) + or self.quality_routers.get(model) + ) + if router_strategy is None: + return None - ######################################################### - # Check if any complexity-router should be used - ######################################################### - if model in self.complexity_routers: - return await self.complexity_routers[model].async_pre_routing_hook( - model=model, - request_kwargs=request_kwargs, - messages=messages, - input=input, - specific_deployment=specific_deployment, - ) + pre_routing_hook_response = await router_strategy.async_pre_routing_hook( + model=model, + request_kwargs=request_kwargs, + messages=messages, + input=input, + specific_deployment=specific_deployment, + ) - ######################################################### - # Check if an adaptive-router should be used - ######################################################### - adaptive_router = self.adaptive_routers.get(model) - if adaptive_router is not None: - return await adaptive_router.async_pre_routing_hook( - model=model, - request_kwargs=request_kwargs, - messages=messages, - input=input, - specific_deployment=specific_deployment, - ) + # `model` (the alias, e.g. "smart-router") is never the deployment actually + # called - apply the alias's own litellm_params (besides `model` itself, + # which is just the alias marker) to the request, since the tier/route + # deployment the hook selected won't have them. Router-only fields + # (tpm, rpm, weight, complexity_router_config, ...) are excluded from the + # actual outbound LLM call downstream by litellm.types.utils.all_litellm_params, + # not here. + if pre_routing_hook_response is not None: + alias_index = self.model_name_to_deployment_indices.get(model, []) + if alias_index: + alias_litellm_params = self.model_list[alias_index[0]].get("litellm_params", {}) + for key, value in alias_litellm_params.items(): + if key != "model" and value is not None: + request_kwargs.setdefault(key, value) - ######################################################### - # Check if any quality-router should be used - ######################################################### - if model in self.quality_routers: - return await self.quality_routers[model].async_pre_routing_hook( - model=model, - request_kwargs=request_kwargs, - messages=messages, - input=input, - specific_deployment=specific_deployment, - ) - - return None + return pre_routing_hook_response def get_available_deployment( self, diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index bebdbba90ef..eb0f74a58e7 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -18,7 +18,7 @@ from __future__ import annotations import asyncio import random import re -from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Literal, Union, cast from pydantic import BaseModel @@ -468,6 +468,38 @@ class ComplexityRouter(CustomLogger): def _tier_pools(self) -> dict[str, list[str]]: return {tier: (models if isinstance(models, list) else [models]) for tier, models in self.config.tiers.items()} + async def _pick_model_for_tier( + self, + tier: ComplexityTier, + raw_messages: list[dict[str, Any]] | None, + resolved_messages: list[dict[str, Any]] | None, + request_kwargs: dict, + ) -> str: + if not self.config.plugins: + return self.get_model_for_tier(tier) + + from litellm.types.router import RoutingContext + + tier_key = tier.value + metadata_key = "litellm_metadata" if "litellm_metadata" in request_kwargs else "metadata" + context = RoutingContext( + raw_messages=raw_messages or [], + structured_messages=resolved_messages or [], + candidate_models=list(self._tier_pools().get(tier_key, [])), + metadata=request_kwargs.get(metadata_key) or {}, + ) + for plugin in self.config.plugins: + context = await plugin.run(context) + + if not context.candidate_models: + # A plugin narrowing a tier to zero candidates is a policy decision (e.g. no + # model this tenant's budget allows) -- falling back to default_model here + # (which was never checked against the plugins) would let that policy be + # silently bypassed. Raise instead, matching the Router-level plugin + # pipeline's own fail-closed behavior for the same situation. + raise ValueError(f"No candidate models left for tier {tier_key} after routing-plugin filtering") + return self._pick_from_tier_value(context.candidate_models, tier_key) + def _ensure_adaptive_router(self) -> Any | None: if not self.config.adaptive: return None @@ -809,6 +841,44 @@ class ComplexityRouter(CustomLogger): return user_message, system_prompt + @staticmethod + def _iter_metadata_dicts(request_kwargs: dict) -> list[dict]: + """Metadata may land on `metadata` or `litellm_metadata` depending on the + endpoint, mirroring DeploymentAffinityCheck's precedence.""" + return [ + metadata + for metadata_key in ("litellm_metadata", "metadata") + if isinstance(metadata := request_kwargs.get(metadata_key), dict) + ] + + @staticmethod + def _get_session_id_from_request_kwargs(request_kwargs: dict) -> str | None: + """Resolve a client-supplied session_id.""" + for metadata in ComplexityRouter._iter_metadata_dicts(request_kwargs): + session_id = metadata.get("session_id") + if session_id is not None: + return str(session_id) + return None + + @staticmethod + def _get_user_api_key_hash_from_request_kwargs(request_kwargs: dict) -> str | None: + """Resolve the proxy-derived API key hash, the same trust boundary + DeploymentAffinityCheck uses for its own key-based affinity (not the + client-supplied OpenAI `user` param, which isn't authenticated).""" + for metadata in ComplexityRouter._iter_metadata_dicts(request_kwargs): + user_key = metadata.get("user_api_key_hash") + if user_key is not None: + return str(user_key) + return None + + def _get_session_affinity_cache_key(self, session_id: str, request_kwargs: dict) -> str: + # Namespace by the caller's API key hash so two different callers reusing the + # same client-supplied session_id can't poison each other's routing pin. Falls + # back to "unscoped" only when there's no authenticated caller to scope by + # (e.g. direct Router usage without the proxy layer). + caller_scope = self._get_user_api_key_hash_from_request_kwargs(request_kwargs) or "unscoped" + return f"complexity_router_session_affinity:v1:{self.model_name}:{caller_scope}:{session_id}" + async def async_pre_routing_hook( self, model: str, @@ -816,10 +886,76 @@ class ComplexityRouter(CustomLogger): messages: list[dict[str, Any]] | None = None, input: Union[str, list] | None = None, specific_deployment: bool | None = False, - ) -> Optional[PreRoutingHookResponse]: + ) -> PreRoutingHookResponse | None: """ Pre-routing hook called before the routing decision. + When `session_affinity` is enabled and a session_id is resolvable on the request, + pins the model chosen on the session's first turn and reuses it for every later + turn, skipping classification entirely. Otherwise delegates to `_classify_and_route`. + + Skipped entirely when `plugins` are configured: reusing a stale pin would bypass + the plugin pipeline on every turn after the first, since a pinned model was never + re-checked against a policy plugin whose decision can change between turns (e.g. a + budget plugin, once the session's spend crosses its cap). + """ + from litellm.types.router import PreRoutingHookResponse + + use_session_affinity = self.config.session_affinity and not self.config.plugins + session_id = self._get_session_id_from_request_kwargs(request_kwargs) if use_session_affinity else None + cache_key = self._get_session_affinity_cache_key(session_id, request_kwargs) if session_id is not None else None + + if cache_key is not None: + pinned_model = await self.litellm_router_instance.cache.async_get_cache(key=cache_key) + if isinstance(pinned_model, str): + # Refresh the TTL on every hit so an active session doesn't lose its + # pin mid-conversation just because it outlives the original write. + await self.litellm_router_instance.cache.async_set_cache( + key=cache_key, + value=pinned_model, + ttl=self.config.session_affinity_ttl_seconds, + ) + if self.config.adaptive: + from litellm.router_strategy.adaptive_router.config import ( + ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY, + ) + + kwargs_metadata = request_kwargs.setdefault("metadata", {}) + if isinstance(kwargs_metadata, dict): + kwargs_metadata[ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY] = pinned_model + verbose_router_logger.info( + f"ComplexityRouter: routing decision cause=session_affinity_pin, routed_model={pinned_model}" + ) + has_original_messages = messages is not None and len(messages) > 0 + return PreRoutingHookResponse( + model=pinned_model, + messages=messages if has_original_messages else None, + ) + + response = await self._classify_and_route( + model=model, + request_kwargs=request_kwargs, + messages=messages, + input=input, + specific_deployment=specific_deployment, + ) + if cache_key is not None and response is not None: + await self.litellm_router_instance.cache.async_set_cache( + key=cache_key, + value=response.model, + ttl=self.config.session_affinity_ttl_seconds, + ) + return response + + async def _classify_and_route( + self, + model: str, + request_kwargs: dict, + messages: list[dict[str, Any]] | None = None, + input: Union[str, list] | None = None, + specific_deployment: bool | None = False, + ) -> PreRoutingHookResponse | None: + """ Classifies the request by complexity and returns the appropriate model. Supports chat completions (messages), Responses API (input), and other formats via the guardrail translation handler dispatch. @@ -849,14 +985,26 @@ class ComplexityRouter(CustomLogger): if user_message is None: verbose_router_logger.debug("ComplexityRouter: No user message found, routing to default model") + if not self.config.plugins and self.config.default_model: + # No plugins configured: preserve the pre-existing default_model-first + # priority exactly (changing it would be a silent behavior change for + # every non-plugin user, not just a security fix). + routed_model = self.config.default_model + else: + # Plugins configured: default_model must never bypass them, so it's not + # checked here at all -- _pick_model_for_tier -> get_model_for_tier still + # falls back to it (after the MEDIUM tier) once the plugin pipeline runs. + routed_model = await self._pick_model_for_tier( + ComplexityTier.MEDIUM, messages, resolved_messages, request_kwargs + ) return PreRoutingHookResponse( - model=self.config.default_model or self.get_model_for_tier(ComplexityTier.MEDIUM), + model=routed_model, messages=messages if has_original_messages else None, ) override_tier = await self._resolve_keyword_tier_override(user_message, request_kwargs) if override_tier is not None: - routed_model = self.get_model_for_tier(override_tier) + routed_model = await self._pick_model_for_tier(override_tier, messages, resolved_messages, request_kwargs) cause = "semantic_keyword_match" if self.config.semantic_keyword_matching else "literal_keyword_match" verbose_router_logger.info( f"ComplexityRouter: routing decision cause={cause}, " @@ -882,7 +1030,7 @@ class ComplexityRouter(CustomLogger): f"signals={signals}, routed_model={routed_model}" ) else: - routed_model = self.get_model_for_tier(tier) + routed_model = await self._pick_model_for_tier(tier, messages, resolved_messages, request_kwargs) verbose_router_logger.info( f"ComplexityRouter: routing decision cause=complexity_scorer, tier={tier.value}, " f"score={score:.3f}, signals={signals}, routed_model={routed_model}" diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index df699d1a059..b7ffa2866f2 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -10,7 +10,7 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from litellm.types.router import AdaptiveRouterWeights +from litellm.types.router import AdaptiveRouterWeights, RoutingPlugin class ComplexityTier(str, Enum): @@ -361,7 +361,26 @@ class ComplexityRouterConfig(BaseModel): description="Minimum cosine similarity for a semantic keyword match", ) - model_config = ConfigDict(extra="allow") # Allow additional fields + # Session affinity: pin the first turn's routed model for the rest of the session + session_affinity: bool = Field( + default=False, + description=( + "When True and a session_id is resolvable on the request, pin the model chosen on the " + "session's first turn and reuse it for every later turn, skipping re-classification." + ), + ) + session_affinity_ttl_seconds: int = Field( + default=3600, + gt=0, + description="TTL for the session affinity pin; refreshed on every cache hit", + ) + + plugins: list[RoutingPlugin] | None = Field( + default=None, + description="RoutingPlugin instances that narrow the classified tier's candidate models before selection", + ) + + model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) # Allow additional fields @field_validator("tiers", mode="before") @classmethod @@ -407,6 +426,15 @@ class ComplexityRouterConfig(BaseModel): raise ValueError("keyword_tier_rules must be non-empty when semantic_keyword_matching is enabled") return self + @model_validator(mode="after") + def _validate_plugins_adaptive_combo(self) -> "ComplexityRouterConfig": + if self.plugins and self.adaptive: + raise ValueError( + "plugins and adaptive=True cannot both be set: adaptive's bandit selection doesn't yet " + "consume plugin-narrowed candidate pools. Disable adaptive or remove plugins." + ) + return self + # Combined default config DEFAULT_COMPLEXITY_CONFIG = ComplexityRouterConfig() diff --git a/litellm/router_utils/common_utils.py b/litellm/router_utils/common_utils.py index 18bce2348f6..5cfea5e3bf2 100644 --- a/litellm/router_utils/common_utils.py +++ b/litellm/router_utils/common_utils.py @@ -1,14 +1,27 @@ import hashlib import json +from collections.abc import Mapping from typing import TYPE_CHECKING, Dict, List, Optional, Union if TYPE_CHECKING: from litellm.types.llms.openai import OpenAIFileObject +from litellm.exceptions import BadRequestError from litellm.types.router import CredentialLiteLLMParams from litellm._logging import verbose_logger +def _is_proxy_admin_request(request_kwargs: Optional[Mapping[str, object]]) -> bool: + if request_kwargs is None: + return False + metadata_value = request_kwargs.get("metadata") + litellm_metadata_value = request_kwargs.get("litellm_metadata") + metadata = metadata_value if isinstance(metadata_value, Mapping) else {} + litellm_metadata = litellm_metadata_value if isinstance(litellm_metadata_value, Mapping) else {} + user_api_key_auth = metadata.get("user_api_key_auth") or litellm_metadata.get("user_api_key_auth") + return getattr(user_api_key_auth, "user_role", None) == "proxy_admin" + + def get_litellm_params_sensitive_credential_hash(litellm_params: dict) -> str: """ Hash of the credential params, used for mapping the file id to the right model @@ -59,6 +72,40 @@ def filter_team_based_models( metadata = request_kwargs.get("metadata") or {} litellm_metadata = request_kwargs.get("litellm_metadata") or {} request_team_id = metadata.get("user_api_key_team_id") or litellm_metadata.get("user_api_key_team_id") + if request_team_id is None and _is_proxy_admin_request(request_kwargs) and isinstance(healthy_deployments, list): + requested_model = ( + request_kwargs.get("model") or metadata.get("model_group") or litellm_metadata.get("model_group") + ) + candidate_deployments = tuple( + (deployment.get("model_name"), deployment.get("model_info") or {}) for deployment in healthy_deployments + ) + team_ids = frozenset( + team_id + for _, model_info in candidate_deployments + for team_id in [model_info.get("team_id")] + if team_id is not None + ) + matches_requested_model = ( + isinstance(requested_model, str) + and bool(candidate_deployments) + and all( + model_info.get("team_id") is not None + and (model_name == requested_model or model_info.get("team_public_model_name") == requested_model) + for model_name, model_info in candidate_deployments + ) + ) + if matches_requested_model and len(team_ids) > 1: + raise BadRequestError( + message=( + f"Model name '{requested_model}' matches deployments from multiple teams. " + "Specify the deployment ID directly to disambiguate." + ), + model=requested_model, + llm_provider="", + ) + if matches_requested_model: + return healthy_deployments + ids_to_remove = set() if isinstance(healthy_deployments, dict): return healthy_deployments diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index c7e080b1363..03b6b36ec2e 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -384,6 +384,86 @@ class PresidioConfigModel(PresidioPresidioConfigModelUserInterface): mock_redacted_text: Optional[dict] = Field(default=None, description="Mock redacted text for testing") +BedrockChecksContentFilterCategory = Literal["VIOLENCE", "HATE", "SEXUAL", "MISCONDUCT", "INSULTS"] +BedrockChecksPromptAttackCategory = Literal["JAILBREAK", "PROMPT_INJECTION", "PROMPT_LEAKAGE"] +BedrockChecksSensitiveInformationEntity = Literal[ + "ADDRESS", + "AGE", + "AWS_ACCESS_KEY", + "AWS_SECRET_KEY", + "CA_HEALTH_NUMBER", + "CA_SOCIAL_INSURANCE_NUMBER", + "CREDIT_DEBIT_CARD_CVV", + "CREDIT_DEBIT_CARD_EXPIRY", + "CREDIT_DEBIT_CARD_NUMBER", + "DRIVER_ID", + "EMAIL", + "INTERNATIONAL_BANK_ACCOUNT_NUMBER", + "IP_ADDRESS", + "LICENSE_PLATE", + "MAC_ADDRESS", + "NAME", + "PASSWORD", + "PHONE", + "PIN", + "SWIFT_CODE", + "UK_NATIONAL_HEALTH_SERVICE_NUMBER", + "UK_NATIONAL_INSURANCE_NUMBER", + "UK_UNIQUE_TAXPAYER_REFERENCE_NUMBER", + "URL", + "USERNAME", + "US_BANK_ACCOUNT_NUMBER", + "US_BANK_ROUTING_NUMBER", + "US_INDIVIDUAL_TAX_IDENTIFICATION_NUMBER", + "US_PASSPORT_NUMBER", + "US_SOCIAL_SECURITY_NUMBER", + "VEHICLE_IDENTIFICATION_NUMBER", +] + + +class BedrockChecksContentFilterCategoryItem(BaseModel): + category: BedrockChecksContentFilterCategory + + +class BedrockChecksContentFilterModel(BaseModel): + categories: list[BedrockChecksContentFilterCategoryItem] + + +class BedrockChecksPromptAttackCategoryItem(BaseModel): + category: BedrockChecksPromptAttackCategory + + +class BedrockChecksPromptAttackModel(BaseModel): + categories: list[BedrockChecksPromptAttackCategoryItem] + + +class BedrockChecksSensitiveInformationEntityItem(BaseModel): + type: BedrockChecksSensitiveInformationEntity + + +class BedrockChecksSensitiveInformationModel(BaseModel): + entities: list[BedrockChecksSensitiveInformationEntityItem] + + +class BedrockChecksConfigModel(BaseModel): + """Inline `checks` config for the resource-less Bedrock InvokeGuardrailChecks API. + + Include only the checks you want to run; at least one must be set. + """ + + contentFilter: BedrockChecksContentFilterModel | None = None + promptAttack: BedrockChecksPromptAttackModel | None = None + sensitiveInformation: BedrockChecksSensitiveInformationModel | None = None + + @model_validator(mode="after") + def _require_at_least_one_check(self) -> "BedrockChecksConfigModel": + if self.contentFilter is None and self.promptAttack is None and self.sensitiveInformation is None: + raise ValueError( + "Bedrock 'checks' must enable at least one of: contentFilter, promptAttack, sensitiveInformation." + ) + return self + + class BedrockGuardrailConfigModel(BaseModel): """Configuration parameters for the AWS Bedrock guardrail""" @@ -408,6 +488,35 @@ class BedrockGuardrailConfigModel(BaseModel): ) aws_sts_endpoint: Optional[str] = Field(default=None, description="AWS STS endpoint URL") aws_bedrock_runtime_endpoint: Optional[str] = Field(default=None, description="AWS Bedrock runtime endpoint URL") + checks: BedrockChecksConfigModel | None = Field( + default=None, + description="Inline safeguards for the resource-less InvokeGuardrailChecks API " + "(contentFilter / promptAttack / sensitiveInformation). When set, the guardrail " + "calls InvokeGuardrailChecks instead of ApplyGuardrail and no guardrailIdentifier " + "is required. Mutually exclusive with guardrailIdentifier.", + ) + content_filter_threshold: float | None = Field( + default=0.5, + ge=0.0, + le=1.0, + description="InvokeGuardrailChecks: block when any contentFilter severityScore >= " + "this value (scores are in [0,1]). Set to null to make the content filter " + "detect-only (logged, never blocks).", + ) + prompt_attack_threshold: float | None = Field( + default=0.5, + ge=0.0, + le=1.0, + description="InvokeGuardrailChecks: block when any promptAttack severityScore >= " + "this value (scores are in [0,1]). Set to null to make prompt-attack detection detect-only.", + ) + pii_confidence_threshold: float | None = Field( + default=0.5, + ge=0.0, + le=1.0, + description="InvokeGuardrailChecks: block when any sensitiveInformation confidenceScore " + ">= this value (scores are in [0,1]). Set to null to make PII detection detect-only.", + ) class LakeraV2GuardrailConfigModel(BaseModel): diff --git a/litellm/types/integrations/custom_logger.py b/litellm/types/integrations/custom_logger.py index 26a0be36ef4..04e490f79ee 100644 --- a/litellm/types/integrations/custom_logger.py +++ b/litellm/types/integrations/custom_logger.py @@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field CHAT_COMPLETION_AGENTIC_SURFACE = "chat_completions" +RESPONSES_AGENTIC_SURFACE = "responses" CODE_INTERPRETER_INTERCEPTION_PREFIX = "_code_interpreter_interception" NON_CODE_INTERPRETER_INTERCEPTION_INTERNAL_PREFIXES = frozenset( ("_websearch_interception", "_compression_interception") diff --git a/litellm/types/integrations/prometheus.py b/litellm/types/integrations/prometheus.py index 69bccff701f..318ba1f5956 100644 --- a/litellm/types/integrations/prometheus.py +++ b/litellm/types/integrations/prometheus.py @@ -213,6 +213,8 @@ DEFINED_PROMETHEUS_METRICS = Literal[ "litellm_input_audio_tokens_metric", "litellm_output_reasoning_tokens_metric", "litellm_output_audio_tokens_metric", + "litellm_video_duration_seconds_metric", + "litellm_images_generated_metric", "litellm_deployment_successful_fallbacks", "litellm_deployment_failed_fallbacks", "litellm_remaining_team_budget_metric", @@ -506,6 +508,9 @@ class PrometheusMetricLabels: litellm_output_reasoning_tokens_metric = litellm_output_tokens_metric litellm_output_audio_tokens_metric = litellm_output_tokens_metric + litellm_video_duration_seconds_metric = litellm_output_tokens_metric + litellm_images_generated_metric = litellm_output_tokens_metric + litellm_deployment_state = [ UserAPIKeyLabelNames.v2_LITELLM_MODEL_NAME.value, UserAPIKeyLabelNames.MODEL_ID.value, @@ -717,6 +722,8 @@ class PrometheusMetricLabels: "litellm_input_tokens_metric", "litellm_total_tokens_metric", "litellm_output_tokens_metric", + "litellm_video_duration_seconds_metric", + "litellm_images_generated_metric", } ) # Managed batch metrics diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index aa9f4dccbd1..c24d072217a 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -439,6 +439,9 @@ class ContentThinkingSignatureBlockDelta(TypedDict): signature: str +StreamingContentBlockDeltaType = Literal["text_delta", "input_json_delta", "thinking_delta", "signature_delta"] + + class ContentBlockDelta(TypedDict): type: Literal["content_block_delta"] index: int diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index bb0baba6cf4..ced6dfe5e2e 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -20,6 +20,10 @@ class MCPOAuthMetadata(BaseModel): authorization_url: Optional[str] = None token_url: Optional[str] = None registration_url: Optional[str] = None + 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 + usable in memory but must never be persisted as configuration.""" class MCPServer(BaseModel): @@ -118,9 +122,10 @@ class MCPServer(BaseModel): # response (supports dot-notation for nested fields, e.g. "team.enterprise_id"). # Tokens that fail validation are rejected before storage. token_validation: Optional[Dict[str, Any]] = None - # Optional TTL override (seconds) for the Redis per-user token cache. - # Defaults to the token's expires_in minus the expiry buffer, or - # MCP_PER_USER_TOKEN_DEFAULT_TTL when expires_in is absent. + # Optional TTL override (seconds) for the Redis per-user token cache, capped + # at the token's expires_in minus the expiry buffer so a cached entry never + # outlives the token. Defaults to the token's expires_in minus the expiry + # buffer, or MCP_PER_USER_TOKEN_DEFAULT_TTL when expires_in is absent. token_storage_ttl_seconds: Optional[int] = None timeout: Optional[float] = None # Max concurrent outbound tool calls to this server; excess calls queue. diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/types/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 74d4616cddd..ad9c9b44857 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional from typing_extensions import TypedDict @@ -126,3 +126,81 @@ class BedrockGuardrailResponse(TypedDict, total=False): output: Optional[List[BedrockGuardrailOutput]] outputs: Optional[List[BedrockGuardrailOutput]] assessments: Optional[List[BedrockGuardrailAssessment]] + + +# --------------------------------------------------------------------------- +# InvokeGuardrailChecks API (resource-less, detect-only) +# POST /guardrail-checks/invoke +# Unlike ApplyGuardrail, this API takes inline `checks` (no guardrail resource) +# and returns numeric scores per check; it never blocks/masks/rewrites content. +# --------------------------------------------------------------------------- + + +class BedrockChecksTextContent(TypedDict, total=False): + text: str + + +class BedrockChecksMessage(TypedDict, total=False): + role: Literal["user", "assistant", "system"] + content: list[BedrockChecksTextContent] + + +class BedrockChecksScoreEntry(TypedDict, total=False): + """A contentFilter/promptAttack result entry; severityScore is a float in [0,1] + (Bedrock returns it in discrete steps: 0, 0.2, 0.4, 0.6, 0.8, 1.0).""" + + category: str | None + severityScore: float | None + + +class BedrockChecksPiiEntry(TypedDict, total=False): + """A sensitiveInformation result entry; confidence is in [0,1].""" + + type: str | None + confidenceScore: float | None + messageIndex: int | None + contentIndex: int | None + beginOffset: int | None + endOffset: int | None + + +class BedrockChecksScoreResult(TypedDict, total=False): + results: list[BedrockChecksScoreEntry] + + +class BedrockChecksSensitiveInformationResult(TypedDict, total=False): + results: list[BedrockChecksPiiEntry] + truncated: bool | None + + +class BedrockChecksResults(TypedDict, total=False): + contentFilter: BedrockChecksScoreResult | None + promptAttack: BedrockChecksScoreResult | None + sensitiveInformation: BedrockChecksSensitiveInformationResult | None + + +class BedrockChecksViolation(TypedDict, total=False): + """One over-threshold InvokeGuardrailChecks result; carries only the + non-sensitive label and score, never offsets or matched text.""" + + check: str + category: str | None + type: str | None + severityScore: float + confidenceScore: float + truncated: bool + + +class BedrockChecksTextUnits(TypedDict, total=False): + textUnits: int | None + + +class BedrockChecksUsage(TypedDict, total=False): + contentFilter: BedrockChecksTextUnits | None + promptAttack: BedrockChecksTextUnits | None + sensitiveInformation: BedrockChecksTextUnits | None + + +class BedrockGuardrailChecksResponse(TypedDict, total=False): + results: BedrockChecksResults | None + usage: BedrockChecksUsage | None diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py index d0ac8bb8998..9e198e13902 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py @@ -84,6 +84,24 @@ class GenericGuardrailAPIOptionalParams(BaseModel): ), ) + streaming_transform_mode: Optional[Literal["block_only", "incremental_diff"]] = Field( + default=None, + description=( + "Controls whether text modifications returned by the guardrail (action=" + "GUARDRAIL_INTERVENED with modified texts) reach the client on the streaming " + "path. 'block_only' (default) preserves the historical behavior: the raw " + "upstream chunks are streamed and only a BLOCK terminates the stream; text " + "rewrites are dropped. 'incremental_diff' withholds the raw chunks and instead " + "emits the guardrailed text as new deltas computed by diffing the mutated " + "accumulated text against what has already been sent, enabling PII masking, " + "pseudonym reversal, redaction and similar rewrites over HTTP. Only supported " + "for the OpenAI chat completions streaming path (string delta.content) and " + "ignored when streaming_end_of_stream_only is True except for a single " + "post-stream synthetic chunk. Defaults to 'block_only' in " + "GenericGuardrailAPI.__init__ when None." + ), + ) + class GenericGuardrailAPIConfigModel( GuardrailConfigModel[GenericGuardrailAPIOptionalParams], @@ -126,6 +144,20 @@ class GenericGuardrailAPIRequest(BaseModel): model: Optional[str] = None # the model being used for the LLM call +def coerce_stream_holdback_value(value: Any) -> int: + """Coerce a single ``stream_holdback_chars`` entry to a non-negative int. + + A guardrail returning a null, non-numeric, or negative holdback element must + not abort the streaming round, so malformed values degrade to 0 (no holdback) + rather than raising. Shared by response parsing (``from_dict``) and the + handler that applies holdback to in-process guardrail return values. + """ + try: + return max(0, int(value)) + except (TypeError, ValueError): + return 0 + + class GenericGuardrailAPIResponse: """Response model for the Generic Guardrail API""" @@ -134,6 +166,7 @@ class GenericGuardrailAPIResponse: tools: Optional[List[GuardrailToolParam]] action: str blocked_reason: Optional[str] + stream_holdback_chars: Optional[List[int]] def __init__( self, @@ -142,19 +175,29 @@ class GenericGuardrailAPIResponse: blocked_reason: Optional[str] = None, images: Optional[List[str]] = None, tools: Optional[List[GuardrailToolParam]] = None, + stream_holdback_chars: Optional[List[int]] = None, ): self.action = action self.blocked_reason = blocked_reason self.texts = texts self.images = images self.tools = tools + # Number of trailing chars, indexed the same as ``texts``, that the + # framework must withhold from streaming emission until the next + # processing round (word-boundary safety for text transformations). + self.stream_holdback_chars = stream_holdback_chars @classmethod def from_dict(cls, data: dict) -> "GenericGuardrailAPIResponse": + raw_holdback = data.get("stream_holdback_chars") + stream_holdback_chars = ( + [coerce_stream_holdback_value(value) for value in raw_holdback] if isinstance(raw_holdback, list) else None + ) return cls( action=data.get("action", "NONE"), blocked_reason=data.get("blocked_reason"), texts=data.get("texts"), images=data.get("images"), tools=data.get("tools"), + stream_holdback_chars=stream_holdback_chars, ) diff --git a/litellm/types/router.py b/litellm/types/router.py index 3bedd97c20c..d62c613bf57 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -9,7 +9,7 @@ from typing import Any, Dict, List, Literal, Optional, Tuple, Union, get_type_hi import httpx from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from typing_extensions import Protocol, Required, TypedDict +from typing_extensions import Protocol, Required, TypedDict, runtime_checkable from litellm._uuid import uuid @@ -852,6 +852,7 @@ class RoutingContext(BaseModel): signals: dict[str, Any] = Field(default_factory=dict) +@runtime_checkable class RoutingPlugin(Protocol): """Interface a custom routing plugin must implement to run in `Router(plugins=[...])`.""" diff --git a/litellm/types/utils.py b/litellm/types/utils.py index e33e2335525..6487a8aa33f 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -209,6 +209,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): input_cost_per_query: Optional[float] # only for rerank models input_cost_per_image: Optional[float] # only for vertex ai models input_cost_per_image_token: Optional[float] # for gpt-image-1 and similar models + input_cost_per_video_token: Optional[float] # for gemini omni models with video input input_cost_per_audio_per_second: Optional[float] # only for vertex ai models input_cost_per_video_per_second: Optional[float] # only for vertex ai models input_cost_per_second: Optional[float] # for OpenAI Speech models @@ -234,6 +235,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): output_cost_per_character_above_128k_tokens: Optional[float] # only for vertex ai models output_cost_per_image: Optional[float] output_cost_per_image_token: Optional[float] + output_cost_per_video_token: Optional[float] # for gemini omni models with video output output_vector_size: Optional[int] output_cost_per_reasoning_token: Optional[float] output_cost_per_video_per_second: Optional[float] # only for vertex ai models @@ -3046,6 +3048,7 @@ class CustomPricingLiteLLMParams(BaseModel): output_cost_per_character_above_128k_tokens: Optional[float] = None output_cost_per_image: Optional[float] = None output_cost_per_image_token: Optional[float] = None + output_cost_per_video_token: Optional[float] = None output_cost_per_reasoning_token: Optional[float] = None output_cost_per_video_per_second: Optional[float] = None output_cost_per_audio_per_second: Optional[float] = None @@ -3055,6 +3058,7 @@ class CustomPricingLiteLLMParams(BaseModel): cache_read_input_token_cost_above_272k_tokens: Optional[float] = None cache_read_input_token_cost_above_512k_tokens: Optional[float] = None input_cost_per_image_token: Optional[float] = None + input_cost_per_video_token: Optional[float] = None input_cost_per_token_above_272k_tokens: Optional[float] = None input_cost_per_token_above_512k_tokens: Optional[float] = None output_cost_per_token_above_272k_tokens: Optional[float] = None @@ -3210,6 +3214,16 @@ all_litellm_params = ( "_litellm_tpm_reserved_model", "_litellm_tpm_reserved_scopes", "_litellm_tpm_reservation_released", + "auto_router_config_path", + "auto_router_config", + "auto_router_default_model", + "auto_router_embedding_model", + "complexity_router_config", + "complexity_router_default_model", + "adaptive_router_config", + "adaptive_router_default_model", + "quality_router_config", + "quality_router_default_model", ] + list(StandardCallbackDynamicParams.__annotations__.keys()) + list(CustomPricingLiteLLMParams.model_fields.keys()) @@ -3764,3 +3778,6 @@ class GenericGuardrailAPIInputs(TypedDict, total=False): AllMessageValues ] # structured messages sent to the LLM - indicates if text is from system or user model: Optional[str] # the model being used for the LLM call + stream_holdback_chars: List[ + int + ] # trailing chars to withhold from streaming emission per text (word-boundary safety) diff --git a/litellm/utils.py b/litellm/utils.py index 18b89ee0d13..0636d3683b7 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5437,6 +5437,7 @@ def _get_model_info_helper( input_cost_per_second=_model_info.get("input_cost_per_second", None), input_cost_per_audio_token=_model_info.get("input_cost_per_audio_token", None), input_cost_per_image_token=_model_info.get("input_cost_per_image_token", None), + input_cost_per_video_token=_model_info.get("input_cost_per_video_token", None), input_cost_per_image=_model_info.get("input_cost_per_image", None), input_cost_per_audio_per_second=_model_info.get("input_cost_per_audio_per_second", None), input_cost_per_video_per_second=_model_info.get("input_cost_per_video_per_second", None), @@ -5480,6 +5481,7 @@ def _get_model_info_helper( output_cost_per_video_per_second=_model_info.get("output_cost_per_video_per_second", None), output_cost_per_image=_model_info.get("output_cost_per_image", None), output_cost_per_image_token=_model_info.get("output_cost_per_image_token", None), + output_cost_per_video_token=_model_info.get("output_cost_per_video_token", None), output_vector_size=_model_info.get("output_vector_size", None), citation_cost_per_token=_model_info.get("citation_cost_per_token", None), tiered_pricing=_model_info.get("tiered_pricing", None), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 08a452be844..e10dde793d1 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -11331,6 +11331,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true }, @@ -11362,6 +11363,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true @@ -11424,6 +11426,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, @@ -11479,6 +11482,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true }, @@ -11506,6 +11510,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true }, @@ -11559,6 +11564,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true @@ -11586,6 +11592,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true @@ -11614,6 +11621,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "provider_specific_entry": { @@ -11648,6 +11656,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "provider_specific_entry": { @@ -11682,6 +11691,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, @@ -11718,6 +11728,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, @@ -11788,6 +11799,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, @@ -19678,6 +19690,39 @@ }, "web_search_billing_unit": "per_query" }, + "gemini/gemini-omni-flash-preview": { + "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "gemini", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "output_cost_per_video_token": 1.75e-05, + "rpm": 2000, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "video" + ], + "supports_audio_input": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_video_input": true, + "supports_vision": true, + "tpm": 800000 + }, "gemini/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, @@ -19842,6 +19887,37 @@ }, "web_search_billing_unit": "per_query" }, + "gemini-omni-flash-preview": { + "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "output_cost_per_video_token": 1.75e-05, + "source": "https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/omni-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "video" + ], + "supports_audio_input": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_video_input": true, + "supports_vision": true + }, "gemini-3.5-flash": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_audio_token": 1e-06, @@ -44306,6 +44382,90 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "bedrock_mantle/openai.gpt-5.6-sol": { + "input_cost_per_token": 5.5e-06, + "cache_creation_input_token_cost": 6.875e-06, + "cache_read_input_token_cost": 5.5e-07, + "output_cost_per_token": 3.3e-05, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/openai.gpt-5.6-terra": { + "input_cost_per_token": 2.75e-06, + "cache_creation_input_token_cost": 3.4375e-06, + "cache_read_input_token_cost": 2.75e-07, + "output_cost_per_token": 1.65e-05, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/openai.gpt-5.6-luna": { + "input_cost_per_token": 1.1e-06, + "cache_creation_input_token_cost": 1.375e-06, + "cache_read_input_token_cost": 1.1e-07, + "output_cost_per_token": 6.6e-06, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "bedrock_mantle/openai.gpt-5.5": { "input_cost_per_token": 5.5e-06, "cache_read_input_token_cost": 5.5e-07, @@ -44418,6 +44578,7 @@ "supports_vision": true }, "bedrock_mantle/xai.grok-4.3": { + "use_openai_responses_path": true, "input_cost_per_token": 1.25e-06, "output_cost_per_token": 2.5e-06, "cache_read_input_token_cost": 2e-07, diff --git a/pyproject.toml b/pyproject.toml index c2a0e85d13a..f890cc976f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.93.0" +version = "1.94.0" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.10, <3.14" @@ -62,8 +62,8 @@ proxy = [ "azure-identity>=1.25.2,<2.0", "azure-storage-blob>=12.28.0,<13.0", "mcp>=1.26.0,<2.0", - "litellm-proxy-extras==0.4.76", - "litellm-enterprise==0.1.49", + "litellm-proxy-extras==0.4.77", + "litellm-enterprise==0.1.50", "RestrictedPython>=8.1,<9.0", "rich>=13.9.4,<14.0", "polars>=1.38.1,<2.0", @@ -205,7 +205,7 @@ ci = [ # protobuf, Pillow is a compiled C extension). "tenacity==8.5.0", "google-generativeai==0.8.6", - "Pillow==12.2.0", + "Pillow==12.3.0", # Azure batch E2E tests still import psycopg2 directly. "psycopg2-binary==2.9.11", "pytest-codspeed==4.3.0", @@ -264,6 +264,8 @@ constraint-dependencies = [ "aiohttp>=3.14.1,<4.0", "packaging>=24.0", "soupsieve>=2.8.4", + "httplib2>=0.32.0", + "setuptools>=83.0.0", ] override-dependencies = [ # a2a-sdk 1.x requires packaging>=24.0; lunary 1.4.x still caps at <24.0. @@ -284,7 +286,7 @@ members = ["enterprise", "litellm-proxy-extras"] profile = "black" [tool.commitizen] -version = "1.93.0" +version = "1.94.0" version_files = [ "pyproject.toml:^version", ] diff --git a/pyrightconfig.json b/pyrightconfig.json index 97f099d5b2c..eabfbf515c4 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,7 +1,7 @@ { "include": ["litellm"], "ignore": [], - "exclude": ["**/node_modules", "**/__pycache__", "litellm/types/utils.py", "litellm/proxy/_types.py"], + "exclude": ["**/node_modules", "**/__pycache__", "tests/e2e/claude_code", "litellm/types/utils.py", "litellm/proxy/_types.py"], "pythonVersion": "3.12", "typeCheckingMode": "strict", "enableTypeIgnoreComments": false, diff --git a/schema.prisma b/schema.prisma index fb4d8d0b5a3..a23cecc3911 100644 --- a/schema.prisma +++ b/schema.prisma @@ -422,6 +422,7 @@ model LiteLLM_VerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") @@ -516,6 +517,7 @@ model LiteLLM_DeletedVerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") diff --git a/terraform/litellm/aws/README.md b/terraform/litellm/aws/README.md index 7d4ef0a14fb..40a9da66c70 100644 --- a/terraform/litellm/aws/README.md +++ b/terraform/litellm/aws/README.md @@ -158,6 +158,38 @@ AgentOps) live under `proxy_config.litellm_settings.callbacks` and are orthogonal to the OTLP variables above; their credentials still go in `*_extra_secrets`. +### Enterprise billing metrics + +License-gated request metering is opt-in and gated entirely on +`billing_metrics_endpoint`. Empty (default) and no billing env is added to +the container, so existing deployments are unchanged. Set it and both +gateway and backend export billable-request counts over OTLP/HTTP, +authenticating to the collector with the mTLS client certificate issued for +your deployment. + +The proxy accepts the certificate, key, and CA bundle as either a file path +or literal PEM content. This stack takes the PEM, writes each one to its own +Secrets Manager entry, grants the task-execution role +`secretsmanager:GetSecretValue` on them, and injects them as +`LITELLM_BILLING_METRICS_CLIENT_CERT` / `_CLIENT_KEY` (and `_CA_CERT` when +set), so no volume mount is needed on Fargate. + +```hcl +billing_metrics_endpoint = "https://telemetry.litellm.ai/v1/metrics" +``` + +```bash +export TF_VAR_billing_metrics_client_cert_pem="$(cat client.crt)" +export TF_VAR_billing_metrics_client_key_pem="$(cat client.key)" +``` + +`billing_metrics_ca_cert_pem` is only for private or test collectors whose +CA is not in the system trust store; leave it empty against +`telemetry.litellm.ai`. Metering requires an enterprise license, so pair +this with `litellm_license`. To tune the export cadence, set +`LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS` through `gateway_extra_env` / +`backend_extra_env` + ## Tenant deployment Every resource the stack creates is named `${tenant}-litellm-${env}` (or diff --git a/terraform/litellm/aws/ecs.tf b/terraform/litellm/aws/ecs.tf index 54ab80de9f4..4df41c278e8 100644 --- a/terraform/litellm/aws/ecs.tf +++ b/terraform/litellm/aws/ecs.tf @@ -76,6 +76,33 @@ locals { { name = "OTEL_HEADERS", valueFrom = var.otel_headers_secret_arn }, ] : [] + # Enterprise request metering, gated on billing_metrics_endpoint. The + # endpoint rides in as a plain env var; the mTLS material is stored in + # Secrets Manager (secrets.tf) and injected as PEM-valued env vars, which + # the proxy accepts in place of file paths. Each PEM is wired only when the + # operator supplied it, so an empty ca_cert_pem falls back to the system + # trust store. + billing_metrics_enabled = var.billing_metrics_endpoint != "" + billing_metrics_client_cert_enabled = local.billing_metrics_enabled && var.billing_metrics_client_cert_pem != "" + billing_metrics_client_key_enabled = local.billing_metrics_enabled && var.billing_metrics_client_key_pem != "" + billing_metrics_ca_cert_enabled = local.billing_metrics_enabled && var.billing_metrics_ca_cert_pem != "" + + billing_metrics_env = local.billing_metrics_enabled ? [ + { name = "LITELLM_BILLING_METRICS_ENDPOINT", value = var.billing_metrics_endpoint }, + ] : [] + + billing_metrics_secrets = concat( + local.billing_metrics_client_cert_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CLIENT_CERT", valueFrom = aws_secretsmanager_secret.billing_metrics_client_cert[0].arn }, + ] : [], + local.billing_metrics_client_key_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CLIENT_KEY", valueFrom = aws_secretsmanager_secret.billing_metrics_client_key[0].arn }, + ] : [], + local.billing_metrics_ca_cert_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CA_CERT", valueFrom = aws_secretsmanager_secret.billing_metrics_ca_cert[0].arn }, + ] : [], + ) + shared_env = [ { name = "IAM_TOKEN_DB_AUTH", value = "true" }, { name = "DATABASE_HOST", value = aws_rds_cluster.this.endpoint }, @@ -108,6 +135,7 @@ locals { { name = "LITELLM_LICENSE", valueFrom = aws_secretsmanager_secret.license[0].arn }, ], local.otel_secrets, + local.billing_metrics_secrets, ) # Backend-only managed secrets. UI_PASSWORD is consumed by the management @@ -179,6 +207,30 @@ locals { # ---------- Gateway ---------- resource "aws_ecs_task_definition" "gateway" { + # Metering needs a client certificate AND its key. Each secret is created only + # when its own PEM is supplied, so an endpoint set with a missing key would + # otherwise apply cleanly and leave the proxy logging "missing config" and + # never exporting. ca_cert_pem stays optional: empty means fall back to the + # system trust store. + # + # The guard lives here, on an unconditional resource, rather than on the cert + # secret: that secret is count-gated on the cert itself, so it has zero + # instances in exactly the case this must catch. Adding count or for_each to + # this resource would silently stop the guard from evaluating. + # + # endpoint cert key -> result + # "" any any -> metering off, no secrets created + # set set set -> metering on + # set any-missing -> plan fails here + lifecycle { + precondition { + condition = var.billing_metrics_endpoint == "" || ( + var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != "" + ) + error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set." + } + } + family = "${local.name}-gateway" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] @@ -198,6 +250,7 @@ resource "aws_ecs_task_definition" "gateway" { environment = concat( local.shared_env, local.gateway_otel_env, + local.billing_metrics_env, local.gateway_extra_env_list, local.proxy_config_env, ) @@ -264,6 +317,18 @@ resource "aws_ecs_service" "gateway" { # ---------- Backend ---------- resource "aws_ecs_task_definition" "backend" { + # Same guard as the gateway: the backend meters too (it serves the named-server + # MCP transport), and a targeted apply of just this resource must not slip a + # billing endpoint through without the credentials to use it. + lifecycle { + precondition { + condition = var.billing_metrics_endpoint == "" || ( + var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != "" + ) + error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set." + } + } + family = "${local.name}-backend" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] @@ -284,6 +349,7 @@ resource "aws_ecs_task_definition" "backend" { local.shared_env, local.backend_default_env, local.backend_otel_env, + local.billing_metrics_env, local.backend_extra_env_list, local.proxy_config_env, ) diff --git a/terraform/litellm/aws/iam.tf b/terraform/litellm/aws/iam.tf index 64e1b1ad5f9..63c6c26f184 100644 --- a/terraform/litellm/aws/iam.tf +++ b/terraform/litellm/aws/iam.tf @@ -53,6 +53,9 @@ data "aws_iam_policy_document" "secrets_access" { [aws_secretsmanager_secret.master_key.arn], aws_secretsmanager_secret.license[*].arn, aws_secretsmanager_secret.ui_password[*].arn, + aws_secretsmanager_secret.billing_metrics_client_cert[*].arn, + aws_secretsmanager_secret.billing_metrics_client_key[*].arn, + aws_secretsmanager_secret.billing_metrics_ca_cert[*].arn, local.extra_secret_arns, var.otel_headers_secret_arn == "" ? [] : [var.otel_headers_secret_arn], ) diff --git a/terraform/litellm/aws/secrets.tf b/terraform/litellm/aws/secrets.tf index 300d38e4053..85d3eb4502c 100644 --- a/terraform/litellm/aws/secrets.tf +++ b/terraform/litellm/aws/secrets.tf @@ -74,6 +74,61 @@ resource "aws_secretsmanager_secret_version" "ui_password" { secret_string = var.ui_password } +# Billing-metrics mTLS material — only created when metering is enabled +# (billing_metrics_endpoint non-empty) and the operator supplied the PEM. +# The task-execution role gets GetSecretValue via iam.tf, and gateway + +# backend pick the env vars up through shared_secrets in ecs.tf. +resource "aws_secretsmanager_secret" "billing_metrics_client_cert" { + count = local.billing_metrics_client_cert_enabled ? 1 : 0 + + name = "${local.name}-billing-metrics-client-cert" + description = "LITELLM_BILLING_METRICS_CLIENT_CERT for gateway + backend." + recovery_window_in_days = 0 + + tags = local.tags +} + +resource "aws_secretsmanager_secret_version" "billing_metrics_client_cert" { + count = local.billing_metrics_client_cert_enabled ? 1 : 0 + + secret_id = aws_secretsmanager_secret.billing_metrics_client_cert[0].id + secret_string = var.billing_metrics_client_cert_pem +} + +resource "aws_secretsmanager_secret" "billing_metrics_client_key" { + count = local.billing_metrics_client_key_enabled ? 1 : 0 + + name = "${local.name}-billing-metrics-client-key" + description = "LITELLM_BILLING_METRICS_CLIENT_KEY for gateway + backend." + recovery_window_in_days = 0 + + tags = local.tags +} + +resource "aws_secretsmanager_secret_version" "billing_metrics_client_key" { + count = local.billing_metrics_client_key_enabled ? 1 : 0 + + secret_id = aws_secretsmanager_secret.billing_metrics_client_key[0].id + secret_string = var.billing_metrics_client_key_pem +} + +resource "aws_secretsmanager_secret" "billing_metrics_ca_cert" { + count = local.billing_metrics_ca_cert_enabled ? 1 : 0 + + name = "${local.name}-billing-metrics-ca-cert" + description = "LITELLM_BILLING_METRICS_CA_CERT for gateway + backend." + recovery_window_in_days = 0 + + tags = local.tags +} + +resource "aws_secretsmanager_secret_version" "billing_metrics_ca_cert" { + count = local.billing_metrics_ca_cert_enabled ? 1 : 0 + + secret_id = aws_secretsmanager_secret.billing_metrics_ca_cert[0].id + secret_string = var.billing_metrics_ca_cert_pem +} + resource "aws_secretsmanager_secret" "db_master_password" { name = "${local.name}-db-master-password" description = "Aurora master-user password - bootstrap only. Runtime auth is IAM-token." diff --git a/terraform/litellm/aws/variables.tf b/terraform/litellm/aws/variables.tf index 8db4935664b..c2ed1db14b1 100644 --- a/terraform/litellm/aws/variables.tf +++ b/terraform/litellm/aws/variables.tf @@ -533,3 +533,65 @@ variable "otel_headers_secret_arn" { type = string default = "" } + +# ---------- Enterprise billing metrics ---------- +# +# License-gated request metering. Opt-in and gated entirely on +# billing_metrics_endpoint: leave it empty (the default) and nothing +# metering-related lands in the container env. Set it and gateway + backend +# export billable-request counts over OTLP/HTTP, authenticating to the +# collector with an mTLS client cert. The proxy accepts the cert, key, and CA +# as either a file path or literal PEM content, so on Fargate they are +# injected straight from Secrets Manager as env vars and no volume is needed. + +variable "billing_metrics_endpoint" { + description = <<-EOT + OTLP/HTTP endpoint for enterprise billing metrics (sets + LITELLM_BILLING_METRICS_ENDPOINT). Non-empty enables request metering; + empty (default) disables it and adds no billing env to the container. + Requires an enterprise license. Example: + "https://telemetry.litellm.ai/v1/metrics" + EOT + type = string + default = "" +} + +variable "billing_metrics_client_cert_pem" { + description = <<-EOT + PEM content of the mTLS client certificate issued for this deployment. + When billing_metrics_endpoint is set, the stack stores this in a + `-litellm--billing-metrics-client-cert` Secrets Manager + entry, grants the task-execution role GetSecretValue on it, and exposes + it to gateway + backend as LITELLM_BILLING_METRICS_CLIENT_CERT. Required + whenever metering is enabled. + EOT + type = string + default = "" + sensitive = true +} + +variable "billing_metrics_client_key_pem" { + description = <<-EOT + PEM content of the private key matching + billing_metrics_client_cert_pem. Stored in a + `-litellm--billing-metrics-client-key` Secrets Manager + entry and exposed as LITELLM_BILLING_METRICS_CLIENT_KEY. Required + whenever metering is enabled. + EOT + type = string + default = "" + sensitive = true +} + +variable "billing_metrics_ca_cert_pem" { + description = <<-EOT + PEM content of the CA bundle used to verify the metering collector. + Only needed for private or test collectors whose CA is not in the + system trust store; telemetry.litellm.ai is publicly trusted, so leave + this empty for production. When set, it is exposed as + LITELLM_BILLING_METRICS_CA_CERT. + EOT + type = string + default = "" + sensitive = true +} diff --git a/terraform/litellm/gcp/README.md b/terraform/litellm/gcp/README.md index 1e0bf4319df..88e9979148f 100644 --- a/terraform/litellm/gcp/README.md +++ b/terraform/litellm/gcp/README.md @@ -204,6 +204,40 @@ Behavior matches the AWS stack 1:1; the only naming differences are `otel_headers_secret` (a Secret Manager resource ID) vs AWS's `otel_headers_secret_arn` (a Secrets Manager ARN). +### Enterprise billing metrics + +License-gated request metering is opt-in and gated entirely on +`billing_metrics_endpoint`. Empty (default) and no billing env is added to +the container, so existing deployments are unchanged. Set it and both +gateway and backend export billable-request counts over OTLP/HTTP, +authenticating to the collector with the mTLS client certificate issued for +your deployment. + +The proxy accepts the certificate, key, and CA bundle as either a file path +or literal PEM content. This stack takes the PEM, writes each one to its own +Secret Manager entry, grants the runtime service account +`roles/secretmanager.secretAccessor` on them, and injects them as Cloud Run +secret env vars `LITELLM_BILLING_METRICS_CLIENT_CERT` / `_CLIENT_KEY` (and +`_CA_CERT` when set), so no volume mount is needed. + +```hcl +billing_metrics_endpoint = "https://telemetry.litellm.ai/v1/metrics" +``` + +```bash +export TF_VAR_billing_metrics_client_cert_pem="$(cat client.crt)" +export TF_VAR_billing_metrics_client_key_pem="$(cat client.key)" +``` + +`billing_metrics_ca_cert_pem` is only for private or test collectors whose +CA is not in the system trust store; leave it empty against +`telemetry.litellm.ai`. Metering requires an enterprise license, so pair +this with `litellm_license`. To tune the export cadence, set +`LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS` through `gateway_extra_env` / +`backend_extra_env` + +Behavior matches the AWS stack 1:1; the variable names are identical + ## Tenant deployment Every resource the stack creates is named `${tenant}-litellm-${env}` (or diff --git a/terraform/litellm/gcp/cloudrun.tf b/terraform/litellm/gcp/cloudrun.tf index 7b1bb901e20..57533b71731 100644 --- a/terraform/litellm/gcp/cloudrun.tf +++ b/terraform/litellm/gcp/cloudrun.tf @@ -59,6 +59,33 @@ locals { { name = "OTEL_HEADERS", secret = var.otel_headers_secret, version = "latest" }, ] : [] + # Enterprise request metering, gated on billing_metrics_endpoint. The + # endpoint rides in as a plain env var; the mTLS material lives in Secret + # Manager (secrets.tf) and is injected as PEM-valued env vars, which the + # proxy accepts in place of file paths. Each PEM is wired only when the + # operator supplied it, so an empty ca_cert_pem falls back to the system + # trust store. + billing_metrics_enabled = var.billing_metrics_endpoint != "" + billing_metrics_client_cert_enabled = local.billing_metrics_enabled && var.billing_metrics_client_cert_pem != "" + billing_metrics_client_key_enabled = local.billing_metrics_enabled && var.billing_metrics_client_key_pem != "" + billing_metrics_ca_cert_enabled = local.billing_metrics_enabled && var.billing_metrics_ca_cert_pem != "" + + billing_metrics_env_kv = local.billing_metrics_enabled ? [ + { name = "LITELLM_BILLING_METRICS_ENDPOINT", value = var.billing_metrics_endpoint }, + ] : [] + + billing_metrics_env_secrets = concat( + local.billing_metrics_client_cert_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CLIENT_CERT", secret = google_secret_manager_secret.billing_metrics_client_cert[0].id, version = "latest" }, + ] : [], + local.billing_metrics_client_key_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CLIENT_KEY", secret = google_secret_manager_secret.billing_metrics_client_key[0].id, version = "latest" }, + ] : [], + local.billing_metrics_ca_cert_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CA_CERT", secret = google_secret_manager_secret.billing_metrics_ca_cert[0].id, version = "latest" }, + ] : [], + ) + # Cloud Run v2 secret env vars use value_source.secret_key_ref pointing at a # secret resource ID. Shared between gateway and backend (the migrations # job has its own narrower env list — see migrations_env_secrets below). @@ -138,6 +165,30 @@ locals { # ---------- Gateway ---------- resource "google_cloud_run_v2_service" "gateway" { + # Metering needs a client certificate AND its key. Each secret is created only + # when its own PEM is supplied, so an endpoint set with a missing key would + # otherwise apply cleanly and leave the proxy logging "missing config" and + # never exporting. ca_cert_pem stays optional: empty means fall back to the + # system trust store. + # + # The guard lives here, on an unconditional resource, rather than on the cert + # secret: that secret is count-gated on the cert itself, so it has zero + # instances in exactly the case this must catch. Adding count or for_each to + # this resource would silently stop the guard from evaluating. + # + # endpoint cert key -> result + # "" any any -> metering off, no secrets created + # set set set -> metering on + # set any-missing -> plan fails here + lifecycle { + precondition { + condition = var.billing_metrics_endpoint == "" || ( + var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != "" + ) + error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set." + } + } + name = "${local.name}-gateway" location = var.region ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" @@ -175,7 +226,7 @@ resource "google_cloud_run_v2_service" "gateway" { } dynamic "env" { - for_each = concat(local.shared_env_kv, local.gateway_otel_env_kv, local.gateway_extra_env_kv, local.proxy_config_env) + for_each = concat(local.shared_env_kv, local.gateway_otel_env_kv, local.billing_metrics_env_kv, local.gateway_extra_env_kv, local.proxy_config_env) content { name = env.value.name value = env.value.value @@ -183,7 +234,7 @@ resource "google_cloud_run_v2_service" "gateway" { } dynamic "env" { - for_each = concat(local.shared_env_secrets, local.otel_env_secrets, local.gateway_extra_secret_kv) + for_each = concat(local.shared_env_secrets, local.otel_env_secrets, local.billing_metrics_env_secrets, local.gateway_extra_secret_kv) content { name = env.value.name value_source { @@ -242,6 +293,9 @@ resource "google_cloud_run_v2_service" "gateway" { google_secret_manager_secret_iam_member.license, google_secret_manager_secret_iam_member.extras, google_secret_manager_secret_iam_member.otel_headers, + google_secret_manager_secret_iam_member.billing_metrics_client_cert, + google_secret_manager_secret_iam_member.billing_metrics_client_key, + google_secret_manager_secret_iam_member.billing_metrics_ca_cert, google_storage_bucket_iam_member.proxy_config_runtime, google_sql_user.app, # Don't go live until the schema is migrated; otherwise the proxy boots, @@ -252,6 +306,18 @@ resource "google_cloud_run_v2_service" "gateway" { # ---------- Backend ---------- resource "google_cloud_run_v2_service" "backend" { + # Same guard as the gateway: the backend meters too (it serves the named-server + # MCP transport), and a targeted apply of just this resource must not slip a + # billing endpoint through without the credentials to use it. + lifecycle { + precondition { + condition = var.billing_metrics_endpoint == "" || ( + var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != "" + ) + error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set." + } + } + name = "${local.name}-backend" location = var.region ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" @@ -289,7 +355,7 @@ resource "google_cloud_run_v2_service" "backend" { } dynamic "env" { - for_each = concat(local.shared_env_kv, local.backend_default_env_kv, local.backend_otel_env_kv, local.backend_extra_env_kv, local.proxy_config_env) + for_each = concat(local.shared_env_kv, local.backend_default_env_kv, local.backend_otel_env_kv, local.billing_metrics_env_kv, local.backend_extra_env_kv, local.proxy_config_env) content { name = env.value.name value = env.value.value @@ -297,7 +363,7 @@ resource "google_cloud_run_v2_service" "backend" { } dynamic "env" { - for_each = concat(local.shared_env_secrets, local.backend_managed_env_secrets, local.otel_env_secrets, local.backend_extra_secret_kv) + for_each = concat(local.shared_env_secrets, local.backend_managed_env_secrets, local.otel_env_secrets, local.billing_metrics_env_secrets, local.backend_extra_secret_kv) content { name = env.value.name value_source { @@ -357,6 +423,9 @@ resource "google_cloud_run_v2_service" "backend" { google_secret_manager_secret_iam_member.ui_password, google_secret_manager_secret_iam_member.extras, google_secret_manager_secret_iam_member.otel_headers, + google_secret_manager_secret_iam_member.billing_metrics_client_cert, + google_secret_manager_secret_iam_member.billing_metrics_client_key, + google_secret_manager_secret_iam_member.billing_metrics_ca_cert, google_storage_bucket_iam_member.proxy_config_runtime, google_sql_user.app, terraform_data.migration, diff --git a/terraform/litellm/gcp/iam.tf b/terraform/litellm/gcp/iam.tf index dc3ae5e0912..09df5e7dff0 100644 --- a/terraform/litellm/gcp/iam.tf +++ b/terraform/litellm/gcp/iam.tf @@ -79,3 +79,29 @@ resource "google_secret_manager_secret_iam_member" "otel_headers" { role = "roles/secretmanager.secretAccessor" member = "serviceAccount:${google_service_account.runtime.email}" } + +# Billing-metrics mTLS accessors — only created when request metering is +# enabled and the matching PEM was supplied. +resource "google_secret_manager_secret_iam_member" "billing_metrics_client_cert" { + count = local.billing_metrics_client_cert_enabled ? 1 : 0 + + secret_id = google_secret_manager_secret.billing_metrics_client_cert[0].id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.runtime.email}" +} + +resource "google_secret_manager_secret_iam_member" "billing_metrics_client_key" { + count = local.billing_metrics_client_key_enabled ? 1 : 0 + + secret_id = google_secret_manager_secret.billing_metrics_client_key[0].id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.runtime.email}" +} + +resource "google_secret_manager_secret_iam_member" "billing_metrics_ca_cert" { + count = local.billing_metrics_ca_cert_enabled ? 1 : 0 + + secret_id = google_secret_manager_secret.billing_metrics_ca_cert[0].id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.runtime.email}" +} diff --git a/terraform/litellm/gcp/secrets.tf b/terraform/litellm/gcp/secrets.tf index f93514bb70b..6ec77139996 100644 --- a/terraform/litellm/gcp/secrets.tf +++ b/terraform/litellm/gcp/secrets.tf @@ -63,3 +63,58 @@ resource "google_secret_manager_secret_version" "ui_password" { secret = google_secret_manager_secret.ui_password[0].id secret_data = var.ui_password } + +# Billing-metrics mTLS material — only created when metering is enabled +# (billing_metrics_endpoint non-empty) and the operator supplied the PEM. +# The runtime SA gets accessor permission via iam.tf, and gateway + backend +# pick the env vars up through billing_metrics_env_secrets in cloudrun.tf. +resource "google_secret_manager_secret" "billing_metrics_client_cert" { + count = local.billing_metrics_client_cert_enabled ? 1 : 0 + + secret_id = "${local.name}-billing-metrics-client-cert" + labels = local.labels + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "billing_metrics_client_cert" { + count = local.billing_metrics_client_cert_enabled ? 1 : 0 + + secret = google_secret_manager_secret.billing_metrics_client_cert[0].id + secret_data = var.billing_metrics_client_cert_pem +} + +resource "google_secret_manager_secret" "billing_metrics_client_key" { + count = local.billing_metrics_client_key_enabled ? 1 : 0 + + secret_id = "${local.name}-billing-metrics-client-key" + labels = local.labels + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "billing_metrics_client_key" { + count = local.billing_metrics_client_key_enabled ? 1 : 0 + + secret = google_secret_manager_secret.billing_metrics_client_key[0].id + secret_data = var.billing_metrics_client_key_pem +} + +resource "google_secret_manager_secret" "billing_metrics_ca_cert" { + count = local.billing_metrics_ca_cert_enabled ? 1 : 0 + + secret_id = "${local.name}-billing-metrics-ca-cert" + labels = local.labels + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "billing_metrics_ca_cert" { + count = local.billing_metrics_ca_cert_enabled ? 1 : 0 + + secret = google_secret_manager_secret.billing_metrics_ca_cert[0].id + secret_data = var.billing_metrics_ca_cert_pem +} diff --git a/terraform/litellm/gcp/variables.tf b/terraform/litellm/gcp/variables.tf index 4355192e9f1..1162e100bb2 100644 --- a/terraform/litellm/gcp/variables.tf +++ b/terraform/litellm/gcp/variables.tf @@ -490,3 +490,66 @@ variable "otel_capture_message_content" { error_message = "otel_capture_message_content must be one of: no_content, prompt_and_completion." } } + +# ---------- Enterprise billing metrics ---------- +# +# License-gated request metering. Opt-in and gated entirely on +# billing_metrics_endpoint: leave it empty (the default) and nothing +# metering-related is added to the container env. Set it and gateway + +# backend export billable-request counts over OTLP/HTTP, authenticating to +# the collector with an mTLS client cert. The proxy accepts the cert, key, +# and CA as either a file path or literal PEM content, so on Cloud Run they +# are injected straight from Secret Manager as env vars and no volume is +# needed. + +variable "billing_metrics_endpoint" { + description = <<-EOT + OTLP/HTTP endpoint for enterprise billing metrics (sets + LITELLM_BILLING_METRICS_ENDPOINT). Non-empty enables request metering; + empty (default) disables it and adds no billing env to the container. + Requires an enterprise license. Example: + "https://telemetry.litellm.ai/v1/metrics" + EOT + type = string + default = "" +} + +variable "billing_metrics_client_cert_pem" { + description = <<-EOT + PEM content of the mTLS client certificate issued for this deployment. + When billing_metrics_endpoint is set, the stack stores this in a + `-litellm--billing-metrics-client-cert` Secret Manager + entry, grants the runtime SA accessor on it, and exposes it to gateway + + backend as LITELLM_BILLING_METRICS_CLIENT_CERT. Required whenever + metering is enabled. + EOT + type = string + default = "" + sensitive = true +} + +variable "billing_metrics_client_key_pem" { + description = <<-EOT + PEM content of the private key matching + billing_metrics_client_cert_pem. Stored in a + `-litellm--billing-metrics-client-key` Secret Manager entry + and exposed as LITELLM_BILLING_METRICS_CLIENT_KEY. Required whenever + metering is enabled. + EOT + type = string + default = "" + sensitive = true +} + +variable "billing_metrics_ca_cert_pem" { + description = <<-EOT + PEM content of the CA bundle used to verify the metering collector. + Only needed for private or test collectors whose CA is not in the + system trust store; telemetry.litellm.ai is publicly trusted, so leave + this empty for production. When set, it is exposed as + LITELLM_BILLING_METRICS_CA_CERT. + EOT + type = string + default = "" + sensitive = true +} diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md index 88992038cb7..f0d283629b0 100644 --- a/tests/e2e/CLAUDE.md +++ b/tests/e2e/CLAUDE.md @@ -11,12 +11,13 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family - `embeddings/` - the `/embeddings` endpoint across providers - `batches/` - the `/batches` endpoint (placeholder until the first test lands) - `realtime/` - realtime websocket sessions, including the pipecat audio path -- `quota_management/` - quota enforcement and accounting, one subfolder per behavior: `budgets/` (budget definition, enforcement, and reset windows: key, team, tag, soft, multi-window) and `spend_tracking/` (spend logging and cost attribution on `/spend/*`) +- `quota_management/` - quota enforcement and accounting, one subfolder per behavior: `ratelimit/` (rpm/tpm blocks, window reset, pacing headers on live traffic), `budgets/` (budget definition, enforcement, and reset windows: key, team, tag, soft, multi-window), and `spend_tracking/` (spend logging and cost attribution on `/spend/*`) - `management/` - key/team/user/organization management routes: create/update/delete persistence via the info routes, team membership, and llm-only-key route denials; also the dashboard UI behavior on top of them, driven through the proxy-served UI at /ui with playwright (optional dep behind importorskip) - `logging/` - logging-integration delivery (datadog and friends) - `security/` - secret handling and log-leak protection - `router/` - routing and reliability behavior (fallbacks, cooldowns) - `gateway/` - proxy configuration only (`litellm-config.yml`); no tests +- `claude_code/` - the Claude Code compatibility matrix: drives the real `claude` CLI (and HTTP probes) against a proxy for each feature x provider cell, reporting tagged-union outcomes via the `compat_result` fixture; ships its own driver/builder/publisher plus `_*_unit_tests/` trees, and does not use the shared transport harness ## Lay the pattern down in a class @@ -77,13 +78,13 @@ llm..... endpoint : chat_completions | messages | responses | embeddings | batches | files | rerank | images_generations | audio_speech | audio_transcriptions | moderations | realtime - route : openai | azure_openai | anthropic | bedrock_converse | vertex | azure_foundry - | cohere | together_ai + route : openai | azure_openai | anthropic | bedrock_converse | bedrock_invoke | vertex + | azure_foundry | cohere | together_ai (vocab varies per endpoint; messages is anthropic-format only) capability : basic | tool_use | prompt_cache_5m | vision | thinking | structured_output - | service_tier + | service_tier | mid_conversation_system streaming : stream | nonstream (omit where n/a) - assertion : works | cost_logged + assertion : works | cost_logged | cache_hit label (not in id): model = haiku-4.5 | sonnet-4.6 | opus-4.7 | gpt-* e.g. llm.chat_completions.bedrock_converse.tool_use.stream.works llm.messages.anthropic.prompt_cache_1h.nonstream.cache_hit diff --git a/tests/e2e/batches/test_batches_e2e.py b/tests/e2e/batches/test_batches_e2e.py index 7d54f05656e..85d9315b8c6 100644 --- a/tests/e2e/batches/test_batches_e2e.py +++ b/tests/e2e/batches/test_batches_e2e.py @@ -17,6 +17,7 @@ from __future__ import annotations import json import time +from datetime import datetime, timedelta, timezone from typing import Callable import pytest @@ -49,7 +50,7 @@ from e2e_http import ( unwrap, ) from lifecycle import ResourceManager -from models import KeyGenerateBody, SpendLogRow, SpendLogsParams +from models import KeyGenerateBody, SpendLogRow pytestmark = pytest.mark.e2e @@ -349,6 +350,10 @@ def test_rate_limited_batch_create_leaves_no_unattributed_spend_row( the file-read path fires while the batch itself is not blocked. ``resources.key()`` cannot set limits, so the key is minted on the gateway directly and its delete deferred. + + Snapshots read /spend/logs/v2 over a bounded window around the test instead + of the unpaginated /spend/logs whole-table read, which grows with the + environment and OOMed the e2e runner on stage. """ user_id = f"e2e-batch-rl-{unique_marker()}" key = client.gateway.generate_key( @@ -356,8 +361,13 @@ def test_rate_limited_batch_create_leaves_no_unattributed_spend_row( ) resources.defer(lambda: client.gateway.delete_key(key)) + window_start = datetime.now(timezone.utc) - timedelta(hours=1) + window_end = window_start + timedelta(hours=2) before = frozenset( - row.request_id for row in unattributed_rows(client.gateway.spend_logs(SpendLogsParams())) + row.request_id + for row in unattributed_rows( + client.gateway.spend_logs_window(start=window_start, end=window_end) + ) ) file = unwrap( @@ -379,7 +389,9 @@ def test_rate_limited_batch_create_leaves_no_unattributed_spend_row( new_orphans = [ row - for row in unattributed_rows(client.gateway.spend_logs(SpendLogsParams())) + for row in unattributed_rows( + client.gateway.spend_logs_window(start=window_start, end=window_end) + ) if row.request_id not in before ] assert not new_orphans, ( diff --git a/tests/e2e/claude_code/__init__.py b/tests/e2e/claude_code/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/_basic_messaging.py b/tests/e2e/claude_code/_basic_messaging.py new file mode 100644 index 00000000000..f6b82a38f6a --- /dev/null +++ b/tests/e2e/claude_code/_basic_messaging.py @@ -0,0 +1,167 @@ +"""Shared body for the `basic_messaging_*` × compat cells. + +Every basic_messaging cell follows the same skeleton: + + 1. Read the proxy base URL + API key from env, fail-early if missing. + 2. Fan the three Claude tiers out via `run_claude_models_parallel`. + 3. Inspect each model's outcome and report one `compat_result` row per + model — `ClaudeCLIError`, non-zero exit, and empty assistant text + are all per-model fails; everything else is a per-model pass. + 4. Surface a joined failure message via `pytest.fail(...)` so the + pytest run also goes red. + +The streaming variant additionally passes `verify_streaming=True`, +which adds the `--include-partial-messages` CLI flag and asserts that +the proxy actually streamed the response (see the helper docstring for +the wire-level rationale). + +The conftest infers `(feature_id, provider)` purely from the test file +path, so each per-provider file just declares its model list and calls +`run_basic_messaging_cell(...)`. This keeps all cell logic in one place +— a future tweak to the env-missing guard or the failure-loop shape +now propagates to every cell automatically. + +The leading underscore in the filename is what keeps pytest from +collecting this module as a test file. +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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" + +# Floor on the number of `stream_event` records (with delta payloads) +# we expect to see when the proxy actually streams. With +# `--include-partial-messages`, the CLI emits one `stream_event` per +# raw upstream SSE event — a fully-streamed response produces many +# (`message_start`, multiple `content_block_delta`s, `content_block_stop`, +# `message_delta`, `message_stop`); a proxy that buffers the upstream +# and returns a single non-streaming chunk produces 0 or 1. Floor of 2 +# is safely above the buffered case for any non-trivial reply, which +# is why the streaming cells use a "count from 1 to 5" style prompt. +MIN_STREAM_DELTA_EVENTS = 2 + + +def _count_stream_event_deltas(events: Sequence[Mapping[str, Any]]) -> int: + """Count `stream_event` records that carry an SSE event payload. + + With `--include-partial-messages`, Claude Code wraps every upstream + SSE event in a `{"type": "stream_event", "event": {...}}` record. + A buffering proxy collapses the upstream stream into a single + non-streaming response, so these records vanish. Counting them + (rather than just `len(events)`) is the wire-level signal that + "did the proxy preserve streaming?" — independent of the `system` + /`assistant`/`result` boilerplate records the CLI always emits. + """ + count = 0 + for event in events: + if event.get("type") != "stream_event": + continue + if isinstance(event.get("event"), Mapping): + count += 1 + return count + + +def run_basic_messaging_cell( + *, + compat_result, + models: Sequence[str], + prompt: str, + verify_streaming: bool = False, +) -> None: + """Run the shared `basic_messaging_*` × cell body. + + When ``verify_streaming=True``, the cell additionally asserts that + the proxy streamed the response end-to-end. The check works by + passing ``--include-partial-messages`` to the `claude` CLI, which + causes it to emit one ``stream_event`` record per raw upstream SSE + event (``message_start``, ``content_block_delta``, ``message_stop``, + etc.). A proxy that buffers the upstream stream and returns a + single non-streaming response collapses those records to zero — + so a floor of ``MIN_STREAM_DELTA_EVENTS`` ``stream_event`` records + catches the buffering regression without needing a streaming-aware + driver. + + This is the same shape of check as ``tool_use_streaming`` uses, + just keyed off the explicit partial-message flag so it works for + plain assistant replies (where the CLI would otherwise collapse a + 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, + ) + + extra_args: Sequence[str] = ( + ("--include-partial-messages",) if verify_streaming else () + ) + + outcomes = run_claude_models_parallel( + models=models, + prompt=prompt, + base_url=base_url, + api_key=api_key, + extra_args=extra_args, + ) + + failures = [] + for model in models: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not outcome.text.strip(): + error = f"[{model}] claude returned empty assistant text" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if verify_streaming: + stream_event_count = _count_stream_event_deltas(outcome.events) + if stream_event_count < MIN_STREAM_DELTA_EVENTS: + error = ( + f"[{model}] only {stream_event_count} stream_event records " + f"observed (< {MIN_STREAM_DELTA_EVENTS}); proxy likely " + f"buffered the upstream response instead of streaming it" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/_builder_unit_tests/__init__.py b/tests/e2e/claude_code/_builder_unit_tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/_builder_unit_tests/fixtures/expected_matrix.json b/tests/e2e/claude_code/_builder_unit_tests/fixtures/expected_matrix.json new file mode 100644 index 00000000000..d3aca0142dc --- /dev/null +++ b/tests/e2e/claude_code/_builder_unit_tests/fixtures/expected_matrix.json @@ -0,0 +1,38 @@ +{ + "schema_version": "1", + "generated_at": "2026-04-25T00:00:00Z", + "litellm_version": "v1.83.0-stable", + "claude_code_version": "2.1.120", + "providers": [ + "anthropic", + "bedrock_invoke" + ], + "features": [ + { + "id": "basic_messaging_non_streaming", + "name": "Basic messaging (non-streaming)", + "providers": { + "anthropic": { + "status": "pass" + }, + "bedrock_invoke": { + "status": "not_tested" + } + } + }, + { + "id": "tool_use", + "name": "Tool use", + "providers": { + "anthropic": { + "status": "fail", + "error": "[claude-sonnet-4-6] tool call dropped" + }, + "bedrock_invoke": { + "status": "not_applicable", + "reason": "tool use not yet wired up for Bedrock Invoke" + } + } + } + ] +} diff --git a/tests/e2e/claude_code/_builder_unit_tests/fixtures/manifest.yaml b/tests/e2e/claude_code/_builder_unit_tests/fixtures/manifest.yaml new file mode 100644 index 00000000000..e88bdc6ddf5 --- /dev/null +++ b/tests/e2e/claude_code/_builder_unit_tests/fixtures/manifest.yaml @@ -0,0 +1,9 @@ +schema_version: "1" +providers: + - anthropic + - bedrock_invoke +features: + - id: basic_messaging_non_streaming + name: Basic messaging (non-streaming) + - id: tool_use + name: Tool use diff --git a/tests/e2e/claude_code/_builder_unit_tests/fixtures/results.json b/tests/e2e/claude_code/_builder_unit_tests/fixtures/results.json new file mode 100644 index 00000000000..a01540c394f --- /dev/null +++ b/tests/e2e/claude_code/_builder_unit_tests/fixtures/results.json @@ -0,0 +1,41 @@ +{ + "schema_version": "1", + "results": [ + { + "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-haiku-4-5]", + "result": {"status": "pass"} + }, + { + "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]", + "result": {"status": "pass"} + }, + { + "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-opus-4-7]", + "result": {"status": "pass"} + }, + { + "feature_id": "tool_use", + "provider": "anthropic", + "nodeid": "tests/e2e/claude_code/tool_use/test_anthropic.py::test_x[claude-haiku-4-5]", + "result": {"status": "pass"} + }, + { + "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"} + }, + { + "feature_id": "tool_use", + "provider": "bedrock_invoke", + "nodeid": "tests/e2e/claude_code/tool_use/test_bedrock_invoke.py::test_x[claude-haiku-4-5]", + "result": {"status": "not_applicable", "reason": "tool use not yet wired up for Bedrock Invoke"} + } + ] +} diff --git a/tests/e2e/claude_code/_builder_unit_tests/test_matrix_builder.py b/tests/e2e/claude_code/_builder_unit_tests/test_matrix_builder.py new file mode 100644 index 00000000000..9ddbdd29846 --- /dev/null +++ b/tests/e2e/claude_code/_builder_unit_tests/test_matrix_builder.py @@ -0,0 +1,479 @@ +"""Golden-file tests for the Matrix JSON Builder. + +These tests fix the published JSON schema. The builder is a pure function +from (manifest, results, metadata) → matrix dict, so we feed it a fixture +input set and compare the produced dict to a checked-in expected output. + +Any schema drift — intentional or accidental — surfaces as a diff in PR +review. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from claude_code.matrix_builder import ( + ManifestError, + ResultsError, + build_from_paths, + build_matrix, + load_manifest, + load_results, +) + +FIXTURES = Path(__file__).parent / "fixtures" + + +def test_build_matrix_matches_golden_file(tmp_path): + manifest = load_manifest(FIXTURES / "manifest.yaml") + results = load_results(FIXTURES / "results.json") + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version="v1.83.0-stable", + claude_code_version="2.1.120", + generated_at="2026-04-25T00:00:00Z", + ) + expected = json.loads((FIXTURES / "expected_matrix.json").read_text()) + assert matrix == expected + + +def test_build_matrix_pass_requires_all_models_pass(): + """Multiple results in one cell must all be pass for the cell to be pass.""" + manifest = { + "schema_version": "1", + "providers": ["anthropic"], + "features": [{"id": "f", "name": "F"}], + } + results = [ + {"feature_id": "f", "provider": "anthropic", "result": {"status": "pass"}}, + {"feature_id": "f", "provider": "anthropic", "result": {"status": "pass"}}, + {"feature_id": "f", "provider": "anthropic", "result": {"status": "pass"}}, + ] + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version="v", + claude_code_version="c", + generated_at="t", + ) + assert matrix["features"][0]["providers"]["anthropic"] == {"status": "pass"} + + +def test_build_matrix_any_fail_makes_cell_fail(): + manifest = { + "schema_version": "1", + "providers": ["anthropic"], + "features": [{"id": "f", "name": "F"}], + } + results = [ + {"feature_id": "f", "provider": "anthropic", "result": {"status": "pass"}}, + { + "feature_id": "f", + "provider": "anthropic", + "result": {"status": "fail", "error": "[claude-opus-4-7] timeout"}, + }, + {"feature_id": "f", "provider": "anthropic", "result": {"status": "pass"}}, + ] + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version="v", + claude_code_version="c", + generated_at="t", + ) + cell = matrix["features"][0]["providers"]["anthropic"] + assert cell["status"] == "fail" + assert cell["error"] == "[claude-opus-4-7] timeout" + + +def test_build_matrix_joins_all_failure_errors_in_one_cell(): + """When multiple tiers fail for different reasons within the same cell, + every failure's error must appear in the published cell so triage + isn't reduced to a single tier's diagnostic. + """ + manifest = { + "schema_version": "1", + "providers": ["anthropic"], + "features": [{"id": "f", "name": "F"}], + } + results = [ + { + "feature_id": "f", + "provider": "anthropic", + "result": {"status": "fail", "error": "[claude-haiku-4-5] 429"}, + }, + {"feature_id": "f", "provider": "anthropic", "result": {"status": "pass"}}, + { + "feature_id": "f", + "provider": "anthropic", + "result": {"status": "fail", "error": "[claude-opus-4-7] timeout"}, + }, + ] + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version="v", + claude_code_version="c", + generated_at="t", + ) + cell = matrix["features"][0]["providers"]["anthropic"] + assert cell["status"] == "fail" + assert "[claude-haiku-4-5] 429" in cell["error"] + assert "[claude-opus-4-7] timeout" in cell["error"] + + +def test_build_matrix_mixed_pass_and_not_tested_surfaces_pass(): + """A `not_tested` row mixed with `pass` rows must not silently demote + the cell to `not_tested` — `not_tested` is "absent data", not a + negative signal. Otherwise a partial crash mid-test, or a test that + explicitly recorded "tier didn't run", would discard real passing + results from the published cell. + """ + manifest = { + "schema_version": "1", + "providers": ["anthropic"], + "features": [{"id": "f", "name": "F"}], + } + results = [ + {"feature_id": "f", "provider": "anthropic", "result": {"status": "pass"}}, + { + "feature_id": "f", + "provider": "anthropic", + "result": {"status": "not_tested"}, + }, + {"feature_id": "f", "provider": "anthropic", "result": {"status": "pass"}}, + ] + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version="v", + claude_code_version="c", + generated_at="t", + ) + assert matrix["features"][0]["providers"]["anthropic"] == {"status": "pass"} + + +def test_build_matrix_all_not_tested_stays_not_tested(): + """A cell whose every row is `not_tested` (or empty) must remain + `not_tested` — the absent-data rule only drops `not_tested` rows + when there's other signal to surface. + """ + manifest = { + "schema_version": "1", + "providers": ["anthropic"], + "features": [{"id": "f", "name": "F"}], + } + results = [ + { + "feature_id": "f", + "provider": "anthropic", + "result": {"status": "not_tested"}, + }, + { + "feature_id": "f", + "provider": "anthropic", + "result": {"status": "not_tested"}, + }, + ] + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version="v", + claude_code_version="c", + generated_at="t", + ) + assert matrix["features"][0]["providers"]["anthropic"] == {"status": "not_tested"} + + +def test_build_matrix_mixed_pass_and_not_applicable_surfaces_pass(): + """A `not_applicable` row mixed with `pass` rows must surface as + `pass`, not `not_applicable`. The published cell answers "does this + feature work on this provider?"; if any tier passes, the feature + works there. Discarding passing tiers because one tier is NA would + misrepresent the cell as unsupported. + """ + manifest = { + "schema_version": "1", + "providers": ["anthropic"], + "features": [{"id": "f", "name": "F"}], + } + results = [ + {"feature_id": "f", "provider": "anthropic", "result": {"status": "pass"}}, + { + "feature_id": "f", + "provider": "anthropic", + "result": { + "status": "not_applicable", + "reason": "haiku does not support extended thinking", + }, + }, + {"feature_id": "f", "provider": "anthropic", "result": {"status": "pass"}}, + ] + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version="v", + claude_code_version="c", + generated_at="t", + ) + assert matrix["features"][0]["providers"]["anthropic"] == {"status": "pass"} + + +def test_build_matrix_all_not_applicable_stays_not_applicable(): + """When every observed row is `not_applicable`, the cell remains + `not_applicable` and the first row's reason carries through to the + published matrix. + """ + manifest = { + "schema_version": "1", + "providers": ["anthropic"], + "features": [{"id": "f", "name": "F"}], + } + results = [ + { + "feature_id": "f", + "provider": "anthropic", + "result": { + "status": "not_applicable", + "reason": "feature unsupported on this provider", + }, + }, + { + "feature_id": "f", + "provider": "anthropic", + "result": {"status": "not_applicable", "reason": "ditto"}, + }, + ] + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version="v", + claude_code_version="c", + generated_at="t", + ) + assert matrix["features"][0]["providers"]["anthropic"] == { + "status": "not_applicable", + "reason": "feature unsupported on this provider", + } + + +def test_build_matrix_fills_not_tested_for_missing_cells(): + manifest = { + "schema_version": "1", + "providers": ["anthropic", "azure"], + "features": [{"id": "f", "name": "F"}], + } + results = [ + {"feature_id": "f", "provider": "anthropic", "result": {"status": "pass"}}, + ] + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version="v", + claude_code_version="c", + generated_at="t", + ) + cells = matrix["features"][0]["providers"] + assert cells["anthropic"] == {"status": "pass"} + assert cells["azure"] == {"status": "not_tested"} + + +def test_build_matrix_preserves_provider_and_feature_order(): + manifest = { + "schema_version": "1", + "providers": ["azure", "anthropic", "vertex_ai"], + "features": [ + {"id": "z", "name": "Z"}, + {"id": "a", "name": "A"}, + ], + } + matrix = build_matrix( + manifest=manifest, + results=[], + litellm_version="v", + claude_code_version="c", + generated_at="t", + ) + assert matrix["providers"] == ["azure", "anthropic", "vertex_ai"] + assert [f["id"] for f in matrix["features"]] == ["z", "a"] + assert list(matrix["features"][0]["providers"].keys()) == [ + "azure", + "anthropic", + "vertex_ai", + ] + + +def test_build_matrix_emits_schema_version_one(): + manifest = { + "schema_version": "1", + "providers": ["anthropic"], + "features": [{"id": "f", "name": "F"}], + } + matrix = build_matrix( + manifest=manifest, + results=[], + litellm_version="v", + claude_code_version="c", + generated_at="t", + ) + assert matrix["schema_version"] == "1" + + +def test_load_manifest_rejects_wrong_schema_version(tmp_path): + bad = tmp_path / "manifest.yaml" + bad.write_text( + 'schema_version: "2"\nproviders: [anthropic]\nfeatures:\n - id: f\n name: F\n' + ) + with pytest.raises(ManifestError, match="schema_version"): + load_manifest(bad) + + +def test_load_manifest_rejects_empty_features(tmp_path): + bad = tmp_path / "manifest.yaml" + bad.write_text('schema_version: "1"\nproviders: [anthropic]\nfeatures: []\n') + with pytest.raises(ManifestError): + load_manifest(bad) + + +def test_load_results_rejects_missing_results_key(tmp_path): + bad = tmp_path / "results.json" + bad.write_text(json.dumps({"schema_version": "1"})) + with pytest.raises(ResultsError): + load_results(bad) + + +def test_build_matrix_6x5_grid_matches_published_sample(): + """Slice 5 acceptance: feeding the per-model results the full v0 + row set produces reproduces the hand-authored 6x5 sample that the + docs page renders. + + Inputs mirror the structure of `compat-results.json` after a real + run with the proxy configured for all five columns and all six + feature directories: every (feature, provider, model) cell yields a + `pass`. Anthropic announced Claude in Microsoft Foundry on + 2025-11-18, so the Azure column is now exercised end-to-end like + the others rather than reporting `not_applicable`. + + The aggregated matrix must equal the checked-in + `sample_compatibility-matrix.json` byte-for-byte (after JSON load), + so any future schema drift surfaces here in review. + """ + repo_root = Path(__file__).resolve().parents[1] + full_manifest = load_manifest(repo_root / "manifest.yaml") + + # The v0 sample matrix is a frozen baseline: it covers exactly the + # six features the PRD shipped with, in their canonical order. The + # live manifest may carry additional rows (extensions added after + # v0 shipped), but the sample is derived only from the v0 slice so + # this test stays a meaningful regression gate for the v0 cell + # shape rather than chasing every new row added downstream. + v0_feature_ids = [ + "basic_messaging_non_streaming", + "basic_messaging_streaming", + "tool_use", + "prompt_caching_5m", + "vision", + # Row 6 of the v0 PRD; originally shipped as `extended_thinking`. + # The id was renamed in-place to `thinking` to match Anthropic's + # current docs (which reserve "extended thinking" for the + # deprecated manual mode only). The row's *position* in v0 is + # the load-bearing invariant, not the id string. + "thinking", + ] + v0_features = [ + feature + for feature in full_manifest["features"] + if feature["id"] in v0_feature_ids + ] + manifest = {**full_manifest, "features": v0_features} + + 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"] + + results = [] + for feature_id in feature_ids: + for provider in providers: + for model in models: + results.append( + { + "feature_id": feature_id, + "provider": provider, + "nodeid": ( + f"tests/e2e/claude_code/{feature_id}/test_{provider}.py" + f"::test[{model}]" + ), + "result": {"status": "pass"}, + } + ) + + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version="v1.83.0-stable", + claude_code_version="2.1.120", + generated_at="2026-04-25T00:00:00Z", + ) + expected = json.loads((repo_root / "sample_compatibility-matrix.json").read_text()) + assert matrix == expected + + +def test_build_matrix_1x5_grid_one_failing_model_breaks_cell(): + """If even one of three models fails on a provider, that cell is fail + and the error string carries the failing model id so the docs + tooltip can name the outlier.""" + repo_root = Path(__file__).resolve().parents[1] + manifest = load_manifest(repo_root / "manifest.yaml") + + results = [ + { + "feature_id": "basic_messaging_non_streaming", + "provider": "bedrock_invoke", + "result": {"status": "pass"}, + }, + { + "feature_id": "basic_messaging_non_streaming", + "provider": "bedrock_invoke", + "result": { + "status": "fail", + "error": "[claude-opus-4-7-bedrock-invoke] claude CLI exited 1: throttled", + }, + }, + { + "feature_id": "basic_messaging_non_streaming", + "provider": "bedrock_invoke", + "result": {"status": "pass"}, + }, + ] + + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version="v", + claude_code_version="c", + generated_at="t", + ) + cell = matrix["features"][0]["providers"]["bedrock_invoke"] + assert cell["status"] == "fail" + assert "claude-opus-4-7-bedrock-invoke" in cell["error"] + + +def test_build_from_paths_writes_output(tmp_path): + out = tmp_path / "compatibility-matrix.json" + matrix = build_from_paths( + manifest_path=FIXTURES / "manifest.yaml", + results_path=FIXTURES / "results.json", + litellm_version="v1.83.0-stable", + claude_code_version="2.1.120", + generated_at="2026-04-25T00:00:00Z", + output_path=out, + ) + assert out.exists() + on_disk = json.loads(out.read_text()) + assert on_disk == matrix + expected = json.loads((FIXTURES / "expected_matrix.json").read_text()) + assert on_disk == expected diff --git a/tests/e2e/claude_code/_builder_unit_tests/test_v0_layout.py b/tests/e2e/claude_code/_builder_unit_tests/test_v0_layout.py new file mode 100644 index 00000000000..b1745008fac --- /dev/null +++ b/tests/e2e/claude_code/_builder_unit_tests/test_v0_layout.py @@ -0,0 +1,183 @@ +"""Structural tests for the full v0 6x5 matrix layout. + +These tests don't run the `claude` CLI — they only verify that the +shape of the test suite on disk matches what the PRD declares: six +features in the prescribed order, and for each feature a directory +with one test file per provider column. + +Catching layout drift here means the daily-cron VM and the PR gate +both see the same row set the docs page declares. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +MANIFEST_PATH = REPO_ROOT / "manifest.yaml" + +# The PRD's "Features in v0" section, in row order. +EXPECTED_FEATURE_IDS = [ + "basic_messaging_non_streaming", + "basic_messaging_streaming", + "tool_use", + "prompt_caching_5m", + "vision", + # v0 originally shipped this row as `extended_thinking`. It was + # renamed in-place to `thinking` because Anthropic's docs reserve + # "extended thinking" for the deprecated manual API mode only; the + # single row exercises both manual and adaptive shapes since Claude + # Code picks per model. The PRD's "v0" identity is the *position* + # (row 6, 0-indexed 5), not the id string. + "thinking", +] + +# The PRD's column order. Every feature directory must have one +# `test_.py` for each of these. +EXPECTED_PROVIDERS = [ + "anthropic", + "bedrock_invoke", + "bedrock_converse", + "vertex_ai", + "azure", +] + + +def _all_manifest_feature_ids() -> list[str]: + """Every feature_id currently declared in `manifest.yaml`. + + Evaluated at import time so the result can drive parametrized + structural tests below. Used to catch layout drift on post-v0 + feature rows added after the matrix shipped — the v0 anchor + constants above only validate the original six rows by design. + """ + return [ + feature["id"] + for feature in yaml.safe_load(MANIFEST_PATH.read_text())["features"] + ] + + +ALL_FEATURE_IDS = _all_manifest_feature_ids() + + +@pytest.fixture(scope="module") +def manifest() -> dict: + return yaml.safe_load(MANIFEST_PATH.read_text()) + + +def test_manifest_lists_all_six_v0_features_in_order(manifest): + """The PRD's v0 row set must appear at the top of the manifest in + order. Features beyond v0 (extensions added after the matrix + shipped) are allowed but must not reorder or displace the v0 + rows — the docs page anchors row links by index, so v0 stays + pinned at positions [0:6] for the lifetime of the schema. + """ + ids = [feature["id"] for feature in manifest["features"]] + assert ids[: len(EXPECTED_FEATURE_IDS)] == EXPECTED_FEATURE_IDS + + +def test_manifest_lists_all_five_v0_providers_in_order(manifest): + assert manifest["providers"] == EXPECTED_PROVIDERS + + +def test_manifest_every_feature_has_human_readable_name(manifest): + for feature in manifest["features"]: + assert isinstance(feature["name"], str) and feature["name"].strip() + + +@pytest.mark.parametrize("feature_id", EXPECTED_FEATURE_IDS) +def test_feature_directory_exists(feature_id): + feature_dir = REPO_ROOT / feature_id + assert feature_dir.is_dir(), f"missing feature directory: {feature_dir}" + + +@pytest.mark.parametrize("feature_id", EXPECTED_FEATURE_IDS) +@pytest.mark.parametrize("provider", EXPECTED_PROVIDERS) +def test_per_provider_test_file_exists(feature_id, provider): + test_file = REPO_ROOT / feature_id / f"test_{provider}.py" + assert test_file.is_file(), f"missing per-provider test file: {test_file}" + + +@pytest.mark.parametrize("feature_id", EXPECTED_FEATURE_IDS) +def test_feature_directory_has_init_file(feature_id): + """Each feature directory needs an __init__.py so pytest collects + the per-provider test files as a package — matches the layout + established by `basic_messaging_non_streaming/`.""" + init_file = REPO_ROOT / feature_id / "__init__.py" + assert init_file.is_file(), f"missing __init__.py: {init_file}" + + +# Manifest-driven structural tests: every feature in `manifest.yaml` +# (v0 and post-v0 alike) must have the expected on-disk layout. The +# v0-only tests above pin the position of the original six rows; these +# extend the same structural guarantees to any row added afterward so +# a broken post-v0 directory still fails CI. +@pytest.mark.parametrize("feature_id", ALL_FEATURE_IDS) +def test_every_manifest_feature_has_directory(feature_id): + feature_dir = REPO_ROOT / feature_id + assert feature_dir.is_dir(), ( + f"manifest declares {feature_id!r} but {feature_dir} is missing — " + "feature_id MUST match its on-disk directory (see manifest.yaml header)." + ) + + +@pytest.mark.parametrize("feature_id", ALL_FEATURE_IDS) +def test_every_manifest_feature_has_init_file(feature_id): + init_file = REPO_ROOT / feature_id / "__init__.py" + assert init_file.is_file(), f"missing __init__.py: {init_file}" + + +@pytest.mark.parametrize("feature_id", ALL_FEATURE_IDS) +@pytest.mark.parametrize("provider", EXPECTED_PROVIDERS) +def test_every_manifest_feature_has_per_provider_test_file(feature_id, provider): + """Every (feature, provider) cell in the rendered matrix must be + backed by a per-provider test file. Without this check, a missing + file silently becomes a `not_tested` cell in the published matrix + rather than a CI failure surfacing the layout drift.""" + test_file = REPO_ROOT / feature_id / f"test_{provider}.py" + assert test_file.is_file(), f"missing per-provider test file: {test_file}" + + +@pytest.mark.parametrize("feature_id", EXPECTED_FEATURE_IDS) +@pytest.mark.parametrize("provider", EXPECTED_PROVIDERS) +def test_per_provider_test_file_imports_and_parametrizes_three_models( + feature_id, provider +): + """Every test file must reference the three Claude tiers required + by the PRD: Haiku 4.5, Sonnet 4.6, Opus 4.7. Implementations may + use plain aliases or per-provider-suffixed aliases (e.g. + `claude-opus-4-7-bedrock-invoke`), so we check for the tier + substrings rather than exact alias names.""" + text = (REPO_ROOT / feature_id / f"test_{provider}.py").read_text() + for tier in ("haiku-4-5", "sonnet-4-6", "opus-4-7"): + assert ( + tier in text + ), f"{feature_id}/test_{provider}.py does not reference {tier}" + + +@pytest.mark.parametrize("feature_id", EXPECTED_FEATURE_IDS) +def test_azure_test_file_drives_the_proxy(feature_id): + """Azure (Microsoft Foundry) hosts Anthropic Claude as of 2025-11-18, + so every Azure cell in the v0 matrix exercises a real route through + the LiteLLM proxy — same shape as the other provider columns. Pin + that here so a future regression doesn't silently revert these + cells to the old `not_applicable` boilerplate. + + We accept either the direct `run_claude(...)` family of entrypoints + or a per-feature shared helper (e.g. `run_basic_messaging_cell`) + that wraps them — both shapes drive the proxy, and we don't want + this layout pin to block legitimate de-duplication of test bodies. + """ + text = (REPO_ROOT / feature_id / "test_azure.py").read_text() + assert "run_claude" in text or "run_basic_messaging_cell" in text, ( + f"{feature_id}/test_azure.py must drive the claude CLI via run_claude() " + "or a shared helper that wraps it; the not_applicable stub was removed " + "when Foundry started hosting Claude." + ) + assert '"status": "not_applicable"' not in text, ( + f"{feature_id}/test_azure.py still reports not_applicable; Microsoft Foundry " + "now hosts Claude (Haiku 4.5, Sonnet 4.6, Opus 4.7), so this row must run." + ) diff --git a/tests/e2e/claude_code/_driver_unit_tests/__init__.py b/tests/e2e/claude_code/_driver_unit_tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/_driver_unit_tests/conftest.py b/tests/e2e/claude_code/_driver_unit_tests/conftest.py new file mode 100644 index 00000000000..bfeaa57c736 --- /dev/null +++ b/tests/e2e/claude_code/_driver_unit_tests/conftest.py @@ -0,0 +1,32 @@ +"""Local conftest for the driver unit tests. + +Installs a hermetic, no-op rate limiter for every test in this +subdirectory. Without this, importing `cli_driver` and calling +`run_claude(..., runner=fake)` would silently consume tokens from the +shared default limiter (which writes to `$TMPDIR/...`), polluting the +on-disk state another test run might rely on and adding flakiness if +the env vars say "rate=0.1/s". + +A no-op limiter (rate=0 for every provider) returns immediately from +`acquire(...)`, so unit tests behave exactly as they did before the +limiter was added. +""" + +from __future__ import annotations + +import pytest + +from claude_code.rate_limiter import ( + ALL_PROVIDERS, + ProviderConfig, + RateLimiter, + use_limiter, +) + + +@pytest.fixture(autouse=True) +def _hermetic_rate_limiter(tmp_path): + config = {p: ProviderConfig(rate_per_sec=0.0, burst=0.0) for p in ALL_PROVIDERS} + limiter = RateLimiter(config=config, state_dir=tmp_path) + with use_limiter(limiter): + yield diff --git a/tests/e2e/claude_code/_driver_unit_tests/test_basic_messaging.py b/tests/e2e/claude_code/_driver_unit_tests/test_basic_messaging.py new file mode 100644 index 00000000000..018121a8e5c --- /dev/null +++ b/tests/e2e/claude_code/_driver_unit_tests/test_basic_messaging.py @@ -0,0 +1,201 @@ +"""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. +""" + +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, + run_basic_messaging_cell, +) +from claude_code.cli_driver import DriverResult + + +class _FakeResult: + """Stand-in for the test's `compat_result` fixture. + + Records every `set` / `add` payload so assertions can inspect what + the cell reported, in order, without needing the real + `pytest_runtest_logreport` plumbing from `conftest.py`. + """ + + def __init__(self) -> None: + self.rows: List[Dict[str, Any]] = [] + self.single: Optional[Dict[str, Any]] = None + + def set(self, payload: Mapping[str, Any]) -> None: + self.single = dict(payload) + + def add(self, payload: Mapping[str, Any]) -> None: + self.rows.append(dict(payload)) + + +def _streamed_events(n_deltas: int = 5) -> List[Dict[str, Any]]: + """Build a stream-json event list that *looks* streamed. + + Includes `n_deltas` `stream_event` records (matching what + `--include-partial-messages` produces) plus the usual + `system`/`assistant`/`result` boilerplate the CLI always emits. + """ + events: List[Dict[str, Any]] = [{"type": "system", "subtype": "init"}] + for i in range(n_deltas): + events.append( + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": str(i)}, + }, + } + ) + events.append( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "1\n2\n3"}]}, + } + ) + events.append({"type": "result"}) + return events + + +def _buffered_events() -> List[Dict[str, Any]]: + """Event list a buffering proxy would produce: zero `stream_event`s.""" + return [ + {"type": "system", "subtype": "init"}, + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "1\n2\n3"}]}, + }, + {"type": "result"}, + ] + + +def _install_fake_runner(monkeypatch, *, outcomes_by_model): + """Patch `run_claude_models_parallel` to return canned outcomes. + + 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`). + """ + captured: Dict[str, Any] = {} + + def fake(*, models, prompt, base_url, api_key, extra_args=None, **_kwargs): + captured["models"] = list(models) + captured["prompt"] = prompt + captured["base_url"] = base_url + captured["api_key"] = api_key + 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") + + +def test_count_stream_event_deltas_only_counts_records_with_event_payload(): + events = [ + {"type": "system"}, + {"type": "stream_event", "event": {"type": "message_start"}}, + {"type": "stream_event", "event": {"type": "content_block_delta"}}, + {"type": "stream_event"}, + {"type": "stream_event", "event": None}, + {"type": "stream_event", "event": "not-a-dict"}, + {"type": "assistant"}, + {"type": "result"}, + ] + assert _count_stream_event_deltas(events) == 2 + + +def test_verify_streaming_passes_when_proxy_streams(monkeypatch): + 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}) + + run_basic_messaging_cell( + compat_result=fake_result, + models=[model], + prompt="Count from 1 to 5, one number per line.", + verify_streaming=True, + ) + + assert captured["extra_args"] == ["--include-partial-messages"] + assert fake_result.rows == [{"status": "pass"}] + + +def test_verify_streaming_fails_when_proxy_buffers(monkeypatch): + 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}) + + with pytest.raises(pytest.fail.Exception): + run_basic_messaging_cell( + compat_result=fake_result, + models=[model], + prompt="Count from 1 to 5, one number per line.", + verify_streaming=True, + ) + + assert len(fake_result.rows) == 1 + row = fake_result.rows[0] + assert row["status"] == "fail" + assert "stream_event" in row["error"] + assert f"< {MIN_STREAM_DELTA_EVENTS}" in row["error"] + + +def test_non_streaming_variant_omits_partial_messages_flag(monkeypatch): + """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}) + + run_basic_messaging_cell( + compat_result=fake_result, + models=[model], + prompt="Reply with the single word 'pong' and nothing else.", + ) + + assert captured["extra_args"] == [] + assert fake_result.rows == [{"status": "pass"}] + + +def test_verify_streaming_requires_all_models_to_stream(monkeypatch): + """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-opus-4-7": DriverResult(text="ok", events=_streamed_events(5)), + } + _install_fake_runner(monkeypatch, outcomes_by_model=outcomes) + + with pytest.raises(pytest.fail.Exception): + run_basic_messaging_cell( + compat_result=fake_result, + models=list(outcomes.keys()), + prompt="Count from 1 to 5, one number per line.", + verify_streaming=True, + ) + + statuses = [row["status"] for row in fake_result.rows] + assert statuses == ["pass", "fail", "pass"] diff --git a/tests/e2e/claude_code/_driver_unit_tests/test_cli_driver.py b/tests/e2e/claude_code/_driver_unit_tests/test_cli_driver.py new file mode 100644 index 00000000000..786f6029993 --- /dev/null +++ b/tests/e2e/claude_code/_driver_unit_tests/test_cli_driver.py @@ -0,0 +1,795 @@ +"""Unit tests for the Claude Code CLI Driver. + +These tests mock the subprocess so they run anywhere — no network, no +`claude` install, no API keys. They cover the behavior contract: +argument assembly, environment overlay, stream-JSON parsing, exit-code +plumbing, and the structured failure modes (CLI not found, timeout). +""" + +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass +from typing import List, Optional + +import pytest + +from claude_code.cli_driver import ( + ClaudeCLIError, + DriverResult, + failure_diagnostic, + run_claude, + run_claude_models_parallel, +) + + +@dataclass +class _Completed: + returncode: int = 0 + stdout: str = "" + stderr: str = "" + + +def _make_runner(*, stdout: str = "", returncode: int = 0, stderr: str = ""): + captured = {} + + def runner(cmd, env, capture_output, text, timeout, check, input=None): + captured["cmd"] = cmd + captured["env"] = env + captured["timeout"] = timeout + captured["input"] = input + return _Completed(returncode=returncode, stdout=stdout, stderr=stderr) + + return runner, captured + + +def test_run_claude_assembles_command_correctly(): + runner, captured = _make_runner( + stdout='{"type":"assistant","message":{"content":[{"type":"text","text":"ok"}]}}\n' + ) + run_claude( + prompt="hello", + model="claude-haiku-4-5", + base_url="http://localhost:4000", + api_key="sk-test", + runner=runner, + ) + cmd = captured["cmd"] + assert cmd[0] == "claude" + assert "--print" in cmd + assert "--output-format" in cmd + assert "stream-json" in cmd + assert "--model" in cmd + assert "claude-haiku-4-5" in cmd + # prompt is the last positional after the `--` end-of-options marker. + assert cmd[-2:] == ["--", "hello"] + + +def test_run_claude_places_extra_args_before_prompt(): + """`claude --print` expects the prompt as the final positional arg. + + Flags appearing after the prompt are ignored or eaten by the prompt + parser (especially variadic flags like `--allowed-tools `), + which silently broke the tool_use, vision, and web_search cells + before the fix. Pin the ordering: every flag (including + caller-supplied `extra_args`) must precede the `--` end-of-options + marker, which itself precedes the prompt. + """ + runner, captured = _make_runner(stdout="") + run_claude( + prompt="say hi", + model="claude-haiku-4-5", + base_url="http://localhost:4000", + api_key="sk-test", + extra_args=["--allowed-tools", "Bash"], + runner=runner, + ) + cmd = captured["cmd"] + # Prompt is last, `--` immediately precedes it, and the caller's + # extra_args sit somewhere earlier in the command. + assert cmd[-2:] == ["--", "say hi"] + assert "--allowed-tools" in cmd + assert cmd.index("--allowed-tools") < cmd.index("--") + + +def test_run_claude_overlays_proxy_env(): + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://proxy.example:4000", + api_key="sk-abc", + runner=runner, + ) + env = captured["env"] + assert env["ANTHROPIC_BASE_URL"] == "http://proxy.example:4000" + assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-abc" + + +def test_run_claude_extra_env_is_added_to_subprocess_env(): + """Caller-supplied extra_env entries land on the subprocess env.""" + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + extra_env={"MAX_THINKING_TOKENS": "4096"}, + runner=runner, + ) + assert captured["env"]["MAX_THINKING_TOKENS"] == "4096" + + +def test_run_claude_inherits_only_allowlisted_os_environ(monkeypatch): + """Process-runtime vars (PATH) flow through; credentials don't. + + The `claude` CLI is a Node binary installed dynamically from npm in + CI. If the package were ever compromised, inheriting the entire + parent environment would hand it every credential the surrounding + proxy job loads (AWS keys, Azure Foundry key, GitHub token, etc.). + Pin the contract: only the small allowlist of runtime vars is + inherited; everything else is dropped unless the caller passes it + explicitly via extra_env. + + `HOME` is *not* on the allowlist anymore — see the dedicated + isolated-HOME test below for the reason. + """ + monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") + 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("VERTEXAI_CREDENTIALS", '{"private_key": "leak"}') + monkeypatch.setenv("GITHUB_TOKEN", "ghs_xxx") + + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + env = captured["env"] + 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 "VERTEXAI_CREDENTIALS" not in env + assert "GITHUB_TOKEN" not in env + + +def test_run_claude_uses_isolated_per_invocation_home(monkeypatch, tmp_path): + """`claude` subprocess never sees the runtime user's real $HOME. + + The CLI needs *a* HOME (it caches per-session state under + `$HOME/.claude/projects//`), but it has no business reading + the runtime user's real one. On the cron VM the runtime user is a + real interactive account with a populated home directory + (~/.config/gh/hosts.yml carrying a GitHub token, ~/.ssh/, etc.); + handing /home/mateo to a compromised npm package — or to a + model-directed `Read` tool call during the PDF/vision cells — + would let it exfiltrate those files. We hand the CLI a fresh + empty per-invocation tmpdir instead. + """ + monkeypatch.setenv("HOME", "/home/runner") + + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + env = captured["env"] + assert "HOME" in env, "claude CLI needs HOME to find ~/.claude session dir" + assert ( + env["HOME"] != "/home/runner" + ), "HOME must not leak the parent process's HOME to claude" + # The isolated HOME is a fresh tmpdir prefixed `claude-cli-home-`; + # see `_make_isolated_home` in cli_driver.py. It exists during the + # subprocess call and is removed afterwards (cleanup runs in a + # `finally`, so by the time this assertion runs the dir is gone — + # we only check the *prefix* of the path string we captured). + assert "claude-cli-home-" in env["HOME"] + + +def test_run_claude_isolated_home_is_distinct_per_invocation(monkeypatch): + """Two consecutive calls get two different isolated HOMEs. + + Reusing a single tmpdir across calls would defeat the isolation + in the parallel matrix run (a compromised CLI could plant a file + in HOME on one model's run and read it on the next). Pin: each + `run_claude` invocation gets its own freshly-created HOME. + """ + monkeypatch.setenv("HOME", "/home/runner") + + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + home_a = captured["env"]["HOME"] + + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + home_b = captured["env"]["HOME"] + + assert home_a != home_b + + +def test_run_claude_isolated_home_cleaned_up_after_run(monkeypatch): + """The per-invocation HOME tmpdir is rm-rf'd when run_claude returns. + + Without cleanup, a long matrix run would accumulate one tmpdir + per cell × per model × per CLI call (~75 dirs per cron run, + growing without bound across days). + """ + import os as _os + + monkeypatch.setenv("HOME", "/home/runner") + + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + isolated_home = captured["env"]["HOME"] + assert not _os.path.exists( + isolated_home + ), f"isolated HOME {isolated_home!r} should be removed after run_claude returns" + + +def test_run_claude_isolated_home_cleaned_up_on_subprocess_failure(monkeypatch): + """Cleanup runs even when the CLI subprocess raises. + + If the CLI is missing or times out, `run_claude` raises + `ClaudeCLIError` — but the per-invocation HOME tmpdir must still + be removed (the `finally` clause), otherwise long failure-prone + runs leak tmpdirs. + """ + import os as _os + + monkeypatch.setenv("HOME", "/home/runner") + + captured: dict = {} + + def runner(cmd, env, capture_output, text, timeout, check, input=None): + captured["env"] = env + raise subprocess.TimeoutExpired(cmd=cmd, timeout=timeout) + + with pytest.raises(ClaudeCLIError): + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + + isolated_home = captured["env"]["HOME"] + assert not _os.path.exists( + isolated_home + ), f"isolated HOME {isolated_home!r} should be removed even on timeout" + + +def test_run_claude_extra_env_can_pass_through_otherwise_blocked_var(monkeypatch): + """The allowlist applies to inherited os.environ; extra_env is the + sanctioned way for a test to opt-in to passing something extra.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "from-os") + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + extra_env={"ANTHROPIC_API_KEY": "from-arg"}, + runner=runner, + ) + assert captured["env"]["ANTHROPIC_API_KEY"] == "from-arg" + + +def test_run_claude_parses_stream_json_assistant_text(): + events = [ + {"type": "system", "session_id": "abc"}, + { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Hello "}, + {"type": "text", "text": "world"}, + ] + }, + }, + {"type": "result", "usage": {"input_tokens": 10, "output_tokens": 2}}, + ] + stdout = "\n".join(json.dumps(e) for e in events) + "\n" + runner, _ = _make_runner(stdout=stdout) + result = run_claude( + prompt="hi", + model="claude-haiku-4-5", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + assert isinstance(result, DriverResult) + assert result.text == "Hello world" + assert len(result.events) == 3 + assert result.usage == {"input_tokens": 10, "output_tokens": 2} + assert result.exit_code == 0 + + +def test_run_claude_handles_string_message_content(): + """Some CLI versions emit `message.content` as a plain string.""" + stdout = ( + json.dumps({"type": "assistant", "message": {"content": "bare text"}}) + "\n" + ) + runner, _ = _make_runner(stdout=stdout) + result = run_claude( + prompt="hi", + model="m", + base_url="http://x", + api_key="k", + runner=runner, + ) + assert result.text == "bare text" + + +def test_run_claude_skips_malformed_lines(): + stdout = ( + "not-json\n" + + json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "x"}]}, + } + ) + + "\n" + + "{also-bad\n" + ) + runner, _ = _make_runner(stdout=stdout) + result = run_claude( + prompt="hi", + model="m", + base_url="http://x", + api_key="k", + runner=runner, + ) + assert result.text == "x" + assert len(result.events) == 1 + + +def test_run_claude_propagates_nonzero_exit_code(): + runner, _ = _make_runner(stdout="", returncode=2, stderr="auth failed") + result = run_claude( + prompt="hi", + model="m", + base_url="http://x", + api_key="k", + runner=runner, + ) + assert result.exit_code == 2 + assert result.stderr == "auth failed" + assert result.text == "" + + +def test_run_claude_raises_on_missing_cli(): + def runner(*args, **kwargs): + raise FileNotFoundError(2, "no such file", "claude") + + with pytest.raises(ClaudeCLIError, match="claude CLI not found"): + run_claude( + prompt="hi", + model="m", + base_url="http://x", + api_key="k", + runner=runner, + ) + + +def test_run_claude_raises_on_timeout(): + def runner(*args, **kwargs): + raise subprocess.TimeoutExpired(cmd="claude", timeout=1) + + with pytest.raises(ClaudeCLIError, match="timed out"): + run_claude( + prompt="hi", + model="m", + base_url="http://x", + api_key="k", + timeout=1, + runner=runner, + ) + + +def test_run_claude_validates_required_params(): + runner, _ = _make_runner() + with pytest.raises(ValueError, match="prompt"): + run_claude( + prompt="", + model="m", + base_url="http://x", + api_key="k", + runner=runner, + ) + with pytest.raises(ValueError, match="stdin_input"): + run_claude( + prompt=None, + stdin_input="", + model="m", + base_url="http://x", + api_key="k", + runner=runner, + ) + with pytest.raises(ValueError, match="model"): + run_claude( + prompt="hi", + model="", + base_url="http://x", + api_key="k", + runner=runner, + ) + with pytest.raises(ValueError, match="base_url"): + run_claude( + prompt="hi", + model="m", + base_url="", + api_key="k", + runner=runner, + ) + with pytest.raises(ValueError, match="api_key"): + run_claude( + prompt="hi", + model="m", + base_url="http://x", + api_key="", + runner=runner, + ) + + +# --------------------------------------------------------------------------- +# failure_diagnostic +# +# Regression coverage for the bring-up incident where the proxy was started +# with the wrong config and tests reported only `claude CLI exited 1` while +# the actual 400 from LiteLLM was sitting in stdout. The helper must surface +# api_status, the assistant text (where API errors land), stderr, and the +# exit code together — and gracefully degrade when individual pieces are +# missing. +# --------------------------------------------------------------------------- + + +def test_failure_diagnostic_surfaces_api_error_text_from_stdout(): + """The CLI hides 4xx/5xx from the proxy in `assistant.message.content` text.""" + api_error_text = ( + 'API Error: 400 {"error":{"message":"litellm.BadRequestError: ' + "You passed in model=claude-haiku-4-5. There are no healthy " + 'deployments..."}}' + ) + result = DriverResult( + text=api_error_text, + events=[ + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": api_error_text}]}, + }, + { + "type": "result", + "is_error": True, + "api_error_status": 400, + "result": api_error_text, + }, + ], + exit_code=1, + stderr="", + ) + + diag = failure_diagnostic(result) + + assert "exit=1" in diag + assert "api_status=400" in diag + assert "There are no healthy deployments" in diag + + +def test_failure_diagnostic_falls_back_to_stderr_when_no_text(): + result = DriverResult(text="", events=[], exit_code=2, stderr="boom\n") + diag = failure_diagnostic(result) + assert "exit=2" in diag + assert "stderr=boom" in diag + + +def test_failure_diagnostic_handles_completely_empty_result(): + """A run that produced literally nothing should still yield a useful string.""" + result = DriverResult(text="", events=[], exit_code=137, stderr="") + diag = failure_diagnostic(result) + assert "exit=137" in diag + assert "no diagnostic output" in diag + + +def test_failure_diagnostic_truncates_long_text(): + """Don't let a 5MB HTML 502 page from a load balancer wreck the matrix JSON.""" + huge = "x" * 5000 + result = DriverResult(text=huge, events=[], exit_code=1, stderr="") + diag = failure_diagnostic(result, max_len=100) + assert "truncated" in diag + # Allow some slack for the prefix/suffix/separator characters. + assert len(diag) < 300 + + +def test_failure_diagnostic_ignores_non_int_api_error_status(): + """The CLI sometimes emits api_error_status as a string; don't crash.""" + result = DriverResult( + text="oops", + events=[{"type": "result", "api_error_status": "n/a"}], + exit_code=1, + stderr="", + ) + diag = failure_diagnostic(result) + assert "api_status" not in diag + assert "text=oops" in diag + + +# --------------------------------------------------------------------------- +# run_claude_models_parallel +# +# The matrix runs three Claude tiers per cell, so the parallel helper has to +# (a) invoke `run_claude` once per model, (b) preserve each model's outcome +# separately, and (c) return errors as values rather than raising — callers +# need both the failed and the succeeded model results to report per-cell +# rows accurately. +# --------------------------------------------------------------------------- + + +def test_run_claude_models_parallel_returns_one_result_per_model(): + """Each model gets its own DriverResult keyed under the helper's dict.""" + seen_models: List[str] = [] + + def runner(cmd, env, capture_output, text, timeout, check, input=None): + # The model id is two slots after `--model` in the assembled command. + idx = cmd.index("--model") + model = cmd[idx + 1] + seen_models.append(model) + return _Completed( + returncode=0, + stdout=json.dumps( + { + "type": "assistant", + "message": { + "content": [{"type": "text", "text": f"reply-{model}"}] + }, + } + ) + + "\n", + ) + + outcomes = run_claude_models_parallel( + models=["a", "b", "c"], + prompt="hi", + base_url="http://x", + api_key="k", + runner=runner, + ) + + assert set(outcomes.keys()) == {"a", "b", "c"} + for model in ("a", "b", "c"): + result = outcomes[model] + assert isinstance(result, DriverResult) + assert result.text == f"reply-{model}" + assert result.exit_code == 0 + assert sorted(seen_models) == ["a", "b", "c"] + + +def test_run_claude_models_parallel_returns_errors_as_values(): + """A model whose CLI is missing surfaces as a ClaudeCLIError, not a raise.""" + + def runner(cmd, env, capture_output, text, timeout, check, input=None): + idx = cmd.index("--model") + model = cmd[idx + 1] + if model == "boom": + raise FileNotFoundError(2, "no such file", "claude") + return _Completed( + returncode=0, + stdout=json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "ok"}]}, + } + ) + + "\n", + ) + + outcomes = run_claude_models_parallel( + models=["ok-model", "boom"], + prompt="hi", + base_url="http://x", + api_key="k", + runner=runner, + ) + + assert isinstance(outcomes["ok-model"], DriverResult) + assert outcomes["ok-model"].text == "ok" + assert isinstance(outcomes["boom"], ClaudeCLIError) + assert "claude CLI not found" in str(outcomes["boom"]) + + +def test_run_claude_models_parallel_preserves_nonzero_exit_codes(): + """Mixed success/failure on exit code should not collapse into one verdict.""" + + def runner(cmd, env, capture_output, text, timeout, check, input=None): + idx = cmd.index("--model") + model = cmd[idx + 1] + if model == "fail": + return _Completed(returncode=2, stdout="", stderr="auth failed") + return _Completed( + returncode=0, + stdout=json.dumps( + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "ok"}]}, + } + ) + + "\n", + ) + + outcomes = run_claude_models_parallel( + models=["ok-model", "fail"], + prompt="hi", + base_url="http://x", + api_key="k", + runner=runner, + ) + + assert outcomes["ok-model"].exit_code == 0 + assert outcomes["fail"].exit_code == 2 + assert outcomes["fail"].stderr == "auth failed" + + +def test_run_claude_models_parallel_rejects_empty_models(): + with pytest.raises(ValueError, match="non-empty"): + run_claude_models_parallel( + models=[], + prompt="hi", + base_url="http://x", + api_key="k", + ) + + +def test_run_claude_models_parallel_stamps_duration_on_each_result(): + """Each DriverResult carries the per-model wall time so callers can + attribute slow cells without re-timing the work themselves. + + The fake runner sleeps for very different durations per model so + we can prove each result is timing its own work (not the batch + wall time). We use generous absolute bounds because thread-pool + scheduling on a loaded CI box adds noise on the order of tens of + milliseconds. + """ + import time + + def runner(cmd, env, capture_output, text, timeout, check, input=None): + idx = cmd.index("--model") + model = cmd[idx + 1] + time.sleep(0.05 if model == "fast" else 0.40) + return _Completed(returncode=0, stdout="") + + outcomes = run_claude_models_parallel( + models=["fast", "slow"], + prompt="hi", + base_url="http://x", + api_key="k", + runner=runner, + ) + + fast_ms = outcomes["fast"].duration_ms + slow_ms = outcomes["slow"].duration_ms + assert fast_ms is not None and slow_ms is not None + # 50ms sleep ⇒ ~50–250ms after scheduling overhead; 400ms sleep ⇒ + # 400–700ms. We just need the two distributions to be non-overlapping + # so we know each row's duration is its own work, not the batch's. + assert fast_ms < 300, fast_ms + assert slow_ms >= 350, slow_ms + assert slow_ms > fast_ms + + +def test_run_claude_models_parallel_breakdown_logs_to_stderr(capsys): + """The breakdown helper must emit a per-model timing block so users + can answer "why didn't parallel help?" without re-instrumenting.""" + + def runner(cmd, env, capture_output, text, timeout, check, input=None): + return _Completed(returncode=0, stdout="") + + run_claude_models_parallel( + models=["model-x", "model-y"], + prompt="hi", + base_url="http://x", + api_key="k", + runner=runner, + ) + + captured = capsys.readouterr() + assert "[parallel] per-model wall time:" in captured.err + assert "model-x" in captured.err + assert "model-y" in captured.err + assert "speedup=" in captured.err + assert "slowest=" in captured.err + + +def test_run_claude_models_parallel_breakdown_marks_cli_errors(capsys): + """When a model raises ClaudeCLIError, the breakdown should still + show its row tagged as `cli-error` rather than crashing or omitting it.""" + + def runner(cmd, env, capture_output, text, timeout, check, input=None): + idx = cmd.index("--model") + if cmd[idx + 1] == "boom": + raise FileNotFoundError(2, "no such file", "claude") + return _Completed(returncode=0, stdout="") + + run_claude_models_parallel( + models=["ok-model", "boom"], + prompt="hi", + base_url="http://x", + api_key="k", + runner=runner, + ) + + captured = capsys.readouterr() + assert "ok-model" in captured.err + assert "boom" in captured.err + assert "cli-error" in captured.err + + +def test_run_claude_models_parallel_forwards_extra_args_and_env(): + """Shared kwargs must reach every per-model invocation unchanged.""" + captured_envs: List[dict] = [] + captured_cmds: List[List[str]] = [] + + def runner(cmd, env, capture_output, text, timeout, check, input=None): + captured_envs.append(env) + captured_cmds.append(cmd) + return _Completed(returncode=0, stdout="") + + run_claude_models_parallel( + models=["a", "b"], + prompt="hi", + base_url="http://x", + api_key="k", + extra_env={"MAX_THINKING_TOKENS": "4096"}, + extra_args=["--allowed-tools", "Bash"], + runner=runner, + ) + + assert all(env["MAX_THINKING_TOKENS"] == "4096" for env in captured_envs) + for cmd in captured_cmds: + assert "--allowed-tools" in cmd + assert "Bash" in cmd + + +def test_failure_diagnostic_uses_last_result_event_status(): + """If multiple `result` events appear, the most recent status wins.""" + result = DriverResult( + text="", + events=[ + {"type": "result", "api_error_status": 500}, + {"type": "assistant", "message": {"content": []}}, + {"type": "result", "api_error_status": 429}, + ], + exit_code=1, + stderr="", + ) + diag = failure_diagnostic(result) + assert "api_status=429" in diag + assert "500" not in diag diff --git a/tests/e2e/claude_code/_driver_unit_tests/test_compat_result.py b/tests/e2e/claude_code/_driver_unit_tests/test_compat_result.py new file mode 100644 index 00000000000..b3a904946d3 --- /dev/null +++ b/tests/e2e/claude_code/_driver_unit_tests/test_compat_result.py @@ -0,0 +1,138 @@ +"""Tests for the `compat_result` fixture's tagged-union validation. + +The conftest's `pytest_runtest_makereport` hook is exercised end-to-end by +the matrix-builder golden-file tests (which consume a results.json that +the harness would produce). Here we just test the input-validation +contract on `CompatResult.set()`. +""" + +from __future__ import annotations + +import pytest + +from claude_code.conftest import CompatResult + + +def test_set_pass_is_accepted(): + r = CompatResult() + r.set({"status": "pass"}) + assert r.value == {"status": "pass"} + + +def test_set_fail_requires_error(): + r = CompatResult() + with pytest.raises(ValueError, match="requires 'error'"): + r.set({"status": "fail"}) + + +def test_set_fail_with_error_is_accepted(): + r = CompatResult() + r.set({"status": "fail", "error": "boom"}) + assert r.value == {"status": "fail", "error": "boom"} + + +def test_set_not_applicable_requires_reason(): + r = CompatResult() + with pytest.raises(ValueError, match="requires 'reason'"): + r.set({"status": "not_applicable"}) + + +def test_set_not_applicable_with_reason_is_accepted(): + r = CompatResult() + r.set({"status": "not_applicable", "reason": "Bedrock has no /thinking"}) + assert r.value == {"status": "not_applicable", "reason": "Bedrock has no /thinking"} + + +def test_set_not_tested_is_accepted(): + r = CompatResult() + r.set({"status": "not_tested"}) + assert r.value == {"status": "not_tested"} + + +def test_set_rejects_unknown_status(): + r = CompatResult() + with pytest.raises(ValueError, match="status must be one of"): + r.set({"status": "maybe"}) + + +def test_set_rejects_non_dict(): + r = CompatResult() + with pytest.raises(TypeError): + r.set("pass") # type: ignore[arg-type] + + +def test_set_copies_input(): + """Mutating the dict after set() must not change the stored value.""" + r = CompatResult() + payload = {"status": "fail", "error": "x"} + r.set(payload) + payload["error"] = "mutated" + assert r.value["error"] == "x" + + +# --------------------------------------------------------------------------- +# add() / collected() +# +# When a single test exercises three Claude tiers in parallel, each tier +# needs its own row in the results artifact so the matrix builder can +# apply its "all three must pass" aggregation. `add()` is the per-tier +# recorder; `collected()` is what the conftest hook reads. +# --------------------------------------------------------------------------- + + +def test_add_appends_each_call_to_values(): + r = CompatResult() + r.add({"status": "pass"}) + r.add({"status": "fail", "error": "bad"}) + assert r.values == [ + {"status": "pass"}, + {"status": "fail", "error": "bad"}, + ] + + +def test_add_validates_like_set(): + """The add() and set() validators are the same; both must reject bad payloads.""" + r = CompatResult() + with pytest.raises(ValueError, match="requires 'error'"): + r.add({"status": "fail"}) + with pytest.raises(ValueError, match="requires 'reason'"): + r.add({"status": "not_applicable"}) + with pytest.raises(ValueError, match="status must be one of"): + r.add({"status": "maybe"}) + with pytest.raises(TypeError): + r.add("pass") # type: ignore[arg-type] + + +def test_add_copies_input(): + """Same defensive copy contract as set().""" + r = CompatResult() + payload = {"status": "fail", "error": "x"} + r.add(payload) + payload["error"] = "mutated" + assert r.values[0]["error"] == "x" + + +def test_collected_returns_values_when_added(): + r = CompatResult() + r.add({"status": "pass"}) + r.add({"status": "pass"}) + assert r.collected() == [{"status": "pass"}, {"status": "pass"}] + + +def test_collected_returns_single_value_when_only_set_called(): + """Legacy single-result tests should still surface their one outcome.""" + r = CompatResult() + r.set({"status": "pass"}) + assert r.collected() == [{"status": "pass"}] + + +def test_collected_prefers_added_values_over_set_value(): + """If both are populated, the per-tier list wins — that's the multi-model shape.""" + r = CompatResult() + r.set({"status": "pass"}) + r.add({"status": "fail", "error": "tier-2 broke"}) + assert r.collected() == [{"status": "fail", "error": "tier-2 broke"}] + + +def test_collected_returns_empty_when_nothing_reported(): + assert CompatResult().collected() == [] diff --git a/tests/e2e/claude_code/_driver_unit_tests/test_rate_limiter.py b/tests/e2e/claude_code/_driver_unit_tests/test_rate_limiter.py new file mode 100644 index 00000000000..92907eda3c4 --- /dev/null +++ b/tests/e2e/claude_code/_driver_unit_tests/test_rate_limiter.py @@ -0,0 +1,329 @@ +"""Unit tests for the cross-process token-bucket rate limiter. + +The tests cover three layers: + +1. Provider inference from model alias — the matrix-column mapping the + live tests rely on (`-bedrock-converse` vs `-bedrock-invoke` vs + `-azure` vs `-vertex` vs bare = anthropic). + +2. Config parsing — env-var precedence, fallback to default, malformed + input handling, burst override semantics. These run against + `os.environ`-shaped dicts so we don't have to monkeypatch globals. + +3. Token-bucket behavior — enforcing rate, accumulating burst, never + over-spending across a fake clock. Filesystem state is exercised + with a real `tmp_path` because the persistence is the whole point; + the only injected seam is `clock` (and `sleep`, so tests don't + actually wait on wall time). + +The cross-process flock semantics are exercised indirectly: every +test creates a fresh `RateLimiter` rooted at `tmp_path`, so the same +file lock that protects production is exercised here too. We don't +fork to test multi-process behavior in this file because pytest +fixtures + xdist already do that for the integration suite. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import List + +import pytest + +from claude_code.rate_limiter import ( + ALL_PROVIDERS, + BURST_ENV, + DEFAULT_RATE, + PROVIDER_ANTHROPIC, + PROVIDER_AZURE, + PROVIDER_BEDROCK_CONVERSE, + PROVIDER_BEDROCK_INVOKE, + PROVIDER_VERTEX_AI, + ProviderConfig, + RateLimiter, + get_default_limiter, + infer_provider, + load_config, + reset_default_limiter, + use_limiter, +) + + +# --------------------------------------------------------------------------- +# Provider inference +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model, expected", + [ + ("claude-haiku-4-5", PROVIDER_ANTHROPIC), + ("claude-sonnet-4-6", PROVIDER_ANTHROPIC), + ("claude-opus-4-7", PROVIDER_ANTHROPIC), + ("claude-haiku-4-5-azure", PROVIDER_AZURE), + ("claude-sonnet-4-6-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), + ], +) +def test_infer_provider_maps_alias_suffix_to_column(model, expected): + assert infer_provider(model) == expected + + +def test_infer_provider_bedrock_converse_beats_bedrock_invoke_lookup_order(): + """Both bedrock suffixes contain `bedrock`; the more-specific suffix wins.""" + assert infer_provider("claude-foo-bedrock-converse") == PROVIDER_BEDROCK_CONVERSE + assert infer_provider("claude-foo-bedrock-invoke") == PROVIDER_BEDROCK_INVOKE + + +def test_infer_provider_rejects_empty_string(): + with pytest.raises(ValueError, match="non-empty"): + infer_provider("") + + +def test_infer_provider_is_case_insensitive(): + """Aliases in the proxy config sometimes drift between cases; we + should still route them to the right column.""" + assert infer_provider("CLAUDE-OPUS-4-7-AZURE") == PROVIDER_AZURE + + +# --------------------------------------------------------------------------- +# Config parsing +# --------------------------------------------------------------------------- + + +def test_load_config_uses_default_rate_when_env_absent(): + cfg = load_config(env={}) + for provider in ALL_PROVIDERS: + assert cfg[provider].rate_per_sec == DEFAULT_RATE + assert cfg[provider].burst == DEFAULT_RATE + + +def test_load_config_reads_per_provider_rate(): + cfg = load_config( + env={ + "LITELLM_COMPAT_RATE_ANTHROPIC": "10", + "LITELLM_COMPAT_RATE_AZURE": "0.5", + } + ) + assert cfg[PROVIDER_ANTHROPIC].rate_per_sec == 10.0 + assert cfg[PROVIDER_AZURE].rate_per_sec == 0.5 + assert cfg[PROVIDER_VERTEX_AI].rate_per_sec == DEFAULT_RATE + + +def test_load_config_zero_rate_disables_provider(): + cfg = load_config(env={"LITELLM_COMPAT_RATE_BEDROCK_INVOKE": "0"}) + assert cfg[PROVIDER_BEDROCK_INVOKE].enabled is False + + +def test_load_config_burst_override_applies_to_every_provider(): + cfg = load_config( + env={ + "LITELLM_COMPAT_RATE_ANTHROPIC": "5", + BURST_ENV: "20", + } + ) + for provider in ALL_PROVIDERS: + assert cfg[provider].burst == 20.0 + + +def test_load_config_falls_back_on_malformed_value(): + cfg = load_config(env={"LITELLM_COMPAT_RATE_ANTHROPIC": "not-a-number"}) + assert cfg[PROVIDER_ANTHROPIC].rate_per_sec == DEFAULT_RATE + + +def test_load_config_burst_floors_at_one_when_rate_is_low(): + """A 0.5/s rate with no burst override must still allow at least + one immediate request — otherwise the very first call would block.""" + cfg = load_config(env={"LITELLM_COMPAT_RATE_ANTHROPIC": "0.5"}) + assert cfg[PROVIDER_ANTHROPIC].burst == 1.0 + + +# --------------------------------------------------------------------------- +# Token bucket +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_clock(): + """A controllable monotonic clock + sleep for the limiter under test. + + Tests advance `clock.now` to simulate elapsed wall time. `sleep` + adds the requested duration to `clock.now` instead of actually + sleeping, so a "wait 200ms" code path runs in microseconds and + is deterministic. + """ + + class Clock: + def __init__(self): + self.now = 1_000.0 + self.sleeps: List[float] = [] + + def __call__(self): + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.now += seconds + + return Clock() + + +def _make_limiter(tmp_path: Path, fake_clock, *, rate=10.0, burst=None): + cfg = { + p: ProviderConfig(rate_per_sec=rate, burst=burst if burst is not None else rate) + for p in ALL_PROVIDERS + } + return RateLimiter( + config=cfg, + state_dir=tmp_path, + clock=fake_clock, + sleep=fake_clock.sleep, + ) + + +def test_acquire_first_call_does_not_wait(tmp_path, fake_clock): + """A freshly-initialized bucket starts full; the first acquire is free.""" + limiter = _make_limiter(tmp_path, fake_clock, rate=10.0, burst=10.0) + waited = limiter.acquire(PROVIDER_ANTHROPIC) + assert waited == 0.0 + assert fake_clock.sleeps == [] + + +def test_acquire_disabled_provider_returns_immediately(tmp_path, fake_clock): + """rate=0 ⇒ no throttling, even if every other provider is throttled.""" + cfg = {p: ProviderConfig(rate_per_sec=0.0, burst=0.0) for p in ALL_PROVIDERS} + limiter = RateLimiter( + config=cfg, state_dir=tmp_path, clock=fake_clock, sleep=fake_clock.sleep + ) + for _ in range(100): + assert limiter.acquire(PROVIDER_ANTHROPIC) == 0.0 + assert fake_clock.sleeps == [] + + +def test_acquire_burns_through_burst_then_throttles(tmp_path, fake_clock): + """`burst` immediate requests succeed; the next one waits 1/rate seconds.""" + limiter = _make_limiter(tmp_path, fake_clock, rate=2.0, burst=3.0) + + for _ in range(3): + assert limiter.acquire(PROVIDER_ANTHROPIC) == 0.0 + + # Bucket is empty; next call must sleep ~0.5s to earn one token at 2/s. + waited = limiter.acquire(PROVIDER_ANTHROPIC) + assert waited == pytest.approx(0.5, abs=0.01) + + +def test_acquire_refills_with_elapsed_time(tmp_path, fake_clock): + """Advancing the clock between calls credits tokens at the configured rate.""" + limiter = _make_limiter(tmp_path, fake_clock, rate=4.0, burst=1.0) + + assert limiter.acquire(PROVIDER_ANTHROPIC) == 0.0 # consumes the 1-token burst + fake_clock.now += 0.25 # 0.25s × 4/s = 1 token earned + assert limiter.acquire(PROVIDER_ANTHROPIC) == 0.0 + + +def test_acquire_caps_refill_at_burst(tmp_path, fake_clock): + """A long quiet period must not let the bucket grow past `burst`.""" + limiter = _make_limiter(tmp_path, fake_clock, rate=10.0, burst=2.0) + + fake_clock.now += 1_000 # would earn 10_000 tokens uncapped + # Only `burst` (=2) immediate calls should succeed before throttling. + assert limiter.acquire(PROVIDER_ANTHROPIC) == 0.0 + assert limiter.acquire(PROVIDER_ANTHROPIC) == 0.0 + waited = limiter.acquire(PROVIDER_ANTHROPIC) + assert waited > 0 + + +def test_acquire_independent_buckets_per_provider(tmp_path, fake_clock): + """Anthropic exhaustion must not throttle Azure (each column has its own bucket).""" + limiter = _make_limiter(tmp_path, fake_clock, rate=2.0, burst=1.0) + + assert limiter.acquire(PROVIDER_ANTHROPIC) == 0.0 + # Anthropic bucket is now empty; Azure is untouched. + assert limiter.acquire(PROVIDER_AZURE) == 0.0 + + +def test_acquire_persists_state_across_limiter_instances(tmp_path): + """A fresh RateLimiter must read the on-disk state, not start fresh. + + This is the property that makes the limiter cross-process: an + xdist worker created mid-run sees the credit consumed by other + workers, instead of getting its own private bucket. + """ + cfg = {p: ProviderConfig(rate_per_sec=10.0, burst=2.0) for p in ALL_PROVIDERS} + state = {"now": 1_000.0, "sleeps": []} + + def clock(): + return state["now"] + + def sleep(seconds): + state["sleeps"].append(seconds) + state["now"] += seconds + + first = RateLimiter(config=cfg, state_dir=tmp_path, clock=clock, sleep=sleep) + first.acquire(PROVIDER_ANTHROPIC) + first.acquire(PROVIDER_ANTHROPIC) + # bucket is now empty + + second = RateLimiter(config=cfg, state_dir=tmp_path, clock=clock, sleep=sleep) + waited = second.acquire(PROVIDER_ANTHROPIC) + assert waited > 0 # had to wait, didn't see a fresh full bucket + + +def test_acquire_recovers_from_corrupt_state_file(tmp_path, fake_clock): + """A truncated/garbage state file must not crash the test session.""" + state_file = tmp_path / f"{PROVIDER_ANTHROPIC}.json" + state_file.write_text("not-json {{") + + limiter = _make_limiter(tmp_path, fake_clock, rate=5.0, burst=5.0) + assert limiter.acquire(PROVIDER_ANTHROPIC) == 0.0 + + +def test_acquire_handles_clock_going_backward(tmp_path, fake_clock): + """Across a host suspend/resume the monotonic clock can briefly + go backward; we must not interpret that as removing tokens.""" + limiter = _make_limiter(tmp_path, fake_clock, rate=1.0, burst=2.0) + limiter.acquire(PROVIDER_ANTHROPIC) + fake_clock.now -= 10 # clock moved backward + # Bucket should still have ~1 token left from the burst, not -9. + assert limiter.acquire(PROVIDER_ANTHROPIC) == 0.0 + + +# --------------------------------------------------------------------------- +# Process-default singleton +# --------------------------------------------------------------------------- + + +def test_use_limiter_swaps_default_for_block(tmp_path): + sentinel_cfg = { + p: ProviderConfig(rate_per_sec=0.0, burst=0.0) for p in ALL_PROVIDERS + } + sentinel = RateLimiter(config=sentinel_cfg, state_dir=tmp_path) + reset_default_limiter() + try: + with use_limiter(sentinel): + assert get_default_limiter() is sentinel + # After the context exits, the default goes back to whatever it + # was — in this test that's "rebuilt on next access" because we + # called reset_default_limiter() above. + assert get_default_limiter() is not sentinel + finally: + reset_default_limiter() + + +# --------------------------------------------------------------------------- +# Persistence shape +# --------------------------------------------------------------------------- + + +def test_state_file_is_json_after_acquire(tmp_path, fake_clock): + limiter = _make_limiter(tmp_path, fake_clock, rate=5.0, burst=5.0) + limiter.acquire(PROVIDER_ANTHROPIC) + state_file = tmp_path / f"{PROVIDER_ANTHROPIC}.json" + payload = json.loads(state_file.read_text()) + assert "tokens" in payload + assert "last_refill" in payload + assert payload["tokens"] == pytest.approx(4.0) diff --git a/tests/e2e/claude_code/_pr_gate_unit_tests/__init__.py b/tests/e2e/claude_code/_pr_gate_unit_tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/_pr_gate_unit_tests/test_bash_tool_restrictions.py b/tests/e2e/claude_code/_pr_gate_unit_tests/test_bash_tool_restrictions.py new file mode 100644 index 00000000000..d698131670a --- /dev/null +++ b/tests/e2e/claude_code/_pr_gate_unit_tests/test_bash_tool_restrictions.py @@ -0,0 +1,147 @@ +"""Pin tests for the `Bash`-using compat cells. + +Every cell that passes `--allowed-tools Bash` to the `claude` CLI is +giving a model-controlled response the ability to run host commands. +On the PR-gate CircleCI machine executor, those commands have access +to the Docker socket and can read `docker inspect compat-proxy` to +recover the provider credentials living inside the proxy container. + +To narrow that surface, every Bash-using cell must: + +1. Restrict the allow rule to the *exact* command `Bash(echo pong)` so + a compromised provider response cannot turn `Bash` into arbitrary + host execution by emitting a `tool_use` with a different command. + +2. Pair it with `--permission-mode dontAsk` so anything not matching + an allow rule is auto-denied instead of prompting (which would + abort the CLI in headless mode, but auto-denial is the explicit + contract). + +These restrictions are enforced by the `claude` CLI, not by the +model — see https://code.claude.com/docs/en/permissions for the +permission-rule precedence (`deny` → `ask` → `allow`). + +This test scans every cell under the three Bash-using feature +directories (`tool_use`, `tool_use_streaming`, `thinking_with_tool_use`) +and pins both requirements so a future test refactor cannot silently +revert any cell to the broad `Bash` allow that was originally +flagged by Veria. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterable + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[4] +CLAUDE_CODE_DIR = REPO_ROOT / "tests" / "e2e" / "claude_code" + +# Feature directories whose cells drive the `Bash` built-in tool. Add +# new entries here when a new Bash-using feature is added; the test +# fails loudly for any unhandled directory so we never miss one by +# silent omission. +BASH_FEATURE_DIRS = ( + "tool_use", + "tool_use_streaming", + "thinking_with_tool_use", +) + + +def _bash_cells() -> Iterable[Path]: + for feature in BASH_FEATURE_DIRS: + feature_dir = CLAUDE_CODE_DIR / feature + assert feature_dir.is_dir(), ( + f"{feature_dir} is missing — BASH_FEATURE_DIRS is out of sync " + f"with the layout under tests/e2e/claude_code/." + ) + for path in sorted(feature_dir.glob("test_*.py")): + yield path + + +def _has_bare_bash_token(text: str) -> bool: + """Return True if `text` contains a `"Bash"` token outside the + `"Bash(echo pong)"` allow rule. + + Extracted as a pure helper so the negative path can be unit-tested + directly. Without it, the previous structure of this assertion was + `'"Bash"' not in text or '"Bash(echo pong)"' in text`, which + short-circuits to True any time the allow rule is present and lets + a stray bare `"Bash"` slip through the security pin undetected. + """ + return '"Bash"' in text.replace('"Bash(echo pong)"', "") + + +@pytest.mark.parametrize( + "cell", list(_bash_cells()), ids=lambda p: str(p.relative_to(REPO_ROOT)) +) +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'`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` " + f"to exfiltrate provider credentials from the proxy container." + ) + # The only place `"Bash"` (the bare token, surrounded by quotes + # exactly as it would appear in `--allowed-tools` lists) is allowed + # to appear is *inside* the exact-match `"Bash(echo pong)"` rule. + # `_has_bare_bash_token` keeps that scan independent of the first + # assertion — otherwise `'"Bash"' not in text or '"Bash(echo pong)"' + # 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'`"Bash"` value outside the `"Bash(echo pong)"` allow rule — ' + f"sweep it out before merging." + ) + + +def test_has_bare_bash_token_flags_unrestricted_value(): + """A file that allows the bare `"Bash"` token alongside the + exact-match rule must be flagged. Without this guard the security + pin reverts to the dead-code `or` it had originally, which let + arbitrary host commands through under the noise of a passing test. + """ + text = '--allowed-tools "Bash" "Bash(echo pong)"' + assert _has_bare_bash_token(text) + + +def test_has_bare_bash_token_accepts_only_exact_match(): + """The standard pattern — only the exact-match allow rule, no bare + `"Bash"` — must be accepted. This is the shape every Bash-using + cell in the suite is required to take. + """ + text = '--allowed-tools "Bash(echo pong)" --permission-mode "dontAsk"' + assert not _has_bare_bash_token(text) + + +def test_has_bare_bash_token_ignores_unrelated_substrings(): + """`Bash(echo pong)` is the only allowed shape; substrings like + `BashTool` or `Bashing` are unrelated identifiers and must not be + confused with the bare `"Bash"` token (i.e. the exact quoted + string `"Bash"`).""" + text = "BashTool helper used by the bashing harness" + assert not _has_bare_bash_token(text) + + +@pytest.mark.parametrize( + "cell", list(_bash_cells()), ids=lambda p: str(p.relative_to(REPO_ROOT)) +) +def test_bash_cell_uses_dontask_permission_mode(cell: Path) -> None: + """The cell must pair the allow rule with `--permission-mode dontAsk` + so tool calls that don't match the allow rule are auto-denied (as + opposed to defaulting to "ask", which in headless mode would + 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"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." + ) diff --git a/tests/e2e/claude_code/_pr_gate_unit_tests/test_pr_gate_version_resolver.py b/tests/e2e/claude_code/_pr_gate_unit_tests/test_pr_gate_version_resolver.py new file mode 100644 index 00000000000..5c516da81c5 --- /dev/null +++ b/tests/e2e/claude_code/_pr_gate_unit_tests/test_pr_gate_version_resolver.py @@ -0,0 +1,164 @@ +"""Unit tests for the Claude Code PR-Gate Version Resolver. + +The resolver picks the newest `@anthropic-ai/claude-code` version whose +publish timestamp is at least 3 days old. The 3-day window is a security +review buffer: a malicious or broken Claude Code release that slipped +through the npm publish process gets at least 72 hours to be detected +before it can land in the LiteLLM PR gate. + +The unit tests inject npm metadata directly (no network) and a fixed +`as_of` clock (no real time), so they run anywhere and never flake on +the wall clock or registry availability. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from claude_code.pr_gate_version_resolver import ( + NoEligibleVersionError, + resolve_pr_gate_version, +) + + +def _t(iso: str) -> str: + """Helper for readable ISO-8601 publish timestamps in fixtures.""" + return iso + + +# A clock fixed at a moment well after every fixture publish time below. +NOW = datetime(2026, 4, 25, 12, 0, 0, tzinfo=timezone.utc) + + +def _metadata_with_times(times: dict) -> dict: + """Shape an npm `packument`-like dict with the `time` field populated. + + The npm registry response includes `time.created` / `time.modified` + keys alongside per-version timestamps; the resolver must skip those. + """ + return { + "name": "@anthropic-ai/claude-code", + "time": { + "created": _t("2024-01-01T00:00:00.000Z"), + "modified": _t("2026-04-25T00:00:00.000Z"), + **times, + }, + } + + +def test_picks_newest_version_at_least_three_days_old(): + metadata = _metadata_with_times( + { + "2.1.118": _t("2026-04-15T10:00:00.000Z"), + "2.1.119": _t("2026-04-21T10:00:00.000Z"), # 4d 2h old + "2.1.120": _t("2026-04-23T10:00:00.000Z"), # 2d 2h old — too new + "2.1.121": _t("2026-04-25T11:00:00.000Z"), # 1h old — too new + } + ) + assert resolve_pr_gate_version(metadata=metadata, as_of=NOW) == "2.1.119" + + +def test_skips_created_and_modified_meta_keys(): + """`time` contains `created` / `modified` non-version entries — must be ignored.""" + metadata = { + "name": "@anthropic-ai/claude-code", + "time": { + "created": _t("2024-01-01T00:00:00.000Z"), + "modified": _t("2026-04-25T00:00:00.000Z"), + "2.0.0": _t("2026-04-10T00:00:00.000Z"), + }, + } + assert resolve_pr_gate_version(metadata=metadata, as_of=NOW) == "2.0.0" + + +def test_min_age_boundary_is_inclusive(): + """A version published exactly 3 days ago is eligible (>= cutoff).""" + three_days_ago = NOW - timedelta(days=3) + metadata = _metadata_with_times( + { + "2.1.0": three_days_ago.isoformat().replace("+00:00", "Z"), + } + ) + assert resolve_pr_gate_version(metadata=metadata, as_of=NOW) == "2.1.0" + + +def test_raises_when_every_version_is_too_new(): + metadata = _metadata_with_times( + { + "2.1.121": _t("2026-04-25T08:00:00.000Z"), # 4h old + "2.1.120": _t("2026-04-24T10:00:00.000Z"), # ~26h old + } + ) + with pytest.raises(NoEligibleVersionError): + resolve_pr_gate_version(metadata=metadata, as_of=NOW) + + +def test_raises_when_metadata_has_no_versions(): + metadata = {"name": "@anthropic-ai/claude-code", "time": {}} + with pytest.raises(NoEligibleVersionError): + resolve_pr_gate_version(metadata=metadata, as_of=NOW) + + +def test_picks_latest_publish_time_not_largest_semver(): + """If a patch is published to an old major after a newer release, + "newest" is by publish time, not semver string ordering.""" + metadata = _metadata_with_times( + { + "1.9.99": _t("2026-04-22T10:00:00.000Z"), # patched recently — wins + "2.0.0": _t("2026-03-01T10:00:00.000Z"), # older publish + } + ) + assert resolve_pr_gate_version(metadata=metadata, as_of=NOW) == "1.9.99" + + +def test_uses_custom_min_age(): + metadata = _metadata_with_times( + { + "1.0.0": _t("2026-04-23T10:00:00.000Z"), # 2d 2h old + "0.9.0": _t("2026-04-10T10:00:00.000Z"), # 15d old + } + ) + # min_age = 5 days disqualifies 1.0.0 + out = resolve_pr_gate_version( + metadata=metadata, as_of=NOW, min_age=timedelta(days=5) + ) + assert out == "0.9.0" + + +def test_excludes_prerelease_versions(): + """Pre-release tags (1.0.0-alpha.1, 2.0.0-rc.1, etc.) must never win, + even if their publish timestamp is the newest eligible one.""" + metadata = _metadata_with_times( + { + "2.1.119": _t("2026-04-21T10:00:00.000Z"), # stable, 4d old + "2.2.0-alpha.1": _t("2026-04-22T10:00:00.000Z"), # newer publish + "2.2.0-rc.1": _t("2026-04-22T11:00:00.000Z"), # newest publish + "3.0.0-beta": _t("2026-04-22T12:00:00.000Z"), # newest publish + } + ) + assert resolve_pr_gate_version(metadata=metadata, as_of=NOW) == "2.1.119" + + +def test_raises_when_only_prereleases_are_eligible(): + metadata = _metadata_with_times( + { + "2.2.0-alpha.1": _t("2026-04-22T10:00:00.000Z"), + "2.2.0-rc.1": _t("2026-04-22T11:00:00.000Z"), + } + ) + with pytest.raises(NoEligibleVersionError): + resolve_pr_gate_version(metadata=metadata, as_of=NOW) + + +def test_resolver_uses_fetcher_when_metadata_not_provided(): + captured = {} + + def fake_fetch(package_name: str) -> dict: + captured["package"] = package_name + return _metadata_with_times({"3.0.0": _t("2026-04-10T10:00:00.000Z")}) + + out = resolve_pr_gate_version(as_of=NOW, fetcher=fake_fetch) + assert out == "3.0.0" + assert captured["package"] == "@anthropic-ai/claude-code" diff --git a/tests/e2e/claude_code/_publisher_unit_tests/__init__.py b/tests/e2e/claude_code/_publisher_unit_tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pytest_scrubs_env.py b/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pytest_scrubs_env.py new file mode 100644 index 00000000000..418d308674b --- /dev/null +++ b/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pytest_scrubs_env.py @@ -0,0 +1,95 @@ +"""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//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//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." + ) diff --git a/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_release_pagination.py b/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_release_pagination.py new file mode 100644 index 00000000000..fc733845ba3 --- /dev/null +++ b/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_release_pagination.py @@ -0,0 +1,289 @@ +"""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." + ) diff --git a/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_version_probe_scrubs_env.py b/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_version_probe_scrubs_env.py new file mode 100644 index 00000000000..1c3959764f3 --- /dev/null +++ b/tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_version_probe_scrubs_env.py @@ -0,0 +1,92 @@ +"""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." + ) diff --git a/tests/e2e/claude_code/_publisher_unit_tests/test_systemd_unit_credential_isolation.py b/tests/e2e/claude_code/_publisher_unit_tests/test_systemd_unit_credential_isolation.py new file mode 100644 index 00000000000..12edce3cb50 --- /dev/null +++ b/tests/e2e/claude_code/_publisher_unit_tests/test_systemd_unit_credential_isolation.py @@ -0,0 +1,104 @@ +"""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." + ) diff --git a/tests/e2e/claude_code/basic_messaging_non_streaming/__init__.py b/tests/e2e/claude_code/basic_messaging_non_streaming/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/basic_messaging_non_streaming/test_anthropic.py b/tests/e2e/claude_code/basic_messaging_non_streaming/test_anthropic.py new file mode 100644 index 00000000000..c06fff28d2d --- /dev/null +++ b/tests/e2e/claude_code/basic_messaging_non_streaming/test_anthropic.py @@ -0,0 +1,47 @@ +"""basic_messaging_non_streaming × Anthropic. + +The thinnest end-to-end path through every layer of the matrix: drive the +real `claude` CLI in headless mode against a running LiteLLM proxy that +routes to Anthropic, and report the outcome via `compat_result`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/basic_messaging_non_streaming/test_anthropic.py + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider + +Per the PRD, every cell exercises Claude Haiku 4.5, Sonnet 4.6, and Opus +4.7; the cell only goes green if all three pass. The shared +`run_basic_messaging_cell` helper fans the three model runs out in +parallel and reports one `compat_result.add(...)` entry per model so +the matrix builder still sees three rows for this (feature, provider). +""" + +from __future__ import annotations + +from claude_code._basic_messaging import run_basic_messaging_cell + +# Per the PRD: each cell is exercised against three Claude tiers via the +# Anthropic provider. Aliases are configured in the LiteLLM proxy's +# routing config; the driver only sends the alias. +ANTHROPIC_MODELS = [ + "claude-haiku-4-5", + "claude-sonnet-4-6", + "claude-opus-4-7", +] + + +def test_basic_messaging_non_streaming_anthropic(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert a reply. + + "Basic messaging" means: send a single user prompt, receive any + non-empty assistant text reply, no tools, no streaming, no thinking. + The whole point of this slice is to prove the path works at all — + so the assertion is intentionally lenient on the reply contents. + """ + run_basic_messaging_cell( + compat_result=compat_result, + models=ANTHROPIC_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + ) diff --git a/tests/e2e/claude_code/basic_messaging_non_streaming/test_azure.py b/tests/e2e/claude_code/basic_messaging_non_streaming/test_azure.py new file mode 100644 index 00000000000..2a962b244a8 --- /dev/null +++ b/tests/e2e/claude_code/basic_messaging_non_streaming/test_azure.py @@ -0,0 +1,53 @@ +"""basic_messaging_non_streaming x Azure (Microsoft Foundry). + +Drive the real `claude` CLI in headless mode against a running LiteLLM +proxy that routes Claude requests to Anthropic's models hosted in +Microsoft Foundry on Azure, and report the outcome via `compat_result`. + +Anthropic announced Claude Haiku 4.5, Sonnet 4.5/4.6, and Opus 4.1/4.6/4.7 +in Microsoft Foundry on 2025-11-18; LiteLLM exposes them via the +`azure_ai/claude-*` provider prefix, which talks to Foundry's +Anthropic-shape `/anthropic/v1/messages` endpoint. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/basic_messaging_non_streaming/test_azure.py + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^ + feature_id provider + +Per the PRD, every cell exercises Claude Haiku 4.5, Sonnet 4.6, and Opus +4.7; the cell only goes green if all three pass. The shared +`run_basic_messaging_cell` helper fans the three model runs out in +parallel and reports one `compat_result.add(...)` entry per model so +the matrix builder still sees three rows for this (feature, provider). +""" + +from __future__ import annotations + +from claude_code._basic_messaging import run_basic_messaging_cell + +# Per-model aliases registered in the LiteLLM proxy's routing config to +# point at Microsoft Foundry's Anthropic deployments. The driver only +# sends the alias; the proxy is the one that knows the upstream Foundry +# resource URL and API key. +AZURE_MODELS = [ + "claude-haiku-4-5-azure", + "claude-sonnet-4-6-azure", + "claude-opus-4-7-azure", +] + + +def test_basic_messaging_non_streaming_azure(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert a reply. + + "Basic messaging" means: send a single user prompt, receive any + non-empty assistant text reply, no tools, no streaming, no thinking. + The whole point of this slice is to prove the path works at all — + so the assertion is intentionally lenient on the reply contents. + """ + run_basic_messaging_cell( + compat_result=compat_result, + models=AZURE_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + ) diff --git a/tests/e2e/claude_code/basic_messaging_non_streaming/test_bedrock_converse.py b/tests/e2e/claude_code/basic_messaging_non_streaming/test_bedrock_converse.py new file mode 100644 index 00000000000..2245ed7417a --- /dev/null +++ b/tests/e2e/claude_code/basic_messaging_non_streaming/test_bedrock_converse.py @@ -0,0 +1,42 @@ +"""basic_messaging_non_streaming x Bedrock (Converse). + +Drive the real `claude` CLI in headless mode against a running LiteLLM +proxy that routes Claude requests to AWS Bedrock via the unified +`Converse` API path, and report the outcome via `compat_result`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/basic_messaging_non_streaming/test_bedrock_converse.py + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider + +Per the PRD, every cell exercises Claude Haiku 4.5, Sonnet 4.6, and Opus +4.7; the cell only goes green if all three pass. The shared +`run_basic_messaging_cell` helper fans the three model runs out in +parallel and reports one `compat_result.add(...)` entry per model so +the matrix builder still sees three rows for this (feature, provider). +""" + +from __future__ import annotations + +from claude_code._basic_messaging import run_basic_messaging_cell + +# Per-model aliases registered in the LiteLLM proxy's routing config to +# point at Bedrock's Converse endpoint. The driver only sends the alias; +# the proxy is the one that knows the upstream model id and routing +# strategy. +BEDROCK_CONVERSE_MODELS = [ + "claude-haiku-4-5-bedrock-converse", + "claude-sonnet-4-6-bedrock-converse", + "claude-opus-4-7-bedrock-converse", +] + + +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( + compat_result=compat_result, + models=BEDROCK_CONVERSE_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + ) diff --git a/tests/e2e/claude_code/basic_messaging_non_streaming/test_bedrock_invoke.py b/tests/e2e/claude_code/basic_messaging_non_streaming/test_bedrock_invoke.py new file mode 100644 index 00000000000..e0a6e77f3c1 --- /dev/null +++ b/tests/e2e/claude_code/basic_messaging_non_streaming/test_bedrock_invoke.py @@ -0,0 +1,42 @@ +"""basic_messaging_non_streaming x Bedrock (Invoke). + +Drive the real `claude` CLI in headless mode against a running LiteLLM +proxy that routes Claude requests to AWS Bedrock via the legacy +`InvokeModel` API path, and report the outcome via `compat_result`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/basic_messaging_non_streaming/test_bedrock_invoke.py + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider + +Per the PRD, every cell exercises Claude Haiku 4.5, Sonnet 4.6, and Opus +4.7; the cell only goes green if all three pass. The shared +`run_basic_messaging_cell` helper fans the three model runs out in +parallel and reports one `compat_result.add(...)` entry per model so +the matrix builder still sees three rows for this (feature, provider). +""" + +from __future__ import annotations + +from claude_code._basic_messaging import run_basic_messaging_cell + +# Per-model aliases registered in the LiteLLM proxy's routing config to +# point at Bedrock's legacy InvokeModel endpoint. The driver only sends +# the alias; the proxy is the one that knows the upstream model id and +# routing strategy. +BEDROCK_INVOKE_MODELS = [ + "claude-haiku-4-5-bedrock-invoke", + "claude-sonnet-4-6-bedrock-invoke", + "claude-opus-4-7-bedrock-invoke", +] + + +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( + compat_result=compat_result, + models=BEDROCK_INVOKE_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + ) diff --git a/tests/e2e/claude_code/basic_messaging_non_streaming/test_vertex_ai.py b/tests/e2e/claude_code/basic_messaging_non_streaming/test_vertex_ai.py new file mode 100644 index 00000000000..e4e2a39e6cd --- /dev/null +++ b/tests/e2e/claude_code/basic_messaging_non_streaming/test_vertex_ai.py @@ -0,0 +1,42 @@ +"""basic_messaging_non_streaming x Vertex AI. + +Drive the real `claude` CLI in headless mode against a running LiteLLM +proxy that routes Claude requests to Anthropic's models on Google Cloud +Vertex AI, and report the outcome via `compat_result`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/basic_messaging_non_streaming/test_vertex_ai.py + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider + +Per the PRD, every cell exercises Claude Haiku 4.5, Sonnet 4.6, and Opus +4.7; the cell only goes green if all three pass. The shared +`run_basic_messaging_cell` helper fans the three model runs out in +parallel and reports one `compat_result.add(...)` entry per model so +the matrix builder still sees three rows for this (feature, provider). +""" + +from __future__ import annotations + +from claude_code._basic_messaging import run_basic_messaging_cell + +# Per-model aliases registered in the LiteLLM proxy's routing config to +# point at Vertex AI's Anthropic model endpoints. The driver only sends +# the alias; the proxy is the one that knows the upstream publisher +# model id and the GCP region. +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + + +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( + compat_result=compat_result, + models=VERTEX_AI_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + ) diff --git a/tests/e2e/claude_code/basic_messaging_streaming/__init__.py b/tests/e2e/claude_code/basic_messaging_streaming/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/basic_messaging_streaming/test_anthropic.py b/tests/e2e/claude_code/basic_messaging_streaming/test_anthropic.py new file mode 100644 index 00000000000..56e3fb6c181 --- /dev/null +++ b/tests/e2e/claude_code/basic_messaging_streaming/test_anthropic.py @@ -0,0 +1,46 @@ +"""basic_messaging_streaming x Anthropic. + +Drive the real `claude` CLI in headless `--output-format stream-json +--include-partial-messages` mode against a running LiteLLM proxy that +routes to Anthropic, and report the outcome via `compat_result`. + +The cell goes green only when every Claude tier (a) returns a non-empty +reply and (b) the proxy actually streamed it — i.e. the CLI observed +multiple `stream_event` records carrying raw SSE deltas. A proxy that +buffers the upstream stream and returns a single non-streaming chunk +emits zero such records, which is the regression this row catches. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/basic_messaging_streaming/test_anthropic.py + ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider + +The shared `run_basic_messaging_cell` helper fans the three Claude tiers +out in parallel inside this single test, with one +`compat_result.add(...)` entry per model so the matrix builder still +sees three rows for this (feature, provider). +""" + +from __future__ import annotations + +from claude_code._basic_messaging import run_basic_messaging_cell + +ANTHROPIC_MODELS = [ + "claude-haiku-4-5", + "claude-sonnet-4-6", + "claude-opus-4-7", +] + + +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). + """ + run_basic_messaging_cell( + compat_result=compat_result, + models=ANTHROPIC_MODELS, + prompt="Count from 1 to 5, one number per line.", + verify_streaming=True, + ) diff --git a/tests/e2e/claude_code/basic_messaging_streaming/test_azure.py b/tests/e2e/claude_code/basic_messaging_streaming/test_azure.py new file mode 100644 index 00000000000..b6c002d0b27 --- /dev/null +++ b/tests/e2e/claude_code/basic_messaging_streaming/test_azure.py @@ -0,0 +1,40 @@ +"""basic_messaging_streaming x Azure (Microsoft Foundry). + +Drive the real `claude` CLI in headless `--output-format stream-json` +mode against a running LiteLLM proxy that routes Claude requests to +Anthropic's models hosted in Microsoft Foundry on Azure, and report the +outcome via `compat_result`. + +Foundry exposes Claude on an Anthropic-shape `/anthropic/v1/messages` +endpoint with native SSE streaming; LiteLLM forwards stream events +through the `azure_ai/claude-*` provider unchanged. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/basic_messaging_streaming/test_azure.py + ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^ + feature_id provider +""" + +from __future__ import annotations + +from claude_code._basic_messaging import run_basic_messaging_cell + +AZURE_MODELS = [ + "claude-haiku-4-5-azure", + "claude-sonnet-4-6-azure", + "claude-opus-4-7-azure", +] + + +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). + """ + run_basic_messaging_cell( + compat_result=compat_result, + models=AZURE_MODELS, + prompt="Count from 1 to 5, one number per line.", + verify_streaming=True, + ) diff --git a/tests/e2e/claude_code/basic_messaging_streaming/test_bedrock_converse.py b/tests/e2e/claude_code/basic_messaging_streaming/test_bedrock_converse.py new file mode 100644 index 00000000000..44ac54515f0 --- /dev/null +++ b/tests/e2e/claude_code/basic_messaging_streaming/test_bedrock_converse.py @@ -0,0 +1,36 @@ +"""basic_messaging_streaming x Bedrock (Converse). + +Drive the real `claude` CLI in headless `--output-format stream-json` +mode against a running LiteLLM proxy that routes Claude requests to AWS +Bedrock via the unified `Converse` API path, and report the outcome via +`compat_result`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/basic_messaging_streaming/test_bedrock_converse.py + ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +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-opus-4-7-bedrock-converse", +] + + +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). + """ + run_basic_messaging_cell( + compat_result=compat_result, + models=BEDROCK_CONVERSE_MODELS, + prompt="Count from 1 to 5, one number per line.", + verify_streaming=True, + ) diff --git a/tests/e2e/claude_code/basic_messaging_streaming/test_bedrock_invoke.py b/tests/e2e/claude_code/basic_messaging_streaming/test_bedrock_invoke.py new file mode 100644 index 00000000000..1d59d16cdc1 --- /dev/null +++ b/tests/e2e/claude_code/basic_messaging_streaming/test_bedrock_invoke.py @@ -0,0 +1,36 @@ +"""basic_messaging_streaming x Bedrock (Invoke). + +Drive the real `claude` CLI in headless `--output-format stream-json` +mode against a running LiteLLM proxy that routes Claude requests to AWS +Bedrock via the legacy `InvokeModel` API path, and report the outcome +via `compat_result`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/basic_messaging_streaming/test_bedrock_invoke.py + ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +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-opus-4-7-bedrock-invoke", +] + + +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). + """ + run_basic_messaging_cell( + compat_result=compat_result, + models=BEDROCK_INVOKE_MODELS, + prompt="Count from 1 to 5, one number per line.", + verify_streaming=True, + ) diff --git a/tests/e2e/claude_code/basic_messaging_streaming/test_vertex_ai.py b/tests/e2e/claude_code/basic_messaging_streaming/test_vertex_ai.py new file mode 100644 index 00000000000..014a31160a8 --- /dev/null +++ b/tests/e2e/claude_code/basic_messaging_streaming/test_vertex_ai.py @@ -0,0 +1,36 @@ +"""basic_messaging_streaming x Vertex AI. + +Drive the real `claude` CLI in headless `--output-format stream-json` +mode against a running LiteLLM proxy that routes Claude requests to +Anthropic's models on Google Cloud Vertex AI, and report the outcome +via `compat_result`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/basic_messaging_streaming/test_vertex_ai.py + ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +from claude_code._basic_messaging import run_basic_messaging_cell + +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + + +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). + """ + run_basic_messaging_cell( + compat_result=compat_result, + models=VERTEX_AI_MODELS, + prompt="Count from 1 to 5, one number per line.", + verify_streaming=True, + ) diff --git a/tests/e2e/claude_code/cli_driver.py b/tests/e2e/claude_code/cli_driver.py new file mode 100644 index 00000000000..97eaa0e6847 --- /dev/null +++ b/tests/e2e/claude_code/cli_driver.py @@ -0,0 +1,542 @@ +"""Claude Code CLI Driver. + +A thin wrapper around the `claude` CLI in headless mode. Every compatibility +test consumes only this module — tests must never shell out directly. This +keeps the subprocess assembly, stream-JSON parsing, and result shape in a +single place that can be unit-tested with a mocked subprocess. + +The driver is deliberately small: it knows how to invoke the CLI, drain its +stream-JSON output, and return a structured `DriverResult`. Higher-level +matrix concerns (status aggregation, manifest lookup, JSON serialization) +live in `matrix_builder.py`. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Union + +from claude_code.rate_limiter import ( + RateLimiter, + get_default_limiter, + infer_provider, +) + +CLAUDE_CLI_DEFAULT = "claude" +# 120s is fine for a single isolated CLI call against an unloaded +# upstream, but the matrix run launches up to 75 concurrent calls and +# upstreams can take several minutes to respond under that contention. +# We expose the timeout as an env var so binary-search runs can grow +# it without touching the test code. +DEFAULT_TIMEOUT_SECONDS = float( + os.environ.get("LITELLM_COMPAT_CLI_TIMEOUT_SECONDS") or 120 +) + +# Env vars the `claude` Node CLI legitimately needs to function: +# locating its own binary + node, basic locale/terminal plumbing. +# Deliberately excludes every credential-bearing var that the +# surrounding CI job sets for the proxy (ANTHROPIC_API_KEY, AWS_*, +# AZURE_*, VERTEXAI_CREDENTIALS, GITHUB_TOKEN, OPENAI_API_KEY, +# DATABASE_URL, ...). The CLI talks to the proxy via the explicit +# ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN we set below — it has no +# business reading the proxy's upstream credentials, and a +# compromised CLI release shouldn't be able to exfiltrate them out +# of the CI environment. +# +# `HOME` is intentionally NOT in this list: see `_make_isolated_home` +# below. We give the CLI a fresh empty per-invocation HOME so a +# compromised claude package or a model-directed `Read` tool call +# can't reach files like `~/.config/gh/hosts.yml`, `~/.ssh/id_rsa`, +# or `~/.bash_history` on the cron VM (and on the CircleCI executor +# the same isolation prevents accidentally exposing checkout-adjacent +# files even though the runner home is ephemeral there). +_CLI_ENV_ALLOWLIST: tuple = ( + "PATH", + "USER", + "LOGNAME", + "SHELL", + "TERM", + "TMPDIR", + "LANG", + "LC_ALL", + "LC_CTYPE", + "NODE_PATH", + "NVM_DIR", + "NVM_BIN", +) + + +def _make_isolated_home() -> str: + """Create a fresh empty HOME directory for a single `claude` subprocess. + + The CLI needs *a* writable HOME (it caches per-session state under + `$HOME/.claude/projects//`), but it has no legitimate need + for the *user's* HOME. Handing it the real one means a compromised + `@anthropic-ai/claude-code` release, or a model-directed `Read` + tool call during a PDF/vision cell, can read host files like + `~/.config/gh/hosts.yml` (GitHub CLI host token), `~/.ssh/`, + `~/.bash_history`, or any other dotfile under the runtime user's + home. On the cron VM the runtime user is a real interactive + account (`mateo`) with a populated home directory, so this is a + real exfiltration surface. + + The directory is created under `tempfile.gettempdir()` (which is + `/tmp` on Linux; under systemd's `PrivateTmp=true` that's a + per-service tmpfs that the service user can't otherwise reach). + Caller is responsible for `shutil.rmtree`-ing it after the + subprocess exits. + """ + return tempfile.mkdtemp(prefix="claude-cli-home-") + + +class ClaudeCLIError(RuntimeError): + """Raised when the `claude` CLI cannot be invoked or returns a fatal error.""" + + +@dataclass +class DriverResult: + """Structured outcome of a single `claude` CLI invocation. + + `text` is the assistant's final user-visible reply (joined across any + intermediate `assistant` events for non-streaming runs). `events` is the + raw list of stream-JSON objects emitted by the CLI, preserved so test + authors can write feature-specific assertions (tool calls, cache hits, + usage) without re-parsing stdout. + """ + + text: str + events: List[Dict[str, Any]] = field(default_factory=list) + exit_code: int = 0 + stderr: str = "" + usage: Optional[Dict[str, Any]] = None + duration_ms: Optional[int] = None + + +def run_claude( + *, + prompt: Optional[str], + model: str, + base_url: str, + api_key: str, + extra_env: Optional[Mapping[str, str]] = None, + extra_args: Optional[Sequence[str]] = None, + stdin_input: Optional[str] = None, + cli_path: str = CLAUDE_CLI_DEFAULT, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + runner: Optional[Any] = None, + rate_limiter: Optional[RateLimiter] = None, +) -> DriverResult: + """Invoke `claude` once in headless stream-JSON mode and return the result. + + The CLI is pointed at a LiteLLM proxy via `ANTHROPIC_BASE_URL` / + `ANTHROPIC_AUTH_TOKEN`, so the same code path exercises every provider + column — only the model id and the proxy's routing differ between + invocations. + + `runner` is an injection seam used by the unit tests: by default we call + `subprocess.run`, but the test suite swaps in a fake that yields canned + stream-JSON. Production callers should never set it. + + `rate_limiter` is the second injection seam: the cross-process + token-bucket limiter throttles outbound calls per provider so a + fully-parallel matrix run doesn't trip 429s. Defaults to the + process-wide singleton; unit tests pass a no-op limiter or one + backed by a tmp dir to keep tests hermetic. + """ + if prompt is None and stdin_input is None: + raise ValueError("must supply either `prompt` or `stdin_input`") + if prompt is not None and stdin_input is not None: + raise ValueError("must supply only one of `prompt` or `stdin_input`, not both") + if prompt is not None and not prompt: + raise ValueError("prompt must be a non-empty string when provided") + if stdin_input is not None and not stdin_input: + raise ValueError("stdin_input must be a non-empty string when provided") + if not model: + raise ValueError("model must be a non-empty string") + if not base_url: + raise ValueError("base_url must be a non-empty string") + if not api_key: + raise ValueError("api_key must be a non-empty string") + + # `claude --print` takes the prompt as the **last positional argument**. + # Flags must come before it, otherwise they're parsed as part of the + # prompt (or silently dropped, depending on the CLI version) and the + # tool_use / vision cells fail with confusing "no tool_use observed" + # errors. Build the flag list first, then append the prompt last. + # + # When `extra_args` contains a *variadic* flag like `--allowed-tools + # WebSearch` (commander.js's ``), the parser greedily + # consumes every subsequent token as part of the variadic list — so + # the prompt would be eaten as a tool name. Inserting `--` before + # the prompt terminates option parsing and leaves the prompt as a + # plain positional, which works for variadic and non-variadic flags + # alike. + cmd: List[str] = [ + cli_path, + "--print", + "--output-format", + "stream-json", + "--verbose", + "--model", + model, + ] + if extra_args: + cmd.extend(extra_args) + if prompt is not None: + cmd.append("--") + cmd.append(prompt) + + # Build a minimal env for the CLI subprocess: only the allowlisted + # process-runtime vars from os.environ, plus the explicit proxy + # creds, plus any caller-supplied overrides. See _CLI_ENV_ALLOWLIST + # above for the security rationale. + env: Dict[str, str] = { + key: os.environ[key] for key in _CLI_ENV_ALLOWLIST if key in os.environ + } + env["ANTHROPIC_BASE_URL"] = base_url + env["ANTHROPIC_AUTH_TOKEN"] = api_key + # Hand the CLI a fresh empty HOME so a compromised claude package + # or a model-directed Read tool call can't see the runtime user's + # real dotfiles. Created here, removed in the `finally` below + # regardless of how the subprocess exits. + isolated_home = _make_isolated_home() + env["HOME"] = isolated_home + if extra_env: + env.update(extra_env) + + # Throttle by provider *before* launching the CLI. Doing this here + # (rather than per-test) means every code path that lands on + # `run_claude` is rate-limited automatically — including + # `run_claude_models_parallel`, which is the hot path during the + # full matrix run. + limiter = rate_limiter if rate_limiter is not None else get_default_limiter() + provider = infer_provider(model) + limiter.acquire(provider) + + run_fn = runner or subprocess.run + try: + try: + completed = run_fn( + cmd, + env=env, + input=stdin_input, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as exc: + raise ClaudeCLIError( + f"claude CLI not found at {cli_path!r}; install with `npm i -g @anthropic-ai/claude-code`" + ) from exc + except subprocess.TimeoutExpired as exc: + raise ClaudeCLIError(f"claude CLI timed out after {timeout}s") from exc + finally: + # Best-effort cleanup. If the subprocess wrote a `.claude/` + # session dir under the isolated HOME, we remove it here so + # parallel matrix runs don't accumulate per-call tmpdirs. + # `ignore_errors=True` because rmtree races with any + # not-yet-reaped child (SIGTERM'd `claude` on a host-side + # timeout) are benign — the next matrix run starts from a + # fresh tmpdir anyway. + shutil.rmtree(isolated_home, ignore_errors=True) + + events = _parse_stream_json(completed.stdout or "") + text = _extract_assistant_text(events) + usage = _extract_usage(events) + + return DriverResult( + text=text, + events=events, + exit_code=completed.returncode, + stderr=completed.stderr or "", + usage=usage, + ) + + +ModelResult = Union[DriverResult, ClaudeCLIError] + + +def run_claude_models_parallel( + *, + models: Sequence[str], + prompt: Optional[str], + base_url: str, + api_key: str, + extra_env: Optional[Mapping[str, str]] = None, + extra_args: Optional[Sequence[str]] = None, + stdin_input: Optional[str] = None, + cli_path: str = CLAUDE_CLI_DEFAULT, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + runner: Optional[Callable[..., Any]] = None, +) -> Dict[str, ModelResult]: + """Invoke `run_claude` for every `models[i]` concurrently and collect outcomes. + + Each `claude` CLI invocation is a long-lived subprocess that spends + almost all of its time waiting on the upstream API; running the + three Claude tiers in parallel cuts the per-cell wall time roughly + threefold without changing what each invocation does. + + Threads (rather than asyncio) are the right primitive here because + `subprocess.run` releases the GIL while it waits, and we want to + keep the synchronous CLI driver unchanged so unit tests can keep + injecting a fake `runner`. + + Returns a dict keyed by model id. Each value is either the + `DriverResult` produced by `run_claude` or the `ClaudeCLIError` + that aborted that model's run — callers decide how to map either + into a `compat_result` entry. The shared kwargs (prompt, env, args, + timeout, runner) are forwarded verbatim so the per-model wire is + identical to what the sequential path produces. + """ + if not models: + raise ValueError("models must be a non-empty sequence") + + def _one(model: str) -> Tuple[str, ModelResult, float]: + # Per-model wall clock: this is what the matrix run actually pays for. + # We record it whether the run succeeded or raised so the breakdown + # log below covers both code paths and surfaces "which model is the + # long pole?" without requiring per-test instrumentation. + started = time.monotonic() + try: + result = run_claude( + prompt=prompt, + model=model, + base_url=base_url, + api_key=api_key, + extra_env=extra_env, + extra_args=extra_args, + stdin_input=stdin_input, + cli_path=cli_path, + timeout=timeout, + runner=runner, + ) + elapsed = time.monotonic() - started + # Stamp the duration onto the DriverResult so callers (tests, + # diagnostics) can attribute slow cells without re-timing. + result.duration_ms = int(elapsed * 1000) + return model, result, elapsed + except ClaudeCLIError as exc: + elapsed = time.monotonic() - started + return model, exc, elapsed + except Exception as exc: + # Honor the documented "errors as values" contract for any + # exception type — not just ClaudeCLIError. The rate + # limiter does file I/O (OSError), `infer_provider` can + # raise ValueError on edge-case model strings, and a future + # bug elsewhere in the call stack must not abort the entire + # parallel batch and lose the other models' outcomes. + elapsed = time.monotonic() - started + wrapped = ClaudeCLIError( + f"unexpected error running model {model!r}: " + f"{type(exc).__name__}: {exc}" + ) + wrapped.__cause__ = exc + return model, wrapped, elapsed + + outcomes: Dict[str, ModelResult] = {} + durations: Dict[str, float] = {} + overall_started = time.monotonic() + with ThreadPoolExecutor(max_workers=len(models)) as pool: + futures = [pool.submit(_one, model) for model in models] + for future in as_completed(futures): + model, outcome, elapsed = future.result() + outcomes[model] = outcome + durations[model] = elapsed + overall_elapsed = time.monotonic() - overall_started + + _log_parallel_breakdown(models, durations, outcomes, overall_elapsed) + return outcomes + + +def _log_parallel_breakdown( + models: Sequence[str], + durations: Mapping[str, float], + outcomes: Mapping[str, ModelResult], + overall_elapsed: float, +) -> None: + """Emit a one-block timing breakdown to stderr. + + Pytest only shows captured output for failing tests by default, but + `-s` surfaces it for passing tests too — which is exactly when you + care about "did parallelization actually help?". The block reports: + + - per-model wall time and outcome (ok / cli-error / non-zero exit) + - the slowest model (the parallel run's wall-time floor) + - the sum of sequential model times (what the old serial path + would have paid) + - the overall parallel wall time and the speedup ratio + + If one model dominates, `slowest ≈ overall ≈ sequential / 1`, and + the speedup will be near 1× — exactly the diagnostic that explains + "why didn't this get faster?". + """ + sequential_total = sum(durations.values()) + slowest_model = max(durations, key=durations.get) if durations else None + slowest = durations[slowest_model] if slowest_model else 0.0 + speedup = sequential_total / overall_elapsed if overall_elapsed > 0 else 0.0 + + lines: List[str] = [] + lines.append("[parallel] per-model wall time:") + for model in models: + elapsed = durations.get(model, 0.0) + outcome = outcomes.get(model) + if isinstance(outcome, ClaudeCLIError): + status = "cli-error" + elif isinstance(outcome, DriverResult): + status = f"exit={outcome.exit_code}" + else: + status = "missing" + lines.append(f" {model:<40s} {elapsed:6.2f}s ({status})") + if slowest_model is not None: + lines.append( + f"[parallel] slowest={slowest_model} ({slowest:.2f}s); " + f"sequential_sum={sequential_total:.2f}s; " + f"parallel_wall={overall_elapsed:.2f}s; " + f"speedup={speedup:.2f}x" + ) + print("\n".join(lines), file=sys.stderr, flush=True) + + +def _parse_stream_json(stdout: str) -> List[Dict[str, Any]]: + """Parse newline-delimited JSON emitted by `claude --output-format stream-json`. + + Lines that don't parse as JSON are silently skipped — the CLI occasionally + emits debug output we don't care about, and a single malformed line should + not abort the whole run. Real failure modes surface via exit code. + """ + events: List[Dict[str, Any]] = [] + for line in stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(obj, dict): + events.append(obj) + return events + + +def _extract_assistant_text(events: Sequence[Mapping[str, Any]]) -> str: + """Concatenate the text content of every `assistant` event in order. + + The non-streaming `--print` path emits a single `assistant` event whose + `message.content` is a list of content blocks. We walk the blocks and + join every `text` block — the CLI prints other block types (e.g. + `tool_use`) which we ignore for the basic-messaging case. + """ + chunks: List[str] = [] + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if isinstance(content, str): + chunks.append(content) + continue + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text" and isinstance(block.get("text"), str): + chunks.append(block["text"]) + return "".join(chunks) + + +def failure_diagnostic(result: "DriverResult", *, max_len: int = 800) -> str: + """Build a human-readable error string from a non-zero `claude` CLI run. + + The CLI is annoying to debug because the most useful failure signal + rarely lands on stderr. When the proxy returns an HTTP error, the CLI + swallows it into an `assistant`/`result` event on **stdout** with + `is_error: true` and a JSON-shaped `text` block — and exits non-zero. + Tests that only print `stderr.strip()` see an empty string, which is + exactly the situation that masked a misconfigured proxy in early + bring-up of the compat matrix. + + This helper concatenates the most useful diagnostic we can find, in + priority order: + + 1. `result.text` (the assistant's user-visible reply, which is where + API errors land in stream-json mode), trimmed + 2. `api_error_status` from any `result` event, if present + 3. `result.stderr`, trimmed + 4. `` as a last resort + + The output is truncated to `max_len` characters so a giant HTML 502 + page from a misbehaving load balancer doesn't blow up the matrix + JSON. + """ + pieces: List[str] = [f"exit={result.exit_code}"] + + # api_error_status only appears on the final `result` event when the + # CLI received an HTTP error from the upstream API. Surfacing it + # explicitly makes "is this a proxy/auth problem or a CLI problem?" + # answerable without re-reading the events list. + api_status = _extract_api_error_status(result.events) + if api_status is not None: + pieces.append(f"api_status={api_status}") + + text = (result.text or "").strip() + if text: + pieces.append(f"text={_truncate(text, max_len)}") + + stderr = (result.stderr or "").strip() + if stderr: + pieces.append(f"stderr={_truncate(stderr, max_len)}") + + if len(pieces) == 1: + pieces.append("(no diagnostic output)") + + return "; ".join(pieces) + + +def _extract_api_error_status( + events: Sequence[Mapping[str, Any]], +) -> Optional[int]: + """Return the `api_error_status` from the last `result` event, if any.""" + for event in reversed(list(events)): + if event.get("type") != "result": + continue + status = event.get("api_error_status") + if isinstance(status, int): + return status + return None + + +def _truncate(s: str, max_len: int) -> str: + if len(s) <= max_len: + return s + return s[:max_len] + "...(truncated)" + + +def _extract_usage(events: Sequence[Mapping[str, Any]]) -> Optional[Dict[str, Any]]: + """Return the most recent `usage` block seen on any event, if any. + + The CLI surfaces token + cache usage on the final `result` event for + non-streaming runs, but earlier events also carry partial usage in some + versions; taking the last non-empty one is the safe default. + """ + last: Optional[Dict[str, Any]] = None + for event in events: + usage = event.get("usage") + if isinstance(usage, dict) and usage: + last = usage + continue + message = event.get("message") + if isinstance(message, dict): + inner = message.get("usage") + if isinstance(inner, dict) and inner: + last = inner + return last diff --git a/tests/e2e/claude_code/conftest.py b/tests/e2e/claude_code/conftest.py new file mode 100644 index 00000000000..d2bfa1a54bf --- /dev/null +++ b/tests/e2e/claude_code/conftest.py @@ -0,0 +1,550 @@ +"""Pytest plumbing for the Claude Code compatibility matrix. + +Three responsibilities live here: + +1. The `compat_result` fixture — the only API a test author needs to learn. + Tests call `compat_result.set({"status": "pass"})` (or fail / not_applicable) + to report their outcome as a tagged union. Multi-model tests call + `.add(...)` once per Claude tier so each tier lands as its own row in + the results artifact. + +2. The `pytest_runtest_makereport` hook — captures each test's reported result, + infers (feature, provider) from the file path, and accumulates rows into + a per-process collector. At session end we serialize them to + `compat-results.json` (or a per-worker file under xdist) so the Matrix + JSON Builder can consume them. + +3. xdist coordination — when `pytest -n auto` is used, every worker writes + its own results shard and the controller merges them into the canonical + `compat-results.json` in `pytest_sessionfinish`. Without this, the + workers race on the same path and the artifact only reflects whichever + worker finished last. The same merge step also emits a rate-limit + summary that the binary-search helper consumes to decide whether the + current X/Y/Z values were too aggressive. + +The (feature, provider) inference comes from the test file path: the parent +directory name is the feature_id (matching `manifest.yaml`), and the file +stem after the leading `test_` is the provider id. This avoids per-file +metadata that drifts. +""" + +from __future__ import annotations + +import functools +import json +import os +import re +import sys +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, FrozenSet, List, Optional, Tuple + +import pytest +import yaml + +VALID_STATUSES = {"pass", "fail", "not_applicable", "not_tested"} +RESULTS_ARTIFACT_ENV = "COMPAT_RESULTS_PATH" +DEFAULT_ARTIFACT_PATH = "compat-results.json" +RATE_LIMIT_SUMMARY_ENV = "COMPAT_RATE_LIMIT_SUMMARY_PATH" +DEFAULT_RATE_LIMIT_SUMMARY_PATH = "compat-rate-limit-summary.json" + +# Heuristic: detect 429s and rate-limit-shaped errors anywhere in the +# error string. The CLI buries upstream errors in `assistant.message.content` +# text on stdout (see `failure_diagnostic`), so we don't get a structured +# status code in every code path — a regex over the joined error text is +# the most reliable signal we have. +# +# We also treat a CLI timeout (`claude CLI timed out after Ns`) as a +# rate-limit-shaped failure for binary-search purposes: in practice the +# only reason every model in a cell stalls past the timeout is the +# upstream collapsing under concurrency, which is exactly the situation +# the rate limiter is supposed to back off from. False positives on a +# genuinely slow upstream are tolerable here because the worst case is +# the binary search runs at a slightly lower rate than necessary. +_RATE_LIMIT_RE = re.compile( + r"(?:\b429\b|rate[\s_-]?limit|too\s+many\s+requests|throttl(?:ed|ing)|" + r"claude\s+CLI\s+timed\s+out)", + re.IGNORECASE, +) + + +@dataclass +class CompatResult: + """Per-test recorder for compatibility outcomes. + + Tests interact via `.set(...)` (single result) or `.add(...)` (one + result per Claude tier when the test fans the three models out in + parallel). `.value` and `.values` are read by the + `pytest_runtest_makereport` hook after the test body finishes. + + Multi-result usage exists because every cell in the compat matrix is + backed by three model invocations (Haiku/Sonnet/Opus) per (feature, + provider). When a test runs them concurrently in a single pytest + node, each model needs its own entry in the results artifact so the + matrix builder's per-cell aggregator can apply its "all three must + pass" rule. + """ + + value: Optional[Dict[str, Any]] = None + values: List[Dict[str, Any]] = field(default_factory=list) + + def set(self, result: Dict[str, Any]) -> None: + validated = self._validate(result) + self.value = validated + + def add(self, result: Dict[str, Any]) -> None: + """Append one model's outcome to the per-test results list. + + Use this when a single test exercises multiple Claude tiers + concurrently and needs to report one outcome per tier. The + conftest hook will emit one entry per appended result. + """ + validated = self._validate(result) + self.values.append(validated) + + @staticmethod + def _validate(result: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(result, dict): + raise TypeError("compat_result requires a dict") + status = result.get("status") + if status not in VALID_STATUSES: + raise ValueError( + f"compat_result status must be one of {sorted(VALID_STATUSES)}, " + f"got {status!r}" + ) + if status == "fail" and not result.get("error"): + raise ValueError("compat_result {'status': 'fail'} requires 'error'") + if status == "not_applicable" and not result.get("reason"): + raise ValueError( + "compat_result {'status': 'not_applicable'} requires 'reason'" + ) + return dict(result) + + def collected(self) -> List[Dict[str, Any]]: + """Return every result reported during the test, preserving order. + + Multi-model tests use `.add(...)` per model; legacy tests use + `.set(...)` once. We surface both shapes in a single list so + the makereport hook only has to think about a list of results. + """ + if self.values: + return list(self.values) + if self.value is not None: + return [dict(self.value)] + return [] + + +@dataclass +class _CollectedResult: + feature_id: str + provider: str + nodeid: str + result: Dict[str, Any] + + +@dataclass +class _Collector: + items: List[_CollectedResult] = field(default_factory=list) + + +_COLLECTOR = _Collector() + + +@pytest.fixture +def compat_result() -> CompatResult: + """Per-test recorder for the (feature, provider) outcome. + + Tests should call `compat_result.set({"status": "pass"})` (or fail / + not_applicable) before returning. If a test exits without calling `.set()` + the harness records `status="fail"` with an explanatory error so that + every collected node maps to a real cell. + """ + return CompatResult() + + +@functools.lru_cache(maxsize=1) +def _manifest_feature_ids() -> FrozenSet[str]: + """Return the set of feature_ids declared in `manifest.yaml`. + + 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. + + 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 + safe default — we'd rather drop a real result than pollute the + artifact with a garbage cell. + """ + manifest_path = Path(__file__).resolve().parent / "manifest.yaml" + try: + raw = yaml.safe_load(manifest_path.read_text()) + except (OSError, yaml.YAMLError): + return frozenset() + if not isinstance(raw, dict): + return frozenset() + features = raw.get("features") + if not isinstance(features, list): + return frozenset() + return frozenset( + entry["id"] + for entry in features + if isinstance(entry, dict) and isinstance(entry.get("id"), str) + ) + + +def _infer_feature_and_provider(node_path: Path) -> Optional[tuple]: + """Infer (feature_id, provider) from a test file path. + + Path shape: tests/e2e/claude_code//test_.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. + """ + name = node_path.name + if not name.startswith("test_") or not name.endswith(".py"): + return None + provider = name[len("test_") : -len(".py")] + feature_id = node_path.parent.name + if feature_id not in _manifest_feature_ids(): + return None + return feature_id, provider + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Capture compat_result reports at end-of-test and remember them for the artifact. + + A single test may report multiple results (one per Claude tier when + the three are run in parallel inside one node). We emit one + `_CollectedResult` per reported entry so the matrix builder's + per-cell aggregator sees the same shape it would have seen if the + test were parametrized — every model lands in the artifact. + """ + outcome = yield + report = outcome.get_result() + # We record on two phases: + # - "call": the normal end-of-test path. + # - "setup" but only on failure: fixture/import errors that prevent the + # test body from running. Without recording these, a broken setup + # silently becomes "not_tested" in the published matrix instead of + # "fail". Teardown is ignored — by then "call" already recorded the + # outcome, and a teardown-only failure (e.g. fixture finalizer) is + # not a cell-level signal. + if report.when == "setup": + if not report.failed: + return + elif report.when != "call": + return + + # A skipped test (e.g. `pytest.skip(...)` called inside the body or + # by a `pytest.mark.skipif` evaluated at call time) is neither a + # pass nor a fail — it just didn't run. Recording it as anything + # here would produce a spurious row (the not-failed/empty-collected + # branch below would mark it as a fail with "test passed without + # reporting via compat_result"), so bail out and let the cell stay + # "not_tested" in the published matrix. + if report.skipped: + return + + inferred = _infer_feature_and_provider(Path(str(item.path))) + if inferred is None: + return + feature_id, provider = inferred + + fixture = item.funcargs.get("compat_result") if hasattr(item, "funcargs") else None + collected: List[Dict[str, Any]] = ( + fixture.collected() if isinstance(fixture, CompatResult) else [] + ) + + if report.failed and not any(entry.get("status") == "fail" for entry in collected): + # The test body (or setup) raised and the test author hasn't + # already recorded a fail row via `.add(...)`. If the test had + # recorded only per-model passes before crashing, those partial + # entries would aggregate to "pass" and hide the crash from the + # published matrix; append an explicit "fail" row so the cell + # aggregator (which gives precedence to any fail) surfaces the + # breakage. We skip the append when a fail row is already + # present so that the common pattern — `.add({"status": "fail", + # ...})` per failing model, then `pytest.fail("; ".join(...))` + # to surface them — doesn't produce a phantom duplicate row. + collected = collected + [ + { + "status": "fail", + "error": (str(report.longrepr) if report.longrepr else "test failed"), + } + ] + elif not report.failed and not collected: + collected = [ + { + "status": "fail", + "error": "test passed without reporting via compat_result; " + "every compat test must report a status.", + } + ] + + for reported in collected: + _COLLECTOR.items.append( + _CollectedResult( + feature_id=feature_id, + provider=provider, + nodeid=report.nodeid, + result=reported, + ) + ) + + +def _is_xdist_worker(session) -> bool: + """Return True iff the current pytest session is an xdist worker. + + The standard idiom is to look up `workerinput` on the config; the + controller process doesn't have it, the workers do. We deliberately + don't `import xdist` because the suite must keep running when xdist + isn't installed at all. + """ + return hasattr(session.config, "workerinput") + + +def _xdist_worker_id(session) -> Optional[str]: + info = getattr(session.config, "workerinput", None) + if not info: + return None + return info.get("workerid") + + +def _shard_dir(artifact_path: Path) -> Path: + """Workers write their shards next to the canonical results path. + + Putting shards in a sibling directory (rather than inline JSON + files in the same dir) keeps the controller's merge step simple + — it just lists `*.json` in `.shards/` — and avoids + accidental shard/canonical filename collisions. + """ + return artifact_path.with_name(artifact_path.name + ".shards") + + +def _serialize_items(items: List["_CollectedResult"]) -> List[Dict[str, Any]]: + return [ + { + "feature_id": item.feature_id, + "provider": item.provider, + "nodeid": item.nodeid, + "result": item.result, + } + for item in items + ] + + +def _build_rate_limit_summary( + rows: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Aggregate per-provider rate-limit signals from the result rows. + + We classify any failure whose error string matches `_RATE_LIMIT_RE` + as a "rate-limited" failure. The binary-search helper reads this + summary to decide whether the current X/Y/Z values were too + aggressive: if any provider has `rate_limited > 0`, the harness + should back off that provider's rate and retry. + + Returns a dict shaped: + + { + "totals": {"pass": N, "fail": N, "rate_limited": N, ...}, + "per_provider": { + "anthropic": {"pass": ..., "fail": ..., "rate_limited": ...}, + ... + }, + "rate_limited_examples": [ + {"feature_id": ..., "provider": ..., "error": "..."}, ... + ], + } + """ + totals: Counter = Counter() + per_provider: Dict[str, Counter] = defaultdict(Counter) + rate_limited_examples: List[Dict[str, Any]] = [] + + for row in rows: + result = row.get("result") or {} + status = result.get("status") or "unknown" + provider = row.get("provider") or "unknown" + totals[status] += 1 + per_provider[provider][status] += 1 + + if status == "fail": + error = str(result.get("error") or "") + if _RATE_LIMIT_RE.search(error): + totals["rate_limited"] += 1 + per_provider[provider]["rate_limited"] += 1 + # Cap examples so a stuck-throttled run doesn't write + # a multi-MB summary file the helper has to slurp. + if len(rate_limited_examples) < 25: + rate_limited_examples.append( + { + "feature_id": row.get("feature_id"), + "provider": provider, + "error": error[:500], + } + ) + + return { + "totals": dict(totals), + "per_provider": {p: dict(c) for p, c in per_provider.items()}, + "rate_limited_examples": rate_limited_examples, + } + + +def _print_rate_limit_summary(summary: Dict[str, Any]) -> None: + """Emit a human-readable per-provider table to stderr. + + Pytest only captures stderr when `-s` isn't set; we deliberately + write here anyway because the binary-search workflow runs pytest + with `-q` and grep-checks the structured JSON artifact, while a + human running locally with `-s` sees the same numbers inline. + """ + totals = summary.get("totals", {}) + per_provider = summary.get("per_provider", {}) + lines: List[str] = [] + lines.append("[compat] session totals:") + for status in ("pass", "fail", "rate_limited", "not_applicable", "not_tested"): + if status in totals: + lines.append(f" {status:<16s} {totals[status]}") + if per_provider: + lines.append("[compat] per-provider breakdown:") + for provider in sorted(per_provider): + counts = per_provider[provider] + parts = " ".join( + f"{k}={v}" + for k, v in sorted(counts.items()) + if k != "not_tested" or v > 0 + ) + lines.append(f" {provider:<20s} {parts}") + if totals.get("rate_limited", 0): + lines.append( + "[compat] WARNING: at least one cell hit a rate-limit-shaped error; " + "lower the corresponding LITELLM_COMPAT_RATE_ and retry" + ) + print("\n".join(lines), file=sys.stderr, flush=True) + + +def pytest_sessionstart(session): + """Reset per-session state before tests run. + + Two responsibilities: + + 1. Clear the module-level `_COLLECTOR` singleton, which survives + across `pytest.main()` invocations within the same Python + process. Without this reset, results from a prior session + would leak into the next run's `compat-results.json` artifact. + + 2. Remove stale per-worker shards from any prior session. Without + this, a previous run's shard directory leaks into the next + `pytest_sessionfinish` merge — yielding a `compat-results.json` + that includes results from runs that aren't part of the current + session, and a misleading rate-limit summary that re-flags + failures the user already saw and addressed. Only the + controller (non-xdist-worker) clears; workers must not race + the controller while it's wiping the directory. + """ + _COLLECTOR.items.clear() + _manifest_feature_ids.cache_clear() + + if _is_xdist_worker(session): + return + artifact_path = Path(os.environ.get(RESULTS_ARTIFACT_ENV) or DEFAULT_ARTIFACT_PATH) + shard_dir = _shard_dir(artifact_path) + if not shard_dir.exists(): + return + for stale in shard_dir.glob("*.json"): + try: + stale.unlink() + except OSError: + # If we can't remove a stale shard (permissions, race with + # an unrelated process), keep going — the merge step is + # robust to malformed shards, and a stale row landing in + # the artifact is recoverable; aborting the session isn't. + continue + + +def pytest_sessionfinish(session, exitstatus): + """Write the per-process results shard, then merge if we're the controller. + + Worker processes (xdist `gw0`, `gw1`, ...) only write their shard + under `.shards/.json`. The controller writes + its own shard if it ran any tests itself, then walks the shards + directory and produces the canonical `compat-results.json` plus + the rate-limit summary. Single-process runs (no xdist) take the + same code path with a single shard, so behavior is consistent. + + Skip when no compat results were collected — this conftest is + loaded for every test under `tests/e2e/claude_code/`, including sibling + unit-test trees (e.g. `_driver_unit_tests/`). Writing an empty + artifact would silently overwrite a real artifact from a prior + compat-test run on the same checkout. + + The xdist controller hits this hook with `_COLLECTOR.items` empty + (it never executes tests itself) and `_is_xdist_worker` False, so + we additionally allow the merge step to run when worker shards + are already on disk — otherwise the canonical artifact would + never be produced under `pytest -n auto`. + """ + artifact_path = Path(os.environ.get(RESULTS_ARTIFACT_ENV) or DEFAULT_ARTIFACT_PATH) + shard_dir = _shard_dir(artifact_path) + has_worker_shards = shard_dir.is_dir() and any(shard_dir.glob("*.json")) + if not _COLLECTOR.items and not _is_xdist_worker(session) and not has_worker_shards: + return + shard_dir.mkdir(parents=True, exist_ok=True) + + worker_id = _xdist_worker_id(session) or "main" + shard_path = shard_dir / f"{worker_id}.json" + shard_path.write_text( + json.dumps( + { + "schema_version": "1", + "worker_id": worker_id, + "results": _serialize_items(_COLLECTOR.items), + }, + indent=2, + sort_keys=True, + ) + ) + + # Workers stop here. The controller merges; if we're not running + # under xdist, we are effectively the controller. + if _is_xdist_worker(session): + return + + merged_rows: List[Dict[str, Any]] = [] + for shard_file in sorted(shard_dir.glob("*.json")): + try: + shard = json.loads(shard_file.read_text()) + except (OSError, ValueError): + continue + rows = shard.get("results") + if isinstance(rows, list): + merged_rows.extend(rows) + + # Skip writing artifact + summary entirely for unit-test-only runs + # (no per-feature compat rows). Otherwise every `pytest tests/...` + # run — including local unit-test invocations — would silently + # overwrite a real artifact from a prior compat-test run. + if not merged_rows: + return + + artifact_path.write_text( + json.dumps( + {"schema_version": "1", "results": merged_rows}, + indent=2, + sort_keys=True, + ) + ) + + summary = _build_rate_limit_summary(merged_rows) + summary_path = Path( + os.environ.get(RATE_LIMIT_SUMMARY_ENV) or DEFAULT_RATE_LIMIT_SUMMARY_PATH + ) + summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True)) + _print_rate_limit_summary(summary) diff --git a/tests/e2e/claude_code/count_tokens/__init__.py b/tests/e2e/claude_code/count_tokens/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/count_tokens/test_anthropic.py b/tests/e2e/claude_code/count_tokens/test_anthropic.py new file mode 100644 index 00000000000..3508063459c --- /dev/null +++ b/tests/e2e/claude_code/count_tokens/test_anthropic.py @@ -0,0 +1,94 @@ +"""count_tokens x Anthropic. + +HTTP-probe row. Unlike the CLI-driven rows, this test never invokes +the `claude` CLI: it `POST`s directly to +`{proxy}/v1/messages/count_tokens` for each Claude tier and asserts +the response is shaped `{"input_tokens": }`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/count_tokens/test_anthropic.py + ^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider + +Why HTTP probe instead of CLI: + +Claude Code calls `count_tokens` internally to compute budget / +context-window usage display, but the result is consumed by the CLI +in-process and never appears in stream-json events. There is no CLI +flag that emits the count to stdout in a way our existing +stream-json parser can pick up, so we can't test the endpoint round +trip through the CLI surface. + +The proxy *is* expected to expose `/v1/messages/count_tokens` for +every Claude-style provider it routes to -- LiteLLM has historically +had provider-specific bugs in this endpoint (Vertex AI `count_tokens` +returned 400 to proxy gateways; see Claude Code release notes 2.1.121). +Treating it as a matrix row keeps regressions in the cron's daily +diff. + +The cell goes red if *any* tier's probe fails the minimal shape +check; the matrix's per-cell aggregator handles that automatically. +Three tiers run sequentially because count_tokens is cheap (<100ms +per request typical) and the parallelization that matters for the +CLI rows isn't useful here. +""" + +from __future__ import annotations + +import os + +import pytest + +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-opus-4-7", +] + + +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, + ) + + failures = [] + for model in ANTHROPIC_MODELS: + result = probe_count_tokens( + base_url=base_url, api_key=api_key, model=model + ) + shape_error = assert_count_tokens_shape(result) + if shape_error is not None: + error = f"[{model}] count_tokens probe failed: {shape_error}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/count_tokens/test_azure.py b/tests/e2e/claude_code/count_tokens/test_azure.py new file mode 100644 index 00000000000..2b8707b50b0 --- /dev/null +++ b/tests/e2e/claude_code/count_tokens/test_azure.py @@ -0,0 +1,94 @@ +"""count_tokens x Azure (Microsoft Foundry). + +HTTP-probe row. Unlike the CLI-driven rows, this test never invokes +the `claude` CLI: it `POST`s directly to +`{proxy}/v1/messages/count_tokens` for each Claude tier and asserts +the response is shaped `{"input_tokens": }`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/count_tokens/test_azure.py + ^^^^^^^^^^^^ ^^^^^ + feature_id provider + +Why HTTP probe instead of CLI: + +Claude Code calls `count_tokens` internally to compute budget / +context-window usage display, but the result is consumed by the CLI +in-process and never appears in stream-json events. There is no CLI +flag that emits the count to stdout in a way our existing +stream-json parser can pick up, so we can't test the endpoint round +trip through the CLI surface. + +The proxy *is* expected to expose `/v1/messages/count_tokens` for +every Claude-style provider it routes to -- LiteLLM has historically +had provider-specific bugs in this endpoint (Vertex AI `count_tokens` +returned 400 to proxy gateways; see Claude Code release notes 2.1.121). +Treating it as a matrix row keeps regressions in the cron's daily +diff. + +The cell goes red if *any* tier's probe fails the minimal shape +check; the matrix's per-cell aggregator handles that automatically. +Three tiers run sequentially because count_tokens is cheap (<100ms +per request typical) and the parallelization that matters for the +CLI rows isn't useful here. +""" + +from __future__ import annotations + +import os + +import pytest + +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-opus-4-7-azure", +] + + +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, + ) + + failures = [] + for model in AZURE_MODELS: + result = probe_count_tokens( + base_url=base_url, api_key=api_key, model=model + ) + shape_error = assert_count_tokens_shape(result) + if shape_error is not None: + error = f"[{model}] count_tokens probe failed: {shape_error}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/count_tokens/test_bedrock_converse.py b/tests/e2e/claude_code/count_tokens/test_bedrock_converse.py new file mode 100644 index 00000000000..4221773ead2 --- /dev/null +++ b/tests/e2e/claude_code/count_tokens/test_bedrock_converse.py @@ -0,0 +1,94 @@ +"""count_tokens x Bedrock (Converse). + +HTTP-probe row. Unlike the CLI-driven rows, this test never invokes +the `claude` CLI: it `POST`s directly to +`{proxy}/v1/messages/count_tokens` for each Claude tier and asserts +the response is shaped `{"input_tokens": }`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/count_tokens/test_bedrock_converse.py + ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider + +Why HTTP probe instead of CLI: + +Claude Code calls `count_tokens` internally to compute budget / +context-window usage display, but the result is consumed by the CLI +in-process and never appears in stream-json events. There is no CLI +flag that emits the count to stdout in a way our existing +stream-json parser can pick up, so we can't test the endpoint round +trip through the CLI surface. + +The proxy *is* expected to expose `/v1/messages/count_tokens` for +every Claude-style provider it routes to -- LiteLLM has historically +had provider-specific bugs in this endpoint (Vertex AI `count_tokens` +returned 400 to proxy gateways; see Claude Code release notes 2.1.121). +Treating it as a matrix row keeps regressions in the cron's daily +diff. + +The cell goes red if *any* tier's probe fails the minimal shape +check; the matrix's per-cell aggregator handles that automatically. +Three tiers run sequentially because count_tokens is cheap (<100ms +per request typical) and the parallelization that matters for the +CLI rows isn't useful here. +""" + +from __future__ import annotations + +import os + +import pytest + +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-opus-4-7-bedrock-converse", +] + + +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, + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + result = probe_count_tokens( + base_url=base_url, api_key=api_key, model=model + ) + shape_error = assert_count_tokens_shape(result) + if shape_error is not None: + error = f"[{model}] count_tokens probe failed: {shape_error}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/count_tokens/test_bedrock_invoke.py b/tests/e2e/claude_code/count_tokens/test_bedrock_invoke.py new file mode 100644 index 00000000000..cc70bf12392 --- /dev/null +++ b/tests/e2e/claude_code/count_tokens/test_bedrock_invoke.py @@ -0,0 +1,94 @@ +"""count_tokens x Bedrock (Invoke). + +HTTP-probe row. Unlike the CLI-driven rows, this test never invokes +the `claude` CLI: it `POST`s directly to +`{proxy}/v1/messages/count_tokens` for each Claude tier and asserts +the response is shaped `{"input_tokens": }`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/count_tokens/test_bedrock_invoke.py + ^^^^^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider + +Why HTTP probe instead of CLI: + +Claude Code calls `count_tokens` internally to compute budget / +context-window usage display, but the result is consumed by the CLI +in-process and never appears in stream-json events. There is no CLI +flag that emits the count to stdout in a way our existing +stream-json parser can pick up, so we can't test the endpoint round +trip through the CLI surface. + +The proxy *is* expected to expose `/v1/messages/count_tokens` for +every Claude-style provider it routes to -- LiteLLM has historically +had provider-specific bugs in this endpoint (Vertex AI `count_tokens` +returned 400 to proxy gateways; see Claude Code release notes 2.1.121). +Treating it as a matrix row keeps regressions in the cron's daily +diff. + +The cell goes red if *any* tier's probe fails the minimal shape +check; the matrix's per-cell aggregator handles that automatically. +Three tiers run sequentially because count_tokens is cheap (<100ms +per request typical) and the parallelization that matters for the +CLI rows isn't useful here. +""" + +from __future__ import annotations + +import os + +import pytest + +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-opus-4-7-bedrock-invoke", +] + + +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, + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + result = probe_count_tokens( + base_url=base_url, api_key=api_key, model=model + ) + shape_error = assert_count_tokens_shape(result) + if shape_error is not None: + error = f"[{model}] count_tokens probe failed: {shape_error}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/count_tokens/test_vertex_ai.py b/tests/e2e/claude_code/count_tokens/test_vertex_ai.py new file mode 100644 index 00000000000..8c2678f7010 --- /dev/null +++ b/tests/e2e/claude_code/count_tokens/test_vertex_ai.py @@ -0,0 +1,94 @@ +"""count_tokens x Vertex AI. + +HTTP-probe row. Unlike the CLI-driven rows, this test never invokes +the `claude` CLI: it `POST`s directly to +`{proxy}/v1/messages/count_tokens` for each Claude tier and asserts +the response is shaped `{"input_tokens": }`. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/count_tokens/test_vertex_ai.py + ^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider + +Why HTTP probe instead of CLI: + +Claude Code calls `count_tokens` internally to compute budget / +context-window usage display, but the result is consumed by the CLI +in-process and never appears in stream-json events. There is no CLI +flag that emits the count to stdout in a way our existing +stream-json parser can pick up, so we can't test the endpoint round +trip through the CLI surface. + +The proxy *is* expected to expose `/v1/messages/count_tokens` for +every Claude-style provider it routes to -- LiteLLM has historically +had provider-specific bugs in this endpoint (Vertex AI `count_tokens` +returned 400 to proxy gateways; see Claude Code release notes 2.1.121). +Treating it as a matrix row keeps regressions in the cron's daily +diff. + +The cell goes red if *any* tier's probe fails the minimal shape +check; the matrix's per-cell aggregator handles that automatically. +Three tiers run sequentially because count_tokens is cheap (<100ms +per request typical) and the parallelization that matters for the +CLI rows isn't useful here. +""" + +from __future__ import annotations + +import os + +import pytest + +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-opus-4-7-vertex", +] + + +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, + ) + + failures = [] + for model in VERTEX_AI_MODELS: + result = probe_count_tokens( + base_url=base_url, api_key=api_key, model=model + ) + shape_error = assert_count_tokens_shape(result) + if shape_error is not None: + error = f"[{model}] count_tokens probe failed: {shape_error}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/cron_vm/build_matrix.py b/tests/e2e/claude_code/cron_vm/build_matrix.py new file mode 100644 index 00000000000..128f041cced --- /dev/null +++ b/tests/e2e/claude_code/cron_vm/build_matrix.py @@ -0,0 +1,50 @@ +"""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()) diff --git a/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.env.example b/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.env.example new file mode 100644 index 00000000000..11633810533 --- /dev/null +++ b/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.env.example @@ -0,0 +1,50 @@ +# 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= + +# 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 diff --git a/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.service b/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.service new file mode 100644 index 00000000000..c05ece90f50 --- /dev/null +++ b/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.service @@ -0,0 +1,141 @@ +# 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 diff --git a/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.timer b/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.timer new file mode 100644 index 00000000000..ee22538c6ed --- /dev/null +++ b/tests/e2e/claude_code/cron_vm/litellm-compat-matrix.timer @@ -0,0 +1,25 @@ +# 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 diff --git a/tests/e2e/claude_code/cron_vm/run_daily.sh b/tests/e2e/claude_code/cron_vm/run_daily.sh new file mode 100755 index 00000000000..ae2d67c070c --- /dev/null +++ b/tests/e2e/claude_code/cron_vm/run_daily.sh @@ -0,0 +1,590 @@ +#!/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/--`), 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(?[0-9]+)\\.(?[0-9]+)\\.(?[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// + # 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//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//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 </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 < ${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" diff --git a/tests/e2e/claude_code/http_probe.py b/tests/e2e/claude_code/http_probe.py new file mode 100644 index 00000000000..d95307db3b9 --- /dev/null +++ b/tests/e2e/claude_code/http_probe.py @@ -0,0 +1,292 @@ +"""Direct HTTP probe helpers for the Claude Code compatibility matrix. + +Most matrix cells drive the `claude` CLI in headless mode and observe +the stream-json wire (see `cli_driver.py`). A handful of features the +proxy must support don't have any CLI surface area -- `count_tokens` is +the canonical example: Claude Code calls it internally for budget +display, but the result never appears in stream-json events, so a CLI +test cannot observe whether the endpoint round-tripped correctly +through the proxy for any given provider. + +This module is the second test pattern the matrix supports: a plain +HTTP POST against a LiteLLM proxy endpoint, parsed and shape-checked +in the test, with the same `compat_result` recording convention as the +CLI-driven cells. The goal is to keep this pattern *narrow* -- if a +feature can be tested via the CLI, it should be, because the CLI path +is closer to what real Claude Code users hit. HTTP probes are only for +features the CLI can't reach. + +The probe deliberately uses a short timeout (30s) and small payloads: +this is a "did the request shape survive the proxy's +provider-specific transformations" test, not a load test, and a real +endpoint regression typically surfaces in well under a second of wall +time (400 / 500 from the upstream, or LiteLLM 500 on a transformation +bug). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Mapping, Optional + +import httpx + +from claude_code.rate_limiter import ( + RateLimiter, + get_default_limiter, + infer_provider, +) + + +DEFAULT_TIMEOUT_SECONDS = 30.0 + + +@dataclass +class ProbeResult: + """Structured outcome of a single HTTP probe. + + `status_code` and `body` are the wire response; `payload` is the + parsed JSON body if the response was JSON, else None. Tests assert + on `status_code` + `payload` shape; `body` is preserved so failure + diagnostics can echo the raw error string (which is the only thing + a maintainer needs to triage a red cell). + """ + + status_code: int + body: str + payload: Optional[Mapping[str, Any]] = None + error: Optional[str] = None + + +def probe_count_tokens( + *, + base_url: str, + api_key: str, + model: str, + message: str = "hello world", + timeout: float = DEFAULT_TIMEOUT_SECONDS, + rate_limiter: Optional[RateLimiter] = None, +) -> ProbeResult: + """POST to `{base_url}/v1/messages/count_tokens` for `model` and return the parsed result. + + The Anthropic / LiteLLM `count_tokens` endpoint accepts a request + body whose shape mirrors `/v1/messages` (model + messages), and + returns `{"input_tokens": N}` for a successful response. Anything + else -- non-200 status, non-JSON body, missing/non-int + `input_tokens` -- is a regression we want the cell to flip red on. + + The same cross-process token-bucket limiter `cli_driver.run_claude` + uses is acquired here too, so probe rows count against the + aggregate per-provider budget. Without this, an HTTP-probe row + would fire unthrottled requests in parallel with throttled CLI + rows and silently violate the limiter's aggregate-rate guarantee. + `rate_limiter` is an injection seam for unit tests; production + callers should leave it unset to use the process-wide default. + """ + limiter = rate_limiter if rate_limiter is not None else get_default_limiter() + limiter.acquire(infer_provider(model)) + + url = base_url.rstrip("/") + "/v1/messages/count_tokens" + try: + response = httpx.post( + url, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + # `anthropic-version` is required by Anthropic's native + # API and harmless on every other provider the proxy + # routes to. Matches what the Claude Code CLI sends + # for its own internal `count_tokens` calls. + "anthropic-version": "2023-06-01", + }, + json={"model": model, "messages": [{"role": "user", "content": message}]}, + timeout=timeout, + ) + except httpx.HTTPError as exc: + return ProbeResult(status_code=0, body="", error=f"transport: {exc}") + + body = response.text or "" + try: + payload = response.json() if body else None + except (json.JSONDecodeError, ValueError): + payload = None + + return ProbeResult( + status_code=response.status_code, + body=body, + payload=payload, + ) + + +def probe_tool_search( + *, + base_url: str, + api_key: str, + model: str, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + rate_limiter: Optional[RateLimiter] = None, +) -> ProbeResult: + """POST to `{base_url}/v1/messages` with a `tool_search_tool_regex_20251119` + tool definition and return the result. + + The shape of the tools array is the one Claude Code emits when its + MCP-tool-search beta is active: a `tool_search_tool_regex_20251119` + discovery tool (name `tool_search_tool_regex`) plus at least one + regular user tool to be searched. LiteLLM's + `is_tool_search_used` helper keys on the `_20251119`-suffixed type + string to decide whether to attach the provider-specific tool-search + beta header (`advanced-tool-use-2025-11-20` for Anthropic/Azure, + `tool-search-tool-2025-10-19` for Vertex/Bedrock). A proxy + regression in that translation will surface here as a 400 from + the upstream complaining about the tool type or beta header. + + The prompt deliberately does not force a tool call -- the goal is + to verify the *request* round-trips without 400 and produces some + response, not to test whether the model decided to invoke + tool_search. That kind of behavior test would couple this row to + Claude Code's model behavior heuristics, which change weekly. + + Like `probe_count_tokens`, this acquires one token from the + process-wide rate limiter so probe traffic counts against the + same aggregate per-provider budget as the CLI rows. `rate_limiter` + is a test seam; production callers should leave it unset. + """ + limiter = rate_limiter if rate_limiter is not None else get_default_limiter() + limiter.acquire(infer_provider(model)) + + url = base_url.rstrip("/") + "/v1/messages" + payload = { + "model": model, + "max_tokens": 64, + "messages": [ + { + "role": "user", + "content": ( + "If you have a tool to discover other tools, use it to " + "find one. Otherwise reply with the word 'done'." + ), + } + ], + "tools": [ + # The tool_search discovery tool itself. Type is the SDK- + # version-pinned `_20251119` suffix; name is the canonical + # `tool_search_tool_regex` (no suffix) Anthropic accepts. + # LiteLLM keys its beta-header translation on the type. + { + "type": "tool_search_tool_regex_20251119", + "name": "tool_search_tool_regex", + }, + # A trivial user tool for the discovery tool to potentially + # surface. Without at least one non-search tool the request + # is shape-valid but semantically empty; we include one so + # the wire shape mirrors what real Claude Code sends. + { + "name": "add_numbers", + "description": "Add two integers", + "input_schema": { + "type": "object", + "properties": { + "a": {"type": "integer"}, + "b": {"type": "integer"}, + }, + "required": ["a", "b"], + }, + }, + ], + } + try: + response = httpx.post( + url, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + }, + json=payload, + timeout=timeout, + ) + except httpx.HTTPError as exc: + return ProbeResult(status_code=0, body="", error=f"transport: {exc}") + + body = response.text or "" + try: + payload_out = response.json() if body else None + except (json.JSONDecodeError, ValueError): + payload_out = None + + return ProbeResult( + status_code=response.status_code, + body=body, + payload=payload_out, + ) + + +def assert_tool_search_shape(result: ProbeResult) -> Optional[str]: + """Return None on success, else describe the first violation. + + Acceptance criteria: + + 1. HTTP status is 200 (no 400 from the upstream rejecting the + tool_search tool type or a missing beta header). + 2. Body is valid JSON. + 3. Body has either `content` (Anthropic-shape passthrough) or + `choices` (LiteLLM normalized openai-shape, used by Bedrock + Converse). Either is acceptable -- the matrix cares that the + proxy *accepts and forwards* tool_search, not that the model + actually chose to invoke it. Tool-invocation behavior is a + model decision the matrix has no business asserting on. + + The cell goes red when the upstream rejects the tool type, the + proxy drops the beta header, or the response shape is unusable. + Anything else (model decided to call or not call tool_search) is + irrelevant for this row. + """ + if result.error is not None: + return f"transport error: {result.error}" + if result.status_code != 200: + return f"status {result.status_code}: {result.body[:400]}" + if result.payload is None: + return f"non-JSON body: {result.body[:400]}" + if not isinstance(result.payload, Mapping): + return f"body is not a JSON object: {type(result.payload).__name__}" + # LiteLLM normalizes some provider responses to OpenAI shape + # (`choices`) and passes others through Anthropic-shape (`content`). + # Accept either; both prove the proxy round-tripped the request. + if "content" not in result.payload and "choices" not in result.payload: + return ( + f"response has neither `content` nor `choices`: " + f"keys={sorted(result.payload.keys())}" + ) + return None + + +def assert_count_tokens_shape(result: ProbeResult) -> Optional[str]: + """Return None on success, or an error string describing the first violation. + + Acceptance criteria are intentionally minimal: + + 1. HTTP status is 200. + 2. Body is valid JSON. + 3. Body has an `input_tokens` key whose value is a positive int. + + Anything beyond that (cache token fields, server metadata) is + optional and varies by provider/transport. Asserting on extras + would create a brittle test that flips red on neutral protocol + drift; matrix cells should only go red on functional regressions + a Claude Code user would feel. + """ + if result.error is not None: + return f"transport error: {result.error}" + if result.status_code != 200: + return f"status {result.status_code}: {result.body[:400]}" + if result.payload is None: + return f"non-JSON body: {result.body[:400]}" + if not isinstance(result.payload, Mapping): + return f"body is not a JSON object: {type(result.payload).__name__}" + tokens = result.payload.get("input_tokens") + if not isinstance(tokens, int) or isinstance(tokens, bool): + return f"input_tokens missing or not an int: got {tokens!r}" + if tokens <= 0: + return f"input_tokens must be positive; got {tokens}" + return None diff --git a/tests/e2e/claude_code/long_context_1m/__init__.py b/tests/e2e/claude_code/long_context_1m/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/long_context_1m/test_anthropic.py b/tests/e2e/claude_code/long_context_1m/test_anthropic.py new file mode 100644 index 00000000000..fb74d5fd40d --- /dev/null +++ b/tests/e2e/claude_code/long_context_1m/test_anthropic.py @@ -0,0 +1,224 @@ +"""long_context_1m x Anthropic. + +Drive the real `claude` CLI in headless mode with a ~210k-token padded +prompt and the `--betas context-1m-2025-08-07` beta header, route +through a LiteLLM proxy aimed at Anthropic, and assert the request +round-trips: no 400 from a stripped beta header, no 413 from a body +the proxy refused to forward, and a non-empty assistant reply at the +end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/long_context_1m/test_anthropic.py + ^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider + +Cost note (read this before scaling the prompt up): + +This row genuinely exercises the long-context path -- a 210k-token +prompt is *just over* Claude's standard 200k context window, which is +the threshold that requires the `context-1m-2025-08-07` beta header +to be honored end-to-end. Anything shorter would only test whether +the proxy forwards the beta header byte-for-byte; it would not catch +provider-side regressions where the header is forwarded but the +upstream silently truncates beyond the standard context (we've seen +this on third-party gateways). Anything longer is wasted spend. + +Per-cell cost at 210k input tokens: ~$0.63 Sonnet, ~$3.15 Opus. +Daily cost across all five providers (this row only): ~$19. + +Haiku 4.5 is intentionally omitted: it does not support 1M context +(its window is 200k). Reporting `not_applicable` for Haiku would +flip the entire cell to `not_applicable`, hiding genuine 1M +regressions on Sonnet/Opus; instead we exclude Haiku from the model +list entirely and let the matrix's per-cell aggregator green the +cell on Sonnet + Opus passing. This is the one row where the "all +three tiers must pass" rule is relaxed; it's relaxed structurally +(via the model list), not semantically (via not_applicable), so the +matrix builder stays unmodified. + +The prompt is delivered via subprocess stdin rather than a positional +argument. ARG_MAX on Linux is typically 2MB and an 840KB prompt +fits within that comfortably, but stdin is safer (no shell escaping +surprises, no surprise ARG_MAX clamp on a tightened sandbox) and +keeps the driver's `extra_args` slot free for the `--betas` flag. + +`--max-budget-usd 6` is a runaway-loop guard: a misbehaving test +that loops three Claude tiers in a single cell can't accidentally +spend more than ~$18 on this cell. The cap is twice the expected +worst case (Opus @ 210k = $3.15) plus a 50% margin. Tighten it if a +provider's pricing changes and the matrix starts spending more than +$10/day on this row. +""" + +from __future__ import annotations + +import os +from typing import Sequence + +import pytest + +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-opus-4-7", +) + +# Beta header that opts an Anthropic-shape model into the 1M context +# window. Same string is accepted on Bedrock (Invoke + Converse) and +# Vertex per LiteLLM's transformers -- no per-provider translation is +# needed for this header, unlike `advanced-tool-use-2025-11-20` / +# `tool-search-tool-2025-10-19`. +LONG_CONTEXT_BETA = "context-1m-2025-08-07" + +# Target a padded prompt that lands just above Claude's standard 200k +# context window so the request can only succeed if the +# `context-1m-2025-08-07` beta header survives all the way to the +# upstream. Below 200k the cell would silently pass even with a +# proxy-dropped beta header; above ~220k we're paying for tokens that +# don't add signal. +TARGET_INPUT_TOKENS = 210_000 + +# Anthropic's English tokenizer averages ~4 chars/token. We cycle +# through several benign pangrams + filler so the padding looks like a +# real document, not a repeating monolith. Identical-line padding + +# "ignore everything above" trips Opus 4.7's safety filter as a +# suspected prompt-injection attempt -- we hit that during smoke +# testing and the cell flipped red for the wrong reason. Varied prose +# with a natural document-style framing keeps the filter quiet. +_PAD_CHUNKS = ( + "The quick brown fox jumps over the lazy dog. ", + "She sells seashells by the seashore on Sunday mornings. ", + "Pack my box with five dozen liquor jugs for the journey. ", + "How vexingly quick daft zebras jump over fences at dawn. ", + "Sphinx of black quartz, judge my vow of silence and patience. ", + "Waltz, bad nymph, for quick jigs in the moonlit meadow. ", + "Glib jocks quiz nymph to vex dwarf with a riddle of stone. ", + "Crazy Fredrick bought many very exquisite opal jewels lately. ", +) +_CHARS_PER_TOKEN = 4 + + +def _build_long_prompt(target_tokens: int = TARGET_INPUT_TOKENS) -> str: + """Build a ~target_tokens-token padded prompt with a trailing instruction. + + Framing: + + - Lead with a benign document-style preamble that justifies the + long context (so safety filters see the prompt as "long + document review" rather than "adversarial padding"). + - Cycle through a small set of pangrams + filler sentences for + the bulk of the padding. Variety matters: identical repeated + lines look like a denial-of-service or injection attempt to + Anthropic's content filter on the larger tiers. + - End with the actual question. Claude's instruction-following + is stronger on recent tokens, so a 210k-token-into-the-past + instruction would risk a false-fail where the model ignores + it. + + `target_tokens` is an approximation: actual token count depends + on the tokenizer, but Anthropic's English tokenizer averages + ~4 chars/token, so 4 × target_tokens chars of padding gets us + close enough to the 1M-beta threshold (200k) that the proxy's + beta-header handling is the only path to success. + """ + preamble = ( + "I'm going to share an excerpt from a long document with you. " + "It contains a mix of practice sentences a typist might use to " + "warm up; treat the bulk of the text as background context. " + "I'll ask a short question at the end.\n\n" + "Begin excerpt:\n\n" + ) + closing = "\n\nEnd of excerpt. Please reply with the single word 'ok'." + + pad_target_chars = target_tokens * _CHARS_PER_TOKEN - len(preamble) - len(closing) + pad_lines = [] + pad_len = 0 + idx = 0 + while pad_len < pad_target_chars: + chunk = _PAD_CHUNKS[idx % len(_PAD_CHUNKS)] + pad_lines.append(chunk) + pad_len += len(chunk) + idx += 1 + return preamble + "".join(pad_lines) + closing + + +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, + ) + + long_prompt = _build_long_prompt() + + outcomes = run_claude_models_parallel( + models=ANTHROPIC_MODELS, + prompt=None, + stdin_input=long_prompt, + base_url=base_url, + api_key=api_key, + extra_args=[ + "--betas", + LONG_CONTEXT_BETA, + # Hard ceiling so a runaway test cannot blow the budget. + # See module docstring for sizing. + "--max-budget-usd", + "6", + ], + # Long-context requests can take a couple of minutes on a + # loaded upstream; the driver's default 120s is too tight. + timeout=300.0, + ) + + failures = [] + for model in ANTHROPIC_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not outcome.text.strip(): + error = f"[{model}] claude returned empty assistant text" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/long_context_1m/test_azure.py b/tests/e2e/claude_code/long_context_1m/test_azure.py new file mode 100644 index 00000000000..5800fdadbfc --- /dev/null +++ b/tests/e2e/claude_code/long_context_1m/test_azure.py @@ -0,0 +1,224 @@ +"""long_context_1m x Azure (Microsoft Foundry). + +Drive the real `claude` CLI in headless mode with a ~210k-token padded +prompt and the `--betas context-1m-2025-08-07` beta header, route +through a LiteLLM proxy aimed at Anthropic, and assert the request +round-trips: no 400 from a stripped beta header, no 413 from a body +the proxy refused to forward, and a non-empty assistant reply at the +end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/long_context_1m/test_azure.py + ^^^^^^^^^^^^^^^ ^^^^^ + feature_id provider + +Cost note (read this before scaling the prompt up): + +This row genuinely exercises the long-context path -- a 210k-token +prompt is *just over* Claude's standard 200k context window, which is +the threshold that requires the `context-1m-2025-08-07` beta header +to be honored end-to-end. Anything shorter would only test whether +the proxy forwards the beta header byte-for-byte; it would not catch +provider-side regressions where the header is forwarded but the +upstream silently truncates beyond the standard context (we've seen +this on third-party gateways). Anything longer is wasted spend. + +Per-cell cost at 210k input tokens: ~$0.63 Sonnet, ~$3.15 Opus. +Daily cost across all five providers (this row only): ~$19. + +Haiku 4.5 is intentionally omitted: it does not support 1M context +(its window is 200k). Reporting `not_applicable` for Haiku would +flip the entire cell to `not_applicable`, hiding genuine 1M +regressions on Sonnet/Opus; instead we exclude Haiku from the model +list entirely and let the matrix's per-cell aggregator green the +cell on Sonnet + Opus passing. This is the one row where the "all +three tiers must pass" rule is relaxed; it's relaxed structurally +(via the model list), not semantically (via not_applicable), so the +matrix builder stays unmodified. + +The prompt is delivered via subprocess stdin rather than a positional +argument. ARG_MAX on Linux is typically 2MB and an 840KB prompt +fits within that comfortably, but stdin is safer (no shell escaping +surprises, no surprise ARG_MAX clamp on a tightened sandbox) and +keeps the driver's `extra_args` slot free for the `--betas` flag. + +`--max-budget-usd 6` is a runaway-loop guard: a misbehaving test +that loops three Claude tiers in a single cell can't accidentally +spend more than ~$18 on this cell. The cap is twice the expected +worst case (Opus @ 210k = $3.15) plus a 50% margin. Tighten it if a +provider's pricing changes and the matrix starts spending more than +$10/day on this row. +""" + +from __future__ import annotations + +import os +from typing import Sequence + +import pytest + +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-opus-4-7-azure", +) + +# Beta header that opts an Anthropic-shape model into the 1M context +# window. Same string is accepted on Bedrock (Invoke + Converse) and +# Vertex per LiteLLM's transformers -- no per-provider translation is +# needed for this header, unlike `advanced-tool-use-2025-11-20` / +# `tool-search-tool-2025-10-19`. +LONG_CONTEXT_BETA = "context-1m-2025-08-07" + +# Target a padded prompt that lands just above Claude's standard 200k +# context window so the request can only succeed if the +# `context-1m-2025-08-07` beta header survives all the way to the +# upstream. Below 200k the cell would silently pass even with a +# proxy-dropped beta header; above ~220k we're paying for tokens that +# don't add signal. +TARGET_INPUT_TOKENS = 210_000 + +# Anthropic's English tokenizer averages ~4 chars/token. We cycle +# through several benign pangrams + filler so the padding looks like a +# real document, not a repeating monolith. Identical-line padding + +# "ignore everything above" trips Opus 4.7's safety filter as a +# suspected prompt-injection attempt -- we hit that during smoke +# testing and the cell flipped red for the wrong reason. Varied prose +# with a natural document-style framing keeps the filter quiet. +_PAD_CHUNKS = ( + "The quick brown fox jumps over the lazy dog. ", + "She sells seashells by the seashore on Sunday mornings. ", + "Pack my box with five dozen liquor jugs for the journey. ", + "How vexingly quick daft zebras jump over fences at dawn. ", + "Sphinx of black quartz, judge my vow of silence and patience. ", + "Waltz, bad nymph, for quick jigs in the moonlit meadow. ", + "Glib jocks quiz nymph to vex dwarf with a riddle of stone. ", + "Crazy Fredrick bought many very exquisite opal jewels lately. ", +) +_CHARS_PER_TOKEN = 4 + + +def _build_long_prompt(target_tokens: int = TARGET_INPUT_TOKENS) -> str: + """Build a ~target_tokens-token padded prompt with a trailing instruction. + + Framing: + + - Lead with a benign document-style preamble that justifies the + long context (so safety filters see the prompt as "long + document review" rather than "adversarial padding"). + - Cycle through a small set of pangrams + filler sentences for + the bulk of the padding. Variety matters: identical repeated + lines look like a denial-of-service or injection attempt to + Anthropic's content filter on the larger tiers. + - End with the actual question. Claude's instruction-following + is stronger on recent tokens, so a 210k-token-into-the-past + instruction would risk a false-fail where the model ignores + it. + + `target_tokens` is an approximation: actual token count depends + on the tokenizer, but Anthropic's English tokenizer averages + ~4 chars/token, so 4 × target_tokens chars of padding gets us + close enough to the 1M-beta threshold (200k) that the proxy's + beta-header handling is the only path to success. + """ + preamble = ( + "I'm going to share an excerpt from a long document with you. " + "It contains a mix of practice sentences a typist might use to " + "warm up; treat the bulk of the text as background context. " + "I'll ask a short question at the end.\n\n" + "Begin excerpt:\n\n" + ) + closing = "\n\nEnd of excerpt. Please reply with the single word 'ok'." + + pad_target_chars = target_tokens * _CHARS_PER_TOKEN - len(preamble) - len(closing) + pad_lines = [] + pad_len = 0 + idx = 0 + while pad_len < pad_target_chars: + chunk = _PAD_CHUNKS[idx % len(_PAD_CHUNKS)] + pad_lines.append(chunk) + pad_len += len(chunk) + idx += 1 + return preamble + "".join(pad_lines) + closing + + +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, + ) + + long_prompt = _build_long_prompt() + + outcomes = run_claude_models_parallel( + models=AZURE_MODELS, + prompt=None, + stdin_input=long_prompt, + base_url=base_url, + api_key=api_key, + extra_args=[ + "--betas", + LONG_CONTEXT_BETA, + # Hard ceiling so a runaway test cannot blow the budget. + # See module docstring for sizing. + "--max-budget-usd", + "6", + ], + # Long-context requests can take a couple of minutes on a + # loaded upstream; the driver's default 120s is too tight. + timeout=300.0, + ) + + failures = [] + for model in AZURE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not outcome.text.strip(): + error = f"[{model}] claude returned empty assistant text" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/long_context_1m/test_bedrock_converse.py b/tests/e2e/claude_code/long_context_1m/test_bedrock_converse.py new file mode 100644 index 00000000000..18587f7c2d6 --- /dev/null +++ b/tests/e2e/claude_code/long_context_1m/test_bedrock_converse.py @@ -0,0 +1,224 @@ +"""long_context_1m x Bedrock (Converse). + +Drive the real `claude` CLI in headless mode with a ~210k-token padded +prompt and the `--betas context-1m-2025-08-07` beta header, route +through a LiteLLM proxy aimed at Anthropic, and assert the request +round-trips: no 400 from a stripped beta header, no 413 from a body +the proxy refused to forward, and a non-empty assistant reply at the +end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/long_context_1m/test_bedrock_converse.py + ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider + +Cost note (read this before scaling the prompt up): + +This row genuinely exercises the long-context path -- a 210k-token +prompt is *just over* Claude's standard 200k context window, which is +the threshold that requires the `context-1m-2025-08-07` beta header +to be honored end-to-end. Anything shorter would only test whether +the proxy forwards the beta header byte-for-byte; it would not catch +provider-side regressions where the header is forwarded but the +upstream silently truncates beyond the standard context (we've seen +this on third-party gateways). Anything longer is wasted spend. + +Per-cell cost at 210k input tokens: ~$0.63 Sonnet, ~$3.15 Opus. +Daily cost across all five providers (this row only): ~$19. + +Haiku 4.5 is intentionally omitted: it does not support 1M context +(its window is 200k). Reporting `not_applicable` for Haiku would +flip the entire cell to `not_applicable`, hiding genuine 1M +regressions on Sonnet/Opus; instead we exclude Haiku from the model +list entirely and let the matrix's per-cell aggregator green the +cell on Sonnet + Opus passing. This is the one row where the "all +three tiers must pass" rule is relaxed; it's relaxed structurally +(via the model list), not semantically (via not_applicable), so the +matrix builder stays unmodified. + +The prompt is delivered via subprocess stdin rather than a positional +argument. ARG_MAX on Linux is typically 2MB and an 840KB prompt +fits within that comfortably, but stdin is safer (no shell escaping +surprises, no surprise ARG_MAX clamp on a tightened sandbox) and +keeps the driver's `extra_args` slot free for the `--betas` flag. + +`--max-budget-usd 6` is a runaway-loop guard: a misbehaving test +that loops three Claude tiers in a single cell can't accidentally +spend more than ~$18 on this cell. The cap is twice the expected +worst case (Opus @ 210k = $3.15) plus a 50% margin. Tighten it if a +provider's pricing changes and the matrix starts spending more than +$10/day on this row. +""" + +from __future__ import annotations + +import os +from typing import Sequence + +import pytest + +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-opus-4-7-bedrock-converse", +) + +# Beta header that opts an Anthropic-shape model into the 1M context +# window. Same string is accepted on Bedrock (Invoke + Converse) and +# Vertex per LiteLLM's transformers -- no per-provider translation is +# needed for this header, unlike `advanced-tool-use-2025-11-20` / +# `tool-search-tool-2025-10-19`. +LONG_CONTEXT_BETA = "context-1m-2025-08-07" + +# Target a padded prompt that lands just above Claude's standard 200k +# context window so the request can only succeed if the +# `context-1m-2025-08-07` beta header survives all the way to the +# upstream. Below 200k the cell would silently pass even with a +# proxy-dropped beta header; above ~220k we're paying for tokens that +# don't add signal. +TARGET_INPUT_TOKENS = 210_000 + +# Anthropic's English tokenizer averages ~4 chars/token. We cycle +# through several benign pangrams + filler so the padding looks like a +# real document, not a repeating monolith. Identical-line padding + +# "ignore everything above" trips Opus 4.7's safety filter as a +# suspected prompt-injection attempt -- we hit that during smoke +# testing and the cell flipped red for the wrong reason. Varied prose +# with a natural document-style framing keeps the filter quiet. +_PAD_CHUNKS = ( + "The quick brown fox jumps over the lazy dog. ", + "She sells seashells by the seashore on Sunday mornings. ", + "Pack my box with five dozen liquor jugs for the journey. ", + "How vexingly quick daft zebras jump over fences at dawn. ", + "Sphinx of black quartz, judge my vow of silence and patience. ", + "Waltz, bad nymph, for quick jigs in the moonlit meadow. ", + "Glib jocks quiz nymph to vex dwarf with a riddle of stone. ", + "Crazy Fredrick bought many very exquisite opal jewels lately. ", +) +_CHARS_PER_TOKEN = 4 + + +def _build_long_prompt(target_tokens: int = TARGET_INPUT_TOKENS) -> str: + """Build a ~target_tokens-token padded prompt with a trailing instruction. + + Framing: + + - Lead with a benign document-style preamble that justifies the + long context (so safety filters see the prompt as "long + document review" rather than "adversarial padding"). + - Cycle through a small set of pangrams + filler sentences for + the bulk of the padding. Variety matters: identical repeated + lines look like a denial-of-service or injection attempt to + Anthropic's content filter on the larger tiers. + - End with the actual question. Claude's instruction-following + is stronger on recent tokens, so a 210k-token-into-the-past + instruction would risk a false-fail where the model ignores + it. + + `target_tokens` is an approximation: actual token count depends + on the tokenizer, but Anthropic's English tokenizer averages + ~4 chars/token, so 4 × target_tokens chars of padding gets us + close enough to the 1M-beta threshold (200k) that the proxy's + beta-header handling is the only path to success. + """ + preamble = ( + "I'm going to share an excerpt from a long document with you. " + "It contains a mix of practice sentences a typist might use to " + "warm up; treat the bulk of the text as background context. " + "I'll ask a short question at the end.\n\n" + "Begin excerpt:\n\n" + ) + closing = "\n\nEnd of excerpt. Please reply with the single word 'ok'." + + pad_target_chars = target_tokens * _CHARS_PER_TOKEN - len(preamble) - len(closing) + pad_lines = [] + pad_len = 0 + idx = 0 + while pad_len < pad_target_chars: + chunk = _PAD_CHUNKS[idx % len(_PAD_CHUNKS)] + pad_lines.append(chunk) + pad_len += len(chunk) + idx += 1 + return preamble + "".join(pad_lines) + closing + + +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, + ) + + long_prompt = _build_long_prompt() + + outcomes = run_claude_models_parallel( + models=BEDROCK_CONVERSE_MODELS, + prompt=None, + stdin_input=long_prompt, + base_url=base_url, + api_key=api_key, + extra_args=[ + "--betas", + LONG_CONTEXT_BETA, + # Hard ceiling so a runaway test cannot blow the budget. + # See module docstring for sizing. + "--max-budget-usd", + "6", + ], + # Long-context requests can take a couple of minutes on a + # loaded upstream; the driver's default 120s is too tight. + timeout=300.0, + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not outcome.text.strip(): + error = f"[{model}] claude returned empty assistant text" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/long_context_1m/test_bedrock_invoke.py b/tests/e2e/claude_code/long_context_1m/test_bedrock_invoke.py new file mode 100644 index 00000000000..0270197ce2a --- /dev/null +++ b/tests/e2e/claude_code/long_context_1m/test_bedrock_invoke.py @@ -0,0 +1,224 @@ +"""long_context_1m x Bedrock (Invoke). + +Drive the real `claude` CLI in headless mode with a ~210k-token padded +prompt and the `--betas context-1m-2025-08-07` beta header, route +through a LiteLLM proxy aimed at Anthropic, and assert the request +round-trips: no 400 from a stripped beta header, no 413 from a body +the proxy refused to forward, and a non-empty assistant reply at the +end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/long_context_1m/test_bedrock_invoke.py + ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider + +Cost note (read this before scaling the prompt up): + +This row genuinely exercises the long-context path -- a 210k-token +prompt is *just over* Claude's standard 200k context window, which is +the threshold that requires the `context-1m-2025-08-07` beta header +to be honored end-to-end. Anything shorter would only test whether +the proxy forwards the beta header byte-for-byte; it would not catch +provider-side regressions where the header is forwarded but the +upstream silently truncates beyond the standard context (we've seen +this on third-party gateways). Anything longer is wasted spend. + +Per-cell cost at 210k input tokens: ~$0.63 Sonnet, ~$3.15 Opus. +Daily cost across all five providers (this row only): ~$19. + +Haiku 4.5 is intentionally omitted: it does not support 1M context +(its window is 200k). Reporting `not_applicable` for Haiku would +flip the entire cell to `not_applicable`, hiding genuine 1M +regressions on Sonnet/Opus; instead we exclude Haiku from the model +list entirely and let the matrix's per-cell aggregator green the +cell on Sonnet + Opus passing. This is the one row where the "all +three tiers must pass" rule is relaxed; it's relaxed structurally +(via the model list), not semantically (via not_applicable), so the +matrix builder stays unmodified. + +The prompt is delivered via subprocess stdin rather than a positional +argument. ARG_MAX on Linux is typically 2MB and an 840KB prompt +fits within that comfortably, but stdin is safer (no shell escaping +surprises, no surprise ARG_MAX clamp on a tightened sandbox) and +keeps the driver's `extra_args` slot free for the `--betas` flag. + +`--max-budget-usd 6` is a runaway-loop guard: a misbehaving test +that loops three Claude tiers in a single cell can't accidentally +spend more than ~$18 on this cell. The cap is twice the expected +worst case (Opus @ 210k = $3.15) plus a 50% margin. Tighten it if a +provider's pricing changes and the matrix starts spending more than +$10/day on this row. +""" + +from __future__ import annotations + +import os +from typing import Sequence + +import pytest + +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-opus-4-7-bedrock-invoke", +) + +# Beta header that opts an Anthropic-shape model into the 1M context +# window. Same string is accepted on Bedrock (Invoke + Converse) and +# Vertex per LiteLLM's transformers -- no per-provider translation is +# needed for this header, unlike `advanced-tool-use-2025-11-20` / +# `tool-search-tool-2025-10-19`. +LONG_CONTEXT_BETA = "context-1m-2025-08-07" + +# Target a padded prompt that lands just above Claude's standard 200k +# context window so the request can only succeed if the +# `context-1m-2025-08-07` beta header survives all the way to the +# upstream. Below 200k the cell would silently pass even with a +# proxy-dropped beta header; above ~220k we're paying for tokens that +# don't add signal. +TARGET_INPUT_TOKENS = 210_000 + +# Anthropic's English tokenizer averages ~4 chars/token. We cycle +# through several benign pangrams + filler so the padding looks like a +# real document, not a repeating monolith. Identical-line padding + +# "ignore everything above" trips Opus 4.7's safety filter as a +# suspected prompt-injection attempt -- we hit that during smoke +# testing and the cell flipped red for the wrong reason. Varied prose +# with a natural document-style framing keeps the filter quiet. +_PAD_CHUNKS = ( + "The quick brown fox jumps over the lazy dog. ", + "She sells seashells by the seashore on Sunday mornings. ", + "Pack my box with five dozen liquor jugs for the journey. ", + "How vexingly quick daft zebras jump over fences at dawn. ", + "Sphinx of black quartz, judge my vow of silence and patience. ", + "Waltz, bad nymph, for quick jigs in the moonlit meadow. ", + "Glib jocks quiz nymph to vex dwarf with a riddle of stone. ", + "Crazy Fredrick bought many very exquisite opal jewels lately. ", +) +_CHARS_PER_TOKEN = 4 + + +def _build_long_prompt(target_tokens: int = TARGET_INPUT_TOKENS) -> str: + """Build a ~target_tokens-token padded prompt with a trailing instruction. + + Framing: + + - Lead with a benign document-style preamble that justifies the + long context (so safety filters see the prompt as "long + document review" rather than "adversarial padding"). + - Cycle through a small set of pangrams + filler sentences for + the bulk of the padding. Variety matters: identical repeated + lines look like a denial-of-service or injection attempt to + Anthropic's content filter on the larger tiers. + - End with the actual question. Claude's instruction-following + is stronger on recent tokens, so a 210k-token-into-the-past + instruction would risk a false-fail where the model ignores + it. + + `target_tokens` is an approximation: actual token count depends + on the tokenizer, but Anthropic's English tokenizer averages + ~4 chars/token, so 4 × target_tokens chars of padding gets us + close enough to the 1M-beta threshold (200k) that the proxy's + beta-header handling is the only path to success. + """ + preamble = ( + "I'm going to share an excerpt from a long document with you. " + "It contains a mix of practice sentences a typist might use to " + "warm up; treat the bulk of the text as background context. " + "I'll ask a short question at the end.\n\n" + "Begin excerpt:\n\n" + ) + closing = "\n\nEnd of excerpt. Please reply with the single word 'ok'." + + pad_target_chars = target_tokens * _CHARS_PER_TOKEN - len(preamble) - len(closing) + pad_lines = [] + pad_len = 0 + idx = 0 + while pad_len < pad_target_chars: + chunk = _PAD_CHUNKS[idx % len(_PAD_CHUNKS)] + pad_lines.append(chunk) + pad_len += len(chunk) + idx += 1 + return preamble + "".join(pad_lines) + closing + + +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, + ) + + long_prompt = _build_long_prompt() + + outcomes = run_claude_models_parallel( + models=BEDROCK_INVOKE_MODELS, + prompt=None, + stdin_input=long_prompt, + base_url=base_url, + api_key=api_key, + extra_args=[ + "--betas", + LONG_CONTEXT_BETA, + # Hard ceiling so a runaway test cannot blow the budget. + # See module docstring for sizing. + "--max-budget-usd", + "6", + ], + # Long-context requests can take a couple of minutes on a + # loaded upstream; the driver's default 120s is too tight. + timeout=300.0, + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not outcome.text.strip(): + error = f"[{model}] claude returned empty assistant text" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/long_context_1m/test_vertex_ai.py b/tests/e2e/claude_code/long_context_1m/test_vertex_ai.py new file mode 100644 index 00000000000..d2db4a1b4ee --- /dev/null +++ b/tests/e2e/claude_code/long_context_1m/test_vertex_ai.py @@ -0,0 +1,224 @@ +"""long_context_1m x Vertex AI. + +Drive the real `claude` CLI in headless mode with a ~210k-token padded +prompt and the `--betas context-1m-2025-08-07` beta header, route +through a LiteLLM proxy aimed at Anthropic, and assert the request +round-trips: no 400 from a stripped beta header, no 413 from a body +the proxy refused to forward, and a non-empty assistant reply at the +end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/long_context_1m/test_vertex_ai.py + ^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider + +Cost note (read this before scaling the prompt up): + +This row genuinely exercises the long-context path -- a 210k-token +prompt is *just over* Claude's standard 200k context window, which is +the threshold that requires the `context-1m-2025-08-07` beta header +to be honored end-to-end. Anything shorter would only test whether +the proxy forwards the beta header byte-for-byte; it would not catch +provider-side regressions where the header is forwarded but the +upstream silently truncates beyond the standard context (we've seen +this on third-party gateways). Anything longer is wasted spend. + +Per-cell cost at 210k input tokens: ~$0.63 Sonnet, ~$3.15 Opus. +Daily cost across all five providers (this row only): ~$19. + +Haiku 4.5 is intentionally omitted: it does not support 1M context +(its window is 200k). Reporting `not_applicable` for Haiku would +flip the entire cell to `not_applicable`, hiding genuine 1M +regressions on Sonnet/Opus; instead we exclude Haiku from the model +list entirely and let the matrix's per-cell aggregator green the +cell on Sonnet + Opus passing. This is the one row where the "all +three tiers must pass" rule is relaxed; it's relaxed structurally +(via the model list), not semantically (via not_applicable), so the +matrix builder stays unmodified. + +The prompt is delivered via subprocess stdin rather than a positional +argument. ARG_MAX on Linux is typically 2MB and an 840KB prompt +fits within that comfortably, but stdin is safer (no shell escaping +surprises, no surprise ARG_MAX clamp on a tightened sandbox) and +keeps the driver's `extra_args` slot free for the `--betas` flag. + +`--max-budget-usd 6` is a runaway-loop guard: a misbehaving test +that loops three Claude tiers in a single cell can't accidentally +spend more than ~$18 on this cell. The cap is twice the expected +worst case (Opus @ 210k = $3.15) plus a 50% margin. Tighten it if a +provider's pricing changes and the matrix starts spending more than +$10/day on this row. +""" + +from __future__ import annotations + +import os +from typing import Sequence + +import pytest + +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-opus-4-7-vertex", +) + +# Beta header that opts an Anthropic-shape model into the 1M context +# window. Same string is accepted on Bedrock (Invoke + Converse) and +# Vertex per LiteLLM's transformers -- no per-provider translation is +# needed for this header, unlike `advanced-tool-use-2025-11-20` / +# `tool-search-tool-2025-10-19`. +LONG_CONTEXT_BETA = "context-1m-2025-08-07" + +# Target a padded prompt that lands just above Claude's standard 200k +# context window so the request can only succeed if the +# `context-1m-2025-08-07` beta header survives all the way to the +# upstream. Below 200k the cell would silently pass even with a +# proxy-dropped beta header; above ~220k we're paying for tokens that +# don't add signal. +TARGET_INPUT_TOKENS = 210_000 + +# Anthropic's English tokenizer averages ~4 chars/token. We cycle +# through several benign pangrams + filler so the padding looks like a +# real document, not a repeating monolith. Identical-line padding + +# "ignore everything above" trips Opus 4.7's safety filter as a +# suspected prompt-injection attempt -- we hit that during smoke +# testing and the cell flipped red for the wrong reason. Varied prose +# with a natural document-style framing keeps the filter quiet. +_PAD_CHUNKS = ( + "The quick brown fox jumps over the lazy dog. ", + "She sells seashells by the seashore on Sunday mornings. ", + "Pack my box with five dozen liquor jugs for the journey. ", + "How vexingly quick daft zebras jump over fences at dawn. ", + "Sphinx of black quartz, judge my vow of silence and patience. ", + "Waltz, bad nymph, for quick jigs in the moonlit meadow. ", + "Glib jocks quiz nymph to vex dwarf with a riddle of stone. ", + "Crazy Fredrick bought many very exquisite opal jewels lately. ", +) +_CHARS_PER_TOKEN = 4 + + +def _build_long_prompt(target_tokens: int = TARGET_INPUT_TOKENS) -> str: + """Build a ~target_tokens-token padded prompt with a trailing instruction. + + Framing: + + - Lead with a benign document-style preamble that justifies the + long context (so safety filters see the prompt as "long + document review" rather than "adversarial padding"). + - Cycle through a small set of pangrams + filler sentences for + the bulk of the padding. Variety matters: identical repeated + lines look like a denial-of-service or injection attempt to + Anthropic's content filter on the larger tiers. + - End with the actual question. Claude's instruction-following + is stronger on recent tokens, so a 210k-token-into-the-past + instruction would risk a false-fail where the model ignores + it. + + `target_tokens` is an approximation: actual token count depends + on the tokenizer, but Anthropic's English tokenizer averages + ~4 chars/token, so 4 × target_tokens chars of padding gets us + close enough to the 1M-beta threshold (200k) that the proxy's + beta-header handling is the only path to success. + """ + preamble = ( + "I'm going to share an excerpt from a long document with you. " + "It contains a mix of practice sentences a typist might use to " + "warm up; treat the bulk of the text as background context. " + "I'll ask a short question at the end.\n\n" + "Begin excerpt:\n\n" + ) + closing = "\n\nEnd of excerpt. Please reply with the single word 'ok'." + + pad_target_chars = target_tokens * _CHARS_PER_TOKEN - len(preamble) - len(closing) + pad_lines = [] + pad_len = 0 + idx = 0 + while pad_len < pad_target_chars: + chunk = _PAD_CHUNKS[idx % len(_PAD_CHUNKS)] + pad_lines.append(chunk) + pad_len += len(chunk) + idx += 1 + return preamble + "".join(pad_lines) + closing + + +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, + ) + + long_prompt = _build_long_prompt() + + outcomes = run_claude_models_parallel( + models=VERTEX_AI_MODELS, + prompt=None, + stdin_input=long_prompt, + base_url=base_url, + api_key=api_key, + extra_args=[ + "--betas", + LONG_CONTEXT_BETA, + # Hard ceiling so a runaway test cannot blow the budget. + # See module docstring for sizing. + "--max-budget-usd", + "6", + ], + # Long-context requests can take a couple of minutes on a + # loaded upstream; the driver's default 120s is too tight. + timeout=300.0, + ) + + failures = [] + for model in VERTEX_AI_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not outcome.text.strip(): + error = f"[{model}] claude returned empty assistant text" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/manifest.yaml b/tests/e2e/claude_code/manifest.yaml new file mode 100644 index 00000000000..f7cccf0cef2 --- /dev/null +++ b/tests/e2e/claude_code/manifest.yaml @@ -0,0 +1,103 @@ +# Claude Code Compatibility Matrix — feature manifest. +# +# Defines the row order of the matrix and maps each feature_id to its +# human-readable display name. Adding a new feature to the matrix is a +# three-step change: +# 1. Append an entry to `features:` below. +# 2. Create a directory `tests/e2e/claude_code//`. +# 3. Add per-provider test files inside that directory. +# +# `feature_id` MUST match the directory name on disk; the test harness +# infers (feature, provider) for each test from its file path. + +schema_version: "1" + +# Provider column order in the rendered matrix. +providers: + - anthropic + - bedrock_invoke + - bedrock_converse + - vertex_ai + - azure + +# Feature row order. +features: + - id: basic_messaging_non_streaming + name: Basic messaging (non-streaming) + - id: basic_messaging_streaming + name: Basic messaging (streaming) + - id: tool_use + name: Tool use + - id: prompt_caching_5m + name: Prompt caching (5m TTL) + - id: vision + name: Vision + - id: thinking + name: Thinking + # The single row covers both API shapes Anthropic exposes — manual + # `thinking: {type: "enabled", budget_tokens: N}` (Haiku 4.5) and + # `thinking: {type: "adaptive"}` (Opus 4.7); Sonnet 4.6 supports + # either and Claude Code picks per model. A break in either + # transformer surfaces as a red cell because all three tiers must + # pass for the cell to go green. The row was named + # `extended_thinking` historically; Anthropic's docs now reserve + # that name for the deprecated manual mode only, so the row was + # renamed to the feature-level "Thinking". + - id: tool_use_streaming + name: Tool use (streaming / fine-grained) + - id: thinking_with_tool_use + name: Extended thinking + tool use + - id: pdf_input + name: PDF document input + - id: prompt_caching_1h + name: Prompt caching (1h TTL) + - id: web_search + name: Web search (server tool) + - id: structured_outputs + name: Structured outputs + # Drives `claude --json-schema ''`. Implementation note: + # Claude Code translates `--json-schema` to a synthetic + # `StructuredOutput` tool whose `input_schema` is the user's + # schema, then surfaces the tool_use input as + # `structured_output: {...}` on the trailing `result` event. + # This row tests that proxy-side handling of that tool round- + # trips end-to-end. It does NOT test Anthropic's server-side + # `output_config.schema` parameter (a separate feature used + # internally by Claude Code for session-title generation) -- + # `output_config` regressions surface in the HTTP-probe rows. + - id: count_tokens + name: count_tokens endpoint + # HTTP-probe row. Sends a direct POST to + # `{proxy}/v1/messages/count_tokens` for each Claude tier and + # asserts the response is shaped `{"input_tokens": }`. The CLI uses this endpoint internally but never + # surfaces its result in stream-json, so the only way to test + # the proxy's handling of it is to hit it directly. LiteLLM has + # shipped fixes here (e.g. Claude Code release-notes 2.1.121 + # "Vertex AI count_tokens returning 400 errors for proxy + # gateways"), which is exactly the regression class this row + # is meant to catch. + - id: tool_search + name: Tool search (MCP discovery) + # HTTP-probe row. Sends a request whose `tools` array includes + # a `tool_search_tool_regex_20251119` discovery tool and asserts + # the proxy + upstream accept it. This verifies LiteLLM's + # per-provider beta-header translation + # (`advanced-tool-use-2025-11-20` for Anthropic/Azure, + # `tool-search-tool-2025-10-19` for Vertex/Bedrock) is wired up. + # We deliberately don't try to trigger Claude Code's MCP-fan-out + # heuristic via `--mcp-config` -- that would couple the row to + # an internal behavior threshold that changes between Claude + # Code releases. The HTTP probe hits the bug surface LiteLLM + # has actually shipped fixes for (2.1.117, 2.1.72, 2.1.70 per + # the Claude Code release notes). + - id: long_context_1m + name: Long context (1M) + # Sends a ~210k-token padded prompt with the + # `context-1m-2025-08-07` beta header. Just-above the standard + # 200k context window so the request can only succeed when the + # beta header makes it all the way through the proxy to the + # upstream. Haiku 4.5 is intentionally omitted from this row's + # model list (its window is 200k); Sonnet 4.6 and Opus 4.7 are + # the only tiers exercised. Costs roughly $4/cell/run -- + # tighten the prompt-token target if pricing changes meaningfully. diff --git a/tests/e2e/claude_code/matrix_builder.py b/tests/e2e/claude_code/matrix_builder.py new file mode 100644 index 00000000000..5641e488da2 --- /dev/null +++ b/tests/e2e/claude_code/matrix_builder.py @@ -0,0 +1,198 @@ +"""Matrix JSON Builder. + +Pure-function module that consumes the pytest-produced `compat-results.json`, +the manifest, and run metadata, and emits the final `compatibility-matrix.json` +conforming to the schema published in the PRD. + +This module is deliberately free of subprocess, network, or filesystem side +effects in its public API — the public entry points take pre-loaded inputs +and return data structures, so they can be exercised by golden-file tests +without I/O. A small `build_from_paths()` convenience wrapper does the I/O +for callers that need it (the daily-cron publisher). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence + +import yaml + +SCHEMA_VERSION = "1" +VALID_STATUSES = {"pass", "fail", "not_applicable", "not_tested"} + + +class ManifestError(ValueError): + """Raised when `manifest.yaml` is malformed.""" + + +class ResultsError(ValueError): + """Raised when the pytest results artifact is malformed.""" + + +def load_manifest(path: Path) -> Dict[str, Any]: + """Load and validate `manifest.yaml`. + + Returns a dict with keys: schema_version, providers, features. Raises + ManifestError on missing fields or schema mismatch. + """ + raw = yaml.safe_load(path.read_text()) + if not isinstance(raw, dict): + raise ManifestError(f"manifest at {path} is not a mapping") + schema_version = str(raw.get("schema_version", "")) + if schema_version != SCHEMA_VERSION: + raise ManifestError( + f"manifest schema_version {schema_version!r} does not match " + f"builder version {SCHEMA_VERSION!r}" + ) + providers = raw.get("providers") + if not isinstance(providers, list) or not providers: + raise ManifestError("manifest.providers must be a non-empty list") + features = raw.get("features") + if not isinstance(features, list) or not features: + raise ManifestError("manifest.features must be a non-empty list") + for feature in features: + if not isinstance(feature, dict): + raise ManifestError("each feature must be a mapping") + if not feature.get("id") or not feature.get("name"): + raise ManifestError("each feature must have id and name") + return raw + + +def load_results(path: Path) -> List[Dict[str, Any]]: + """Load the pytest results artifact and return its `results` list.""" + raw = json.loads(path.read_text()) + if not isinstance(raw, dict) or not isinstance(raw.get("results"), list): + raise ResultsError(f"results artifact at {path} has no `results` list") + return raw["results"] + + +def build_matrix( + *, + manifest: Mapping[str, Any], + results: Sequence[Mapping[str, Any]], + litellm_version: str, + claude_code_version: str, + generated_at: str, +) -> Dict[str, Any]: + """Build the published matrix JSON from pre-loaded inputs. + + Empty cells (no test ran for a (feature, provider) and no + `not_applicable` was declared) are filled in with `not_tested`. If + multiple results report on the same cell — e.g. a per-feature test + file containing one parametrize per Claude model — the cell aggregates + to `pass` only if every model passed; otherwise `fail` with the first + breaking model surfaced in the error. + """ + providers: List[str] = list(manifest["providers"]) + feature_specs: List[Dict[str, Any]] = list(manifest["features"]) + + grouped: Dict[tuple, List[Dict[str, Any]]] = {} + for entry in results: + if not isinstance(entry, Mapping): + continue + feature_id = entry.get("feature_id") + provider = entry.get("provider") + result = entry.get("result") + if not feature_id or not provider or not isinstance(result, Mapping): + continue + if result.get("status") not in VALID_STATUSES: + continue + grouped.setdefault((feature_id, provider), []).append(dict(result)) + + features_out: List[Dict[str, Any]] = [] + for spec in feature_specs: + feature_id = spec["id"] + cells: Dict[str, Dict[str, Any]] = {} + for provider in providers: + cell_results = grouped.get((feature_id, provider), []) + cells[provider] = _aggregate_cell(cell_results) + features_out.append( + { + "id": feature_id, + "name": spec["name"], + "providers": cells, + } + ) + + return { + "schema_version": SCHEMA_VERSION, + "generated_at": generated_at, + "litellm_version": litellm_version, + "claude_code_version": claude_code_version, + "providers": providers, + "features": features_out, + } + + +def _aggregate_cell(results: Sequence[Mapping[str, Any]]) -> Dict[str, Any]: + """Aggregate a list of per-model results into a single cell status. + + Order of precedence (most informative wins): + - Any `fail` → cell is `fail` with every failing model's error + joined by `"; "` so a multi-tier breakage doesn't silently hide + all but the first error from the published matrix. + - Any `pass` → cell is `pass`. A mix of (pass, not_applicable) — + e.g. a tier where the feature isn't supported alongside tiers + where it works — surfaces as `pass` so the published cell + reflects that the feature *does* work on this provider rather + than silently demoting it to `not_applicable` and discarding + the passing tiers. + - All `not_applicable` → cell is `not_applicable` with the first + row's reason. + - empty / nothing recognized → `not_tested`. + + `not_tested` rows are treated as absent data: they're dropped before + aggregation so a mix of (pass, not_tested) — e.g. from a partial + crash or a test that explicitly recorded "this tier didn't run" — + still surfaces the passing tiers rather than silently demoting the + whole cell to `not_tested`. A cell is only `not_tested` when *every* + row is `not_tested` (or there are no rows at all). + """ + if not results: + return {"status": "not_tested"} + + observed = [r for r in results if r.get("status") != "not_tested"] + if not observed: + return {"status": "not_tested"} + + failures = [r for r in observed if r.get("status") == "fail"] + if failures: + errors = [str(r.get("error", "test failed")) for r in failures] + return {"status": "fail", "error": "; ".join(errors)} + + if any(r.get("status") == "pass" for r in observed): + return {"status": "pass"} + + if all(r.get("status") == "not_applicable" for r in observed): + return { + "status": "not_applicable", + "reason": str(observed[0].get("reason", "not applicable")), + } + + return {"status": "not_tested"} + + +def build_from_paths( + *, + manifest_path: Path, + results_path: Path, + litellm_version: str, + claude_code_version: str, + generated_at: str, + output_path: Optional[Path] = None, +) -> Dict[str, Any]: + """I/O wrapper around build_matrix used by the publisher script.""" + manifest = load_manifest(manifest_path) + results = load_results(results_path) + matrix = build_matrix( + manifest=manifest, + results=results, + litellm_version=litellm_version, + claude_code_version=claude_code_version, + generated_at=generated_at, + ) + if output_path is not None: + output_path.write_text(json.dumps(matrix, indent=2, sort_keys=False) + "\n") + return matrix diff --git a/tests/e2e/claude_code/pdf_input/__init__.py b/tests/e2e/claude_code/pdf_input/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/pdf_input/test_anthropic.py b/tests/e2e/claude_code/pdf_input/test_anthropic.py new file mode 100644 index 00000000000..36fb69a1db6 --- /dev/null +++ b/tests/e2e/claude_code/pdf_input/test_anthropic.py @@ -0,0 +1,176 @@ +"""pdf_input x Anthropic. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Anthropic, write a tiny valid PDF to disk, allow the built-in `Read` +tool, and ask Claude to read the PDF and report what it contains. + +The Read tool inlines the PDF bytes as `document` content blocks on the +next assistant turn, which is exactly the gateway path we want to +exercise: it's distinct from image content blocks (which are tested in +`vision/`) and uses a different transformation in LiteLLM's Anthropic +provider. We assert the upstream produces a non-empty reply that +references the contents of the PDF — proving the proxy preserved the +document content block end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/pdf_input/test_anthropic.py + ^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os + +import pytest + +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-opus-4-7", +] + +# Smallest valid PDF that renders a single visible word ("PONG"). Built +# inline rather than checked in as a binary fixture so the test stays +# self-contained and the marker word is easy to grep for in CI logs. +# The structure is a hand-crafted single-page PDF with one Helvetica +# text show; offsets are computed at write time so the xref table +# stays consistent regardless of platform line endings. +PDF_MARKER = "PONG" + + +def _build_minimal_pdf(marker: str) -> bytes: + """Return a single-page PDF whose only visible text is `marker`. + + We construct the PDF imperatively because `pypdf`/`reportlab` are + not in the test deps and we want the cell to work in a clean + environment. The xref offsets are recomputed for each `marker` + length so the file stays well-formed. + """ + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + ( + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>" + ), + # Page content stream: position the text and show the marker. + # `BT ... ET` is a text object; `Tf` selects font, `Td` moves + # the cursor, `Tj` paints a string. + ( + b"<< /Length %d >>\nstream\nBT /F1 24 Tf 50 100 Td (" + + marker.encode("ascii") + + b") Tj ET\nendstream" + ), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + # Fix up the /Length on the content stream to match its body. + body_open = objects[3].index(b"\nstream\n") + len(b"\nstream\n") + body_close = objects[3].index(b"\nendstream") + body_len = body_close - body_open + objects[3] = ( + b"<< /Length " + + str(body_len).encode("ascii") + + b" >>\nstream\n" + + objects[3][body_open:body_close] + + b"\nendstream" + ) + + out = bytearray(b"%PDF-1.4\n") + offsets = [] + for i, obj in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{i} 0 obj\n".encode("ascii") + obj + b"\nendobj\n" + + xref_offset = len(out) + out += b"xref\n0 %d\n" % (len(objects) + 1) + out += b"0000000000 65535 f \n" + for off in offsets: + out += f"{off:010d} 00000 n \n".encode("ascii") + out += ( + b"trailer\n<< /Size " + + str(len(objects) + 1).encode("ascii") + + b" /Root 1 0 R >>\nstartxref\n" + + str(xref_offset).encode("ascii") + + b"\n%%EOF\n" + ) + return bytes(out) + + +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 + ) + + pdf_path = tmp_path / "marker.pdf" + pdf_path.write_bytes(_build_minimal_pdf(PDF_MARKER)) + + outcomes = run_claude_models_parallel( + models=ANTHROPIC_MODELS, + prompt=( + f"Use the Read tool to read the file at {pdf_path}. " + "Report the single word that appears in the document." + ), + base_url=base_url, + api_key=api_key, + extra_args=["--allowed-tools", "Read"], + ) + + failures = [] + for model in ANTHROPIC_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + # The strongest gateway-level signal we can assert without + # parsing every event type: the model's final user-visible + # reply names the marker word that only the PDF carries. + # If the proxy dropped the `document` content block, the + # model has no way to produce this token. + if PDF_MARKER not in outcome.text.upper(): + error = ( + f"[{model}] reply did not reference the PDF marker {PDF_MARKER!r}; " + f"got: {outcome.text.strip()!r}" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/pdf_input/test_azure.py b/tests/e2e/claude_code/pdf_input/test_azure.py new file mode 100644 index 00000000000..810c857e407 --- /dev/null +++ b/tests/e2e/claude_code/pdf_input/test_azure.py @@ -0,0 +1,146 @@ +"""pdf_input x Microsoft Foundry (Azure). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to Microsoft Foundry's Anthropic deployments on Azure, +write a tiny valid PDF to disk, allow the built-in `Read` tool, and +ask Claude to read the PDF and report what it contains. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/pdf_input/test_azure.py + ^^^^^^^^^ ^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os + +import pytest + +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-opus-4-7-azure", +] + +PDF_MARKER = "PONG" + + +def _build_minimal_pdf(marker: str) -> bytes: + """Return a single-page PDF whose only visible text is `marker`.""" + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + ( + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>" + ), + ( + b"<< /Length %d >>\nstream\nBT /F1 24 Tf 50 100 Td (" + + marker.encode("ascii") + + b") Tj ET\nendstream" + ), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + body_open = objects[3].index(b"\nstream\n") + len(b"\nstream\n") + body_close = objects[3].index(b"\nendstream") + body_len = body_close - body_open + objects[3] = ( + b"<< /Length " + + str(body_len).encode("ascii") + + b" >>\nstream\n" + + objects[3][body_open:body_close] + + b"\nendstream" + ) + + out = bytearray(b"%PDF-1.4\n") + offsets = [] + for i, obj in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{i} 0 obj\n".encode("ascii") + obj + b"\nendobj\n" + + xref_offset = len(out) + out += b"xref\n0 %d\n" % (len(objects) + 1) + out += b"0000000000 65535 f \n" + for off in offsets: + out += f"{off:010d} 00000 n \n".encode("ascii") + out += ( + b"trailer\n<< /Size " + + str(len(objects) + 1).encode("ascii") + + b" /Root 1 0 R >>\nstartxref\n" + + str(xref_offset).encode("ascii") + + b"\n%%EOF\n" + ) + return bytes(out) + + +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 + ) + + pdf_path = tmp_path / "marker.pdf" + pdf_path.write_bytes(_build_minimal_pdf(PDF_MARKER)) + + outcomes = run_claude_models_parallel( + models=AZURE_MODELS, + prompt=( + f"Use the Read tool to read the file at {pdf_path}. " + "Report the single word that appears in the document." + ), + base_url=base_url, + api_key=api_key, + extra_args=["--allowed-tools", "Read"], + ) + + failures = [] + for model in AZURE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if PDF_MARKER not in outcome.text.upper(): + error = ( + f"[{model}] reply did not reference the PDF marker {PDF_MARKER!r}; " + f"got: {outcome.text.strip()!r}" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/pdf_input/test_bedrock_converse.py b/tests/e2e/claude_code/pdf_input/test_bedrock_converse.py new file mode 100644 index 00000000000..191a27c6d46 --- /dev/null +++ b/tests/e2e/claude_code/pdf_input/test_bedrock_converse.py @@ -0,0 +1,152 @@ +"""pdf_input x Bedrock (Converse). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the `Converse` API, write a tiny +valid PDF to disk, allow the built-in `Read` tool, and ask Claude to +read the PDF and report what it contains. + +Bedrock Converse expresses documents via its own +`document = { format, name, source: { bytes } }` shape; this cell +catches gateway regressions where the proxy fails to translate +Anthropic's `document` content block to Converse's document format +(or vice versa on the response). + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/pdf_input/test_bedrock_converse.py + ^^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os + +import pytest + +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-opus-4-7-bedrock-converse", +] + +PDF_MARKER = "PONG" + + +def _build_minimal_pdf(marker: str) -> bytes: + """Return a single-page PDF whose only visible text is `marker`.""" + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + ( + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>" + ), + ( + b"<< /Length %d >>\nstream\nBT /F1 24 Tf 50 100 Td (" + + marker.encode("ascii") + + b") Tj ET\nendstream" + ), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + body_open = objects[3].index(b"\nstream\n") + len(b"\nstream\n") + body_close = objects[3].index(b"\nendstream") + body_len = body_close - body_open + objects[3] = ( + b"<< /Length " + + str(body_len).encode("ascii") + + b" >>\nstream\n" + + objects[3][body_open:body_close] + + b"\nendstream" + ) + + out = bytearray(b"%PDF-1.4\n") + offsets = [] + for i, obj in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{i} 0 obj\n".encode("ascii") + obj + b"\nendobj\n" + + xref_offset = len(out) + out += b"xref\n0 %d\n" % (len(objects) + 1) + out += b"0000000000 65535 f \n" + for off in offsets: + out += f"{off:010d} 00000 n \n".encode("ascii") + out += ( + b"trailer\n<< /Size " + + str(len(objects) + 1).encode("ascii") + + b" /Root 1 0 R >>\nstartxref\n" + + str(xref_offset).encode("ascii") + + b"\n%%EOF\n" + ) + return bytes(out) + + +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 + ) + + pdf_path = tmp_path / "marker.pdf" + pdf_path.write_bytes(_build_minimal_pdf(PDF_MARKER)) + + outcomes = run_claude_models_parallel( + models=BEDROCK_CONVERSE_MODELS, + prompt=( + f"Use the Read tool to read the file at {pdf_path}. " + "Report the single word that appears in the document." + ), + base_url=base_url, + api_key=api_key, + extra_args=["--allowed-tools", "Read"], + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if PDF_MARKER not in outcome.text.upper(): + error = ( + f"[{model}] reply did not reference the PDF marker {PDF_MARKER!r}; " + f"got: {outcome.text.strip()!r}" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/pdf_input/test_bedrock_invoke.py b/tests/e2e/claude_code/pdf_input/test_bedrock_invoke.py new file mode 100644 index 00000000000..163cabb45a0 --- /dev/null +++ b/tests/e2e/claude_code/pdf_input/test_bedrock_invoke.py @@ -0,0 +1,151 @@ +"""pdf_input x Bedrock (Invoke). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the legacy `InvokeModel` API path, +write a tiny valid PDF to disk, allow the built-in `Read` tool, and +ask Claude to read the PDF and report what it contains. + +Bedrock InvokeModel for Anthropic models accepts the native +`document` content block shape; this cell catches gateway regressions +where the proxy drops or mis-encodes the document content block on +the way through (e.g. base64-only encoding, missing media_type, etc.). + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/pdf_input/test_bedrock_invoke.py + ^^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os + +import pytest + +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-opus-4-7-bedrock-invoke", +] + +PDF_MARKER = "PONG" + + +def _build_minimal_pdf(marker: str) -> bytes: + """Return a single-page PDF whose only visible text is `marker`.""" + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + ( + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>" + ), + ( + b"<< /Length %d >>\nstream\nBT /F1 24 Tf 50 100 Td (" + + marker.encode("ascii") + + b") Tj ET\nendstream" + ), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + body_open = objects[3].index(b"\nstream\n") + len(b"\nstream\n") + body_close = objects[3].index(b"\nendstream") + body_len = body_close - body_open + objects[3] = ( + b"<< /Length " + + str(body_len).encode("ascii") + + b" >>\nstream\n" + + objects[3][body_open:body_close] + + b"\nendstream" + ) + + out = bytearray(b"%PDF-1.4\n") + offsets = [] + for i, obj in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{i} 0 obj\n".encode("ascii") + obj + b"\nendobj\n" + + xref_offset = len(out) + out += b"xref\n0 %d\n" % (len(objects) + 1) + out += b"0000000000 65535 f \n" + for off in offsets: + out += f"{off:010d} 00000 n \n".encode("ascii") + out += ( + b"trailer\n<< /Size " + + str(len(objects) + 1).encode("ascii") + + b" /Root 1 0 R >>\nstartxref\n" + + str(xref_offset).encode("ascii") + + b"\n%%EOF\n" + ) + return bytes(out) + + +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 + ) + + pdf_path = tmp_path / "marker.pdf" + pdf_path.write_bytes(_build_minimal_pdf(PDF_MARKER)) + + outcomes = run_claude_models_parallel( + models=BEDROCK_INVOKE_MODELS, + prompt=( + f"Use the Read tool to read the file at {pdf_path}. " + "Report the single word that appears in the document." + ), + base_url=base_url, + api_key=api_key, + extra_args=["--allowed-tools", "Read"], + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if PDF_MARKER not in outcome.text.upper(): + error = ( + f"[{model}] reply did not reference the PDF marker {PDF_MARKER!r}; " + f"got: {outcome.text.strip()!r}" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/pdf_input/test_vertex_ai.py b/tests/e2e/claude_code/pdf_input/test_vertex_ai.py new file mode 100644 index 00000000000..0d0573d05b3 --- /dev/null +++ b/tests/e2e/claude_code/pdf_input/test_vertex_ai.py @@ -0,0 +1,146 @@ +"""pdf_input x Vertex AI. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to GCP Vertex AI, write a tiny valid PDF to disk, allow +the built-in `Read` tool, and ask Claude to read the PDF and report +what it contains. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/pdf_input/test_vertex_ai.py + ^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os + +import pytest + +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" + +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + +PDF_MARKER = "PONG" + + +def _build_minimal_pdf(marker: str) -> bytes: + """Return a single-page PDF whose only visible text is `marker`.""" + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + ( + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>" + ), + ( + b"<< /Length %d >>\nstream\nBT /F1 24 Tf 50 100 Td (" + + marker.encode("ascii") + + b") Tj ET\nendstream" + ), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + body_open = objects[3].index(b"\nstream\n") + len(b"\nstream\n") + body_close = objects[3].index(b"\nendstream") + body_len = body_close - body_open + objects[3] = ( + b"<< /Length " + + str(body_len).encode("ascii") + + b" >>\nstream\n" + + objects[3][body_open:body_close] + + b"\nendstream" + ) + + out = bytearray(b"%PDF-1.4\n") + offsets = [] + for i, obj in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{i} 0 obj\n".encode("ascii") + obj + b"\nendobj\n" + + xref_offset = len(out) + out += b"xref\n0 %d\n" % (len(objects) + 1) + out += b"0000000000 65535 f \n" + for off in offsets: + out += f"{off:010d} 00000 n \n".encode("ascii") + out += ( + b"trailer\n<< /Size " + + str(len(objects) + 1).encode("ascii") + + b" /Root 1 0 R >>\nstartxref\n" + + str(xref_offset).encode("ascii") + + b"\n%%EOF\n" + ) + return bytes(out) + + +def test_pdf_input_vertex_ai(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 + ) + + pdf_path = tmp_path / "marker.pdf" + pdf_path.write_bytes(_build_minimal_pdf(PDF_MARKER)) + + outcomes = run_claude_models_parallel( + models=VERTEX_AI_MODELS, + prompt=( + f"Use the Read tool to read the file at {pdf_path}. " + "Report the single word that appears in the document." + ), + base_url=base_url, + api_key=api_key, + extra_args=["--allowed-tools", "Read"], + ) + + failures = [] + for model in VERTEX_AI_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if PDF_MARKER not in outcome.text.upper(): + error = ( + f"[{model}] reply did not reference the PDF marker {PDF_MARKER!r}; " + f"got: {outcome.text.strip()!r}" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/pr_gate_version_resolver.py b/tests/e2e/claude_code/pr_gate_version_resolver.py new file mode 100644 index 00000000000..82e12a2bf15 --- /dev/null +++ b/tests/e2e/claude_code/pr_gate_version_resolver.py @@ -0,0 +1,150 @@ +"""Claude Code PR-Gate Version Resolver. + +Resolves the `@anthropic-ai/claude-code` npm version that the PR-gate CI +job installs. Selects the newest version (by publish timestamp) whose +publish timestamp is at least 3 days old. The 3-day window is a security +review buffer — see PRD #26476, "Version resolvers". + +Two surfaces: + +- ``resolve_pr_gate_version(...)`` — the importable function. Accepts + pre-fetched npm metadata (for unit tests) or a custom ``fetcher`` + callable. The default fetcher hits the public npm registry. +- ``python -m claude_code.pr_gate_version_resolver`` — prints the + resolved version string to stdout, suitable for piping into a shell + ``$(...)`` substitution inside the CircleCI job. + +The CLI form is what CircleCI runs at job start; engineers reading the +job log can see the selected version on a single line above the +``npm install -g`` step (acceptance criterion: "the selected Claude +Code version is logged in the CI output"). +""" + +from __future__ import annotations + +import json +import sys +import urllib.request +from datetime import datetime, timedelta, timezone +from typing import Callable, Mapping, Optional + +PACKAGE_NAME = "@anthropic-ai/claude-code" +NPM_REGISTRY_URL = "https://registry.npmjs.org/{package}" +DEFAULT_MIN_AGE = timedelta(days=3) +DEFAULT_FETCH_TIMEOUT_SECONDS = 30 + +# npm's `time` map mixes per-version timestamps with these meta keys. +_TIME_META_KEYS = frozenset({"created", "modified"}) + + +class NoEligibleVersionError(RuntimeError): + """Raised when no version in the npm metadata satisfies the min-age cutoff.""" + + +def _parse_npm_timestamp(value: str) -> datetime: + """Parse the ISO-8601 timestamps npm emits (always UTC, may use ``Z``).""" + if value.endswith("Z"): + value = value[:-1] + "+00:00" + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _default_fetcher(package_name: str) -> dict: + """Fetch the npm packument for ``package_name`` over HTTPS. + + Uses urllib (stdlib) so this module has no extra dependencies in the + CI environment. Returns the raw JSON dict. + """ + # urllib.parse.quote would encode the leading '@' / '/' which the + # npm registry expects literally; do a minimal hand-roll instead. + url = NPM_REGISTRY_URL.format(package=package_name.replace("/", "%2F")) + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen( # noqa: S310 — registry URL is constant + req, timeout=DEFAULT_FETCH_TIMEOUT_SECONDS + ) as response: + body = response.read().decode("utf-8") + return json.loads(body) + + +def resolve_pr_gate_version( + *, + metadata: Optional[Mapping] = None, + fetcher: Optional[Callable[[str], Mapping]] = None, + as_of: Optional[datetime] = None, + min_age: timedelta = DEFAULT_MIN_AGE, + package_name: str = PACKAGE_NAME, +) -> str: + """Return the newest npm version of ``package_name`` published >= ``min_age`` ago. + + "Newest" means newest by **publish time**, not semver string order — + if a patch lands on an older major after a newer release, the + patched line is the eligible one. + + Args: + metadata: Pre-fetched npm packument (skips the HTTP call). Useful + for unit tests. + fetcher: Callable taking a package name and returning the + packument. Defaults to a stdlib HTTPS fetcher. + as_of: The clock used to decide whether a version is "old + enough". Defaults to ``datetime.now(timezone.utc)``. + min_age: Minimum publish age. Defaults to 3 days. + package_name: Defaults to ``@anthropic-ai/claude-code``. + + Raises: + NoEligibleVersionError: when no version in the registry meets + the age cutoff. + """ + if metadata is None: + fetch = fetcher or _default_fetcher + metadata = fetch(package_name) + + times = metadata.get("time") or {} + if as_of is None: + as_of = datetime.now(timezone.utc) + cutoff = as_of - min_age + + eligible: list[tuple[datetime, str]] = [] + for version, raw_ts in times.items(): + if version in _TIME_META_KEYS: + continue + if not isinstance(raw_ts, str): + continue + if "-" in version: + continue + published = _parse_npm_timestamp(raw_ts) + if published <= cutoff: + eligible.append((published, version)) + + if not eligible: + raise NoEligibleVersionError( + f"no version of {package_name} is at least {min_age} old " + f"as of {as_of.isoformat()}" + ) + + eligible.sort(key=lambda pair: pair[0], reverse=True) + return eligible[0][1] + + +def _main(argv: list[str]) -> int: + """Print the resolved version to stdout. Exit code 0 on success. + + Stderr carries the human-readable announcement so the version can be + captured cleanly with ``$(python -m ...)`` in shell. + """ + try: + version = resolve_pr_gate_version() + except Exception as exc: # noqa: BLE001 — CLI surface, want everything + print(f"pr_gate_version_resolver: {exc}", file=sys.stderr) # noqa: T201 + return 1 + print( # noqa: T201 + f"pr_gate_version_resolver: selected {PACKAGE_NAME}@{version}", + file=sys.stderr, + ) + print(version) # noqa: T201 + return 0 + + +if __name__ == "__main__": + raise SystemExit(_main(sys.argv[1:])) diff --git a/tests/e2e/claude_code/prompt_caching_1h/__init__.py b/tests/e2e/claude_code/prompt_caching_1h/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/prompt_caching_1h/test_anthropic.py b/tests/e2e/claude_code/prompt_caching_1h/test_anthropic.py new file mode 100644 index 00000000000..d81887231d8 --- /dev/null +++ b/tests/e2e/claude_code/prompt_caching_1h/test_anthropic.py @@ -0,0 +1,124 @@ +"""prompt_caching_1h x Anthropic. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Anthropic, opt into the 1-hour cache TTL via `ENABLE_PROMPT_CACHING_1H`, +and assert that the upstream's usage block reports either +`cache_creation_input_tokens` or `cache_read_input_tokens` > 0 — i.e. +the proxy preserved Claude Code's `cache_control: { ttl: "1h" }` +annotations end-to-end and the upstream actually honored them. + +This is the 1-hour-TTL companion to `prompt_caching_5m/`. It exists as +its own cell because the 1h TTL travels through the proxy with a +distinct `cache_control` shape (and a distinct beta-header gate on +some providers); a regression that strips or downgrades the TTL on the +way through is invisible to the 5m cell, which would still see cache +hits with a default-TTL annotation. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/prompt_caching_1h/test_anthropic.py + ^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Optional + +import pytest + +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-opus-4-7", +] + +# Per the changelog (2.1.108): `ENABLE_PROMPT_CACHING_1H` flips Claude +# Code from the default 5-minute cache TTL to a 1-hour TTL on the +# `cache_control` annotations it adds to the system prompt and the +# most recent user turn. Setting it here is what we are validating +# the proxy faithfully forwards to the upstream. +CACHE_1H_ENV = {"ENABLE_PROMPT_CACHING_1H": "1"} + + +def _cache_tokens(usage: Optional[Mapping[str, Any]]) -> int: + """Return cache_creation_input_tokens + cache_read_input_tokens from + the upstream usage block, or 0 if the keys are missing.""" + if not isinstance(usage, Mapping): + return 0 + creation = usage.get("cache_creation_input_tokens") or 0 + read = usage.get("cache_read_input_tokens") or 0 + try: + return int(creation) + int(read) + except (TypeError, ValueError): + return 0 + + +def test_prompt_caching_1h_anthropic(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with the 1h + TTL opt-in env var set, and assert the upstream usage block + surfaces a non-zero cache token count.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=ANTHROPIC_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + base_url=base_url, + api_key=api_key, + extra_env=CACHE_1H_ENV, + ) + + failures = [] + for model in ANTHROPIC_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _cache_tokens(outcome.usage) <= 0: + error = ( + f"[{model}] usage block reported zero cache tokens with " + "ENABLE_PROMPT_CACHING_1H=1; the proxy likely stripped the " + "1h TTL beta header or rejected the cache_control shape" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/prompt_caching_1h/test_azure.py b/tests/e2e/claude_code/prompt_caching_1h/test_azure.py new file mode 100644 index 00000000000..416757f8691 --- /dev/null +++ b/tests/e2e/claude_code/prompt_caching_1h/test_azure.py @@ -0,0 +1,104 @@ +"""prompt_caching_1h x Microsoft Foundry (Azure). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to Microsoft Foundry's Anthropic deployments on Azure, +opt into the 1-hour cache TTL via `ENABLE_PROMPT_CACHING_1H`, and +assert the upstream's usage block reports a non-zero cache token count. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/prompt_caching_1h/test_azure.py + ^^^^^^^^^^^^^^^^^ ^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Optional + +import pytest + +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-opus-4-7-azure", +] + +CACHE_1H_ENV = {"ENABLE_PROMPT_CACHING_1H": "1"} + + +def _cache_tokens(usage: Optional[Mapping[str, Any]]) -> int: + if not isinstance(usage, Mapping): + return 0 + creation = usage.get("cache_creation_input_tokens") or 0 + read = usage.get("cache_read_input_tokens") or 0 + try: + return int(creation) + int(read) + except (TypeError, ValueError): + return 0 + + +def test_prompt_caching_1h_azure(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=AZURE_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + base_url=base_url, + api_key=api_key, + extra_env=CACHE_1H_ENV, + ) + + failures = [] + for model in AZURE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _cache_tokens(outcome.usage) <= 0: + error = ( + f"[{model}] usage block reported zero cache tokens with " + "ENABLE_PROMPT_CACHING_1H=1" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/prompt_caching_1h/test_bedrock_converse.py b/tests/e2e/claude_code/prompt_caching_1h/test_bedrock_converse.py new file mode 100644 index 00000000000..5bc632c6f1b --- /dev/null +++ b/tests/e2e/claude_code/prompt_caching_1h/test_bedrock_converse.py @@ -0,0 +1,112 @@ +"""prompt_caching_1h x Bedrock (Converse). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the `Converse` API, opt into the +1-hour cache TTL via `ENABLE_PROMPT_CACHING_1H`, and assert the +upstream's usage block reports a non-zero cache token count. + +Bedrock Converse expresses prompt caching via `cachePoint` markers in +the message list, with TTL controlled out-of-band; this cell catches +proxy regressions where the 1h opt-in fails to translate into the +correct Converse cache configuration. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/prompt_caching_1h/test_bedrock_converse.py + ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Optional + +import pytest + +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-opus-4-7-bedrock-converse", +] + +CACHE_1H_ENV = { + "ENABLE_PROMPT_CACHING_1H": "1", + "ENABLE_PROMPT_CACHING_1H_BEDROCK": "1", +} + + +def _cache_tokens(usage: Optional[Mapping[str, Any]]) -> int: + if not isinstance(usage, Mapping): + return 0 + creation = usage.get("cache_creation_input_tokens") or 0 + read = usage.get("cache_read_input_tokens") or 0 + try: + return int(creation) + int(read) + except (TypeError, ValueError): + return 0 + + +def test_prompt_caching_1h_bedrock_converse(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_CONVERSE_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + base_url=base_url, + api_key=api_key, + extra_env=CACHE_1H_ENV, + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _cache_tokens(outcome.usage) <= 0: + error = ( + f"[{model}] usage block reported zero cache tokens with " + "1h-TTL opt-in env vars set" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/prompt_caching_1h/test_bedrock_invoke.py b/tests/e2e/claude_code/prompt_caching_1h/test_bedrock_invoke.py new file mode 100644 index 00000000000..4501834956b --- /dev/null +++ b/tests/e2e/claude_code/prompt_caching_1h/test_bedrock_invoke.py @@ -0,0 +1,117 @@ +"""prompt_caching_1h x Bedrock (Invoke). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the legacy `InvokeModel` API path, +opt into the 1-hour cache TTL via `ENABLE_PROMPT_CACHING_1H`, and +assert the upstream's usage block reports a non-zero cache token count. + +Bedrock historically gated 1h prompt caching behind a separate +`ENABLE_PROMPT_CACHING_1H_BEDROCK` env var (see 2.1.108: deprecated but +still honored). The proxy must accept either env var and forward an +appropriate `cache_control` shape to the Bedrock InvokeModel endpoint; +this cell catches regressions where the TTL is silently downgraded to +5 minutes on the way through. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/prompt_caching_1h/test_bedrock_invoke.py + ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Optional + +import pytest + +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-opus-4-7-bedrock-invoke", +] + +# Set both the modern and the deprecated-but-honored Bedrock var so we +# match whichever code path the proxy is following. +CACHE_1H_ENV = { + "ENABLE_PROMPT_CACHING_1H": "1", + "ENABLE_PROMPT_CACHING_1H_BEDROCK": "1", +} + + +def _cache_tokens(usage: Optional[Mapping[str, Any]]) -> int: + if not isinstance(usage, Mapping): + return 0 + creation = usage.get("cache_creation_input_tokens") or 0 + read = usage.get("cache_read_input_tokens") or 0 + try: + return int(creation) + int(read) + except (TypeError, ValueError): + return 0 + + +def test_prompt_caching_1h_bedrock_invoke(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_INVOKE_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + base_url=base_url, + api_key=api_key, + extra_env=CACHE_1H_ENV, + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _cache_tokens(outcome.usage) <= 0: + error = ( + f"[{model}] usage block reported zero cache tokens with " + "1h-TTL opt-in env vars set; the proxy likely stripped or " + "downgraded the cache_control TTL" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/prompt_caching_1h/test_vertex_ai.py b/tests/e2e/claude_code/prompt_caching_1h/test_vertex_ai.py new file mode 100644 index 00000000000..09ded634b45 --- /dev/null +++ b/tests/e2e/claude_code/prompt_caching_1h/test_vertex_ai.py @@ -0,0 +1,104 @@ +"""prompt_caching_1h x Vertex AI. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to GCP Vertex AI, opt into the 1-hour cache TTL via +`ENABLE_PROMPT_CACHING_1H`, and assert the upstream's usage block +reports a non-zero cache token count. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/prompt_caching_1h/test_vertex_ai.py + ^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Optional + +import pytest + +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" + +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + +CACHE_1H_ENV = {"ENABLE_PROMPT_CACHING_1H": "1"} + + +def _cache_tokens(usage: Optional[Mapping[str, Any]]) -> int: + if not isinstance(usage, Mapping): + return 0 + creation = usage.get("cache_creation_input_tokens") or 0 + read = usage.get("cache_read_input_tokens") or 0 + try: + return int(creation) + int(read) + except (TypeError, ValueError): + return 0 + + +def test_prompt_caching_1h_vertex_ai(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=VERTEX_AI_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + base_url=base_url, + api_key=api_key, + extra_env=CACHE_1H_ENV, + ) + + failures = [] + for model in VERTEX_AI_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _cache_tokens(outcome.usage) <= 0: + error = ( + f"[{model}] usage block reported zero cache tokens with " + "ENABLE_PROMPT_CACHING_1H=1" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/prompt_caching_5m/__init__.py b/tests/e2e/claude_code/prompt_caching_5m/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/prompt_caching_5m/test_anthropic.py b/tests/e2e/claude_code/prompt_caching_5m/test_anthropic.py new file mode 100644 index 00000000000..4b20a65f31b --- /dev/null +++ b/tests/e2e/claude_code/prompt_caching_5m/test_anthropic.py @@ -0,0 +1,113 @@ +"""prompt_caching_5m x Anthropic. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Anthropic, and assert that the upstream's usage block reports either +`cache_creation_input_tokens` or `cache_read_input_tokens` > 0 — i.e. +the proxy preserves Claude Code's `cache_control` annotations end-to-end +and the upstream actually honored them. This is the 5-minute (default) +cache TTL row. + +Claude Code itself sets `cache_control: { type: "ephemeral" }` on the +system prompt and the most recent user turn for every request, so a +single live invocation is enough to surface a cache-creation count on +the first call and a cache-read count on a warm follow-up call. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/prompt_caching_5m/test_anthropic.py + ^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Optional + +import pytest + +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-opus-4-7", +] + + +def _cache_tokens(usage: Optional[Mapping[str, Any]]) -> int: + """Return cache_creation_input_tokens + cache_read_input_tokens from + the upstream usage block, or 0 if the keys are missing.""" + if not isinstance(usage, Mapping): + return 0 + creation = usage.get("cache_creation_input_tokens") or 0 + read = usage.get("cache_read_input_tokens") or 0 + try: + return int(creation) + int(read) + except (TypeError, ValueError): + return 0 + + +def test_prompt_caching_5m_anthropic(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert the + upstream usage block surfaces a non-zero cache token count.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=ANTHROPIC_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + base_url=base_url, + api_key=api_key, + ) + + failures = [] + for model in ANTHROPIC_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _cache_tokens(outcome.usage) <= 0: + error = ( + f"[{model}] usage block reported zero cache tokens; " + "expected cache_control on the system prompt to produce a non-zero " + "cache_creation_input_tokens or cache_read_input_tokens" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/prompt_caching_5m/test_azure.py b/tests/e2e/claude_code/prompt_caching_5m/test_azure.py new file mode 100644 index 00000000000..22bd5aa7048 --- /dev/null +++ b/tests/e2e/claude_code/prompt_caching_5m/test_azure.py @@ -0,0 +1,111 @@ +"""prompt_caching_5m x Azure (Microsoft Foundry). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to Anthropic's models hosted in Microsoft Foundry on +Azure, and assert that the upstream's usage block reports either +`cache_creation_input_tokens` or `cache_read_input_tokens` > 0. + +Foundry's Anthropic deployments honor the default 5-minute ephemeral +`cache_control` exactly like anthropic.com. The 1-hour `scope: "global"` +variant is *not* supported on Foundry — LiteLLM strips that field +before forwarding (see `_remove_scope_from_cache_control` in +`litellm/llms/azure_ai/anthropic/messages_transformation.py`) — but +this row exercises the 5-minute TTL only, so that quirk does not apply. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/prompt_caching_5m/test_azure.py + ^^^^^^^^^^^^^^^^^ ^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Optional + +import pytest + +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-opus-4-7-azure", +] + + +def _cache_tokens(usage: Optional[Mapping[str, Any]]) -> int: + if not isinstance(usage, Mapping): + return 0 + creation = usage.get("cache_creation_input_tokens") or 0 + read = usage.get("cache_read_input_tokens") or 0 + try: + return int(creation) + int(read) + except (TypeError, ValueError): + return 0 + + +def test_prompt_caching_5m_azure(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert the + upstream usage block surfaces a non-zero cache token count.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=AZURE_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + base_url=base_url, + api_key=api_key, + ) + + failures = [] + for model in AZURE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _cache_tokens(outcome.usage) <= 0: + error = ( + f"[{model}] usage block reported zero cache tokens; " + "expected cache_control on the system prompt to produce a non-zero " + "cache_creation_input_tokens or cache_read_input_tokens" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/prompt_caching_5m/test_bedrock_converse.py b/tests/e2e/claude_code/prompt_caching_5m/test_bedrock_converse.py new file mode 100644 index 00000000000..681a6ecce10 --- /dev/null +++ b/tests/e2e/claude_code/prompt_caching_5m/test_bedrock_converse.py @@ -0,0 +1,104 @@ +"""prompt_caching_5m x Bedrock (Converse). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the unified `Converse` API path, and +assert that the upstream's usage block reports either +`cache_creation_input_tokens` or `cache_read_input_tokens` > 0. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/prompt_caching_5m/test_bedrock_converse.py + ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Optional + +import pytest + +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-opus-4-7-bedrock-converse", +] + + +def _cache_tokens(usage: Optional[Mapping[str, Any]]) -> int: + if not isinstance(usage, Mapping): + return 0 + creation = usage.get("cache_creation_input_tokens") or 0 + read = usage.get("cache_read_input_tokens") or 0 + try: + return int(creation) + int(read) + except (TypeError, ValueError): + return 0 + + +def test_prompt_caching_5m_bedrock_converse(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert the + upstream usage block surfaces a non-zero cache token count.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_CONVERSE_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + base_url=base_url, + api_key=api_key, + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _cache_tokens(outcome.usage) <= 0: + error = ( + f"[{model}] usage block reported zero cache tokens; " + "expected cache_control on the system prompt to produce a non-zero " + "cache_creation_input_tokens or cache_read_input_tokens" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/prompt_caching_5m/test_bedrock_invoke.py b/tests/e2e/claude_code/prompt_caching_5m/test_bedrock_invoke.py new file mode 100644 index 00000000000..f1a3109b3a1 --- /dev/null +++ b/tests/e2e/claude_code/prompt_caching_5m/test_bedrock_invoke.py @@ -0,0 +1,104 @@ +"""prompt_caching_5m x Bedrock (Invoke). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the legacy `InvokeModel` API path, +and assert that the upstream's usage block reports either +`cache_creation_input_tokens` or `cache_read_input_tokens` > 0. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/prompt_caching_5m/test_bedrock_invoke.py + ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Optional + +import pytest + +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-opus-4-7-bedrock-invoke", +] + + +def _cache_tokens(usage: Optional[Mapping[str, Any]]) -> int: + if not isinstance(usage, Mapping): + return 0 + creation = usage.get("cache_creation_input_tokens") or 0 + read = usage.get("cache_read_input_tokens") or 0 + try: + return int(creation) + int(read) + except (TypeError, ValueError): + return 0 + + +def test_prompt_caching_5m_bedrock_invoke(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert the + upstream usage block surfaces a non-zero cache token count.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_INVOKE_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + base_url=base_url, + api_key=api_key, + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _cache_tokens(outcome.usage) <= 0: + error = ( + f"[{model}] usage block reported zero cache tokens; " + "expected cache_control on the system prompt to produce a non-zero " + "cache_creation_input_tokens or cache_read_input_tokens" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/prompt_caching_5m/test_vertex_ai.py b/tests/e2e/claude_code/prompt_caching_5m/test_vertex_ai.py new file mode 100644 index 00000000000..cc5d337dfbe --- /dev/null +++ b/tests/e2e/claude_code/prompt_caching_5m/test_vertex_ai.py @@ -0,0 +1,104 @@ +"""prompt_caching_5m x Vertex AI. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to Anthropic's models on Google Cloud Vertex AI, and +assert that the upstream's usage block reports either +`cache_creation_input_tokens` or `cache_read_input_tokens` > 0. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/prompt_caching_5m/test_vertex_ai.py + ^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Optional + +import pytest + +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" + +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + + +def _cache_tokens(usage: Optional[Mapping[str, Any]]) -> int: + if not isinstance(usage, Mapping): + return 0 + creation = usage.get("cache_creation_input_tokens") or 0 + read = usage.get("cache_read_input_tokens") or 0 + try: + return int(creation) + int(read) + except (TypeError, ValueError): + return 0 + + +def test_prompt_caching_5m_vertex_ai(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert the + upstream usage block surfaces a non-zero cache token count.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=VERTEX_AI_MODELS, + prompt="Reply with the single word 'pong' and nothing else.", + base_url=base_url, + api_key=api_key, + ) + + failures = [] + for model in VERTEX_AI_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _cache_tokens(outcome.usage) <= 0: + error = ( + f"[{model}] usage block reported zero cache tokens; " + "expected cache_control on the system prompt to produce a non-zero " + "cache_creation_input_tokens or cache_read_input_tokens" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/rate_limiter.py b/tests/e2e/claude_code/rate_limiter.py new file mode 100644 index 00000000000..06d21b83832 --- /dev/null +++ b/tests/e2e/claude_code/rate_limiter.py @@ -0,0 +1,358 @@ +"""Cross-process token-bucket rate limiter for the Claude Code compat suite. + +The compat matrix runs 75 live `claude` CLI invocations (25 cells × 3 +Claude tiers per cell). When pytest-xdist fans these out across worker +processes, each worker would maintain its own in-memory rate limiter +and the *aggregate* request rate hitting any one upstream provider +would be `workers × per-worker rate` — exactly the situation that +trips Anthropic / Azure / Bedrock / Vertex 429s in the middle of a +matrix run and silently flips green cells red. + +The fix is a **shared, cross-process** token bucket per provider, +backed by a small JSON state file and an OS-level `flock`. Each +`run_claude` invocation acquires one token (sleeping if the bucket is +empty) before launching the CLI; refills happen lazily based on wall +time, so workers can be killed and restarted without losing or +double-spending budget. + +Configuration is driven entirely by environment variables so a +binary-search workflow can shift per-provider rates without code +edits: + + LITELLM_COMPAT_RATE_ANTHROPIC (req/s, default 5.0) + LITELLM_COMPAT_RATE_AZURE (req/s, default 5.0) + LITELLM_COMPAT_RATE_VERTEX_AI (req/s, default 5.0) + LITELLM_COMPAT_RATE_BEDROCK_CONVERSE (req/s, default 5.0) + LITELLM_COMPAT_RATE_BEDROCK_INVOKE (req/s, default 5.0) + LITELLM_COMPAT_RATE_BURST (per-bucket burst override; + default = rate) + LITELLM_COMPAT_RATE_STATE_DIR (state file directory; + default = $TMPDIR/litellm-claude-compat-ratelimit) + +A rate of 0 (or any non-positive value) disables throttling for that +provider — useful when you trust the upstream to handle the burst or +when running unit-test-shaped workloads that never actually hit the +network. + +The provider id is inferred from the model id by `infer_provider`, +mirroring the matrix's column layout (`anthropic`, `azure`, +`vertex_ai`, `bedrock_converse`, `bedrock_invoke`). +""" + +from __future__ import annotations + +import contextlib +import json +import math +import os +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterator, Mapping, Optional + +# `fcntl` is POSIX-only; the suite is Linux/macOS only, so we don't +# attempt a Windows fallback. Importing at module load fails fast on +# the (currently non-existent) Windows runner so we don't silently +# degrade to no-locking behavior. +import fcntl + +PROVIDER_ANTHROPIC = "anthropic" +PROVIDER_AZURE = "azure" +PROVIDER_VERTEX_AI = "vertex_ai" +PROVIDER_BEDROCK_CONVERSE = "bedrock_converse" +PROVIDER_BEDROCK_INVOKE = "bedrock_invoke" + +ALL_PROVIDERS = ( + PROVIDER_ANTHROPIC, + PROVIDER_AZURE, + PROVIDER_VERTEX_AI, + PROVIDER_BEDROCK_CONVERSE, + PROVIDER_BEDROCK_INVOKE, +) + +DEFAULT_RATE = 5.0 # req/s per provider, conservative starting point +RATE_ENV_PREFIX = "LITELLM_COMPAT_RATE_" +BURST_ENV = "LITELLM_COMPAT_RATE_BURST" +STATE_DIR_ENV = "LITELLM_COMPAT_RATE_STATE_DIR" +DEFAULT_STATE_DIR_NAME = "litellm-claude-compat-ratelimit" + + +def infer_provider(model: str) -> str: + """Map a model alias to its compat-matrix provider id. + + The matrix column layout is fixed; aliases registered in the proxy + encode the provider via a suffix (`-bedrock-converse`, + `-bedrock-invoke`, `-azure`, `-vertex`) or its absence (Anthropic). + Order matters: the bedrock suffixes both contain `bedrock`, so we + test the more-specific ones first. + """ + if not model: + raise ValueError("model must be a non-empty string") + lower = model.lower() + if lower.endswith("-bedrock-converse"): + return PROVIDER_BEDROCK_CONVERSE + if lower.endswith("-bedrock-invoke"): + return PROVIDER_BEDROCK_INVOKE + if lower.endswith("-azure"): + return PROVIDER_AZURE + if lower.endswith("-vertex"): + return PROVIDER_VERTEX_AI + return PROVIDER_ANTHROPIC + + +@dataclass(frozen=True) +class ProviderConfig: + """Static config snapshot for one provider's bucket. + + Captured up front (rather than re-read per acquire) so the limiter's + behavior in a single process is stable even if env vars are mutated + mid-run. A fresh `RateLimiter` picks up env changes on construction. + """ + + rate_per_sec: float + burst: float + + @property + def enabled(self) -> bool: + return self.rate_per_sec > 0 and self.burst > 0 + + +def load_config( + env: Optional[Mapping[str, str]] = None, +) -> Dict[str, ProviderConfig]: + """Build a {provider: ProviderConfig} from env, applying defaults. + + Parsing failures fall back to the default rate rather than + crashing the test session — a typo in `LITELLM_COMPAT_RATE_AZURE` + should not silently disable throttling, but it also shouldn't + abort 75 live tests with a `ValueError` ten minutes in. + """ + src = env if env is not None else os.environ + + def _as_float(value: Optional[str], default: float) -> float: + if value is None or value == "": + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + burst_override = _as_float(src.get(BURST_ENV), -1.0) + + out: Dict[str, ProviderConfig] = {} + for provider in ALL_PROVIDERS: + env_key = RATE_ENV_PREFIX + provider.upper() + rate = _as_float(src.get(env_key), DEFAULT_RATE) + burst = burst_override if burst_override > 0 else max(rate, 1.0) + out[provider] = ProviderConfig(rate_per_sec=rate, burst=burst) + return out + + +def _state_dir(env: Optional[Mapping[str, str]] = None) -> Path: + """Resolve the directory holding per-provider state files. + + A user-supplied `LITELLM_COMPAT_RATE_STATE_DIR` wins for tests and + container environments where `$TMPDIR` may be ephemeral or shared + in surprising ways. The directory is created lazily with + `parents=True, exist_ok=True` so first-run setup needs no + fixture wiring. + """ + src = env if env is not None else os.environ + explicit = src.get(STATE_DIR_ENV) + if explicit: + return Path(explicit) + return Path(tempfile.gettempdir()) / DEFAULT_STATE_DIR_NAME + + +class RateLimiter: + """Cross-process token bucket per provider. + + Each `acquire(provider)` call: + 1. Opens (creating if needed) `/.json`. + 2. Holds an exclusive `flock` while reading + updating the + {tokens, last_refill} state. + 3. Refills tokens based on `now - last_refill`, capped at burst. + 4. If tokens >= 1, subtracts one and returns immediately. + 5. Otherwise, computes the wall-time delay needed to earn a + single token at the configured rate, releases the lock, and + sleeps. After sleeping it retries — staying under the lock + while sleeping would serialize all workers behind whichever + one held it longest. + + This bounds the *aggregate* req/s seen by the upstream, regardless + of how many xdist workers, threads, or processes are concurrently + running tests against the same provider. + + A `_clock` / `_sleep` injection seam keeps the unit tests fast and + deterministic; production callers should never override either. + """ + + def __init__( + self, + config: Optional[Mapping[str, ProviderConfig]] = None, + state_dir: Optional[Path] = None, + clock: Optional[callable] = None, + sleep: Optional[callable] = None, + ) -> None: + self._config = dict(config) if config is not None else load_config() + self._state_dir = Path(state_dir) if state_dir is not None else _state_dir() + self._clock = clock or time.monotonic + self._sleep = sleep or time.sleep + # `_state_dir.mkdir` once on construction is fine; concurrent + # workers all racing to create the same directory is benign. + self._state_dir.mkdir(parents=True, exist_ok=True) + + def acquire(self, provider: str) -> float: + """Block until one token is available for `provider`. + + Returns the cumulative wall-time spent waiting (0.0 when the + bucket had budget and we returned immediately). Callers can + log this to attribute slow cells to throttling vs. upstream + latency — same role `DriverResult.duration_ms` plays for the + actual CLI invocation. + """ + cfg = self._config.get(provider) + if cfg is None or not cfg.enabled: + return 0.0 + + path = self._state_path(provider) + total_waited = 0.0 + while True: + now = self._clock() + sleep_for = self._try_consume(path, cfg, now) + if sleep_for <= 0: + return total_waited + self._sleep(sleep_for) + total_waited += sleep_for + + def _state_path(self, provider: str) -> Path: + return self._state_dir / f"{provider}.json" + + def _try_consume(self, path: Path, cfg: ProviderConfig, now: float) -> float: + """Atomically refill and try to take one token. + + Returns 0.0 if a token was consumed, or a positive sleep + duration (seconds) if the caller must wait before retrying. + + We hold an exclusive `flock` only across the read-modify-write + of the JSON state — never across a `sleep` — so workers don't + serialize while one of them is parked. + """ + # `os.open` + `os.O_CREAT | os.O_RDWR` gives us a fd we can + # both lock and read/write through. Opening with `"a+"` then + # seeking is equivalent but uglier; this version is closer to + # the canonical flock recipe. + fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + try: + tokens, last_refill = self._read_state(fd, cfg, now) + tokens, last_refill = self._refill(tokens, last_refill, now, cfg) + if tokens >= 1.0: + tokens -= 1.0 + self._write_state(fd, tokens, last_refill) + return 0.0 + # Not enough budget. Persist the refilled state so a + # subsequent caller doesn't have to redo the math, then + # release the lock and tell the caller how long to + # sleep before retrying. + self._write_state(fd, tokens, last_refill) + deficit = 1.0 - tokens + # `deficit / rate` seconds will earn exactly enough + # for one token. Add a tiny safety margin so we don't + # wake up nanoseconds early and spin. + return (deficit / cfg.rate_per_sec) + 1e-3 + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + @staticmethod + def _read_state(fd: int, cfg: ProviderConfig, now: float) -> tuple: + """Read {tokens, last_refill} from `fd`, defaulting to a full + bucket on a missing/empty/corrupt file. + + New files start full so the very first request never waits; + corrupt files are treated like new files because the + alternative — refusing to run — is worse than briefly + over-spending one bucket's worth of budget. + """ + os.lseek(fd, 0, os.SEEK_SET) + raw = os.read(fd, 4096).decode("utf-8") + if not raw.strip(): + return cfg.burst, now + try: + obj = json.loads(raw) + tokens = float(obj.get("tokens", cfg.burst)) + last_refill = float(obj.get("last_refill", now)) + return tokens, last_refill + except (ValueError, TypeError): + return cfg.burst, now + + @staticmethod + def _refill( + tokens: float, last_refill: float, now: float, cfg: ProviderConfig + ) -> tuple: + """Apply elapsed time to the bucket, capped at burst. + + Negative elapsed (clock went backward, e.g. across host + sleep/resume or a manually-tweaked monotonic mock) is clamped + to zero so we never *remove* tokens. + """ + elapsed = max(0.0, now - last_refill) + tokens = min(cfg.burst, tokens + elapsed * cfg.rate_per_sec) + return tokens, now + + @staticmethod + def _write_state(fd: int, tokens: float, last_refill: float) -> None: + payload = json.dumps({"tokens": tokens, "last_refill": last_refill}).encode( + "utf-8" + ) + os.lseek(fd, 0, os.SEEK_SET) + os.ftruncate(fd, 0) + os.write(fd, payload) + + +# A single process-wide instance is all we need: provider-keyed state +# is stored in files, so multiple `RateLimiter` instances would just +# duplicate the in-process bookkeeping. We expose a getter rather than +# the instance directly so unit tests can install a custom limiter +# scoped to a tmp directory without monkeypatching globals. +_default: Optional[RateLimiter] = None + + +def get_default_limiter() -> RateLimiter: + global _default + if _default is None: + _default = RateLimiter() + return _default + + +def reset_default_limiter() -> None: + """Drop the cached default limiter; the next `get_default_limiter` + call rebuilds it from the current environment. + + Useful between unit tests that patch env vars: without this they'd + keep reading the stale config snapshot from the first call. + """ + global _default + _default = None + + +@contextlib.contextmanager +def use_limiter(limiter: RateLimiter) -> Iterator[RateLimiter]: + """Temporarily install `limiter` as the process default. + + The driver's `run_claude` calls `get_default_limiter()`; tests that + want a controlled tmp-dir-backed limiter use this contextmanager + to swap one in without touching env vars or the on-disk default + state. + """ + global _default + previous = _default + _default = limiter + try: + yield limiter + finally: + _default = previous diff --git a/tests/e2e/claude_code/run_compat.sh b/tests/e2e/claude_code/run_compat.sh new file mode 100755 index 00000000000..4d8d0b6d7b2 --- /dev/null +++ b/tests/e2e/claude_code/run_compat.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Run the Claude Code compat matrix end-to-end against a live LiteLLM +# proxy, with per-provider rate limits applied via the cross-process +# token bucket in `tests/e2e/claude_code/rate_limiter.py`. +# +# Designed for binary-searching the ideal X / Y / Z req/s per provider: +# 1. Pick an initial rate (e.g. 5/s for everyone). +# 2. Run this script. +# 3. Read `compat-rate-limit-summary.json` to see whether any provider +# hit a 429-shaped error during the run. +# 4. If a provider has `rate_limited > 0`, halve its rate; else, double it. +# 5. Repeat until the highest no-429 rate is found. +# +# Required env (proxy connection): +# LITELLM_PROXY_BASE_URL e.g. http://localhost:4000 +# LITELLM_PROXY_API_KEY e.g. sk-1234 +# +# Optional env (rate limits, all default to 5 req/s; 0 disables a column): +# LITELLM_COMPAT_RATE_ANTHROPIC +# LITELLM_COMPAT_RATE_AZURE +# LITELLM_COMPAT_RATE_VERTEX_AI +# LITELLM_COMPAT_RATE_BEDROCK_CONVERSE +# LITELLM_COMPAT_RATE_BEDROCK_INVOKE +# LITELLM_COMPAT_RATE_BURST override per-bucket burst +# +# Optional env (parallelism): +# COMPAT_XDIST_WORKERS passed to `pytest -n` (default: auto) +# +# Optional env (artifacts): +# COMPAT_RESULTS_PATH default: compat-results.json +# COMPAT_RATE_LIMIT_SUMMARY_PATH default: compat-rate-limit-summary.json + +set -euo pipefail + +if [[ -z "${LITELLM_PROXY_BASE_URL:-}" || -z "${LITELLM_PROXY_API_KEY:-}" ]]; then + echo "error: LITELLM_PROXY_BASE_URL and LITELLM_PROXY_API_KEY must be set" >&2 + exit 64 +fi + +# Reset the cross-process rate-limiter state from any prior run. Stale +# token-bucket files would let a previous run's accumulated budget bleed +# into the new one, which subtly biases the binary search. +state_dir="${LITELLM_COMPAT_RATE_STATE_DIR:-${TMPDIR:-/tmp}/litellm-claude-compat-ratelimit}" +if [[ -d "$state_dir" ]]; then + rm -rf "$state_dir" +fi + +# Worker count. `auto` picks one worker per CPU; the rate limiter +# enforces aggregate provider rates regardless of worker count, so +# this is a "go as fast as the limiter allows" knob, not a tuning knob. +workers="${COMPAT_XDIST_WORKERS:-auto}" + +# Where the artifacts land. We resolve them now so the summary file is +# always at a known path the caller can grep, even if they didn't set +# the env explicitly. +results_path="${COMPAT_RESULTS_PATH:-compat-results.json}" +summary_path="${COMPAT_RATE_LIMIT_SUMMARY_PATH:-compat-rate-limit-summary.json}" + +echo "[run_compat] rates:" +for provider in ANTHROPIC AZURE VERTEX_AI BEDROCK_CONVERSE BEDROCK_INVOKE; do + var="LITELLM_COMPAT_RATE_${provider}" + echo " ${provider}=${!var:-default(5/s)}" +done +echo " BURST=${LITELLM_COMPAT_RATE_BURST:-default(=rate)}" +echo "[run_compat] xdist workers: ${workers}" +echo "[run_compat] results: ${results_path}" +echo "[run_compat] summary: ${summary_path}" + +# Run only the per-feature live tests; skip the unit-test directories +# (they're under directories starting with `_`). The dist=loadfile +# scheduler keeps each test file pinned to a single worker, which is +# what we want — every test in a file shares a single ThreadPoolExecutor +# fanout, and we don't gain anything by splitting it across workers. +start=$(date +%s) +set +e +COMPAT_RESULTS_PATH="${results_path}" \ +COMPAT_RATE_LIMIT_SUMMARY_PATH="${summary_path}" \ +PATH="$HOME/.local/bin:$PATH" \ +uv run pytest \ + tests/e2e/claude_code/basic_messaging_non_streaming \ + tests/e2e/claude_code/basic_messaging_streaming \ + tests/e2e/claude_code/thinking \ + tests/e2e/claude_code/tool_use \ + tests/e2e/claude_code/vision \ + tests/e2e/claude_code/prompt_caching_5m \ + -n "${workers}" \ + --dist=loadfile \ + -q \ + "$@" + +exit_code=$? +set -e +end=$(date +%s) +echo "[run_compat] wall time: $((end - start))s" + +# Surface the rate-limit summary inline so a human reader doesn't have +# to `cat` the JSON file. The full file is still on disk for the binary +# search loop. +if [[ -f "${summary_path}" ]]; then + echo "[run_compat] summary: ${summary_path}" + if command -v jq >/dev/null 2>&1; then + jq '.totals, .per_provider' "${summary_path}" + else + cat "${summary_path}" + fi +fi + +exit "${exit_code}" diff --git a/tests/e2e/claude_code/sample_compatibility-matrix.json b/tests/e2e/claude_code/sample_compatibility-matrix.json new file mode 100644 index 00000000000..cfc7f3885d3 --- /dev/null +++ b/tests/e2e/claude_code/sample_compatibility-matrix.json @@ -0,0 +1,141 @@ +{ + "schema_version": "1", + "generated_at": "2026-04-25T00:00:00Z", + "litellm_version": "v1.83.0-stable", + "claude_code_version": "2.1.120", + "providers": [ + "anthropic", + "bedrock_invoke", + "bedrock_converse", + "vertex_ai", + "azure" + ], + "features": [ + { + "id": "basic_messaging_non_streaming", + "name": "Basic messaging (non-streaming)", + "providers": { + "anthropic": { + "status": "pass" + }, + "bedrock_invoke": { + "status": "pass" + }, + "bedrock_converse": { + "status": "pass" + }, + "vertex_ai": { + "status": "pass" + }, + "azure": { + "status": "pass" + } + } + }, + { + "id": "basic_messaging_streaming", + "name": "Basic messaging (streaming)", + "providers": { + "anthropic": { + "status": "pass" + }, + "bedrock_invoke": { + "status": "pass" + }, + "bedrock_converse": { + "status": "pass" + }, + "vertex_ai": { + "status": "pass" + }, + "azure": { + "status": "pass" + } + } + }, + { + "id": "tool_use", + "name": "Tool use", + "providers": { + "anthropic": { + "status": "pass" + }, + "bedrock_invoke": { + "status": "pass" + }, + "bedrock_converse": { + "status": "pass" + }, + "vertex_ai": { + "status": "pass" + }, + "azure": { + "status": "pass" + } + } + }, + { + "id": "prompt_caching_5m", + "name": "Prompt caching (5m TTL)", + "providers": { + "anthropic": { + "status": "pass" + }, + "bedrock_invoke": { + "status": "pass" + }, + "bedrock_converse": { + "status": "pass" + }, + "vertex_ai": { + "status": "pass" + }, + "azure": { + "status": "pass" + } + } + }, + { + "id": "vision", + "name": "Vision", + "providers": { + "anthropic": { + "status": "pass" + }, + "bedrock_invoke": { + "status": "pass" + }, + "bedrock_converse": { + "status": "pass" + }, + "vertex_ai": { + "status": "pass" + }, + "azure": { + "status": "pass" + } + } + }, + { + "id": "thinking", + "name": "Thinking", + "providers": { + "anthropic": { + "status": "pass" + }, + "bedrock_invoke": { + "status": "pass" + }, + "bedrock_converse": { + "status": "pass" + }, + "vertex_ai": { + "status": "pass" + }, + "azure": { + "status": "pass" + } + } + } + ] +} diff --git a/tests/e2e/claude_code/structured_outputs/__init__.py b/tests/e2e/claude_code/structured_outputs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/structured_outputs/test_anthropic.py b/tests/e2e/claude_code/structured_outputs/test_anthropic.py new file mode 100644 index 00000000000..610d8433b72 --- /dev/null +++ b/tests/e2e/claude_code/structured_outputs/test_anthropic.py @@ -0,0 +1,220 @@ +"""structured_outputs x Anthropic. + +Drive the real `claude` CLI in headless mode with the `--json-schema` +flag, route through a LiteLLM proxy aimed at Anthropic, and assert that +the final stream-json `result` event surfaces a `structured_output` +object whose shape matches the schema. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/structured_outputs/test_anthropic.py + ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider + +What this row actually exercises (and what it does not): + +`--json-schema` is implemented client-side by Claude Code: the CLI +synthesizes a synthetic `StructuredOutput` tool whose `input_schema` +equals the user-supplied JSON Schema, forces the model toward it, and +finally extracts the tool_use input on the trailing `result` event as +`structured_output: {...}`. The proxy never sees `output_config.schema` +in this flow -- it sees a normal `tools` array with one synthetic +tool. + +This makes the row a tool-use feature test in disguise. It's still a +distinct row from `tool_use` because: + + - The synthetic tool is generated per request from a user schema, not + a developer-declared one. Provider-side bugs that special-case + `Claude Code`-generated tool names (e.g. case-folding `tool_use` + blocks back to lowercase, or stripping the StructuredOutput-only + `additionalProperties: false`) only surface here. + - The success signal lives on the *final* `result` event, not the + intermediate `assistant` events the `tool_use` row checks. A proxy + that drops trailing events (seen in early Bedrock Converse SSE + plumbing) breaks this cell while leaving `tool_use` green. + +It is NOT a test of Anthropic's server-side `output_config.schema` +parameter -- that's a different feature used internally by Claude Code +for session-title generation and is not reachable from any CLI flag. +LiteLLM's `output_config`-stripping fixes (2.1.122, 2.1.81) surface in +the `count_tokens` and other HTTP-probe rows, not here. + +Three Claude tiers run in parallel; one `compat_result.add(...)` per +tier so the matrix's "all three must pass" rule applies. +""" + +from __future__ import annotations + +import json +import os +import re +from typing import Any, Mapping, Optional, Sequence, Tuple + +import pytest + +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-opus-4-7", +] + +# Minimal schema with one required integer field. Kept intentionally +# small -- the matrix tests the *plumbing*, not the model's ability to +# satisfy a complex schema. A trivial arithmetic prompt + a one-field +# integer schema gives every tier (including Haiku) enough headroom +# that schema satisfaction is essentially deterministic, isolating +# failures to the proxy / transport. +SCHEMA = { + "type": "object", + "properties": {"answer": {"type": "integer"}}, + "required": ["answer"], + "additionalProperties": False, +} +SCHEMA_JSON = json.dumps(SCHEMA, separators=(",", ":")) + +# A prompt the model has no reason to misanswer; we don't check the +# value, but a wrong answer would suggest the structured-output +# pathway is silently degrading reasoning, which is itself worth +# noticing. +PROMPT = "What is 2 + 2? Reply only via the structured output." + + +def _extract_structured_output( + events: Sequence[Mapping[str, Any]], +) -> Optional[Mapping[str, Any]]: + """Return the `structured_output` payload from the last `result` event. + + Claude Code emits its terminal stream-json line as + `{"type":"result","structured_output":{...},...}` when a request + used `--json-schema` and the model actually produced a valid tool + call. If the model bailed or the proxy ate the trailing events, + `structured_output` is missing -- which is exactly the failure + mode we want this row to surface, so the caller treats `None` as + "feature did not work end-to-end". + """ + for event in reversed(list(events)): + if event.get("type") != "result": + continue + so = event.get("structured_output") + if isinstance(so, Mapping): + return so + return None + + +def _validate_against_schema( + payload: Mapping[str, Any], schema: Mapping[str, Any] +) -> Optional[str]: + """Tiny shape validator covering the subset we actually need. + + We deliberately do not pull in `jsonschema` as a test dep: the + matrix's success signal is "does the proxy let the synthetic + StructuredOutput tool round-trip end-to-end", and that's + answerable with a presence + type check over `required` keys. + Any malformed schema beyond that would be a Claude Code bug, + not a LiteLLM-proxy bug, so a deeper check would only add false + failures on the wrong axis. + """ + type_map = { + "integer": int, + "number": (int, float), + "string": str, + "boolean": bool, + "array": list, + "object": Mapping, + } + required = schema.get("required") or [] + properties = schema.get("properties") or {} + for key in required: + if key not in payload: + return f"missing required key {key!r}" + expected = (properties.get(key) or {}).get("type") + if expected and expected in type_map: + if not isinstance(payload[key], type_map[expected]): + return ( + f"key {key!r} has wrong type: " + f"expected {expected}, got {type(payload[key]).__name__}" + ) + # bool is a subclass of int in Python; reject `True`/`False` + # when the schema asked for an integer/number. + if expected in ("integer", "number") and isinstance(payload[key], bool): + return f"key {key!r} is a bool but schema asked for {expected}" + return None + + +def test_structured_outputs_anthropic(compat_result): + """Drive `claude --json-schema ...` against the LiteLLM proxy and + assert the trailing `result` event contains a schema-conforming + `structured_output`.""" + 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, + ) + + outcomes = run_claude_models_parallel( + models=ANTHROPIC_MODELS, + prompt=PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=["--json-schema", SCHEMA_JSON], + ) + + failures = [] + for model in ANTHROPIC_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + payload = _extract_structured_output(outcome.events) + if payload is None: + error = ( + f"[{model}] no `structured_output` in trailing result event; " + "Claude Code's StructuredOutput tool round-trip did not " + "complete end-to-end through the proxy" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + shape_error = _validate_against_schema(payload, SCHEMA) + if shape_error is not None: + error = f"[{model}] structured_output shape error: {shape_error}; payload={payload}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/structured_outputs/test_azure.py b/tests/e2e/claude_code/structured_outputs/test_azure.py new file mode 100644 index 00000000000..290f9156910 --- /dev/null +++ b/tests/e2e/claude_code/structured_outputs/test_azure.py @@ -0,0 +1,220 @@ +"""structured_outputs x Azure (Microsoft Foundry). + +Drive the real `claude` CLI in headless mode with the `--json-schema` +flag, route through a LiteLLM proxy aimed at Azure (Microsoft Foundry), and assert that +the final stream-json `result` event surfaces a `structured_output` +object whose shape matches the schema. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/structured_outputs/test_azure.py + ^^^^^^^^^^^^^^^^^^ ^^^^^ + feature_id provider + +What this row actually exercises (and what it does not): + +`--json-schema` is implemented client-side by Claude Code: the CLI +synthesizes a synthetic `StructuredOutput` tool whose `input_schema` +equals the user-supplied JSON Schema, forces the model toward it, and +finally extracts the tool_use input on the trailing `result` event as +`structured_output: {...}`. The proxy never sees `output_config.schema` +in this flow -- it sees a normal `tools` array with one synthetic +tool. + +This makes the row a tool-use feature test in disguise. It's still a +distinct row from `tool_use` because: + + - The synthetic tool is generated per request from a user schema, not + a developer-declared one. Provider-side bugs that special-case + `Claude Code`-generated tool names (e.g. case-folding `tool_use` + blocks back to lowercase, or stripping the StructuredOutput-only + `additionalProperties: false`) only surface here. + - The success signal lives on the *final* `result` event, not the + intermediate `assistant` events the `tool_use` row checks. A proxy + that drops trailing events (seen in early Bedrock Converse SSE + plumbing) breaks this cell while leaving `tool_use` green. + +It is NOT a test of Anthropic's server-side `output_config.schema` +parameter -- that's a different feature used internally by Claude Code +for session-title generation and is not reachable from any CLI flag. +LiteLLM's `output_config`-stripping fixes (2.1.122, 2.1.81) surface in +the `count_tokens` and other HTTP-probe rows, not here. + +Three Claude tiers run in parallel; one `compat_result.add(...)` per +tier so the matrix's "all three must pass" rule applies. +""" + +from __future__ import annotations + +import json +import os +import re +from typing import Any, Mapping, Optional, Sequence, Tuple + +import pytest + +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-opus-4-7-azure", +] + +# Minimal schema with one required integer field. Kept intentionally +# small -- the matrix tests the *plumbing*, not the model's ability to +# satisfy a complex schema. A trivial arithmetic prompt + a one-field +# integer schema gives every tier (including Haiku) enough headroom +# that schema satisfaction is essentially deterministic, isolating +# failures to the proxy / transport. +SCHEMA = { + "type": "object", + "properties": {"answer": {"type": "integer"}}, + "required": ["answer"], + "additionalProperties": False, +} +SCHEMA_JSON = json.dumps(SCHEMA, separators=(",", ":")) + +# A prompt the model has no reason to misanswer; we don't check the +# value, but a wrong answer would suggest the structured-output +# pathway is silently degrading reasoning, which is itself worth +# noticing. +PROMPT = "What is 2 + 2? Reply only via the structured output." + + +def _extract_structured_output( + events: Sequence[Mapping[str, Any]], +) -> Optional[Mapping[str, Any]]: + """Return the `structured_output` payload from the last `result` event. + + Claude Code emits its terminal stream-json line as + `{"type":"result","structured_output":{...},...}` when a request + used `--json-schema` and the model actually produced a valid tool + call. If the model bailed or the proxy ate the trailing events, + `structured_output` is missing -- which is exactly the failure + mode we want this row to surface, so the caller treats `None` as + "feature did not work end-to-end". + """ + for event in reversed(list(events)): + if event.get("type") != "result": + continue + so = event.get("structured_output") + if isinstance(so, Mapping): + return so + return None + + +def _validate_against_schema( + payload: Mapping[str, Any], schema: Mapping[str, Any] +) -> Optional[str]: + """Tiny shape validator covering the subset we actually need. + + We deliberately do not pull in `jsonschema` as a test dep: the + matrix's success signal is "does the proxy let the synthetic + StructuredOutput tool round-trip end-to-end", and that's + answerable with a presence + type check over `required` keys. + Any malformed schema beyond that would be a Claude Code bug, + not a LiteLLM-proxy bug, so a deeper check would only add false + failures on the wrong axis. + """ + type_map = { + "integer": int, + "number": (int, float), + "string": str, + "boolean": bool, + "array": list, + "object": Mapping, + } + required = schema.get("required") or [] + properties = schema.get("properties") or {} + for key in required: + if key not in payload: + return f"missing required key {key!r}" + expected = (properties.get(key) or {}).get("type") + if expected and expected in type_map: + if not isinstance(payload[key], type_map[expected]): + return ( + f"key {key!r} has wrong type: " + f"expected {expected}, got {type(payload[key]).__name__}" + ) + # bool is a subclass of int in Python; reject `True`/`False` + # when the schema asked for an integer/number. + if expected in ("integer", "number") and isinstance(payload[key], bool): + return f"key {key!r} is a bool but schema asked for {expected}" + return None + + +def test_structured_outputs_azure(compat_result): + """Drive `claude --json-schema ...` against the LiteLLM proxy and + assert the trailing `result` event contains a schema-conforming + `structured_output`.""" + 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, + ) + + outcomes = run_claude_models_parallel( + models=AZURE_MODELS, + prompt=PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=["--json-schema", SCHEMA_JSON], + ) + + failures = [] + for model in AZURE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + payload = _extract_structured_output(outcome.events) + if payload is None: + error = ( + f"[{model}] no `structured_output` in trailing result event; " + "Claude Code's StructuredOutput tool round-trip did not " + "complete end-to-end through the proxy" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + shape_error = _validate_against_schema(payload, SCHEMA) + if shape_error is not None: + error = f"[{model}] structured_output shape error: {shape_error}; payload={payload}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/structured_outputs/test_bedrock_converse.py b/tests/e2e/claude_code/structured_outputs/test_bedrock_converse.py new file mode 100644 index 00000000000..5179014773c --- /dev/null +++ b/tests/e2e/claude_code/structured_outputs/test_bedrock_converse.py @@ -0,0 +1,220 @@ +"""structured_outputs x Bedrock (Converse). + +Drive the real `claude` CLI in headless mode with the `--json-schema` +flag, route through a LiteLLM proxy aimed at Bedrock (Converse), and assert that +the final stream-json `result` event surfaces a `structured_output` +object whose shape matches the schema. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/structured_outputs/test_bedrock_converse.py + ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider + +What this row actually exercises (and what it does not): + +`--json-schema` is implemented client-side by Claude Code: the CLI +synthesizes a synthetic `StructuredOutput` tool whose `input_schema` +equals the user-supplied JSON Schema, forces the model toward it, and +finally extracts the tool_use input on the trailing `result` event as +`structured_output: {...}`. The proxy never sees `output_config.schema` +in this flow -- it sees a normal `tools` array with one synthetic +tool. + +This makes the row a tool-use feature test in disguise. It's still a +distinct row from `tool_use` because: + + - The synthetic tool is generated per request from a user schema, not + a developer-declared one. Provider-side bugs that special-case + `Claude Code`-generated tool names (e.g. case-folding `tool_use` + blocks back to lowercase, or stripping the StructuredOutput-only + `additionalProperties: false`) only surface here. + - The success signal lives on the *final* `result` event, not the + intermediate `assistant` events the `tool_use` row checks. A proxy + that drops trailing events (seen in early Bedrock Converse SSE + plumbing) breaks this cell while leaving `tool_use` green. + +It is NOT a test of Anthropic's server-side `output_config.schema` +parameter -- that's a different feature used internally by Claude Code +for session-title generation and is not reachable from any CLI flag. +LiteLLM's `output_config`-stripping fixes (2.1.122, 2.1.81) surface in +the `count_tokens` and other HTTP-probe rows, not here. + +Three Claude tiers run in parallel; one `compat_result.add(...)` per +tier so the matrix's "all three must pass" rule applies. +""" + +from __future__ import annotations + +import json +import os +import re +from typing import Any, Mapping, Optional, Sequence, Tuple + +import pytest + +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-opus-4-7-bedrock-converse", +] + +# Minimal schema with one required integer field. Kept intentionally +# small -- the matrix tests the *plumbing*, not the model's ability to +# satisfy a complex schema. A trivial arithmetic prompt + a one-field +# integer schema gives every tier (including Haiku) enough headroom +# that schema satisfaction is essentially deterministic, isolating +# failures to the proxy / transport. +SCHEMA = { + "type": "object", + "properties": {"answer": {"type": "integer"}}, + "required": ["answer"], + "additionalProperties": False, +} +SCHEMA_JSON = json.dumps(SCHEMA, separators=(",", ":")) + +# A prompt the model has no reason to misanswer; we don't check the +# value, but a wrong answer would suggest the structured-output +# pathway is silently degrading reasoning, which is itself worth +# noticing. +PROMPT = "What is 2 + 2? Reply only via the structured output." + + +def _extract_structured_output( + events: Sequence[Mapping[str, Any]], +) -> Optional[Mapping[str, Any]]: + """Return the `structured_output` payload from the last `result` event. + + Claude Code emits its terminal stream-json line as + `{"type":"result","structured_output":{...},...}` when a request + used `--json-schema` and the model actually produced a valid tool + call. If the model bailed or the proxy ate the trailing events, + `structured_output` is missing -- which is exactly the failure + mode we want this row to surface, so the caller treats `None` as + "feature did not work end-to-end". + """ + for event in reversed(list(events)): + if event.get("type") != "result": + continue + so = event.get("structured_output") + if isinstance(so, Mapping): + return so + return None + + +def _validate_against_schema( + payload: Mapping[str, Any], schema: Mapping[str, Any] +) -> Optional[str]: + """Tiny shape validator covering the subset we actually need. + + We deliberately do not pull in `jsonschema` as a test dep: the + matrix's success signal is "does the proxy let the synthetic + StructuredOutput tool round-trip end-to-end", and that's + answerable with a presence + type check over `required` keys. + Any malformed schema beyond that would be a Claude Code bug, + not a LiteLLM-proxy bug, so a deeper check would only add false + failures on the wrong axis. + """ + type_map = { + "integer": int, + "number": (int, float), + "string": str, + "boolean": bool, + "array": list, + "object": Mapping, + } + required = schema.get("required") or [] + properties = schema.get("properties") or {} + for key in required: + if key not in payload: + return f"missing required key {key!r}" + expected = (properties.get(key) or {}).get("type") + if expected and expected in type_map: + if not isinstance(payload[key], type_map[expected]): + return ( + f"key {key!r} has wrong type: " + f"expected {expected}, got {type(payload[key]).__name__}" + ) + # bool is a subclass of int in Python; reject `True`/`False` + # when the schema asked for an integer/number. + if expected in ("integer", "number") and isinstance(payload[key], bool): + return f"key {key!r} is a bool but schema asked for {expected}" + return None + + +def test_structured_outputs_bedrock_converse(compat_result): + """Drive `claude --json-schema ...` against the LiteLLM proxy and + assert the trailing `result` event contains a schema-conforming + `structured_output`.""" + 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, + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_CONVERSE_MODELS, + prompt=PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=["--json-schema", SCHEMA_JSON], + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + payload = _extract_structured_output(outcome.events) + if payload is None: + error = ( + f"[{model}] no `structured_output` in trailing result event; " + "Claude Code's StructuredOutput tool round-trip did not " + "complete end-to-end through the proxy" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + shape_error = _validate_against_schema(payload, SCHEMA) + if shape_error is not None: + error = f"[{model}] structured_output shape error: {shape_error}; payload={payload}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/structured_outputs/test_bedrock_invoke.py b/tests/e2e/claude_code/structured_outputs/test_bedrock_invoke.py new file mode 100644 index 00000000000..313a714be34 --- /dev/null +++ b/tests/e2e/claude_code/structured_outputs/test_bedrock_invoke.py @@ -0,0 +1,220 @@ +"""structured_outputs x Bedrock (Invoke). + +Drive the real `claude` CLI in headless mode with the `--json-schema` +flag, route through a LiteLLM proxy aimed at Bedrock (Invoke), and assert that +the final stream-json `result` event surfaces a `structured_output` +object whose shape matches the schema. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/structured_outputs/test_bedrock_invoke.py + ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider + +What this row actually exercises (and what it does not): + +`--json-schema` is implemented client-side by Claude Code: the CLI +synthesizes a synthetic `StructuredOutput` tool whose `input_schema` +equals the user-supplied JSON Schema, forces the model toward it, and +finally extracts the tool_use input on the trailing `result` event as +`structured_output: {...}`. The proxy never sees `output_config.schema` +in this flow -- it sees a normal `tools` array with one synthetic +tool. + +This makes the row a tool-use feature test in disguise. It's still a +distinct row from `tool_use` because: + + - The synthetic tool is generated per request from a user schema, not + a developer-declared one. Provider-side bugs that special-case + `Claude Code`-generated tool names (e.g. case-folding `tool_use` + blocks back to lowercase, or stripping the StructuredOutput-only + `additionalProperties: false`) only surface here. + - The success signal lives on the *final* `result` event, not the + intermediate `assistant` events the `tool_use` row checks. A proxy + that drops trailing events (seen in early Bedrock Converse SSE + plumbing) breaks this cell while leaving `tool_use` green. + +It is NOT a test of Anthropic's server-side `output_config.schema` +parameter -- that's a different feature used internally by Claude Code +for session-title generation and is not reachable from any CLI flag. +LiteLLM's `output_config`-stripping fixes (2.1.122, 2.1.81) surface in +the `count_tokens` and other HTTP-probe rows, not here. + +Three Claude tiers run in parallel; one `compat_result.add(...)` per +tier so the matrix's "all three must pass" rule applies. +""" + +from __future__ import annotations + +import json +import os +import re +from typing import Any, Mapping, Optional, Sequence, Tuple + +import pytest + +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-opus-4-7-bedrock-invoke", +] + +# Minimal schema with one required integer field. Kept intentionally +# small -- the matrix tests the *plumbing*, not the model's ability to +# satisfy a complex schema. A trivial arithmetic prompt + a one-field +# integer schema gives every tier (including Haiku) enough headroom +# that schema satisfaction is essentially deterministic, isolating +# failures to the proxy / transport. +SCHEMA = { + "type": "object", + "properties": {"answer": {"type": "integer"}}, + "required": ["answer"], + "additionalProperties": False, +} +SCHEMA_JSON = json.dumps(SCHEMA, separators=(",", ":")) + +# A prompt the model has no reason to misanswer; we don't check the +# value, but a wrong answer would suggest the structured-output +# pathway is silently degrading reasoning, which is itself worth +# noticing. +PROMPT = "What is 2 + 2? Reply only via the structured output." + + +def _extract_structured_output( + events: Sequence[Mapping[str, Any]], +) -> Optional[Mapping[str, Any]]: + """Return the `structured_output` payload from the last `result` event. + + Claude Code emits its terminal stream-json line as + `{"type":"result","structured_output":{...},...}` when a request + used `--json-schema` and the model actually produced a valid tool + call. If the model bailed or the proxy ate the trailing events, + `structured_output` is missing -- which is exactly the failure + mode we want this row to surface, so the caller treats `None` as + "feature did not work end-to-end". + """ + for event in reversed(list(events)): + if event.get("type") != "result": + continue + so = event.get("structured_output") + if isinstance(so, Mapping): + return so + return None + + +def _validate_against_schema( + payload: Mapping[str, Any], schema: Mapping[str, Any] +) -> Optional[str]: + """Tiny shape validator covering the subset we actually need. + + We deliberately do not pull in `jsonschema` as a test dep: the + matrix's success signal is "does the proxy let the synthetic + StructuredOutput tool round-trip end-to-end", and that's + answerable with a presence + type check over `required` keys. + Any malformed schema beyond that would be a Claude Code bug, + not a LiteLLM-proxy bug, so a deeper check would only add false + failures on the wrong axis. + """ + type_map = { + "integer": int, + "number": (int, float), + "string": str, + "boolean": bool, + "array": list, + "object": Mapping, + } + required = schema.get("required") or [] + properties = schema.get("properties") or {} + for key in required: + if key not in payload: + return f"missing required key {key!r}" + expected = (properties.get(key) or {}).get("type") + if expected and expected in type_map: + if not isinstance(payload[key], type_map[expected]): + return ( + f"key {key!r} has wrong type: " + f"expected {expected}, got {type(payload[key]).__name__}" + ) + # bool is a subclass of int in Python; reject `True`/`False` + # when the schema asked for an integer/number. + if expected in ("integer", "number") and isinstance(payload[key], bool): + return f"key {key!r} is a bool but schema asked for {expected}" + return None + + +def test_structured_outputs_bedrock_invoke(compat_result): + """Drive `claude --json-schema ...` against the LiteLLM proxy and + assert the trailing `result` event contains a schema-conforming + `structured_output`.""" + 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, + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_INVOKE_MODELS, + prompt=PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=["--json-schema", SCHEMA_JSON], + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + payload = _extract_structured_output(outcome.events) + if payload is None: + error = ( + f"[{model}] no `structured_output` in trailing result event; " + "Claude Code's StructuredOutput tool round-trip did not " + "complete end-to-end through the proxy" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + shape_error = _validate_against_schema(payload, SCHEMA) + if shape_error is not None: + error = f"[{model}] structured_output shape error: {shape_error}; payload={payload}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/structured_outputs/test_vertex_ai.py b/tests/e2e/claude_code/structured_outputs/test_vertex_ai.py new file mode 100644 index 00000000000..ec04c724193 --- /dev/null +++ b/tests/e2e/claude_code/structured_outputs/test_vertex_ai.py @@ -0,0 +1,220 @@ +"""structured_outputs x Vertex AI. + +Drive the real `claude` CLI in headless mode with the `--json-schema` +flag, route through a LiteLLM proxy aimed at Vertex AI, and assert that +the final stream-json `result` event surfaces a `structured_output` +object whose shape matches the schema. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/structured_outputs/test_vertex_ai.py + ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider + +What this row actually exercises (and what it does not): + +`--json-schema` is implemented client-side by Claude Code: the CLI +synthesizes a synthetic `StructuredOutput` tool whose `input_schema` +equals the user-supplied JSON Schema, forces the model toward it, and +finally extracts the tool_use input on the trailing `result` event as +`structured_output: {...}`. The proxy never sees `output_config.schema` +in this flow -- it sees a normal `tools` array with one synthetic +tool. + +This makes the row a tool-use feature test in disguise. It's still a +distinct row from `tool_use` because: + + - The synthetic tool is generated per request from a user schema, not + a developer-declared one. Provider-side bugs that special-case + `Claude Code`-generated tool names (e.g. case-folding `tool_use` + blocks back to lowercase, or stripping the StructuredOutput-only + `additionalProperties: false`) only surface here. + - The success signal lives on the *final* `result` event, not the + intermediate `assistant` events the `tool_use` row checks. A proxy + that drops trailing events (seen in early Bedrock Converse SSE + plumbing) breaks this cell while leaving `tool_use` green. + +It is NOT a test of Anthropic's server-side `output_config.schema` +parameter -- that's a different feature used internally by Claude Code +for session-title generation and is not reachable from any CLI flag. +LiteLLM's `output_config`-stripping fixes (2.1.122, 2.1.81) surface in +the `count_tokens` and other HTTP-probe rows, not here. + +Three Claude tiers run in parallel; one `compat_result.add(...)` per +tier so the matrix's "all three must pass" rule applies. +""" + +from __future__ import annotations + +import json +import os +import re +from typing import Any, Mapping, Optional, Sequence, Tuple + +import pytest + +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" + +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + +# Minimal schema with one required integer field. Kept intentionally +# small -- the matrix tests the *plumbing*, not the model's ability to +# satisfy a complex schema. A trivial arithmetic prompt + a one-field +# integer schema gives every tier (including Haiku) enough headroom +# that schema satisfaction is essentially deterministic, isolating +# failures to the proxy / transport. +SCHEMA = { + "type": "object", + "properties": {"answer": {"type": "integer"}}, + "required": ["answer"], + "additionalProperties": False, +} +SCHEMA_JSON = json.dumps(SCHEMA, separators=(",", ":")) + +# A prompt the model has no reason to misanswer; we don't check the +# value, but a wrong answer would suggest the structured-output +# pathway is silently degrading reasoning, which is itself worth +# noticing. +PROMPT = "What is 2 + 2? Reply only via the structured output." + + +def _extract_structured_output( + events: Sequence[Mapping[str, Any]], +) -> Optional[Mapping[str, Any]]: + """Return the `structured_output` payload from the last `result` event. + + Claude Code emits its terminal stream-json line as + `{"type":"result","structured_output":{...},...}` when a request + used `--json-schema` and the model actually produced a valid tool + call. If the model bailed or the proxy ate the trailing events, + `structured_output` is missing -- which is exactly the failure + mode we want this row to surface, so the caller treats `None` as + "feature did not work end-to-end". + """ + for event in reversed(list(events)): + if event.get("type") != "result": + continue + so = event.get("structured_output") + if isinstance(so, Mapping): + return so + return None + + +def _validate_against_schema( + payload: Mapping[str, Any], schema: Mapping[str, Any] +) -> Optional[str]: + """Tiny shape validator covering the subset we actually need. + + We deliberately do not pull in `jsonschema` as a test dep: the + matrix's success signal is "does the proxy let the synthetic + StructuredOutput tool round-trip end-to-end", and that's + answerable with a presence + type check over `required` keys. + Any malformed schema beyond that would be a Claude Code bug, + not a LiteLLM-proxy bug, so a deeper check would only add false + failures on the wrong axis. + """ + type_map = { + "integer": int, + "number": (int, float), + "string": str, + "boolean": bool, + "array": list, + "object": Mapping, + } + required = schema.get("required") or [] + properties = schema.get("properties") or {} + for key in required: + if key not in payload: + return f"missing required key {key!r}" + expected = (properties.get(key) or {}).get("type") + if expected and expected in type_map: + if not isinstance(payload[key], type_map[expected]): + return ( + f"key {key!r} has wrong type: " + f"expected {expected}, got {type(payload[key]).__name__}" + ) + # bool is a subclass of int in Python; reject `True`/`False` + # when the schema asked for an integer/number. + if expected in ("integer", "number") and isinstance(payload[key], bool): + return f"key {key!r} is a bool but schema asked for {expected}" + return None + + +def test_structured_outputs_vertex_ai(compat_result): + """Drive `claude --json-schema ...` against the LiteLLM proxy and + assert the trailing `result` event contains a schema-conforming + `structured_output`.""" + 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, + ) + + outcomes = run_claude_models_parallel( + models=VERTEX_AI_MODELS, + prompt=PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=["--json-schema", SCHEMA_JSON], + ) + + failures = [] + for model in VERTEX_AI_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + payload = _extract_structured_output(outcome.events) + if payload is None: + error = ( + f"[{model}] no `structured_output` in trailing result event; " + "Claude Code's StructuredOutput tool round-trip did not " + "complete end-to-end through the proxy" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + shape_error = _validate_against_schema(payload, SCHEMA) + if shape_error is not None: + error = f"[{model}] structured_output shape error: {shape_error}; payload={payload}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/test_config.yaml b/tests/e2e/claude_code/test_config.yaml new file mode 100644 index 00000000000..eec68d11dcf --- /dev/null +++ b/tests/e2e/claude_code/test_config.yaml @@ -0,0 +1,103 @@ +# Proxy routing config for the Claude Code Compatibility Matrix PR gate. +# +# The tests under `tests/e2e/claude_code/` only know **alias** names (e.g. +# `claude-haiku-4-5-bedrock-invoke`). The proxy is the layer that maps +# each alias to a real upstream model id, region, and credentials. +# +# Adding a new (feature, provider) cell is a three-step change in the +# test repo (manifest + test file + alias here); changing which upstream +# model a cell exercises is a one-step change here, with no test edits. +# +# Aliases: +# - claude-{tier} → Anthropic API +# - claude-{tier}-bedrock-invoke → Bedrock InvokeModel API +# - claude-{tier}-bedrock-converse → Bedrock Converse API +# - claude-{tier}-vertex → GCP Vertex AI +# - claude-{tier}-azure → Microsoft Foundry (Anthropic deployments) + +model_list: + # ---- Anthropic ---- + - model_name: claude-haiku-4-5 + litellm_params: + model: anthropic/claude-haiku-4-5 + api_key: os.environ/ANTHROPIC_API_KEY + - model_name: claude-sonnet-4-6 + litellm_params: + model: anthropic/claude-sonnet-4-6 + api_key: os.environ/ANTHROPIC_API_KEY + - model_name: claude-opus-4-7 + litellm_params: + model: anthropic/claude-opus-4-7 + api_key: os.environ/ANTHROPIC_API_KEY + + # ---- Bedrock (InvokeModel) ---- + - model_name: claude-haiku-4-5-bedrock-invoke + litellm_params: + model: bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0 + aws_region_name: us-east-1 + - model_name: claude-sonnet-4-6-bedrock-invoke + litellm_params: + model: bedrock/us.anthropic.claude-sonnet-4-6 + aws_region_name: us-east-1 + - model_name: claude-opus-4-7-bedrock-invoke + litellm_params: + model: bedrock/us.anthropic.claude-opus-4-7 + aws_region_name: us-east-1 + + # ---- Bedrock (Converse) ---- + - model_name: claude-haiku-4-5-bedrock-converse + litellm_params: + model: bedrock/converse/us.anthropic.claude-haiku-4-5-20251001-v1:0 + aws_region_name: us-east-1 + - model_name: claude-sonnet-4-6-bedrock-converse + litellm_params: + model: bedrock/converse/us.anthropic.claude-sonnet-4-6 + aws_region_name: us-east-1 + - model_name: claude-opus-4-7-bedrock-converse + litellm_params: + model: bedrock/converse/us.anthropic.claude-opus-4-7 + aws_region_name: us-east-1 + + # ---- Vertex AI ---- + - model_name: claude-haiku-4-5-vertex + litellm_params: + model: vertex_ai/claude-haiku-4-5 + vertex_ai_project: os.environ/VERTEXAI_PROJECT + vertex_ai_location: os.environ/VERTEXAI_LOCATION + - model_name: claude-sonnet-4-6-vertex + litellm_params: + model: vertex_ai/claude-sonnet-4-6 + vertex_ai_project: os.environ/VERTEXAI_PROJECT + vertex_ai_location: os.environ/VERTEXAI_LOCATION + - model_name: claude-opus-4-7-vertex + litellm_params: + model: vertex_ai/claude-opus-4-7 + vertex_ai_project: os.environ/VERTEXAI_PROJECT + vertex_ai_location: os.environ/VERTEXAI_LOCATION + + # ---- Microsoft Foundry (Anthropic deployments on Azure) ---- + - model_name: claude-haiku-4-5-azure + litellm_params: + model: azure_ai/claude-haiku-4-5 + api_base: os.environ/AZURE_FOUNDRY_API_BASE + api_key: os.environ/AZURE_FOUNDRY_API_KEY + - model_name: claude-sonnet-4-6-azure + litellm_params: + model: azure_ai/claude-sonnet-4-6 + api_base: os.environ/AZURE_FOUNDRY_API_BASE + api_key: os.environ/AZURE_FOUNDRY_API_KEY + - model_name: claude-opus-4-7-azure + litellm_params: + model: azure_ai/claude-opus-4-7 + api_base: os.environ/AZURE_FOUNDRY_API_BASE + api_key: os.environ/AZURE_FOUNDRY_API_KEY + +general_settings: + # Claude Code sends provider-specific headers (e.g. anthropic-beta) we + # want to forward verbatim to the upstream so the wire-shape under + # test matches what real customers send. + forward_client_headers_to_llm_api: true + +litellm_settings: + drop_params: true + modify_params: true diff --git a/tests/e2e/claude_code/thinking/__init__.py b/tests/e2e/claude_code/thinking/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/thinking/test_anthropic.py b/tests/e2e/claude_code/thinking/test_anthropic.py new file mode 100644 index 00000000000..1090d1b384e --- /dev/null +++ b/tests/e2e/claude_code/thinking/test_anthropic.py @@ -0,0 +1,132 @@ +"""thinking x Anthropic. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Anthropic, enable extended thinking via `--effort high`, and assert +that the upstream returned a `thinking` content block. This proves the +proxy preserves Anthropic's `thinking` request parameter and the +upstream response's `thinking` content blocks end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/thinking/test_anthropic.py + ^^^^^^^^ ^^^^^^^^^ + feature_id provider + +The three Claude tiers run in parallel inside this single test, with +one `compat_result.add(...)` entry per model so the matrix builder +still sees three rows for this (feature, provider). +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7", +] + +# --effort max maps to the largest thinking budget on every supported +# Claude tier; the test cares about wire shape, not answer quality. We +# use a CLI flag (rather than the legacy MAX_THINKING_TOKENS env var) +# because Claude Code 2.x reads thinking config from --effort, not from +# the env, and silently no-ops the env var. We use `max` rather than +# `high` because Sonnet 4.6 / Opus 4.7 only emit thinking blocks when +# the budget is generous and the prompt is non-trivial. +THINKING_ARGS = ["--effort", "max"] +# A puzzle non-trivial enough that Sonnet/Opus actually engage thinking +# rather than answer from memory. Trivial arithmetic ("3-2=?") is +# optimized away on the modern tiers and arrives without a thinking +# block, which would make this test silently false-fail under +# `--effort max`. Haiku 4.5 thinks even for trivial prompts; Sonnet 4.6 +# and Opus 4.7 only emit thinking when the upstream judges it useful. +THINKING_PROMPT = ( + "I have a 3-gallon jug and a 5-gallon jug. How can I measure " + "exactly 4 gallons of water? Think through the steps carefully." +) + + +def _has_thinking_block(events: Sequence[Mapping[str, Any]]) -> bool: + """Walk the stream-json events and return True if any assistant + message included a `thinking` content block.""" + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "thinking": + return True + return False + + +def test_thinking_anthropic(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with thinking + enabled and assert a `thinking` content block was emitted.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=ANTHROPIC_MODELS, + prompt=THINKING_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=THINKING_ARGS, + ) + + failures = [] + for model in ANTHROPIC_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_thinking_block(outcome.events): + error = ( + f"[{model}] no `thinking` content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/thinking/test_azure.py b/tests/e2e/claude_code/thinking/test_azure.py new file mode 100644 index 00000000000..1fd5138d574 --- /dev/null +++ b/tests/e2e/claude_code/thinking/test_azure.py @@ -0,0 +1,120 @@ +"""thinking x Azure (Microsoft Foundry). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to Anthropic's models hosted in Microsoft Foundry on +Azure, enable extended thinking via `--effort high`, and assert +that the upstream returned a `thinking` content block. + +Foundry's Claude deployments advertise `supports_reasoning: true` in +LiteLLM's pricing metadata; the `thinking={"type": "enabled", ...}` +parameter passes through `azure_ai/claude-*` to Foundry's +`/anthropic/v1/messages` endpoint unchanged. Note that +`claude-opus-4-7-preview` documents thinking as not supported on +Foundry; if that lands, this row may flip to a partial pass and we'll +re-evaluate. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/thinking/test_azure.py + ^^^^^^^^ ^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-azure", +] + +THINKING_ARGS = ["--effort", "max"] +THINKING_PROMPT = ( + "I have a 3-gallon jug and a 5-gallon jug. How can I measure " + "exactly 4 gallons of water? Think through the steps carefully." +) + + +def _has_thinking_block(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "thinking": + return True + return False + + +def test_thinking_azure(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with thinking + enabled and assert a `thinking` content block was emitted.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=AZURE_MODELS, + prompt=THINKING_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=THINKING_ARGS, + ) + + failures = [] + for model in AZURE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_thinking_block(outcome.events): + error = ( + f"[{model}] no `thinking` content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/thinking/test_bedrock_converse.py b/tests/e2e/claude_code/thinking/test_bedrock_converse.py new file mode 100644 index 00000000000..793ce8542da --- /dev/null +++ b/tests/e2e/claude_code/thinking/test_bedrock_converse.py @@ -0,0 +1,112 @@ +"""thinking x Bedrock (Converse). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the unified `Converse` API path, +enable extended thinking via `--effort high`, and assert that the +upstream returned a `thinking` content block. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/thinking/test_bedrock_converse.py + ^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-bedrock-converse", +] + +THINKING_ARGS = ["--effort", "max"] +THINKING_PROMPT = ( + "I have a 3-gallon jug and a 5-gallon jug. How can I measure " + "exactly 4 gallons of water? Think through the steps carefully." +) + + +def _has_thinking_block(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "thinking": + return True + return False + + +def test_thinking_bedrock_converse(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with thinking + enabled and assert a `thinking` content block was emitted.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_CONVERSE_MODELS, + prompt=THINKING_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=THINKING_ARGS, + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_thinking_block(outcome.events): + error = ( + f"[{model}] no `thinking` content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/thinking/test_bedrock_invoke.py b/tests/e2e/claude_code/thinking/test_bedrock_invoke.py new file mode 100644 index 00000000000..e31b60eb004 --- /dev/null +++ b/tests/e2e/claude_code/thinking/test_bedrock_invoke.py @@ -0,0 +1,112 @@ +"""thinking x Bedrock (Invoke). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the legacy `InvokeModel` API path, +enable extended thinking via `--effort high`, and assert that the +upstream returned a `thinking` content block. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/thinking/test_bedrock_invoke.py + ^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-bedrock-invoke", +] + +THINKING_ARGS = ["--effort", "max"] +THINKING_PROMPT = ( + "I have a 3-gallon jug and a 5-gallon jug. How can I measure " + "exactly 4 gallons of water? Think through the steps carefully." +) + + +def _has_thinking_block(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "thinking": + return True + return False + + +def test_thinking_bedrock_invoke(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with thinking + enabled and assert a `thinking` content block was emitted.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_INVOKE_MODELS, + prompt=THINKING_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=THINKING_ARGS, + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_thinking_block(outcome.events): + error = ( + f"[{model}] no `thinking` content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/thinking/test_vertex_ai.py b/tests/e2e/claude_code/thinking/test_vertex_ai.py new file mode 100644 index 00000000000..c5c7df1f9b8 --- /dev/null +++ b/tests/e2e/claude_code/thinking/test_vertex_ai.py @@ -0,0 +1,112 @@ +"""thinking x Vertex AI. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to Anthropic's models on Google Cloud Vertex AI, enable +extended thinking via `--effort high`, and assert that the +upstream returned a `thinking` content block. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/thinking/test_vertex_ai.py + ^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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" + +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + +THINKING_ARGS = ["--effort", "max"] +THINKING_PROMPT = ( + "I have a 3-gallon jug and a 5-gallon jug. How can I measure " + "exactly 4 gallons of water? Think through the steps carefully." +) + + +def _has_thinking_block(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "thinking": + return True + return False + + +def test_thinking_vertex_ai(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with thinking + enabled and assert a `thinking` content block was emitted.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=VERTEX_AI_MODELS, + prompt=THINKING_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=THINKING_ARGS, + ) + + failures = [] + for model in VERTEX_AI_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_thinking_block(outcome.events): + error = ( + f"[{model}] no `thinking` content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/thinking_with_tool_use/__init__.py b/tests/e2e/claude_code/thinking_with_tool_use/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/thinking_with_tool_use/test_anthropic.py b/tests/e2e/claude_code/thinking_with_tool_use/test_anthropic.py new file mode 100644 index 00000000000..2c573ea039e --- /dev/null +++ b/tests/e2e/claude_code/thinking_with_tool_use/test_anthropic.py @@ -0,0 +1,158 @@ +"""thinking_with_tool_use x Anthropic. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Anthropic, enable extended thinking via `--effort high`, allow +the built-in `Bash` tool, and ask Claude to plan-and-execute a task +that requires both reasoning and a tool call. Assert the upstream +returned both a `thinking` content block and a `tool_use` content +block in the same turn — proving the proxy preserves the wire shape +where extended thinking and tool use coexist. + +This is the cell that historically catches the most provider bugs: +"thinking blocks cannot be modified" 400s, the recurring Bedrock +"thinking.type.enabled is not supported" error, and the +`fine-grained-tool-streaming` + `interleaved-thinking` beta-header +interactions. A regression in any of those collapses this cell. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/thinking_with_tool_use/test_anthropic.py + ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7", +] + +# Extended thinking on, with a small budget — enough to surface a +# non-empty thinking block on a trivial reasoning prompt without +# blowing up wall time. +THINKING_ARGS = ["--effort", "max"] + +# Prompt designed to force both blocks: the model has to *reason* about +# what command to run before *invoking* the Bash tool. Using a fixed +# expected output keeps the assertion focused on the wire shape rather +# than on answer quality. +# Prompt fixes the exact bash command to `echo pong`. The thinking +# block is preserved (the model reasons about why `echo pong` works), +# but the executed command is pinned so the cell can run under the +# tight `Bash(echo pong) + dontAsk` permission below — see +# `tool_use/test_anthropic.py` for the full security rationale. +THINKING_TOOL_PROMPT = ( + "Think step by step about why the command `echo pong` prints just the " + "word 'pong'. Then use the Bash tool to run exactly the command " + "`echo pong` and report what it printed." +) +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", +] + + +def _has_block_type( + events: Sequence[Mapping[str, Any]], + block_type: str, +) -> bool: + """Walk the stream-json events and return True if any assistant + message included a content block of the given type.""" + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == block_type: + return True + return False + + +def test_thinking_with_tool_use_anthropic(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with thinking + enabled and tool use, and assert both `thinking` and `tool_use` + content blocks landed in the same turn.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=ANTHROPIC_MODELS, + prompt=THINKING_TOOL_PROMPT, + base_url=base_url, + api_key=api_key, + # thinking + tools combined into a single extra_args; see THINKING_ARGS + extra_args=THINKING_ARGS + TOOL_USE_ARGS, + ) + + failures = [] + for model in ANTHROPIC_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_block_type(outcome.events, "thinking"): + error = ( + f"[{model}] no `thinking` content block observed; thinking " + f"either disabled by the proxy or stripped by the upstream" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_block_type(outcome.events, "tool_use"): + error = ( + f"[{model}] no `tool_use` content block observed alongside " + f"thinking; the proxy may have dropped tools when thinking is on" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/thinking_with_tool_use/test_azure.py b/tests/e2e/claude_code/thinking_with_tool_use/test_azure.py new file mode 100644 index 00000000000..3d65e82cdec --- /dev/null +++ b/tests/e2e/claude_code/thinking_with_tool_use/test_azure.py @@ -0,0 +1,130 @@ +"""thinking_with_tool_use x Microsoft Foundry (Azure). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to Microsoft Foundry's Anthropic deployments on Azure, +enable extended thinking via `--effort high`, allow the built-in +`Bash` tool, and ask Claude to plan-and-execute a task that requires +both reasoning and a tool call. Assert the upstream returned both a +`thinking` content block and a `tool_use` content block in the same +turn. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/thinking_with_tool_use/test_azure.py + ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-azure", +] + +THINKING_ARGS = ["--effort", "max"] +# Prompt + Bash restriction pin the executed command to `echo pong`; +# see `tool_use/test_anthropic.py` for the security rationale. +THINKING_TOOL_PROMPT = ( + "Think step by step about why the command `echo pong` prints just the " + "word 'pong'. Then use the Bash tool to run exactly the command " + "`echo pong` and report what it printed." +) +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", +] + + +def _has_block_type( + events: Sequence[Mapping[str, Any]], + block_type: str, +) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == block_type: + return True + return False + + +def test_thinking_with_tool_use_azure(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=AZURE_MODELS, + prompt=THINKING_TOOL_PROMPT, + base_url=base_url, + api_key=api_key, + # thinking + tools combined into a single extra_args; see THINKING_ARGS + extra_args=THINKING_ARGS + TOOL_USE_ARGS, + ) + + failures = [] + for model in AZURE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_block_type(outcome.events, "thinking"): + error = f"[{model}] no `thinking` content block observed" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_block_type(outcome.events, "tool_use"): + error = f"[{model}] no `tool_use` content block observed alongside thinking" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/thinking_with_tool_use/test_bedrock_converse.py b/tests/e2e/claude_code/thinking_with_tool_use/test_bedrock_converse.py new file mode 100644 index 00000000000..eb916323546 --- /dev/null +++ b/tests/e2e/claude_code/thinking_with_tool_use/test_bedrock_converse.py @@ -0,0 +1,135 @@ +"""thinking_with_tool_use x Bedrock (Converse). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the `Converse` API, enable extended +thinking via `--effort high`, allow the built-in `Bash` tool, and +ask Claude to plan-and-execute a task that requires both reasoning and +a tool call. Assert the upstream returned both a `thinking` content +block and a `tool_use` content block in the same turn. + +The Converse API has its own `additionalModelRequestFields.thinking` +shape and its own tool-use envelope; this cell catches gateway +regressions where the proxy fails to translate between Anthropic's +`thinking` parameter and Converse's reasoning configuration when tools +are also present. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/thinking_with_tool_use/test_bedrock_converse.py + ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-bedrock-converse", +] + +THINKING_ARGS = ["--effort", "max"] +# Prompt + Bash restriction pin the executed command to `echo pong`; +# see `tool_use/test_anthropic.py` for the security rationale. +THINKING_TOOL_PROMPT = ( + "Think step by step about why the command `echo pong` prints just the " + "word 'pong'. Then use the Bash tool to run exactly the command " + "`echo pong` and report what it printed." +) +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", +] + + +def _has_block_type( + events: Sequence[Mapping[str, Any]], + block_type: str, +) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == block_type: + return True + return False + + +def test_thinking_with_tool_use_bedrock_converse(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_CONVERSE_MODELS, + prompt=THINKING_TOOL_PROMPT, + base_url=base_url, + api_key=api_key, + # thinking + tools combined into a single extra_args; see THINKING_ARGS + extra_args=THINKING_ARGS + TOOL_USE_ARGS, + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_block_type(outcome.events, "thinking"): + error = f"[{model}] no `thinking` content block observed" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_block_type(outcome.events, "tool_use"): + error = f"[{model}] no `tool_use` content block observed alongside thinking" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/thinking_with_tool_use/test_bedrock_invoke.py b/tests/e2e/claude_code/thinking_with_tool_use/test_bedrock_invoke.py new file mode 100644 index 00000000000..d1a61a59772 --- /dev/null +++ b/tests/e2e/claude_code/thinking_with_tool_use/test_bedrock_invoke.py @@ -0,0 +1,137 @@ +"""thinking_with_tool_use x Bedrock (Invoke). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the legacy `InvokeModel` API path, +enable extended thinking via `--effort high`, allow the built-in +`Bash` tool, and ask Claude to plan-and-execute a task that requires +both reasoning and a tool call. Assert the upstream returned both a +`thinking` content block and a `tool_use` content block in the same +turn. + +This is the cell most likely to flush out Bedrock-specific bugs in +LiteLLM's Anthropic <-> Bedrock translation: the recurring +"thinking.type.enabled is not supported" 400 error has reappeared on +several Bedrock model routes (notably application inference profile +ARNs), and the only reliable signal that the fix is wired through the +proxy is a successful round-trip on this cell. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/thinking_with_tool_use/test_bedrock_invoke.py + ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-bedrock-invoke", +] + +THINKING_ARGS = ["--effort", "max"] +# Prompt + Bash restriction pin the executed command to `echo pong`; +# see `tool_use/test_anthropic.py` for the security rationale. +THINKING_TOOL_PROMPT = ( + "Think step by step about why the command `echo pong` prints just the " + "word 'pong'. Then use the Bash tool to run exactly the command " + "`echo pong` and report what it printed." +) +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", +] + + +def _has_block_type( + events: Sequence[Mapping[str, Any]], + block_type: str, +) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == block_type: + return True + return False + + +def test_thinking_with_tool_use_bedrock_invoke(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_INVOKE_MODELS, + prompt=THINKING_TOOL_PROMPT, + base_url=base_url, + api_key=api_key, + # thinking + tools combined into a single extra_args; see THINKING_ARGS + extra_args=THINKING_ARGS + TOOL_USE_ARGS, + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_block_type(outcome.events, "thinking"): + error = f"[{model}] no `thinking` content block observed" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_block_type(outcome.events, "tool_use"): + error = f"[{model}] no `tool_use` content block observed alongside thinking" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/thinking_with_tool_use/test_vertex_ai.py b/tests/e2e/claude_code/thinking_with_tool_use/test_vertex_ai.py new file mode 100644 index 00000000000..285419c67f7 --- /dev/null +++ b/tests/e2e/claude_code/thinking_with_tool_use/test_vertex_ai.py @@ -0,0 +1,135 @@ +"""thinking_with_tool_use x Vertex AI. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to GCP Vertex AI, enable extended thinking via +`--effort high`, allow the built-in `Bash` tool, and ask Claude +to plan-and-execute a task that requires both reasoning and a tool +call. Assert the upstream returned both a `thinking` content block and +a `tool_use` content block in the same turn. + +Vertex AI exposes Anthropic models via `:rawPredict` / +`:streamRawPredict` and has its own beta-header allowlist. This cell +catches gateway regressions where the proxy strips +`anthropic-beta: interleaved-thinking-2025-05-14` (or the equivalent +header set the upstream needs) on the way to Vertex. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/thinking_with_tool_use/test_vertex_ai.py + ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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" + +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + +THINKING_ARGS = ["--effort", "max"] +# Prompt + Bash restriction pin the executed command to `echo pong`; +# see `tool_use/test_anthropic.py` for the security rationale. +THINKING_TOOL_PROMPT = ( + "Think step by step about why the command `echo pong` prints just the " + "word 'pong'. Then use the Bash tool to run exactly the command " + "`echo pong` and report what it printed." +) +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", +] + + +def _has_block_type( + events: Sequence[Mapping[str, Any]], + block_type: str, +) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == block_type: + return True + return False + + +def test_thinking_with_tool_use_vertex_ai(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=VERTEX_AI_MODELS, + prompt=THINKING_TOOL_PROMPT, + base_url=base_url, + api_key=api_key, + # thinking + tools combined into a single extra_args; see THINKING_ARGS + extra_args=THINKING_ARGS + TOOL_USE_ARGS, + ) + + failures = [] + for model in VERTEX_AI_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_block_type(outcome.events, "thinking"): + error = f"[{model}] no `thinking` content block observed" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_block_type(outcome.events, "tool_use"): + error = f"[{model}] no `tool_use` content block observed alongside thinking" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_search/__init__.py b/tests/e2e/claude_code/tool_search/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/tool_search/test_anthropic.py b/tests/e2e/claude_code/tool_search/test_anthropic.py new file mode 100644 index 00000000000..3495c882e06 --- /dev/null +++ b/tests/e2e/claude_code/tool_search/test_anthropic.py @@ -0,0 +1,99 @@ +"""tool_search x Anthropic. + +HTTP-probe row. Sends a single `/v1/messages` request whose `tools` +array includes a `tool_search_tool_regex_20251119` discovery tool, and +asserts the proxy round-trips it to the upstream without a 400. This +verifies LiteLLM's tool-search beta-header translation +(`advanced-tool-use-2025-11-20` for Anthropic-shape providers, +`tool-search-tool-2025-10-19` for Vertex/Bedrock) survives end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_search/test_anthropic.py + ^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider + +Why HTTP probe instead of CLI / MCP fan-out: + +Real Claude Code activates tool_search by registering >N MCP tools and +relying on the model's internal heuristic to call the discovery tool +before any user tool. That setup requires standing up a stub MCP +server that exposes 50+ tool stubs and depends on Claude Code's +auto-deferral heuristic continuing to fire at today's tool count -- +both of which break silently when Claude Code's threshold changes +between releases. + +The bugs LiteLLM has actually shipped fixes for in this area +(2.1.117, 2.1.72, 2.1.70 in the Claude Code release notes) are +beta-header translation and proxy-side type recognition, not MCP +fan-out behavior. An HTTP probe hits exactly that surface: the +request goes out with a `tool_search_tool_regex_20251119` tool type, +the proxy is responsible for attaching the per-provider beta header +and forwarding, and the upstream either accepts or 400s. A red cell +here is always a proxy-side regression, not a flaky model-behavior +artifact. + +Three Claude tiers are probed in sequence (count is too low to be +worth the parallelism overhead, and HTTP probes don't compete for +the proxy's `--num-workers` slots the way CLI subprocess runs do). +The matrix's "all three must pass" rule still applies via the +per-cell aggregator. +""" + +from __future__ import annotations + +import os + +import pytest + +from claude_code.http_probe import ( + assert_tool_search_shape, + probe_tool_search, +) + +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-opus-4-7", +] + + +def test_tool_search_anthropic(compat_result): + """Probe `/v1/messages` with a `tool_search_tool_regex_20251119` + tool and assert the proxy + upstream accept it for every Anthropic + tier.""" + 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, + ) + + failures = [] + for model in ANTHROPIC_MODELS: + result = probe_tool_search(base_url=base_url, api_key=api_key, model=model) + shape_error = assert_tool_search_shape(result) + if shape_error is not None: + error = f"[{model}] tool_search probe failed: {shape_error}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_search/test_azure.py b/tests/e2e/claude_code/tool_search/test_azure.py new file mode 100644 index 00000000000..1d9cb5673c5 --- /dev/null +++ b/tests/e2e/claude_code/tool_search/test_azure.py @@ -0,0 +1,99 @@ +"""tool_search x Azure (Microsoft Foundry). + +HTTP-probe row. Sends a single `/v1/messages` request whose `tools` +array includes a `tool_search_tool_regex_20251119` discovery tool, and +asserts the proxy round-trips it to the upstream without a 400. This +verifies LiteLLM's tool-search beta-header translation +(`advanced-tool-use-2025-11-20` for Anthropic-shape providers, +`tool-search-tool-2025-10-19` for Vertex/Bedrock) survives end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_search/test_azure.py + ^^^^^^^^^^^ ^^^^^ + feature_id provider + +Why HTTP probe instead of CLI / MCP fan-out: + +Real Claude Code activates tool_search by registering >N MCP tools and +relying on the model's internal heuristic to call the discovery tool +before any user tool. That setup requires standing up a stub MCP +server that exposes 50+ tool stubs and depends on Claude Code's +auto-deferral heuristic continuing to fire at today's tool count -- +both of which break silently when Claude Code's threshold changes +between releases. + +The bugs LiteLLM has actually shipped fixes for in this area +(2.1.117, 2.1.72, 2.1.70 in the Claude Code release notes) are +beta-header translation and proxy-side type recognition, not MCP +fan-out behavior. An HTTP probe hits exactly that surface: the +request goes out with a `tool_search_tool_regex_20251119` tool type, +the proxy is responsible for attaching the per-provider beta header +and forwarding, and the upstream either accepts or 400s. A red cell +here is always a proxy-side regression, not a flaky model-behavior +artifact. + +Three Claude tiers are probed in sequence (count is too low to be +worth the parallelism overhead, and HTTP probes don't compete for +the proxy's `--num-workers` slots the way CLI subprocess runs do). +The matrix's "all three must pass" rule still applies via the +per-cell aggregator. +""" + +from __future__ import annotations + +import os + +import pytest + +from claude_code.http_probe import ( + assert_tool_search_shape, + probe_tool_search, +) + +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-opus-4-7-azure", +] + + +def test_tool_search_azure(compat_result): + """Probe `/v1/messages` with a `tool_search_tool_regex_20251119` + tool and assert the proxy + upstream accept it for every Azure (Microsoft Foundry) + tier.""" + 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, + ) + + failures = [] + for model in AZURE_MODELS: + result = probe_tool_search(base_url=base_url, api_key=api_key, model=model) + shape_error = assert_tool_search_shape(result) + if shape_error is not None: + error = f"[{model}] tool_search probe failed: {shape_error}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_search/test_bedrock_converse.py b/tests/e2e/claude_code/tool_search/test_bedrock_converse.py new file mode 100644 index 00000000000..5ca0792529a --- /dev/null +++ b/tests/e2e/claude_code/tool_search/test_bedrock_converse.py @@ -0,0 +1,99 @@ +"""tool_search x Bedrock (Converse). + +HTTP-probe row. Sends a single `/v1/messages` request whose `tools` +array includes a `tool_search_tool_regex_20251119` discovery tool, and +asserts the proxy round-trips it to the upstream without a 400. This +verifies LiteLLM's tool-search beta-header translation +(`advanced-tool-use-2025-11-20` for Anthropic-shape providers, +`tool-search-tool-2025-10-19` for Vertex/Bedrock) survives end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_search/test_bedrock_converse.py + ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider + +Why HTTP probe instead of CLI / MCP fan-out: + +Real Claude Code activates tool_search by registering >N MCP tools and +relying on the model's internal heuristic to call the discovery tool +before any user tool. That setup requires standing up a stub MCP +server that exposes 50+ tool stubs and depends on Claude Code's +auto-deferral heuristic continuing to fire at today's tool count -- +both of which break silently when Claude Code's threshold changes +between releases. + +The bugs LiteLLM has actually shipped fixes for in this area +(2.1.117, 2.1.72, 2.1.70 in the Claude Code release notes) are +beta-header translation and proxy-side type recognition, not MCP +fan-out behavior. An HTTP probe hits exactly that surface: the +request goes out with a `tool_search_tool_regex_20251119` tool type, +the proxy is responsible for attaching the per-provider beta header +and forwarding, and the upstream either accepts or 400s. A red cell +here is always a proxy-side regression, not a flaky model-behavior +artifact. + +Three Claude tiers are probed in sequence (count is too low to be +worth the parallelism overhead, and HTTP probes don't compete for +the proxy's `--num-workers` slots the way CLI subprocess runs do). +The matrix's "all three must pass" rule still applies via the +per-cell aggregator. +""" + +from __future__ import annotations + +import os + +import pytest + +from claude_code.http_probe import ( + assert_tool_search_shape, + probe_tool_search, +) + +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-opus-4-7-bedrock-converse", +] + + +def test_tool_search_bedrock_converse(compat_result): + """Probe `/v1/messages` with a `tool_search_tool_regex_20251119` + tool and assert the proxy + upstream accept it for every Bedrock (Converse) + tier.""" + 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, + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + result = probe_tool_search(base_url=base_url, api_key=api_key, model=model) + shape_error = assert_tool_search_shape(result) + if shape_error is not None: + error = f"[{model}] tool_search probe failed: {shape_error}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_search/test_bedrock_invoke.py b/tests/e2e/claude_code/tool_search/test_bedrock_invoke.py new file mode 100644 index 00000000000..21bb33e34bd --- /dev/null +++ b/tests/e2e/claude_code/tool_search/test_bedrock_invoke.py @@ -0,0 +1,99 @@ +"""tool_search x Bedrock (Invoke). + +HTTP-probe row. Sends a single `/v1/messages` request whose `tools` +array includes a `tool_search_tool_regex_20251119` discovery tool, and +asserts the proxy round-trips it to the upstream without a 400. This +verifies LiteLLM's tool-search beta-header translation +(`advanced-tool-use-2025-11-20` for Anthropic-shape providers, +`tool-search-tool-2025-10-19` for Vertex/Bedrock) survives end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_search/test_bedrock_invoke.py + ^^^^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider + +Why HTTP probe instead of CLI / MCP fan-out: + +Real Claude Code activates tool_search by registering >N MCP tools and +relying on the model's internal heuristic to call the discovery tool +before any user tool. That setup requires standing up a stub MCP +server that exposes 50+ tool stubs and depends on Claude Code's +auto-deferral heuristic continuing to fire at today's tool count -- +both of which break silently when Claude Code's threshold changes +between releases. + +The bugs LiteLLM has actually shipped fixes for in this area +(2.1.117, 2.1.72, 2.1.70 in the Claude Code release notes) are +beta-header translation and proxy-side type recognition, not MCP +fan-out behavior. An HTTP probe hits exactly that surface: the +request goes out with a `tool_search_tool_regex_20251119` tool type, +the proxy is responsible for attaching the per-provider beta header +and forwarding, and the upstream either accepts or 400s. A red cell +here is always a proxy-side regression, not a flaky model-behavior +artifact. + +Three Claude tiers are probed in sequence (count is too low to be +worth the parallelism overhead, and HTTP probes don't compete for +the proxy's `--num-workers` slots the way CLI subprocess runs do). +The matrix's "all three must pass" rule still applies via the +per-cell aggregator. +""" + +from __future__ import annotations + +import os + +import pytest + +from claude_code.http_probe import ( + assert_tool_search_shape, + probe_tool_search, +) + +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-opus-4-7-bedrock-invoke", +] + + +def test_tool_search_bedrock_invoke(compat_result): + """Probe `/v1/messages` with a `tool_search_tool_regex_20251119` + tool and assert the proxy + upstream accept it for every Bedrock (Invoke) + tier.""" + 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, + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + result = probe_tool_search(base_url=base_url, api_key=api_key, model=model) + shape_error = assert_tool_search_shape(result) + if shape_error is not None: + error = f"[{model}] tool_search probe failed: {shape_error}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_search/test_vertex_ai.py b/tests/e2e/claude_code/tool_search/test_vertex_ai.py new file mode 100644 index 00000000000..f91400b1817 --- /dev/null +++ b/tests/e2e/claude_code/tool_search/test_vertex_ai.py @@ -0,0 +1,99 @@ +"""tool_search x Vertex AI. + +HTTP-probe row. Sends a single `/v1/messages` request whose `tools` +array includes a `tool_search_tool_regex_20251119` discovery tool, and +asserts the proxy round-trips it to the upstream without a 400. This +verifies LiteLLM's tool-search beta-header translation +(`advanced-tool-use-2025-11-20` for Anthropic-shape providers, +`tool-search-tool-2025-10-19` for Vertex/Bedrock) survives end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_search/test_vertex_ai.py + ^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider + +Why HTTP probe instead of CLI / MCP fan-out: + +Real Claude Code activates tool_search by registering >N MCP tools and +relying on the model's internal heuristic to call the discovery tool +before any user tool. That setup requires standing up a stub MCP +server that exposes 50+ tool stubs and depends on Claude Code's +auto-deferral heuristic continuing to fire at today's tool count -- +both of which break silently when Claude Code's threshold changes +between releases. + +The bugs LiteLLM has actually shipped fixes for in this area +(2.1.117, 2.1.72, 2.1.70 in the Claude Code release notes) are +beta-header translation and proxy-side type recognition, not MCP +fan-out behavior. An HTTP probe hits exactly that surface: the +request goes out with a `tool_search_tool_regex_20251119` tool type, +the proxy is responsible for attaching the per-provider beta header +and forwarding, and the upstream either accepts or 400s. A red cell +here is always a proxy-side regression, not a flaky model-behavior +artifact. + +Three Claude tiers are probed in sequence (count is too low to be +worth the parallelism overhead, and HTTP probes don't compete for +the proxy's `--num-workers` slots the way CLI subprocess runs do). +The matrix's "all three must pass" rule still applies via the +per-cell aggregator. +""" + +from __future__ import annotations + +import os + +import pytest + +from claude_code.http_probe import ( + assert_tool_search_shape, + probe_tool_search, +) + +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-opus-4-7-vertex", +] + + +def test_tool_search_vertex_ai(compat_result): + """Probe `/v1/messages` with a `tool_search_tool_regex_20251119` + tool and assert the proxy + upstream accept it for every Vertex AI + tier.""" + 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, + ) + + failures = [] + for model in VERTEX_AI_MODELS: + result = probe_tool_search(base_url=base_url, api_key=api_key, model=model) + shape_error = assert_tool_search_shape(result) + if shape_error is not None: + error = f"[{model}] tool_search probe failed: {shape_error}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_use/__init__.py b/tests/e2e/claude_code/tool_use/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/tool_use/test_anthropic.py b/tests/e2e/claude_code/tool_use/test_anthropic.py new file mode 100644 index 00000000000..7d2aa4be683 --- /dev/null +++ b/tests/e2e/claude_code/tool_use/test_anthropic.py @@ -0,0 +1,129 @@ +"""tool_use x Anthropic. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Anthropic, ask Claude to invoke a built-in tool (`Bash`), and assert +that the upstream returned a `tool_use` content block. This proves the +proxy preserves Claude Code's tool-call wire shape end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_use/test_anthropic.py + ^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7", +] + +# Built-in tool-use prompt: ask Claude to use the `Bash` tool. The CLI +# allow-lists the tool via `--allowed-tools` so the run completes without +# an interactive permission prompt. +TOOL_USE_PROMPT = ( + "Use the Bash tool to run the command `echo pong` and report what it printed." +) +# Restrict the Bash tool to the exact command `echo pong` and put the +# CLI in `dontAsk` mode so anything else the model returns is auto- +# denied instead of executed. `dontAsk` mode in headless `--print` mode +# only runs tools matching an explicit `allow` rule (plus the built-in +# read-only set), so a compromised provider response cannot turn the +# `Bash` allowlist into arbitrary host execution (which would expose +# `docker inspect compat-proxy` / `/proc//environ` and +# thereby provider credentials living in the proxy container). +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", +] + + +def _has_tool_use_event(events: Sequence[Mapping[str, Any]]) -> bool: + """Walk the stream-json events and return True if any assistant + message included a `tool_use` content block.""" + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def test_tool_use_anthropic(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert a + tool call was emitted on the wire.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=ANTHROPIC_MODELS, + prompt=TOOL_USE_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=TOOL_USE_ARGS, + ) + + failures = [] + for model in ANTHROPIC_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_tool_use_event(outcome.events): + error = ( + f"[{model}] no tool_use content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_use/test_azure.py b/tests/e2e/claude_code/tool_use/test_azure.py new file mode 100644 index 00000000000..484f50a5508 --- /dev/null +++ b/tests/e2e/claude_code/tool_use/test_azure.py @@ -0,0 +1,123 @@ +"""tool_use x Azure (Microsoft Foundry). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to Anthropic's models hosted in Microsoft Foundry on +Azure, ask Claude to invoke a built-in tool (`Bash`), and assert that +the upstream returned a `tool_use` content block. + +Foundry's Anthropic deployments support function/tool calling +identically to anthropic.com; LiteLLM's `azure_ai/claude-*` route +inherits the full Anthropic tool-use transformation. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_use/test_azure.py + ^^^^^^^^ ^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-azure", +] + +TOOL_USE_PROMPT = ( + "Use the Bash tool to run the command `echo pong` and report what it printed." +) +# Bash is restricted to the exact command `echo pong` + `dontAsk` +# permission mode; see `tool_use/test_anthropic.py` for the security +# rationale. +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", +] + + +def _has_tool_use_event(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def test_tool_use_azure(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert a + tool call was emitted on the wire.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=AZURE_MODELS, + prompt=TOOL_USE_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=TOOL_USE_ARGS, + ) + + failures = [] + for model in AZURE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_tool_use_event(outcome.events): + error = ( + f"[{model}] no tool_use content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_use/test_bedrock_converse.py b/tests/e2e/claude_code/tool_use/test_bedrock_converse.py new file mode 100644 index 00000000000..7d1b58fce90 --- /dev/null +++ b/tests/e2e/claude_code/tool_use/test_bedrock_converse.py @@ -0,0 +1,119 @@ +"""tool_use x Bedrock (Converse). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the unified `Converse` API path, ask +Claude to invoke a built-in tool (`Bash`), and assert that the upstream +returned a `tool_use` content block. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_use/test_bedrock_converse.py + ^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-bedrock-converse", +] + +TOOL_USE_PROMPT = ( + "Use the Bash tool to run the command `echo pong` and report what it printed." +) +# Bash is restricted to the exact command `echo pong` + `dontAsk` +# permission mode; see `tool_use/test_anthropic.py` for the security +# rationale. +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", +] + + +def _has_tool_use_event(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def test_tool_use_bedrock_converse(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert a + tool call was emitted on the wire.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_CONVERSE_MODELS, + prompt=TOOL_USE_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=TOOL_USE_ARGS, + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_tool_use_event(outcome.events): + error = ( + f"[{model}] no tool_use content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_use/test_bedrock_invoke.py b/tests/e2e/claude_code/tool_use/test_bedrock_invoke.py new file mode 100644 index 00000000000..7d2b72b951d --- /dev/null +++ b/tests/e2e/claude_code/tool_use/test_bedrock_invoke.py @@ -0,0 +1,119 @@ +"""tool_use x Bedrock (Invoke). + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to AWS Bedrock via the legacy `InvokeModel` API path, +ask Claude to invoke a built-in tool (`Bash`), and assert that the +upstream returned a `tool_use` content block. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_use/test_bedrock_invoke.py + ^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-bedrock-invoke", +] + +TOOL_USE_PROMPT = ( + "Use the Bash tool to run the command `echo pong` and report what it printed." +) +# Bash is restricted to the exact command `echo pong` + `dontAsk` +# permission mode; see `tool_use/test_anthropic.py` for the security +# rationale. +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", +] + + +def _has_tool_use_event(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def test_tool_use_bedrock_invoke(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert a + tool call was emitted on the wire.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_INVOKE_MODELS, + prompt=TOOL_USE_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=TOOL_USE_ARGS, + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_tool_use_event(outcome.events): + error = ( + f"[{model}] no tool_use content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_use/test_vertex_ai.py b/tests/e2e/claude_code/tool_use/test_vertex_ai.py new file mode 100644 index 00000000000..0a8ecc9f7a7 --- /dev/null +++ b/tests/e2e/claude_code/tool_use/test_vertex_ai.py @@ -0,0 +1,119 @@ +"""tool_use x Vertex AI. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +Claude requests to Anthropic's models on Google Cloud Vertex AI, ask +Claude to invoke a built-in tool (`Bash`), and assert that the upstream +returned a `tool_use` content block. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_use/test_vertex_ai.py + ^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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" + +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + +TOOL_USE_PROMPT = ( + "Use the Bash tool to run the command `echo pong` and report what it printed." +) +# Bash is restricted to the exact command `echo pong` + `dontAsk` +# permission mode; see `tool_use/test_anthropic.py` for the security +# rationale. +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", +] + + +def _has_tool_use_event(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def test_tool_use_vertex_ai(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert a + tool call was emitted on the wire.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=VERTEX_AI_MODELS, + prompt=TOOL_USE_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=TOOL_USE_ARGS, + ) + + failures = [] + for model in VERTEX_AI_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_tool_use_event(outcome.events): + error = ( + f"[{model}] no tool_use content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_use_streaming/__init__.py b/tests/e2e/claude_code/tool_use_streaming/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/tool_use_streaming/test_anthropic.py b/tests/e2e/claude_code/tool_use_streaming/test_anthropic.py new file mode 100644 index 00000000000..9aa94c89241 --- /dev/null +++ b/tests/e2e/claude_code/tool_use_streaming/test_anthropic.py @@ -0,0 +1,165 @@ +"""tool_use_streaming x Anthropic. + +Drive the real `claude` CLI in headless `--output-format stream-json` +mode (with `--include-partial-messages`) against a running LiteLLM +proxy that routes to Anthropic, ask Claude to invoke a built-in tool +(`Bash`), and assert that the upstream (a) emitted a `tool_use` content +block and (b) actually streamed the tool input incrementally — i.e. +`input_json_delta` stream events were observed for the block. + +This is the "fine-grained tool streaming" path. Historically gateways +break it in two ways: they either buffer/collapse the streamed tool +input into a single complete block (no `input_json_delta` records +reach the client) or they strip the +`fine-grained-tool-streaming-2025-05-14` beta header and the upstream +falls back to non-streaming tool_use. Both regressions are caught by +the assertions below. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_use_streaming/test_anthropic.py + ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7", +] + +# Same shape as the non-streaming `tool_use` cell: ask Claude to call +# the built-in `Bash` tool. `--include-partial-messages` surfaces the +# raw SSE records as `stream_event` entries in the stream-json output, +# which is the wire-level signal for whether the proxy preserved +# incremental `input_json_delta` events for the tool_use block. +TOOL_USE_PROMPT = ( + "Use the Bash tool to run the command `echo pong` and report what it printed." +) +# Bash is restricted to the exact command `echo pong` + `dontAsk` +# permission mode; see `tool_use/test_anthropic.py` for the security +# rationale. +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", + "--include-partial-messages", +] + + +def _has_tool_use_event(events: Sequence[Mapping[str, Any]]) -> bool: + """Walk the stream-json events and return True if any assistant + message included a `tool_use` content block.""" + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def _count_input_json_deltas(events: Sequence[Mapping[str, Any]]) -> int: + """Count `input_json_delta` records among the `stream_event` + entries. Zero means the proxy collapsed the streamed tool input + into a single complete block instead of forwarding the incremental + deltas the upstream emitted.""" + inner_events = ( + event.get("event") for event in events if event.get("type") == "stream_event" + ) + return sum( + 1 + for inner in inner_events + if isinstance(inner, Mapping) + and inner.get("type") == "content_block_delta" + and isinstance(inner.get("delta"), Mapping) + and inner["delta"].get("type") == "input_json_delta" + ) + + +def test_tool_use_streaming_anthropic(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert the + proxy preserves fine-grained tool streaming end-to-end.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=ANTHROPIC_MODELS, + prompt=TOOL_USE_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=TOOL_USE_ARGS, + ) + + failures = [] + for model in ANTHROPIC_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_tool_use_event(outcome.events): + error = ( + f"[{model}] no tool_use content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _count_input_json_deltas(outcome.events) == 0: + error = ( + f"[{model}] no input_json_delta stream events observed; proxy " + f"likely buffered the tool input into a complete block or " + f"stripped fine-grained tool streaming" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_use_streaming/test_azure.py b/tests/e2e/claude_code/tool_use_streaming/test_azure.py new file mode 100644 index 00000000000..c73062b72cd --- /dev/null +++ b/tests/e2e/claude_code/tool_use_streaming/test_azure.py @@ -0,0 +1,148 @@ +"""tool_use_streaming x Microsoft Foundry (Azure). + +Drive the real `claude` CLI in headless `--output-format stream-json` +mode against a running LiteLLM proxy that routes Claude requests to +Microsoft Foundry's Anthropic deployments on Azure, ask Claude to +invoke a built-in tool (`Bash`), and assert that the upstream (a) +emitted a `tool_use` content block and (b) actually streamed events +incrementally. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_use_streaming/test_azure.py + ^^^^^^^^^^^^^^^^^^ ^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-azure", +] + +TOOL_USE_PROMPT = ( + "Use the Bash tool to run the command `echo pong` and report what it printed." +) +# Bash is restricted to the exact command `echo pong` + `dontAsk` +# permission mode; see `tool_use/test_anthropic.py` for the security +# rationale. +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", + "--include-partial-messages", +] + + +def _has_tool_use_event(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def _count_input_json_deltas(events: Sequence[Mapping[str, Any]]) -> int: + """Count `input_json_delta` records among the `stream_event` + entries. Zero means the proxy collapsed the streamed tool input + into a single complete block instead of forwarding the incremental + deltas the upstream emitted.""" + inner_events = ( + event.get("event") for event in events if event.get("type") == "stream_event" + ) + return sum( + 1 + for inner in inner_events + if isinstance(inner, Mapping) + and inner.get("type") == "content_block_delta" + and isinstance(inner.get("delta"), Mapping) + and inner["delta"].get("type") == "input_json_delta" + ) + + +def test_tool_use_streaming_azure(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=AZURE_MODELS, + prompt=TOOL_USE_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=TOOL_USE_ARGS, + ) + + failures = [] + for model in AZURE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_tool_use_event(outcome.events): + error = ( + f"[{model}] no tool_use content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _count_input_json_deltas(outcome.events) == 0: + error = ( + f"[{model}] no input_json_delta stream events observed; proxy " + f"likely buffered the tool input into a complete block or " + f"stripped fine-grained tool streaming" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_use_streaming/test_bedrock_converse.py b/tests/e2e/claude_code/tool_use_streaming/test_bedrock_converse.py new file mode 100644 index 00000000000..3642551c7c3 --- /dev/null +++ b/tests/e2e/claude_code/tool_use_streaming/test_bedrock_converse.py @@ -0,0 +1,154 @@ +"""tool_use_streaming x Bedrock (Converse). + +Drive the real `claude` CLI in headless `--output-format stream-json` +mode against a running LiteLLM proxy that routes Claude requests to +AWS Bedrock via the `Converse` API (`ConverseStream`), ask Claude to +invoke a built-in tool (`Bash`), and assert that the upstream (a) +emitted a `tool_use` content block and (b) actually streamed events +incrementally. + +Bedrock Converse has its own tool-streaming envelope (`toolUse` blocks +with `delta` chunks); this cell catches gateway regressions where the +proxy buffers the response or fails to translate Converse's streaming +envelope back to the Anthropic `message_*` event shape Claude Code +expects. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_use_streaming/test_bedrock_converse.py + ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-bedrock-converse", +] + +TOOL_USE_PROMPT = ( + "Use the Bash tool to run the command `echo pong` and report what it printed." +) +# Bash is restricted to the exact command `echo pong` + `dontAsk` +# permission mode; see `tool_use/test_anthropic.py` for the security +# rationale. +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", + "--include-partial-messages", +] + + +def _has_tool_use_event(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def _count_input_json_deltas(events: Sequence[Mapping[str, Any]]) -> int: + """Count `input_json_delta` records among the `stream_event` + entries. Zero means the proxy collapsed the streamed tool input + into a single complete block instead of forwarding the incremental + deltas the upstream emitted.""" + inner_events = ( + event.get("event") for event in events if event.get("type") == "stream_event" + ) + return sum( + 1 + for inner in inner_events + if isinstance(inner, Mapping) + and inner.get("type") == "content_block_delta" + and isinstance(inner.get("delta"), Mapping) + and inner["delta"].get("type") == "input_json_delta" + ) + + +def test_tool_use_streaming_bedrock_converse(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_CONVERSE_MODELS, + prompt=TOOL_USE_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=TOOL_USE_ARGS, + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_tool_use_event(outcome.events): + error = ( + f"[{model}] no tool_use content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _count_input_json_deltas(outcome.events) == 0: + error = ( + f"[{model}] no input_json_delta stream events observed; proxy " + f"likely buffered the tool input into a complete block or " + f"stripped fine-grained tool streaming" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_use_streaming/test_bedrock_invoke.py b/tests/e2e/claude_code/tool_use_streaming/test_bedrock_invoke.py new file mode 100644 index 00000000000..af4689b2847 --- /dev/null +++ b/tests/e2e/claude_code/tool_use_streaming/test_bedrock_invoke.py @@ -0,0 +1,152 @@ +"""tool_use_streaming x Bedrock (Invoke). + +Drive the real `claude` CLI in headless `--output-format stream-json` +mode against a running LiteLLM proxy that routes Claude requests to +AWS Bedrock via the legacy `InvokeModel` API path, ask Claude to invoke +a built-in tool (`Bash`), and assert that the upstream (a) emitted a +`tool_use` content block and (b) actually streamed events incrementally. + +Bedrock InvokeModel surfaces tool-streaming via `InvokeModelWithResponseStream`; +this cell catches gateway regressions where the proxy buffers the +response or fails to translate the streaming envelope to Anthropic +`message_*` event shape. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_use_streaming/test_bedrock_invoke.py + ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-bedrock-invoke", +] + +TOOL_USE_PROMPT = ( + "Use the Bash tool to run the command `echo pong` and report what it printed." +) +# Bash is restricted to the exact command `echo pong` + `dontAsk` +# permission mode; see `tool_use/test_anthropic.py` for the security +# rationale. +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", + "--include-partial-messages", +] + + +def _has_tool_use_event(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def _count_input_json_deltas(events: Sequence[Mapping[str, Any]]) -> int: + """Count `input_json_delta` records among the `stream_event` + entries. Zero means the proxy collapsed the streamed tool input + into a single complete block instead of forwarding the incremental + deltas the upstream emitted.""" + inner_events = ( + event.get("event") for event in events if event.get("type") == "stream_event" + ) + return sum( + 1 + for inner in inner_events + if isinstance(inner, Mapping) + and inner.get("type") == "content_block_delta" + and isinstance(inner.get("delta"), Mapping) + and inner["delta"].get("type") == "input_json_delta" + ) + + +def test_tool_use_streaming_bedrock_invoke(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_INVOKE_MODELS, + prompt=TOOL_USE_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=TOOL_USE_ARGS, + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_tool_use_event(outcome.events): + error = ( + f"[{model}] no tool_use content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _count_input_json_deltas(outcome.events) == 0: + error = ( + f"[{model}] no input_json_delta stream events observed; proxy " + f"likely buffered the tool input into a complete block or " + f"stripped fine-grained tool streaming" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/tool_use_streaming/test_vertex_ai.py b/tests/e2e/claude_code/tool_use_streaming/test_vertex_ai.py new file mode 100644 index 00000000000..19ef9a4e90e --- /dev/null +++ b/tests/e2e/claude_code/tool_use_streaming/test_vertex_ai.py @@ -0,0 +1,151 @@ +"""tool_use_streaming x Vertex AI. + +Drive the real `claude` CLI in headless `--output-format stream-json` +mode against a running LiteLLM proxy that routes Claude requests to +GCP Vertex AI, ask Claude to invoke a built-in tool (`Bash`), and +assert that the upstream (a) emitted a `tool_use` content block and +(b) actually streamed events incrementally. + +Vertex AI exposes Anthropic models via `:streamRawPredict`; this cell +catches gateway regressions where the proxy buffers the response or +strips the streaming beta header on the way to Vertex. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/tool_use_streaming/test_vertex_ai.py + ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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" + +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + +TOOL_USE_PROMPT = ( + "Use the Bash tool to run the command `echo pong` and report what it printed." +) +# Bash is restricted to the exact command `echo pong` + `dontAsk` +# permission mode; see `tool_use/test_anthropic.py` for the security +# rationale. +TOOL_USE_ARGS = [ + "--allowed-tools", + "Bash(echo pong)", + "--permission-mode", + "dontAsk", + "--include-partial-messages", +] + + +def _has_tool_use_event(events: Sequence[Mapping[str, Any]]) -> bool: + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def _count_input_json_deltas(events: Sequence[Mapping[str, Any]]) -> int: + """Count `input_json_delta` records among the `stream_event` + entries. Zero means the proxy collapsed the streamed tool input + into a single complete block instead of forwarding the incremental + deltas the upstream emitted.""" + inner_events = ( + event.get("event") for event in events if event.get("type") == "stream_event" + ) + return sum( + 1 + for inner in inner_events + if isinstance(inner, Mapping) + and inner.get("type") == "content_block_delta" + and isinstance(inner.get("delta"), Mapping) + and inner["delta"].get("type") == "input_json_delta" + ) + + +def test_tool_use_streaming_vertex_ai(compat_result): + 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 + ) + + outcomes = run_claude_models_parallel( + models=VERTEX_AI_MODELS, + prompt=TOOL_USE_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=TOOL_USE_ARGS, + ) + + failures = [] + for model in VERTEX_AI_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_tool_use_event(outcome.events): + error = ( + f"[{model}] no tool_use content block observed in stream-json events" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if _count_input_json_deltas(outcome.events) == 0: + error = ( + f"[{model}] no input_json_delta stream events observed; proxy " + f"likely buffered the tool input into a complete block or " + f"stripped fine-grained tool streaming" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/vision/__init__.py b/tests/e2e/claude_code/vision/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/vision/test_anthropic.py b/tests/e2e/claude_code/vision/test_anthropic.py new file mode 100644 index 00000000000..650940248ea --- /dev/null +++ b/tests/e2e/claude_code/vision/test_anthropic.py @@ -0,0 +1,142 @@ +"""vision x Anthropic. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Anthropic, attach a small image as an inline base64 `image` content +block via the CLI's `--input-format stream-json` mode, and assert that +the upstream produces a non-empty reply. This proves the proxy +preserves Claude Code's multimodal content blocks end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/vision/test_anthropic.py + ^^^^^^ ^^^^^^^^^ + feature_id provider + +Why stream-json input rather than `--image `: the claude CLI +dropped `--image` in 2.x. Image attachments are now driven via either +the Files API (server-uploaded blobs referenced by file_id) or by +sending an Anthropic-shaped user message through stdin. We use the +latter because it requires no upstream pre-upload — the test stays +hermetic and the wire shape (an `image` content block) is exactly what +the proxy must preserve. +""" + +from __future__ import annotations + +import json +import os + +import pytest + +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-opus-4-7", +] + +# Minimal 1x1 red PNG, base64-encoded. We embed it directly as the +# `image` content block's source — no temp file or Files API upload +# needed, the test stays hermetic. +RED_PIXEL_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8" + "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) + +VISION_PROMPT = ( + "What single color do you see in the attached image? Answer in one word." +) + + +def _build_stdin_input() -> str: + """Build the newline-delimited JSON payload for `--input-format stream-json`. + + The CLI consumes a stream of `user` events whose `message.content` is + a list of Anthropic content blocks. A single user event with one + text block + one image block is enough to exercise the multimodal + code path. + """ + user_event = { + "type": "user", + "message": { + "role": "user", + "content": [ + {"type": "text", "text": VISION_PROMPT}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": RED_PIXEL_PNG_B64, + }, + }, + ], + }, + } + return json.dumps(user_event) + "\n" + + +def test_vision_anthropic(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with an image + attached via stream-json input and assert a non-empty reply.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=ANTHROPIC_MODELS, + # When using --input-format stream-json the CLI rejects a + # positional prompt; the prompt + image come in via stdin. + prompt=None, + base_url=base_url, + api_key=api_key, + extra_args=["--input-format", "stream-json"], + stdin_input=_build_stdin_input(), + ) + + failures = [] + for model in ANTHROPIC_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not outcome.text.strip(): + error = f"[{model}] claude returned empty assistant text on a vision prompt" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/vision/test_azure.py b/tests/e2e/claude_code/vision/test_azure.py new file mode 100644 index 00000000000..3b03c0f2b35 --- /dev/null +++ b/tests/e2e/claude_code/vision/test_azure.py @@ -0,0 +1,142 @@ +"""vision x Azure. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Azure, attach a small image as an inline base64 `image` content +block via the CLI's `--input-format stream-json` mode, and assert that +the upstream produces a non-empty reply. This proves the proxy +preserves Claude Code's multimodal content blocks end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/vision/test_azure.py + ^^^^^^ ^^^^^^^^^ + feature_id provider + +Why stream-json input rather than `--image `: the claude CLI +dropped `--image` in 2.x. Image attachments are now driven via either +the Files API (server-uploaded blobs referenced by file_id) or by +sending an Anthropic-shaped user message through stdin. We use the +latter because it requires no upstream pre-upload — the test stays +hermetic and the wire shape (an `image` content block) is exactly what +the proxy must preserve. +""" + +from __future__ import annotations + +import json +import os + +import pytest + +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-opus-4-7-azure", +] + +# Minimal 1x1 red PNG, base64-encoded. We embed it directly as the +# `image` content block's source — no temp file or Files API upload +# needed, the test stays hermetic. +RED_PIXEL_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8" + "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) + +VISION_PROMPT = ( + "What single color do you see in the attached image? Answer in one word." +) + + +def _build_stdin_input() -> str: + """Build the newline-delimited JSON payload for `--input-format stream-json`. + + The CLI consumes a stream of `user` events whose `message.content` is + a list of Anthropic content blocks. A single user event with one + text block + one image block is enough to exercise the multimodal + code path. + """ + user_event = { + "type": "user", + "message": { + "role": "user", + "content": [ + {"type": "text", "text": VISION_PROMPT}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": RED_PIXEL_PNG_B64, + }, + }, + ], + }, + } + return json.dumps(user_event) + "\n" + + +def test_vision_azure(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with an image + attached via stream-json input and assert a non-empty reply.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=AZURE_MODELS, + # When using --input-format stream-json the CLI rejects a + # positional prompt; the prompt + image come in via stdin. + prompt=None, + base_url=base_url, + api_key=api_key, + extra_args=["--input-format", "stream-json"], + stdin_input=_build_stdin_input(), + ) + + failures = [] + for model in AZURE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not outcome.text.strip(): + error = f"[{model}] claude returned empty assistant text on a vision prompt" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/vision/test_bedrock_converse.py b/tests/e2e/claude_code/vision/test_bedrock_converse.py new file mode 100644 index 00000000000..4201f9e64fc --- /dev/null +++ b/tests/e2e/claude_code/vision/test_bedrock_converse.py @@ -0,0 +1,142 @@ +"""vision x Bedrock Converse. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Bedrock Converse, attach a small image as an inline base64 `image` content +block via the CLI's `--input-format stream-json` mode, and assert that +the upstream produces a non-empty reply. This proves the proxy +preserves Claude Code's multimodal content blocks end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/vision/test_bedrock_converse.py + ^^^^^^ ^^^^^^^^^ + feature_id provider + +Why stream-json input rather than `--image `: the claude CLI +dropped `--image` in 2.x. Image attachments are now driven via either +the Files API (server-uploaded blobs referenced by file_id) or by +sending an Anthropic-shaped user message through stdin. We use the +latter because it requires no upstream pre-upload — the test stays +hermetic and the wire shape (an `image` content block) is exactly what +the proxy must preserve. +""" + +from __future__ import annotations + +import json +import os + +import pytest + +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-opus-4-7-bedrock-converse", +] + +# Minimal 1x1 red PNG, base64-encoded. We embed it directly as the +# `image` content block's source — no temp file or Files API upload +# needed, the test stays hermetic. +RED_PIXEL_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8" + "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) + +VISION_PROMPT = ( + "What single color do you see in the attached image? Answer in one word." +) + + +def _build_stdin_input() -> str: + """Build the newline-delimited JSON payload for `--input-format stream-json`. + + The CLI consumes a stream of `user` events whose `message.content` is + a list of Anthropic content blocks. A single user event with one + text block + one image block is enough to exercise the multimodal + code path. + """ + user_event = { + "type": "user", + "message": { + "role": "user", + "content": [ + {"type": "text", "text": VISION_PROMPT}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": RED_PIXEL_PNG_B64, + }, + }, + ], + }, + } + return json.dumps(user_event) + "\n" + + +def test_vision_bedrock_converse(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with an image + attached via stream-json input and assert a non-empty reply.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_CONVERSE_MODELS, + # When using --input-format stream-json the CLI rejects a + # positional prompt; the prompt + image come in via stdin. + prompt=None, + base_url=base_url, + api_key=api_key, + extra_args=["--input-format", "stream-json"], + stdin_input=_build_stdin_input(), + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not outcome.text.strip(): + error = f"[{model}] claude returned empty assistant text on a vision prompt" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/vision/test_bedrock_invoke.py b/tests/e2e/claude_code/vision/test_bedrock_invoke.py new file mode 100644 index 00000000000..d2e641f1462 --- /dev/null +++ b/tests/e2e/claude_code/vision/test_bedrock_invoke.py @@ -0,0 +1,142 @@ +"""vision x Bedrock Invoke. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Bedrock Invoke, attach a small image as an inline base64 `image` content +block via the CLI's `--input-format stream-json` mode, and assert that +the upstream produces a non-empty reply. This proves the proxy +preserves Claude Code's multimodal content blocks end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/vision/test_bedrock_invoke.py + ^^^^^^ ^^^^^^^^^ + feature_id provider + +Why stream-json input rather than `--image `: the claude CLI +dropped `--image` in 2.x. Image attachments are now driven via either +the Files API (server-uploaded blobs referenced by file_id) or by +sending an Anthropic-shaped user message through stdin. We use the +latter because it requires no upstream pre-upload — the test stays +hermetic and the wire shape (an `image` content block) is exactly what +the proxy must preserve. +""" + +from __future__ import annotations + +import json +import os + +import pytest + +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-opus-4-7-bedrock-invoke", +] + +# Minimal 1x1 red PNG, base64-encoded. We embed it directly as the +# `image` content block's source — no temp file or Files API upload +# needed, the test stays hermetic. +RED_PIXEL_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8" + "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) + +VISION_PROMPT = ( + "What single color do you see in the attached image? Answer in one word." +) + + +def _build_stdin_input() -> str: + """Build the newline-delimited JSON payload for `--input-format stream-json`. + + The CLI consumes a stream of `user` events whose `message.content` is + a list of Anthropic content blocks. A single user event with one + text block + one image block is enough to exercise the multimodal + code path. + """ + user_event = { + "type": "user", + "message": { + "role": "user", + "content": [ + {"type": "text", "text": VISION_PROMPT}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": RED_PIXEL_PNG_B64, + }, + }, + ], + }, + } + return json.dumps(user_event) + "\n" + + +def test_vision_bedrock_invoke(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with an image + attached via stream-json input and assert a non-empty reply.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_INVOKE_MODELS, + # When using --input-format stream-json the CLI rejects a + # positional prompt; the prompt + image come in via stdin. + prompt=None, + base_url=base_url, + api_key=api_key, + extra_args=["--input-format", "stream-json"], + stdin_input=_build_stdin_input(), + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not outcome.text.strip(): + error = f"[{model}] claude returned empty assistant text on a vision prompt" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/vision/test_vertex_ai.py b/tests/e2e/claude_code/vision/test_vertex_ai.py new file mode 100644 index 00000000000..a39ef1a34b7 --- /dev/null +++ b/tests/e2e/claude_code/vision/test_vertex_ai.py @@ -0,0 +1,142 @@ +"""vision x Vertex AI. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Vertex AI, attach a small image as an inline base64 `image` content +block via the CLI's `--input-format stream-json` mode, and assert that +the upstream produces a non-empty reply. This proves the proxy +preserves Claude Code's multimodal content blocks end-to-end. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/vision/test_vertex_ai.py + ^^^^^^ ^^^^^^^^^ + feature_id provider + +Why stream-json input rather than `--image `: the claude CLI +dropped `--image` in 2.x. Image attachments are now driven via either +the Files API (server-uploaded blobs referenced by file_id) or by +sending an Anthropic-shaped user message through stdin. We use the +latter because it requires no upstream pre-upload — the test stays +hermetic and the wire shape (an `image` content block) is exactly what +the proxy must preserve. +""" + +from __future__ import annotations + +import json +import os + +import pytest + +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" + +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + +# Minimal 1x1 red PNG, base64-encoded. We embed it directly as the +# `image` content block's source — no temp file or Files API upload +# needed, the test stays hermetic. +RED_PIXEL_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8" + "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) + +VISION_PROMPT = ( + "What single color do you see in the attached image? Answer in one word." +) + + +def _build_stdin_input() -> str: + """Build the newline-delimited JSON payload for `--input-format stream-json`. + + The CLI consumes a stream of `user` events whose `message.content` is + a list of Anthropic content blocks. A single user event with one + text block + one image block is enough to exercise the multimodal + code path. + """ + user_event = { + "type": "user", + "message": { + "role": "user", + "content": [ + {"type": "text", "text": VISION_PROMPT}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": RED_PIXEL_PNG_B64, + }, + }, + ], + }, + } + return json.dumps(user_event) + "\n" + + +def test_vision_vertex_ai(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy with an image + attached via stream-json input and assert a non-empty reply.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=VERTEX_AI_MODELS, + # When using --input-format stream-json the CLI rejects a + # positional prompt; the prompt + image come in via stdin. + prompt=None, + base_url=base_url, + api_key=api_key, + extra_args=["--input-format", "stream-json"], + stdin_input=_build_stdin_input(), + ) + + failures = [] + for model in VERTEX_AI_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not outcome.text.strip(): + error = f"[{model}] claude returned empty assistant text on a vision prompt" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/web_search/__init__.py b/tests/e2e/claude_code/web_search/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/claude_code/web_search/test_anthropic.py b/tests/e2e/claude_code/web_search/test_anthropic.py new file mode 100644 index 00000000000..b8fa806f923 --- /dev/null +++ b/tests/e2e/claude_code/web_search/test_anthropic.py @@ -0,0 +1,146 @@ +"""web_search x Anthropic. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Anthropic, allow the built-in `WebSearch` tool, ask a question that +requires fresh web data, and assert that the upstream emitted a +`tool_use` block calling `WebSearch` — proving the proxy preserves +Claude Code's tool definitions and the upstream's tool-use response +end-to-end. + +Note: Claude Code's `WebSearch` is a *client-side* tool (the CLI +executes the search itself and feeds the result back as a `tool_result` +block), so the wire shape is `tool_use` with `name="WebSearch"` rather +than the Anthropic-managed `server_tool_use` / `web_search_tool_result` +blocks (which only appear when the request includes the +`web_search_20250305` server tool definition — something the CLI does +not currently inject). A regression where the proxy strips the +`WebSearch` tool from the request or drops the `tool_use` block from +the response will break this assertion. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/web_search/test_anthropic.py + ^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7", +] + +# A prompt the model cannot answer from training data alone — it forces +# the model to actually hit the web_search server tool rather than +# replying from memory. We pick "this week" as the freshness anchor +# because it's stable across long-running test schedules without +# pinning to a specific date that would go stale. +WEB_SEARCH_PROMPT = ( + "Use web search to find a news headline published this week about " + "Anthropic. Reply with one sentence summarizing what you found." +) +# Allow only WebSearch so the model has no fallback path: if the proxy +# strips the server tool, the run will fail loudly rather than silently +# answering from training data via a different tool. +WEB_SEARCH_ARGS = ["--allowed-tools", "WebSearch"] + +# The CLI tool name surfaced as `tool_use.name` when WebSearch fires. +WEB_SEARCH_TOOL_NAME = "WebSearch" + + +def _has_web_search_tool_use(events: Sequence[Mapping[str, Any]]) -> bool: + """Walk the stream-json events and return True if any assistant + message included a `tool_use` block calling `WebSearch`.""" + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if ( + block.get("type") == "tool_use" + and block.get("name") == WEB_SEARCH_TOOL_NAME + ): + return True + return False + + +def test_web_search_anthropic(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert the + upstream emitted a `tool_use` block calling `WebSearch`, proving + the proxy preserved both the request-side tool definition and the + response-side tool_use block.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=ANTHROPIC_MODELS, + prompt=WEB_SEARCH_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=WEB_SEARCH_ARGS, + ) + + failures = [] + for model in ANTHROPIC_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_web_search_tool_use(outcome.events): + error = ( + f"[{model}] no `tool_use` block with name=WebSearch observed; " + "the proxy may have stripped the WebSearch tool definition from " + "the request or the tool_use block from the response" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/web_search/test_azure.py b/tests/e2e/claude_code/web_search/test_azure.py new file mode 100644 index 00000000000..e70dc848dcf --- /dev/null +++ b/tests/e2e/claude_code/web_search/test_azure.py @@ -0,0 +1,146 @@ +"""web_search x Azure. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Azure, allow the built-in `WebSearch` tool, ask a question that +requires fresh web data, and assert that the upstream emitted a +`tool_use` block calling `WebSearch` — proving the proxy preserves +Claude Code's tool definitions and the upstream's tool-use response +end-to-end. + +Note: Claude Code's `WebSearch` is a *client-side* tool (the CLI +executes the search itself and feeds the result back as a `tool_result` +block), so the wire shape is `tool_use` with `name="WebSearch"` rather +than the Anthropic-managed `server_tool_use` / `web_search_tool_result` +blocks (which only appear when the request includes the +`web_search_20250305` server tool definition — something the CLI does +not currently inject). A regression where the proxy strips the +`WebSearch` tool from the request or drops the `tool_use` block from +the response will break this assertion. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/web_search/test_azure.py + ^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-azure", +] + +# A prompt the model cannot answer from training data alone — it forces +# the model to actually hit the web_search server tool rather than +# replying from memory. We pick "this week" as the freshness anchor +# because it's stable across long-running test schedules without +# pinning to a specific date that would go stale. +WEB_SEARCH_PROMPT = ( + "Use web search to find a news headline published this week about " + "Anthropic. Reply with one sentence summarizing what you found." +) +# Allow only WebSearch so the model has no fallback path: if the proxy +# strips the server tool, the run will fail loudly rather than silently +# answering from training data via a different tool. +WEB_SEARCH_ARGS = ["--allowed-tools", "WebSearch"] + +# The CLI tool name surfaced as `tool_use.name` when WebSearch fires. +WEB_SEARCH_TOOL_NAME = "WebSearch" + + +def _has_web_search_tool_use(events: Sequence[Mapping[str, Any]]) -> bool: + """Walk the stream-json events and return True if any assistant + message included a `tool_use` block calling `WebSearch`.""" + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if ( + block.get("type") == "tool_use" + and block.get("name") == WEB_SEARCH_TOOL_NAME + ): + return True + return False + + +def test_web_search_azure(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert the + upstream emitted a `tool_use` block calling `WebSearch`, proving + the proxy preserved both the request-side tool definition and the + response-side tool_use block.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=AZURE_MODELS, + prompt=WEB_SEARCH_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=WEB_SEARCH_ARGS, + ) + + failures = [] + for model in AZURE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_web_search_tool_use(outcome.events): + error = ( + f"[{model}] no `tool_use` block with name=WebSearch observed; " + "the proxy may have stripped the WebSearch tool definition from " + "the request or the tool_use block from the response" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/web_search/test_bedrock_converse.py b/tests/e2e/claude_code/web_search/test_bedrock_converse.py new file mode 100644 index 00000000000..cbeea03df40 --- /dev/null +++ b/tests/e2e/claude_code/web_search/test_bedrock_converse.py @@ -0,0 +1,146 @@ +"""web_search x Bedrock Converse. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Bedrock Converse, allow the built-in `WebSearch` tool, ask a question that +requires fresh web data, and assert that the upstream emitted a +`tool_use` block calling `WebSearch` — proving the proxy preserves +Claude Code's tool definitions and the upstream's tool-use response +end-to-end. + +Note: Claude Code's `WebSearch` is a *client-side* tool (the CLI +executes the search itself and feeds the result back as a `tool_result` +block), so the wire shape is `tool_use` with `name="WebSearch"` rather +than the Anthropic-managed `server_tool_use` / `web_search_tool_result` +blocks (which only appear when the request includes the +`web_search_20250305` server tool definition — something the CLI does +not currently inject). A regression where the proxy strips the +`WebSearch` tool from the request or drops the `tool_use` block from +the response will break this assertion. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/web_search/test_bedrock_converse.py + ^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-bedrock-converse", +] + +# A prompt the model cannot answer from training data alone — it forces +# the model to actually hit the web_search server tool rather than +# replying from memory. We pick "this week" as the freshness anchor +# because it's stable across long-running test schedules without +# pinning to a specific date that would go stale. +WEB_SEARCH_PROMPT = ( + "Use web search to find a news headline published this week about " + "Anthropic. Reply with one sentence summarizing what you found." +) +# Allow only WebSearch so the model has no fallback path: if the proxy +# strips the server tool, the run will fail loudly rather than silently +# answering from training data via a different tool. +WEB_SEARCH_ARGS = ["--allowed-tools", "WebSearch"] + +# The CLI tool name surfaced as `tool_use.name` when WebSearch fires. +WEB_SEARCH_TOOL_NAME = "WebSearch" + + +def _has_web_search_tool_use(events: Sequence[Mapping[str, Any]]) -> bool: + """Walk the stream-json events and return True if any assistant + message included a `tool_use` block calling `WebSearch`.""" + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if ( + block.get("type") == "tool_use" + and block.get("name") == WEB_SEARCH_TOOL_NAME + ): + return True + return False + + +def test_web_search_bedrock_converse(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert the + upstream emitted a `tool_use` block calling `WebSearch`, proving + the proxy preserved both the request-side tool definition and the + response-side tool_use block.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_CONVERSE_MODELS, + prompt=WEB_SEARCH_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=WEB_SEARCH_ARGS, + ) + + failures = [] + for model in BEDROCK_CONVERSE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_web_search_tool_use(outcome.events): + error = ( + f"[{model}] no `tool_use` block with name=WebSearch observed; " + "the proxy may have stripped the WebSearch tool definition from " + "the request or the tool_use block from the response" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/web_search/test_bedrock_invoke.py b/tests/e2e/claude_code/web_search/test_bedrock_invoke.py new file mode 100644 index 00000000000..86068e1e22b --- /dev/null +++ b/tests/e2e/claude_code/web_search/test_bedrock_invoke.py @@ -0,0 +1,146 @@ +"""web_search x Bedrock Invoke. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Bedrock Invoke, allow the built-in `WebSearch` tool, ask a question that +requires fresh web data, and assert that the upstream emitted a +`tool_use` block calling `WebSearch` — proving the proxy preserves +Claude Code's tool definitions and the upstream's tool-use response +end-to-end. + +Note: Claude Code's `WebSearch` is a *client-side* tool (the CLI +executes the search itself and feeds the result back as a `tool_result` +block), so the wire shape is `tool_use` with `name="WebSearch"` rather +than the Anthropic-managed `server_tool_use` / `web_search_tool_result` +blocks (which only appear when the request includes the +`web_search_20250305` server tool definition — something the CLI does +not currently inject). A regression where the proxy strips the +`WebSearch` tool from the request or drops the `tool_use` block from +the response will break this assertion. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/web_search/test_bedrock_invoke.py + ^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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-opus-4-7-bedrock-invoke", +] + +# A prompt the model cannot answer from training data alone — it forces +# the model to actually hit the web_search server tool rather than +# replying from memory. We pick "this week" as the freshness anchor +# because it's stable across long-running test schedules without +# pinning to a specific date that would go stale. +WEB_SEARCH_PROMPT = ( + "Use web search to find a news headline published this week about " + "Anthropic. Reply with one sentence summarizing what you found." +) +# Allow only WebSearch so the model has no fallback path: if the proxy +# strips the server tool, the run will fail loudly rather than silently +# answering from training data via a different tool. +WEB_SEARCH_ARGS = ["--allowed-tools", "WebSearch"] + +# The CLI tool name surfaced as `tool_use.name` when WebSearch fires. +WEB_SEARCH_TOOL_NAME = "WebSearch" + + +def _has_web_search_tool_use(events: Sequence[Mapping[str, Any]]) -> bool: + """Walk the stream-json events and return True if any assistant + message included a `tool_use` block calling `WebSearch`.""" + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if ( + block.get("type") == "tool_use" + and block.get("name") == WEB_SEARCH_TOOL_NAME + ): + return True + return False + + +def test_web_search_bedrock_invoke(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert the + upstream emitted a `tool_use` block calling `WebSearch`, proving + the proxy preserved both the request-side tool definition and the + response-side tool_use block.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=BEDROCK_INVOKE_MODELS, + prompt=WEB_SEARCH_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=WEB_SEARCH_ARGS, + ) + + failures = [] + for model in BEDROCK_INVOKE_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_web_search_tool_use(outcome.events): + error = ( + f"[{model}] no `tool_use` block with name=WebSearch observed; " + "the proxy may have stripped the WebSearch tool definition from " + "the request or the tool_use block from the response" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/claude_code/web_search/test_vertex_ai.py b/tests/e2e/claude_code/web_search/test_vertex_ai.py new file mode 100644 index 00000000000..a33515771f3 --- /dev/null +++ b/tests/e2e/claude_code/web_search/test_vertex_ai.py @@ -0,0 +1,146 @@ +"""web_search x Vertex AI. + +Drive the real `claude` CLI against a running LiteLLM proxy that routes +to Vertex AI, allow the built-in `WebSearch` tool, ask a question that +requires fresh web data, and assert that the upstream emitted a +`tool_use` block calling `WebSearch` — proving the proxy preserves +Claude Code's tool definitions and the upstream's tool-use response +end-to-end. + +Note: Claude Code's `WebSearch` is a *client-side* tool (the CLI +executes the search itself and feeds the result back as a `tool_result` +block), so the wire shape is `tool_use` with `name="WebSearch"` rather +than the Anthropic-managed `server_tool_use` / `web_search_tool_result` +blocks (which only appear when the request includes the +`web_search_20250305` server tool definition — something the CLI does +not currently inject). A regression where the proxy strips the +`WebSearch` tool from the request or drops the `tool_use` block from +the response will break this assertion. + +The (feature, provider) for this cell is inferred from the file path by +`tests/e2e/claude_code/conftest.py`: + + tests/e2e/claude_code/web_search/test_vertex_ai.py + ^^^^^^^^^^ ^^^^^^^^^ + feature_id provider +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, Sequence + +import pytest + +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" + +VERTEX_AI_MODELS = [ + "claude-haiku-4-5-vertex", + "claude-sonnet-4-6-vertex", + "claude-opus-4-7-vertex", +] + +# A prompt the model cannot answer from training data alone — it forces +# the model to actually hit the web_search server tool rather than +# replying from memory. We pick "this week" as the freshness anchor +# because it's stable across long-running test schedules without +# pinning to a specific date that would go stale. +WEB_SEARCH_PROMPT = ( + "Use web search to find a news headline published this week about " + "Anthropic. Reply with one sentence summarizing what you found." +) +# Allow only WebSearch so the model has no fallback path: if the proxy +# strips the server tool, the run will fail loudly rather than silently +# answering from training data via a different tool. +WEB_SEARCH_ARGS = ["--allowed-tools", "WebSearch"] + +# The CLI tool name surfaced as `tool_use.name` when WebSearch fires. +WEB_SEARCH_TOOL_NAME = "WebSearch" + + +def _has_web_search_tool_use(events: Sequence[Mapping[str, Any]]) -> bool: + """Walk the stream-json events and return True if any assistant + message included a `tool_use` block calling `WebSearch`.""" + for event in events: + if event.get("type") != "assistant": + continue + message = event.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if ( + block.get("type") == "tool_use" + and block.get("name") == WEB_SEARCH_TOOL_NAME + ): + return True + return False + + +def test_web_search_vertex_ai(compat_result): + """Drive the `claude` CLI against the LiteLLM proxy and assert the + upstream emitted a `tool_use` block calling `WebSearch`, proving + the proxy preserved both the request-side tool definition and the + response-side tool_use block.""" + 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 + ) + + outcomes = run_claude_models_parallel( + models=VERTEX_AI_MODELS, + prompt=WEB_SEARCH_PROMPT, + base_url=base_url, + api_key=api_key, + extra_args=WEB_SEARCH_ARGS, + ) + + failures = [] + for model in VERTEX_AI_MODELS: + outcome = outcomes[model] + if isinstance(outcome, ClaudeCLIError): + error = f"[{model}] {outcome}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if outcome.exit_code != 0: + error = f"[{model}] claude CLI failed: {failure_diagnostic(outcome)}" + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + if not _has_web_search_tool_use(outcome.events): + error = ( + f"[{model}] no `tool_use` block with name=WebSearch observed; " + "the proxy may have stripped the WebSearch tool definition from " + "the request or the tool_use block from the response" + ) + compat_result.add({"status": "fail", "error": error}) + failures.append(error) + continue + + compat_result.add({"status": "pass"}) + + if failures: + pytest.fail("; ".join(failures), pytrace=False) diff --git a/tests/e2e/coverage_registry/llm_conversational.yaml b/tests/e2e/coverage_registry/llm_conversational.yaml index 7360aacb916..be8a291c6fc 100644 --- a/tests/e2e/coverage_registry/llm_conversational.yaml +++ b/tests/e2e/coverage_registry/llm_conversational.yaml @@ -39,6 +39,8 @@ - {id: llm.messages.anthropic.vision.nonstream.works, module: llm, tier: P0, subject_endpoint: messages, route: anthropic, capability: vision, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Vision via Messages API"} - {id: llm.messages.anthropic.prompt_cache_5m.nonstream.works, module: llm, tier: P0, subject_endpoint: messages, route: anthropic, capability: prompt_cache_5m, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Prompt caching via Messages API"} - {id: llm.messages.anthropic.thinking.nonstream.works, module: llm, tier: P1, subject_endpoint: messages, route: anthropic, capability: thinking, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Extended thinking via Messages API"} +- {id: llm.messages.bedrock_invoke.mid_conversation_system.nonstream.cache_hit, module: llm, tier: P0, subject_endpoint: messages, route: bedrock_invoke, capability: mid_conversation_system, streaming: nonstream, assertions: [works, cache_hit], source: "llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py", rationale: "Flagged Claude 4.8+/5 must keep mid-conversation system reminders in messages; hoisting mutates the system prefix and collapses the prompt cache (#32578/#32831/#32882)", fail_before_fix: proven} +- {id: llm.messages.bedrock_invoke.mid_conversation_system.nonstream.works, module: llm, tier: P0, subject_endpoint: messages, route: bedrock_invoke, capability: mid_conversation_system, streaming: nonstream, assertions: [works], source: "llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py", rationale: "Claude <= 4.7 rejects role system inside messages; unflagged models must hoist reminders into top-level system or every Claude Code session 400s (#32831)", fail_before_fix: proven} - {id: llm.responses.openai.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Core endpoint; OpenAI Responses native"} - {id: llm.responses.openai.basic.stream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Streaming via /v1/responses"} - {id: llm.responses.openai.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: nonstream, assertions: [works, cost_logged], source: "response_api_endpoints/endpoints.py:26", rationale: "Cost logged on responses"} diff --git a/tests/e2e/coverage_registry/logging.yaml b/tests/e2e/coverage_registry/logging.yaml index 65ab8f0096f..afb6dbc964e 100644 --- a/tests/e2e/coverage_registry/logging.yaml +++ b/tests/e2e/coverage_registry/logging.yaml @@ -8,7 +8,8 @@ - {id: logging.datadog.success.exports_metric, module: logging, tier: P0, event: success, assertions: [exports_metric], exercised_on: [chat_completions, messages, embeddings], source: "integrations/datadog/datadog.py", rationale: "Powers dashboards/alerts; cardinality regressions common"} - {id: logging.datadog.failure.exports_metric, module: logging, tier: P0, event: failure, assertions: [exports_metric], exercised_on: [chat_completions], source: "integrations/datadog/datadog.py", rationale: "Failure metrics for alerting/SLO"} - {id: logging.prometheus.success.exports_metric, module: logging, tier: P0, event: success, assertions: [exports_metric], exercised_on: [chat_completions, messages, embeddings], source: "integrations/prometheus.py", rationale: "Standard OSS metrics; per-key cardinality (existing e2e)"} -- {id: logging.otel.success.exports_metric, module: logging, tier: P0, event: success, assertions: [exports_metric], exercised_on: [chat_completions, messages, embeddings], source: "integrations/otel/logger.py", rationale: "OTEL spans on every call path"} +- {id: logging.otel.success.exports_metric, module: logging, tier: P0, event: success, assertions: [exports_metric], exercised_on: [chat_completions, messages, responses, embeddings], source: "integrations/otel/logger.py", rationale: "OTEL spans on every call path"} +- {id: logging.otel.stream.exports_metric, module: logging, tier: P0, event: stream, assertions: [exports_metric], exercised_on: [chat_completions, messages, responses], source: "integrations/otel/logger.py", rationale: "Streaming closes the LLM span from the stream path; historically prone to duplicate/orphaned spans"} - {id: logging.otel.failure.exports_metric, module: logging, tier: P0, event: failure, assertions: [exports_metric], exercised_on: [chat_completions, messages], source: "integrations/otel/logger.py", rationale: "Error spans for observability continuity"} - {id: logging.braintrust.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions, messages], source: "integrations/braintrust_logging.py", rationale: "Evals platform spend"} - {id: logging.langsmith.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions, messages], source: "integrations/langsmith.py", rationale: "LangChain ecosystem"} diff --git a/tests/e2e/coverage_registry/schema.py b/tests/e2e/coverage_registry/schema.py index 21be0acb18f..7482088d93f 100644 --- a/tests/e2e/coverage_registry/schema.py +++ b/tests/e2e/coverage_registry/schema.py @@ -45,6 +45,7 @@ LlmRoute = Literal[ "azure_foundry", "azure_openai", "bedrock_converse", + "bedrock_invoke", "cohere", "openai", "together_ai", @@ -53,6 +54,7 @@ LlmRoute = Literal[ LlmCapability = Literal[ "basic", + "mid_conversation_system", "prompt_cache_5m", "service_tier", "structured_output", diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml index 0cfbb5b0b66..e2bb6ca8933 100644 --- a/tests/e2e/docker-compose.yml +++ b/tests/e2e/docker-compose.yml @@ -18,6 +18,12 @@ configs: type: redis host: redis port: 6379 + # OTEL v2 trace destination for the logging suite's trace-completeness + # tests: the arize_phoenix preset is OTLP with a configurable endpoint + # (PHOENIX_COLLECTOR_HTTP_ENDPOINT below points it at the jaeger service), + # so gen-AI spans export through a preset-owned provider - the code path + # where trace splits actually happen - with no cloud credentials needed. + callbacks: ["arize_phoenix"] router_settings: routing_strategy: simple-shuffle @@ -68,9 +74,15 @@ services: condition: service_healthy redis: condition: service_healthy + jaeger: + condition: service_healthy env_file: .env environment: LITELLM_MASTER_KEY: sk-1234 + STORE_MODEL_IN_DB: "True" + LITELLM_OTEL_V2: "true" + PHOENIX_COLLECTOR_HTTP_ENDPOINT: http://jaeger:4318/v1/traces + PHOENIX_API_KEY: local-jaeger-noauth DATABASE_URL: postgresql://litellm:litellm@db:5432/litellm UI_USERNAME: admin UI_PASSWORD: sk-1234 @@ -114,3 +126,15 @@ services: interval: 3s timeout: 3s retries: 20 + +# throwaway OTEL trace destination (OTLP ingest on 4318 inside the network, +# query API on host 16686 for test read-back; see E2E_OTEL_QUERY_URL) + jaeger: + image: jaegertracing/all-in-one:1.62.0 + ports: + - "16686:16686" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:14269/"] + interval: 3s + timeout: 3s + retries: 20 diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index 75bd715a23a..6e6c30709de 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -24,6 +24,14 @@ CONTROL_PLANE_BASE_URL = os.environ.get( UI_USERNAME = os.environ.get("E2E_UI_USERNAME", "admin") UI_PASSWORD = os.environ.get("E2E_UI_PASSWORD", MASTER_KEY) +CHEAP_ANTHROPIC_MODEL = os.environ.get("E2E_CHEAP_ANTHROPIC_MODEL", "claude-haiku-4-5") +CHEAP_OPENAI_MODEL = os.environ.get("E2E_CHEAP_OPENAI_MODEL", "gpt-5.5") + +# Jaeger query API of the compose stack's OTEL trace destination (the `jaeger` +# service in docker-compose.yml maps it to host 16686). Trace-completeness tests +# read exported spans back through it. +OTEL_QUERY_URL = os.environ.get("E2E_OTEL_QUERY_URL", "http://localhost:16686").rstrip("/") + # Writes on the proxy are eventually consistent (e.g. spend rows flush on # proxy_batch_write_at, ~60s). Read-backs poll to this deadline, never sleep-once. POLL_TIMEOUT = float(os.environ.get("E2E_POLL_TIMEOUT", "120")) diff --git a/tests/e2e/e2e_gateway.py b/tests/e2e/e2e_gateway.py index 05f83ecc085..d40b96d60fa 100644 --- a/tests/e2e/e2e_gateway.py +++ b/tests/e2e/e2e_gateway.py @@ -12,6 +12,7 @@ import time import warnings from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from e2e_http import ( NoBody, @@ -50,6 +51,8 @@ from models import ( OcrResponse, SpendLogRow, SpendLogs, + SpendLogsPage, + SpendLogsPageParams, SpendLogsParams, ) from e2e_config import ( @@ -255,6 +258,28 @@ class Gateway: case _: return [] + def spend_logs_window(self, *, start: datetime, end: datetime) -> list[SpendLogRow]: + def fetch(page: int) -> SpendLogsPage: + return unwrap( + self.transport.get( + "/spend/logs/v2", + headers=self.transport.master, + params=SpendLogsPageParams( + start_date=start.strftime("%Y-%m-%d %H:%M:%S"), + end_date=end.strftime("%Y-%m-%d %H:%M:%S"), + page=page, + page_size=100, + ), + response_type=SpendLogsPage, + ) + ) + + first = fetch(1) + return [ + *first.data, + *(row for page in range(2, first.total_pages + 1) for row in fetch(page).data), + ] + def poll_logs_for_key( self, key: str, *, min_rows: int = 1, predicate: RowsPredicate | None = None ) -> list[SpendLogRow]: diff --git a/tests/e2e/e2e_http.py b/tests/e2e/e2e_http.py index 32005faed4b..ff296969079 100644 --- a/tests/e2e/e2e_http.py +++ b/tests/e2e/e2e_http.py @@ -109,16 +109,23 @@ class StreamingResponse(BaseModel): """Raw outcome for calls whose body is provider-native or streamed: status, the x-litellm-call-id header, the x-litellm-response-cost header (StandardLogging response_cost), the content-type (which tells streaming `text/event-stream` from - non-streaming `application/json`), and the body. SpendLogs.request_id is the - completion body id, not call_id. Used by passthrough and streaming, where one - validated JSON model does not fit.""" + non-streaming `application/json`), the response headers (lowercased names, e.g. + the x-ratelimit-* pacing headers and retry-after on a 429), and the body. + SpendLogs.request_id is the completion body id, not call_id. Used by passthrough + and streaming, where one validated JSON model does not fit.""" status_code: int call_id: str | None = None # x-litellm-call-id header response_cost: float | None = None # x-litellm-response-cost header content_type: str | None = None + headers: dict[str, str] = {} body: str chunks: int = 0 # streamed events (0 for non-streaming) + # First in-stream error event, if any. A streamed call commits its HTTP 200 + # before the upstream completes, so upstream failures (e.g. insufficient + # quota) arrive as SSE error events inside an otherwise-successful response; + # the consumed body is elided, so this is the only place they surface. + stream_error: str | None = None @property def ok(self) -> bool: @@ -276,23 +283,39 @@ def _streaming_outcome(resp: requests.Response, stream: bool) -> StreamingRespon call_id = _hdr(resp, "x-litellm-call-id") response_cost = _parse_response_cost(resp) content_type = _hdr(resp, "content-type") + headers = {name.lower(): value for name, value in resp.headers.items()} if not stream or not (200 <= resp.status_code < 300): return StreamingResponse( status_code=resp.status_code, call_id=call_id, response_cost=response_cost, content_type=content_type, + headers=headers, body=resp.text, ) lines = cast("Iterator[bytes]", resp.iter_lines()) - chunks = sum(1 for line in lines if line) + chunks = 0 + stream_error: str | None = None + for line in lines: + if not line: + continue + chunks += 1 + if stream_error is None and ( + line.startswith(b"event: error") + or b'"type":"error"' in line + or b'"type": "error"' in line + or line.startswith(b'data: {"error"') + ): + stream_error = line.decode(errors="replace")[:300] return StreamingResponse( status_code=resp.status_code, call_id=call_id, response_cost=response_cost, content_type=content_type, + headers=headers, body="", chunks=chunks, + stream_error=stream_error, ) diff --git a/tests/e2e/llm_translation/endpoints_client.py b/tests/e2e/llm_translation/endpoints_client.py index 0ab87472748..c3816928564 100644 --- a/tests/e2e/llm_translation/endpoints_client.py +++ b/tests/e2e/llm_translation/endpoints_client.py @@ -30,6 +30,28 @@ class MessagesRequest(BaseModel): messages: list[ChatMessage] +class CacheControl(BaseModel): + type: str = "ephemeral" + + +class TextBlock(BaseModel): + type: str = "text" + text: str + cache_control: CacheControl | None = None + + +class RichMessage(BaseModel): + role: str + content: list[TextBlock] + + +class RichMessagesRequest(BaseModel): + model: str + max_tokens: int = 64 + system: list[TextBlock] + messages: list[RichMessage] + + class EmbeddingsRequest(BaseModel): model: str input: str @@ -83,11 +105,19 @@ class AnthropicContentBlock(BaseModel): text: str | None = None +class MessagesUsage(BaseModel): + input_tokens: int = 0 + output_tokens: int = 0 + cache_creation_input_tokens: int = 0 + cache_read_input_tokens: int = 0 + + class MessagesResult(BaseModel): id: str | None = None role: str | None = None model: str | None = None content: list[AnthropicContentBlock] = [] + usage: MessagesUsage = MessagesUsage() @property def text(self) -> str: diff --git a/tests/e2e/llm_translation/test_cache_control.py b/tests/e2e/llm_translation/test_cache_control.py index 51ae6ccbc1d..ba701f4869c 100644 --- a/tests/e2e/llm_translation/test_cache_control.py +++ b/tests/e2e/llm_translation/test_cache_control.py @@ -15,7 +15,7 @@ service_tier lives in test_provider_features_e2e.py. The provider-native cache_control request shape is not expressible with the shared ``ChatBody`` (whose content is a plain string), so the cacheable body is -modelled locally with typed content blocks. +built from the typed content blocks shared in ``endpoints_client.py``. """ from __future__ import annotations @@ -27,6 +27,7 @@ from pydantic import BaseModel from e2e_config import unique_marker from e2e_http import Result, unwrap +from endpoints_client import CacheControl, RichMessage, TextBlock from lifecycle import ResourceManager from models import ChatResponse, LiteLLMParamsBody, Usage from passthrough_client import PassthroughClient @@ -38,21 +39,6 @@ BEDROCK_MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" VERTEX_MODEL = "vertex_ai/gemini-2.5-flash" -class CacheControl(BaseModel): - type: str = "ephemeral" - - -class TextBlock(BaseModel): - type: str = "text" - text: str - cache_control: CacheControl | None = None - - -class RichMessage(BaseModel): - role: str - content: list[TextBlock] - - class CacheChatBody(BaseModel): model: str messages: list[RichMessage] diff --git a/tests/e2e/llm_translation/test_messages_mid_conversation_system_e2e.py b/tests/e2e/llm_translation/test_messages_mid_conversation_system_e2e.py new file mode 100644 index 00000000000..1025d603fca --- /dev/null +++ b/tests/e2e/llm_translation/test_messages_mid_conversation_system_e2e.py @@ -0,0 +1,220 @@ +"""Live e2e: mid-conversation ``role: "system"`` handling on the Bedrock Invoke +/v1/messages path is model-aware (PRs #32578, #32831, #32882). + +Models flagged ``supports_mid_conversation_system`` in the cost map (Claude 4.8+ +and the 5 family) must keep a mid-conversation system reminder in place inside +``messages`` so the top-level ``system`` prefix stays byte-identical and the +prompt cache written on turn one is read back in full on turn two. Models +without the flag (Claude 4.7 and older) reject the role inside ``messages`` +outright, so the proxy must hoist the reminder into the top-level ``system`` +field and the call must still return a completion instead of a provider 400. + +The conversation shape mirrors what Claude Code sends mid-session: a cached +system prompt, a user turn carrying its own ``cache_control`` breakpoint, a +``role: "system"`` reminder, an assistant turn, and a fresh user turn. The +message-turn breakpoint is what makes the cache assertion able to fail: a cache +entry whose prefix spans ``system`` plus message turns is invalidated when the +reminder is hoisted (the ``system`` field mutates and a turn disappears from +``messages``), while an entry ending at the system block itself would survive +the hoist and mask the regression. +""" + +from __future__ import annotations + +import time + +import pytest +from pydantic import BaseModel + +from e2e_config import unique_marker +from e2e_http import Result, unwrap +from endpoints_client import ( + CacheControl, + EndpointsClient, + MessagesResult, + RichMessage, + RichMessagesRequest, + TextBlock, +) +from lifecycle import ResourceManager +from models import LiteLLMParamsBody + +pytestmark = pytest.mark.e2e + +FLAGGED_INVOKE_MODEL = "bedrock/invoke/us.anthropic.claude-sonnet-5" +UNFLAGGED_INVOKE_MODEL = "bedrock/invoke/us.anthropic.claude-haiku-4-5-20251001-v1:0" +AWS_REGION = "us-east-1" +CACHE_PRIMING_DEADLINE_SECONDS = 60.0 +CACHE_PRIMING_INTERVAL_SECONDS = 3.0 + + +def _cacheable_system_block(marker: str) -> TextBlock: + """A system prompt comfortably above Sonnet's 1024-token minimum cacheable + size, unique per run so no other run's cache entry can satisfy the read.""" + text = " ".join( + f"Reference paragraph {index} for run {marker}." for index in range(300) + ) + return TextBlock(text=text, cache_control=CacheControl()) + + +def _user_turn(text: str, *, cached: bool = False) -> RichMessage: + block = TextBlock(text=text, cache_control=CacheControl() if cached else None) + return RichMessage(role="user", content=[block]) + + +def _system_reminder_turn() -> RichMessage: + return RichMessage( + role="system", + content=[ + TextBlock( + text="Answer with exactly one word." + ) + ], + ) + + +def _post_messages( + client: EndpointsClient, key: str, body: RichMessagesRequest +) -> Result[MessagesResult]: + return client.gateway.transport.post( + "/v1/messages", + headers=client.gateway.transport.bearer(key), + json=body, + response_type=MessagesResult, + ) + + +def _register_invoke_deployment( + client: EndpointsClient, resources: ResourceManager, bedrock_model: str +) -> str: + model = f"e2e-midsys-{unique_marker()}" + model_id = client.create_model( + model, LiteLLMParamsBody(model=bedrock_model, aws_region_name=AWS_REGION) + ) + resources.defer(lambda: client.delete_model(model_id)) + return model + + +def _first_turn_user_text(marker: str) -> str: + """A first user turn heavy enough (hundreds of tokens) that losing its cache + entry is unambiguous in the usage numbers, unique per attempt so priming + retries never depend on the proxy's response cache behavior.""" + notes = " ".join(f"Session note {index} for attempt {marker}." for index in range(100)) + return f"Reply with one word.\n{notes}" + + +class PrimedCache(BaseModel): + first_user_text: str + prefix_read_tokens: int + first_turn_creation_tokens: int + + @property + def full_prefix_tokens(self) -> int: + return self.prefix_read_tokens + self.first_turn_creation_tokens + + +def _prime_prompt_cache( + client: EndpointsClient, key: str, model: str, system_block: TextBlock +) -> PrimedCache: + """Send first-turn calls (fresh cache-marked user turn each attempt, + identical system prefix) until one both reads the system prefix back from + cache and writes its own user-turn chunk, proving the cache is live in both + directions. Only the pre-reminder turn is ever retried here, so retries can + never warm a mutated-prefix cache entry and mask the regression the second + turn asserts on.""" + deadline = time.monotonic() + CACHE_PRIMING_DEADLINE_SECONDS + while True: + user_text = _first_turn_user_text(unique_marker()) + body = RichMessagesRequest( + model=model, + system=[system_block], + messages=[_user_turn(user_text, cached=True)], + ) + usage = unwrap(_post_messages(client, key, body)).usage + if usage.cache_read_input_tokens > 0 and usage.cache_creation_input_tokens > 0: + return PrimedCache( + first_user_text=user_text, + prefix_read_tokens=usage.cache_read_input_tokens, + first_turn_creation_tokens=usage.cache_creation_input_tokens, + ) + if time.monotonic() >= deadline: + pytest.fail( + f"{model}: prompt cache never became readable within " + f"{CACHE_PRIMING_DEADLINE_SECONDS}s (last usage: {usage})" + ) + time.sleep(CACHE_PRIMING_INTERVAL_SECONDS) + + +class TestBedrockInvokeMidConversationSystem: + @pytest.mark.covers( + "llm.messages.bedrock_invoke.mid_conversation_system.nonstream.cache_hit", + exercised_on=[], + ) + def test_flagged_model_keeps_prompt_cache_across_system_reminder( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model = _register_invoke_deployment( + endpoints_client, resources, FLAGGED_INVOKE_MODEL + ) + key = resources.key(models=[model]) + system_block = _cacheable_system_block(unique_marker()) + + primed = _prime_prompt_cache(endpoints_client, key, model, system_block) + + reminder_turn_body = RichMessagesRequest( + model=model, + system=[system_block], + messages=[ + _user_turn(primed.first_user_text, cached=True), + _system_reminder_turn(), + RichMessage(role="assistant", content=[TextBlock(text="OK.")]), + _user_turn("Reply with one word again.", cached=True), + ], + ) + second = unwrap(_post_messages(endpoints_client, key, reminder_turn_body)) + + assert second.text.strip(), ( + f"{model}: reminder turn returned no completion text" + ) + assert second.usage.cache_read_input_tokens >= primed.full_prefix_tokens, ( + f"{model}: turn with a mid-conversation system reminder read " + f"{second.usage.cache_read_input_tokens} cached tokens, expected at " + f"least the {primed.full_prefix_tokens} cached on turn one " + f"({primed.prefix_read_tokens} system prefix + " + f"{primed.first_turn_creation_tokens} first user turn); the reminder " + f"was hoisted into the top-level system field, which mutates the " + f"cached prefix and re-bills the conversation at cache-write pricing" + ) + + @pytest.mark.covers( + "llm.messages.bedrock_invoke.mid_conversation_system.nonstream.works", + exercised_on=[], + ) + def test_unflagged_model_hoists_system_reminder_and_succeeds( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model = _register_invoke_deployment( + endpoints_client, resources, UNFLAGGED_INVOKE_MODEL + ) + key = resources.key(models=[model]) + + body = RichMessagesRequest( + model=model, + system=[TextBlock(text="You are terse.")], + messages=[ + _user_turn(f"Say hi. Run {unique_marker()}."), + _system_reminder_turn(), + RichMessage(role="assistant", content=[TextBlock(text="Hi.")]), + _user_turn("Say bye."), + ], + ) + completion = unwrap(_post_messages(endpoints_client, key, body)) + + assert completion.role == "assistant", ( + f"{model}: unexpected role {completion.role!r}" + ) + assert completion.text.strip(), ( + f"{model}: conversation with a mid-conversation system reminder " + f"returned no text; the reminder was forwarded in place to a model " + f"that rejects role 'system' inside messages instead of being hoisted" + ) diff --git a/tests/e2e/logging/conftest.py b/tests/e2e/logging/conftest.py index 567355f23ac..43d279602ef 100644 --- a/tests/e2e/logging/conftest.py +++ b/tests/e2e/logging/conftest.py @@ -11,6 +11,7 @@ import os import pytest from logging_client import LangfuseCreds, LoggingClient, build_logging_client, load_langfuse_creds +from otel_client import OtelReader, build_otel_reader def pytest_configure(config: pytest.Config) -> None: @@ -28,6 +29,12 @@ def client() -> LoggingClient: return build_logging_client() +@pytest.fixture(scope="session") +def otel_reader() -> OtelReader: + """Read-back client for the compose stack's Jaeger trace destination.""" + return build_otel_reader() + + @pytest.fixture def datadog_creds() -> None: """Require Datadog shipping credentials. Hard-fail when absent; never skip.""" diff --git a/tests/e2e/logging/logging_client.py b/tests/e2e/logging/logging_client.py index 6071e657bd3..e37d6175705 100644 --- a/tests/e2e/logging/logging_client.py +++ b/tests/e2e/logging/logging_client.py @@ -35,6 +35,7 @@ from e2e_http import ( unwrap, ) from models import ( + AnthropicMessagesBody, ChatBody, ChatMessage, ChatResponse, @@ -75,6 +76,15 @@ WEATHER_TOOL = ChatTool( ) +class ResponsesRequestBody(BaseModel): + """OpenAI Responses API /v1/responses request.""" + + model: str + input: str + max_output_tokens: int + stream: bool | None = None + + class TeamCallbackBody(BaseModel): callback_name: Literal["langfuse_otel", "langfuse", "langsmith", "gcs"] callback_type: Literal["success", "failure", "success_and_failure"] @@ -455,6 +465,45 @@ class LoggingClient: json=body, ) + def messages_raw( + self, key: str, model: str, text: str, *, max_tokens: int = 16, stream: bool = False + ) -> StreamingResponse: + """POST /v1/messages (Anthropic-native body): raw outcome judged by + status/body/headers, for tests that need x-litellm-call-id. With + ``stream=True`` the SSE body is consumed and its events counted.""" + body = AnthropicMessagesBody( + model=model, + max_tokens=max_tokens, + messages=[ChatMessage(role="user", content=text)], + stream=True if stream else None, + ) + if stream: + return self.gateway.transport.stream( + "/v1/messages", headers=self.gateway.transport.bearer(key), json=body + ) + return self.gateway.transport.send( + "/v1/messages", headers=self.gateway.transport.bearer(key), json=body + ) + + def responses_raw( + self, key: str, model: str, text: str, *, max_output_tokens: int = 64, stream: bool = False + ) -> StreamingResponse: + """POST /v1/responses (OpenAI Responses API): raw outcome judged by + status/body/headers, for tests that need x-litellm-call-id. + max_output_tokens caps reasoning-model output cost; a capped response is + still a 200 and still exports the trace. With ``stream=True`` the SSE + body is consumed and its events counted.""" + body = ResponsesRequestBody( + model=model, input=text, max_output_tokens=max_output_tokens, stream=True if stream else None + ) + if stream: + return self.gateway.transport.stream( + "/v1/responses", headers=self.gateway.transport.bearer(key), json=body + ) + return self.gateway.transport.send( + "/v1/responses", headers=self.gateway.transport.bearer(key), json=body + ) + def scrape_metrics(self) -> str: return self.gateway.probe("/metrics", params=NoBody()).body diff --git a/tests/e2e/logging/otel_client.py b/tests/e2e/logging/otel_client.py new file mode 100644 index 00000000000..f4a0e4fe102 --- /dev/null +++ b/tests/e2e/logging/otel_client.py @@ -0,0 +1,138 @@ +"""Jaeger read-back for the OTEL trace-completeness tests: typed models over the +Jaeger query API (the destination's own API - completeness is judged on what the +backend actually holds, never on "export succeeded" proxy-side). + +Traces are fetched server-side by the ``litellm.call_id`` tag the gen-AI span +carries (the request's x-litellm-call-id response header), so read-back is +immune to the query page filling up with unrelated traffic (background jobs, +other suites sharing the stack). Jaeger returns every span of a matching trace, +so the completeness assertions see the whole tree. A failed query is a hard +failure, never an empty result - an unreachable destination must not read as +"the trace never arrived". + +External reads go through ``e2e_http`` (the only module allowed to call +``requests.*``). +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass + +import pytest +from pydantic import BaseModel, ConfigDict, Field + +from e2e_config import OTEL_QUERY_URL, POLL_INTERVAL, POLL_TIMEOUT +from e2e_http import URL, NoBody, Success, get + +#: OTEL resource service.name the proxy exports under (OTEL_SERVICE_NAME default). +JAEGER_SERVICE = "litellm" +#: Span tag carrying the request's x-litellm-call-id (stamped on the gen-AI span). +CALL_ID_TAG = "litellm.call_id" + + +class JaegerTag(BaseModel): + model_config = ConfigDict(extra="ignore") + + key: str + value: str | int | float | bool | None = None + + +class JaegerReference(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + ref_type: str = Field(alias="refType") + trace_id: str = Field(alias="traceID") + span_id: str = Field(alias="spanID") + + +class JaegerSpan(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + span_id: str = Field(alias="spanID") + operation_name: str = Field(alias="operationName") + start_time: int = Field(default=0, alias="startTime") + references: list[JaegerReference] = [] + tags: list[JaegerTag] = [] + + @property + def kind(self) -> str: + for tag in self.tags: + if tag.key == "span.kind": + return str(tag.value) + return "" + + +class JaegerTrace(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + trace_id: str = Field(alias="traceID") + spans: list[JaegerSpan] = [] + + def span_names(self) -> list[str]: + return sorted(span.operation_name for span in self.spans) + + +class JaegerTracesPage(BaseModel): + model_config = ConfigDict(extra="ignore") + + data: list[JaegerTrace] = [] + + +class _TracesQuery(BaseModel): + service: str + tags: str + limit: int = 20 + lookback: str = "1h" + + +def _settled(trace: JaegerTrace, names: set[str], prefixes: set[str]) -> bool: + present = set(trace.span_names()) + return names.issubset(present) and all( + any(name.startswith(prefix) for name in present) for prefix in prefixes + ) + + +@dataclass(frozen=True, slots=True) +class OtelReader: + query_url: str + + def traces_for_call(self, call_id: str) -> list[JaegerTrace]: + """Every trace holding a span tagged with this call id. Jaeger matches + spans server-side and returns their full traces; more than one hit for + one call IS the split-trace bug, so this never collapses to one.""" + result = get( + URL(f"{self.query_url}/api/traces"), + headers=NoBody(), + params=_TracesQuery(service=JAEGER_SERVICE, tags=json.dumps({CALL_ID_TAG: call_id})), + response_type=JaegerTracesPage, + timeout=30.0, + ) + match result: + case Success(data=page): + return page.data + case failure: + pytest.fail(f"Jaeger query API at {self.query_url} failed: {failure}") + + def poll_traces_for_call( + self, *, call_id: str, settled_names: set[str], settled_prefixes: set[str] + ) -> list[JaegerTrace]: + """Poll until exactly one trace holds the call and it carries every span + name in ``settled_names`` plus at least one name per prefix in + ``settled_prefixes`` (spans flush in batches, the cost write lands after + the response), then return the hits. At the deadline the last hits are + returned as-is so the caller's assertions report the real final state - + on a split trace this never settles and the orphan comes back.""" + deadline = time.monotonic() + POLL_TIMEOUT + hits: list[JaegerTrace] = [] + while time.monotonic() < deadline: + hits = self.traces_for_call(call_id) + if len(hits) == 1 and _settled(hits[0], settled_names, settled_prefixes): + return hits + time.sleep(POLL_INTERVAL) + return hits + + +def build_otel_reader() -> OtelReader: + return OtelReader(query_url=OTEL_QUERY_URL) diff --git a/tests/e2e/logging/test_otel_trace_e2e.py b/tests/e2e/logging/test_otel_trace_e2e.py new file mode 100644 index 00000000000..b00fd91be3c --- /dev/null +++ b/tests/e2e/logging/test_otel_trace_e2e.py @@ -0,0 +1,569 @@ +"""Live e2e: OTEL trace completeness on the admin-owned destination (LIT-3787). + +Covers logging.otel.success.exports_metric: a successful non-streaming call must +land at the OTEL destination as ONE connected trace - a single root SERVER span +with the auth phase, db lookups, and cost write under it, and the gen-AI CLIENT +span parented into the same tree. The regression this pins: the proxy publishing +the global TracerProvider before callbacks init made server spans export through +a different provider than the preset's gen-AI spans, so the destination received +the gen-AI span alone, dangling (fixed in #30590; verified failing at its parent +commit 1bd603d1ac). + +Both halves of the contract are asserted: the recorded state (the proxy reports +the OTEL v2 logger active via /health/readiness/details) and the enforced +behavior (the complete span tree at the destination, read back through the +destination's own query API - never proxy-side "export succeeded" logs). +""" + +from __future__ import annotations + +import time +from collections.abc import Callable + +import pytest +from pydantic import BaseModel, ConfigDict, ValidationError + +from e2e_config import CHEAP_ANTHROPIC_MODEL, CHEAP_OPENAI_MODEL, unique_marker +from e2e_http import NoBody, StreamingResponse, require_successful_call +from lifecycle import ResourceManager +from logging_client import INVALID_UPSTREAM_API_KEY, LoggingClient +from models import LiteLLMParamsBody +from otel_client import JaegerSpan, JaegerTrace, OtelReader + +pytestmark = pytest.mark.e2e + +MODEL = CHEAP_ANTHROPIC_MODEL +COST_SPAN = "batch_write_to_db _PROXY_track_cost_callback" +DB_SPAN_PREFIX = "postgres " +#: The active OTEL v2 logger's name in /health/readiness/details success_callbacks. +OTEL_V2_LOGGER_NAME = "OpenTelemetryV2" + + +class _ReadinessDetails(BaseModel): + model_config = ConfigDict(extra="ignore") + + success_callbacks: list[str] = [] + + +def _assert_otel_destination_configured(client: LoggingClient) -> None: + """Recorded state: the proxy reports the OTEL v2 logger among its active + callbacks, so a missing/failed destination config fails here, before any + traffic-based assertion can time out confusingly.""" + result = client.gateway.probe("/health/readiness/details", params=NoBody()) + assert result.status_code == 200, ( + f"/health/readiness/details must answer 200, got {result.status_code}: {result.body[:300]}" + ) + details = _ReadinessDetails.model_validate_json(result.body) + assert OTEL_V2_LOGGER_NAME in details.success_callbacks, ( + f"the proxy must report the {OTEL_V2_LOGGER_NAME} callback active " + f"(LITELLM_OTEL_V2 + arize_phoenix preset in the compose config); got: {details.success_callbacks}" + ) + + +def _first_ok(client: LoggingClient, send: Callable[[], StreamingResponse]) -> StreamingResponse: + """First successful call on a fresh key. A fresh key may briefly 401 until + the data plane's auth cache picks it up, so retry on 401 to a deadline; a + 401 is rejected before the LLM call so it exports no gen-AI span and cannot + contaminate the trace assertions. Any other failure is behavior under test + and fails hard.""" + deadline = time.monotonic() + client.gateway.poll_timeout + while True: + outcome = send() + if outcome.ok: + return outcome + if outcome.status_code != 401 or time.monotonic() >= deadline: + require_successful_call(outcome) + time.sleep(client.gateway.poll_interval) + + +def _parent_ids(span_id: str, trace: JaegerTrace) -> list[str]: + span = next(s for s in trace.spans if s.span_id == span_id) + return [ref.span_id for ref in span.references if ref.ref_type == "CHILD_OF"] + + +def _chain_reaches(span_id: str, root_id: str, trace: JaegerTrace) -> bool: + """Walk parent references (within the trace) from span_id up to root_id.""" + seen: set[str] = set() + in_trace = {s.span_id for s in trace.spans} + current = span_id + while current not in seen: + if current == root_id: + return True + seen.add(current) + parents = [p for p in _parent_ids(current, trace) if p in in_trace] + if not parents: + return False + current = parents[0] + return False + + +def _assert_complete_trace( + hits: list[JaegerTrace], *, route: str, genai_span: str, require_cost_span: bool = True +) -> None: + """The enforced behavior: the destination holds exactly one trace for the + call, rooted at the SERVER span, with auth/db/cost children and the gen-AI + span all connected into that one tree - no dangling parent references.""" + assert hits, ( + "no trace for this call arrived at the destination within the deadline " + "(nothing tagged with its call id was found)" + ) + assert len(hits) == 1, ( + f"expected exactly ONE trace for the call, got {len(hits)}: " + f"{[(t.trace_id, t.span_names()) for t in hits]} - more than one trace for " + "one call is the split-trace bug (gen-AI span exported away from its root)" + ) + trace = hits[0] + names = trace.span_names() + in_trace = {span.span_id for span in trace.spans} + + dangling = [ + span.operation_name + for span in trace.spans + if span.references and not any(ref.span_id in in_trace for ref in span.references) + ] + assert not dangling, ( + f"span(s) {dangling} reference a parent that never reached the destination " + f"(orphaned trace); spans present: {names}" + ) + + roots = [span for span in trace.spans if not span.references] + assert len(roots) == 1, f"expected exactly one root span, got {[s.operation_name for s in roots]}; spans: {names}" + root = roots[0] + assert root.operation_name == f"POST {route}", ( + f"the root must be the SERVER span 'POST {route}', got {root.operation_name!r}" + ) + assert root.kind == "server", f"the root span must have kind=server, got {root.kind!r}" + + assert f"auth {route}" in names, f"auth phase span 'auth {route}' missing; spans: {names}" + assert any(name.startswith(DB_SPAN_PREFIX) for name in names), ( + f"no db ('{DB_SPAN_PREFIX}*') span in the trace; spans: {names}" + ) + if require_cost_span: + assert COST_SPAN in names, f"cost write span {COST_SPAN!r} missing; spans: {names}" + + genai = next((span for span in trace.spans if span.operation_name == genai_span), None) + assert genai is not None, f"gen-AI span {genai_span!r} missing; spans: {names}" + assert genai.kind == "client", f"gen-AI span must have kind=client, got {genai.kind!r}" + assert _chain_reaches(genai.span_id, root.span_id, trace), ( + f"gen-AI span {genai_span!r} is in the trace but its parent chain does not " + f"reach the root SERVER span; spans: {names}" + ) + + +def _settled_names(*, route: str, genai_span: str, require_cost_span: bool = True) -> set[str]: + names = {f"POST {route}", f"auth {route}", genai_span} + return (names | {COST_SPAN}) if require_cost_span else names + + +def _tag(span: JaegerSpan, key: str) -> str | int | float | bool | None: + for tag in span.tags: + if tag.key == key: + return tag.value + return None + + +#: The attribute contract a failed call's gen-AI span must carry (LIT-4179), as +#: one reviewable payload. Exact-match values; error.message is additionally +#: proven untruncated by _assert_error_span_contract, which parses the provider +#: error JSON embedded in it - a truncated message stops parsing. +EXPECTED_ERROR_SPAN_ATTRIBUTES: dict[str, str] = { + "error": "True", + "error.type": "AuthenticationError", + "otel.status_code": "ERROR", + "litellm.provider.error.code": "401", + "litellm.provider.error.llm_provider": "anthropic", +} + + +class _ProviderErrorDetail(BaseModel): + model_config = ConfigDict(extra="ignore") + + type: str + message: str + + +class _ProviderError(BaseModel): + """The provider error JSON embedded in error.message; validating it proves + the attribute survived untruncated (a cut-off message stops parsing).""" + + model_config = ConfigDict(extra="ignore") + + error: _ProviderErrorDetail + + +def _assert_error_span_contract(span: JaegerSpan) -> None: + """The failed call's gen-AI span carries the LIT-4179 error contract: the + exact attributes in EXPECTED_ERROR_SPAN_ATTRIBUTES, plus an untruncated + error.message whose embedded provider error JSON still parses and whose + text also rides the span status description.""" + for key, expected in EXPECTED_ERROR_SPAN_ATTRIBUTES.items(): + actual = _tag(span, key) + assert str(actual) == expected, f"error span attribute {key!r} must be {expected!r}, got {actual!r}" + + message = _tag(span, "error.message") + assert isinstance(message, str) and message, "error span must carry a non-empty error.message" + assert "AnthropicException" in message, ( + f"error.message must carry the upstream provider exception, got: {message[:200]}" + ) + start, end = message.find("{"), message.rfind("}") + assert start != -1 and end > start, ( + f"error.message carries no parseable provider error JSON (truncated?): {message[:200]}" + ) + try: + provider_error = _ProviderError.model_validate_json(message[start : end + 1]) + except ValidationError: + pytest.fail(f"the embedded provider error JSON does not parse (truncated?): {message[:300]}") + assert provider_error.error.message == "invalid x-api-key", ( + f"the embedded provider error must survive untruncated; parsed: {provider_error}" + ) + assert _tag(span, "otel.status_description") == message, ( + "the span status description must carry the same untruncated message as error.message" + ) + stack = _tag(span, "litellm.provider.error.stack_trace") + assert isinstance(stack, str) and stack, ( + "the error span must carry a non-empty litellm.provider.error.stack_trace" + ) + + +class TestOtelTraceCompleteness: + @pytest.mark.covers("logging.otel.success.exports_metric", exercised_on=["chat_completions"]) + def test_chat_completions_exports_complete_trace( + self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager + ) -> None: + """This test verifies that a successful non-streaming + /chat/completions request produces one complete OTEL trace. + + The trace should have a single server root span for the incoming request, with + the authentication, database, and cost-recording work beneath it. The span for + the actual model call must also belong to that same trace, rather than being + exported separately with a missing parent. + + This matters because a split trace is easy to miss: all of the spans may still + arrive, but the model call appears without the surrounding request context. + That makes it difficult to understand where time was spent, connect the model + cost to the original request, or investigate a slow or failed call. + + /chat/completions is the main OpenAI-compatible route used by most customers, + so it is important that trace parenting works correctly on this path. + """ + route = "/chat/completions" + _assert_otel_destination_configured(client) + + key = client.key_with_alias(f"otel-trace-chat-{unique_marker()}", models=[MODEL]) + resources.defer(lambda: client.delete_key(key)) + + marker = unique_marker() + outcome = _first_ok( + client, lambda: client.chat_raw(key, MODEL, f"reply with one word {marker}", max_tokens=16) + ) + assert outcome.call_id is not None, "success response must carry x-litellm-call-id" + + hits = otel_reader.poll_traces_for_call( + call_id=outcome.call_id, + settled_names=_settled_names(route=route, genai_span=f"chat {MODEL}"), + settled_prefixes={DB_SPAN_PREFIX}, + ) + _assert_complete_trace(hits, route=route, genai_span=f"chat {MODEL}") + + @pytest.mark.covers("logging.otel.success.exports_metric", exercised_on=["messages"]) + def test_messages_exports_complete_trace( + self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager + ) -> None: + """This test verifies that one successful non-streaming /v1/messages request + produces exactly one complete OTEL trace. + + The trace must have a single root span named "POST /v1/messages". The + authentication, database, cost-writing, and model-call spans must all belong to + the same trace and have valid parent relationships leading back to that root. + + The model-call span is expected to be named "chat ". The test fails if + the request is split across multiple traces, if any span references a missing + parent, or if the model-call span cannot be connected back to the root.""" + route = "/v1/messages" + _assert_otel_destination_configured(client) + + key = client.key_with_alias(f"otel-trace-messages-{unique_marker()}", models=[MODEL]) + resources.defer(lambda: client.delete_key(key)) + + marker = unique_marker() + outcome = _first_ok( + client, lambda: client.messages_raw(key, MODEL, f"reply with one word {marker}", max_tokens=16) + ) + assert outcome.call_id is not None, "success response must carry x-litellm-call-id" + + hits = otel_reader.poll_traces_for_call( + call_id=outcome.call_id, + settled_names=_settled_names(route=route, genai_span=f"chat {MODEL}"), + settled_prefixes={DB_SPAN_PREFIX}, + ) + _assert_complete_trace(hits, route=route, genai_span=f"chat {MODEL}") + + @pytest.mark.covers("logging.otel.success.exports_metric", exercised_on=["responses"]) + def test_responses_exports_complete_trace( + self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager + ) -> None: + """This test verifies that one successful non-streaming /v1/responses request + produces exactly one complete OTEL trace. + + The trace must have a single root span named "POST /v1/responses". The + authentication, database, cost-writing, and model-call spans must all belong to + the same trace and have valid parent relationships leading back to that root. + + The model-call span is expected to be named "chat ". The test fails if + the request is split across multiple traces, if any span references a missing + parent, or if the model-call span cannot be connected back to the root.""" + route = "/v1/responses" + _assert_otel_destination_configured(client) + + key = client.key_with_alias(f"otel-trace-responses-{unique_marker()}", models=[CHEAP_OPENAI_MODEL]) + resources.defer(lambda: client.delete_key(key)) + + marker = unique_marker() + outcome = _first_ok( + client, + lambda: client.responses_raw(key, CHEAP_OPENAI_MODEL, f"reply with one word {marker}"), + ) + assert outcome.call_id is not None, "success response must carry x-litellm-call-id" + + genai_span = f"chat {CHEAP_OPENAI_MODEL}" + hits = otel_reader.poll_traces_for_call( + call_id=outcome.call_id, + settled_names=_settled_names(route=route, genai_span=genai_span), + settled_prefixes={DB_SPAN_PREFIX}, + ) + _assert_complete_trace(hits, route=route, genai_span=genai_span) + + @pytest.mark.covers("logging.otel.stream.exports_metric", exercised_on=["chat_completions"]) + def test_chat_completions_stream_exports_complete_trace( + self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager + ) -> None: + """A successful streamed `/chat/completions` request should export one + complete OTEL trace. The trace must contain a single root `SERVER` + span, with the auth, database, cost, and gen-AI `CLIENT` spans all + connected back to that root. + + Streaming has an additional lifecycle risk because the gen-AI span is + closed by the stream-consumption path after the final chunk has + arrived and usage has been aggregated. Historically, this has caused + duplicate or orphaned spans. + + The test therefore confirms that: + + * The response actually streams. + * Exactly one gen-AI span is created for the request. + * The gen-AI span contains `litellm.request.streaming=true`. + """ + route = "/chat/completions" + _assert_otel_destination_configured(client) + + key = client.key_with_alias(f"otel-stream-chat-{unique_marker()}", models=[MODEL]) + resources.defer(lambda: client.delete_key(key)) + + marker = unique_marker() + outcome = _first_ok( + client, + lambda: client.chat_raw(key, MODEL, f"reply with one word {marker}", stream=True, max_tokens=16), + ) + assert outcome.call_id is not None, "success response must carry x-litellm-call-id" + assert outcome.is_streaming, f"response must be an event stream, got content-type {outcome.content_type!r}" + assert outcome.chunks > 0, "the stream must deliver at least one event" + assert outcome.stream_error is None, ( + f"the stream carried an upstream error event despite the 200: {outcome.stream_error}" + ) + + genai_span = f"chat {MODEL}" + hits = otel_reader.poll_traces_for_call( + call_id=outcome.call_id, + settled_names=_settled_names(route=route, genai_span=genai_span), + settled_prefixes={DB_SPAN_PREFIX}, + ) + _assert_complete_trace(hits, route=route, genai_span=genai_span) + + genai_spans = [span for span in hits[0].spans if span.operation_name == genai_span] + assert len(genai_spans) == 1, ( + f"a streamed call must produce exactly ONE gen-AI span, got {len(genai_spans)}; " + f"spans: {hits[0].span_names()}" + ) + assert _tag(genai_spans[0], "litellm.request.streaming") is True, ( + "the gen-AI span must record litellm.request.streaming=true; its absence means " + "the stream flag was dropped before the model call" + ) + + @pytest.mark.covers("logging.otel.stream.exports_metric", exercised_on=["messages"]) + def test_messages_stream_exports_complete_trace( + self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager + ) -> None: + """A successful streamed `/v1/messages` request should export one + complete OTEL trace. The trace must contain a single root `SERVER` + span, with the auth, database, cost, and gen-AI `CLIENT` spans all + connected back to that root. + + This endpoint has the same streaming lifecycle risk as + `/chat/completions`: the gen-AI span is closed by the + stream-consumption path after the final chunk has arrived and usage + has been aggregated. + + The test therefore confirms that: + + * The response actually streams. + * Exactly one gen-AI span is created for the request. + * The gen-AI span contains `litellm.request.streaming=true`. + """ + route = "/v1/messages" + _assert_otel_destination_configured(client) + + key = client.key_with_alias(f"otel-stream-messages-{unique_marker()}", models=[MODEL]) + resources.defer(lambda: client.delete_key(key)) + + marker = unique_marker() + outcome = _first_ok( + client, + lambda: client.messages_raw(key, MODEL, f"reply with one word {marker}", max_tokens=16, stream=True), + ) + assert outcome.call_id is not None, "success response must carry x-litellm-call-id" + assert outcome.is_streaming, f"response must be an event stream, got content-type {outcome.content_type!r}" + assert outcome.chunks > 0, "the stream must deliver at least one event" + assert outcome.stream_error is None, ( + f"the stream carried an upstream error event despite the 200: {outcome.stream_error}" + ) + + genai_span = f"chat {MODEL}" + hits = otel_reader.poll_traces_for_call( + call_id=outcome.call_id, + settled_names=_settled_names(route=route, genai_span=genai_span), + settled_prefixes={DB_SPAN_PREFIX}, + ) + _assert_complete_trace(hits, route=route, genai_span=genai_span) + + genai_spans = [span for span in hits[0].spans if span.operation_name == genai_span] + assert len(genai_spans) == 1, ( + f"a streamed call must produce exactly ONE gen-AI span, got {len(genai_spans)}; " + f"spans: {hits[0].span_names()}" + ) + assert _tag(genai_spans[0], "litellm.request.streaming") is True, ( + "the gen-AI span must record litellm.request.streaming=true; its absence means " + "the stream flag was dropped before the model call" + ) + + @pytest.mark.covers("logging.otel.stream.exports_metric", exercised_on=["responses"]) + def test_responses_stream_exports_complete_trace( + self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager + ) -> None: + """A successful streamed /v1/responses request should export one complete + OTEL trace. The trace must contain a single root SERVER span, with the + auth, database, and gen-AI CLIENT spans all connected back to that + root. + + This endpoint has the same streaming lifecycle risk as the other + streaming surfaces: the gen-AI span is closed by the + stream-consumption path after the final event has arrived and usage + has been aggregated. + + The test therefore confirms that: + + * The response actually streams. + * Exactly one gen-AI span is created for the request. + * Spend is recorded correctly. + """ + route = "/v1/responses" + _assert_otel_destination_configured(client) + + key = client.key_with_alias( + f"otel-stream-responses-{unique_marker()}", models=[CHEAP_OPENAI_MODEL] + ) + resources.defer(lambda: client.delete_key(key)) + + marker = unique_marker() + outcome = _first_ok( + client, + lambda: client.responses_raw(key, CHEAP_OPENAI_MODEL, f"reply with one word {marker}", stream=True), + ) + assert outcome.call_id is not None, "success response must carry x-litellm-call-id" + assert outcome.is_streaming, f"response must be an event stream, got content-type {outcome.content_type!r}" + assert outcome.chunks > 0, "the stream must deliver at least one event" + assert outcome.stream_error is None, ( + f"the stream carried an upstream error event despite the 200: {outcome.stream_error}" + ) + + genai_span = f"chat {CHEAP_OPENAI_MODEL}" + hits = otel_reader.poll_traces_for_call( + call_id=outcome.call_id, + settled_names=_settled_names(route=route, genai_span=genai_span, require_cost_span=False), + settled_prefixes={DB_SPAN_PREFIX}, + ) + _assert_complete_trace(hits, route=route, genai_span=genai_span, require_cost_span=False) + + genai_spans = [span for span in hits[0].spans if span.operation_name == genai_span] + assert len(genai_spans) == 1, ( + f"a streamed call must produce exactly ONE gen-AI span, got {len(genai_spans)}; " + f"spans: {hits[0].span_names()}" + ) + + spend_row = client.poll_proxy_spend_for_key(key) + assert spend_row is not None and spend_row.spend is not None and spend_row.spend > 0, ( + "a successful streamed responses call must record a positive-spend row in /spend/logs " + "(the cost-write SPAN is knowingly absent on this surface, LIT-4428, but the spend " + f"itself must land); got {spend_row!r}" + ) + assert spend_row.call_type == "aresponses", ( + f"the spend row must be attributed to the responses call type, got {spend_row.call_type!r}" + ) + + @pytest.mark.covers("logging.otel.failure.exports_metric", exercised_on=["chat_completions"]) + def test_failed_chat_completions_error_span_attributes( + self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager + ) -> None: + """This test checks that a failed `/chat/completions` request produces a + single, complete OTEL trace. The model-call span should include all + expected error attributes, a non-empty stack trace, and the full, + untruncated `error.message`. The provider error embedded in that + message must also remain valid JSON. The root server span should + record the same 401 response returned to the client. + + The test uses a deployment with an invalid upstream API key. This + allows the request to pass LiteLLM’s proxy authentication and fail at + the provider, which is necessary to generate a model-call error span. + There should be no cost-write span because failed requests are not + billed.""" + route = "/chat/completions" + _assert_otel_destination_configured(client) + + model_name = f"otel-err-{unique_marker()}" + model_id = client.create_model( + model_name, + LiteLLMParamsBody(model="anthropic/claude-haiku-4-5", api_key=INVALID_UPSTREAM_API_KEY), + ) + resources.defer(lambda: client.delete_model(model_id)) + key = client.key_with_alias(f"otel-err-{unique_marker()}", models=[model_name]) + resources.defer(lambda: client.delete_key(key)) + + deadline = time.monotonic() + client.gateway.poll_timeout + while True: + outcome = client.chat_raw(key, model_name, "trigger an upstream auth failure", max_tokens=16) + assert not outcome.ok, "the call must fail; the deployment's upstream key is invalid" + if "AnthropicException" in outcome.body or time.monotonic() >= deadline: + break + time.sleep(client.gateway.poll_interval) + assert "AnthropicException" in outcome.body, ( + "never saw the upstream provider failure before the deadline; the key may still be " + f"propagating - last outcome {outcome.status_code}: {outcome.body[:200]}" + ) + assert outcome.status_code == 401, ( + f"an upstream auth failure must map to 401, got {outcome.status_code}: {outcome.body[:200]}" + ) + assert outcome.call_id is not None, "failed responses must still carry x-litellm-call-id" + + genai_span = f"chat {model_name}" + hits = otel_reader.poll_traces_for_call( + call_id=outcome.call_id, + settled_names=_settled_names(route=route, genai_span=genai_span, require_cost_span=False), + settled_prefixes={DB_SPAN_PREFIX}, + ) + _assert_complete_trace(hits, route=route, genai_span=genai_span, require_cost_span=False) + + root = next(span for span in hits[0].spans if not span.references) + assert str(_tag(root, "http.status_code")) == "401", ( + f"the SERVER span must record the 401 the client received, got {_tag(root, 'http.status_code')!r}" + ) + genai = next(span for span in hits[0].spans if span.operation_name == genai_span) + _assert_error_span_contract(genai) diff --git a/tests/e2e/management/test_management_e2e.py b/tests/e2e/management/test_management_e2e.py index cbd5db0d59f..7a05a5c520f 100644 --- a/tests/e2e/management/test_management_e2e.py +++ b/tests/e2e/management/test_management_e2e.py @@ -111,7 +111,7 @@ class TestKeyRoutes: key = _generate_key( client, resources, - KeyGenerateBody(models=["gemini-2.5-flash"], key_alias=alias, tpm_limit=424242), + KeyGenerateBody(models=["gemini-2.5-flash"], key_alias=alias, tpm_limit=424242, rpm_limit=424243), ) info = client.gateway.key_info(key) @@ -122,6 +122,9 @@ class TestKeyRoutes: assert info.tpm_limit == 424242, ( f"/key/info reports tpm_limit {info.tpm_limit}, configured 424242" ) + assert info.rpm_limit == 424243, ( + f"/key/info reports rpm_limit {info.rpm_limit}, configured 424243" + ) _poll_chat_ok(client, key, "gemini-2.5-flash") _assert_model_denied( diff --git a/tests/e2e/models.py b/tests/e2e/models.py index e32f2709181..4140967f3e0 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import Literal -from pydantic import BaseModel, ConfigDict, RootModel +from pydantic import BaseModel, ConfigDict, RootModel, model_validator # ---------- keys ---------- @@ -82,6 +82,7 @@ class KeyInfo(BaseModel): key_alias: str | None = None models: list[str] = [] tpm_limit: int | None = None + rpm_limit: int | None = None team_id: str | None = None spend: float | None = None max_budget: float | None = None @@ -153,6 +154,7 @@ class AnthropicMessagesBody(BaseModel): model: str messages: list[ChatMessage] max_tokens: int + stream: bool | None = None class AnthropicMessagesResponse(BaseModel): @@ -255,6 +257,16 @@ class SpendLogsParams(BaseModel): request_id: str | None = None api_key: str | None = None + @model_validator(mode="after") + def require_filter(self) -> SpendLogsParams: + if self.request_id is None and self.api_key is None: + raise ValueError( + "unfiltered /spend/logs returns the entire spend table and OOMs the " + "runner on long-lived environments; filter by request_id or api_key, " + "or use Gateway.spend_logs_window for a bounded /spend/logs/v2 read" + ) + return self + class SpendLogsPageParams(BaseModel): """Query for /spend/logs/v2, which requires an explicit date window and diff --git a/tests/e2e/quota_management/ratelimit/conftest.py b/tests/e2e/quota_management/ratelimit/conftest.py new file mode 100644 index 00000000000..4a5a73bb5e4 --- /dev/null +++ b/tests/e2e/quota_management/ratelimit/conftest.py @@ -0,0 +1,15 @@ +"""Quota-management suite's `client` fixture. + +The shared lifecycle (resources/scoped_key), proxy liveness skip, and e2e marker +live in the parent tests/e2e/conftest.py. QuotaClient holds the shared Gateway, +so the `resources` fixture cleans up keys through it. +""" + +import pytest + +from quota_client import QuotaClient, build_client + + +@pytest.fixture(scope="session") +def client() -> QuotaClient: + return build_client() diff --git a/tests/e2e/quota_management/ratelimit/quota_client.py b/tests/e2e/quota_management/ratelimit/quota_client.py new file mode 100644 index 00000000000..806ab1d1557 --- /dev/null +++ b/tests/e2e/quota_management/ratelimit/quota_client.py @@ -0,0 +1,32 @@ +"""Client for the quota-management suite: the shared Gateway plus raw chat +calls judged by HTTP status, body, and headers (a rate-limit block is a 429 +whose body and retry-after header carry the contract, not a typed success +model).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from e2e_gateway import Gateway, build_gateway +from e2e_http import StreamingResponse +from models import ChatBody, ChatMessage + + +@dataclass(frozen=True, slots=True) +class QuotaClient: + gateway: Gateway + + def chat(self, key: str, model: str, content: str, *, max_tokens: int = 16) -> StreamingResponse: + return self.gateway.transport.send( + "/chat/completions", + headers=self.gateway.transport.bearer(key), + json=ChatBody( + model=model, + messages=[ChatMessage(role="user", content=content)], + max_tokens=max_tokens, + ), + ) + + +def build_client() -> QuotaClient: + return QuotaClient(gateway=build_gateway()) diff --git a/tests/e2e/quota_management/ratelimit/test_rate_limit_e2e.py b/tests/e2e/quota_management/ratelimit/test_rate_limit_e2e.py new file mode 100644 index 00000000000..a6c15b79bb1 --- /dev/null +++ b/tests/e2e/quota_management/ratelimit/test_rate_limit_e2e.py @@ -0,0 +1,254 @@ +"""Live e2e: key-level rpm/tpm rate limits on the gateway. + +Covers quota_management.ratelimit.*: a key generated with rpm_limit/tpm_limit gets a +429 once the limit is crossed inside one window (blocks_over_limit), serves +again once the window rolls and no sooner (resets_after_window), and successful responses +report x-ratelimit-* limit/remaining headers so clients can pace +(headers_report_remaining). Each test asserts both halves of the contract: the +recorded state (/key/info echoes the configured limit) and the enforced +behavior (the 429, the recovery, or the headers on live traffic). + +The v3 limiter counts a request against the rpm budget at the pre-call hook, +before model routing, so every call that clears auth consumes budget whether or +not it ultimately succeeds. The tpm budget is reserved pre-call from an estimate +(message chars // 4 + max_tokens) and reconciled to the body's actual +usage.total_tokens after the call, so a block may legitimately fire before the +actual spend crosses the limit; the tpm test asserts the exact contract on both +sides (a 429 only once the blocked call's reservation exceeds the remaining +budget, and no later than the first call after actual spend reaches the limit). +All calls of one test must land inside a single window +(LITELLM_RATE_LIMIT_WINDOW_SIZE, 60s default), which real chat latency +comfortably allows. + +The window opens at the pre-call hook of the first counted call, which happens +after that call is sent, so the send timestamp of the winning first call is a +lower bound on the window start. The reset test uses it to reject an early +reset: recovery must not arrive before the full window has elapsed from that +send, less a small tolerance for the limiter's integer-second window +arithmetic. +""" + +from __future__ import annotations + +import re +import time +from dataclasses import dataclass + +import pytest +from pydantic import BaseModel, ConfigDict, ValidationError + +from e2e_config import CHEAP_ANTHROPIC_MODEL, unique_marker +from e2e_http import StreamingResponse, require_successful_call +from lifecycle import ResourceManager +from models import KeyGenerateBody +from quota_client import QuotaClient + +pytestmark = pytest.mark.e2e + +MODEL = CHEAP_ANTHROPIC_MODEL +TPM_LIMIT = 60 +CHAT_MAX_TOKENS = 16 +RESERVATION_CHARS_PER_TOKEN = 4 +WINDOW_SECONDS = 60 +RESET_TOLERANCE_SECONDS = 5 +LAST_CALL_LATENCY_MARGIN_SECONDS = 10 + + +@dataclass(frozen=True, slots=True) +class _FirstOk: + sent_at: float + response: StreamingResponse + + +class _ChatUsage(BaseModel): + model_config = ConfigDict(extra="ignore") + + total_tokens: int + + +class _ChatBodyWithUsage(BaseModel): + model_config = ConfigDict(extra="ignore") + + usage: _ChatUsage + + +def _total_tokens(outcome: StreamingResponse) -> int: + try: + return _ChatBodyWithUsage.model_validate_json(outcome.body).usage.total_tokens + except ValidationError: + pytest.fail(f"successful chat body must report usage.total_tokens, got: {outcome.body[:300]}") + + +def _reserved_tokens(content: str) -> int: + return max(1, len(content) // RESERVATION_CHARS_PER_TOKEN) + CHAT_MAX_TOKENS + + +def _remaining_from_429(body: str) -> int: + found = re.search(r"Remaining: (\d+)", body) + if found is None: + pytest.fail(f"429 body must report the remaining budget, got: {body[:300]}") + return int(found.group(1)) + + +@dataclass(frozen=True, slots=True) +class _BlockedByReservation: + outcome: StreamingResponse + content: str + spent: int + + +@dataclass(frozen=True, slots=True) +class _CrossedLimit: + spent: int + + +def _spend_until_blocked_or_crossed( + client: QuotaClient, key: str, first: _FirstOk +) -> _BlockedByReservation | _CrossedLimit: + """Drive chat traffic, summing each body's actual usage.total_tokens, until + the limiter blocks (which the reservation may do before the actual spend + crosses the limit) or the actual spend reaches the limit.""" + window_deadline = first.sent_at + WINDOW_SECONDS - LAST_CALL_LATENCY_MARGIN_SECONDS + spent = _total_tokens(first.response) + while spent < TPM_LIMIT: + assert time.monotonic() < window_deadline, ( + f"spent only {spent} of {TPM_LIMIT} tokens before the {WINDOW_SECONDS}s window could roll; " + "the exact-crossing assertion needs every call inside one window" + ) + content = f"reply with one word {unique_marker()}" + outcome = client.chat(key, MODEL, content, max_tokens=CHAT_MAX_TOKENS) + if outcome.status_code == 429: + return _BlockedByReservation(outcome=outcome, content=content, spent=spent) + require_successful_call(outcome) + spent += _total_tokens(outcome) + return _CrossedLimit(spent=spent) + + +def _limited_key( + client: QuotaClient, + resources: ResourceManager, + *, + rpm_limit: int | None = None, + tpm_limit: int | None = None, +) -> str: + key = client.gateway.generate_key(KeyGenerateBody(models=[MODEL], rpm_limit=rpm_limit, tpm_limit=tpm_limit)) + resources.defer(lambda: client.gateway.delete_key(key)) + return key + + +def _chat(client: QuotaClient, key: str) -> StreamingResponse: + return client.chat(key, MODEL, f"reply with one word {unique_marker()}") + + +def _first_ok(client: QuotaClient, key: str) -> _FirstOk: + """First successful call on a fresh key, which opens the rate-limit window; + `sent_at` is captured just before the winning send, so the window opened no + earlier than it. A fresh key may briefly 401 until the data plane's auth + cache picks it up, so retry on 401 to a deadline; a 401 never reaches the + rate limiter, so only the successful call consumes budget. Any other failure + is behavior under test and fails hard.""" + deadline = time.monotonic() + client.gateway.poll_timeout + while True: + sent_at = time.monotonic() + outcome = _chat(client, key) + if outcome.ok: + return _FirstOk(sent_at=sent_at, response=outcome) + if outcome.status_code != 401 or time.monotonic() >= deadline: + require_successful_call(outcome) + time.sleep(client.gateway.poll_interval) + + +def _assert_rate_limited(outcome: StreamingResponse, limit_type: str) -> None: + assert outcome.status_code == 429, ( + f"expected a 429 {limit_type} rate-limit block, got {outcome.status_code}: {outcome.body[:300]}" + ) + assert "Rate limit exceeded for api_key" in outcome.body, ( + f"429 body must name the api_key scope, got: {outcome.body[:300]}" + ) + assert f"Limit type: {limit_type}" in outcome.body, ( + f"429 body must carry 'Limit type: {limit_type}', got: {outcome.body[:300]}" + ) + retry_after = outcome.headers.get("retry-after") + assert retry_after is not None and retry_after.isdigit() and int(retry_after) > 0, ( + f"429 must carry a positive integer retry-after header, got {retry_after!r}" + ) + + +class TestKeyRateLimits: + @pytest.mark.covers("quota_management.ratelimit.rpm.blocks_over_limit") + def test_rpm_limit_blocks_over_limit(self, client: QuotaClient, resources: ResourceManager) -> None: + key = _limited_key(client, resources, rpm_limit=3) + info = client.gateway.key_info(key) + assert info.rpm_limit == 3, f"/key/info reports rpm_limit {info.rpm_limit}, configured 3" + + _ = _first_ok(client, key) + for _ in range(2): + require_successful_call(_chat(client, key)) + + _assert_rate_limited(_chat(client, key), "requests") + + @pytest.mark.covers("quota_management.ratelimit.tpm.blocks_over_limit") + def test_tpm_limit_blocks_over_limit(self, client: QuotaClient, resources: ResourceManager) -> None: + key = _limited_key(client, resources, tpm_limit=TPM_LIMIT) + info = client.gateway.key_info(key) + assert info.tpm_limit == TPM_LIMIT, f"/key/info reports tpm_limit {info.tpm_limit}, configured {TPM_LIMIT}" + + first = _first_ok(client, key) + match _spend_until_blocked_or_crossed(client, key, first): + case _BlockedByReservation(outcome=outcome, content=content, spent=spent): + _assert_rate_limited(outcome, "tokens") + remaining = _remaining_from_429(outcome.body) + reserved = _reserved_tokens(content) + assert reserved > remaining, ( + f"blocked while the call still fit: {remaining} of {TPM_LIMIT} tokens remained but the call " + f"reserved only {reserved} ({spent} actual tokens spent so far)" + ) + case _CrossedLimit(): + _assert_rate_limited(_chat(client, key), "tokens") + + @pytest.mark.covers("quota_management.ratelimit.rpm.resets_after_window") + def test_rpm_limit_resets_after_window(self, client: QuotaClient, resources: ResourceManager) -> None: + key = _limited_key(client, resources, rpm_limit=1) + + first = _first_ok(client, key) + _assert_rate_limited(_chat(client, key), "requests") + + deadline = time.monotonic() + client.gateway.poll_timeout + while time.monotonic() < deadline: + attempt_sent_at = time.monotonic() + outcome = _chat(client, key) + if outcome.ok: + window_age = attempt_sent_at - first.sent_at + assert window_age >= WINDOW_SECONDS - RESET_TOLERANCE_SECONDS, ( + f"the key recovered {window_age:.1f}s after the window opened, before the " + f"{WINDOW_SECONDS}s window (less {RESET_TOLERANCE_SECONDS}s tolerance) elapsed; " + "the limiter reset early instead of after the window" + ) + return + assert outcome.status_code == 429, ( + f"while the window drains only 429s are acceptable, got {outcome.status_code}: {outcome.body[:300]}" + ) + time.sleep(client.gateway.poll_interval) + pytest.fail("a blocked key never recovered after the rate-limit window elapsed") + + @pytest.mark.covers("quota_management.ratelimit.rpm.headers_report_remaining") + def test_headers_report_limit_and_remaining(self, client: QuotaClient, resources: ResourceManager) -> None: + key = _limited_key(client, resources, rpm_limit=5, tpm_limit=100000) + + first = _first_ok(client, key).response + assert first.headers.get("x-ratelimit-api_key-limit-requests") == "5", ( + f"success response must report the key's request limit, headers: " + f"{ {k: v for k, v in first.headers.items() if 'ratelimit' in k} }" + ) + assert first.headers.get("x-ratelimit-api_key-remaining-requests") == str(5 - 1), ( + f"first call against rpm_limit=5 must leave {5 - 1} remaining, got " + f"{first.headers.get('x-ratelimit-api_key-remaining-requests')!r}" + ) + assert first.headers.get("x-ratelimit-api_key-limit-tokens") == "100000", ( + f"success response must report the key's token limit, got " + f"{first.headers.get('x-ratelimit-api_key-limit-tokens')!r}" + ) + remaining_tokens = first.headers.get("x-ratelimit-api_key-remaining-tokens") + assert remaining_tokens is not None and remaining_tokens.isdigit() and int(remaining_tokens) < 100000, ( + f"one call must leave remaining tokens reported and below the limit, got {remaining_tokens!r}" + ) diff --git a/tests/e2e/test_e2e_gateway.py b/tests/e2e/test_e2e_gateway.py index 9a9aa2fd2cc..70e846ad8d5 100644 --- a/tests/e2e/test_e2e_gateway.py +++ b/tests/e2e/test_e2e_gateway.py @@ -1,17 +1,23 @@ """Unit coverage for the Gateway model-management surface (create_model / -delete_model). +delete_model) and the bounded spend read-back (spend_logs_window). The batches conftest and several llm_translation tests register deployments at runtime through gateway.create_model; when that method went missing, every batch test errored at fixture setup (AttributeError) before a single request reached the proxy. This pins the surface with a typed fake Transport so a rename or signature drift fails here instead of in a live stage run. + +spend_logs_window exists because the unpaginated /spend/logs whole-table read +grew past the e2e runner's memory limit on stage and OOMKilled every run; these +tests pin its /spend/logs/v2 pagination and that SpendLogsParams can no longer +express the unfiltered read. """ from dataclasses import dataclass, field +from datetime import datetime, timezone import pytest -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from batches.batch_client import BatchClient from e2e_gateway import Gateway @@ -30,6 +36,9 @@ from models import ( ModelNewBody, ModelNewResponse, ModelsListResponse, + SpendLogsPage, + SpendLogsPageParams, + SpendLogsParams, ) @@ -46,6 +55,8 @@ class _RecordingTransport: servable_after_gets: int = 0 models_error: UnknownApiError | None = None model_gets: int = 0 + spend_total: int = 0 + spend_gets: list[SpendLogsPageParams] = field(default_factory=list) _created: list[str] = field(default_factory=list) def post[R: BaseModel]( @@ -91,6 +102,22 @@ class _RecordingTransport: return Success( data=response_type.model_validate({"data": [{"id": name} for name in visible]}) ) + if path == "/spend/logs/v2" and response_type is SpendLogsPage: + assert isinstance(params, SpendLogsPageParams) + self.spend_gets.append(params) + offset = (params.page - 1) * params.page_size + count = min(params.page_size, max(self.spend_total - offset, 0)) + return Success( + data=response_type.model_validate( + { + "data": [{"request_id": f"req-{offset + i}"} for i in range(count)], + "total": self.spend_total, + "page": params.page, + "page_size": params.page_size, + "total_pages": (self.spend_total + params.page_size - 1) // params.page_size, + } + ) + ) raise AssertionError(f"unexpected get: {path}") def delete[R: BaseModel]( @@ -202,3 +229,45 @@ def test_gateway_delete_model_posts_the_model_id() -> None: assert path == "/model/delete" assert isinstance(body, ModelDeleteBody) assert body.id == "registered-id" + + +WINDOW_START = datetime(2026, 7, 14, 12, 0, 0, tzinfo=timezone.utc) +WINDOW_END = datetime(2026, 7, 14, 14, 0, 0, tzinfo=timezone.utc) + + +def test_gateway_spend_logs_window_pages_through_every_row_in_the_window() -> None: + transport = _RecordingTransport(spend_total=250) + gateway = Gateway(transport=transport) + + rows = gateway.spend_logs_window(start=WINDOW_START, end=WINDOW_END) + + assert len(rows) == 250 + assert len({row.request_id for row in rows}) == 250 + assert [params.page for params in transport.spend_gets] == [1, 2, 3] + assert all(params.start_date == "2026-07-14 12:00:00" for params in transport.spend_gets) + assert all(params.end_date == "2026-07-14 14:00:00" for params in transport.spend_gets) + + +def test_gateway_spend_logs_window_stops_at_an_exact_page_boundary() -> None: + transport = _RecordingTransport(spend_total=200) + gateway = Gateway(transport=transport) + + rows = gateway.spend_logs_window(start=WINDOW_START, end=WINDOW_END) + + assert len(rows) == 200 + assert [params.page for params in transport.spend_gets] == [1, 2] + + +def test_gateway_spend_logs_window_returns_empty_for_an_empty_window() -> None: + transport = _RecordingTransport(spend_total=0) + gateway = Gateway(transport=transport) + + rows = gateway.spend_logs_window(start=WINDOW_START, end=WINDOW_END) + + assert rows == [] + assert [params.page for params in transport.spend_gets] == [1] + + +def test_spend_logs_params_rejects_the_unfiltered_whole_table_read() -> None: + with pytest.raises(ValidationError, match="spend_logs_window"): + SpendLogsParams() diff --git a/tests/logging_callback_tests/test_logging_redaction_e2e_test.py b/tests/logging_callback_tests/test_logging_redaction_e2e_test.py index 891e5020f37..d56d5e51b04 100644 --- a/tests/logging_callback_tests/test_logging_redaction_e2e_test.py +++ b/tests/logging_callback_tests/test_logging_redaction_e2e_test.py @@ -2,7 +2,7 @@ import io import os import sys -from typing import Optional +from typing import Optional, Union sys.path.insert(0, os.path.abspath("../..")) @@ -12,6 +12,7 @@ import json import logging import time from unittest.mock import AsyncMock, patch +from datetime import datetime import httpx import pytest @@ -20,17 +21,24 @@ import litellm from litellm._logging import verbose_logger from litellm.integrations.custom_logger import CustomLogger from litellm.responses.main import mock_responses_api_response -from litellm.types.utils import StandardLoggingPayload +from litellm.types.utils import ( + ModelResponse, + ResponsesAPIResponse, + StandardLoggingPayload, + TextCompletionResponse, +) class TestCustomLogger(CustomLogger): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.logged_standard_logging_payload: Optional[StandardLoggingPayload] = None + self.response_obj: Optional[Union[ModelResponse, TextCompletionResponse, ResponsesAPIResponse]] = None async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): standard_logging_payload = kwargs.get("standard_logging_object", None) self.logged_standard_logging_payload = standard_logging_payload + self.response_obj = response_obj @pytest.mark.asyncio @@ -108,6 +116,78 @@ async def test_dynamic_turn_off_message_logging_overrides_global_off(dynamic_tur assert standard_logging_payload["messages"][0]["content"] == expected_message_content +@pytest.mark.asyncio +async def test_redaction_with_custom_logger_streaming(): + """Test redaction of responses for custom logger callbacks""" + from litellm.litellm_core_utils.litellm_logging import Logging + + class LoggingWithoutSyncSuccessHandler(Logging): + def success_handler(self, result=None, start_time=None, end_time=None, cache_hit=None, **kwargs): + pass + + litellm.turn_off_message_logging = True + test_custom_logger = TestCustomLogger() + + try: + litellm_logging_obj = LoggingWithoutSyncSuccessHandler( + model="gpt-5-mini", + messages=[{"role": "user", "content": "hi"}], + stream=True, + call_type="acompletion", + litellm_call_id="1234", + start_time=datetime.now(), + function_id="1234", + dynamic_async_success_callbacks=[test_custom_logger], + ) + + response = await litellm.acompletion( + model="gpt-5-mini", + messages=[{"role": "user", "content": "hi"}], + mock_response="hello", + stream=True, + litellm_logging_obj=litellm_logging_obj, + ) + + # Consume the stream to trigger logging + chunks = [] + async for chunk in response: + chunks.append(chunk) + + await asyncio.sleep(1) + async_complete_streaming_response = test_custom_logger.response_obj + assert async_complete_streaming_response is not None + assert async_complete_streaming_response.choices[0].message.content == "redacted-by-litellm" + finally: + litellm.turn_off_message_logging = False + + +@pytest.mark.asyncio +async def test_streaming_redaction_scoped_to_opted_out_logger(): + """One logger opting out of message logging must not blank the response for other loggers""" + litellm.turn_off_message_logging = False + opted_out_logger = TestCustomLogger(message_logging=False) + compliant_logger = TestCustomLogger() + litellm.callbacks = [opted_out_logger, compliant_logger] + + try: + response = await litellm.acompletion( + model="gpt-5-mini", + messages=[{"role": "user", "content": "hi"}], + mock_response="hello", + stream=True, + ) + async for _ in response: + pass + + await asyncio.sleep(1) + assert opted_out_logger.response_obj is not None + assert opted_out_logger.response_obj.choices[0].message.content == "redacted-by-litellm" + assert compliant_logger.response_obj is not None + assert compliant_logger.response_obj.choices[0].message.content == "hello" + finally: + litellm.callbacks = [] + + @pytest.mark.asyncio async def test_redaction_responses_api(): """Test redaction with ResponsesAPIResponse format""" diff --git a/tests/proxy_unit_tests/test_user_api_key_auth.py b/tests/proxy_unit_tests/test_user_api_key_auth.py index 958b028c542..5471d2668e4 100644 --- a/tests/proxy_unit_tests/test_user_api_key_auth.py +++ b/tests/proxy_unit_tests/test_user_api_key_auth.py @@ -219,8 +219,8 @@ async def test_aaauser_personal_budgets(key_ownership): """ Set a personal budget on a user - - have it only apply when key belongs to user -> raises BudgetExceededError - - if key belongs to team, have key respect team budget -> allows call to go through + User budget is enforced regardless of key ownership (personal or team). + Both cases should raise BudgetExceededError when the user is over budget. """ import asyncio import time @@ -229,7 +229,12 @@ async def test_aaauser_personal_budgets(key_ownership): from starlette.datastructures import URL import litellm - from litellm.proxy._types import LiteLLM_UserTable, UserAPIKeyAuth + from litellm.proxy._types import ( + LiteLLM_UserTable, + ProxyErrorTypes, + ProxyException, + UserAPIKeyAuth, + ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.proxy_server import hash_token, user_api_key_cache @@ -273,14 +278,9 @@ async def test_aaauser_personal_budgets(key_ownership): == valid_token ) - try: + with pytest.raises(ProxyException) as exc_info: await user_api_key_auth(request=request, api_key="Bearer " + user_key) - - if key_ownership == "user_key": - pytest.fail("Expected this call to fail. User is over limit.") - except Exception: - if key_ownership == "team_key": - pytest.fail("Expected this call to work. Key is below team budget.") + assert exc_info.value.type == ProxyErrorTypes.budget_exceeded @pytest.mark.asyncio diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index d300f326b9e..9289dece83f 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -1614,3 +1614,105 @@ class TestGuardrailInterventionClassification: slg = request_data["metadata"]["standard_logging_guardrail_information"][0] assert slg["guardrail_status"] == "guardrail_intervened" + + +class _ApplyStyleGuardrail(CustomGuardrail): + """Overrides only apply_guardrail, like openai_moderation; async_pre_call_hook stays the CustomLogger no-op.""" + + def __init__(self, block: bool): + from litellm.types.guardrails import GuardrailEventHooks + + super().__init__( + guardrail_name="apply-style-guardrail", + event_hook=GuardrailEventHooks.pre_call, + default_on=False, + ) + self.block = block + self.apply_called = False + self.seen_texts = None + + async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None): + from fastapi import HTTPException + + self.apply_called = True + self.seen_texts = inputs.get("texts") + if self.block: + raise HTTPException(status_code=400, detail={"error": "Violated moderation policy"}) + return inputs + + +class TestApplyGuardrailStyleDeploymentDispatch: + """LIT-4217 regression: model-level guardrails that implement only the + unified apply_guardrail interface must execute in + async_pre_call_deployment_hook instead of silently hitting the + async_pre_call_hook no-op.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize("call_type", [CallTypes.completion, CallTypes.acompletion]) + async def test_blocks_when_requested_via_model_level_guardrails(self, call_type): + from fastapi import HTTPException + + guardrail = _ApplyStyleGuardrail(block=True) + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "flagged content"}], + "guardrails": ["apply-style-guardrail"], + "metadata": {}, + } + + with pytest.raises(HTTPException): + await guardrail.async_pre_call_deployment_hook(kwargs, call_type) + + assert guardrail.apply_called is True + assert guardrail.seen_texts == ["flagged content"] + + @pytest.mark.asyncio + async def test_pass_path_runs_guardrail_and_strips_dispatch_key(self): + guardrail = _ApplyStyleGuardrail(block=False) + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "hello"}], + "guardrails": ["apply-style-guardrail"], + "metadata": {}, + } + + result = await guardrail.async_pre_call_deployment_hook(kwargs, CallTypes.acompletion) + + assert guardrail.apply_called is True + assert result is not None + assert "guardrail_to_apply" not in result + assert result["messages"] == [{"role": "user", "content": "hello"}] + + @pytest.mark.asyncio + async def test_skips_when_not_requested(self): + guardrail = _ApplyStyleGuardrail(block=True) + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "hello"}], + "guardrails": ["some-other-guardrail"], + "metadata": {}, + } + + result = await guardrail.async_pre_call_deployment_hook(kwargs, CallTypes.acompletion) + + assert guardrail.apply_called is False + assert result is not None + + @pytest.mark.asyncio + async def test_fails_closed_when_proxy_extras_missing(self): + import sys + from unittest.mock import patch + + guardrail = _ApplyStyleGuardrail(block=True) + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "flagged content"}], + "guardrails": ["apply-style-guardrail"], + "metadata": {}, + } + + with patch.dict(sys.modules, {"litellm.proxy.utils": None}): + with pytest.raises(ImportError, match="litellm\\[proxy\\]"): + await guardrail.async_pre_call_deployment_hook(kwargs, CallTypes.acompletion) + + assert guardrail.apply_called is False diff --git a/tests/test_litellm/integrations/test_prometheus_media_generation_metrics.py b/tests/test_litellm/integrations/test_prometheus_media_generation_metrics.py new file mode 100644 index 00000000000..2ce92f08ef0 --- /dev/null +++ b/tests/test_litellm/integrations/test_prometheus_media_generation_metrics.py @@ -0,0 +1,180 @@ +""" +Unit tests for the video-seconds and images-generated Prometheus counters (LIT-4254). + +Video providers report ``duration_seconds`` inside the usage object that lands +on ``standard_logging_payload["metadata"]["usage_object"]``; image generation +calls report ``output_image_count`` there. Both counters are sparse: only +incremented when the value is present and > 0. +""" + +from typing import get_args +from unittest.mock import MagicMock + +import pytest + +from litellm.integrations.prometheus import PrometheusLogger +from litellm.types.integrations.prometheus import ( + DEFINED_PROMETHEUS_METRICS, + PrometheusMetricLabels, + UserAPIKeyLabelValues, +) + +MEDIA_GENERATION_METRICS = [ + "litellm_video_duration_seconds_metric", + "litellm_images_generated_metric", +] + + +@pytest.fixture +def sample_enum_values(): + return UserAPIKeyLabelValues( + end_user="test-end-user", + hashed_api_key="test-key-hash", + api_key_alias="test-key-alias", + team="test-team", + team_alias="test-team-alias", + user="test-user", + model="sora-2", + ) + + +def _make_mock_logger(): + logger = MagicMock() + for name in MEDIA_GENERATION_METRICS: + setattr(logger, name, MagicMock()) + logger.get_labels_for_metric = MagicMock( + return_value=[ + "model", + "hashed_api_key", + "api_key_alias", + "team", + "team_alias", + "end_user", + "user", + ] + ) + return logger + + +class TestMediaGenerationMetricsRegistration: + def test_metrics_in_defined_prometheus_metrics(self): + defined = get_args(DEFINED_PROMETHEUS_METRICS) + for name in MEDIA_GENERATION_METRICS: + assert name in defined, f"{name} missing from DEFINED_PROMETHEUS_METRICS" + + def test_metric_labels_defined(self): + for name in MEDIA_GENERATION_METRICS: + assert hasattr(PrometheusMetricLabels, name), f"{name} missing from PrometheusMetricLabels" + + def test_metrics_share_output_token_label_set(self): + assert ( + PrometheusMetricLabels.litellm_video_duration_seconds_metric + == PrometheusMetricLabels.litellm_output_tokens_metric + ) + assert ( + PrometheusMetricLabels.litellm_images_generated_metric + == PrometheusMetricLabels.litellm_output_tokens_metric + ) + + def test_runtime_label_set_matches_output_tokens_metric(self): + """Full parity with litellm_output_tokens_metric, including the org labels + appended via _org_label_metrics, so existing token dashboards can be cloned.""" + expected = PrometheusMetricLabels.get_labels("litellm_output_tokens_metric") + for name in MEDIA_GENERATION_METRICS: + assert PrometheusMetricLabels.get_labels(name) == expected + + +class TestIncrementMediaGenerationMetrics: + def test_video_duration_incremented(self, sample_enum_values): + logger = _make_mock_logger() + payload = {"metadata": {"usage_object": {"duration_seconds": 8.0}}} + + PrometheusLogger._increment_media_generation_metrics( + logger, + standard_logging_payload=payload, + enum_values=sample_enum_values, + ) + + logger.litellm_video_duration_seconds_metric.labels().inc.assert_called_once_with(8.0) + logger.litellm_images_generated_metric.labels.assert_not_called() + + def test_image_count_incremented(self, sample_enum_values): + logger = _make_mock_logger() + payload = { + "metadata": { + "usage_object": { + "prompt_tokens": 18, + "completion_tokens": 391, + "total_tokens": 409, + "output_image_count": 2, + } + } + } + + PrometheusLogger._increment_media_generation_metrics( + logger, + standard_logging_payload=payload, + enum_values=sample_enum_values, + ) + + logger.litellm_images_generated_metric.labels().inc.assert_called_once_with(2.0) + logger.litellm_video_duration_seconds_metric.labels.assert_not_called() + + def test_token_only_usage_is_a_noop(self, sample_enum_values): + logger = _make_mock_logger() + payload = { + "metadata": { + "usage_object": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30, + } + } + } + + PrometheusLogger._increment_media_generation_metrics( + logger, + standard_logging_payload=payload, + enum_values=sample_enum_values, + ) + + for name in MEDIA_GENERATION_METRICS: + getattr(logger, name).labels.assert_not_called() + + @pytest.mark.parametrize("bad_value", [0, 0.0, None, -4.0, "4", True]) + def test_non_positive_or_non_numeric_values_are_ignored(self, sample_enum_values, bad_value): + logger = _make_mock_logger() + payload = { + "metadata": { + "usage_object": { + "duration_seconds": bad_value, + "output_image_count": bad_value, + } + } + } + + PrometheusLogger._increment_media_generation_metrics( + logger, + standard_logging_payload=payload, + enum_values=sample_enum_values, + ) + + for name in MEDIA_GENERATION_METRICS: + getattr(logger, name).labels.assert_not_called() + + def test_missing_usage_object_is_a_noop(self, sample_enum_values): + logger = _make_mock_logger() + + for payload in ({"metadata": {}}, {"metadata": None}, {"metadata": {"usage_object": "redacted"}}): + PrometheusLogger._increment_media_generation_metrics( + logger, + standard_logging_payload=payload, + enum_values=sample_enum_values, + ) + + for name in MEDIA_GENERATION_METRICS: + getattr(logger, name).labels.assert_not_called() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py b/tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py index bb035c4c3ee..9c6d2e018ff 100644 --- a/tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py +++ b/tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py @@ -326,3 +326,148 @@ async def test_should_leave_rate_limit_labels_blank_for_non_rate_limit_failure() assert isinstance(enum_values, UserAPIKeyLabelValues) assert enum_values.rate_limit_category is None assert enum_values.rate_limit_type is None + + +def _logger_with_mock_virtual_key_gauges() -> PrometheusLogger: + with patch( + "litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None + ): + logger = PrometheusLogger() + logger.litellm_remaining_api_key_requests_for_model = MagicMock() + logger.litellm_remaining_api_key_tokens_for_model = MagicMock() + logger.get_labels_for_metric = MagicMock(return_value=[]) + return logger + + +def _kwargs_with_v3_rate_limit_headers(additional_headers: dict) -> dict: + return { + "litellm_params": {"metadata": {"model_group": "gpt-4o-mini"}}, + "standard_logging_object": { + "metadata": {}, + "hidden_params": {"additional_headers": additional_headers}, + }, + } + + +def _set_virtual_key_metrics(logger: PrometheusLogger, kwargs: dict) -> None: + logger._set_virtual_key_rate_limit_metrics( + user_api_key="test-hash", + user_api_key_alias="test-alias", + kwargs=kwargs, + metadata=kwargs["litellm_params"]["metadata"], + model_id="model-123", + ) + + +def test_should_read_v3_remaining_headers_when_metadata_keys_absent(): + """ + Regression for LIT-2577: the default v3 rate limiter writes remaining + per-(key, model) values into + ``standard_logging_object.hidden_params.additional_headers`` as + ``x-ratelimit-model_per_key-remaining-{requests,tokens}`` and never sets + the legacy ``litellm-key-remaining-*`` metadata keys, so the gauges were + pinned to ``sys.maxsize``. + """ + logger = _logger_with_mock_virtual_key_gauges() + kwargs = _kwargs_with_v3_rate_limit_headers( + { + "x-ratelimit-model_per_key-remaining-requests": 42, + "x-ratelimit-model_per_key-remaining-tokens": 900, + "x-ratelimit-model_per_key-limit-requests": 100, + "x-ratelimit-model_per_key-limit-tokens": 1000, + } + ) + + _set_virtual_key_metrics(logger, kwargs) + + logger.litellm_remaining_api_key_requests_for_model.labels.return_value.set.assert_called_once_with( + 42 + ) + logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with( + 900 + ) + + +def test_should_prefer_legacy_metadata_keys_over_v3_headers(): + logger = _logger_with_mock_virtual_key_gauges() + kwargs = _kwargs_with_v3_rate_limit_headers( + { + "x-ratelimit-model_per_key-remaining-requests": 42, + "x-ratelimit-model_per_key-remaining-tokens": 900, + } + ) + kwargs["litellm_params"]["metadata"].update( + { + "litellm-key-remaining-requests-gpt-4o-mini": 3, + "litellm-key-remaining-tokens-gpt-4o-mini": 200, + } + ) + + _set_virtual_key_metrics(logger, kwargs) + + logger.litellm_remaining_api_key_requests_for_model.labels.return_value.set.assert_called_once_with( + 3 + ) + logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with( + 200 + ) + + +def test_should_treat_zero_v3_remaining_as_zero(): + logger = _logger_with_mock_virtual_key_gauges() + kwargs = _kwargs_with_v3_rate_limit_headers( + { + "x-ratelimit-model_per_key-remaining-requests": 0, + "x-ratelimit-model_per_key-remaining-tokens": 0, + } + ) + + _set_virtual_key_metrics(logger, kwargs) + + logger.litellm_remaining_api_key_requests_for_model.labels.return_value.set.assert_called_once_with( + 0 + ) + logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with( + 0 + ) + + +def test_should_keep_maxsize_sentinel_when_no_rate_limit_source_present(): + import sys + + logger = _logger_with_mock_virtual_key_gauges() + kwargs = { + "litellm_params": {"metadata": {"model_group": "gpt-4o-mini"}}, + "standard_logging_object": {"metadata": {}, "hidden_params": {}}, + } + + _set_virtual_key_metrics(logger, kwargs) + + logger.litellm_remaining_api_key_requests_for_model.labels.return_value.set.assert_called_once_with( + sys.maxsize + ) + logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with( + sys.maxsize + ) + + +@pytest.mark.parametrize("bad_value", ["not-a-number", None, True]) +def test_should_ignore_non_int_v3_header_values(bad_value): + import sys + + logger = _logger_with_mock_virtual_key_gauges() + kwargs = _kwargs_with_v3_rate_limit_headers( + { + "x-ratelimit-model_per_key-remaining-requests": bad_value, + "x-ratelimit-model_per_key-remaining-tokens": bad_value, + } + ) + + _set_virtual_key_metrics(logger, kwargs) + + logger.litellm_remaining_api_key_requests_for_model.labels.return_value.set.assert_called_once_with( + sys.maxsize + ) + logger.litellm_remaining_api_key_tokens_for_model.labels.return_value.set.assert_called_once_with( + sys.maxsize + ) diff --git a/tests/test_litellm/integrations/test_s3_v2.py b/tests/test_litellm/integrations/test_s3_v2.py index 246b378c982..f0a33f2ebfc 100644 --- a/tests/test_litellm/integrations/test_s3_v2.py +++ b/tests/test_litellm/integrations/test_s3_v2.py @@ -1125,6 +1125,47 @@ async def test_combined_prefix_reflects_in_s3_object_key(): assert "myteam/apikey/" in key, f"Expected both prefixes in key: {key}" +def test_s3_object_key_sanitizes_slashes_in_file_name(): + """Response ids containing slashes (e.g. bedrock batch job ARNs) must not + create nested S3 folders; only path/prefix/date slashes are separators.""" + from litellm.integrations.s3 import get_s3_object_key + + start_time = datetime(2026, 2, 11, 0, 35, 18, 391582) + file_name = "time-00-35-18-391582_arn:aws:bedrock:us-east-1:123456789012:model-invocation-job/gl18r6skk9yy" + + key = get_s3_object_key( + s3_path="LiteLLMAPPLogs", + prefix="myteam/", + start_time=start_time, + s3_file_name=file_name, + ) + + assert key == ( + "LiteLLMAPPLogs/myteam/2026-02-11/" + "time-00-35-18-391582_arn:aws:bedrock:us-east-1:123456789012:model-invocation-job_gl18r6skk9yy.json" + ) + + +def test_create_s3_batch_logging_element_flat_key_for_arn_response_id(): + """End-to-end through the s3_v2 element builder: an ARN response id must + yield a flat file directly under the date segment.""" + logger = S3Logger(s3_use_team_prefix=False, s3_use_key_prefix=False) + payload = StandardLoggingPayload( + id="arn:aws:bedrock:us-east-1:123456789012:model-invocation-job/gl18r6skk9yy", + metadata={}, + messages=[], + ) + + start_time = datetime(2026, 2, 11, 0, 35, 18, 391582) + result = logger.create_s3_batch_logging_element(start_time, payload) + + assert result is not None + date_segment = "2026-02-11/" + file_segment = result.s3_object_key.split(date_segment, 1)[1] + assert "/" not in file_segment, f"Expected flat file under date segment, got: {result.s3_object_key}" + assert file_segment.endswith("model-invocation-job_gl18r6skk9yy.json") + + # -------------------------------------------------------------- # params_source / s3_callback_params_override (audit-log decoupling) # -------------------------------------------------------------- diff --git a/tests/test_litellm/integrations/websearch_interception/test_websearch_responses.py b/tests/test_litellm/integrations/websearch_interception/test_websearch_responses.py new file mode 100644 index 00000000000..00b7299cc31 --- /dev/null +++ b/tests/test_litellm/integrations/websearch_interception/test_websearch_responses.py @@ -0,0 +1,318 @@ +""" +Integration tests for WebSearch interception with the Responses API. + +Tests that the websearch_interception callback intercepts litellm_web_search +tool calls returned by /v1/responses, executes the search server-side, and +builds a Responses-format follow-up request. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from litellm.integrations.websearch_interception.handler import ( + WebSearchInterceptionLogger, +) +from litellm.types.integrations.custom_logger import ( + RESPONSES_AGENTIC_SURFACE, +) +from litellm.types.utils import CallTypes, LlmProviders + + +def _responses_output_with_web_search(call_id: str = "fc_1", query: str = "latest ai news"): + return SimpleNamespace( + output=[ + SimpleNamespace( + type="function_call", + name="litellm_web_search", + call_id=call_id, + arguments='{"query": "%s"}' % query, + ) + ] + ) + + +@pytest.mark.asyncio +async def test_responses_hook_detects_function_call(): + """async_should_run_responses_agentic_loop detects a litellm_web_search function_call.""" + logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI]) + + should_run, tools_dict = await logger.async_should_run_responses_agentic_loop( + response=_responses_output_with_web_search(), + model="gpt-4o", + messages=[{"role": "user", "content": "What's the latest AI news?"}], + tools=[{"type": "function", "name": "litellm_web_search"}], + stream=False, + custom_llm_provider="openai", + kwargs={}, + ) + + assert should_run is True + assert tools_dict["response_format"] == "responses" + assert len(tools_dict["tool_calls"]) == 1 + assert tools_dict["tool_calls"][0]["name"] == "litellm_web_search" + assert tools_dict["tool_calls"][0]["call_id"] == "fc_1" + assert tools_dict["tool_calls"][0]["input"] == {"query": "latest ai news"} + + +@pytest.mark.asyncio +async def test_responses_hook_not_triggered_without_tool(): + """No web search tool in the request -> hook must not run.""" + logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI]) + + should_run, tools_dict = await logger.async_should_run_responses_agentic_loop( + response=_responses_output_with_web_search(), + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + tools=[{"type": "function", "name": "get_weather"}], + stream=False, + custom_llm_provider="openai", + kwargs={}, + ) + + assert should_run is False + assert tools_dict == {} + + +@pytest.mark.asyncio +async def test_responses_hook_not_triggered_for_disabled_provider(): + """Provider not in enabled_providers -> hook must not run.""" + logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.BEDROCK]) + + should_run, tools_dict = await logger.async_should_run_responses_agentic_loop( + response=_responses_output_with_web_search(), + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + tools=[{"type": "function", "name": "litellm_web_search"}], + stream=False, + custom_llm_provider="openai", + kwargs={}, + ) + + assert should_run is False + assert tools_dict == {} + + +@pytest.mark.asyncio +async def test_responses_hook_ignores_non_websearch_function_call(): + """A function_call for a different tool must not be intercepted.""" + logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI]) + response = SimpleNamespace( + output=[SimpleNamespace(type="function_call", name="get_weather", call_id="c1", arguments="{}")] + ) + + should_run, tools_dict = await logger.async_should_run_responses_agentic_loop( + response=response, + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + tools=[{"type": "function", "name": "litellm_web_search"}], + stream=False, + custom_llm_provider="openai", + kwargs={}, + ) + + assert should_run is False + assert tools_dict == {} + + +@pytest.mark.asyncio +async def test_responses_hook_ignores_bare_web_search_function_call(): + logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI]) + response = SimpleNamespace( + output=[SimpleNamespace(type="function_call", name="web_search", call_id="c1", arguments="{}")] + ) + + should_run, tools_dict = await logger.async_should_run_responses_agentic_loop( + response=response, + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + tools=[{"type": "function", "name": "litellm_web_search"}], + stream=False, + custom_llm_provider="openai", + kwargs={}, + ) + + assert should_run is False + assert tools_dict == {} + + +@pytest.mark.asyncio +async def test_surface_marker_routes_should_run_to_responses_branch(): + """async_should_run_agentic_loop must dispatch to the responses branch when the + surface marker says responses. + + Without the marker the default anthropic branch runs and never detects the + Responses-format function_call, so interception silently no-ops on /v1/responses. + """ + logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI]) + + should_run, tools_dict = await logger.async_should_run_agentic_loop( + response=_responses_output_with_web_search(), + model="gpt-4o", + messages=[{"role": "user", "content": "What's the latest AI news?"}], + tools=[{"type": "function", "name": "litellm_web_search"}], + stream=False, + custom_llm_provider="openai", + kwargs={"_agentic_loop_api_surface": RESPONSES_AGENTIC_SURFACE}, + ) + + assert should_run is True + assert tools_dict["response_format"] == "responses" + + +@pytest.mark.asyncio +async def test_default_branch_does_not_detect_responses_output(): + """Regression guard: the default (anthropic) branch must not detect a + Responses-format function_call, proving the responses branch is required. + """ + logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI]) + + should_run, tools_dict = await logger.async_should_run_agentic_loop( + response=_responses_output_with_web_search(), + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + tools=[{"type": "function", "name": "litellm_web_search"}], + stream=False, + custom_llm_provider="openai", + kwargs={}, + ) + + assert should_run is False + + +@pytest.mark.asyncio +async def test_build_responses_plan_produces_responses_input(): + """async_build_responses_agentic_loop_plan builds a Responses-format follow-up: + the user input followed by function_call + function_call_output items, with + the web search tool preserved and tool_choice stripped. + """ + logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI]) + + tools_dict = { + "tool_calls": [ + { + "id": "fc_1", + "call_id": "fc_1", + "type": "function_call", + "name": "litellm_web_search", + "arguments": '{"query": "latest ai news"}', + "input": {"query": "latest ai news"}, + } + ], + "tool_type": "websearch", + "provider": "openai", + "response_format": "responses", + } + + with patch.object( + logger, + "_execute_search", + new=AsyncMock(return_value=("OpenAI shipped a new model", None)), + ): + plan = await logger.async_build_responses_agentic_loop_plan( + tools=tools_dict, + model="gpt-4o", + messages=[{"role": "user", "content": "What's the latest AI news?"}], + response=_responses_output_with_web_search(), + optional_params={ + "tools": [{"type": "function", "name": "litellm_web_search"}], + "tool_choice": {"type": "function", "name": "litellm_web_search"}, + }, + logging_obj=MagicMock(), + stream=False, + kwargs={ + "custom_llm_provider": "openai", + "_agentic_loop_api_surface": RESPONSES_AGENTIC_SURFACE, + }, + ) + + assert plan.run_agentic_loop is True + patch_obj = plan.request_patch + assert patch_obj is not None + input_items = patch_obj.messages + assert input_items is not None + + assert input_items[0] == {"role": "user", "content": "What's the latest AI news?"} + assert input_items[1] == { + "type": "function_call", + "call_id": "fc_1", + "name": "litellm_web_search", + "arguments": '{"query": "latest ai news"}', + } + assert input_items[2] == { + "type": "function_call_output", + "call_id": "fc_1", + "output": "OpenAI shipped a new model", + } + + assert patch_obj.tools == [{"type": "function", "name": "litellm_web_search"}] + assert "tool_choice" not in patch_obj.optional_params + assert "_agentic_loop_api_surface" not in patch_obj.kwargs + assert patch_obj.model == "openai/gpt-4o" + + +@pytest.mark.asyncio +async def test_deployment_hook_converts_native_responses_web_search_tool(): + """async_pre_call_deployment_hook converts a native Responses web_search tool + into the flat litellm_web_search function tool (Responses shape, not the + nested Chat Completions {"function": {...}} wrapper). + """ + logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI]) + + result = await logger.async_pre_call_deployment_hook( + kwargs={ + "model": "gpt-4o", + "custom_llm_provider": "openai", + "tools": [{"type": "web_search"}], + }, + call_type=CallTypes.aresponses, + ) + + assert result is not None + converted_tools = result["tools"] + assert len(converted_tools) == 1 + tool = converted_tools[0] + assert tool["type"] == "function" + assert tool["name"] == "litellm_web_search" + assert "function" not in tool + assert tool["parameters"]["required"] == ["query"] + + +@pytest.mark.asyncio +async def test_deployment_hook_responses_returns_none_without_web_search(): + """No web search tool in a responses request -> deployment hook makes no change.""" + logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI]) + + result = await logger.async_pre_call_deployment_hook( + kwargs={ + "model": "gpt-4o", + "custom_llm_provider": "openai", + "tools": [{"type": "function", "name": "get_weather"}], + }, + call_type=CallTypes.aresponses, + ) + + assert result is None + + +@pytest.mark.asyncio +async def test_deployment_hook_responses_converts_stream_to_non_stream(): + """Streaming responses requests are converted to non-streaming so the agentic + loop can run, and flagged for re-wrapping afterwards. + """ + logger = WebSearchInterceptionLogger(enabled_providers=[LlmProviders.OPENAI]) + + result = await logger.async_pre_call_deployment_hook( + kwargs={ + "model": "gpt-4o", + "custom_llm_provider": "openai", + "tools": [{"type": "web_search_preview"}], + "stream": True, + }, + call_type=CallTypes.aresponses, + ) + + assert result is not None + assert result["stream"] is False + assert result["_websearch_interception_converted_stream"] is True diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index ca23e61352e..b156faf3ea6 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -270,6 +270,105 @@ def test_image_tokens_fallback_to_base_cost(): assert round(completion_cost, 12) == round(expected_completion_cost, 12) +def test_video_output_tokens_gemini_omni_flash_preview(): + """Video output tokens are billed at output_cost_per_video_token, not the text rate and not zero.""" + model = "gemini-omni-flash-preview" + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + text_tokens = 100 + video_tokens = 46336 + usage = Usage( + completion_tokens=text_tokens + video_tokens, + prompt_tokens=20, + total_tokens=20 + text_tokens + video_tokens, + completion_tokens_details=CompletionTokensDetailsWrapper( + text_tokens=text_tokens, + video_tokens=video_tokens, + ), + prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=20), + ) + model_cost_map = litellm.model_cost[f"gemini/{model}"] + assert model_cost_map["input_cost_per_token"] == 1.5e-06 + assert model_cost_map["output_cost_per_token"] == 9e-06 + assert model_cost_map["output_cost_per_video_token"] == 1.75e-05 + + prompt_cost, completion_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider="gemini", + ) + + assert round(prompt_cost, 10) == round( + model_cost_map["input_cost_per_token"] * usage.prompt_tokens, + 10, + ) + assert round(completion_cost, 10) == round( + (model_cost_map["output_cost_per_token"] * text_tokens) + + (model_cost_map["output_cost_per_video_token"] * video_tokens), + 10, + ) + + +def test_video_input_tokens_gemini_omni_flash_preview(): + """Video input tokens are billed at the standard input rate instead of being dropped.""" + model = "gemini-omni-flash-preview" + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + usage = Usage( + completion_tokens=10, + prompt_tokens=10050, + total_tokens=10060, + completion_tokens_details=CompletionTokensDetailsWrapper(text_tokens=10), + prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=50, video_tokens=10000), + ) + model_cost_map = litellm.model_cost[f"gemini/{model}"] + + prompt_cost, _ = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider="gemini", + ) + + assert round(prompt_cost, 10) == round( + model_cost_map["input_cost_per_token"] * usage.prompt_tokens, + 10, + ) + + +def test_video_tokens_fallback_to_base_cost(): + """Video output tokens fall back to the base output rate when output_cost_per_video_token is not set.""" + from unittest.mock import patch + + mock_model_info = { + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + } + + usage = Usage( + completion_tokens=1720, + prompt_tokens=14, + total_tokens=1734, + completion_tokens_details=CompletionTokensDetailsWrapper( + text_tokens=600, + video_tokens=1120, + ), + prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=14), + ) + + with patch( + "litellm.litellm_core_utils.llm_cost_calc.utils.get_model_info", + return_value=mock_model_info, + ): + prompt_cost, completion_cost = generic_cost_per_token( + model="test-model", usage=usage, custom_llm_provider="gemini" + ) + + assert round(prompt_cost, 12) == round(14 * 1e-6, 12) + assert round(completion_cost, 12) == round((600 + 1120) * 2e-6, 12) + + def test_generic_cost_per_token_above_200k_tokens(): # gemini-2.5-pro-exp-03-25 was removed; gemini-2.5-pro has same above-200k pricing model = "gemini-2.5-pro" @@ -1086,6 +1185,7 @@ def test_cache_writing_cost_with_zero_creation_tokens_and_ephemeral_details(): "text_tokens": 0, "audio_tokens": 0, "image_tokens": 0, + "video_tokens": 0, "character_count": 0, "image_count": 0, "video_length_seconds": 0.0, diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 0523ed7ecb1..ade2c677745 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -3707,3 +3707,69 @@ def test_set_cost_breakdown_stores_reasoning_cost(): cost_for_built_in_tools_cost_usd_dollar=0.0, ) assert "reasoning_cost" not in no_reasoning.cost_breakdown + + +def _build_payload_for_media_response(logging_obj, init_response_obj, kwargs=None): + import datetime + + from litellm.litellm_core_utils.litellm_logging import ( + get_standard_logging_object_payload, + ) + + now = datetime.datetime.now() + return get_standard_logging_object_payload( + kwargs=kwargs or {"litellm_call_id": "media-call-id", "model": "test-model", "messages": []}, + init_response_obj=init_response_obj, + start_time=now, + end_time=now, + logging_obj=logging_obj, + status="success", + ) + + +def test_image_response_sets_output_image_count_on_usage_object(logging_obj): + """Generated-image count must land on metadata.usage_object for callbacks (e.g. Prometheus).""" + from litellm.types.utils import ImageResponse + + response = ImageResponse(created=1, data=[{"url": "https://img/1"}, {"url": "https://img/2"}]) + + payload = _build_payload_for_media_response(logging_obj, response) + + assert payload is not None + assert payload["metadata"]["usage_object"]["output_image_count"] == 2 + + +def test_output_image_count_survives_message_redaction(logging_obj, monkeypatch): + """Redaction replaces the ImageResponse body, so the count must be captured pre-redaction.""" + import litellm + from litellm.types.utils import ImageResponse + + monkeypatch.setattr(litellm, "turn_off_message_logging", True) + response = ImageResponse(created=1, data=[{"url": "https://img/1"}]) + + payload = _build_payload_for_media_response(logging_obj, response) + + assert payload is not None + assert payload["response"] == {"text": "redacted-by-litellm"} + assert payload["metadata"]["usage_object"]["output_image_count"] == 1 + + +def test_non_image_response_has_no_output_image_count(logging_obj): + payload = _build_payload_for_media_response( + logging_obj, {"id": "chatcmpl-1", "usage": {"prompt_tokens": 1, "completion_tokens": 2}} + ) + + assert payload is not None + assert "output_image_count" not in payload["metadata"]["usage_object"] + + +def test_zero_token_video_usage_preserves_duration_seconds(logging_obj): + """Video usage bills by duration; the payload must keep duration_seconds even with zero tokens.""" + payload = _build_payload_for_media_response( + logging_obj, {"id": "video-1", "usage": {"duration_seconds": 4.0}} + ) + + assert payload is not None + assert payload["metadata"]["usage_object"]["duration_seconds"] == 4.0 + assert payload["total_tokens"] == 0 + assert payload["completion_tokens"] == 0 diff --git a/tests/test_litellm/litellm_core_utils/test_redact_messages.py b/tests/test_litellm/litellm_core_utils/test_redact_messages.py index 36f220f9a2c..0f7f492ddb6 100644 --- a/tests/test_litellm/litellm_core_utils/test_redact_messages.py +++ b/tests/test_litellm/litellm_core_utils/test_redact_messages.py @@ -10,9 +10,11 @@ from types import SimpleNamespace import pytest import litellm +from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.redact_messages import ( _redact_responses_api_output, perform_redaction, + redact_streaming_responses_for_custom_logger, should_redact_message_logging, ) from litellm.responses.main import mock_responses_api_response @@ -442,3 +444,109 @@ class TestPerformRedaction: assert "vertex_ai_url_context_metadata" not in hidden_params assert "vertex_ai_safety_ratings" not in hidden_params assert "vertex_ai_citation_metadata" not in hidden_params + + def test_redact_async_complete_streaming_response(self): + """Test that async_complete_streaming_response is properly redacted.""" + response_obj = litellm.ModelResponse( + choices=[ + litellm.Choices( + message=litellm.Message(content="secret content", role="assistant") + ) + ] + ) + + model_call_details = { + "messages": [{"role": "user", "content": "hi"}], + "prompt": "hi", + "input": "hi", + "stream": True, + "async_complete_streaming_response": response_obj, + } + + perform_redaction(model_call_details, result=None) + + redacted_response = model_call_details["async_complete_streaming_response"] + assert redacted_response.choices[0].message.content == "redacted-by-litellm" + + def test_redact_complete_streaming_response(self): + """Test that complete_streaming_response is properly redacted.""" + response_obj = litellm.ModelResponse( + choices=[ + litellm.Choices( + message=litellm.Message(content="secret content", role="assistant") + ) + ] + ) + + model_call_details = { + "messages": [{"role": "user", "content": "hi"}], + "prompt": "hi", + "input": "hi", + "stream": True, + "complete_streaming_response": response_obj, + } + + perform_redaction(model_call_details, result=None) + + redacted_response = model_call_details["complete_streaming_response"] + assert redacted_response.choices[0].message.content == "redacted-by-litellm" + + def test_streaming_responses_untouched_when_disabled(self): + response_obj = litellm.ModelResponse( + choices=[ + litellm.Choices( + message=litellm.Message(content="secret content", role="assistant") + ) + ] + ) + + model_call_details = { + "messages": [{"role": "user", "content": "hi"}], + "prompt": "hi", + "input": "hi", + "stream": True, + "async_complete_streaming_response": response_obj, + } + + perform_redaction(model_call_details, result=None, redact_streaming_responses=False) + + assert response_obj.choices[0].message.content == "secret content" + + +class TestRedactStreamingResponsesForCustomLogger: + def _model_call_details(self): + response_obj = litellm.ModelResponse( + choices=[ + litellm.Choices( + message=litellm.Message(content="secret content", role="assistant") + ) + ] + ) + return { + "stream": True, + "async_complete_streaming_response": response_obj, + }, response_obj + + def test_opted_out_logger_gets_redacted_copy(self): + model_call_details, response_obj = self._model_call_details() + opted_out_logger = CustomLogger(message_logging=False) + + redacted_details = redact_streaming_responses_for_custom_logger( + model_call_details=model_call_details, custom_logger=opted_out_logger + ) + + redacted_response = redacted_details["async_complete_streaming_response"] + assert redacted_response.choices[0].message.content == "redacted-by-litellm" + assert response_obj.choices[0].message.content == "secret content" + assert model_call_details["async_complete_streaming_response"] is response_obj + + def test_compliant_logger_gets_shared_response(self): + model_call_details, response_obj = self._model_call_details() + compliant_logger = CustomLogger() + + result_details = redact_streaming_responses_for_custom_logger( + model_call_details=model_call_details, custom_logger=compliant_logger + ) + + assert result_details is model_call_details + assert response_obj.choices[0].message.content == "secret content" diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 43ef7fcd971..94a4a3fc945 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -957,15 +957,15 @@ def test_anthropic_structured_output_beta_header(): @pytest.mark.parametrize( "model_name", [ - "claude-opus-4-6-20250918", - "claude-opus-4.6-20250918", + "claude-opus-4-8", + "claude-opus-4-6-20260205", "claude-opus-4-5-20251101", "claude-opus-4.5-20251101", ], ) def test_opus_uses_native_structured_output(model_name): """ - Test that Opus 4.5 and 4.6 models use native Anthropic structured outputs + Test that supported Opus models use native Anthropic structured outputs (output_format) rather than the tool-based workaround. """ config = AnthropicConfig() @@ -1005,6 +1005,43 @@ def test_opus_uses_native_structured_output(model_name): assert optional_params.get("json_mode") is True +def test_native_structured_output_uses_bundled_capability_when_remote_map_lags( + monkeypatch: pytest.MonkeyPatch, +) -> None: + model = "claude-opus-4-8" + monkeypatch.setattr( + litellm, + "model_cost", + {model: {"supports_response_schema": True}}, + ) + litellm.get_model_info.cache_clear() + + try: + optional_params = AnthropicConfig().map_openai_params( + non_default_params={ + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "answer", + "schema": { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + }, + }, + } + }, + optional_params={}, + model=model, + drop_params=False, + ) + finally: + litellm.get_model_info.cache_clear() + + assert "output_format" in optional_params + assert "tools" not in optional_params + + def test_non_structured_output_model_uses_tool_workaround(): """ Test that models NOT in the native structured output list still use the diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py index d9d9474d6f3..6ed79763753 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py @@ -12,6 +12,13 @@ silently dropped — e.g. text resuming after a tool call started from the secon token ("The weather is nice." was lost, "Hi" rendered as ""). Bundled ``input_json_delta`` tool arguments were already preserved and must stay preserved, and empty trigger deltas must not produce spurious events. + +Also covers the inverse regression: a chunk whose translated delta carries no +payload must not be emitted at all. The translate fallback types empty deltas +as ``text_delta`` regardless of the active block, so an empty reasoning delta +mid-thinking-block (Bedrock Converse sends these) used to emit ``text_delta`` +into an open ``thinking`` block, crashing Anthropic SDK clients (Claude Code) +with "Content block is not a text block". """ import os @@ -51,6 +58,13 @@ def _make_chunk(delta: Delta, finish_reason: Optional[str] = None) -> MagicMock: return chunk +def _thinking_chunk(thinking: str, signature: str = "") -> MagicMock: + block = {"type": "thinking", "thinking": thinking} + if signature: + block["signature"] = signature + return _make_chunk(Delta(content=None, thinking_blocks=[block])) + + def _tool_chunk( call_id: str, name: Optional[str], arguments: Optional[str] ) -> MagicMock: @@ -109,6 +123,47 @@ def _input_json_deltas(events: List[dict]) -> List[str]: ] +def _thinking_deltas(events: List[dict]) -> List[str]: + return [ + e["delta"]["thinking"] + for e in events + if e.get("type") == "content_block_delta" + and e["delta"].get("type") == "thinking_delta" + ] + + +def _signature_deltas(events: List[dict]) -> List[str]: + return [ + e["delta"]["signature"] + for e in events + if e.get("type") == "content_block_delta" + and e["delta"].get("type") == "signature_delta" + ] + + +_DELTA_TYPES_PER_BLOCK_TYPE = { + "text": {"text_delta"}, + "thinking": {"thinking_delta", "signature_delta"}, + "tool_use": {"input_json_delta"}, +} + + +def _assert_deltas_match_their_block_type(events: List[dict]) -> None: + """Enforce the invariant the Anthropic SDK enforces client-side: every + ``content_block_delta`` must be of a type valid for the block opened by + the most recent ``content_block_start`` at the same index. + """ + block_types = {} + for event in events: + if event.get("type") == "content_block_start": + block_types[event["index"]] = event["content_block"]["type"] + if event.get("type") == "content_block_delta": + block_type = block_types[event["index"]] + assert event["delta"]["type"] in _DELTA_TYPES_PER_BLOCK_TYPE[block_type], ( + f"{event['delta']['type']} emitted into a {block_type} block: {event}" + ) + + def test_held_stop_reason_usage_merge_preserves_openai_cache_token_details(): """OpenAI-compatible usage chunks carry cache reads in prompt_tokens_details.""" wrapper = AnthropicStreamWrapper(completion_stream=iter([]), model="claude-x") @@ -332,11 +387,70 @@ def test_bundled_tool_args_on_transition_still_preserved_sync(): ({"type": "content_block_delta", "delta": None}, False), ], ) -def test_trigger_delta_has_content_branches(processed_chunk, expected): - """Directly exercise the re-emit predicate across all delta types and the +def test_delta_has_content_branches(processed_chunk, expected): + """Directly exercise the emission predicate across all delta types and the empty/malformed guards, so the helper's behavior is pinned independently of upstream chunk-translation details. """ - assert ( - AnthropicStreamWrapper._trigger_delta_has_content(processed_chunk) is expected + assert AnthropicStreamWrapper._delta_has_content(processed_chunk) is expected + + +def _empty_reasoning_delta_mid_thinking_chunks() -> List[MagicMock]: + return [ + _thinking_chunk("Let me think"), + _thinking_chunk(""), + _thinking_chunk("", signature="sig123"), + _make_chunk(Delta(content="Hello")), + _make_chunk(Delta(content=None), finish_reason="stop"), + ] + + +def _assert_empty_reasoning_delta_suppressed(events: List[dict]) -> None: + _assert_deltas_match_their_block_type(events) + assert _thinking_deltas(events) == ["Let me think"] + assert _signature_deltas(events) == ["sig123"] + assert _text_deltas(events) == ["Hello"] + + +def test_empty_reasoning_delta_mid_thinking_block_is_suppressed_sync(): + """Bedrock Converse repro: an empty reasoning delta arriving inside an open + thinking block used to be emitted as ``text_delta {"text": ""}`` at the + thinking block's index (no block transition), which crashes Claude Code's + Anthropic SDK with "Content block is not a text block". It must be dropped, + while the surrounding thinking/signature/text deltas all still flow. + """ + wrapper = AnthropicStreamWrapper( + completion_stream=iter(_empty_reasoning_delta_mid_thinking_chunks()), + model="claude-x", ) + _assert_empty_reasoning_delta_suppressed(_drain_sync(wrapper)) + + +@pytest.mark.asyncio +async def test_empty_reasoning_delta_mid_thinking_block_is_suppressed_async(): + """Async twin of the Bedrock Converse repro — the proxy serves the async + iterator, so the skip must exist on that path too. + """ + wrapper = AnthropicStreamWrapper( + completion_stream=_AsyncStream(_empty_reasoning_delta_mid_thinking_chunks()), + model="claude-x", + ) + _assert_empty_reasoning_delta_suppressed(await _drain_async(wrapper)) + + +def test_empty_content_chunk_mid_text_block_is_suppressed_sync(): + """An empty-content chunk arriving mid-text-block (no transition) used to + emit a pointless ``text_delta {"text": ""}``; it must be dropped without + affecting the surrounding text deltas. + """ + chunks = [ + _make_chunk(Delta(content="Hi")), + _make_chunk(Delta(content="")), + _make_chunk(Delta(content=" there")), + _make_chunk(Delta(content=None), finish_reason="stop"), + ] + wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="claude-x") + events = _drain_sync(wrapper) + + assert _text_deltas(events) == ["Hi", " there"] + _assert_deltas_match_their_block_type(events) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py index 06d3effcfbb..5254808e315 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py @@ -2,6 +2,7 @@ import pytest from litellm.constants import ( DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, ) @@ -174,6 +175,81 @@ def test_unrecognized_effort_raises_clean_400(): assert exc_info.value.status_code == 400 +def test_pinned_temperature_dropped_when_adaptive_downgraded_to_enabled(): + """Regression (#33203): Claude Code's safety classifier sends adaptive thinking + + temperature=0 to Haiku 4.5. The adaptive interface is downgraded to legacy enabled + thinking, but Anthropic rejects "temperature may only be set to 1 when thinking is + enabled". The pinned temperature must be dropped so the request succeeds while the + downgraded thinking is preserved.""" + params = _claude_code_payload(effort="medium") + params["temperature"] = 0 + result = _transform("claude-haiku-4-5", params) + + assert result["thinking"] == { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + } + assert "temperature" not in result + + +def test_temperature_one_preserved_with_enabled_thinking(): + """temperature=1 is compatible with extended thinking, so it must be kept.""" + params = _claude_code_payload(effort="medium") + params["temperature"] = 1 + result = _transform("claude-haiku-4-5", params) + + assert result["thinking"]["type"] == "enabled" + assert result["temperature"] == 1 + + +def test_pinned_temperature_preserved_when_thinking_dropped(): + """When thinking is dropped entirely (non-reasoning model), there is no thinking + conflict, so a pinned temperature must survive untouched.""" + params = _claude_code_payload(effort="medium") + params["temperature"] = 0 + result = _transform("claude-3-5-haiku-latest", params) + + assert "thinking" not in result + assert result["temperature"] == 0 + + +def test_pinned_temperature_preserved_for_adaptive_model(): + """Adaptive models (4.6+) own the thinking/temperature relationship natively, so + the passthrough must not strip a pinned temperature for them.""" + params = _claude_code_payload(effort="high") + params["temperature"] = 0 + result = _transform("claude-sonnet-4-6", params) + + assert result["thinking"] == {"type": "adaptive"} + assert result["temperature"] == 0 + + +def test_pinned_temperature_dropped_for_opus_4_5_effort(): + """Opus 4.5 keeps native output_config.effort (extended thinking), which is equally + incompatible with a pinned non-1 temperature, so the temperature must be dropped.""" + params = _claude_code_payload(effort="medium") + params["temperature"] = 0 + result = _transform("claude-opus-4-5", params) + + assert result["output_config"] == {"effort": "medium"} + assert "temperature" not in result + + +def test_reasoning_effort_with_pinned_temperature_drops_temperature(): + """The reasoning_effort alias synthesizes legacy enabled thinking on a non-adaptive + model; a co-pinned non-1 temperature must be dropped to avoid the Anthropic 400.""" + result = _transform( + "claude-haiku-4-5", + {"max_tokens": 8192, "reasoning_effort": "low", "temperature": 0}, + ) + + assert result["thinking"] == { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, + } + assert "temperature" not in result + + def test_non_adaptive_request_without_effort_is_untouched(): """A non-adaptive model receiving a request with no adaptive interface (no effort, no adaptive thinking) must pass through untouched.""" diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py index 14e872485ec..606ff39b35e 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py @@ -99,17 +99,11 @@ class TestContextManagementConversion: } ) kwargs = _ADAPTER.translate_request(req) - assert kwargs["context_management"] == [ - {"type": "compaction", "compact_threshold": 100000} - ] + assert kwargs["context_management"] == [{"type": "compaction", "compact_threshold": 100000}] def test_translate_request_drops_anthropic_only_context_management(self): """context_management with only unknown edit types is omitted from kwargs.""" - req = _make_request( - context_management={ - "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] - } - ) + req = _make_request(context_management={"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]}) kwargs = _ADAPTER.translate_request(req) assert "context_management" not in kwargs @@ -134,9 +128,7 @@ class TestOutputConfigStructuredOutput: def test_output_config_format_json_schema_converted(self): """output_config.format.json_schema is converted to OpenAI text.format.""" - req = _make_request( - output_config={"format": {"type": "json_schema", "schema": self._SCHEMA}} - ) + req = _make_request(output_config={"format": {"type": "json_schema", "schema": self._SCHEMA}}) kwargs = _ADAPTER.translate_request(req) assert "text" in kwargs fmt = kwargs["text"]["format"] @@ -153,9 +145,7 @@ class TestOutputConfigStructuredOutput: def test_output_format_still_works(self): """The original output_format field still takes precedence when present.""" - req = _make_request( - output_format={"type": "json_schema", "schema": self._SCHEMA} - ) + req = _make_request(output_format={"type": "json_schema", "schema": self._SCHEMA}) kwargs = _ADAPTER.translate_request(req) assert "text" in kwargs assert kwargs["text"]["format"]["type"] == "json_schema" @@ -250,9 +240,7 @@ class TestTranslateMessagesToResponsesInput: ] result = _translate_messages(messages) assert len(result) == 1 - assert result[0]["content"] == [ - {"type": "input_image", "image_url": "data:image/png;base64,abc123"} - ] + assert result[0]["content"] == [{"type": "input_image", "image_url": "data:image/png;base64,abc123"}] def test_user_url_image(self): """User message with URL image source becomes input_image with the URL.""" @@ -268,9 +256,7 @@ class TestTranslateMessagesToResponsesInput: } ] result = _translate_messages(messages) - assert result[0]["content"] == [ - {"type": "input_image", "image_url": "https://example.com/img.jpg"} - ] + assert result[0]["content"] == [{"type": "input_image", "image_url": "https://example.com/img.jpg"}] def test_user_base64_image_empty_data_skipped(self): """Base64 image with empty data is skipped (no URL can be formed).""" @@ -341,9 +327,7 @@ class TestTranslateMessagesToResponsesInput: messages = [ { "role": "user", - "content": [ - {"type": "tool_result", "tool_use_id": "call_null", "content": None} - ], + "content": [{"type": "tool_result", "tool_use_id": "call_null", "content": None}], } ] result = _translate_messages(messages) @@ -370,9 +354,7 @@ class TestTranslateMessagesToResponsesInput: } ] result = _translate_messages(messages) - assert result[0]["content"] == [ - {"type": "output_text", "text": "Here is the answer."} - ] + assert result[0]["content"] == [{"type": "output_text", "text": "Here is the answer."}] def test_assistant_tool_use_becomes_function_call(self): """Assistant tool_use block becomes a top-level function_call item.""" @@ -404,15 +386,11 @@ class TestTranslateMessagesToResponsesInput: messages = [ { "role": "assistant", - "content": [ - {"type": "thinking", "thinking": "Let me reason step by step."} - ], + "content": [{"type": "thinking", "thinking": "Let me reason step by step."}], } ] result = _translate_messages(messages) - assert result[0]["content"] == [ - {"type": "output_text", "text": "Let me reason step by step."} - ] + assert result[0]["content"] == [{"type": "output_text", "text": "Let me reason step by step."}] def test_assistant_empty_thinking_block_skipped(self): """Assistant thinking block with empty thinking text is skipped.""" @@ -584,28 +562,27 @@ class TestTranslateToolsToResponsesAPI: class TestTranslateToolChoiceToResponsesAPI: - """Anthropic tool_choice -> Responses API tool_choice.""" + """Anthropic tool_choice -> Responses API tool_choice. - def test_auto_maps_to_auto(self): - assert _ADAPTER.translate_tool_choice_to_responses_api({"type": "auto"}) == { - "type": "auto" - } + The Responses API's tool_choice schema (openai.types.responses.tool_choice_options) + is a bare Literal["none", "auto", "required"] for these simple cases - not an + object like {"type": "auto"}. Sending the object shape to an OpenAI-compatible + server gets rejected with a pydantic validation error. + """ - def test_any_maps_to_required(self): - assert _ADAPTER.translate_tool_choice_to_responses_api({"type": "any"}) == { - "type": "required" - } + def test_auto_maps_to_bare_string_auto(self): + assert _ADAPTER.translate_tool_choice_to_responses_api({"type": "auto"}) == "auto" + + def test_any_maps_to_bare_string_required(self): + assert _ADAPTER.translate_tool_choice_to_responses_api({"type": "any"}) == "required" + + def test_none_maps_to_bare_string_none(self): + assert _ADAPTER.translate_tool_choice_to_responses_api({"type": "none"}) == "none" def test_specific_tool_maps_to_function(self): - result = _ADAPTER.translate_tool_choice_to_responses_api( - {"type": "tool", "name": "get_weather"} - ) + result = _ADAPTER.translate_tool_choice_to_responses_api({"type": "tool", "name": "get_weather"}) assert result == {"type": "function", "name": "get_weather"} - def test_unknown_type_defaults_to_auto(self): - result = _ADAPTER.translate_tool_choice_to_responses_api({"type": "none"}) - assert result == {"type": "auto"} - # --------------------------------------------------------------------------- # translate_thinking_to_reasoning @@ -616,17 +593,13 @@ class TestTranslateThinkingToReasoning: """Anthropic thinking param -> Responses API reasoning param.""" def test_budget_high_effort(self): - result = _ADAPTER.translate_thinking_to_reasoning( - {"type": "enabled", "budget_tokens": 10000} - ) + result = _ADAPTER.translate_thinking_to_reasoning({"type": "enabled", "budget_tokens": 10000}) # Default (reasoning_auto_summary=False): only effort, no summary assert result == {"effort": "high"} assert result is not None and "summary" not in result def test_budget_above_threshold_high_effort(self): - result = _ADAPTER.translate_thinking_to_reasoning( - {"type": "enabled", "budget_tokens": 50000} - ) + result = _ADAPTER.translate_thinking_to_reasoning({"type": "enabled", "budget_tokens": 50000}) assert result is not None assert result["effort"] == "high" assert "summary" not in result @@ -652,9 +625,7 @@ class TestTranslateThinkingToReasoning: assert result is not None and "summary" not in result def test_budget_minimal_effort(self): - result = _ADAPTER.translate_thinking_to_reasoning( - {"type": "enabled", "budget_tokens": 500} - ) + result = _ADAPTER.translate_thinking_to_reasoning({"type": "enabled", "budget_tokens": 500}) assert result == {"effort": "minimal"} assert result is not None and "summary" not in result @@ -707,9 +678,7 @@ class TestTranslateThinkingToReasoning: original = litellm.reasoning_auto_summary try: litellm.reasoning_auto_summary = True - result = _ADAPTER.translate_thinking_to_reasoning( - {"type": "enabled", "budget_tokens": 10000} - ) + result = _ADAPTER.translate_thinking_to_reasoning({"type": "enabled", "budget_tokens": 10000}) assert result == {"effort": "high", "summary": "detailed"} finally: litellm.reasoning_auto_summary = original @@ -789,11 +758,7 @@ class TestTranslateRequestBroaderCoverage: assert kwargs["top_p"] == 0.9 def test_tools_translated(self): - req = _make_request( - tools=[ - {"name": "calculator", "description": "Does math.", "input_schema": {}} - ] - ) + req = _make_request(tools=[{"name": "calculator", "description": "Does math.", "input_schema": {}}]) kwargs = _ADAPTER.translate_request(req) assert len(kwargs["tools"]) == 1 assert kwargs["tools"][0]["name"] == "calculator" @@ -929,9 +894,7 @@ class TestTranslateResponse: def test_multiple_text_parts(self): """Multiple output_text parts become multiple text content blocks.""" - response = _make_mock_response( - output=[_make_output_message(["Part 1", "Part 2"])] - ) + response = _make_mock_response(output=[_make_output_message(["Part 1", "Part 2"])]) result: Any = _ADAPTER.translate_response(response) assert len(result["content"]) == 2 assert result["content"][0]["text"] == "Part 1" diff --git a/tests/test_litellm/llms/bedrock/test_web_identity_session_policy.py b/tests/test_litellm/llms/bedrock/test_web_identity_session_policy.py index 7e9c8a273ae..0cbdc518cc2 100644 --- a/tests/test_litellm/llms/bedrock/test_web_identity_session_policy.py +++ b/tests/test_litellm/llms/bedrock/test_web_identity_session_policy.py @@ -158,6 +158,54 @@ class TestClaudePlatformActionsCovered: ) +class TestBedrockMantleActionsCovered: + """LIT-3859: bedrock_mantle inference authorizes against the + ``bedrock-mantle`` action namespace, so the session-policy ceiling + must include it or every Mantle request via OIDC/WIF auth denies + with "no session policy allows the bedrock-mantle:CreateInference + action" even when the role's identity policy grants it.""" + + def test_bedrock_mantle_create_inference_present(self): + policy = _captured_policy() + all_actions: set = set() + for stmt in policy["Statement"]: + stmt_actions = stmt.get("Action") + if isinstance(stmt_actions, str): + all_actions.add(stmt_actions) + elif isinstance(stmt_actions, list): + all_actions.update(stmt_actions) + assert "bedrock-mantle:CreateInference" in all_actions, ( + "bedrock-mantle:CreateInference missing from session policy — " + "bedrock_mantle/* requests will 403 on OIDC/WIF auth" + ) + + def test_bedrock_mantle_statement_allows(self): + policy = _captured_policy() + stmt = _statement_by_sid(policy, "BedrockMantleLiteLLM") + assert stmt["Effect"] == "Allow" + assert stmt["Resource"] == "*" + + def test_no_bedrock_mantle_wildcard(self): + policy = _captured_policy() + stmt = _statement_by_sid(policy, "BedrockMantleLiteLLM") + actions = stmt["Action"] + if isinstance(actions, str): + actions = [actions] + assert "bedrock-mantle:*" not in actions, ( + "session policy must not grant bedrock-mantle:* — " + "the ceiling should match the documented action set" + ) + + def test_bedrock_mantle_statement_carries_secure_transport_condition(self): + policy = _captured_policy() + stmt = _statement_by_sid(policy, "BedrockMantleLiteLLM") + cond = stmt.get("Condition") or {} + assert cond.get("Bool", {}).get("aws:SecureTransport") == "true", ( + "BedrockMantleLiteLLM must require aws:SecureTransport=true " + "to keep parity with the bedrock statement" + ) + + def _make_jwt(payload: dict) -> str: def _segment(data: dict) -> str: return base64.urlsafe_b64encode(json.dumps(data).encode()).rstrip(b"=").decode() diff --git a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py index aafaf401700..816a025e11a 100644 --- a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py +++ b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py @@ -397,6 +397,20 @@ class TestBedrockMantleResponsesRegistry: assert isinstance(cfg, BedrockMantleResponsesAPIConfig) assert cfg.use_openai_path is True + @pytest.mark.parametrize( + "model", + ["openai.gpt-5.6-sol", "openai.gpt-5.6-terra", "openai.gpt-5.6-luna"], + ) + def test_registry_returns_config_for_gpt_5_6_family(self, local_cost_map, model): + from litellm.utils import ProviderConfigManager + + cfg = ProviderConfigManager.get_provider_responses_api_config( + provider="bedrock_mantle", + model=model, + ) + assert isinstance(cfg, BedrockMantleResponsesAPIConfig) + assert cfg.use_openai_path is True + def test_registry_returns_native_config_for_gpt_oss(self, local_cost_map): # Core regression: gpt-oss-120b supports the native Responses API (AWS # model card), so it must get a BedrockMantleResponsesAPIConfig on the @@ -460,7 +474,12 @@ class TestBedrockMantleResponsesRegistry: model="xai.grok-4.3", ) assert isinstance(cfg, BedrockMantleResponsesAPIConfig) - assert cfg.use_openai_path is False + # grok-4.3 is a third-party frontier model on Bedrock Mantle, served on the + # /openai/v1 base (like gpt-5.x / gemma-4), not the standard /v1 path used by + # open-weights models such as gpt-oss. The standard /v1 base returns + # "Berm is not enabled for this account", so the price-map entry carries + # use_openai_responses_path=true. + assert cfg.use_openai_path is True def test_unmapped_frontier_model_falls_through_to_none(self, restore_model_cost): # The gate is data-driven, not name-based: an unseen model not yet in the @@ -1237,6 +1256,25 @@ class TestBedrockMantleResponsesPricing: assert info["cache_read_input_token_cost"] == pytest.approx(2.75e-07) assert info["max_input_tokens"] == 272000 + @pytest.mark.parametrize( + "model, input_cost, cache_creation_cost, cache_read_cost, output_cost", + [ + ("openai.gpt-5.6-sol", 5.5e-06, 6.875e-06, 5.5e-07, 3.3e-05), + ("openai.gpt-5.6-terra", 2.75e-06, 3.4375e-06, 2.75e-07, 1.65e-05), + ("openai.gpt-5.6-luna", 1.1e-06, 1.375e-06, 1.1e-07, 6.6e-06), + ], + ) + def test_gpt_5_6_pricing_and_mode( + self, local_cost_map, model, input_cost, cache_creation_cost, cache_read_cost, output_cost + ): + info = litellm.get_model_info(f"bedrock_mantle/{model}") + assert info["mode"] == "responses" + assert info["input_cost_per_token"] == pytest.approx(input_cost) + assert info["cache_creation_input_token_cost"] == pytest.approx(cache_creation_cost) + assert info["cache_read_input_token_cost"] == pytest.approx(cache_read_cost) + assert info["output_cost_per_token"] == pytest.approx(output_cost) + assert info["max_input_tokens"] == 272000 + def test_models_registered(self, local_cost_map): assert "bedrock_mantle/openai.gpt-5.5" in litellm.bedrock_mantle_models assert "bedrock_mantle/openai.gpt-5.4" in litellm.bedrock_mantle_models diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index d389b54b3f1..151c51f1ca0 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -44,6 +44,60 @@ class TestOpenAIResponsesAPIConfig: # The function should return the params unchanged assert result == test_params + @pytest.mark.parametrize("max_output_tokens", [1, 15]) + def test_map_openai_params_clamps_max_output_tokens_below_minimum(self, max_output_tokens): + """OpenAI's Responses API rejects max_output_tokens < 16. + + Claude Code (via the Anthropic Messages -> Responses adapter) sends a + max_tokens=1 warmup probe when running `/model`, which produced: + "Invalid 'max_output_tokens': integer below minimum value. + Expected a value >= 16, but got 1 instead." + Clamp anything below the minimum up to 16 instead of erroring. + """ + result = self.config.map_openai_params( + response_api_optional_params={"max_output_tokens": max_output_tokens}, + model=self.model, + drop_params=False, + ) + + assert result["max_output_tokens"] == 16 + + def test_map_openai_params_preserves_max_output_tokens_at_or_above_minimum(self): + """Values already >= 16 must pass through untouched.""" + result = self.config.map_openai_params( + response_api_optional_params={"max_output_tokens": 256}, + model=self.model, + drop_params=False, + ) + + assert result["max_output_tokens"] == 256 + + def test_map_openai_params_leaves_max_output_tokens_absent(self): + """A request without max_output_tokens must not gain the key.""" + result = self.config.map_openai_params( + response_api_optional_params={"input": "hi"}, + model=self.model, + drop_params=False, + ) + + assert "max_output_tokens" not in result + + @pytest.mark.parametrize( + "value, expected", + [ + (1, 16), + (15, 16), + (16, 16), + (17, 17), + (256, 256), + (None, None), + ], + ) + def test_enforce_min_max_output_tokens(self, value, expected): + """Below the minimum clamps to 16; the boundary, larger values, and None + are returned unchanged so no previously-valid request regresses.""" + assert self.config._enforce_min_max_output_tokens(value) == expected + def validate_responses_api_request_params(self, params, expected_fields): """ Validate that the params dict has the expected structure of ResponsesAPIRequestParams diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 51f13affa3f..40f9f4e7910 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -880,6 +880,12 @@ def test_vertex_ai_usage_metadata_with_image_tokens_in_prompt(): ) +def test_map_response_modalities_video(): + """The video modality maps to VIDEO instead of MODALITY_UNSPECIFIED, which Gemini rejects.""" + v = VertexGeminiConfig() + assert v.map_response_modalities(["text", "video"]) == ["TEXT", "VIDEO"] + + def test_vertex_ai_usage_metadata_accumulates_duplicate_modalities(): """Ensure _calculate_usage accumulates repeated modality entries.""" v = VertexGeminiConfig() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index c785ac577f7..6f132aaae9c 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -4910,6 +4910,7 @@ class TestMCPDcrBridgeDelegateAdmission: cls, *, key_hash=None, + user_id=None, server_id="bridge-server-id", access_token="inner-upstream-access-token", token_type="Bearer", @@ -4921,17 +4922,23 @@ class TestMCPDcrBridgeDelegateAdmission: envelope_keys_from_master_key, ) from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( - EnvelopeIdentity, SealedEnvelope, UpstreamTokenGrant, + key_hash_identity, mint_envelope, + user_identity, ) from pydantic import SecretStr + identity = ( + user_identity(server_id=server_id, user_id=user_id) + if user_id is not None + else key_hash_identity(server_id=server_id, key_hash=key_hash or cls._KEY_HASH) + ) keys = envelope_keys_from_master_key(master_key or cls._MASTER_KEY) now = minted_at or datetime.now(timezone.utc) sealed = mint_envelope( - identity=EnvelopeIdentity(server_id=server_id, key_hash=key_hash or cls._KEY_HASH), + identity=identity, grant=UpstreamTokenGrant( access_token=SecretStr(access_token), token_type=token_type, @@ -4999,6 +5006,38 @@ class TestMCPDcrBridgeDelegateAdmission: stack.enter_context(patcher) yield get_key_object + @staticmethod + @contextlib.contextmanager + def _patch_user_reload(*, return_value=None, side_effect=None): + """Patch the user-subject reload path an interactively-minted envelope takes: the + ``get_user_object`` lookup ``_reload_admitted_user`` runs (which also drives the SCIM gate), + plus the ``prisma_client`` / ``user_api_key_cache`` globals. The centralized gate's own + fetches fail-safe to None under the MagicMock prisma, so an unblocked user admits. Yields the + ``get_user_object`` mock so a caller can assert the sealed user_id was the reload key.""" + get_user_object = AsyncMock(return_value=return_value, side_effect=side_effect) + with ( + patch("litellm.proxy.auth.auth_checks.get_user_object", get_user_object), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), + ): + yield get_user_object + + @staticmethod + def _wrapped_user_lookup_error(original: BaseException) -> ValueError: + """Reproduce get_user_object's real exception contract (litellm/proxy/auth/auth_checks.py): it + catches every DB failure in a broad ``except`` and re-raises a bare ``ValueError``, so the + original error (a missing-user Exception or a real outage) survives only as ``__context__``. + Injecting a raw ConnectionError/Exception instead would exercise a shape production never + produces and let a chain-blind outage classifier pass. That wrapping fidelity is itself pinned by + test_get_user_object_wraps_db_outage_as_valueerror_preserving_context in test_auth_checks.""" + try: + raise original + except BaseException: + try: + raise ValueError(f"User doesn't exist in db. Got error - {original}") + except ValueError as wrapped: + return wrapped + @staticmethod def _mcp_request(path="/mcp/bridge_delegate_server"): """A minimal ``Request`` for direct ``_admit_dcr_bridge_delegate`` calls, mirroring how @@ -5060,6 +5099,155 @@ class TestMCPDcrBridgeDelegateAdmission: "bridge_delegate_server": {"Authorization": "Bearer inner-upstream-access-token"} } + async def test_user_subject_envelope_admits_under_the_reloaded_user(self): + """An interactively-minted (user_id) envelope admits under the reloaded USER, not a key: the + reload is keyed by the sealed user_id, the admitted auth carries that user_id, the raw-key + pipeline is never invoked, and the inner upstream token is injected for egress. This is the + interactive-DCR admission the whole flow exists for.""" + envelope = self._mint_bridge_envelope(user_id="sso-user-7") + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))], + } + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + ) as mock_auth, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + self._patch_user_reload( + return_value=MagicMock( + user_id="sso-user-7", + metadata={"scim_active": True}, + user_role=None, + object_permission=None, + object_permission_id=None, + ) + ) as get_user_object, + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + (auth_result, _h, _s, mcp_server_auth_headers, _o, _r) = await MCPRequestHandler.process_mcp_request(scope) + + assert get_user_object.await_args.kwargs["user_id"] == "sso-user-7" + assert auth_result.user_id == "sso-user-7" + mock_auth.assert_not_called() + assert mcp_server_auth_headers == { + "bridge_delegate_server": {"Authorization": "Bearer inner-upstream-access-token"} + } + + async def test_user_subject_envelope_carries_the_users_mcp_object_permission(self): + """The admitted user's own MCP object permission rides on the returned auth so the shared + get_allowed_mcp_servers grants the user their litellm-granted servers, rather than admitting a + bare user with no MCP access. Regression for the signed-in SSO client getting zero tools because + the reload dropped the user's object permission.""" + object_permission = LiteLLM_ObjectPermissionTable( + object_permission_id="op-user-7", mcp_servers=["bridge_delegate_server"] + ) + envelope = self._mint_bridge_envelope(user_id="sso-user-7") + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))], + } + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + ), + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + self._patch_user_reload( + return_value=MagicMock( + user_id="sso-user-7", + metadata={"scim_active": True}, + user_role=None, + object_permission=object_permission, + object_permission_id="op-user-7", + ) + ), + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + (auth_result, _h, _s, _headers, _o, _r) = await MCPRequestHandler.process_mcp_request(scope) + + assert auth_result.object_permission is not None + assert auth_result.object_permission.mcp_servers == ["bridge_delegate_server"] + + async def test_user_subject_envelope_missing_user_fails_closed_401(self): + """A user_id envelope whose user has since been deleted must fail closed with a 401, not a 500. + get_user_object catches the missing row and re-raises a bare ValueError (it does not return None + on the production path), so the reload must fail closed rather than let it propagate as an opaque + 500, and must not mistake the wrapped ValueError for a DB outage. Regression for the missing-user + path surfacing as a 500.""" + envelope = self._mint_bridge_envelope(user_id="ghost-user") + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))], + } + with ( + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + self._patch_user_reload(side_effect=self._wrapped_user_lookup_error(Exception())), + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 401 + + async def test_user_subject_envelope_db_outage_is_retryable_503(self): + """A transient database outage while reloading the envelope's user is a retryable 503, not an + opaque 500, matching the key path's contract so an interactive DCR client retries instead of + treating a live identity as invalid. get_user_object wraps the outage in a bare ValueError, so this + exercises the chain-aware classifier; a raw ConnectionError would falsely pass even the old + chain-blind check because it is an OSError. Regression for the user reload dropping the 503 arm.""" + envelope = self._mint_bridge_envelope(user_id="sso-user-7") + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))], + } + with ( + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + self._patch_user_reload( + side_effect=self._wrapped_user_lookup_error(ConnectionError("auth database unreachable")) + ), + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 503 + + async def test_user_subject_envelope_scim_deactivated_user_fails_closed_401(self): + """SCIM-deactivating the envelope's user revokes it immediately: the reloaded user carries + scim_active False, so admission 401s rather than letting an offboarded user keep tool access + until the envelope expires.""" + envelope = self._mint_bridge_envelope(user_id="offboarded-user") + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))], + } + with ( + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + self._patch_user_reload(return_value=MagicMock(user_id="offboarded-user", metadata={"scim_active": False})), + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 401 + async def test_revoked_key_envelope_fails_closed_401(self): """An envelope whose key has since been deleted must fail closed: ``get_key_object`` raises for the missing row, so admission 401s instead of admitting the caller as an unrestricted diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_classify.py b/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_classify.py new file mode 100644 index 00000000000..dc20d664a53 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_classify.py @@ -0,0 +1,147 @@ +"""Classification matrix for upstream OAuth/DCR rejections: who is blamed depends only on the §5.2 +code and whose credentials the gateway presented, never on the upstream's HTTP status.""" + +import httpx + +from litellm.proxy._experimental.mcp_server.faults.classify import ( + classify_upstream_dcr_rejection, + classify_upstream_token_rejection, +) +from litellm.proxy._experimental.mcp_server.faults.types import ( + CallerRejected, + GatewayRejected, + UpstreamProtocolFault, + UpstreamReportedFault, +) + + +def _response(status_code: int, *, json_body: object = None, text_body: str = "", headers: dict = None) -> httpx.Response: + request = httpx.Request("POST", "https://idp.example.com/token") + if json_body is not None: + return httpx.Response(status_code, json=json_body, request=request) + return httpx.Response(status_code, text=text_body, headers=headers or {}, request=request) + + +def test_caller_fault_code_classifies_as_caller_rejected_regardless_of_status(): + fault = classify_upstream_token_rejection( + _response(500, json_body={"error": "invalid_grant", "error_description": "Code expired."}), + credential_source="gateway_stored", + log_context="srv", + ) + assert isinstance(fault, CallerRejected) + assert fault.code == "invalid_grant" + assert fault.description == "Code expired." + + +def test_credential_code_with_gateway_stored_credentials_indicts_gateway(): + fault = classify_upstream_token_rejection( + _response(401, json_body={"error": "invalid_client", "error_description": "not found"}), + credential_source="gateway_stored", + log_context="srv", + ) + assert isinstance(fault, GatewayRejected) + assert fault.code == "invalid_client" + + +def test_credential_code_with_caller_supplied_credentials_stays_caller_fault(): + fault = classify_upstream_token_rejection( + _response(401, json_body={"error": "invalid_client"}), + credential_source="caller_supplied", + log_context="srv", + ) + assert isinstance(fault, CallerRejected) + assert fault.code == "invalid_client" + + +def test_unknown_code_relays_as_caller_rejected(): + fault = classify_upstream_token_rejection( + _response(400, json_body={"error": "slow_down", "error_description": "Polling too fast."}), + credential_source="gateway_stored", + log_context="srv", + ) + assert isinstance(fault, CallerRejected) + assert fault.code == "slow_down" + + +def test_body_without_error_field_is_protocol_fault(): + fault = classify_upstream_token_rejection( + _response(404, text_body="not here"), + credential_source="gateway_stored", + log_context="srv", + ) + assert isinstance(fault, UpstreamProtocolFault) + assert fault.note == "upstream token endpoint returned HTTP 404" + + +def test_unreadable_body_is_protocol_fault_not_exception(): + unreadable = httpx.Response( + 400, + stream=httpx.ByteStream(b"\x1f\x8bnot-gzip"), + headers={"content-encoding": "gzip"}, + request=httpx.Request("POST", "https://idp.example.com/token"), + ) + fault = classify_upstream_token_rejection(unreadable, credential_source="gateway_stored", log_context="srv") + assert isinstance(fault, UpstreamProtocolFault) + + +def test_wire_fields_are_bounded(): + fault = classify_upstream_token_rejection( + _response(400, json_body={"error": "invalid_request", "error_description": "x" * 5000}), + credential_source="gateway_stored", + log_context="srv", + ) + assert isinstance(fault, CallerRejected) + assert len(fault.description) == 500 + + +def test_dcr_rejection_with_rfc7591_code_is_caller_rejected(): + fault = classify_upstream_dcr_rejection( + _response(400, json_body={"error": "invalid_redirect_uri", "error_description": "not allowed"}), + log_context="srv", + ) + assert isinstance(fault, CallerRejected) + assert fault.code == "invalid_redirect_uri" + + +def test_dcr_rejection_without_code_is_protocol_fault(): + fault = classify_upstream_dcr_rejection(_response(500, text_body="trace"), log_context="srv") + assert isinstance(fault, UpstreamProtocolFault) + assert fault.note == "upstream registration failed with HTTP 500" + + +def test_upstream_self_blame_codes_stay_upstream_faults(): + fault = classify_upstream_token_rejection( + _response(400, json_body={"error": "server_error", "error_description": "boom"}), + credential_source="caller_supplied", + log_context="srv", + ) + assert isinstance(fault, UpstreamReportedFault) + assert fault.code == "server_error" + + +def test_temporarily_unavailable_is_upstream_fault(): + fault = classify_upstream_token_rejection( + _response(503, json_body={"error": "temporarily_unavailable"}), + credential_source="gateway_stored", + log_context="srv", + ) + assert isinstance(fault, UpstreamReportedFault) + assert fault.code == "temporarily_unavailable" + + +def test_invalid_target_is_gateway_fault_even_with_caller_credentials(): + fault = classify_upstream_token_rejection( + _response(400, json_body={"error": "invalid_target"}), + credential_source="caller_supplied", + log_context="srv", + ) + assert isinstance(fault, GatewayRejected) + assert fault.code == "invalid_target" + + +def test_dcr_server_error_code_is_not_blamed_on_caller(): + fault = classify_upstream_dcr_rejection( + _response(500, json_body={"error": "server_error"}), + log_context="srv", + ) + assert isinstance(fault, UpstreamReportedFault) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_render_oauth.py b/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_render_oauth.py new file mode 100644 index 00000000000..78513e315a7 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_render_oauth.py @@ -0,0 +1,96 @@ +"""Rendering contract: status, wire code, and prose all derive from the fault tag, so a caller-fault +code can never ship on a server-fault status and gateway-side faults never carry provider prose.""" + +import json + +from litellm.proxy._experimental.mcp_server.faults.render_oauth import ( + dcr_fault_detail, + render_token_fault, +) +from litellm.proxy._experimental.mcp_server.faults.types import ( + CallerRejected, + GatewayRejected, + UpstreamProtocolFault, + UpstreamReportedFault, +) + + +def test_caller_rejected_renders_code_derived_status(): + response = render_token_fault(CallerRejected(code="invalid_grant", description="Code expired.")) + assert response.status_code == 400 + assert json.loads(response.body) == {"error": "invalid_grant", "error_description": "Code expired."} + assert response.headers["cache-control"] == "no-store" + + +def test_caller_rejected_invalid_client_renders_401(): + response = render_token_fault(CallerRejected(code="invalid_client")) + assert response.status_code == 401 + assert json.loads(response.body) == {"error": "invalid_client"} + + +def test_caller_rejected_includes_error_uri_only_when_present(): + response = render_token_fault( + CallerRejected(code="invalid_scope", description="bad scope", error_uri="https://idp.example.com/e") + ) + assert json.loads(response.body) == { + "error": "invalid_scope", + "error_description": "bad scope", + "error_uri": "https://idp.example.com/e", + } + + +def test_gateway_rejected_renders_502_with_gateway_prose(): + response = render_token_fault(GatewayRejected(code="invalid_client")) + assert response.status_code == 502 + body = json.loads(response.body) + assert body["error"] == "server_error" + assert "invalid_client" in body["error_description"] + assert "client_id and client_secret" in body["error_description"] + + +def test_gateway_invalid_target_prose_names_resource_indicators(): + response = render_token_fault(GatewayRejected(code="invalid_target")) + body = json.loads(response.body) + assert response.status_code == 502 + assert "RFC 8707" in body["error_description"] + + +def test_protocol_fault_renders_502_note(): + response = render_token_fault(UpstreamProtocolFault(note="upstream token endpoint returned HTTP 503")) + assert response.status_code == 502 + assert json.loads(response.body) == { + "error": "server_error", + "error_description": "upstream token endpoint returned HTTP 503", + } + + +def test_dcr_caller_rejection_is_400_per_rfc7591_regardless_of_upstream_status(): + status_code, detail = dcr_fault_detail(CallerRejected(code="invalid_client_metadata", description="bad grant types")) + assert status_code == 400 + assert detail == "invalid_client_metadata: bad grant types" + + +def test_dcr_protocol_fault_is_502(): + status_code, detail = dcr_fault_detail(UpstreamProtocolFault(note="upstream registration failed with HTTP 500")) + assert status_code == 502 + assert detail == "upstream registration failed with HTTP 500" + + +def test_upstream_reported_server_error_renders_502_with_matching_code(): + response = render_token_fault(UpstreamReportedFault(code="server_error")) + assert response.status_code == 502 + assert json.loads(response.body)["error"] == "server_error" + + +def test_upstream_reported_temporarily_unavailable_renders_503_with_matching_code(): + response = render_token_fault(UpstreamReportedFault(code="temporarily_unavailable")) + assert response.status_code == 503 + body = json.loads(response.body) + assert body["error"] == "temporarily_unavailable" + assert "retry" in body["error_description"] + + +def test_dcr_upstream_reported_fault_maps_to_5xx(): + status_code, detail = dcr_fault_detail(UpstreamReportedFault(code="server_error")) + assert status_code == 502 + assert "internal error" in detail diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_bridge_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_bridge_credentials.py index 82e8e2aae89..753a3d6a942 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_bridge_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_bridge_credentials.py @@ -15,10 +15,14 @@ from pydantic import SecretStr from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( BridgeEnvelopeAdmitted, BridgeEnvelopeInvalid, + BridgeRefreshInvalid, + BridgeRefreshOpened, NotBridgeEnvelope, + build_bridge_refresh_token_response, build_bridge_token_response, envelope_keys_from_master_key, is_bridge_envelope_shaped, + open_bridge_refresh_envelope, resolve_bridge_envelope, ) from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( @@ -26,15 +30,17 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import EnvelopeIdentity, EnvelopeKeys, EnvelopeTooLarge, + RefreshCredential, SealedEnvelope, UpstreamTokenGrant, + key_hash_identity, mint_envelope, ) _NOW = datetime(2026, 7, 9, 12, 0, 0, tzinfo=timezone.utc) _MASTER_KEY = "sk-master-key-for-derivation-tests-0123456789" _ACCESS_TOKEN = "upstream-access-token-do-not-leak-8f14e45fceea" -_IDENTITY = EnvelopeIdentity(server_id="srv-456", key_hash="hashed-key-123") +_IDENTITY = key_hash_identity(server_id="srv-456", key_hash="hashed-key-123") _SERVER_ID = _IDENTITY.server_id @@ -48,6 +54,76 @@ def _sealed_token(keys: EnvelopeKeys, now: datetime = _NOW, identity: EnvelopeId return sealed.token.get_secret_value() +_UPSTREAM_REFRESH = "upstream-refresh-do-not-leak-9b2c" + + +def _sealed_refresh(keys: EnvelopeKeys, now: datetime = _NOW, identity: EnvelopeIdentity = _IDENTITY) -> str: + sealed = build_bridge_refresh_token_response( + identity, RefreshCredential(refresh_token=SecretStr(_UPSTREAM_REFRESH)), keys, now + ) + assert isinstance(sealed, SealedEnvelope) + return sealed.token.get_secret_value() + + +def test_open_bridge_refresh_envelope_round_trips_identity_and_refresh(): + keys = envelope_keys_from_master_key(_MASTER_KEY) + result = open_bridge_refresh_envelope(_sealed_refresh(keys), keys, _NOW, _SERVER_ID) + assert isinstance(result, BridgeRefreshOpened) + assert result.identity == _IDENTITY + assert result.refresh.refresh_token.get_secret_value() == _UPSTREAM_REFRESH + + +def test_open_bridge_refresh_envelope_strips_bearer_scheme(): + keys = envelope_keys_from_master_key(_MASTER_KEY) + result = open_bridge_refresh_envelope(f"Bearer {_sealed_refresh(keys)}", keys, _NOW, _SERVER_ID) + assert isinstance(result, BridgeRefreshOpened) + + +def test_open_bridge_refresh_envelope_rejects_wrong_server(): + keys = envelope_keys_from_master_key(_MASTER_KEY) + result = open_bridge_refresh_envelope(_sealed_refresh(keys), keys, _NOW, "a-different-server") + assert isinstance(result, BridgeRefreshInvalid) + + +def test_open_bridge_refresh_envelope_rejects_non_refresh_bearers(): + keys = envelope_keys_from_master_key(_MASTER_KEY) + # an access envelope is not a refresh envelope; a raw upstream refresh token is not one either + assert isinstance(open_bridge_refresh_envelope(_sealed_token(keys), keys, _NOW, _SERVER_ID), BridgeRefreshInvalid) + assert isinstance(open_bridge_refresh_envelope("raw-refresh-token", keys, _NOW, _SERVER_ID), BridgeRefreshInvalid) + + +def test_open_bridge_refresh_envelope_rejects_under_wrong_master_key(): + minted = envelope_keys_from_master_key(_MASTER_KEY) + other = envelope_keys_from_master_key(_MASTER_KEY + "-rotated") + result = open_bridge_refresh_envelope(_sealed_refresh(minted), other, _NOW, _SERVER_ID) + assert isinstance(result, BridgeRefreshInvalid) + + +def test_refresh_envelope_is_never_admitted_at_the_tool_call_edge(): + """A refresh envelope must never authenticate a tool call. The admission edge engages the bridge arm + for it (is_bridge_envelope_shaped is true for either envelope kind), and the consumer rejects it as + BridgeEnvelopeInvalid, which admission fails closed (401): a refresh credential is only ever + presented back to the token endpoint.""" + keys = envelope_keys_from_master_key(_MASTER_KEY) + refresh = _sealed_refresh(keys) + assert is_bridge_envelope_shaped(refresh) is True + assert is_bridge_envelope_shaped(f"Bearer {refresh}") is True + result = resolve_bridge_envelope(refresh, keys, _NOW, _SERVER_ID) + assert isinstance(result, BridgeEnvelopeInvalid) + + +def test_refresh_jwt_wearing_the_access_prefix_is_rejected_at_the_edge(): + """Belt-and-suspenders against a swapped wire prefix: a refresh JWT re-prefixed as an access envelope + opens far enough to hit the signed kind claim, which rejects it, so admission fails closed rather + than forwarding a refresh credential's contents upstream.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import REFRESH_ENVELOPE_PREFIX + + keys = envelope_keys_from_master_key(_MASTER_KEY) + swapped = ENVELOPE_PREFIX + _sealed_refresh(keys).removeprefix(REFRESH_ENVELOPE_PREFIX) + result = resolve_bridge_envelope(swapped, keys, _NOW, _SERVER_ID) + assert isinstance(result, BridgeEnvelopeInvalid) + + def test_key_derivation_is_deterministic(): assert envelope_keys_from_master_key(_MASTER_KEY) == envelope_keys_from_master_key(_MASTER_KEY) @@ -138,7 +214,7 @@ def test_resolve_envelope_minted_for_another_server_is_invalid(): captured or misrouted envelope cannot forward one server's upstream credential to another. The valid access token stays sealed; the mismatch alone fails the resolve.""" keys = envelope_keys_from_master_key(_MASTER_KEY) - other_server_identity = EnvelopeIdentity(server_id="srv-OTHER", key_hash=_IDENTITY.key_hash) + other_server_identity = key_hash_identity(server_id="srv-OTHER", key_hash=_IDENTITY.subject) token = _sealed_token(keys, identity=other_server_identity) result = resolve_bridge_envelope(token, keys, _NOW, _SERVER_ID) assert isinstance(result, BridgeEnvelopeInvalid) @@ -155,7 +231,7 @@ def test_resolve_non_ascii_server_id_stays_total_and_does_not_raise(): unicode server_id); it stays total and returns a typed result. A matching non-ASCII id admits, a mismatching one is BridgeEnvelopeInvalid, and neither raises.""" keys = envelope_keys_from_master_key(_MASTER_KEY) - unicode_identity = EnvelopeIdentity(server_id="srv-café", key_hash=_IDENTITY.key_hash) + unicode_identity = key_hash_identity(server_id="srv-café", key_hash=_IDENTITY.subject) token = _sealed_token(keys, identity=unicode_identity) assert isinstance(resolve_bridge_envelope(token, keys, _NOW, "srv-café"), BridgeEnvelopeAdmitted) assert isinstance(resolve_bridge_envelope(token, keys, _NOW, "srv-cafe"), BridgeEnvelopeInvalid) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py index b44f3f84cc9..ae196c9080b 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py @@ -24,6 +24,8 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ENVELOPE_PREFIX, MAX_ENVELOPE_BYTES, MAX_ENVELOPE_TTL_SECONDS, + MAX_REFRESH_ENVELOPE_TTL_SECONDS, + REFRESH_ENVELOPE_PREFIX, BadSignature, DecryptFailed, EnvelopeIdentity, @@ -33,11 +35,18 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import MalformedPayload, NotAnEnvelope, OpenedEnvelope, + OpenedRefreshEnvelope, + RefreshCredential, SealedEnvelope, UpstreamTokenGrant, is_envelope, + is_refresh_envelope, + key_hash_identity, mint_envelope, + mint_refresh_envelope, open_envelope, + open_refresh_envelope, + user_identity, ) from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value, encrypt_value @@ -51,7 +60,7 @@ _WRONG_SIGNING = EnvelopeKeys(signing_key=SecretStr(_OTHER_SIGNING_KEY), encrypt _WRONG_ENCRYPTION = EnvelopeKeys(signing_key=SecretStr(_SIGNING_KEY), encryption_key=SecretStr(_OTHER_ENCRYPTION_KEY)) _ACCESS_TOKEN = "upstream-access-token-do-not-leak-8f14e45fceea" _REFRESH_TOKEN = "upstream-refresh-token-do-not-leak-1d0aa4b7" -_IDENTITY = EnvelopeIdentity(server_id="srv-456", key_hash="hashed-key-123") +_IDENTITY = key_hash_identity(server_id="srv-456", key_hash="hashed-key-123") def _full_grant() -> UpstreamTokenGrant: @@ -137,17 +146,99 @@ def test_minimal_grant_round_trips_without_none_leakage_into_claims(): def test_claim_layout_and_no_plaintext_token_in_envelope(): token = _sealed_token(_full_grant()) claims = _unverified_claims(token) - assert set(claims) == {"iss", "iat", "exp", "server_id", "key_hash", "grant"} + assert set(claims) == {"iss", "iat", "exp", "kind", "server_id", "subject_type", "subject", "grant"} assert claims["iss"] == ENVELOPE_ISSUER assert claims["iat"] == int(_NOW.timestamp()) assert claims["exp"] == int(_NOW.timestamp()) + 600 + assert claims["kind"] == "access" assert claims["server_id"] == "srv-456" - assert claims["key_hash"] == "hashed-key-123" + assert claims["subject_type"] == "key_hash" + assert claims["subject"] == "hashed-key-123" assert _ACCESS_TOKEN not in token assert _ACCESS_TOKEN not in json.dumps(claims) assert _REFRESH_TOKEN not in json.dumps(claims) +def _refresh_credential() -> RefreshCredential: + return RefreshCredential(refresh_token=SecretStr(_REFRESH_TOKEN), scope="read:tools", expires_in=None) + + +def _sealed_refresh_token(refresh: RefreshCredential | None = None, keys: EnvelopeKeys = _KEYS) -> str: + sealed = mint_refresh_envelope(_IDENTITY, refresh or _refresh_credential(), keys, _NOW) + assert isinstance(sealed, SealedEnvelope) + return sealed.token.get_secret_value() + + +def test_refresh_envelope_round_trips_identity_and_refresh_token(): + token = _sealed_refresh_token() + assert is_refresh_envelope(token) + assert not is_envelope(token) + opened = open_refresh_envelope(token, _KEYS, _NOW) + assert isinstance(opened, OpenedRefreshEnvelope) + assert opened.identity == _IDENTITY + assert opened.refresh.refresh_token.get_secret_value() == _REFRESH_TOKEN + assert opened.refresh.scope == "read:tools" + + +def test_refresh_envelope_ttl_is_min_of_upstream_refresh_lifetime_and_cap(): + short = mint_refresh_envelope( + _IDENTITY, RefreshCredential(refresh_token=SecretStr("r"), expires_in=120), _KEYS, _NOW + ) + assert isinstance(short, SealedEnvelope) + assert short.expires_at == _NOW + timedelta(seconds=120) + capped = mint_refresh_envelope( + _IDENTITY, + RefreshCredential(refresh_token=SecretStr("r"), expires_in=MAX_REFRESH_ENVELOPE_TTL_SECONDS + 86400), + _KEYS, + _NOW, + ) + assert isinstance(capped, SealedEnvelope) + assert capped.expires_at == _NOW + timedelta(seconds=MAX_REFRESH_ENVELOPE_TTL_SECONDS) + default = mint_refresh_envelope(_IDENTITY, RefreshCredential(refresh_token=SecretStr("r")), _KEYS, _NOW) + assert isinstance(default, SealedEnvelope) + assert default.expires_at == _NOW + timedelta(seconds=MAX_REFRESH_ENVELOPE_TTL_SECONDS) + + +def test_access_and_refresh_envelopes_do_not_cross_open(): + access = _sealed_token(_full_grant()) + refresh = _sealed_refresh_token() + # each opener rejects the other kind's prefix outright + assert isinstance(open_refresh_envelope(access, _KEYS, _NOW), NotAnEnvelope) + assert isinstance(open_envelope(refresh, _KEYS, _NOW), NotAnEnvelope) + + +def test_prefix_swap_is_rejected_by_the_signed_kind_claim(): + # the wire prefix is not signed, so swap it; the signed kind claim must still reject the cross-use + refresh = _sealed_refresh_token() + swapped_to_access = ENVELOPE_PREFIX + refresh.removeprefix(REFRESH_ENVELOPE_PREFIX) + assert isinstance(open_envelope(swapped_to_access, _KEYS, _NOW), MalformedPayload) + access = _sealed_token(_full_grant()) + swapped_to_refresh = REFRESH_ENVELOPE_PREFIX + access.removeprefix(ENVELOPE_PREFIX) + assert isinstance(open_refresh_envelope(swapped_to_refresh, _KEYS, _NOW), MalformedPayload) + + +def test_refresh_envelope_total_over_hostile_input(): + token = _sealed_refresh_token() + # expired against the injected clock + assert isinstance( + open_refresh_envelope(token, _KEYS, _NOW + timedelta(seconds=MAX_REFRESH_ENVELOPE_TTL_SECONDS)), Expired + ) + # wrong signing key + assert isinstance(open_refresh_envelope(token, _WRONG_SIGNING, _NOW), BadSignature) + # right signature, wrong encryption key + assert isinstance(open_refresh_envelope(token, _WRONG_ENCRYPTION, _NOW), DecryptFailed) + # not an envelope at all + assert isinstance(open_refresh_envelope("raw-upstream-refresh-token", _KEYS, _NOW), NotAnEnvelope) + + +def test_refresh_envelope_never_leaks_the_refresh_token_in_plaintext(): + token = _sealed_refresh_token() + assert _REFRESH_TOKEN not in token + claims = jwt.decode(token.removeprefix(REFRESH_ENVELOPE_PREFIX), options={"verify_signature": False}) + assert claims["kind"] == "refresh" + assert _REFRESH_TOKEN not in json.dumps(claims) + + @pytest.mark.parametrize( "expires_in, expected_ttl", [ @@ -226,11 +317,11 @@ def test_wrong_issuer_is_malformed_payload(): def test_missing_identity_claim_is_malformed_payload(): claims = _unverified_claims(_sealed_token(_full_grant())) - forged = _forge({key: value for key, value in claims.items() if key != "key_hash"}) + forged = _forge({key: value for key, value in claims.items() if key != "subject"}) assert isinstance(open_envelope(forged, _KEYS, _NOW), MalformedPayload) -@pytest.mark.parametrize("identity_claim", ["server_id", "key_hash"]) +@pytest.mark.parametrize("identity_claim", ["server_id", "subject"]) def test_signed_empty_identity_claim_is_malformed_payload_not_a_raise(identity_claim): claims = _unverified_claims(_sealed_token(_full_grant())) forged = _forge({**claims, identity_claim: ""}) @@ -463,9 +554,11 @@ def test_non_positive_expires_in_is_rejected_at_construction_without_leaking(): def test_empty_identity_and_key_fields_are_rejected_at_construction(): with pytest.raises(ValidationError): - EnvelopeIdentity(server_id="", key_hash="hashed-key-123") + EnvelopeIdentity(server_id="", subject_type="key_hash", subject="hashed-key-123") with pytest.raises(ValidationError): - EnvelopeIdentity(server_id="srv-456", key_hash="") + EnvelopeIdentity(server_id="srv-456", subject_type="key_hash", subject="") + with pytest.raises(ValidationError): + EnvelopeIdentity(server_id="srv-456", subject_type="not-a-subject-type", subject="x") with pytest.raises(ValidationError): EnvelopeKeys(signing_key=SecretStr(""), encryption_key=SecretStr(_ENCRYPTION_KEY)) with pytest.raises(ValidationError): @@ -474,6 +567,20 @@ def test_empty_identity_and_key_fields_are_rejected_at_construction(): UpstreamTokenGrant(access_token=SecretStr(""), token_type="Bearer") +def test_user_subject_identity_round_trips(): + """The user_id subject variant seals and opens with its discriminator intact, so the edge can + tell an interactively-minted (user) envelope from a scripted (key_hash) one and reload the right + kind of record.""" + identity = user_identity(server_id="srv-456", user_id="user-42") + sealed = mint_envelope(identity, _full_grant(), _KEYS, _NOW) + assert isinstance(sealed, SealedEnvelope) + opened = open_envelope(sealed.token.get_secret_value(), _KEYS, _NOW) + assert isinstance(opened, OpenedEnvelope) + assert opened.identity.server_id == "srv-456" + assert opened.identity.subject_type == "user_id" + assert opened.identity.subject == "user-42" + + def test_public_models_are_frozen(): sealed = mint_envelope(_IDENTITY, _full_grant(), _KEYS, _NOW) assert isinstance(sealed, SealedEnvelope) @@ -484,4 +591,4 @@ def test_public_models_are_frozen(): with pytest.raises(ValidationError): opened.grant = _minimal_grant() with pytest.raises(ValidationError): - _IDENTITY.key_hash = "someone-elses-hash" + _IDENTITY.subject = "someone-elses-hash" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index c55a631c7b3..f5ac229d119 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -657,6 +657,7 @@ async def test_register_client_persists_dcr_client_identity(): update_data = mock_update.call_args.kwargs["data"] assert update_data.server_id == "remote_server" assert update_data.token_url == "https://provider.example/oauth/token" + assert "authorization_url" not in update_data.fields_set() assert update_data.credentials["client_id"] == "generated-client" assert update_data.credentials["client_secret"] == "generated-secret" assert update_data.credentials["token_endpoint_auth_method"] == "client_secret_basic" @@ -4274,6 +4275,9 @@ async def test_register_bridge_relay_surfaces_upstream_error_not_500(): error_response = MagicMock() error_response.status_code = 400 error_response.text = '{"error":"invalid_redirect_uri","error_description":"redirect_uri not allowed"}' + error_response.json = MagicMock( + return_value={"error": "invalid_redirect_uri", "error_description": "redirect_uri not allowed"} + ) error_response.raise_for_status = MagicMock( side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response) ) @@ -4307,9 +4311,10 @@ async def test_register_bridge_relay_surfaces_upstream_error_not_500(): @pytest.mark.asyncio -async def test_register_non_bridge_upstream_error_still_raises_500(): - """Non-bridge DCR keeps its pre-change behavior: raise_for_status propagates so the flag-off - contract is byte-identical; only the bridge relay arm relays the upstream status.""" +async def test_register_non_bridge_upstream_error_relays_status_not_500(): + """A non-bridge DCR rejection must relay the upstream status and RFC 7591 error body just like + the bridge relay arm; a raw HTTPStatusError would escape to the global handler and surface as an + opaque 500 that hides the real reason from the create-flow UI.""" import httpx from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( @@ -4319,6 +4324,7 @@ async def test_register_non_bridge_upstream_error_still_raises_500(): error_response = MagicMock() error_response.status_code = 400 error_response.text = '{"error":"invalid_client_metadata"}' + error_response.json = MagicMock(return_value={"error": "invalid_client_metadata"}) error_response.raise_for_status = MagicMock( side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response) ) @@ -4338,7 +4344,7 @@ async def test_register_non_bridge_upstream_error_still_raises_500(): return_value=False, ), ): - with pytest.raises(httpx.HTTPStatusError): + with pytest.raises(HTTPException) as exc: await register_client_with_server( request=_bridge_mock_request(), mcp_server=oauth2_server, @@ -4348,6 +4354,9 @@ async def test_register_non_bridge_upstream_error_still_raises_500(): token_endpoint_auth_method=None, ) + assert exc.value.status_code == 400 + assert "invalid_client_metadata" in str(exc.value.detail) + @pytest.mark.asyncio async def test_register_bridge_relay_never_persists(): @@ -4362,6 +4371,1247 @@ async def test_register_bridge_relay_never_persists(): mock_persist.assert_not_called() +_BRIDGE_MASTER_KEY = "sk-bridge-producer-master-key-0123456789abcdef" + + +async def _exchange_for_bridge_server(server, upstream_body, key_hash, code="auth-code", fake_client_out=None): + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _ResolvedKey + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server + + fake_http_response = MagicMock() + fake_http_response.json.return_value = upstream_body + fake_http_response.raise_for_status = MagicMock() + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock(return_value=fake_http_response) + # The mint consumes _resolve_active_litellm_key's tagged result: an active key resolves to a + # _ResolvedKey carrying its hash; a request with no usable credential resolves to "no_active_key". + resolution = _ResolvedKey(key_hash=key_hash, key=MagicMock()) if key_hash is not None else "no_active_key" + key_resolver = AsyncMock(return_value=resolution) + if fake_client_out is not None: + fake_client_out["client"] = fake_http_client + + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=fake_http_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._resolve_active_litellm_key", + new=key_resolver, + ), + patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY), + ): + response = await exchange_token_with_server( + request=_bridge_mock_request(), + mcp_server=server, + grant_type="authorization_code", + code=code, + redirect_uri="https://claude.ai/api/mcp/auth_callback", + client_id="dcr-client-123", + client_secret=None, + code_verifier="verifier", + ) + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import is_bridge_authorization_code + + # The key_hash path resolves the presented litellm key; the interactive SSO path recovers identity + # from the gateway authorization code instead, so it never awaits the resolver. + if server.is_oauth_delegate and server.is_dcr_bridge and not is_bridge_authorization_code(code): + key_resolver.assert_awaited_once() + else: + key_resolver.assert_not_awaited() + return response + + +@pytest.mark.asyncio +async def test_oauth_delegate_bridge_token_exchange_mints_envelope_not_raw_token(): + """A dcr_bridge oauth_delegate token exchange returns a gateway-bound envelope, not the raw + upstream token: the response access_token opens (under the same master-key-derived keys and the + server_id) to the caller's identity and the upstream Authorization, and the raw upstream token + never appears in the bearer the client receives.""" + from datetime import datetime, timezone + + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( + BridgeEnvelopeAdmitted, + envelope_keys_from_master_key, + resolve_bridge_envelope, + ) + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600} + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + + body = json.loads(response.body) + token = body["access_token"] + assert body["token_type"] == "Bearer" + assert body["expires_in"] > 0 + assert token.startswith("llm_env_") + assert "UPSTREAM-SECRET-TOKEN" not in token + assert "refresh_token" not in body + + keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY) + opened = resolve_bridge_envelope(token, keys, datetime.now(timezone.utc), server.server_id) + assert isinstance(opened, BridgeEnvelopeAdmitted) + assert opened.identity.subject_type == "key_hash" + assert opened.identity.subject == "hashed-litellm-key-77" + assert opened.upstream_authorization.get_secret_value() == "Bearer UPSTREAM-SECRET-TOKEN" + + +def test_bridge_authorization_code_round_trips_and_rejects_hostile_input(): + """The gateway authorization code seals and recovers the upstream code and the SSO user, and is + total over hostile input: a raw upstream code (scripted path) opens to None, and a tampered or + non-gateway value opens to None rather than raising.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + is_bridge_authorization_code, + open_bridge_authorization_code, + seal_bridge_authorization_code, + ) + + with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY): + sealed = seal_bridge_authorization_code( + upstream_code="up-code", litellm_user_id="sso-user-9", mcp_server_id="srv-1" + ) + assert is_bridge_authorization_code(sealed) + opened = open_bridge_authorization_code(sealed) + assert opened is not None + assert opened.upstream_code == "up-code" + assert opened.litellm_user_id == "sso-user-9" + assert opened.mcp_server_id == "srv-1" + assert open_bridge_authorization_code("raw-upstream-code") is None + assert open_bridge_authorization_code(sealed[:-4] + "aaaa") is None + + +@pytest.mark.asyncio +async def test_interactive_bridge_token_exchange_mints_user_subject_envelope(): + """An interactive dcr_bridge oauth_delegate exchange (the client presents the gateway code the + callback sealed, and NO litellm key) mints an envelope bound to the SSO-captured user: it opens + to a user_id subject, and the upstream exchange used the real upstream code recovered from the + gateway code, not the sealed wrapper.""" + from datetime import datetime, timezone + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + seal_bridge_authorization_code, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( + BridgeEnvelopeAdmitted, + envelope_keys_from_master_key, + resolve_bridge_envelope, + ) + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY): + gateway_code = seal_bridge_authorization_code( + upstream_code="REAL-UPSTREAM-CODE", litellm_user_id="sso-user-42", mcp_server_id=server.server_id + ) + upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600} + captured: dict = {} + response = await _exchange_for_bridge_server( + server, upstream, key_hash=None, code=gateway_code, fake_client_out=captured + ) + + token = json.loads(response.body)["access_token"] + keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY) + opened = resolve_bridge_envelope(token, keys, datetime.now(timezone.utc), server.server_id) + assert isinstance(opened, BridgeEnvelopeAdmitted) + assert opened.identity.subject_type == "user_id" + assert opened.identity.subject == "sso-user-42" + assert opened.upstream_authorization.get_secret_value() == "Bearer UPSTREAM-SECRET-TOKEN" + assert captured["client"].post.call_args.kwargs["data"]["code"] == "REAL-UPSTREAM-CODE" + + +@pytest.mark.asyncio +async def test_interactive_bridge_gateway_code_for_another_server_is_rejected_400(): + """A gateway authorization code is bound to the server it was minted for: presenting it at another + server's token endpoint is a 400, so a code cannot be replayed across a server boundary.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + seal_bridge_authorization_code, + ) + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY): + gateway_code = seal_bridge_authorization_code( + upstream_code="up-code", litellm_user_id="sso-user-42", mcp_server_id="a-different-server-id" + ) + upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600} + with pytest.raises(HTTPException) as exc: + await _exchange_for_bridge_server(server, upstream, key_hash=None, code=gateway_code) + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_interactive_bridge_authorize_seals_sso_user_into_state(): + """On the short-circuit bridge oauth_delegate arm, authorize captures the SSO user from the UI + session cookie and seals it (and the target server) into the encrypted OAuth state, so the + callback can later mint a user-bound gateway code; it still proceeds to the upstream redirect.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import authorize_with_server + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate, client_id="admin-client", registration_url=None) + captured: dict = {} + + def _capture(**kwargs): + captured.update(kwargs) + return "mocked_encrypted_state" + + with ( + patch( + "litellm.proxy._experimental.mcp_server.byok_oauth_endpoints._user_id_from_session_cookie", + return_value="sso-user-42", + ), + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.encode_state_with_base_url", + side_effect=_capture, + ), + ): + response = await authorize_with_server( + request=_bridge_mock_request(), + mcp_server=server, + client_id="ignored", + redirect_uri="http://127.0.0.1:60108/callback", + state="s", + code_challenge="chal", + code_challenge_method="S256", + ) + + assert captured["litellm_user_id"] == "sso-user-42" + assert captured["mcp_server_id"] == server.server_id + assert "/sso/key/generate" not in response.headers["location"] + + +@pytest.mark.asyncio +async def test_interactive_bridge_authorize_without_session_redirects_to_login(): + """Without a UI session there is no identity to bind, so the short-circuit bridge oauth_delegate + authorize sends the browser through litellm login instead of proceeding to the upstream.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import authorize_with_server + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate, client_id="admin-client", registration_url=None) + with patch( + "litellm.proxy._experimental.mcp_server.byok_oauth_endpoints._user_id_from_session_cookie", + return_value=None, + ): + response = await authorize_with_server( + request=_bridge_mock_request(), + mcp_server=server, + client_id="ignored", + redirect_uri="http://127.0.0.1:60108/callback", + state="s", + code_challenge="chal", + code_challenge_method="S256", + ) + assert "/sso/key/generate" in response.headers["location"] + + +@pytest.mark.asyncio +async def test_interactive_bridge_callback_seals_user_into_gateway_code(): + """When the OAuth state carries the captured SSO user, the callback forwards a gateway + authorization code (sealing the user and upstream code) to the client instead of the raw upstream + code, so the client's later token call can prove who signed in.""" + from urllib.parse import parse_qs, urlparse + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + callback, + is_bridge_authorization_code, + ) + + state_data = { + "original_state": "client-state", + "client_redirect_uri": "http://127.0.0.1:60108/cb", + "base_url": "http://127.0.0.1:60108/cb", + "litellm_user_id": "sso-user-42", + "mcp_server_id": "bridge_srv", + } + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints._resolve_encoded_oauth_state", + return_value="enc", + ), + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.decode_state_hash", + return_value=state_data, + ), + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints._get_validated_client_redirect_uri", + return_value="http://127.0.0.1:60108/cb", + ), + patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY), + ): + response = await callback(request=_bridge_mock_request(), code="REAL-UPSTREAM-CODE", state="relay") + + forwarded_code = parse_qs(urlparse(response.headers["location"]).query)["code"][0] + assert is_bridge_authorization_code(forwarded_code) + + +@pytest.mark.asyncio +async def test_oauth_delegate_bridge_token_exchange_fails_closed_without_litellm_identity(): + """Without a resolvable litellm identity on the token request, the exchange must not mint an + identity-less envelope. It returns an RFC 6749 §5.2-shaped invalid_request (error at the top + level, not wrapped in detail) BEFORE exchanging the upstream code, so the single-use code is not + burned and the client can retry.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600} + captured: dict = {} + response = await _exchange_for_bridge_server(server, upstream, key_hash=None, fake_client_out=captured) + + assert response.status_code == 400 + assert json.loads(response.body)["error"] == "invalid_request" + # identity resolution failed first, so the upstream single-use code was never exchanged (not burned) + captured["client"].post.assert_not_called() + + +@pytest.mark.asyncio +async def test_bridge_envelope_too_large_upstream_token_is_502(): + """An upstream token too large to seal into the envelope is an upstream-payload condition, so the + mint surfaces a 502 (as an RFC 6749 §5.2 error body, not a raised HTTPException) rather than a 500: + build_bridge_token_response returns EnvelopeTooLarge as a value, _finish_bridge_mint returns the + "too_large" failure, and _bridge_mint_error_response maps it to a truthful status.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"access_token": "x" * 40000, "token_type": "Bearer", "expires_in": 3600} + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + assert response.status_code == 502 + assert json.loads(response.body)["error"] == "server_error" + + +@pytest.mark.asyncio +async def test_bridge_access_envelope_never_carries_upstream_refresh_token(): + """The upstream refresh token is never sealed into the ACCESS envelope, the bearer forwarded upstream + on every tool call: the opened access grant carries no refresh token even when the upstream returned + one, and the raw refresh token never appears in the access envelope. It rides only in the separate + refresh envelope returned as the response's refresh_token, encrypted, never in plaintext.""" + from datetime import datetime, timezone + + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( + envelope_keys_from_master_key, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( + OpenedEnvelope, + open_envelope, + ) + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = { + "access_token": "UP", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "UPSTREAM-REFRESH", + } + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + + body = json.loads(response.body) + assert "UPSTREAM-REFRESH" not in body["access_token"] + keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY) + opened = open_envelope(body["access_token"], keys, datetime.now(timezone.utc)) + assert isinstance(opened, OpenedEnvelope) + assert opened.grant.refresh_token is None + # the refresh token rides only in the separate, encrypted refresh envelope, never in plaintext + assert body["refresh_token"].startswith("llm_refresh_") + assert "UPSTREAM-REFRESH" not in body["refresh_token"] + + +@pytest.mark.asyncio +async def test_bridge_refresh_grant_with_non_envelope_is_invalid_grant_before_upstream(): + """A bridge oauth_delegate client only ever holds a refresh envelope, never a raw upstream refresh + token, so a refresh_token grant carrying a bare (non-envelope) value is invalid_grant, rejected in + _prepare_bridge_refresh BEFORE any upstream exchange. Rejecting before the exchange means a bad + refresh request can never consume or rotate an upstream refresh token.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock() + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=fake_http_client, + ), + patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY), + ): + response = await exchange_token_with_server( + request=_bridge_mock_request(), + mcp_server=server, + grant_type="refresh_token", + code=None, + redirect_uri=None, + client_id="dcr-client-123", + client_secret=None, + code_verifier=None, + refresh_token="client-refresh-token", + ) + + assert response.status_code == 400 + assert json.loads(response.body)["error"] == "invalid_grant" + fake_http_client.post.assert_not_called() + + +def _mint_test_refresh_envelope( + server_id="bridge_srv", key_hash="hashed-litellm-key-77", upstream_refresh="UPSTREAM-REFRESH", identity=None, + scope=None, +): + """Mint a refresh envelope the way the producer does, for driving the refresh_token grant in tests. + Defaults to a key_hash subject; pass ``identity`` to seal a specific subject (e.g. a user_id), and + ``scope`` to seal the scope to re-request on refresh.""" + from datetime import datetime, timezone + + from pydantic import SecretStr + + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( + build_bridge_refresh_token_response, + envelope_keys_from_master_key, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( + RefreshCredential, + SealedEnvelope, + key_hash_identity, + ) + + keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY) + identity = identity if identity is not None else key_hash_identity(server_id=server_id, key_hash=key_hash) + sealed = build_bridge_refresh_token_response( + identity, RefreshCredential(refresh_token=SecretStr(upstream_refresh), scope=scope), keys, + datetime.now(timezone.utc), + ) + assert isinstance(sealed, SealedEnvelope) + return sealed.token.get_secret_value() + + +async def _refresh_for_bridge_server( + server, refresh_envelope_value, upstream_body, revalidate_result=None, fake_client_out=None +): + """Drive a refresh_token grant for a bridge server: the client presents ``refresh_envelope_value``, + the sealed subject re-validates to ``revalidate_result`` (``None`` when the key or user is still + active, or a failure literal like "no_active_key" when revoked/deactivated), and the upstream returns + ``upstream_body``. Patching the single subject-revalidation dispatch covers both a key_hash and a + user_id refresh envelope. Returns the response; the captured client exposes the POST call so a test + can assert what refresh token was actually sent upstream.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server + + fake_http_response = MagicMock() + fake_http_response.json.return_value = upstream_body + fake_http_response.raise_for_status = MagicMock() + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock(return_value=fake_http_response) + if fake_client_out is not None: + fake_client_out["client"] = fake_http_client + + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=fake_http_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._revalidate_active_subject", + new=AsyncMock(return_value=revalidate_result), + ), + patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY), + ): + return await exchange_token_with_server( + request=_bridge_mock_request(), + mcp_server=server, + grant_type="refresh_token", + code=None, + redirect_uri=None, + client_id="dcr-client-123", + client_secret=None, + code_verifier=None, + refresh_token=refresh_envelope_value, + ) + + +@pytest.mark.asyncio +async def test_bridge_mint_returns_refresh_envelope_that_opens_to_upstream_refresh(): + """When the upstream returns a refresh token, the authorization_code mint returns a refresh envelope + alongside the access envelope. The refresh envelope is a distinct llm_refresh_ credential that opens + (under the same keys and server_id) to the upstream refresh token, so the client can renew later.""" + from datetime import datetime, timezone + + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( + envelope_keys_from_master_key, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( + OpenedRefreshEnvelope, + open_refresh_envelope, + ) + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "R-UP"} + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + + body = json.loads(response.body) + refresh_env = body["refresh_token"] + assert refresh_env.startswith("llm_refresh_") + keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY) + opened = open_refresh_envelope(refresh_env, keys, datetime.now(timezone.utc)) + assert isinstance(opened, OpenedRefreshEnvelope) + assert opened.identity.server_id == server.server_id + assert opened.refresh.refresh_token.get_secret_value() == "R-UP" + + +@pytest.mark.asyncio +async def test_bridge_mint_omits_refresh_envelope_when_upstream_has_no_refresh(): + """No refresh envelope is issued when the upstream returns no refresh token, so the response carries + only the access envelope; the client re-authenticates at access expiry (nothing to renew with).""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 3600} + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + + body = json.loads(response.body) + assert body["access_token"].startswith("llm_env_") + assert "refresh_token" not in body + + +@pytest.mark.asyncio +async def test_bridge_refresh_grant_sends_unwrapped_upstream_token_and_renews(): + """A refresh_token grant carrying a valid refresh envelope renews: the exchange unwraps the envelope + and sends the REAL upstream refresh token upstream (never the envelope), then returns a fresh access + envelope. This is the flow that lets the client renew without re-authenticating.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="UPSTREAM-REFRESH") + upstream = {"access_token": "NEW-ACCESS", "token_type": "Bearer", "expires_in": 3600} + captured: dict = {} + response = await _refresh_for_bridge_server(server, refresh_env, upstream, None, fake_client_out=captured) + + assert response.status_code == 200 + body = json.loads(response.body) + assert body["access_token"].startswith("llm_env_") + # the upstream exchange received the unwrapped upstream refresh token, never the client's envelope + sent = captured["client"].post.call_args.kwargs["data"] + assert sent["grant_type"] == "refresh_token" + assert sent["refresh_token"] == "UPSTREAM-REFRESH" + assert not sent["refresh_token"].startswith("llm_refresh_") + + +@pytest.mark.asyncio +async def test_bridge_refresh_grant_rotates_refresh_envelope_wrapping_new_upstream_token(): + """When the upstream rotates the refresh token on renewal, the client receives a new refresh envelope + that wraps the NEW upstream refresh token, so the rotation is carried through faithfully.""" + from datetime import datetime, timezone + + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( + envelope_keys_from_master_key, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( + OpenedRefreshEnvelope, + open_refresh_envelope, + ) + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="OLD-UP-REFRESH") + upstream = { + "access_token": "NEW-ACCESS", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "NEW-UP-REFRESH", + } + response = await _refresh_for_bridge_server(server, refresh_env, upstream, None) + + body = json.loads(response.body) + keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY) + opened = open_refresh_envelope(body["refresh_token"], keys, datetime.now(timezone.utc)) + assert isinstance(opened, OpenedRefreshEnvelope) + assert opened.refresh.refresh_token.get_secret_value() == "NEW-UP-REFRESH" + + +@pytest.mark.asyncio +async def test_bridge_refresh_grant_with_revoked_key_is_invalid_grant_before_upstream(): + """A valid refresh envelope whose sealed litellm key has since been revoked cannot keep refreshing: + the reload gate reports no_active_key and the refresh is invalid_grant, returned BEFORE the upstream + exchange so the upstream refresh token is never consumed. Revocation kills renewal.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + refresh_env = _mint_test_refresh_envelope(server_id=server.server_id) + captured: dict = {} + response = await _refresh_for_bridge_server( + server, refresh_env, {"access_token": "NEW"}, "no_active_key", fake_client_out=captured + ) + + assert response.status_code == 400 + assert json.loads(response.body)["error"] == "invalid_grant" + captured["client"].post.assert_not_called() + + +@pytest.mark.asyncio +async def test_bridge_refresh_envelope_for_another_server_is_invalid_grant(): + """A refresh envelope minted for one server cannot renew against another: the sealed server_id must + match the server the refresh targets, so a cross-server refresh envelope is invalid_grant and never + reaches the upstream exchange.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + foreign_env = _mint_test_refresh_envelope(server_id="some-other-server") + captured: dict = {} + response = await _refresh_for_bridge_server( + server, foreign_env, {"access_token": "NEW"}, None, fake_client_out=captured + ) + + assert response.status_code == 400 + assert json.loads(response.body)["error"] == "invalid_grant" + captured["client"].post.assert_not_called() + + +@pytest.mark.asyncio +async def test_bridge_refresh_grant_renews_a_user_subject_envelope(): + """The interactive SSO client mints a user_id-subject envelope, so its refresh envelope carries a + user subject too. Renewing it re-validates the user (still active here), unwraps the upstream refresh + token, and returns a fresh access envelope that opens back to the same user_id subject; the upstream + exchange received the real upstream refresh token, not the client's envelope.""" + from datetime import datetime, timezone + + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( + BridgeEnvelopeAdmitted, + envelope_keys_from_master_key, + resolve_bridge_envelope, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import user_identity + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + user_env = _mint_test_refresh_envelope( + identity=user_identity(server_id=server.server_id, user_id="sso-user-42"), upstream_refresh="UP-REFRESH-USER" + ) + upstream = {"access_token": "NEW-ACCESS", "token_type": "Bearer", "expires_in": 3600} + captured: dict = {} + response = await _refresh_for_bridge_server(server, user_env, upstream, None, fake_client_out=captured) + + assert response.status_code == 200 + body = json.loads(response.body) + keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY) + opened = resolve_bridge_envelope(body["access_token"], keys, datetime.now(timezone.utc), server.server_id) + assert isinstance(opened, BridgeEnvelopeAdmitted) + assert opened.identity.subject_type == "user_id" + assert opened.identity.subject == "sso-user-42" + assert captured["client"].post.call_args.kwargs["data"]["refresh_token"] == "UP-REFRESH-USER" + + +@pytest.mark.asyncio +async def test_bridge_refresh_re_requests_the_sealed_scope_when_client_omits_it(): + """A DCR/MCP client omits scope on the refresh request, so the gateway must re-request the scope sealed + at mint; dropping it lets a stricter upstream narrow the renewed token. The upstream POST must carry + the sealed scope even though the client sent none. Regression for the dropped sealed refresh scope.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + refresh_env = _mint_test_refresh_envelope( + server_id=server.server_id, upstream_refresh="UP-REFRESH", scope="read:tools write:tools" + ) + captured: dict = {} + response = await _refresh_for_bridge_server( + server, refresh_env, {"access_token": "NEW-ACCESS", "token_type": "Bearer", "expires_in": 3600}, None, + fake_client_out=captured, + ) + + assert response.status_code == 200 + assert captured["client"].post.call_args.kwargs["data"]["scope"] == "read:tools write:tools" + + +@pytest.mark.asyncio +async def test_bridge_refresh_re_seals_scope_when_upstream_omits_it_so_the_chain_keeps_it(): + """RFC 6749 5.1 lets an upstream omit scope in a refresh response when it is unchanged. The re-minted + refresh envelope must still seal the scope that was requested, otherwise the NEXT refresh loses it and + a stricter upstream could narrow the token. The returned refresh envelope carries the scope even though + the upstream response had none, and a second refresh off it still re-requests the scope.""" + from datetime import datetime, timezone + + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( + envelope_keys_from_master_key, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( + OpenedRefreshEnvelope, + open_refresh_envelope, + ) + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + refresh_env = _mint_test_refresh_envelope( + server_id=server.server_id, upstream_refresh="UP-1", scope="mcp:read mcp:write" + ) + # the upstream rotates the refresh token but OMITS scope (valid when unchanged) + upstream_no_scope = {"access_token": "NEW", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "UP-2"} + + captured: dict = {} + r1 = await _refresh_for_bridge_server(server, refresh_env, upstream_no_scope, None, fake_client_out=captured) + assert r1.status_code == 200 + assert captured["client"].post.call_args.kwargs["data"]["scope"] == "mcp:read mcp:write" + + keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY) + new_env = json.loads(r1.body)["refresh_token"] + opened = open_refresh_envelope(new_env, keys, datetime.now(timezone.utc)) + assert isinstance(opened, OpenedRefreshEnvelope) + assert opened.refresh.scope == "mcp:read mcp:write" + + captured2: dict = {} + r2 = await _refresh_for_bridge_server(server, new_env, upstream_no_scope, None, fake_client_out=captured2) + assert r2.status_code == 200 + assert captured2["client"].post.call_args.kwargs["data"]["scope"] == "mcp:read mcp:write" + + +@pytest.mark.asyncio +async def test_bridge_refresh_grant_with_deactivated_user_is_invalid_grant_before_upstream(): + """A user_id-subject refresh envelope whose user has since been deactivated (SCIM offboarding, or + the user no longer exists) cannot keep refreshing: subject re-validation reports no_active_key and + the refresh is invalid_grant, returned BEFORE the upstream exchange. Revocation kills renewal for the + user subject exactly as it does for the key subject.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import user_identity + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + user_env = _mint_test_refresh_envelope(identity=user_identity(server_id=server.server_id, user_id="gone-user")) + captured: dict = {} + response = await _refresh_for_bridge_server( + server, user_env, {"access_token": "NEW"}, "no_active_key", fake_client_out=captured + ) + + assert response.status_code == 400 + assert json.loads(response.body)["error"] == "invalid_grant" + captured["client"].post.assert_not_called() + + +@pytest.mark.asyncio +async def test_revalidate_active_subject_dispatches_on_subject_type(): + """Subject re-validation routes a key_hash envelope to the key reload and a user_id envelope to the + user reload, so revocation gates renewal for either identity source through one dispatch point.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( + _ResolvedKey, + _revalidate_active_subject, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import key_hash_identity, user_identity + + with ( + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._reload_active_key_by_hash", + new=AsyncMock(return_value=_ResolvedKey(key_hash="kh", key=MagicMock())), + ) as key_reload, + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._reload_active_user_by_id", + new=AsyncMock(return_value=None), + ) as user_reload, + ): + assert await _revalidate_active_subject(key_hash_identity(server_id="s", key_hash="kh")) is None + key_reload.assert_awaited_once_with("kh") + user_reload.assert_not_awaited() + + with ( + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._reload_active_key_by_hash", + new=AsyncMock(), + ) as key_reload2, + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._reload_active_user_by_id", + new=AsyncMock(return_value="no_active_key"), + ) as user_reload2, + ): + assert await _revalidate_active_subject(user_identity(server_id="s", user_id="u42")) == "no_active_key" + user_reload2.assert_awaited_once_with("u42") + key_reload2.assert_not_awaited() + + +def test_upstream_refresh_credential_expired_refresh_token_is_not_sealed(): + """An upstream that reports its refresh token already elapsed (refresh_expires_in non-positive) must + not be sealed: _upstream_refresh_credential returns None so the exchange degrades to an access-only + response, mirroring how the access grant refuses an already-elapsed access token rather than capping a + dead token to the full refresh TTL. A live or unspecified lifetime still yields a credential.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _upstream_refresh_credential + + assert _upstream_refresh_credential({"access_token": "A", "refresh_token": "R", "refresh_expires_in": 0}) is None + assert _upstream_refresh_credential({"refresh_token": "R", "refresh_expires_in": -5}) is None + live = _upstream_refresh_credential({"refresh_token": "R", "refresh_expires_in": 1800}) + assert live is not None and live.expires_in == 1800 + unspecified = _upstream_refresh_credential({"refresh_token": "R"}) + assert unspecified is not None and unspecified.expires_in is None + + +@pytest.mark.asyncio +async def test_bridge_refresh_upstream_invalid_grant_maps_to_invalid_grant(): + """When the sealed upstream refresh token has been revoked or expired at the IdP, the upstream returns + 400 invalid_grant. The bridge refresh path maps that to an RFC 6749 invalid_grant response so the OAuth + client re-runs authorization_code, rather than surfacing the opaque upstream error it cannot act on.""" + import httpx + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="LIVE-ENVELOPE-REFRESH") + + error_response = MagicMock() + error_response.status_code = 400 + error_response.text = '{"error": "invalid_grant", "error_description": "refresh token expired"}' + error_response.json = MagicMock(return_value={"error": "invalid_grant", "error_description": "refresh token expired"}) + error_response.raise_for_status = MagicMock( + side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response) + ) + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock(return_value=error_response) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=fake_http_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._revalidate_active_subject", + new=AsyncMock(return_value=None), + ), + patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY), + ): + response = await exchange_token_with_server( + request=_bridge_mock_request(), + mcp_server=server, + grant_type="refresh_token", + code=None, + redirect_uri=None, + client_id="dcr-client-123", + client_secret=None, + code_verifier=None, + refresh_token=refresh_env, + ) + + assert response.status_code == 400 + assert json.loads(response.body)["error"] == "invalid_grant" + + +@pytest.mark.asyncio +async def test_bridge_refresh_upstream_error_detection_parses_json_not_substring(): + """The upstream invalid_grant detection reads the classified RFC 6749 5.2 error code, not a substring + of the body. An upstream error whose code is not invalid_grant (here invalid_client, with the string + invalid_grant only inside error_description) must NOT be mistaken for a dead refresh token: it renders + as the classified upstream rejection rather than triggering a spurious authorization_code re-run.""" + import httpx + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="UP") + + error_response = MagicMock() + error_response.status_code = 400 + error_response.text = '{"error": "invalid_client", "error_description": "this is not an invalid_grant problem"}' + error_response.json = MagicMock( + return_value={"error": "invalid_client", "error_description": "this is not an invalid_grant problem"} + ) + error_response.raise_for_status = MagicMock( + side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response) + ) + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock(return_value=error_response) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=fake_http_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._revalidate_active_subject", + new=AsyncMock(return_value=None), + ), + patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY), + ): + response = await exchange_token_with_server( + request=_bridge_mock_request(), + mcp_server=server, + grant_type="refresh_token", + code=None, + redirect_uri=None, + client_id="dcr-client-123", + client_secret=None, + code_verifier=None, + refresh_token=refresh_env, + ) + + assert response.status_code == 401 + body = json.loads(response.body) + assert body["error"] == "invalid_client" + + +@pytest.mark.asyncio +async def test_revalidate_key_subject_revoked_when_owner_scim_deactivated(proxy_globals): + """A key_hash refresh envelope whose key is still active but whose OWNING user was SCIM-deactivated must + fail closed to no_active_key, mirroring how admission's _reject_if_admitted_owner_scim_deactivated + revokes an offboarded owner's key. Without this, an offboarded user keeps renewing a live key.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _ResolvedKey, _revalidate_active_subject + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import key_hash_identity + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + proxy_globals.user_api_key_cache = UserApiKeyCache() + proxy_globals.prisma_client = object() + + resolved = _ResolvedKey(key_hash="kh", key=MagicMock(user_id="offboarded-owner")) + with ( + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._reload_active_key_by_hash", + new=AsyncMock(return_value=resolved), + ), + patch( + "litellm.proxy.auth.auth_checks.get_user_object", + new=AsyncMock(return_value=MagicMock(metadata={"scim_active": False})), + ), + ): + result = await _revalidate_active_subject(key_hash_identity(server_id="s", key_hash="kh")) + + assert result == "no_active_key" + + +@pytest.mark.asyncio +async def test_revalidate_key_subject_active_owner_renews_and_missing_owner_fails_open(proxy_globals): + """The key-owner SCIM gate blocks only an explicit scim_active False: an active owner renews (None), and + a missing owner (get_user_object's wrapped ValueError) fails OPEN, since a key may outlive its owner + record and a transient blip must not revoke a live key.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _ResolvedKey, _revalidate_active_subject + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import key_hash_identity + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + proxy_globals.user_api_key_cache = UserApiKeyCache() + proxy_globals.prisma_client = object() + resolved = _ResolvedKey(key_hash="kh", key=MagicMock(user_id="live-owner")) + identity = key_hash_identity(server_id="s", key_hash="kh") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._reload_active_key_by_hash", + new=AsyncMock(return_value=resolved), + ), + patch( + "litellm.proxy.auth.auth_checks.get_user_object", + new=AsyncMock(return_value=MagicMock(metadata={"scim_active": True})), + ), + ): + assert await _revalidate_active_subject(identity) is None + + with ( + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._reload_active_key_by_hash", + new=AsyncMock(return_value=resolved), + ), + patch( + "litellm.proxy.auth.auth_checks.get_user_object", + new=AsyncMock(side_effect=_wrapped_user_lookup_error(Exception())), + ), + ): + assert await _revalidate_active_subject(identity) is None + + +@pytest.mark.asyncio +async def test_bridge_mint_fails_closed_before_upstream_when_master_key_unset(): + """master_key is validated BEFORE the upstream exchange (in _prepare_bridge_mint), so a + misconfigured gateway returns a 500 server_error without consuming the single-use code, avoiding + the burn-then-fail the pre-exchange phase exists to prevent. The failure is returned as an RFC 6749 + error body, not raised.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock() + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=fake_http_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._resolve_active_litellm_key", + new=AsyncMock(return_value="no_active_key"), + ), + patch("litellm.proxy.proxy_server.master_key", None), + ): + response = await exchange_token_with_server( + request=_bridge_mock_request(), + mcp_server=server, + grant_type="authorization_code", + code="auth-code", + redirect_uri="https://claude.ai/api/mcp/auth_callback", + client_id="dcr-client-123", + client_secret=None, + code_verifier="verifier", + ) + + assert response.status_code == 500 + assert json.loads(response.body)["error"] == "server_error" + fake_http_client.post.assert_not_called() + + +async def _prepare_only_bridge_exchange(resolver_result): + """Drive exchange_token_with_server for a bridge oauth_delegate authorization_code request with the + identity resolver stubbed to a given tagged result, returning (response, post_mock) so a test can + assert the mapped status and that the single-use code was never exchanged.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import exchange_token_with_server + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock() + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=fake_http_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.bridge_token_flow._resolve_active_litellm_key", + new=AsyncMock(return_value=resolver_result), + ), + patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY), + ): + response = await exchange_token_with_server( + request=_bridge_mock_request(), + mcp_server=server, + grant_type="authorization_code", + code="auth-code", + redirect_uri="https://claude.ai/api/mcp/auth_callback", + client_id="dcr-client-123", + client_secret=None, + code_verifier="verifier", + ) + return response, fake_http_client.post + + +@pytest.mark.asyncio +async def test_bridge_mint_db_outage_is_503_before_upstream(): + """A DB outage while resolving identity is a retryable gateway failure, so the mint returns 503 + temporarily_unavailable WITHOUT consuming the single-use code, matching how admission statuses the + same outage on the egress side. Collapsing every resolution failure to None used to blame the + client with 400 invalid_request for an infrastructure problem.""" + response, post = await _prepare_only_bridge_exchange("unavailable") + assert response.status_code == 503 + assert json.loads(response.body)["error"] == "temporarily_unavailable" + post.assert_not_called() + + +@pytest.mark.asyncio +async def test_bridge_mint_unresolvable_identity_is_500_before_upstream(): + """An unresolvable identity (no DB connection, or an unexpected resolution error) is a gateway + fault, so the mint returns 500 server_error before the exchange, a status distinct from both the + caller's 400 and the transient 503, matching admission's 500-vs-503 split for the same conditions.""" + response, post = await _prepare_only_bridge_exchange("unresolvable") + assert response.status_code == 500 + assert json.loads(response.body)["error"] == "server_error" + post.assert_not_called() + + +@pytest.mark.asyncio +async def test_bridge_mint_upstream_expired_lifetime_is_502(): + """An upstream token response reporting an already-elapsed lifetime (a parseable non-positive + expires_in) is rejected with 502 rather than sealed into an hour-long envelope around a dead + bearer. Regression for expires_in<=0 silently falling through to the 1h cap.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0} + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + assert response.status_code == 502 + assert json.loads(response.body)["error"] == "server_error" + + +@pytest.mark.asyncio +async def test_bridge_mint_positive_sub_second_lifetime_mints_not_502(): + """A positive fractional expires_in in (0, 1) is a live token, not an elapsed one, so it mints a + (1s-floored) envelope rather than being truncated to 0 and rejected with 502 after the single-use + code was already consumed. Regression for classifying a sub-second remaining lifetime as expired.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0.5} + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + assert response.status_code == 200 + body = json.loads(response.body) + assert body["access_token"].startswith("llm_env_") + assert body["expires_in"] >= 0 + + +@pytest.mark.asyncio +async def test_bridge_mint_unknown_lifetime_is_capped_not_rejected(): + """An absent or unparseable expires_in leaves the lifetime unknown, which the envelope caps (never + inventing a longer life than the upstream stated); it is NOT rejected. Only an explicitly-dead + lifetime fails, so a metadata glitch on an otherwise-valid token still mints a bounded envelope.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": "not-a-number"} + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + assert response.status_code == 200 + body = json.loads(response.body) + assert body["access_token"].startswith("llm_env_") + assert 0 < body["expires_in"] <= 3600 + + +@pytest.mark.asyncio +async def test_bridge_reported_expires_in_does_not_overstate_jwt_exp(): + """The reported expires_in is derived from the envelope JWT's second-truncated exp (rounding the + elapsed portion up), so the client is never told the bearer lives past the point admission expires + it. Regression for the sub-second overstatement of the raw (expires_at - now) delta.""" + import time + + import jwt as _jwt + + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 300} + before = int(time.time()) + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + body = json.loads(response.body) + claims = _jwt.decode(body["access_token"].removeprefix("llm_env_"), options={"verify_signature": False}) + # projecting the reported lifetime from a time no later than the mint must not exceed the JWT exp + assert before + body["expires_in"] <= claims["exp"] + + +def test_bridge_reported_expires_in_can_be_zero_at_jwt_exp_boundary(): + from datetime import datetime, timezone + + from fastapi.responses import JSONResponse + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _BridgeMintReady, + _finish_bridge_mint, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( + envelope_keys_from_master_key, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import key_hash_identity + from litellm.types.mcp import MCPAuth + + ready = _BridgeMintReady( + identity=key_hash_identity(server_id="bridge_srv", key_hash="hashed-litellm-key-77"), + keys=envelope_keys_from_master_key(_BRIDGE_MASTER_KEY), + ) + response = _finish_bridge_mint( + ready=ready, + mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate), + token_response={"access_token": "UP", "expires_in": 1}, + now=datetime.fromtimestamp(100.25, tz=timezone.utc), + ) + + assert isinstance(response, JSONResponse) + assert json.loads(response.body)["expires_in"] == 0 + + +def test_classify_upstream_lifetime(): + """expires_in from an IdP may be an int, a float (3600.0), or a numeric string ("3600"); each + coerces to a positive number of seconds. Absent or unparseable input (bool, non-numeric, NaN/inf, + oversized) is "unspecified" so the envelope caps it, while a parseable non-positive value is + "expired": the upstream reporting an already-dead token, which the mint must reject rather than + silently give the 1h cap.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _classify_upstream_lifetime + + assert _classify_upstream_lifetime(300) == 300 + assert _classify_upstream_lifetime(300.0) == 300 + assert _classify_upstream_lifetime("300") == 300 + assert _classify_upstream_lifetime(" 300 ") == 300 + # explicit, parseable, non-positive -> the upstream says the token is already dead + assert _classify_upstream_lifetime(0) == "expired" + assert _classify_upstream_lifetime(-5) == "expired" + assert _classify_upstream_lifetime(-0.5) == "expired" + # a positive sub-second lifetime is alive, not elapsed; it clamps up to the envelope's 1s floor + # rather than truncating to 0 and being misread as expired + assert _classify_upstream_lifetime(0.5) == 1 + assert _classify_upstream_lifetime(0.001) == 1 + # a positive value >= 1 truncates toward zero (never overstating the stated lifetime) + assert _classify_upstream_lifetime(1.9) == 1 + # unknown lifetime -> cap (never invent a longer life than the upstream stated) + assert _classify_upstream_lifetime(None) == "unspecified" + assert _classify_upstream_lifetime(True) == "unspecified" + assert _classify_upstream_lifetime("nope") == "unspecified" + # hostile numerics must not raise (int(float(...)) can OverflowError) -> unspecified + assert _classify_upstream_lifetime("inf") == "unspecified" + assert _classify_upstream_lifetime("1e999") == "unspecified" + assert _classify_upstream_lifetime("-inf") == "unspecified" + assert _classify_upstream_lifetime("nan") == "unspecified" + assert _classify_upstream_lifetime(float("inf")) == "unspecified" + assert _classify_upstream_lifetime(10**400) == "unspecified" + + +def test_bridge_grant_honors_and_rejects_upstream_lifetime(): + """The grant validator honors a positive lifetime, leaves an unknown one None for the envelope to + cap, and rejects an explicitly-expired one with "expired_lifetime" so a dead upstream token is + never sealed into an hour-long envelope.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _bridge_grant_from_token_response + + def grant(v): + return _bridge_grant_from_token_response({"access_token": "x", "expires_in": v}) + + assert grant(300).expires_in == 300 + assert grant(120.0).expires_in == 120 + # unknown lifetime backs a grant whose expires_in the envelope caps; it is not a rejection + assert grant("nope").expires_in is None + assert _bridge_grant_from_token_response({"access_token": "x"}).expires_in is None + # an explicitly already-dead lifetime is rejected, not silently capped at 1h + assert grant(0) == "expired_lifetime" + assert grant(-5) == "expired_lifetime" + + +@pytest.mark.asyncio +async def test_bridge_token_exchange_honors_short_float_expires_in_ttl(): + """A short float expires_in from the upstream caps the envelope TTL, so the client-held envelope + does not outlive the upstream token. Before coercion a float was dropped and the envelope + defaulted to the 1h cap (3600), which would forward a stale bearer after the upstream token + expired.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 120.0} + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + assert json.loads(response.body)["expires_in"] <= 120 + + +@pytest.mark.asyncio +async def test_oauth_delegate_bridge_token_exchange_missing_access_token_is_502_not_keyerror(): + """When the upstream token response has no access_token, a dcr_bridge oauth_delegate exchange + returns a clean 502 error body rather than raising a KeyError. _finish_bridge_mint asks + _bridge_grant_from_token_response for a typed grant, gets None, and returns the "no_upstream_token" + failure, which maps to 502; nothing indexes token_response["access_token"] on the bridge path.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"token_type": "Bearer", "expires_in": 3600} + + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + + assert response.status_code == 502 + assert json.loads(response.body)["error"] == "server_error" + + +@pytest.mark.asyncio +async def test_true_passthrough_bridge_token_exchange_returns_raw_upstream_token(): + """Only oauth_delegate mints. A true_passthrough dcr_bridge server relays the raw upstream token + to the client, since that mode has no litellm identity to bind and the caller owns the token.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.true_passthrough) + upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600} + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + + body = json.loads(response.body) + assert body["access_token"] == "UPSTREAM-SECRET-TOKEN" + assert not body["access_token"].startswith("llm_env_") + + +@pytest.mark.asyncio +async def test_non_bridge_oauth_delegate_token_exchange_returns_raw_upstream_token(): + """An oauth_delegate server without dcr_bridge keeps the pre-change contract: the raw upstream + token is returned, so flag-off behavior is byte-identical.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate, dcr_bridge=None) + upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600} + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + + body = json.loads(response.body) + assert body["access_token"] == "UPSTREAM-SECRET-TOKEN" + + async def _exchange_persistence_attempted_for_auth_type(auth_type) -> bool: """Run exchange_token_with_server for a server of ``auth_type`` and report whether it attempted to persist the exchanged token server-side. The client-forwarded token modes must not persist: @@ -4580,7 +5830,7 @@ async def test_extract_user_id_reads_x_litellm_api_key_header(proxy_globals): """The LiteLLM key arrives on x-litellm-api-key (what Claude Desktop/Code send), not Authorization. Reading only Authorization dropped the identity, so the per-user token was never stored and the egress 401'd forever. Resolution must honor x-litellm-api-key.""" - from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( _extract_user_id_from_request, ) from litellm.proxy._types import UserAPIKeyAuth, hash_token @@ -4605,7 +5855,7 @@ async def test_extract_user_id_rehydrates_cross_replica_dict_cache(proxy_globals """Cross-replica, async_get_cache hands back a serialized dict, not a UserAPIKeyAuth. Resolution must rehydrate it; the old getattr(cached, "user_id") returned None on a dict, which is exactly why a multi-replica gateway never found the stored token.""" - from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( _extract_user_id_from_request, ) from litellm.proxy._types import hash_token @@ -4626,7 +5876,7 @@ async def test_extract_user_id_falls_back_to_db_on_cache_miss(proxy_globals): """A cache miss must read the key from the DB rather than returning None; the old code did a cache-only peek and skipped the DB, so any replica that hadn't just authenticated the key failed to store the token.""" - from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( _extract_user_id_from_request, ) from litellm.proxy._types import UserAPIKeyAuth @@ -4648,7 +5898,7 @@ async def test_extract_user_id_falls_back_to_db_on_cache_miss(proxy_globals): @pytest.mark.asyncio async def test_extract_user_id_none_without_litellm_key(proxy_globals): """No LiteLLM key on the request resolves to None without consulting the resolver.""" - from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( _extract_user_id_from_request, ) from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache @@ -4665,7 +5915,7 @@ async def test_extract_user_id_rejects_blocked_key(proxy_globals): """A blocked LiteLLM key must not resolve an identity. get_key_object returns the DB row without checking blocked/expiry (the main auth pipeline does, and the public token endpoint bypasses it), so a revoked key could otherwise overwrite the stored per-user OAuth token for its user.""" - from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( _extract_user_id_from_request, ) from litellm.proxy._types import UserAPIKeyAuth @@ -4687,7 +5937,7 @@ async def test_extract_user_id_rejects_expired_key(proxy_globals): """An expired LiteLLM key must not resolve an identity, for the same reason as a blocked key.""" from datetime import datetime, timedelta, timezone - from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( _extract_user_id_from_request, ) from litellm.proxy._types import UserAPIKeyAuth @@ -4706,6 +5956,219 @@ async def test_extract_user_id_rejects_expired_key(proxy_globals): assert await _extract_user_id_from_request(request) is None +@pytest.mark.asyncio +async def test_resolve_active_litellm_key_returns_resolved_key_for_active_key(proxy_globals): + """The dcr_bridge mint seals the hash of the authorizing key so admission can reload the live + record. For an active key the resolver returns exactly hash_token(key), the same value + get_key_object and the whole cache/DB layer key the record by, so the sealed reference resolves + back to this key at admission.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( + _resolve_active_litellm_key, + _ResolvedKey, + ) + from litellm.proxy._types import UserAPIKeyAuth, hash_token + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + key = "sk-alice-key" + cache = UserApiKeyCache() + await cache.async_set_cache( + hash_token(key), + UserAPIKeyAuth(token=hash_token(key), user_id="alice"), + model_type=UserAPIKeyAuth, + ) + proxy_globals.user_api_key_cache = cache + proxy_globals.prisma_client = object() + + request = _token_request({"x-litellm-api-key": f"Bearer {key}"}) + resolved = await _resolve_active_litellm_key(request) + assert isinstance(resolved, _ResolvedKey) + assert resolved.key_hash == hash_token(key) + + +@pytest.mark.asyncio +async def test_resolve_active_litellm_key_resolves_key_without_user_id(proxy_globals): + """A valid team-scoped or service-account key has no user_id but is a legitimate credential, so it + must still resolve to a hash and be able to mint a bridge envelope. Gating the resolver on user_id + presence wrongly rejected these keys with invalid_request; the active-state gate now checks only + blocked and expiry, and the key hash (not the user) is what the mint seals. The per-user token + store still gets no user for such a key, since there is none to key a stored credential by.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( + _extract_user_id_from_request, + _ResolvedKey, + _resolve_active_litellm_key, + ) + from litellm.proxy._types import UserAPIKeyAuth, hash_token + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + key = "sk-team-scoped-key" + cache = UserApiKeyCache() + await cache.async_set_cache( + hash_token(key), + UserAPIKeyAuth(token=hash_token(key), user_id=None, team_id="team-x"), + model_type=UserAPIKeyAuth, + ) + proxy_globals.user_api_key_cache = cache + proxy_globals.prisma_client = object() + + request = _token_request({"x-litellm-api-key": f"Bearer {key}"}) + resolved = await _resolve_active_litellm_key(request) + assert isinstance(resolved, _ResolvedKey) + assert resolved.key_hash == hash_token(key) + assert await _extract_user_id_from_request(request) is None + + +@pytest.mark.asyncio +async def test_resolve_active_litellm_key_rejects_blocked_key(proxy_globals): + """A blocked key must not yield a hash, so no gateway-bound envelope is minted for a revoked key; + the mint fails closed with invalid_request instead.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( + _resolve_active_litellm_key, + ) + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + class _FakePrisma: + async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None): + return UserAPIKeyAuth(token=token, user_id="blocked-user", blocked=True) + + proxy_globals.user_api_key_cache = UserApiKeyCache() + proxy_globals.prisma_client = _FakePrisma() + + request = _token_request({"x-litellm-api-key": "sk-blocked-key"}) + assert await _resolve_active_litellm_key(request) == "no_active_key" + + +@pytest.mark.asyncio +async def test_resolve_active_litellm_key_fails_closed_on_malformed_expiry(proxy_globals): + """A key whose stored expires string does not parse must fail closed to no-hash (the mint then + returns invalid_request), not surface an unhandled 500. The active-state check runs outside the + resolver's try, so it must be total over a bad expires rather than letting datetime.fromisoformat + raise. Before the fix this raised a ValueError instead of returning None.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( + _resolve_active_litellm_key, + ) + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + class _FakePrisma: + async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None): + return UserAPIKeyAuth(token=token, user_id="u", expires="not-a-parseable-date") + + proxy_globals.user_api_key_cache = UserApiKeyCache() + proxy_globals.prisma_client = _FakePrisma() + + request = _token_request({"x-litellm-api-key": "sk-bad-expiry-key"}) + assert await _resolve_active_litellm_key(request) == "no_active_key" + + +@pytest.mark.asyncio +async def test_resolve_active_litellm_key_no_active_key_without_litellm_key(proxy_globals): + """No LiteLLM key on the request yields no hash without consulting the resolver.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( + _resolve_active_litellm_key, + ) + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + proxy_globals.user_api_key_cache = UserApiKeyCache() + proxy_globals.prisma_client = object() + + request = _token_request({"content-type": "application/json"}) + assert await _resolve_active_litellm_key(request) == "no_active_key" + + +@pytest.mark.asyncio +async def test_resolve_active_litellm_key_db_outage_is_unavailable(proxy_globals): + """A database outage while resolving the presented key is a retryable infrastructure failure, not + the caller's fault, so the resolver reports "unavailable" (the mint statuses it 503) rather than + collapsing it to the same value as a missing credential. is_database_service_unavailable_error + classifies a connection error (an OSError) as an outage, matching admission's egress-side handling.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( + _resolve_active_litellm_key, + ) + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + class _OutagePrisma: + async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None): + raise ConnectionError("connection refused") + + proxy_globals.user_api_key_cache = UserApiKeyCache() + proxy_globals.prisma_client = _OutagePrisma() + + request = _token_request({"x-litellm-api-key": "sk-during-outage"}) + assert await _resolve_active_litellm_key(request) == "unavailable" + + +@pytest.mark.asyncio +async def test_resolve_active_litellm_key_no_database_is_unresolvable(proxy_globals): + """With no database connection configured the gateway cannot verify the presented key at all, so + the resolver reports "unresolvable" (the mint statuses it 500) instead of blaming the caller. + Mirrors admission, which 500s a missing prisma_client on the egress side.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( + _resolve_active_litellm_key, + ) + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + proxy_globals.user_api_key_cache = UserApiKeyCache() + proxy_globals.prisma_client = None + + request = _token_request({"x-litellm-api-key": "sk-no-db"}) + assert await _resolve_active_litellm_key(request) == "unresolvable" + + +def _wrapped_user_lookup_error(original: BaseException) -> ValueError: + """Reproduce get_user_object's real exception contract (litellm/proxy/auth/auth_checks.py): it + catches every DB failure in a broad ``except`` and re-raises a bare ``ValueError``, so the original + error (a missing-user Exception or a real outage) survives only as ``__context__``. Injecting a raw + ConnectionError/Exception instead would exercise a shape production never produces and let a + chain-blind outage classifier pass. The wrapping fidelity is pinned by + test_get_user_object_wraps_db_outage_as_valueerror_preserving_context in test_auth_checks.""" + try: + raise original + except BaseException: + try: + raise ValueError(f"User doesn't exist in db. Got error - {original}") + except ValueError as wrapped: + return wrapped + + +@pytest.mark.asyncio +async def test_reload_active_user_by_id_missing_user_is_no_active_key(proxy_globals): + """A user_id refresh envelope whose user has been deleted must fail closed to no_active_key (the + refresh path maps it to invalid_grant), not unresolvable/500. get_user_object catches the missing row + and re-raises a bare ValueError, so a missing user must not be misclassified as a DB outage or an + opaque gateway fault.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _reload_active_user_by_id + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + proxy_globals.user_api_key_cache = UserApiKeyCache() + proxy_globals.prisma_client = object() + + with patch( + "litellm.proxy.auth.auth_checks.get_user_object", + new=AsyncMock(side_effect=_wrapped_user_lookup_error(Exception())), + ): + assert await _reload_active_user_by_id("gone-user") == "no_active_key" + + +@pytest.mark.asyncio +async def test_reload_active_user_by_id_db_outage_is_unavailable(proxy_globals): + """A transient DB outage while re-validating the user on refresh is a retryable outage, distinct from + a missing user, so the refresh path surfaces "unavailable" (a 503) rather than blaming the caller. + get_user_object wraps the outage in a bare ValueError, so this exercises the chain-aware classifier; a + raw ConnectionError would falsely pass even a chain-blind check because it is an OSError.""" + from litellm.proxy._experimental.mcp_server.bridge_token_flow import _reload_active_user_by_id + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + proxy_globals.user_api_key_cache = UserApiKeyCache() + proxy_globals.prisma_client = object() + + with patch( + "litellm.proxy.auth.auth_checks.get_user_object", + new=AsyncMock(side_effect=_wrapped_user_lookup_error(ConnectionError("user database unreachable"))), + ): + assert await _reload_active_user_by_id("sso-user-7") == "unavailable" + + @pytest.mark.asyncio async def test_token_endpoint_uses_client_secret_basic_when_configured(): """LIT-4091: a server with token_endpoint_auth_method=client_secret_basic must send the @@ -5306,3 +6769,364 @@ async def test_token_exchange_pairs_client_secret_with_server_client_id(): sent = mock_async_client.post.call_args.kwargs["data"] assert sent["client_id"] == "persisted-client" assert "client_secret" not in sent + + +def _upstream_token_response(status_code: int, *, json_body: object = None, text_body: str = "") -> "httpx.Response": + import httpx + + request = httpx.Request("POST", "https://oauth2.googleapis.com/token") + if json_body is not None: + return httpx.Response(status_code, json=json_body, request=request) + return httpx.Response(status_code, text=text_body, request=request) + + +async def _exchange_with_upstream_response(upstream_response, *, server_client_id="web-client.apps.googleusercontent.com"): + """Run the raw (non-bridge) authorization_code exchange against a canned upstream token-endpoint + response and return what the gateway would hand the client. ``server_client_id=None`` models the + caller-supplied-credentials flow (no stored client on the server).""" + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + exchange_token_with_server, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="gcal", + name="gcal", + server_name="gcal", + alias="gcal", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id=server_client_id, + authorization_url="https://accounts.google.com/o/oauth2/v2/auth", + token_url="https://oauth2.googleapis.com/token", + ) + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + mock_async_client = MagicMock() + mock_async_client.post = AsyncMock(return_value=upstream_response) + + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=mock_async_client, + ): + return await exchange_token_with_server( + request=mock_request, + mcp_server=server, + grant_type="authorization_code", + code="auth-code", + redirect_uri="https://litellm.example.com/ui/mcp/oauth/callback", + client_id="web-client.apps.googleusercontent.com", + client_secret=None, + code_verifier="verifier", + ) + + +@pytest.mark.asyncio +async def test_token_exchange_gateway_credential_rejection_is_502_with_gateway_prose(): + """When the gateway presented the server's stored client credentials and the IdP rejected them + (Google refusing a secret-less or unknown client), the fault is the operator's, not the caller's: + 502 server_error with gateway-authored prose naming the code, and the IdP's own prose stays in + server logs. Before the framework this either 500ed raw or relayed provider prose verbatim.""" + response = await _exchange_with_upstream_response( + _upstream_token_response( + 401, json_body={"error": "invalid_client", "error_description": "The OAuth client was not found."} + ) + ) + + assert response.status_code == 502 + body = json.loads(response.body) + assert body["error"] == "server_error" + assert "invalid_client" in body["error_description"] + assert "client_id and client_secret" in body["error_description"] + assert "The OAuth client was not found." not in body["error_description"] + assert response.headers["cache-control"] == "no-store" + + +@pytest.mark.asyncio +async def test_token_exchange_caller_supplied_credential_rejection_relays_code(): + """When the caller supplied the client credentials themselves (no stored client on the server), + an invalid_client rejection is theirs to act on: the §5.2 code relays on the 401 that code + implies.""" + response = await _exchange_with_upstream_response( + _upstream_token_response( + 401, json_body={"error": "invalid_client", "error_description": "The OAuth client was not found."} + ), + server_client_id=None, + ) + + assert response.status_code == 401 + body = json.loads(response.body) + assert body == {"error": "invalid_client", "error_description": "The OAuth client was not found."} + + +@pytest.mark.asyncio +async def test_token_exchange_status_derives_from_error_code_not_upstream_status(): + """An upstream that pairs a caller-fault code with a server-fault status (invalid_grant on a 500) + must not produce a contradictory response: status derives from the classified fault, so the + caller sees 400 invalid_grant and knows to re-authorize rather than blaming the gateway.""" + response = await _exchange_with_upstream_response( + _upstream_token_response(500, json_body={"error": "invalid_grant", "error_description": "Code expired."}) + ) + + assert response.status_code == 400 + body = json.loads(response.body) + assert body == {"error": "invalid_grant", "error_description": "Code expired."} + + +@pytest.mark.asyncio +async def test_token_exchange_relays_only_rfc6749_error_fields(): + """Only error / error_description / error_uri cross the gateway; any other upstream body field is + dropped so an arbitrary rejection payload cannot ride the relay to the client.""" + response = await _exchange_with_upstream_response( + _upstream_token_response( + 400, + json_body={ + "error": "invalid_grant", + "error_description": "Code was already redeemed.", + "error_uri": "https://idp.example.com/errors/invalid_grant", + "internal_trace": "should never reach the client", + }, + ) + ) + + assert response.status_code == 400 + body = json.loads(response.body) + assert set(body.keys()) == {"error", "error_description", "error_uri"} + + +@pytest.mark.asyncio +async def test_token_exchange_maps_out_of_contract_rejection_to_502(): + """A rejection outside the §5.2 contract (no JSON error field, or a status the token-endpoint + contract does not define) is an upstream fault; 502 keeps it from being misread as a caller + mistake while the description still names the upstream status.""" + response = await _exchange_with_upstream_response( + _upstream_token_response(503, text_body="upstream maintenance") + ) + + assert response.status_code == 502 + body = json.loads(response.body) + assert body["error"] == "server_error" + assert "HTTP 503" in body["error_description"] + + +@pytest.mark.asyncio +async def test_token_exchange_bounds_relayed_error_fields(): + """Relayed §5.2 fields are length-bounded so a hostile or broken upstream cannot bloat the + gateway response.""" + response = await _exchange_with_upstream_response( + _upstream_token_response(400, json_body={"error": "invalid_request", "error_description": "x" * 5000}) + ) + + assert response.status_code == 400 + body = json.loads(response.body) + assert len(body["error_description"]) == 500 + + +@pytest.mark.asyncio +async def test_token_exchange_200_without_access_token_is_502_not_keyerror(): + """A 200 whose body has no usable access_token used to KeyError into a 500; the raw arm now + answers 502 with the same wording as the bridge arm's no_upstream_token rejection.""" + response = await _exchange_with_upstream_response( + _upstream_token_response(200, json_body={"token_type": "Bearer"}) + ) + + assert response.status_code == 502 + body = json.loads(response.body) + assert body["error"] == "server_error" + assert "access_token" in body["error_description"] + + +@pytest.mark.asyncio +async def test_token_exchange_relays_rejection_when_http_client_raises(): + """litellm's AsyncHTTPHandler.post raise_for_status()es internally and raises MaskedHTTPStatusError + at call time, so in production the rejection escapes from the post call itself rather than from the + explicit raise_for_status; the relay must catch it there too (proven live: a mock returning the + error response passed while the real proxy still 500ed).""" + import httpx + + rejection = _upstream_token_response( + 401, json_body={"error": "invalid_client", "error_description": "The OAuth client was not found."} + ) + raising_client = MagicMock() + raising_client.post = AsyncMock( + side_effect=httpx.HTTPStatusError("Client error '401 Unauthorized'", request=rejection.request, response=rejection) + ) + + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + exchange_token_with_server, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="gcal", + name="gcal", + server_name="gcal", + alias="gcal", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="web-client.apps.googleusercontent.com", + authorization_url="https://accounts.google.com/o/oauth2/v2/auth", + token_url="https://oauth2.googleapis.com/token", + ) + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=raising_client, + ): + response = await exchange_token_with_server( + request=mock_request, + mcp_server=server, + grant_type="authorization_code", + code="auth-code", + redirect_uri="https://litellm.example.com/ui/mcp/oauth/callback", + client_id="web-client.apps.googleusercontent.com", + client_secret=None, + code_verifier="verifier", + ) + + assert response.status_code == 502 + body = json.loads(response.body) + assert body["error"] == "server_error" + assert "invalid_client" in body["error_description"] + + +@pytest.mark.asyncio +async def test_register_relays_rejection_when_http_client_raises(): + """Same live mechanism as the token exchange: the DCR rejection escapes from the post call itself, + so the register relay must catch it there, not only from the explicit raise_for_status.""" + import httpx + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + register_client_with_server, + ) + + rejection = httpx.Response( + 400, + json={"error": "invalid_client_metadata"}, + request=httpx.Request("POST", "https://idp.example.com/register"), + ) + raising_client = MagicMock() + raising_client.post = AsyncMock( + side_effect=httpx.HTTPStatusError("Client error '400 Bad Request'", request=rejection.request, response=rejection) + ) + + oauth2_server = _bridge_server(auth_type=MCPAuth.oauth2, dcr_bridge=None) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=raising_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available", + new_callable=AsyncMock, + return_value=False, + ), + ): + with pytest.raises(HTTPException) as exc: + await register_client_with_server( + request=_bridge_mock_request(), + mcp_server=oauth2_server, + client_name="Claude", + grant_types=None, + response_types=None, + token_endpoint_auth_method=None, + ) + + assert exc.value.status_code == 400 + assert "invalid_client_metadata" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_token_exchange_never_relays_out_of_contract_body_to_client(): + """These endpoints serve unauthenticated OAuth clients, so a non-RFC6749 upstream body (HTML + error page, proxy banner, stack trace) must stay in server logs; the client sees only the + upstream status.""" + response = await _exchange_with_upstream_response( + _upstream_token_response(404, text_body="Error 404 stack trace: secret internals") + ) + + assert response.status_code == 502 + body = json.loads(response.body) + assert body == {"error": "server_error", "error_description": "upstream token endpoint returned HTTP 404"} + + +@pytest.mark.asyncio +async def test_register_never_relays_out_of_contract_body_to_client(): + """Same trust boundary for DCR: a non-RFC7591 rejection body is logged server-side and the + client detail names only the status.""" + import httpx + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + register_client_with_server, + ) + + rejection = httpx.Response( + 500, + text="Tomcat stack trace with internals", + request=httpx.Request("POST", "https://idp.example.com/register"), + ) + raising_client = MagicMock() + raising_client.post = AsyncMock( + side_effect=httpx.HTTPStatusError("Server error '500'", request=rejection.request, response=rejection) + ) + + oauth2_server = _bridge_server(auth_type=MCPAuth.oauth2, dcr_bridge=None) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=raising_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available", + new_callable=AsyncMock, + return_value=False, + ), + ): + with pytest.raises(HTTPException) as exc: + await register_client_with_server( + request=_bridge_mock_request(), + mcp_server=oauth2_server, + client_name="Claude", + grant_types=None, + response_types=None, + token_endpoint_auth_method=None, + ) + + assert exc.value.status_code == 502 + assert str(exc.value.detail) == "upstream registration failed with HTTP 500" + assert "Tomcat" not in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_token_exchange_unreadable_body_still_renders_oauth_fault(): + """An upstream whose failure body cannot be read (unconsumed stream, lying content-encoding) + makes response.text/.json raise; the classifier must stay total so the caller still gets the + §5.2-shaped 502 instead of the opaque 500 this change set out to remove.""" + import httpx + + unreadable = httpx.Response( + 400, + stream=httpx.ByteStream(b"\x1f\x8bnot-actually-gzip"), + headers={"content-encoding": "gzip"}, + request=httpx.Request("POST", "https://oauth2.googleapis.com/token"), + ) + + response = await _exchange_with_upstream_response(unreadable) + + assert response.status_code == 502 + body = json.loads(response.body) + assert body == {"error": "server_error", "error_description": "upstream token endpoint returned HTTP 400"} diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 32996905166..a983ac3ff48 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -5428,6 +5428,457 @@ def test_get_forwarded_auth_from_scope_skips_when_no_litellm_key_header(): assert _get_forwarded_auth_from_scope(scope) is None +def _delegate_auth_mcp_server(server_id: str = "delegate-1") -> MCPServer: + return MCPServer( + server_id=server_id, + name="delegate_test", + url="http://upstream:9401/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + oauth2_flow="authorization_code", + ) + + +def _delegate_scope(headers: list) -> dict: + return { + "type": "http", + "method": "POST", + "path": "/mcp/delegate_test", + "scheme": "http", + "server": ("localhost", 4000), + "headers": headers, + } + + +def _patch_delegate_resolver(server: MCPServer, *resolvable_names: str): + """Patch the admission-parity resolver the delegate probe gates on. Returns + ``server`` only for names admission's ``get_mcp_server_by_name`` would match + (alias / server_name / name); every other name (server_id, access group) yields + None, exactly as the real resolver does.""" + + def _resolve(name, client_ip=None): + return server if name in resolvable_names else None + + return patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + side_effect=_resolve, + ) + + +@pytest.mark.asyncio +async def test_delegate_bad_token_gets_connect_time_401(): + """Regression (LIT-4194): a rejected upstream token on a delegate-auth server + must fail the connect with 401 + ``error="invalid_token"``, not be absorbed + into HTTP 200 + an empty tool list by the tools/list handler. + + Delegate-mode clients send only ``Authorization`` (no ``x-litellm-api-key``), + so ``_get_forwarded_auth_from_scope`` returns None and, before the fix, the + preflight returned early without probing. + """ + from litellm.proxy._experimental.mcp_server.server import ( + _check_passthrough_upstream_auth, + ) + from litellm.proxy._types import UserAPIKeyAuth + + server = _delegate_auth_mcp_server() + scope = _delegate_scope([(b"authorization", b"Bearer bogus-token")]) + + with _patch_delegate_resolver(server, "delegate_test"), patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[server]), + ), patch( + "litellm.proxy._experimental.mcp_server.server._probe_upstream_auth", + new=AsyncMock(return_value=(401, 'Bearer realm="upstream", error="invalid_token"')), + ) as probe: + with pytest.raises(HTTPException) as exc_info: + await _check_passthrough_upstream_auth( + scope=scope, + user_api_key_auth=UserAPIKeyAuth(), + mcp_servers=["delegate_test"], + client_ip=None, + ) + + assert exc_info.value.status_code == 401 + challenge = exc_info.value.headers["www-authenticate"] + assert 'error="invalid_token"' in challenge + assert 'resource_metadata="http://localhost:4000/.well-known/oauth-protected-resource/mcp/delegate_test"' in challenge + probe.assert_awaited_once() + probe_url, probe_auth = probe.call_args.args + assert probe_url == "http://upstream:9401/mcp" + assert probe_auth == "Bearer bogus-token" + + +@pytest.mark.asyncio +async def test_delegate_valid_token_passes_preflight(): + """An upstream-accepted token must not be blocked by the delegate preflight.""" + from litellm.proxy._experimental.mcp_server.server import ( + _check_passthrough_upstream_auth, + ) + from litellm.proxy._types import UserAPIKeyAuth + + server = _delegate_auth_mcp_server() + scope = _delegate_scope([(b"authorization", b"Bearer good-token")]) + + with _patch_delegate_resolver(server, "delegate_test"), patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[server]), + ), patch( + "litellm.proxy._experimental.mcp_server.server._probe_upstream_auth", + new=AsyncMock(return_value=(200, None)), + ) as probe: + await _check_passthrough_upstream_auth( + scope=scope, + user_api_key_auth=UserAPIKeyAuth(), + mcp_servers=["delegate_test"], + client_ip=None, + ) + + probe.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_delegate_valid_token_forbidden_returns_403(): + """An upstream that accepts the token but forbids the caller (403) must surface + as a bare 403 with no ``WWW-Authenticate`` re-auth hint (a fresh token with the + same scopes would loop), not as an invalid_token challenge.""" + from litellm.proxy._experimental.mcp_server.server import ( + _check_passthrough_upstream_auth, + ) + from litellm.proxy._types import UserAPIKeyAuth + + server = _delegate_auth_mcp_server() + scope = _delegate_scope([(b"authorization", b"Bearer scoped-out-token")]) + + with _patch_delegate_resolver(server, "delegate_test"), patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[server]), + ), patch( + "litellm.proxy._experimental.mcp_server.server._probe_upstream_auth", + new=AsyncMock(return_value=(403, None)), + ): + with pytest.raises(HTTPException) as exc_info: + await _check_passthrough_upstream_auth( + scope=scope, + user_api_key_auth=UserAPIKeyAuth(), + mcp_servers=["delegate_test"], + client_ip=None, + ) + + assert exc_info.value.status_code == 403 + assert not (exc_info.value.headers or {}) + + +@pytest.mark.asyncio +async def test_delegate_tokenless_request_not_probed(): + """Tokenless delegate requests are the preemptive challenge's job; the + preflight must not probe upstream with an empty credential.""" + from litellm.proxy._experimental.mcp_server.server import ( + _check_passthrough_upstream_auth, + ) + from litellm.proxy._types import UserAPIKeyAuth + + server = _delegate_auth_mcp_server() + scope = _delegate_scope([(b"content-type", b"application/json")]) + + with _patch_delegate_resolver(server, "delegate_test"), patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[server]), + ), patch( + "litellm.proxy._experimental.mcp_server.server._probe_upstream_auth", + new=AsyncMock(return_value=(401, None)), + ) as probe: + await _check_passthrough_upstream_auth( + scope=scope, + user_api_key_auth=UserAPIKeyAuth(), + mcp_servers=["delegate_test"], + client_ip=None, + ) + + probe.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delegate_preflight_skipped_on_multi_server_routes(): + """The delegate probe is gated to single-server routes so one rejected token + cannot 401 a multi-server aggregate connect (matching the OBO preflight).""" + from litellm.proxy._experimental.mcp_server.server import ( + _check_passthrough_upstream_auth, + ) + from litellm.proxy._types import UserAPIKeyAuth + + servers = [_delegate_auth_mcp_server("delegate-1"), _delegate_auth_mcp_server("delegate-2")] + scope = _delegate_scope([(b"authorization", b"Bearer bogus-token")]) + + with _patch_delegate_resolver(servers[0], "delegate_test", "other_server"), patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=servers), + ), patch( + "litellm.proxy._experimental.mcp_server.server._probe_upstream_auth", + new=AsyncMock(return_value=(401, None)), + ) as probe: + await _check_passthrough_upstream_auth( + scope=scope, + user_api_key_auth=UserAPIKeyAuth(), + mcp_servers=["delegate_test", "other_server"], + client_ip=None, + ) + + probe.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_bare_authorization_never_probes_passthrough_servers(): + """A bare ``Authorization`` header may be a LiteLLM key (backward-compat), so + only delegate servers (where admission classified it as an upstream token) + may be probed with it; ``is_oauth_passthrough`` servers still require the + unambiguous ``x-litellm-api-key`` + ``Authorization`` pair.""" + from litellm.proxy._experimental.mcp_server.server import ( + _check_passthrough_upstream_auth, + ) + from litellm.proxy._types import UserAPIKeyAuth + + passthrough_server = MCPServer( + server_id="pt-1", + name="pt_server", + url="http://upstream:9402/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + oauth_passthrough=True, + extra_headers=["Authorization"], + ) + scope = _delegate_scope([(b"authorization", b"Bearer ambiguous-token")]) + + with _patch_delegate_resolver(passthrough_server, "pt_server"), patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[passthrough_server]), + ), patch( + "litellm.proxy._experimental.mcp_server.server._probe_upstream_auth", + new=AsyncMock(return_value=(401, None)), + ) as probe: + await _check_passthrough_upstream_auth( + scope=scope, + user_api_key_auth=UserAPIKeyAuth(), + mcp_servers=["pt_server"], + client_ip=None, + ) + + probe.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delegate_not_probed_when_named_only_via_server_id(): + """Security regression (LIT-4194): a delegate server reachable by the requested + name only through its server_id (or an access group) is admitted as a real + LiteLLM key by ``process_mcp_request`` (its ``get_mcp_server_by_name`` misses), + so the bare ``Authorization`` header is that LiteLLM key. The probe must resolve + the target through the SAME resolver and therefore skip it, never forwarding the + key upstream, even though the widened allowed-server set still contains it.""" + from litellm.proxy._experimental.mcp_server.server import ( + _check_passthrough_upstream_auth, + ) + from litellm.proxy._types import UserAPIKeyAuth + + server = _delegate_auth_mcp_server(server_id="delegate-secret-id") + # Admission's resolver matches alias/server_name/name only, never server_id: the + # requested server_id resolves to None here, mirroring the real divergence. + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/delegate-secret-id", + "scheme": "http", + "server": ("localhost", 4000), + "headers": [(b"authorization", b"Bearer sk-litellm-proxy-key")], + } + + with _patch_delegate_resolver(server, "delegate_test"), patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[server]), + ), patch( + "litellm.proxy._experimental.mcp_server.server._probe_upstream_auth", + new=AsyncMock(return_value=(401, None)), + ) as probe: + await _check_passthrough_upstream_auth( + scope=scope, + user_api_key_auth=UserAPIKeyAuth(user_id="u1", api_key="hashed-sk"), + mcp_servers=["delegate-secret-id"], + client_ip=None, + ) + + probe.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delegate_preflight_with_unpatched_probe(): + """Integration across the preflight and the unpatched ``_probe_upstream_auth``, + mocked only at the httpx-client boundary (tests/test_litellm is mocked-only; the + real-network proof lives in the PR's live-proxy evidence). The mock honors the + ``AsyncHTTPHandler.post`` contract by raising ``httpx.HTTPStatusError`` on the + upstream 401, so the production ``except httpx.HTTPStatusError`` branch is the one + exercised. A rejected token surfaces as the connect-time 401 challenge; an + accepted token passes untouched, and the caller's bearer reaches the delegate URL.""" + import httpx + + from litellm.proxy._experimental.mcp_server.server import ( + _check_passthrough_upstream_auth, + ) + from litellm.proxy._types import UserAPIKeyAuth + + accepted = MagicMock() + accepted.status_code = 200 + accepted.headers = {} + rejected = MagicMock() + rejected.status_code = 401 + rejected.headers = {"www-authenticate": 'Bearer realm="stub-upstream", error="invalid_token"'} + + async def respond_by_token(url=None, headers=None, json=None, timeout=None, **kwargs): + if headers.get("Authorization") == "Bearer good-token": + return accepted + raise httpx.HTTPStatusError( + "401 Unauthorized", + request=httpx.Request("POST", url), + response=rejected, + ) + + mock_client = MagicMock() + mock_client.post = AsyncMock(side_effect=respond_by_token) + + server = _delegate_auth_mcp_server() + + with _patch_delegate_resolver(server, "delegate_test"), patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[server]), + ), patch( + "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client", + return_value=mock_client, + ): + with pytest.raises(HTTPException) as exc_info: + await _check_passthrough_upstream_auth( + scope=_delegate_scope([(b"authorization", b"Bearer bogus-token")]), + user_api_key_auth=UserAPIKeyAuth(), + mcp_servers=["delegate_test"], + client_ip=None, + ) + + await _check_passthrough_upstream_auth( + scope=_delegate_scope([(b"authorization", b"Bearer good-token")]), + user_api_key_auth=UserAPIKeyAuth(), + mcp_servers=["delegate_test"], + client_ip=None, + ) + + assert exc_info.value.status_code == 401 + challenge = exc_info.value.headers["www-authenticate"] + assert 'error="invalid_token"' in challenge + assert 'resource_metadata="http://localhost:4000/.well-known/oauth-protected-resource/mcp/delegate_test"' in challenge + probed_urls = [call.kwargs["url"] for call in mock_client.post.await_args_list] + assert probed_urls == ["http://upstream:9401/mcp", "http://upstream:9401/mcp"] + + +@pytest.mark.asyncio +async def test_delegate_challenge_echoes_requested_alias(): + """An alias-routed delegate request must be probed, and the challenge must echo + the requested alias (not the canonical server name) so the resource_metadata + URL matches what the tokenless preemptive challenge emits for the same route.""" + from litellm.proxy._experimental.mcp_server.server import ( + _check_passthrough_upstream_auth, + ) + from litellm.proxy._types import UserAPIKeyAuth + + server = _delegate_auth_mcp_server().model_copy(update={"alias": "dt-alias"}) + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/dt-alias", + "scheme": "http", + "server": ("localhost", 4000), + "headers": [(b"authorization", b"Bearer bogus-token")], + } + + with _patch_delegate_resolver(server, "dt-alias"), patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[server]), + ), patch( + "litellm.proxy._experimental.mcp_server.server._probe_upstream_auth", + new=AsyncMock(return_value=(401, 'Bearer error="invalid_token"')), + ): + with pytest.raises(HTTPException) as exc_info: + await _check_passthrough_upstream_auth( + scope=scope, + user_api_key_auth=UserAPIKeyAuth(), + mcp_servers=["dt-alias"], + client_ip=None, + ) + + challenge = exc_info.value.headers["www-authenticate"] + assert 'error="invalid_token"' in challenge + assert 'resource_metadata="http://localhost:4000/.well-known/oauth-protected-resource/mcp/dt-alias"' in challenge + + +@pytest.mark.asyncio +async def test_delegate_probe_not_fanned_out_to_access_group_members(): + """A single access-group name passes the one-target route gate but must not fan + the delegate probe out to group-expanded member servers; the group name resolves + to no server under admission's resolver, so no probe fires.""" + from litellm.proxy._experimental.mcp_server.server import ( + _check_passthrough_upstream_auth, + ) + from litellm.proxy._types import UserAPIKeyAuth + + group_member = _delegate_auth_mcp_server() + + with _patch_delegate_resolver(group_member, "delegate_test"), patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[group_member]), + ), patch( + "litellm.proxy._experimental.mcp_server.server._probe_upstream_auth", + new=AsyncMock(return_value=(401, None)), + ) as probe: + await _check_passthrough_upstream_auth( + scope=_delegate_scope([(b"authorization", b"Bearer bogus-token")]), + user_api_key_auth=UserAPIKeyAuth(), + mcp_servers=["prod_tools_group"], + client_ip=None, + ) + + probe.assert_not_awaited() + + +def test_is_delegate_upstream_probe_target_fails_closed_on_m2m_shape(): + """An unstamped M2M-shape row (null ``oauth2_flow`` + client credentials) + resolves to ``client_credentials`` and must not be probed with the caller's + bearer; its stored client credentials drive egress instead.""" + from litellm.proxy._experimental.mcp_server.server import ( + _is_delegate_upstream_probe_target, + ) + + assert _is_delegate_upstream_probe_target(_delegate_auth_mcp_server()) is True + + m2m_shape = MCPServer( + server_id="delegate-m2m", + name="delegate_m2m", + url="http://upstream:9401/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + oauth2_flow=None, + token_url="http://idp:9000/token", + client_id="client", + client_secret="secret", + ) + assert _is_delegate_upstream_probe_target(m2m_shape) is False + + non_delegate = MCPServer( + server_id="oauth2-plain", + name="oauth2_plain", + url="http://upstream:9401/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + ) + assert _is_delegate_upstream_probe_target(non_delegate) is False + + @pytest.mark.asyncio async def test_create_mcp_client_sampling_disabled_by_default(): """Sampling callback must be None when allow_sampling is not set (default False).""" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 7c55bd4560f..adcfff6fe9d 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -1040,7 +1040,7 @@ class TestMCPServerManager: updated_at=datetime.now(), ) - metadata = SimpleNamespace( + metadata = MCPOAuthMetadata( authorization_url="https://idp.example.com/authorize", token_url="https://idp.example.com/token", registration_url="https://idp.example.com/register", @@ -2125,6 +2125,7 @@ class TestMCPServerManager: ) assert result is mock_metadata assert result.scopes == ["api://some-scope/.default"] + assert result.from_origin_fallback is False @pytest.mark.asyncio async def test_fetch_single_authorization_server_metadata_supports_azure_issuer_path( @@ -2247,6 +2248,7 @@ class TestMCPServerManager: mock_fetch_auth.assert_awaited_once_with(["https://example.com"], server_url) assert result is mock_metadata assert result.scopes == ["read"] + assert result.from_origin_fallback is True @pytest.mark.asyncio async def test_load_servers_from_config_overrides_discovery_metadata(self): @@ -4821,6 +4823,276 @@ class TestMCPServerTimestamps: update_mock.assert_awaited_once() + @pytest.mark.asyncio + async def test_build_mcp_server_from_table_persists_discovered_oauth_endpoints(self): + """A DB-backed oauth2 server with no configured endpoints discovers them and must write + authorization_url, token_url, and scopes back to the row; otherwise the resolved values + live only in memory and one failed re-discovery serves 400 "authorization url is not set" + from /authorize. registration_url must never be persisted because + _dcr_bridge_relays_client_registration keys off that column.""" + manager = MCPServerManager() + + async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True): + assert allow_origin_fallback is True + return MCPOAuthMetadata( + scopes=["mcp.read", "mcp.write"], + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + registration_url="https://idp.example.com/register", + ) + + manager._descovery_metadata = fake_discovery # type: ignore[attr-defined] + + record = LiteLLM_MCPServerTable( + server_id="oauth-persist-1", + server_name="oauth_persist", + url="https://example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + credentials={"client_id": "cid", "client_secret": "csec"}, + ) + + update_mcp_server_mock = AsyncMock() + with ( + patch( + "litellm.proxy._experimental.mcp_server.db.update_mcp_server", + new=update_mcp_server_mock, + ), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + ): + server = await manager.build_mcp_server_from_table(record, credentials_are_encrypted=False) + + assert server.authorization_url == "https://idp.example.com/authorize" + update_mcp_server_mock.assert_awaited_once() + persisted = update_mcp_server_mock.call_args.kwargs["data"] + assert persisted.server_id == "oauth-persist-1" + assert persisted.authorization_url == "https://idp.example.com/authorize" + assert persisted.token_url == "https://idp.example.com/token" + assert persisted.credentials == {"scopes": ["mcp.read", "mcp.write"]} + assert "registration_url" not in persisted.fields_set() + assert update_mcp_server_mock.call_args.kwargs["touched_by"] == "mcp_oauth_discovery" + + @pytest.mark.asyncio + async def test_persist_discovered_oauth_endpoints_guards(self): + """The write-back must no-op for non-discovery auth types, empty discovery, origin-fallback + guesses (never harden an inferred authorization server into configuration), and rows whose + fields are all already populated.""" + manager = MCPServerManager() + advertised = MCPOAuthMetadata( + scopes=["s1"], + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + ) + + update_mcp_server_mock = AsyncMock() + with ( + patch( + "litellm.proxy._experimental.mcp_server.db.update_mcp_server", + new=update_mcp_server_mock, + ), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + ): + await manager._persist_discovered_oauth_endpoints( + server_id="s", + auth_type=MCPAuth.api_key, + existing_authorization_url=None, + existing_token_url=None, + existing_scopes=None, + metadata=advertised, + ) + await manager._persist_discovered_oauth_endpoints( + server_id="s", + auth_type=MCPAuth.oauth2, + existing_authorization_url=None, + existing_token_url=None, + existing_scopes=None, + metadata=None, + ) + await manager._persist_discovered_oauth_endpoints( + server_id="s", + auth_type=MCPAuth.oauth2, + existing_authorization_url=None, + existing_token_url=None, + existing_scopes=None, + metadata=advertised.model_copy(update={"from_origin_fallback": True}), + ) + await manager._persist_discovered_oauth_endpoints( + server_id="s", + auth_type=MCPAuth.oauth2, + existing_authorization_url="https://configured.example.com/authorize", + existing_token_url="https://configured.example.com/token", + existing_scopes=["configured"], + metadata=advertised, + ) + + update_mcp_server_mock.assert_not_awaited() + + @pytest.mark.asyncio + async def test_persist_discovered_oauth_endpoints_only_fills_empty_fields(self): + """A row that already has token_url keeps it; only the missing authorization_url and + scopes are written, so admin-typed values always win over discovery.""" + manager = MCPServerManager() + + update_mcp_server_mock = AsyncMock() + with ( + patch( + "litellm.proxy._experimental.mcp_server.db.update_mcp_server", + new=update_mcp_server_mock, + ), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + ): + await manager._persist_discovered_oauth_endpoints( + server_id="s", + auth_type=MCPAuth.oauth2, + existing_authorization_url=None, + existing_token_url="https://configured.example.com/token", + existing_scopes=None, + metadata=MCPOAuthMetadata( + scopes=["s1"], + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + ), + ) + + update_mcp_server_mock.assert_awaited_once() + persisted = update_mcp_server_mock.call_args.kwargs["data"] + assert persisted.authorization_url == "https://idp.example.com/authorize" + assert persisted.credentials == {"scopes": ["s1"]} + assert "token_url" not in persisted.fields_set() + + @pytest.mark.asyncio + async def test_build_mcp_server_from_table_skips_persistence_for_temporary_servers(self): + """The session endpoint builds temporary servers whose server_id has no DB row; with + persist_discovered_endpoints=False neither the oauth2 nor the OBO write-back may fire.""" + manager = MCPServerManager() + + async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True): + return MCPOAuthMetadata( + scopes=["s1"], + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + ) + + manager._descovery_metadata = fake_discovery # type: ignore[attr-defined] + + update_mcp_server_mock = AsyncMock() + obo_update_mock = AsyncMock() + repo_instance = MagicMock() + repo_instance.table.update = obo_update_mock + with ( + patch( + "litellm.proxy._experimental.mcp_server.db.update_mcp_server", + new=update_mcp_server_mock, + ), + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPServerRepository", + return_value=repo_instance, + ), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + ): + oauth2_record = LiteLLM_MCPServerTable( + server_id="temp-oauth-1", + server_name="temp_oauth", + url="https://example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + credentials={"client_id": "cid", "client_secret": "csec"}, + ) + obo_record = LiteLLM_MCPServerTable( + server_id="temp-obo-1", + server_name="temp_obo", + url="https://example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2_token_exchange, + credentials={"client_id": "cid", "client_secret": "csec"}, + ) + built_oauth2 = await manager.build_mcp_server_from_table( + oauth2_record, credentials_are_encrypted=False, persist_discovered_endpoints=False + ) + await manager.build_mcp_server_from_table( + obo_record, credentials_are_encrypted=False, persist_discovered_endpoints=False + ) + + assert built_oauth2.authorization_url == "https://idp.example.com/authorize" + update_mcp_server_mock.assert_not_awaited() + obo_update_mock.assert_not_awaited() + + @pytest.mark.asyncio + async def test_update_server_carries_forward_last_known_good_oauth_endpoints(self): + """A rebuild whose re-discovery fails must not downgrade a working registry entry: the + previous entry's resolved endpoints and scopes carry forward instead of being replaced + with None (which turns every /authorize into a 400 with no configuration change).""" + manager = MCPServerManager() + manager.registry["lkg-1"] = MCPServer( + server_id="lkg-1", + name="lkg_server", + server_name="lkg_server", + url="https://example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + registration_url="https://idp.example.com/register", + scopes=["mcp.read"], + ) + + manager._descovery_metadata = AsyncMock(return_value=None) # type: ignore[attr-defined] + + record = LiteLLM_MCPServerTable( + server_id="lkg-1", + server_name="lkg_server", + url="https://example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + credentials={"client_id": "cid", "client_secret": "csec"}, + ) + + with patch("litellm.proxy.proxy_server.prisma_client", None): + await manager.update_server(record) + + rebuilt = manager.registry["lkg-1"] + assert rebuilt.authorization_url == "https://idp.example.com/authorize" + assert rebuilt.token_url == "https://idp.example.com/token" + assert rebuilt.registration_url == "https://idp.example.com/register" + assert rebuilt.scopes == ["mcp.read"] + + def test_carry_forward_skips_when_url_or_auth_type_changed(self): + """Stale endpoints from a different upstream or auth mode must not carry forward.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + _carry_forward_resolved_oauth_endpoints, + ) + + def make_server(url: str, auth_type: MCPAuth, authorization_url: Optional[str]) -> MCPServer: + return MCPServer( + server_id="s1", + name="s1", + url=url, + transport=MCPTransport.http, + auth_type=auth_type, + authorization_url=authorization_url, + ) + + previous = make_server("https://old.example.com/mcp", MCPAuth.oauth2, "https://idp.example.com/authorize") + + url_changed = make_server("https://new.example.com/mcp", MCPAuth.oauth2, None) + _carry_forward_resolved_oauth_endpoints(new_server=url_changed, previous_server=previous) + assert url_changed.authorization_url is None + + auth_changed = make_server("https://old.example.com/mcp", MCPAuth.true_passthrough, None) + _carry_forward_resolved_oauth_endpoints(new_server=auth_changed, previous_server=previous) + assert auth_changed.authorization_url is None + + same = make_server("https://old.example.com/mcp", MCPAuth.oauth2, None) + _carry_forward_resolved_oauth_endpoints(new_server=same, previous_server=previous) + assert same.authorization_url == "https://idp.example.com/authorize" + + explicit = make_server("https://old.example.com/mcp", MCPAuth.oauth2, "https://configured.example.com/auth") + _carry_forward_resolved_oauth_endpoints(new_server=explicit, previous_server=previous) + assert explicit.authorization_url == "https://configured.example.com/auth" + def test_build_mcp_server_table_preserves_timestamps(self): """_build_mcp_server_table must use the MCPServer's stored timestamps, not datetime.now().""" manager = MCPServerManager() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py index 7d2cf4442a5..47112fc9900 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py @@ -227,3 +227,66 @@ async def test_client_credentials_uses_client_secret_basic_when_configured(): assert "client_secret" not in kwargs["data"] assert "client_id" not in kwargs["data"] assert kwargs["data"]["grant_type"] == "client_credentials" + + +def test_storage_ttl_capped_at_token_lifetime(): + """A token_storage_ttl_seconds longer than the token's own lifetime must be capped at + expires_in minus the expiry buffer. Before the cap, the configured TTL won outright and the + Redis fast path (which never re-checks expires_at) kept serving the dead token until eviction, + while the stored refresh_token sat unused because refresh only runs on the DB read-through.""" + from litellm.constants import MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS + from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( + _compute_per_user_token_ttl, + ) + + server = _server(oauth2_flow=None, token_storage_ttl_seconds=604800) + assert _compute_per_user_token_ttl(server, expires_in=86400) == 86400 - MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS + + +def test_storage_ttl_shorter_than_token_lifetime_wins(): + """A configured TTL below the token lifetime is the operative value: the knob's purpose is to + force earlier DB re-checks (staleness backstop), so the shorter side must win the min().""" + from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( + _compute_per_user_token_ttl, + ) + + server = _server(oauth2_flow=None, token_storage_ttl_seconds=3600) + assert _compute_per_user_token_ttl(server, expires_in=86400) == 3600 + + +def test_storage_ttl_verbatim_when_token_lifetime_unknown(): + """With no expires_in from the upstream there is nothing to cap against, so the configured + TTL applies as-is (matching the pre-cap behavior for lifetime-less tokens).""" + from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( + _compute_per_user_token_ttl, + ) + + server = _server(oauth2_flow=None, token_storage_ttl_seconds=604800) + assert _compute_per_user_token_ttl(server, expires_in=None) == 604800 + + +def test_storage_ttl_floors_at_one_second_for_nearly_dead_token(): + """A token already inside the expiry buffer yields the 1-second floor, not zero or a negative + TTL, mirroring the floor the default (unconfigured) path has always had.""" + from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( + _compute_per_user_token_ttl, + ) + + server = _server(oauth2_flow=None, token_storage_ttl_seconds=3600) + assert _compute_per_user_token_ttl(server, expires_in=30) == 1 + + +def test_default_ttl_paths_unchanged_without_storage_ttl(): + """With token_storage_ttl_seconds unset the TTL still derives from expires_in minus the + buffer, and falls back to MCP_PER_USER_TOKEN_DEFAULT_TTL when expires_in is absent.""" + from litellm.constants import ( + MCP_PER_USER_TOKEN_DEFAULT_TTL, + MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS, + ) + from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( + _compute_per_user_token_ttl, + ) + + server = _server(oauth2_flow=None) + assert _compute_per_user_token_ttl(server, expires_in=86400) == 86400 - MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS + assert _compute_per_user_token_ttl(server, expires_in=None) == MCP_PER_USER_TOKEN_DEFAULT_TTL diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index d12ff20ee5b..27f43c4948f 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -701,6 +701,38 @@ async def test_default_internal_user_params_with_get_user_object(monkeypatch): assert creation_args["user_role"] == "internal_user" +@pytest.mark.asyncio +async def test_get_user_object_wraps_db_outage_as_valueerror_preserving_context(): + """Pin get_user_object's exception contract: it catches every DB failure in a broad except and + re-raises a bare ValueError, so a real outage survives only as __context__ rather than as the + exception type. The MCP dcr_bridge admission and refresh paths depend on this to tell a transient + outage (retry, 503) from a missing user (fail closed), which is why they classify across the cause + chain instead of the top exception's type. If this wrapping ever changes, that classification must + change with it, so this test guards the contract the callers rely on.""" + from unittest.mock import AsyncMock, MagicMock, patch + + mock_prisma_client = MagicMock() + mock_prisma_client.db = AsyncMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock( + side_effect=ConnectionError("can't reach database server") + ) + mock_cache = MagicMock() + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() + + with patch("litellm.proxy.auth.auth_checks._should_check_db", return_value=True): + with pytest.raises(ValueError) as exc_info: + await get_user_object( + user_id="outage-contract-probe-user", + prisma_client=mock_prisma_client, + user_api_key_cache=mock_cache, + user_id_upsert=False, + proxy_logging_obj=None, + ) + + assert isinstance(exc_info.value.__context__, ConnectionError) + + @pytest.mark.asyncio async def test_get_user_object_upsert_includes_user_email(): """Test that user_email is included when creating a new user via get_user_object upsert""" @@ -4413,3 +4445,85 @@ async def test_common_checks_personal_user_budget_blocks_in_gather(): request=MagicMock(spec=Request), ) assert "User=u1" in str(over.value) + + +@pytest.mark.asyncio +async def test_user_budget_enforced_on_team_key(): + """User budget must be enforced even when the key belongs to a team. + + Previously _user_max_budget_check skipped enforcement for team keys, + letting a user with a $100 personal budget spend unlimited through a + team key. This regression test ensures that is no longer the case. + """ + from fastapi import Request + + from litellm.proxy.auth.auth_checks import common_checks + + user = LiteLLM_UserTable(user_id="u1", spend=0.0, max_budget=100.0) + team = LiteLLM_TeamTable(team_id="t1", max_budget=2100.0) + token = UserAPIKeyAuth(token="k1", user_id="u1", team_id="t1") + + async def _spend_by_counter(counter_key, fallback_spend, max_budget=None, **kwargs): + return 999.0 if counter_key == "spend:user:u1" else 0.0 + + async def _no_membership(*a, **kw): + return None + + with patch("litellm.proxy.proxy_server.prisma_client", None), patch( + "litellm.proxy.proxy_server.get_current_spend", _spend_by_counter + ), patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership): + with pytest.raises(litellm.BudgetExceededError) as over: + await common_checks( + request_body={"messages": [{"role": "user", "content": "hi"}]}, + team_object=team, + user_object=user, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=token, + request=MagicMock(spec=Request), + ) + assert "User=u1" in str(over.value) + + +@pytest.mark.asyncio +async def test_skip_user_budget_on_team_key_flag_restores_old_behavior(): + """Setting skip_user_budget_on_team_key=True skips user budget for team keys. + + This is the opt-in escape hatch that restores the legacy behavior where + user budgets were not enforced when the key belonged to a team. + """ + from fastapi import Request + + from litellm.proxy.auth.auth_checks import common_checks + + user = LiteLLM_UserTable(user_id="u1", spend=0.0, max_budget=100.0) + team = LiteLLM_TeamTable(team_id="t1", max_budget=2100.0) + token = UserAPIKeyAuth(token="k1", user_id="u1", team_id="t1") + + async def _spend_by_counter(counter_key, fallback_spend, max_budget=None, **kwargs): + return 999.0 if counter_key == "spend:user:u1" else 0.0 + + async def _no_membership(*a, **kw): + return None + + with patch("litellm.proxy.proxy_server.prisma_client", None), patch( + "litellm.proxy.proxy_server.get_current_spend", _spend_by_counter + ), patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership): + result = await common_checks( + request_body={"messages": [{"role": "user", "content": "hi"}]}, + team_object=team, + user_object=user, + end_user_object=None, + global_proxy_spend=None, + general_settings={"skip_user_budget_on_team_key": True}, + route="/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=token, + request=MagicMock(spec=Request), + ) + assert result is True diff --git a/tests/test_litellm/proxy/auth/test_cli_auth.py b/tests/test_litellm/proxy/auth/test_cli_auth.py index a4f72ef90ef..c9b31a1d776 100644 --- a/tests/test_litellm/proxy/auth/test_cli_auth.py +++ b/tests/test_litellm/proxy/auth/test_cli_auth.py @@ -60,13 +60,13 @@ async def test_normalize_teams_with_details_with_aliases(): @patch("litellm.proxy.client.cli.commands.auth.requests.post") def test_start_cli_sso_flow_rejects_invalid_response(request_mock): - """Test CLI SSO start rejects malformed server responses""" + """Test CLI SSO start rejects malformed server responses and names the missing fields""" response = Mock() - response.raise_for_status = Mock() + response.status_code = 200 response.json.return_value = {"login_id": "cli-session", "user_code": "ABCD-EFGH"} request_mock.return_value = response - with pytest.raises(ValueError, match="Invalid CLI SSO start response"): + with pytest.raises(ValueError, match="missing required field\\(s\\): poll_secret"): _start_cli_sso_flow("https://litellm.com") @@ -75,15 +75,13 @@ def test_start_cli_sso_flow_rejects_invalid_response(request_mock): "litellm.proxy.client.cli.commands.auth.requests.get", side_effect=[Mock(status_code=404)], ) -@patch("litellm.proxy.client.cli.commands.auth.click.echo") @patch("litellm.proxy.client.cli.commands.auth.time.sleep") -async def test_poll_for_ready_404(sleep_mock, click_mock, request_mock): - """Test poll_for_ready function""" - actual = _poll_for_ready_data( - "https://litellm.com", poll_interval=1, total_timeout=1, request_timeout=42 - ) - assert actual is None - click_mock.assert_called_once_with("Polling error: HTTP 404") +async def test_poll_for_ready_404(sleep_mock, request_mock): + """Test polling treats HTTP 404 as a permanent error and raises instead of retrying""" + with pytest.raises(ValueError, match="rejected the login session with HTTP 404"): + _poll_for_ready_data( + "https://litellm.com", poll_interval=1, total_timeout=1, request_timeout=42 + ) request_mock.assert_called_once_with("https://litellm.com", timeout=42) diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 90f46152837..a0248963cf1 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -3992,6 +3992,80 @@ async def test_non_admin_cli_session_token_reaches_production_auth_path(monkeypa assert result.is_session_token is True +@pytest.mark.asyncio +async def test_cli_session_token_authenticates_when_jwt_auth_enabled_without_license(monkeypatch): + """A lite login token is an encrypted (non-JWT) session blob. With + enable_jwt_auth on and no enterprise license (premium_user False), the JWT + premium gate used to fire for every request before the token was decoded, so + the CLI token 401'd with 'JWT Auth is an enterprise only feature' and was + never decrypted. The gate must apply only to actual JWTs; a non-JWT session + token has to keep authenticating on its own path regardless of license.""" + monkeypatch.delenv("EXPERIMENTAL_UI_LOGIN", raising=False) + cli_token = _mint_cli_session_token(monkeypatch) + + jwt_handler = MagicMock() + jwt_handler.is_jwt = JWTHandler.is_jwt + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth() + + mock_request = MagicMock() + mock_request.url.path = "/v1/messages" + mock_request.method = "POST" + mock_request.headers = {"authorization": f"Bearer {cli_token}"} + mock_request.query_params = {} + + with ( + patch("litellm.proxy.proxy_server.general_settings", {"enable_jwt_auth": True}), + patch("litellm.proxy.proxy_server.premium_user", False), + patch("litellm.proxy.proxy_server.jwt_handler", jwt_handler), + patch("litellm.proxy.proxy_server.master_key", "sk-master"), + patch("litellm.proxy.proxy_server.prisma_client", None), + ): + result = await user_api_key_auth( + request=mock_request, + api_key=f"Bearer {cli_token}", + ) + + assert result.user_id == "cli-admin" + assert result.team_id == "cli-team" + assert result.token is not None and result.token.startswith("cli-session-") + + +@pytest.mark.asyncio +async def test_real_jwt_still_requires_license_when_jwt_auth_enabled(monkeypatch): + """Guard for the reorder above: the enterprise gate must still reject an + actual JWT when there is no license. Moving the premium check inside the + is_jwt branch must not open JWT auth to non-premium deployments.""" + monkeypatch.delenv("EXPERIMENTAL_UI_LOGIN", raising=False) + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-cli-test") + + jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.sig" + jwt_handler = MagicMock() + jwt_handler.is_jwt = JWTHandler.is_jwt + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth() + + mock_request = MagicMock() + mock_request.url.path = "/v1/messages" + mock_request.method = "POST" + mock_request.headers = {"authorization": f"Bearer {jwt_token}"} + mock_request.query_params = {} + + with ( + patch("litellm.proxy.proxy_server.general_settings", {"enable_jwt_auth": True}), + patch("litellm.proxy.proxy_server.premium_user", False), + patch("litellm.proxy.proxy_server.jwt_handler", jwt_handler), + patch("litellm.proxy.proxy_server.master_key", "sk-master"), + patch("litellm.proxy.proxy_server.prisma_client", None), + ): + with pytest.raises(Exception) as exc_info: + await user_api_key_auth( + request=mock_request, + api_key=f"Bearer {jwt_token}", + ) + + message = str(getattr(exc_info.value, "message", exc_info.value)) + assert "enterprise only feature" in message + + @pytest.mark.asyncio async def test_auth_path_caches_team_object_under_canonical_team_id_key(): """Regression for LIT-4000: the auth builder must cache the team object under diff --git a/tests/test_litellm/proxy/client/cli/test_auth_commands.py b/tests/test_litellm/proxy/client/cli/test_auth_commands.py index 6be43c9da44..a451889415f 100644 --- a/tests/test_litellm/proxy/client/cli/test_auth_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_auth_commands.py @@ -8,6 +8,7 @@ from unittest.mock import Mock, mock_open, patch sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path +import pytest from click.testing import CliRunner from litellm.constants import CLI_JWT_EXPIRATION_HOURS @@ -40,6 +41,138 @@ def _mock_cli_sso_start_response( return mock_response +class TestPollingErrorSurfacing: + def test_client_error_raises_with_server_detail_and_stops_polling(self): + from litellm.proxy.client.cli.commands.auth import _poll_for_ready_data + + mock_response = Mock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "detail": "Your litellm CLI is out of date and uses a login flow this proxy no longer supports." + } + + with patch("requests.get", return_value=mock_response) as mock_get, patch("time.sleep"): + with pytest.raises(ValueError) as exc_info: + _poll_for_ready_data("http://test/sso/cli/poll/sk-legacy") + + assert mock_get.call_count == 1 + assert ( + "The proxy rejected the login session with HTTP 400: Your litellm CLI is out of date " + "and uses a login flow this proxy no longer supports." in str(exc_info.value) + ) + + def test_login_command_shows_server_rejection_to_user(self): + mock_context = Mock() + mock_context.obj = {"base_url": "https://test.example.com"} + + mock_poll_response = Mock() + mock_poll_response.status_code = 400 + mock_poll_response.json.return_value = {"detail": "CLI login session not found or expired."} + + with ( + patch("webbrowser.open"), + patch("requests.post", return_value=_mock_cli_sso_start_response()), + patch("requests.get", return_value=mock_poll_response), + patch("time.sleep"), + ): + result = CliRunner().invoke(login, obj=mock_context.obj) + + assert result.exit_code == 0 + assert "❌ Authentication failed:" in result.output + assert "CLI login session not found or expired." in result.output + assert "Authentication timed out" not in result.output + + def test_server_error_without_json_body_retries_until_timeout(self, capsys): + from litellm.proxy.client.cli.commands.auth import _poll_for_ready_data + + mock_response = Mock() + mock_response.status_code = 500 + mock_response.json.side_effect = ValueError("no json") + + with patch("requests.get", return_value=mock_response) as mock_get, patch("time.sleep"): + result = _poll_for_ready_data("http://test/sso/cli/poll/cli-abc", total_timeout=6, poll_interval=2) + + assert result is None + assert mock_get.call_count == 3 + assert "Polling error: HTTP 500" in capsys.readouterr().out + + def test_rate_limit_is_retried_not_aborted(self, capsys): + from litellm.proxy.client.cli.commands.auth import _poll_for_ready_data + + mock_response = Mock() + mock_response.status_code = 429 + mock_response.json.return_value = {"detail": "Too many CLI login attempts. Try again later."} + + with patch("requests.get", return_value=mock_response) as mock_get, patch("time.sleep"): + result = _poll_for_ready_data("http://test/sso/cli/poll/cli-abc", total_timeout=4, poll_interval=2) + + assert result is None + assert mock_get.call_count == 2 + assert "Polling error: HTTP 429: Too many CLI login attempts. Try again later." in capsys.readouterr().out + + +class TestStartCliSsoFlowErrors: + def test_endpoint_not_found_explains_version_or_base_url(self): + from litellm.proxy.client.cli.commands.auth import _start_cli_sso_flow + + mock_response = Mock() + mock_response.status_code = 404 + + with patch("requests.post", return_value=mock_response): + with pytest.raises(ValueError) as exc_info: + _start_cli_sso_flow("https://old-proxy.example.com") + + message = str(exc_info.value) + assert "HTTP 404" in message + assert "--base-url" in message + assert "older than this CLI" in message + + def test_http_error_includes_server_detail(self): + from litellm.proxy.client.cli.commands.auth import _start_cli_sso_flow + + mock_response = Mock() + mock_response.status_code = 429 + mock_response.json.return_value = {"detail": "Too many CLI login attempts. Try again later."} + + with patch("requests.post", return_value=mock_response): + with pytest.raises(ValueError) as exc_info: + _start_cli_sso_flow("https://test.example.com") + + assert "HTTP 429" in str(exc_info.value) + assert "Too many CLI login attempts. Try again later." in str(exc_info.value) + + def test_non_json_response_names_interception(self): + from litellm.proxy.client.cli.commands.auth import _start_cli_sso_flow + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.side_effect = ValueError("no json") + mock_response.headers = {"content-type": "text/html"} + mock_response.text = "Sign in to corporate VPN" + + with patch("requests.post", return_value=mock_response): + with pytest.raises(ValueError) as exc_info: + _start_cli_sso_flow("https://test.example.com") + + message = str(exc_info.value) + assert "non-JSON response" in message + assert "text/html" in message + assert "Sign in to corporate VPN" in message + + def test_connection_error_points_at_base_url(self): + import requests + + from litellm.proxy.client.cli.commands.auth import _start_cli_sso_flow + + with patch("requests.post", side_effect=requests.ConnectionError("Connection refused")): + with pytest.raises(ValueError) as exc_info: + _start_cli_sso_flow("https://unreachable.example.com") + + message = str(exc_info.value) + assert "Could not reach the proxy" in message + assert "https://unreachable.example.com/sso/cli/start" in message + + class TestTokenUtilities: """Test token file utility functions""" diff --git a/tests/test_litellm/proxy/db/test_exception_handler.py b/tests/test_litellm/proxy/db/test_exception_handler.py index 0634a01326c..23099177812 100644 --- a/tests/test_litellm/proxy/db/test_exception_handler.py +++ b/tests/test_litellm/proxy/db/test_exception_handler.py @@ -286,6 +286,46 @@ def test_is_database_service_unavailable_error_excludes_non_infra(error): ) +def _wrapped_like_get_user_object(original): + """Reproduce get_user_object's exception contract (litellm/proxy/auth/auth_checks.py): it catches + every DB failure in a broad ``except`` and re-raises a bare ``ValueError``, so the original error + survives only as ``__context__``. Building it by raising inside an ``except`` sets ``__context__`` + exactly as production does.""" + try: + raise original + except BaseException: + try: + raise ValueError("User doesn't exist in db. Got error - x") + except ValueError as wrapped: + return wrapped + + +def test_is_database_service_unavailable_error_in_chain_sees_through_wrapping(): + """The chain-aware classifier must see a real outage that a caller wrapped in a different type. + get_user_object turns a connection error into a bare ValueError whose type check reads as non-infra, + so the single-exception check returns False and only the chain walk recovers the outage. A missing + user (whose wrapped cause is a plain Exception) must stay non-infra on both.""" + outage = _wrapped_like_get_user_object(ConnectionError("can't reach database server")) + missing_user = _wrapped_like_get_user_object(Exception()) + + assert PrismaDBExceptionHandler.is_database_service_unavailable_error(outage) is False + assert PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(outage) is True + assert PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(missing_user) is False + # parity: a raw outage with no wrapper is still an outage, and a plain ValueError is not + assert PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(ConnectionError("boom")) is True + assert PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(ValueError("nope")) is False + + +def test_is_database_service_unavailable_error_in_chain_terminates_on_a_cause_cycle(): + """The walk must terminate on a pathological __cause__ cycle rather than hang. Neither link is an + outage, so the bounded walk returns False instead of looping forever.""" + first = ValueError("first") + second = ValueError("second") + first.__cause__ = second + second.__cause__ = first + assert PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(first) is False + + def test_is_database_service_unavailable_error_asyncpg(monkeypatch): """asyncpg connection/interface errors map to service-unavailable. asyncpg is not a hard dependency, so inject a stand-in module to exercise the diff --git a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py new file mode 100644 index 00000000000..0446cfeeab0 --- /dev/null +++ b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py @@ -0,0 +1,432 @@ +""" +Tests for the enterprise billing-metrics recorder and its factory. + +These verify the license gate, the missing-config and missing-cert disable +paths, the OTLP/HTTP exporter wiring (client cert+key authenticate us to the +collector's mTLS-terminating front end; CA override optional for private +collectors), the metric attribute mapping, and that recording produces the +expected OTLP counter via an in-memory reader. +""" + +import os +import socket +import stat +from pathlib import Path +from typing import Dict, List, Optional + +import pytest +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from litellm.proxy.enterprise_billing import billing_metrics as bm +from litellm.proxy.middleware.billable_request_metrics_middleware import BillableCategory + +_ENV_VARS = ( + bm.ENDPOINT_ENV, + bm.CLIENT_CERT_ENV, + bm.CLIENT_KEY_ENV, + bm.CA_CERT_ENV, + bm.EXPORT_INTERVAL_ENV, +) + + +@pytest.fixture(autouse=True) +def clear_env(monkeypatch): + for name in _ENV_VARS: + monkeypatch.delenv(name, raising=False) + yield + bm.shutdown_billing_metrics_recorder() + + +def _write_certs(tmp_path: Path) -> Dict[str, str]: + files = { + bm.CA_CERT_ENV: ("ca.pem", b"ca-bytes"), + bm.CLIENT_CERT_ENV: ("client.pem", b"client-cert-bytes"), + bm.CLIENT_KEY_ENV: ("client.key", b"client-key-bytes"), + } + paths = {} + for env_name, (filename, content) in files.items(): + path = tmp_path / filename + path.write_bytes(content) + paths[env_name] = str(path) + return paths + + +def _set_full_env(monkeypatch, tmp_path: Path) -> Dict[str, str]: + paths = _write_certs(tmp_path) + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CA_CERT_ENV, paths[bm.CA_CERT_ENV]) + monkeypatch.setenv(bm.CLIENT_CERT_ENV, paths[bm.CLIENT_CERT_ENV]) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, paths[bm.CLIENT_KEY_ENV]) + return paths + + +def _config(tmp_path: Path, license_id: Optional[str] = "org-1") -> bm.BillingMetricsConfig: + paths = _write_certs(tmp_path) + return bm.BillingMetricsConfig( + endpoint="https://collector.example:4317", + client_cert_path=paths[bm.CLIENT_CERT_ENV], + client_key_path=paths[bm.CLIENT_KEY_ENV], + ca_cert_path=paths[bm.CA_CERT_ENV], + export_interval_ms=60_000, + litellm_version="1.2.3", + license_id=license_id, + ) + + +# ── Factory gating ──────────────────────────────────────────────────────────── + + +def test_not_premium_returns_none(tmp_path, monkeypatch): + _set_full_env(monkeypatch, tmp_path) + assert bm.build_billing_metrics_recorder(premium=False, license_data=None, litellm_version="1.0") is None + + +def test_premium_without_config_returns_none(monkeypatch): + assert bm.build_billing_metrics_recorder(premium=True, license_data={"user_id": "x"}, litellm_version="1.0") is None + + +def test_premium_with_missing_cert_files_returns_none(monkeypatch, tmp_path): + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CA_CERT_ENV, str(tmp_path / "missing-ca.pem")) + monkeypatch.setenv(bm.CLIENT_CERT_ENV, str(tmp_path / "missing-cert.pem")) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, str(tmp_path / "missing-key.pem")) + assert bm.build_billing_metrics_recorder(premium=True, license_data=None, litellm_version="1.0") is None + + +def test_premium_with_full_config_builds_recorder(monkeypatch, tmp_path): + """Builds a real MeterProvider, so the exporter is stubbed: the live one + resolves the collector and opens a TLS connection during the shutdown flush. + The getaddrinfo spy keeps that stub from being quietly dropped later.""" + _set_full_env(monkeypatch, tmp_path) + monkeypatch.setattr(bm, "OTLPMetricExporter", _fake_exporter_class({})) + + resolved: List[str] = [] + real_getaddrinfo = socket.getaddrinfo + + def _spy_getaddrinfo(host, port, *args, **kwargs): + resolved.append(str(host)) + return real_getaddrinfo(host, port, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", _spy_getaddrinfo) + + recorder = bm.build_billing_metrics_recorder( + premium=True, license_data={"user_id": "org-1"}, litellm_version="1.0" + ) + assert isinstance(recorder, bm.BillingMetricsRecorder) + recorder.record(category=BillableCategory.LLM, route="/chat/completions", status_code=200, model_id=None) + bm.shutdown_billing_metrics_recorder() + + assert [host for host in resolved if "collector.example" in host] == [] + + +def test_building_the_recorder_logs_an_affirmative_line(monkeypatch, tmp_path): + """ + Every disable path logs; a successful build must log too. Otherwise an + operator cannot tell a metering component from one that silently returned + None, which is how an unlicensed component looks healthy while exporting + nothing. + """ + _set_full_env(monkeypatch, tmp_path) + monkeypatch.setenv(bm.EXPORT_INTERVAL_ENV, "5000") + monkeypatch.setattr(bm, "OTLPMetricExporter", _fake_exporter_class({})) + + infos: List[str] = [] + monkeypatch.setattr(bm.verbose_proxy_logger, "info", lambda msg, *args: infos.append(msg % args if args else msg)) + + recorder = bm.build_billing_metrics_recorder(premium=True, license_data={"user_id": "org-1"}, litellm_version="1.0") + + assert recorder is not None + joined = "\n".join(infos) + assert "https://collector.example:4317" in joined + assert "5000" in joined + + +def test_unlicensed_build_does_not_warn(monkeypatch, tmp_path): + """Unlicensed is the common OSS case; warning there would be pure noise.""" + _set_full_env(monkeypatch, tmp_path) + + warnings: List[str] = [] + monkeypatch.setattr(bm.verbose_proxy_logger, "warning", lambda msg, *args: warnings.append(str(msg))) + + assert bm.build_billing_metrics_recorder(premium=False, license_data=None, litellm_version="1.0") is None + assert warnings == [] + + +def test_shutdown_flushes_active_recorder_once(monkeypatch, tmp_path): + """The shutdown hook must flush the recorder the factory built (buffered + counts are lost on restart otherwise) and be idempotent for repeat calls.""" + _set_full_env(monkeypatch, tmp_path) + shutdowns = [] + + class _SpyProvider: + def get_meter(self, name): + return MeterProvider().get_meter(name) + + def shutdown(self, timeout_millis=None): + shutdowns.append(timeout_millis) + + monkeypatch.setattr(bm, "build_mtls_meter_provider", lambda config: _SpyProvider()) + recorder = bm.build_billing_metrics_recorder(premium=True, license_data=None, litellm_version="1.0") + assert recorder is not None + + bm.shutdown_billing_metrics_recorder() + bm.shutdown_billing_metrics_recorder() + assert shutdowns == [bm.SHUTDOWN_FLUSH_TIMEOUT_MS] + + +def test_shutdown_without_active_recorder_is_noop(): + bm.shutdown_billing_metrics_recorder() + + +# ── Config loading ──────────────────────────────────────────────────────────── + + +def test_load_config_carries_license_id(monkeypatch, tmp_path): + _set_full_env(monkeypatch, tmp_path) + config = bm.load_billing_metrics_config(license_data={"user_id": "org-42"}, litellm_version="9.9") + assert config is not None and config.license_id == "org-42" and config.litellm_version == "9.9" + + +def test_load_config_with_empty_string_env_is_disabled(monkeypatch, tmp_path): + """An env var set to the empty string is as unusable as an unset one and + must disable metering rather than produce a config with a blank endpoint.""" + paths = _write_certs(tmp_path) + monkeypatch.setenv(bm.ENDPOINT_ENV, "") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, paths[bm.CLIENT_CERT_ENV]) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, paths[bm.CLIENT_KEY_ENV]) + assert bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") is None + + +_CLIENT_CERT_PEM = "-----BEGIN CERTIFICATE-----\nclient-cert-body\n-----END CERTIFICATE-----" +_CLIENT_KEY_PEM = "-----BEGIN PRIVATE KEY-----\nclient-key-body\n-----END PRIVATE KEY-----" +_CA_CERT_PEM = "-----BEGIN CERTIFICATE-----\nca-body\n-----END CERTIFICATE-----" + + +def test_load_config_materializes_inline_pem_content(monkeypatch): + """ + ECS and Cloud Run inject secrets as env content, not as mounted files, so the + cert env vars must accept PEM directly. The exporter takes paths, so the PEM + is written to disk and the config points at those files. + """ + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, _CLIENT_CERT_PEM) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, _CLIENT_KEY_PEM) + monkeypatch.setenv(bm.CA_CERT_ENV, _CA_CERT_PEM) + + config = bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") + + assert config is not None + assert config.ca_cert_path is not None + written = { + config.client_cert_path: _CLIENT_CERT_PEM, + config.client_key_path: _CLIENT_KEY_PEM, + config.ca_cert_path: _CA_CERT_PEM, + } + for path, pem in written.items(): + assert path != pem, "config must carry a file path, not the PEM itself" + assert os.path.isfile(path) + assert Path(path).read_text(encoding="utf-8") == f"{pem}\n" + + # The private key must not be world- or group-readable. + assert stat.S_IMODE(os.stat(config.client_key_path).st_mode) == 0o600 + + +def test_load_config_accepts_a_mix_of_pem_content_and_file_paths(monkeypatch, tmp_path): + """A deployment may mount the CA but inject the client credentials inline.""" + paths = _write_certs(tmp_path) + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, _CLIENT_CERT_PEM) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, _CLIENT_KEY_PEM) + monkeypatch.setenv(bm.CA_CERT_ENV, paths[bm.CA_CERT_ENV]) + + config = bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") + + assert config is not None + assert config.ca_cert_path == paths[bm.CA_CERT_ENV] + assert Path(config.client_cert_path).read_text(encoding="utf-8") == f"{_CLIENT_CERT_PEM}\n" + + +def test_load_config_leaves_file_paths_untouched(monkeypatch, tmp_path): + """Path-valued env vars keep working; nothing is copied or rewritten.""" + paths = _set_full_env(monkeypatch, tmp_path) + + config = bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") + + assert config is not None + assert config.client_cert_path == paths[bm.CLIENT_CERT_ENV] + assert config.client_key_path == paths[bm.CLIENT_KEY_ENV] + assert config.ca_cert_path == paths[bm.CA_CERT_ENV] + + +def test_load_config_with_inline_pem_disabled_when_unwritable(monkeypatch): + """A failure to materialize the PEM disables metering instead of raising.""" + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, _CLIENT_CERT_PEM) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, _CLIENT_KEY_PEM) + + def _explode(prefix=None): + raise OSError("read-only filesystem") + + monkeypatch.setattr(bm.tempfile, "mkdtemp", _explode) + + assert bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") is None + + +def test_load_config_never_logs_credential_values(monkeypatch): + """ + A value that is neither a readable path nor `-----BEGIN`-prefixed PEM is + still secret material. The disable warning must name the env vars, never + echo their contents, or a malformed key lands in the proxy logs. + """ + secret_material = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ-not-pem-prefixed" + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, secret_material) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, secret_material) + + logged: List[str] = [] + + def _capture(msg, *args): + logged.append(msg % args if args else msg) + + monkeypatch.setattr(bm.verbose_proxy_logger, "warning", _capture) + + assert bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") is None + + joined = "\n".join(logged) + assert secret_material not in joined + assert bm.CLIENT_CERT_ENV in joined and bm.CLIENT_KEY_ENV in joined + + +def test_load_config_with_empty_pem_env_is_disabled(monkeypatch): + """Empty stays empty: an unset secret must not be mistaken for inline PEM.""" + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, "") + monkeypatch.setenv(bm.CLIENT_KEY_ENV, "") + + assert bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") is None + + +def test_export_interval_default_and_override(monkeypatch): + assert bm._export_interval_ms() == bm.DEFAULT_EXPORT_INTERVAL_MS + monkeypatch.setenv(bm.EXPORT_INTERVAL_ENV, "5000") + assert bm._export_interval_ms() == 5000 + monkeypatch.setenv(bm.EXPORT_INTERVAL_ENV, "not-a-number") + assert bm._export_interval_ms() == bm.DEFAULT_EXPORT_INTERVAL_MS + + +# ── OTLP/HTTP exporter wiring ───────────────────────────────────────────────── + + +def test_metrics_endpoint_appends_signal_path(): + assert bm._metrics_endpoint("https://telemetry.example.com") == "https://telemetry.example.com/v1/metrics" + assert bm._metrics_endpoint("https://telemetry.example.com/") == "https://telemetry.example.com/v1/metrics" + assert bm._metrics_endpoint("https://telemetry.example.com/v1/metrics") == "https://telemetry.example.com/v1/metrics" + + +def _fake_exporter_class(captured: Dict[str, object]) -> type: + """A no-network stand-in for OTLPMetricExporter. Tests that build a real + MeterProvider must install this: the real exporter resolves the collector + host and opens a TLS connection on the reader's first export and on the + shutdown flush.""" + + class _FakeExporter: + # PeriodicExportingMetricReader probes these on the exporter it wraps. + _preferred_temporality: dict = {} + _preferred_aggregation: dict = {} + + def __init__(self, **kwargs): + captured.update(kwargs) + + def export(self, *args, **kwargs): + return None + + def shutdown(self, *args, **kwargs): + return None + + def force_flush(self, *args, **kwargs): + return True + + return _FakeExporter + + +def test_meter_provider_wires_client_cert_into_http_exporter(tmp_path, monkeypatch): + """Client cert+key authenticate us at the collector's mTLS front end; CA override rides certificate_file.""" + captured: Dict[str, object] = {} + + monkeypatch.setattr(bm, "OTLPMetricExporter", _fake_exporter_class(captured)) + config = _config(tmp_path) + provider = bm.build_mtls_meter_provider(config) + provider.shutdown() + + assert captured["endpoint"] == "https://collector.example:4317/v1/metrics" + assert captured["client_certificate_file"] == config.client_cert_path + assert captured["client_key_file"] == config.client_key_path + assert captured["certificate_file"] == config.ca_cert_path + + +def test_load_config_without_ca_is_valid(monkeypatch, tmp_path): + """The production collector presents a public web-PKI cert: no CA override required.""" + paths = _write_certs(tmp_path) + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://telemetry.example.com") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, paths[bm.CLIENT_CERT_ENV]) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, paths[bm.CLIENT_KEY_ENV]) + config = bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") + assert config is not None and config.ca_cert_path is None + + +# ── Resource and metric attributes ──────────────────────────────────────────── + + +def test_resource_attributes_include_license_id(tmp_path): + attrs = bm._resource_attributes(_config(tmp_path, license_id="org-7")) + assert attrs["service.name"] == "litellm-proxy" + assert attrs["litellm.version"] == "1.2.3" + assert attrs["litellm.license.id"] == "org-7" + + +def test_resource_attributes_omit_license_id_when_absent(tmp_path): + attrs = bm._resource_attributes(_config(tmp_path, license_id=None)) + assert "litellm.license.id" not in attrs + + +def test_billable_attributes_with_model_id(): + attrs = bm._billable_attributes(BillableCategory.LLM, "/chat/completions", 200, "deploy-3") + assert attrs == { + "litellm.endpoint.category": "llm", + "http.route": "/chat/completions", + "http.response.status_code": 200, + "litellm.model_id": "deploy-3", + } + + +def test_billable_attributes_omit_model_id_when_none(): + attrs = bm._billable_attributes(BillableCategory.MCP, "/mcp", 200, None) + assert "litellm.model_id" not in attrs + + +# ── End-to-end recording via in-memory reader ───────────────────────────────── + + +def _counter_points(reader: InMemoryMetricReader): + data = reader.get_metrics_data() + for resource_metric in data.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + if metric.name == bm.METRIC_NAME: + return list(metric.data.data_points) + return [] + + +def test_record_increments_counter_with_attributes(): + reader = InMemoryMetricReader() + recorder = bm.BillingMetricsRecorder(MeterProvider(metric_readers=[reader])) + + recorder.record(category=BillableCategory.LLM, route="/chat/completions", status_code=200, model_id="m1") + recorder.record(category=BillableCategory.LLM, route="/chat/completions", status_code=200, model_id="m1") + recorder.record(category=BillableCategory.MCP, route="/mcp", status_code=200, model_id=None) + + points = _counter_points(reader) + by_category = {point.attributes["litellm.endpoint.category"]: point.value for point in points} + assert by_category["llm"] == 2 + assert by_category["mcp"] == 1 diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_invoke_guardrail_checks.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_invoke_guardrail_checks.py new file mode 100644 index 00000000000..3adf8b8407d --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_invoke_guardrail_checks.py @@ -0,0 +1,838 @@ +""" +Unit tests for the Bedrock InvokeGuardrailChecks (resource-less, detect-only) mode. + +All Bedrock HTTP calls are mocked; no real AWS calls are made. +""" + +import json +import logging +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +sys.path.insert(0, os.path.abspath("../../../../../..")) + +from litellm.exceptions import ModifyResponseException +from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( + _BEDROCK_INVOKE_GUARDRAIL_CHECKS_PATH, + BedrockGuardrail, +) +from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( + BedrockGuardrailResponse, +) +from litellm.types.utils import Choices, Message, ModelResponse + +CONTENT_FILTER_CHECKS = {"contentFilter": {"categories": [{"category": "VIOLENCE"}]}} + + +def _mock_http_response(status_code: int = 200, payload: dict | None = None): + response = MagicMock() + response.status_code = status_code + response.json.return_value = payload if payload is not None else {} + response.text = json.dumps(payload if payload is not None else {}) + return response + + +def _patched(guardrail: BedrockGuardrail, http_response): + """Patch credentials, request prep, and the HTTP post for a checks call.""" + mock_credentials = MagicMock() + post = AsyncMock(return_value=http_response) + return ( + patch.object( + guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1") + ), + patch.object(guardrail, "_prepare_request", return_value=MagicMock()), + patch.object(guardrail.async_handler, "post", new=post), + post, + ) + + +# --------------------------------------------------------------------------- +# __init__ / config validation +# --------------------------------------------------------------------------- + + +def test_init_rejects_both_identifier_and_checks(): + with pytest.raises(ValueError): + BedrockGuardrail(guardrailIdentifier="gid", checks=CONTENT_FILTER_CHECKS) + + +def test_init_normalizes_checks_and_drops_unknown_keys(): + g = BedrockGuardrail( + checks={ + "contentFilter": {"categories": [{"category": "VIOLENCE"}]}, + "unknownCheck": {"foo": "bar"}, # unknown key -> dropped + "promptAttack": {}, # empty known check -> kept (enable with defaults) + } + ) + assert g.checks == { + "contentFilter": {"categories": [{"category": "VIOLENCE"}]}, + "promptAttack": {}, + } + + +def test_init_empty_checks_falls_back_to_apply_mode(): + g = BedrockGuardrail(guardrailIdentifier="gid", checks={}) + assert g.checks is None # empty checks => ApplyGuardrail path, no conflict + + +def test_normalize_checks_keeps_empty_known_check_config(): + assert BedrockGuardrail._normalize_checks({"contentFilter": {}}) == {"contentFilter": {}} + + +def test_normalize_checks_warns_on_unknown_keys(caplog): + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + result = BedrockGuardrail._normalize_checks({"contentFilter": {}, "typo_key": True}) + assert result == {"contentFilter": {}} + assert any("typo_key" in m for m in caplog.messages) + + +def test_normalize_checks_all_unknown_raises(): + with pytest.raises(ValueError, match="unrecognized or empty keys"): + BedrockGuardrail._normalize_checks({"snake_case_typo": True}) + + +# --------------------------------------------------------------------------- +# Message building +# --------------------------------------------------------------------------- + + +def test_build_input_messages_tags_all_as_user_and_scans_all(): + """Every INPUT message is scanned and tagged user regardless of the caller role. + + Bedrock excludes system content from prompt-attack evaluation, so tagging a + caller-supplied system/developer message as system would let an injection avoid + the promptAttack check. Every INPUT message is caller-controlled, so all of it is + treated as untrusted user input, and no message is skipped. + """ + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS) + messages = [ + {"role": "system", "content": "sys"}, + {"role": "developer", "content": "dev"}, + {"role": "user", "content": "hi"}, + {"role": "tool", "content": "tool-output"}, + {"role": "function", "content": "fn-output"}, + ] + built = g._build_invoke_guardrail_checks_messages("INPUT", messages=messages) + assert built == [ + {"role": "user", "content": [{"text": "sys"}]}, + {"role": "user", "content": [{"text": "dev"}]}, + {"role": "user", "content": [{"text": "hi"}]}, + {"role": "user", "content": [{"text": "tool-output"}]}, + {"role": "user", "content": [{"text": "fn-output"}]}, + ] + + +def test_build_output_messages_tags_assistant(): + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS) + response = ModelResponse( + choices=[Choices(message=Message(role="assistant", content="bad text"))] + ) + built = g._build_invoke_guardrail_checks_messages("OUTPUT", response=response) + assert built == [{"role": "assistant", "content": [{"text": "bad text"}]}] + + +# --------------------------------------------------------------------------- +# Block / pass behavior +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_blocks_when_score_meets_threshold(): + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS, content_filter_threshold=0.5) + payload = { + "results": { + "contentFilter": { + "results": [ + {"category": "VIOLENCE", "severityScore": 0.8}, + {"category": "HATE", "severityScore": 0.2}, + ] + } + }, + "usage": {"contentFilter": {"textUnits": 1}}, + } + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + with pytest.raises(HTTPException) as exc: + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "how to hurt people"}], + request_data={"messages": []}, + ) + assert exc.value.status_code == 400 + detail = exc.value.detail + violations = detail["bedrock_guardrail_checks"] + assert violations == [ + {"check": "contentFilter", "category": "VIOLENCE", "severityScore": 0.8} + ] + # No raw user input / offsets leak into the client-facing detail. + assert "how to hurt people" not in json.dumps(detail) + + +@pytest.mark.asyncio +async def test_allows_when_score_below_threshold(): + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS, content_filter_threshold=0.5) + payload = { + "results": { + "contentFilter": { + "results": [{"category": "VIOLENCE", "severityScore": 0.2}] + } + } + } + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + result = await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "hello"}], + request_data={"messages": []}, + ) + assert result == BedrockGuardrailResponse() # empty -> pass + + +@pytest.mark.asyncio +async def test_threshold_none_is_detect_only(): + """A null threshold logs the score but never blocks.""" + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS, content_filter_threshold=None) + payload = { + "results": { + "contentFilter": { + "results": [{"category": "VIOLENCE", "severityScore": 1.0}] + } + } + } + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + result = await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "violent content"}], + request_data={"messages": []}, + ) + assert result == BedrockGuardrailResponse() # detect-only, no block + + +@pytest.mark.asyncio +async def test_unsolicited_check_scores_are_ignored(): + """Scores for checks the user never configured must not block, even over threshold.""" + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS, prompt_attack_threshold=0.5) + payload = { + "results": { + "promptAttack": { + "results": [{"category": "JAILBREAK", "severityScore": 1.0}] + } + } + } + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + result = await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "hello"}], + request_data={"messages": []}, + ) + assert result == BedrockGuardrailResponse() + + +@pytest.mark.asyncio +async def test_blocks_when_score_equals_threshold(): + """The documented contract is score >= threshold blocks; equality must block.""" + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS, content_filter_threshold=0.5) + payload = { + "results": { + "contentFilter": { + "results": [{"category": "VIOLENCE", "severityScore": 0.5}] + } + } + } + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + with pytest.raises(HTTPException) as exc: + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "borderline"}], + request_data={"messages": []}, + ) + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_truncated_pii_results_block(): + """Truncated sensitiveInformation results fail closed: omitted detections were + never scored, so sub-threshold visible entries must not let the request pass.""" + g = BedrockGuardrail( + checks={"sensitiveInformation": {"entities": [{"type": "EMAIL"}]}}, + pii_confidence_threshold=0.5, + ) + payload = { + "results": { + "sensitiveInformation": { + "results": [{"type": "EMAIL", "confidenceScore": 0.1}], + "truncated": True, + } + } + } + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + with pytest.raises(HTTPException) as exc: + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "many entities"}], + request_data={"messages": []}, + ) + assert exc.value.status_code == 400 + assert {"check": "sensitiveInformation", "truncated": True} in exc.value.detail["bedrock_guardrail_checks"] + + +@pytest.mark.asyncio +async def test_truncated_pii_ignored_when_pii_check_not_configured(): + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS) + payload = { + "results": { + "sensitiveInformation": {"results": [], "truncated": True} + } + } + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + result = await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "hello"}], + request_data={"messages": []}, + ) + assert result == BedrockGuardrailResponse() + + +@pytest.mark.asyncio +async def test_checks_with_guardrail_version_rejected(): + with pytest.raises(ValueError): + BedrockGuardrail(checks=CONTENT_FILTER_CHECKS, guardrailVersion="DRAFT") + + +@pytest.mark.asyncio +async def test_malformed_200_response_fails_closed(): + """A 200 whose body does not match the checks response shape must raise, not pass.""" + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS) + payload = {"results": "not-a-mapping"} + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + with pytest.raises(HTTPException) as exc: + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "hello"}], + request_data={"messages": []}, + ) + assert exc.value.status_code == 500 + + +@pytest.mark.asyncio +async def test_disable_exception_on_block_raises_modify_response_exception(): + g = BedrockGuardrail( + checks=CONTENT_FILTER_CHECKS, + content_filter_threshold=0.5, + disable_exception_on_block=True, + ) + payload = { + "results": { + "contentFilter": { + "results": [{"category": "VIOLENCE", "severityScore": 0.8}] + } + } + } + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + with pytest.raises(ModifyResponseException): + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "violent"}], + request_data={"messages": []}, + ) + + +# --------------------------------------------------------------------------- +# Request shape: path + body +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_request_uses_checks_path_and_body(): + g = BedrockGuardrail( + checks={ + "contentFilter": {"categories": [{"category": "VIOLENCE"}]}, + "sensitiveInformation": {"entities": [{"type": "EMAIL"}]}, + } + ) + captured = {} + + def fake_prepare(**kwargs): + captured.update(kwargs) + return MagicMock() + + mock_credentials = MagicMock() + with ( + patch.object( + g, "_load_credentials", return_value=(mock_credentials, "us-east-1") + ), + patch.object(g, "_prepare_request", side_effect=fake_prepare), + patch.object( + g.async_handler, + "post", + new=AsyncMock(return_value=_mock_http_response(200, {"results": {}})), + ), + ): + await g.make_bedrock_api_request( + source="INPUT", + messages=[ + {"role": "user", "content": "hi"}, + {"role": "tool", "content": "tool-result"}, + ], + request_data={"messages": []}, + ) + + assert captured["request_path"] == _BEDROCK_INVOKE_GUARDRAIL_CHECKS_PATH + body = captured["data"] + assert body["checks"] == g.checks + # tool content is scanned too (mapped to user), not skipped. + assert body["messages"] == [ + {"role": "user", "content": [{"text": "hi"}]}, + {"role": "user", "content": [{"text": "tool-result"}]}, + ] + + +@pytest.mark.asyncio +async def test_empty_messages_passes_without_api_call(): + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS) + creds, prep, post_patch, post = _patched(g, _mock_http_response(200, {})) + with creds, prep, post_patch: + # No extractable text in any message (e.g. a tool-call-only assistant turn). + result = await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "assistant", "content": None}], + request_data={"messages": []}, + ) + assert result == BedrockGuardrailResponse() + post.assert_not_awaited() # no scannable content => no Bedrock call + + +# --------------------------------------------------------------------------- +# Logging / PII safety +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_pii_offsets_stripped_from_standard_logging(): + g = BedrockGuardrail( + checks={"sensitiveInformation": {"entities": [{"type": "EMAIL"}]}}, + pii_confidence_threshold=None, # detect-only so the call completes + ) + payload = { + "results": { + "sensitiveInformation": { + "results": [ + { + "type": "EMAIL", + "confidenceScore": 0.9, + "messageIndex": 0, + "contentIndex": 0, + "beginOffset": 12, + "endOffset": 28, + } + ], + "truncated": False, + } + } + } + request_data = {"messages": [{"role": "user", "content": "email me at a@b.com"}]} + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + await g.make_bedrock_api_request( + source="INPUT", + messages=request_data["messages"], + request_data=request_data, + ) + + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + logged_entry = slg["guardrail_response"]["results"]["sensitiveInformation"][ + "results" + ][0] + for offset_key in ("beginOffset", "endOffset", "messageIndex", "contentIndex"): + assert offset_key not in logged_entry + # Non-locating fields are preserved for observability. + assert logged_entry["type"] == "EMAIL" + assert logged_entry["confidenceScore"] == 0.9 + assert slg["guardrail_status"] == "success" + + +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_non_200_raises_and_logs_failed_status(): + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS) + request_data = {"messages": []} + creds, prep, post_patch, _ = _patched( + g, _mock_http_response(400, {"message": "ValidationException: bad request"}) + ) + with creds, prep, post_patch: + with pytest.raises(HTTPException) as exc: + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "hi"}], + request_data=request_data, + ) + assert exc.value.status_code == 400 + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_status"] == "guardrail_failed_to_respond" + + +# --------------------------------------------------------------------------- +# Empty-response no-op contract (locks the masking-bypass design) +# --------------------------------------------------------------------------- + + +def test_masking_helpers_noop_on_empty_response(): + """A pass returns an empty BedrockGuardrailResponse; masking must be a no-op.""" + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS) + empty = BedrockGuardrailResponse() + + assert g._extract_masked_texts_from_response(empty) == [] + + messages = [{"role": "user", "content": "keep me"}] + assert ( + g._update_messages_with_updated_bedrock_guardrail_response( + messages=messages, bedrock_guardrail_response=empty + ) + == messages + ) + + response = ModelResponse( + choices=[Choices(message=Message(role="assistant", content="unchanged"))] + ) + g._apply_masking_to_response(response=response, bedrock_guardrail_response=empty) + assert response.choices[0].message.content == "unchanged" + + +# --------------------------------------------------------------------------- +# experimental_use_latest_role_message_only + checks +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_experimental_latest_message_only_with_checks(): + g = BedrockGuardrail( + checks=CONTENT_FILTER_CHECKS, + experimental_use_latest_role_message_only=True, + ) + data = { + "messages": [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "reply"}, + {"role": "user", "content": "latest"}, + ] + } + captured = {} + + def fake_prepare(**kwargs): + captured.update(kwargs) + return MagicMock() + + mock_credentials = MagicMock() + with ( + patch.object( + g, "_load_credentials", return_value=(mock_credentials, "us-east-1") + ), + patch.object(g, "_prepare_request", side_effect=fake_prepare), + patch.object( + g.async_handler, + "post", + new=AsyncMock(return_value=_mock_http_response(200, {"results": {}})), + ), + ): + await g.async_pre_call_hook( + user_api_key_dict=MagicMock(), + cache=MagicMock(), + data=data, + call_type="completion", + ) + + # Only the latest user message should be scanned; original data preserved. + assert captured["data"]["messages"] == [ + {"role": "user", "content": [{"text": "latest"}]} + ] + assert len(data["messages"]) == 3 + + +# --------------------------------------------------------------------------- +# Block paths for every check (field-mapping regression guard) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_blocks_when_prompt_attack_meets_threshold(): + g = BedrockGuardrail( + checks={"promptAttack": {"categories": [{"category": "JAILBREAK"}]}}, + prompt_attack_threshold=0.5, + ) + payload = { + "results": { + "promptAttack": { + "results": [{"category": "JAILBREAK", "severityScore": 0.8}] + } + } + } + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + with pytest.raises(HTTPException) as exc: + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "ignore your instructions"}], + request_data={"messages": []}, + ) + assert exc.value.detail["bedrock_guardrail_checks"] == [ + {"check": "promptAttack", "category": "JAILBREAK", "severityScore": 0.8} + ] + + +@pytest.mark.asyncio +async def test_blocks_when_pii_confidence_meets_threshold(): + g = BedrockGuardrail( + checks={"sensitiveInformation": {"entities": [{"type": "EMAIL"}]}}, + pii_confidence_threshold=0.5, + ) + payload = { + "results": { + "sensitiveInformation": { + "results": [ + { + "type": "EMAIL", + "confidenceScore": 0.95, + "beginOffset": 1, + "endOffset": 10, + "messageIndex": 0, + "contentIndex": 0, + } + ], + "truncated": False, + } + } + } + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + with pytest.raises(HTTPException) as exc: + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "a@b.com"}], + request_data={"messages": []}, + ) + detail = exc.value.detail + # Proves the confidenceScore/type field-mapping branch fires for PII. + assert detail["bedrock_guardrail_checks"] == [ + {"check": "sensitiveInformation", "type": "EMAIL", "confidenceScore": 0.95} + ] + # PII offsets must never reach the client-facing detail. + for offset_key in ("beginOffset", "endOffset", "messageIndex", "contentIndex"): + assert offset_key not in json.dumps(detail) + + +@pytest.mark.asyncio +async def test_mixed_checks_only_over_threshold_reported(): + g = BedrockGuardrail( + checks={ + "contentFilter": {"categories": [{"category": "VIOLENCE"}]}, + "sensitiveInformation": {"entities": [{"type": "EMAIL"}]}, + }, + content_filter_threshold=0.5, + pii_confidence_threshold=0.5, + ) + payload = { + "results": { + "contentFilter": { + "results": [{"category": "VIOLENCE", "severityScore": 0.2}] + }, + "sensitiveInformation": { + "results": [{"type": "EMAIL", "confidenceScore": 0.9}], + "truncated": False, + }, + } + } + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + with pytest.raises(HTTPException) as exc: + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "x"}], + request_data={"messages": []}, + ) + # contentFilter is below threshold; only the PII violation is reported. + assert exc.value.detail["bedrock_guardrail_checks"] == [ + {"check": "sensitiveInformation", "type": "EMAIL", "confidenceScore": 0.9} + ] + + +@pytest.mark.asyncio +async def test_output_source_blocks_and_logs_intervened(): + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS, content_filter_threshold=0.5) + payload = { + "results": { + "contentFilter": { + "results": [{"category": "VIOLENCE", "severityScore": 0.8}] + } + } + } + request_data = {"messages": []} + response = ModelResponse( + choices=[Choices(message=Message(role="assistant", content="violent output"))] + ) + creds, prep, post_patch, _ = _patched(g, _mock_http_response(200, payload)) + with creds, prep, post_patch: + with pytest.raises(HTTPException): + await g.make_bedrock_api_request( + source="OUTPUT", + response=response, + request_data=request_data, + ) + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_status"] == "guardrail_intervened" + + +# --------------------------------------------------------------------------- +# Dispatcher routing + normalization + init warning +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dispatcher_routes_to_apply_mode_when_no_checks(): + g = BedrockGuardrail(guardrailIdentifier="gid", guardrailVersion="DRAFT") + with ( + patch.object( + g, "_make_apply_guardrail_request", new=AsyncMock(return_value={}) + ) as apply_mock, + patch.object( + g, "_make_invoke_guardrail_checks_request", new=AsyncMock(return_value={}) + ) as checks_mock, + ): + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "hi"}], + request_data={"messages": []}, + ) + apply_mock.assert_awaited_once() + checks_mock.assert_not_awaited() + + +def test_normalize_checks_accepts_pydantic_model(): + """The proxy initializer passes a BedrockChecksConfigModel, not a raw dict.""" + from litellm.types.guardrails import ( + BedrockChecksConfigModel, + BedrockChecksContentFilterModel, + ) + + model = BedrockChecksConfigModel( + contentFilter=BedrockChecksContentFilterModel( + categories=[{"category": "VIOLENCE"}] + ) + ) + g = BedrockGuardrail(checks=model) + assert g.checks == {"contentFilter": {"categories": [{"category": "VIOLENCE"}]}} + + +def test_init_warns_when_masking_set_with_checks(): + with patch( + "litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails.verbose_proxy_logger.warning" + ) as mock_warning: + BedrockGuardrail(checks=CONTENT_FILTER_CHECKS, mask_request_content=True) + assert any("detect-only" in str(call) for call in mock_warning.call_args_list) + + +# --------------------------------------------------------------------------- +# Content-block limit: chunk (scan everything), never truncate (bypass guard) +# --------------------------------------------------------------------------- + + +def test_input_message_with_many_blocks_is_chunked_not_truncated(): + """>10 text blocks must all be scanned (split across messages), not truncated. + + Regression for the bypass where content past the per-message block cap would + skip scanning while still being forwarded to the model. + """ + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS) + blocks = [{"type": "text", "text": f"block{i}"} for i in range(23)] + built = g._build_invoke_guardrail_checks_messages( + "INPUT", messages=[{"role": "user", "content": blocks}] + ) + # 23 blocks -> messages of <=10, covering EVERY block in order. + assert all(m["role"] == "user" for m in built) + assert all(len(m["content"]) <= 10 for m in built) + all_texts = [c["text"] for m in built for c in m["content"]] + assert all_texts == [f"block{i}" for i in range(23)] + + +def test_output_multiple_choices_all_scanned(): + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS) + response = ModelResponse( + choices=[ + Choices(index=0, message=Message(role="assistant", content="choice-0")), + Choices(index=1, message=Message(role="assistant", content="choice-1")), + ] + ) + built = g._build_invoke_guardrail_checks_messages("OUTPUT", response=response) + all_texts = [c["text"] for m in built for c in m["content"]] + assert all_texts == ["choice-0", "choice-1"] + assert all(m["role"] == "assistant" for m in built) + assert all(len(m["content"]) <= 10 for m in built) + + +def test_checks_config_model_rejects_empty(): + """BedrockChecksConfigModel must require at least one check (fail closed).""" + import pydantic + + from litellm.types.guardrails import BedrockChecksConfigModel + + with pytest.raises(pydantic.ValidationError): + BedrockChecksConfigModel() + + +@pytest.mark.asyncio +async def test_many_blocks_scanned_at_request_level_and_can_block(): + """End-to-end: a >10-block message reaches Bedrock as multiple chunked messages + (every block in the actual request body) and a violation still blocks. + + Closes the bypass at the request boundary, not just the message-builder. + """ + g = BedrockGuardrail(checks=CONTENT_FILTER_CHECKS, content_filter_threshold=0.5) + blocks = [{"type": "text", "text": f"b{i}"} for i in range(25)] + payload = { + "results": { + "contentFilter": { + "results": [{"category": "VIOLENCE", "severityScore": 0.8}] + } + } + } + captured = {} + + def fake_prepare(**kwargs): + captured.update(kwargs) + return MagicMock() + + with ( + patch.object(g, "_load_credentials", return_value=(MagicMock(), "us-east-1")), + patch.object(g, "_prepare_request", side_effect=fake_prepare), + patch.object( + g.async_handler, + "post", + new=AsyncMock(return_value=_mock_http_response(200, payload)), + ), + ): + with pytest.raises(HTTPException): + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": blocks}], + request_data={"messages": []}, + ) + + body_messages = captured["data"]["messages"] + # Every one of the 25 blocks is present in the request actually sent to Bedrock. + sent_texts = [c["text"] for m in body_messages for c in m["content"]] + assert sent_texts == [f"b{i}" for i in range(25)] + assert all(len(m["content"]) <= 10 for m in body_messages) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py index 791fdd4077c..5be0d43c250 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py @@ -1150,6 +1150,23 @@ class TestGenericGuardrailAPIStreamingConfig: assert GenericGuardrailAPI.get_config_model() is GenericGuardrailAPIConfigModel + def test_streaming_transform_mode_defaults_block_only(self): + guardrail = GenericGuardrailAPI( + api_base="https://api.test.guardrail.com", + guardrail_name="test-generic-guardrail", + event_hook="post_call", + ) + assert guardrail.streaming_transform_mode == "block_only" + + def test_streaming_transform_mode_override(self): + guardrail = GenericGuardrailAPI( + api_base="https://api.test.guardrail.com", + guardrail_name="test-generic-guardrail", + event_hook="post_call", + streaming_transform_mode="incremental_diff", + ) + assert guardrail.streaming_transform_mode == "incremental_diff" + def test_initialize_guardrail_forwards_streaming_flags(self): from litellm.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( initialize_guardrail, @@ -1306,6 +1323,86 @@ class TestGenericGuardrailAPIStreamingConfig: assert guardrail.streaming_sampling_rate == 2 +class TestGenericGuardrailAPIResponseParsing: + """GenericGuardrailAPIResponse.from_dict handling of the streaming holdback field.""" + + def test_from_dict_parses_stream_holdback_chars(self): + from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( + GenericGuardrailAPIResponse, + ) + + response = GenericGuardrailAPIResponse.from_dict( + { + "action": "GUARDRAIL_INTERVENED", + "texts": ["Alice went to Berlin"], + "stream_holdback_chars": [5], + } + ) + + assert response.action == "GUARDRAIL_INTERVENED" + assert response.texts == ["Alice went to Berlin"] + assert response.stream_holdback_chars == [5] + + def test_from_dict_coerces_holdback_values_to_int(self): + from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( + GenericGuardrailAPIResponse, + ) + + response = GenericGuardrailAPIResponse.from_dict( + {"action": "GUARDRAIL_INTERVENED", "texts": ["x", "y"], "stream_holdback_chars": ["3", 0]} + ) + + assert response.stream_holdback_chars == [3, 0] + + def test_from_dict_holdback_absent_is_none(self): + from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( + GenericGuardrailAPIResponse, + ) + + response = GenericGuardrailAPIResponse.from_dict({"action": "NONE", "texts": ["hi"]}) + + assert response.stream_holdback_chars is None + + def test_from_dict_malformed_holdback_degrades_to_zero(self): + """A null/non-numeric/negative holdback element must not raise; it degrades + to 0 (no holdback) so a bad guardrail response can't abort the stream.""" + from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( + GenericGuardrailAPIResponse, + ) + + response = GenericGuardrailAPIResponse.from_dict( + { + "action": "GUARDRAIL_INTERVENED", + "texts": ["a", "b", "c", "d"], + "stream_holdback_chars": ["3", None, "bad", -2], + } + ) + + assert response.stream_holdback_chars == [3, 0, 0, 0] + + @pytest.mark.asyncio + async def test_apply_guardrail_flows_holdback_back_to_inputs(self, generic_guardrail): + """A GUARDRAIL_INTERVENED response with stream_holdback_chars is surfaced on + the returned inputs so the streaming framework can apply it.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "action": "GUARDRAIL_INTERVENED", + "texts": ["Alice went to Berlin"], + "stream_holdback_chars": [5], + } + mock_response.raise_for_status = MagicMock() + + with patch.object(generic_guardrail.async_handler, "post", return_value=mock_response): + result = await generic_guardrail.apply_guardrail( + inputs={"texts": ["Zorg went to Xanadu"]}, + request_data={}, + input_type="response", + ) + + assert result["texts"] == ["Alice went to Berlin"] + assert result["stream_holdback_chars"] == [5] + + class TestGenericGuardrailAPIStreamingViaUnified: """Streaming output checks routed through UnifiedLLMGuardrails.""" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_lasso.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_lasso.py index 5a84b6ebecd..16185cadbdf 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_lasso.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_lasso.py @@ -693,6 +693,8 @@ class TestLassoGuardrail: assert prompt_payload["messages"] == messages assert prompt_payload["userId"] == "test-user" assert prompt_payload["sessionId"] == "test-conversation" + # Every call is attributed to the "litellm" integration for the "Used By" badge. + assert prompt_payload["source"] == {"type": "litellm"} # Test COMPLETION payload completion_messages = [{"role": "assistant", "content": "Test response"}] @@ -703,6 +705,7 @@ class TestLassoGuardrail: assert completion_payload["messages"] == completion_messages assert completion_payload["userId"] == "test-user" assert completion_payload["sessionId"] == "test-conversation" + assert completion_payload["source"] == {"type": "litellm"} def test_header_preparation(self): """Test header preparation.""" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py index 6e027fa4941..e84e9b74201 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py @@ -648,3 +648,845 @@ class TestUnifiedLLMGuardrails: # Response returned with pages intact assert result.pages[0].markdown == "Some text" + + +class _StreamingTextGuardrail(CustomGuardrail): + """Guardrail whose apply_guardrail rewrites (uppercases) response text. + + Optionally schedules a per-response-call ``stream_holdback_chars`` (indexed + like ``texts``) and can force the mutated text shorter than the input to + exercise the streaming underflow guard. + """ + + def __init__(self, *, holdback_schedule=None, shrink_to=None, shrink_after=0, sampling_rate=1): + super().__init__(guardrail_name="streaming-text-guardrail") + self.streaming_transform_mode = "incremental_diff" + self.streaming_sampling_rate = sampling_rate + self.streaming_end_of_stream_only = False + self.guardrail_config = {} + self._holdback_schedule = list(holdback_schedule or []) + self._shrink_to = shrink_to + self._shrink_after = shrink_after + self.response_calls = 0 + self.received_texts = [] + self.received_tool_calls = [] + + def should_run_guardrail(self, data, event_type): # type: ignore[override] + return True + + async def apply_guardrail(self, inputs, request_data, input_type, **kwargs): + texts = inputs.get("texts", []) + if input_type != "response": + return {"texts": [t.upper() for t in texts]} + + if inputs.get("tool_calls"): + self.received_tool_calls.append(inputs.get("tool_calls")) + self.received_texts.append(list(texts)) + idx = self.response_calls + self.response_calls += 1 + if self._shrink_to is not None and idx >= self._shrink_after: + transformed = [self._shrink_to for _ in texts] + else: + transformed = [t.upper() for t in texts] + result = {"texts": transformed} + if idx < len(self._holdback_schedule): + result["stream_holdback_chars"] = [self._holdback_schedule[idx]] * len(texts) + return result + + +def _stream_chunk(content, finish_reason=None, index=0): + return ModelResponseStream( + choices=[ + StreamingChoices( + index=index, + delta=Delta(content=content, role="assistant"), + finish_reason=finish_reason, + ) + ], + ) + + +async def _drive_stream(handler, guardrail, chunks, request_route="/v1/chat/completions"): + async def _mock_stream(): + for chunk in chunks: + yield chunk + + user_api_key_dict = UserAPIKeyAuth(api_key="test-key", request_route=request_route) + request_data = {"guardrail_to_apply": guardrail, "model": "gpt-4"} + out = [] + async for item in handler.async_post_call_streaming_iterator_hook( + user_api_key_dict=user_api_key_dict, + response=_mock_stream(), + request_data=request_data, + ): + out.append(item) + return out + + +def _delta_text(item): + if not getattr(item, "choices", None): + return "" + return item.choices[0].delta.content or "" + + +class TestStreamingTransform: + """Streaming text-transformation (incremental_diff) path on the OpenAI chat + completions streaming surface.""" + + @pytest.fixture(autouse=True) + def _use_openai_handler_mapping(self): + unified_module.endpoint_guardrail_translation_mappings = { + CallTypes.acompletion: OpenAIChatCompletionsHandler, + } + yield + unified_module.endpoint_guardrail_translation_mappings = None + + @pytest.mark.asyncio + async def test_block_only_drops_text_rewrites(self): + """Default block_only: the guardrail's uppercasing never reaches the + client; the original lowercase chunks are streamed verbatim.""" + guardrail = _StreamingTextGuardrail() + guardrail.streaming_transform_mode = "block_only" + + chunks = [ + _stream_chunk("hello "), + _stream_chunk("world"), + _stream_chunk("", finish_reason="stop"), + ] + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + streamed = "".join(_delta_text(i) for i in out) + + assert streamed == "hello world" + assert streamed != streamed.upper() + + @pytest.mark.asyncio + async def test_incremental_diff_emits_uppercased_deltas(self): + """incremental_diff: the client receives uppercased deltas whose + concatenation equals uppercase(full).""" + guardrail = _StreamingTextGuardrail() + + full = "hello world this is streaming" + words = ["hello ", "world ", "this ", "is ", "streaming"] + chunks = [_stream_chunk(w) for w in words] + chunks.append(_stream_chunk("", finish_reason="stop")) + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + streamed = "".join(_delta_text(i) for i in out) + + assert streamed == full.upper() + # No raw lowercase content leaked onto the wire. + assert "hello" not in streamed + + @pytest.mark.asyncio + async def test_incremental_diff_holdback_boundary(self): + """Holdback=5 on the first sample withholds the trailing chars until the + next round; the final concatenation matches with no loss or duplication.""" + guardrail = _StreamingTextGuardrail(holdback_schedule=[5, 0]) + + # No finish_reason: every sample uses the combined-text branch so the + # scheduled holdback is applied on the first round. + chunks = [_stream_chunk("abcdef"), _stream_chunk("ghij")] + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + deltas = [_delta_text(i) for i in out] + streamed = "".join(deltas) + + # First sample: "ABCDEF" with holdback 5 -> only "A" is emitted. + assert deltas[0] == "A" + assert streamed == "ABCDEFGHIJ" + + @pytest.mark.asyncio + async def test_incremental_diff_underflow_raises(self): + """A transform shorter than what was already streamed cannot retract + bytes: it raises HTTPException(stream_transform_underflow).""" + # First sample emits "ABCDEF" (6 chars); second sample shrinks to 3. + guardrail = _StreamingTextGuardrail(shrink_to="ABC", shrink_after=1) + + chunks = [_stream_chunk("abcdef"), _stream_chunk("ghij")] + + with pytest.raises(unified_module.HTTPException) as exc_info: + await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["error"] == "stream_transform_underflow" + + @pytest.mark.asyncio + async def test_incremental_diff_final_chunk_preserves_finish_reason(self): + """The final synthetic chunk carries the finish_reason of the last raw + chunk.""" + guardrail = _StreamingTextGuardrail() + + chunks = [ + _stream_chunk("hello "), + _stream_chunk("world"), + _stream_chunk("", finish_reason="stop"), + ] + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + + assert out, "expected at least one synthetic chunk" + assert out[-1].choices[0].finish_reason == "stop" + assert "".join(_delta_text(i) for i in out) == "HELLO WORLD" + + @pytest.mark.asyncio + async def test_end_of_stream_only_emits_single_final_chunk(self): + """incremental_diff + streaming_end_of_stream_only: a single post-stream + synthetic chunk carries the whole guardrailed text and the finish_reason.""" + guardrail = _StreamingTextGuardrail() + guardrail.streaming_end_of_stream_only = True + + chunks = [ + _stream_chunk("hello "), + _stream_chunk("world"), + _stream_chunk("", finish_reason="stop"), + ] + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + + non_empty = [i for i in out if _delta_text(i)] + assert len(non_empty) == 1 + assert _delta_text(non_empty[0]) == "HELLO WORLD" + assert out[-1].choices[0].finish_reason == "stop" + + @pytest.mark.asyncio + async def test_unsupported_route_falls_back_to_block_only(self): + """A route that does not resolve to the OpenAI chat handler falls back to + block_only rather than transforming.""" + guardrail = _StreamingTextGuardrail() + + chunks = [_stream_chunk("hello "), _stream_chunk("world", finish_reason="stop")] + + # request_route=None => no resolvable call type => block_only fallback. + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks, request_route=None) + streamed = "".join(_delta_text(i) for i in out) + + assert streamed == "hello world" + + @pytest.mark.asyncio + async def test_emit_streaming_http_error_a2a_yields_jsonrpc_chunk(self): + """The shared streaming error helper emits an in-stream JSON-RPC error for + A2A call types instead of raising.""" + import json + + handler = UnifiedLLMGuardrails() + exc = unified_module.HTTPException( + status_code=400, + detail={"error": "stream_transform_underflow", "message": "boom"}, + ) + + emitted = [] + async for item in handler._emit_streaming_http_error( + exc, + call_type=CallTypes.asend_message.value, + responses_so_far=[{"id": "req-1"}], + request_data={}, + ): + emitted.append(item) + + assert len(emitted) == 1 + payload = json.loads(emitted[0]) + assert payload["error"]["message"] == "stream_transform_underflow" + assert payload["id"] == "req-1" + + def test_final_chunk_preserves_per_choice_finish_reason(self): + """The final flush must carry each choice's own finish_reason, not + choices[0]'s, for n > 1 (e.g. "stop" vs "length").""" + reference_chunk = ModelResponseStream(choices=[StreamingChoices(index=0, delta=Delta(content="a"))]) + + synthetic = UnifiedLLMGuardrails()._build_transform_chunk( + reference_chunk=reference_chunk, + mutated_text_per_choice={0: "A", 1: "B"}, + emitted_text_per_choice={}, + holdback_per_choice={}, + finish_reason_per_choice={0: "stop", 1: "length"}, + is_final=True, + ) + + by_index = {c.index: c for c in synthetic.choices} + assert by_index[0].finish_reason == "stop" + assert by_index[1].finish_reason == "length" + assert by_index[0].delta.content == "A" + assert by_index[1].delta.content == "B" + + def test_synthetic_chunk_drops_raw_tool_calls(self): + """v1 does not transform streamed tool calls; the synthetic chunk must not + pass raw upstream tool_calls through (they would bypass the guardrail).""" + reference_chunk = ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta( + content="hi", + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "leak", "arguments": '{"ssn": "123-45-6789"}'}, + } + ], + ), + finish_reason=None, + ), + ], + ) + + synthetic = UnifiedLLMGuardrails()._build_transform_chunk( + reference_chunk=reference_chunk, + mutated_text_per_choice={0: "HI"}, + emitted_text_per_choice={}, + holdback_per_choice={}, + finish_reason_per_choice={}, + is_final=False, + ) + + assert synthetic.choices[0].delta.tool_calls is None + assert synthetic.choices[0].delta.content == "HI" + + def test_rewriting_already_emitted_prefix_raises(self): + """If a later transform rewrites bytes already streamed (not a forward + extension), the framework fails closed rather than leaking the original.""" + reference_chunk = ModelResponseStream( + choices=[StreamingChoices(index=0, delta=Delta(content="x"), finish_reason=None)], + ) + + with pytest.raises(unified_module.HTTPException) as exc_info: + UnifiedLLMGuardrails()._build_transform_chunk( + reference_chunk=reference_chunk, + # already streamed "My SSN is 123"; the guardrail now wants to + # redact those already-sent chars -> not a forward extension. + mutated_text_per_choice={0: "My SSN is [REDACTED]"}, + emitted_text_per_choice={0: "My SSN is 123"}, + holdback_per_choice={}, + finish_reason_per_choice={}, + is_final=False, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["error"] == "stream_transform_underflow" + + @pytest.mark.asyncio + async def test_tool_call_chunks_pass_through_and_not_dropped(self): + """A tool-call chunk is passed through raw under incremental_diff (v1 does + not transform tool calls) rather than being withheld and dropped, and no + bogus empty-choices chunk is emitted for a tool-call-only turn.""" + guardrail = _StreamingTextGuardrail() + + tool_chunk = ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta( + content=None, + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + } + ], + ), + finish_reason="tool_calls", + ) + ], + ) + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, [tool_chunk]) + + assert len(out) == 1 + assert out[0].choices[0].delta.tool_calls + assert out[0].choices[0].finish_reason == "tool_calls" + # The tool call is delivered raw but still inspected by the guardrail at + # end of stream (it can block), matching block_only. + assert guardrail.received_tool_calls + + @pytest.mark.asyncio + async def test_per_choice_finish_reason_when_choices_finish_in_different_chunks(self): + """n>1: a choice finishing before the stream's last chunk keeps its own + finish_reason (it must not be lost because it is not on last_chunk).""" + guardrail = _StreamingTextGuardrail() + guardrail.streaming_end_of_stream_only = True # only the flush emits + + chunks = [ + _stream_chunk("aa", index=0), + _stream_chunk("bb", index=1), + _stream_chunk("", finish_reason="stop", index=0), + _stream_chunk("", finish_reason="length", index=1), + ] + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + + by_index = {} + for item in out: + for choice in item.choices: + if choice.finish_reason is not None: + by_index[choice.index] = choice.finish_reason + assert by_index == {0: "stop", 1: "length"} + + @pytest.mark.asyncio + async def test_short_guardrail_texts_withheld_not_leaked(self): + """If the guardrail returns fewer texts than sent (contract violation), + the unmatched choice is withheld (fail closed), not emitted raw.""" + + class _DropsSecondChoice(_StreamingTextGuardrail): + async def apply_guardrail(self, inputs, request_data, input_type, **kwargs): + texts = inputs.get("texts", []) + if input_type != "response": + return {"texts": [t.upper() for t in texts]} + # Return only the first choice's transformed text. + return {"texts": [texts[0].upper()] if texts else []} + + guardrail = _DropsSecondChoice() + guardrail.streaming_end_of_stream_only = True + + chunks = [ + _stream_chunk("secret-a", index=0), + _stream_chunk("secret-b", index=1), + _stream_chunk("", finish_reason="stop", index=0), + _stream_chunk("", finish_reason="stop", index=1), + ] + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + + streamed = "".join(_delta_text(i) for i in out) + assert "SECRET-A" in streamed + # Choice 1 had no guardrailed text returned: withheld, never leaked raw. + assert "secret-b" not in streamed + assert "SECRET-B" not in streamed + + @pytest.mark.asyncio + async def test_no_spurious_chunk_after_text_then_tool_call_finish(self): + """When a choice streams text and then finishes via a tool-call chunk, the + raw tool-call chunk carries the finish_reason and no spurious empty chunk + for that choice is emitted afterwards (protocol: no delta after finish).""" + guardrail = _StreamingTextGuardrail() + + tool_chunk = ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta( + content=None, + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + } + ], + ), + finish_reason="tool_calls", + ) + ], + ) + chunks = [_stream_chunk("let me check "), tool_chunk] + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + + # Exactly: one synthetic text delta, then the raw tool-call chunk. No + # trailing empty chunk for choice 0 after it already finished. + assert len(out) == 2 + assert _delta_text(out[0]) == "LET ME CHECK " + assert out[1].choices[0].delta.tool_calls + assert out[1].choices[0].finish_reason == "tool_calls" + + @pytest.mark.asyncio + async def test_tool_call_blocking_guardrail_is_enforced(self): + """A guardrail that blocks on tool calls must terminate the incremental_diff + stream: tool calls go through the block decision, not bypass it.""" + from litellm.exceptions import GuardrailRaisedException + + class _ToolCallBlocker(_StreamingTextGuardrail): + async def apply_guardrail(self, inputs, request_data, input_type, **kwargs): + if input_type == "response" and inputs.get("tool_calls"): + raise GuardrailRaisedException( + guardrail_name="tc-block", + message="blocked tool call", + should_wrap_with_default_message=False, + ) + return await super().apply_guardrail(inputs, request_data, input_type, **kwargs) + + tool_chunk = ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta( + content=None, + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "exfiltrate", "arguments": "{}"}, + } + ], + ), + finish_reason="tool_calls", + ) + ], + ) + + with pytest.raises(GuardrailRaisedException): + await _drive_stream(UnifiedLLMGuardrails(), _ToolCallBlocker(), [tool_chunk]) + + @pytest.mark.asyncio + async def test_mixed_content_and_tool_call_chunk_does_not_leak_text(self): + """A chunk carrying BOTH delta.content and a tool call must not be yielded + raw: the text has to go through the transform, only tool-call fields pass + through raw (content stripped).""" + guardrail = _StreamingTextGuardrail() + + mixed = ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta( + content="secret", + role="assistant", + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + ), + finish_reason="tool_calls", + ) + ], + ) + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, [mixed]) + + streamed = "".join(_delta_text(i) for i in out) + # Raw text never reaches the client; only the transformed text does. + assert "secret" not in streamed + assert "SECRET" in streamed + # The tool call is delivered, but its chunk carries no text. + tool_chunks = [i for i in out if i.choices[0].delta.tool_calls] + assert tool_chunks + assert all(not (c.choices[0].delta.content or "") for c in tool_chunks) + + @pytest.mark.asyncio + async def test_n_gt_1_text_and_tool_call_in_same_chunk_no_text_leak(self): + """n>1 chunk where one choice streams text and another a tool call: the + text choice must be transformed, not emitted raw alongside the tool call.""" + guardrail = _StreamingTextGuardrail() + + chunk = ModelResponseStream( + choices=[ + StreamingChoices(index=0, delta=Delta(content="secret", role="assistant"), finish_reason=None), + StreamingChoices( + index=1, + delta=Delta( + content=None, + role="assistant", + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + ), + finish_reason="tool_calls", + ), + ], + ) + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, [chunk]) + + # choice 0's text is transformed, never delivered raw on the tool chunk. + for item in out: + for c in item.choices: + if c.delta.tool_calls: + assert not (c.delta.content or "") + all_text = "".join(c.delta.content or "" for i in out for c in i.choices) + assert "secret" not in all_text + assert "SECRET" in all_text + + @pytest.mark.asyncio + async def test_mixed_chunk_finish_reason_arrives_after_transformed_text(self): + """Fix #1: when a single chunk carries both delta.content and tool_calls + with finish_reason set, the passthrough must NOT emit finish_reason + before the transformed text — SSE clients that stop reading at + finish_reason would silently drop the guardrailed text. finish_reason + must ride on a terminator after the transformed text.""" + guardrail = _StreamingTextGuardrail() + mixed = ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta( + content="secret", + role="assistant", + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + ), + finish_reason="tool_calls", + ) + ], + ) + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, [mixed]) + + passthrough_idx = next(i for i, item in enumerate(out) if item.choices and item.choices[0].delta.tool_calls) + # Passthrough MUST NOT carry finish_reason for a mixed chunk (deferred). + assert out[passthrough_idx].choices[0].finish_reason is None, ( + f"passthrough of mixed chunk leaked finish_reason: {out[passthrough_idx].choices[0].finish_reason}" + ) + # finish_reason arrives via a terminator that comes AFTER the passthrough, + # so an SSE client reading top-down sees the transformed text before it + # sees the terminator. + finish_carriers = [ + i for i, item in enumerate(out) if item.choices and item.choices[0].finish_reason == "tool_calls" + ] + assert finish_carriers, "finish_reason=tool_calls never delivered" + assert min(finish_carriers) > passthrough_idx + # And the redacted text ("SECRET") reached the wire on some non-tool + # chunk (i.e. the text terminator). + transformed = "".join( + item.choices[0].delta.content or "" + for item in out + if item.choices and not item.choices[0].delta.tool_calls + ) + assert "SECRET" in transformed + assert "secret" not in transformed + + @pytest.mark.asyncio + async def test_text_flush_precedes_tool_call_passthrough(self): + """Fix #3: text chunks followed by a pure tool-call chunk carrying + finish_reason="tool_calls" must emit transformed text BEFORE the + passthrough, otherwise SSE-compliant clients stop reading at + finish_reason and drop the transformed text.""" + # sampling_rate 5: no mid-stream round would fire on 2 text chunks + # without the pre-tool-call flush. + guardrail = _StreamingTextGuardrail(sampling_rate=5) + chunks = [ + _stream_chunk("hello "), + _stream_chunk("world"), + ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta( + content=None, + role="assistant", + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + ), + finish_reason="tool_calls", + ) + ], + ), + ] + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + + text_indices = [ + i + for i, item in enumerate(out) + if item.choices and (item.choices[0].delta.content or "") and not item.choices[0].delta.tool_calls + ] + tool_indices = [i for i, item in enumerate(out) if item.choices and item.choices[0].delta.tool_calls] + assert text_indices, "transformed text was never emitted" + assert tool_indices, "tool-call passthrough missing" + assert max(text_indices) < min(tool_indices) + transformed = "".join(out[i].choices[0].delta.content or "" for i in text_indices) + assert "HELLO WORLD" in transformed + + @pytest.mark.asyncio + async def test_final_finish_reason_flushed_when_guardrail_suppresses_text(self): + """Fix #4: when the guardrail returns texts=[] (full suppression) and a + mixed content+tool_call chunk had deferred its finish_reason to the + text flush, the final flush must still emit a terminator chunk carrying + finish_reason. Otherwise the SSE stream ends without finish_reason.""" + + class _SuppressAll(_StreamingTextGuardrail): + async def apply_guardrail(self, inputs, request_data, input_type, **kwargs): + self.received_texts.append(list(inputs.get("texts") or [])) + return {"texts": []} + + mixed = ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta( + content="secret", + role="assistant", + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + ), + finish_reason="tool_calls", + ) + ], + ) + + out = await _drive_stream(UnifiedLLMGuardrails(), _SuppressAll(), [mixed]) + finishes = [c.finish_reason for item in out for c in item.choices if c.index == 0] + assert "tool_calls" in finishes + + @pytest.mark.asyncio + async def test_transform_sends_texts_sorted_by_choice_index(self): + """Fix #2: for n>1 streams where choice 1 emits before choice 0, the + transform must send texts to the guardrail in ascending choice-index + order so its returned texts realign to the correct choice indices.""" + + class _RecordingGuardrail(_StreamingTextGuardrail): + async def apply_guardrail(self, inputs, request_data, input_type, **kwargs): + self.received_texts.append(list(inputs.get("texts") or [])) + return {"texts": list(inputs.get("texts") or [])} + + guardrail = _RecordingGuardrail() + chunks = [ + _stream_chunk("beta", index=1), + _stream_chunk("alpha", index=0), + _stream_chunk("", index=0, finish_reason="stop"), + ] + await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + last = guardrail.received_texts[-1] + # Sorted ascending: alpha (index 0) before beta (index 1). + assert last[0].startswith("alpha") + assert last[1].startswith("beta") + + @pytest.mark.asyncio + async def test_non_idempotent_guardrail_not_double_applied_on_tool_call_streams(self): + """A non-idempotent guardrail must not see its own output as input on a + subsequent round. Regression guard for the bug where + ``_inspect_full_response_for_block`` shared ``responses_so_far`` with a + block-only path that mutated ``delta.content`` in place — the next + ``_round(is_final=True)`` would then re-read the already-guardrailed text + and re-apply the transform. + + Uses an n>1 chunk with text on choice 0 and tool_calls (with + finish_reason) on choice 1 — the exact shape that trips the + ``has_stream_ended=False → block path mutates anyway`` failure mode. + The test guardrail replaces the literal 'John' with '[REDACTED]', which is + non-idempotent: '[REDACTED]' does not contain 'John' so a second pass + produces the same output, BUT the raw accumulator would concat as + '[REDACTED]' + partial-raw, tripping stream_transform_underflow or + producing double-output. Assert the guardrail was called with the raw + text each time, not with any already-guardrailed prefix.""" + + class _RedactJohn(_StreamingTextGuardrail): + async def apply_guardrail(self, inputs, request_data, input_type, **kwargs): + texts = list(inputs.get("texts") or []) + self.received_texts.append(texts) + return {"texts": [t.replace("John", "[REDACTED]") for t in texts]} + + guardrail = _RedactJohn(sampling_rate=5) + + chunks = [ + _stream_chunk("John "), + _stream_chunk("went home."), + ModelResponseStream( + choices=[ + StreamingChoices(index=0, delta=Delta(content=None, role="assistant", tool_calls=None), finish_reason=None), + StreamingChoices( + index=1, + delta=Delta( + content=None, + role="assistant", + tool_calls=[ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + ), + finish_reason="tool_calls", + ), + ], + ), + ] + + await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + + # Every text the guardrail saw as input must be raw ("John " not "[REDACTED]"). + # If the block path mutated the shared accumulator, the final _round would + # send "[REDACTED] went home." instead of "John went home.". + for received in guardrail.received_texts: + for text in received: + assert "[REDACTED]" not in text, ( + f"guardrail was re-invoked with its own already-redacted output " + f"— shared accumulator mutation regression: {received!r}" + ) + """The guardrail must receive the raw accumulated output each round, not a + transformed-prefix + raw-suffix mix (responses_so_far stays untouched).""" + guardrail = _StreamingTextGuardrail() + + chunks = [_stream_chunk("aa "), _stream_chunk("bb "), _stream_chunk("cc", finish_reason="stop")] + + await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + + # Every recorded input is the cumulative RAW (lowercase) text; if the + # accumulator were corrupted by write-back, later rounds would contain + # uppercased prefixes like "AA bb ". + for received in guardrail.received_texts: + assert received[0] == received[0].lower() + assert guardrail.received_texts[-1] == ["aa bb cc"] + + def test_accumulate_keys_by_choice_index_not_position(self): + """Single-choice chunks carrying a non-zero .index (n>1 streaming) must be + keyed by index, not enumerate position (which would collapse to 0).""" + handler = OpenAIChatCompletionsHandler() + chunks = [ + _stream_chunk("hello", index=1), + _stream_chunk(" world", index=1), + ] + + accumulated = handler._accumulate_string_content_by_choice_index(chunks) + + assert accumulated == {1: "hello world"} + + @pytest.mark.asyncio + async def test_terminal_chunk_not_guardrailed_twice(self): + """A terminal (finish_reason) chunk that is also a sampling boundary must + be processed once by the end-of-stream flush, not by a sampled round too.""" + guardrail = _StreamingTextGuardrail() # sampling_rate=1 + + chunks = [_stream_chunk("aa "), _stream_chunk("bb", finish_reason="stop")] + + await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + + # Round 1 (chunk 1) + end-of-stream flush = 2 calls. Without the terminal + # skip, chunk 2 would be guardrailed by a sampled round AND the flush (3). + assert guardrail.response_calls == 2 + + @pytest.mark.asyncio + async def test_malformed_holdback_from_in_process_guardrail_degrades(self): + """An in-process guardrail (bypassing from_dict) returning a null holdback + must degrade to 0 in the handler, not raise and abort the stream.""" + guardrail = _StreamingTextGuardrail(holdback_schedule=[None]) + + chunks = [_stream_chunk("abc"), _stream_chunk("def", finish_reason="stop")] + + out = await _drive_stream(UnifiedLLMGuardrails(), guardrail, chunks) + + # None holdback treated as 0: full text emitted, no crash. + assert "".join(_delta_text(i) for i in out) == "ABCDEF" diff --git a/tests/test_litellm/proxy/guardrails/test_content_utils.py b/tests/test_litellm/proxy/guardrails/test_content_utils.py index 34d92505359..3dfb98c12ea 100644 --- a/tests/test_litellm/proxy/guardrails/test_content_utils.py +++ b/tests/test_litellm/proxy/guardrails/test_content_utils.py @@ -524,3 +524,42 @@ def test_apply_redacted_messages_back_skips_input_when_not_string(): data = {"input": [{"type": "text", "text": "leak"}]} apply_redacted_messages_back(data, [{"role": "user", "content": "[REDACTED]"}]) assert data["input"] == [{"type": "text", "text": "leak"}] + + +# ------------------------------------------------------------------- +# LIT-4302: custom_tool_call_output walking +# ------------------------------------------------------------------- + +def test_iter_message_text_walks_custom_tool_call_output(): + """custom_tool_call_output items should yield their output text.""" + data = { + "input": [ + {"type": "custom_tool_call_output", "output": "tool-secret"}, + ] + } + from litellm.proxy.guardrails._content_utils import iter_message_text + texts = list(iter_message_text(data)) + assert "tool-secret" in texts + + +def test_walk_user_text_redacts_custom_tool_call_output(): + """walk_user_text should rewrite text inside custom_tool_call_output.""" + data = { + "input": [ + {"type": "custom_tool_call_output", "output": "PII-data"}, + ] + } + count = walk_user_text(data, lambda t: t.replace("PII-data", "[MASKED]")) + assert count >= 1 + assert data["input"][0]["output"] == "[MASKED]" + + +def test_build_inspection_messages_custom_tool_call_output(): + """build_inspection_messages should include custom_tool_call_output text.""" + data = { + "input": [ + {"type": "custom_tool_call_output", "output": "custom-tool-leak"}, + ] + } + msgs = build_inspection_messages(data) + assert any("custom-tool-leak" in m["content"] for m in msgs) diff --git a/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py b/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py index 813a0c5e38f..f289148101a 100644 --- a/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py +++ b/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py @@ -14,6 +14,7 @@ from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.hooks.proxy_track_cost_callback import ( _ProxyDBLogger, _get_budget_reservation_from_metadata, + _should_track_cost_callback, _update_database_and_spend_counters, ) @@ -1177,3 +1178,88 @@ async def test_track_cost_callback_enriches_user_id_for_mcp_style_metadata(): kwargs["litellm_params"]["metadata"]["user_api_key_user_id"] == "mcp-user@example.com" ) + + +@pytest.mark.parametrize( + "call_type, expected", + [ + ("pass_through_endpoint", True), + ("llm_passthrough_route", True), + ("allm_passthrough_route", True), + ("acompletion", False), + ("call_mcp_tool", False), + (None, False), + ], +) +def test_should_track_cost_callback_pass_through_without_owner(call_type, expected): + """Regression for LIT-3782: unauthenticated pass-through requests (auth=false) + carry no key/user/team/end-user, yet must still be tracked so they land in + LiteLLM_SpendLogs. Other call types with no owner stay untracked.""" + assert ( + _should_track_cost_callback( + user_api_key=None, + user_id=None, + team_id=None, + end_user_id=None, + call_type=call_type, + ) + is expected + ) + + +@pytest.mark.parametrize( + "call_type, expect_spend_log", + [ + ("pass_through_endpoint", True), + ("acompletion", False), + (None, False), + ], +) +@pytest.mark.asyncio +async def test_track_cost_callback_logs_unauthenticated_pass_through_request( + call_type, expect_spend_log +): + """Regression for LIT-3782: a pass-through request with auth=false reaches the + cost callback with no key/user/team/end-user. Before the fix the spend-log + write was skipped and the request never appeared in request/usage logs. It + must now be written for pass-through call types while other unauthenticated + calls remain skipped.""" + logger = _ProxyDBLogger() + + kwargs = { + "call_type": call_type, + "model": "unknown", + "litellm_params": {"metadata": {}}, + "standard_logging_object": { + "response_cost": 0.0, + "request_tags": None, + }, + "stream": False, + } + + with ( + patch( + "litellm.proxy.proxy_server.increment_spend_counters", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy.proxy_server.update_cache", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", + ) as mock_proxy_logging, + ): + mock_proxy_logging.db_spend_update_writer.update_database = AsyncMock() + mock_proxy_logging.slack_alerting_instance.customer_spend_alert = AsyncMock() + + await logger._PROXY_track_cost_callback( + kwargs=kwargs, + completion_response=None, + start_time=datetime.now(), + end_time=datetime.now(), + ) + + assert mock_proxy_logging.db_spend_update_writer.update_database.await_count == ( + 1 if expect_spend_log else 0 + ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_utils.py b/tests/test_litellm/proxy/management_endpoints/test_common_utils.py index dd9cbcf5232..81840745d0e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_utils.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_utils.py @@ -608,3 +608,320 @@ class TestValidateFiniteSpend: with pytest.raises(HTTPException) as exc_info: validate_finite_spend(bad) assert exc_info.value.status_code == 400 + + +class TestValidateFiniteSpendErrorDetail: + """The 400 for non-finite spend must carry the exact {"error": } body.""" + + def test_rejection_detail_is_exact(self): + from fastapi import HTTPException + + from litellm.proxy.management_endpoints.common_utils import ( + validate_finite_spend, + ) + + with pytest.raises(HTTPException) as exc_info: + validate_finite_spend(float("nan")) + + assert exc_info.value.detail == { + "error": "spend must be a finite number. Received: nan" + } + + +class TestRequireCallerUserIdErrorDetail: + """The 403 for a service-account key must carry the exact error body.""" + + def test_rejection_detail_is_exact(self): + from fastapi import HTTPException + + from litellm.proxy.management_endpoints.common_utils import ( + require_caller_user_id_for_non_admin, + ) + + service_account_key = UserAPIKeyAuth( + user_id=None, + user_role=LitellmUserRoles.INTERNAL_USER, + ) + with pytest.raises(HTTPException) as exc_info: + require_caller_user_id_for_non_admin(service_account_key) + + assert exc_info.value.detail == { + "error": "Service-account keys cannot query user analytics. Use a user-bound key, or call as a proxy admin." + } + + +class TestCheckPassthroughRoutesCallerPermission: + """Only proxy admins may set allowed_passthrough_routes (top-level or under + metadata); non-admins get a 403 naming the entity.""" + + def _non_admin(self): + return UserAPIKeyAuth( + user_id="u1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER + ) + + def test_top_level_routes_rejected_with_default_entity(self): + from fastapi import HTTPException + from pydantic import BaseModel + + from litellm.proxy.management_endpoints.common_utils import ( + _check_passthrough_routes_caller_permission, + ) + + class _RouteData(BaseModel): + allowed_passthrough_routes: list | None = None + metadata: dict | None = None + + data = _RouteData(allowed_passthrough_routes=["/v1/foo"]) + with pytest.raises(HTTPException) as exc_info: + _check_passthrough_routes_caller_permission(data, self._non_admin()) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == { + "error": "Only proxy admins can set `allowed_passthrough_routes` on a key." + } + + def test_metadata_routes_rejected_with_default_entity(self): + from fastapi import HTTPException + from pydantic import BaseModel + + from litellm.proxy.management_endpoints.common_utils import ( + _check_passthrough_routes_caller_permission, + ) + + class _RouteData(BaseModel): + allowed_passthrough_routes: list | None = None + metadata: dict | None = None + + data = _RouteData(metadata={"allowed_passthrough_routes": ["/v1/foo"]}) + with pytest.raises(HTTPException) as exc_info: + _check_passthrough_routes_caller_permission(data, self._non_admin()) + + assert exc_info.value.detail == { + "error": "Only proxy admins can set `metadata.allowed_passthrough_routes` on a key." + } + + def test_tolerates_data_missing_passthrough_and_metadata_fields(self): + from pydantic import BaseModel + + from litellm.proxy.management_endpoints.common_utils import ( + _check_passthrough_routes_caller_permission, + ) + + class _Bare(BaseModel): + unrelated: str = "x" + + assert ( + _check_passthrough_routes_caller_permission(_Bare(), self._non_admin()) + is None + ) + + +class TestIsUserOrgAdminForTeam: + """The caller must be looked up with its exact identity; a nulled or omitted + lookup argument would silently mis-resolve org-admin status.""" + + @pytest.mark.asyncio + async def test_get_user_object_called_with_caller_identity(self): + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, + ) + + team = LiteLLM_TeamTable( + team_id="t1", organization_id="org1", members_with_roles=[] + ) + key = UserAPIKeyAuth( + user_id="u1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER + ) + fake_prisma, fake_cache, fake_logging = MagicMock(), MagicMock(), MagicMock() + mock_get_user = AsyncMock(return_value=None) + + with patch( + "litellm.proxy.proxy_server.prisma_client", fake_prisma + ), patch( + "litellm.proxy.proxy_server.user_api_key_cache", fake_cache + ), patch( + "litellm.proxy.proxy_server.proxy_logging_obj", fake_logging + ), patch( + "litellm.proxy.auth.auth_checks.get_user_object", mock_get_user + ): + result = await _is_user_org_admin_for_team(key, team) + + assert result is False + mock_get_user.assert_awaited_once_with( + user_id="u1", + prisma_client=fake_prisma, + user_api_key_cache=fake_cache, + user_id_upsert=False, + proxy_logging_obj=fake_logging, + ) + + +class TestTeamMemberHasPermission: + def test_requires_caller_to_be_a_team_member(self): + from litellm.proxy.management_endpoints.common_utils import ( + _team_member_has_permission, + ) + + team = LiteLLM_TeamTable( + team_id="t1", + team_member_permissions=["/key/generate"], + members_with_roles=[Member(user_id="someone-else", role="user")], + ) + key = UserAPIKeyAuth( + user_id="u1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER + ) + assert _team_member_has_permission(key, team, "/key/generate") is False + + +class TestUserHasAdminPrivilegesGuard: + @pytest.mark.asyncio + async def test_no_user_lookup_when_prisma_is_none(self): + """With no DB the guard short-circuits before any user lookup.""" + auth = UserAPIKeyAuth( + user_id="user1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER + ) + mock_get_user = AsyncMock(return_value=None) + with patch("litellm.proxy.auth.auth_checks.get_user_object", mock_get_user): + result = await _user_has_admin_privileges( + user_api_key_dict=auth, prisma_client=None + ) + assert result is False + mock_get_user.assert_not_called() + + @pytest.mark.asyncio + async def test_org_admin_membership_grants_privileges(self): + """With DB + user_id present, an ORG_ADMIN membership yields True.""" + auth = UserAPIKeyAuth( + user_id="user1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER + ) + now = datetime.now(timezone.utc) + user_obj = LiteLLM_UserTable( + user_id="user1", + organization_memberships=[ + LiteLLM_OrganizationMembershipTable( + user_id="user1", + organization_id="org1", + user_role=LitellmUserRoles.ORG_ADMIN.value, + created_at=now, + updated_at=now, + ) + ], + ) + mock_get_user = AsyncMock(return_value=user_obj) + with patch("litellm.proxy.auth.auth_checks.get_user_object", mock_get_user): + result = await _user_has_admin_privileges( + user_api_key_dict=auth, prisma_client=MagicMock() + ) + assert result is True + + +class TestAdminCanInviteUserGuard: + @pytest.mark.asyncio + async def test_no_user_lookup_when_prisma_is_none(self): + auth = UserAPIKeyAuth( + user_id="admin1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER + ) + mock_get_user = AsyncMock(return_value=None) + with patch("litellm.proxy.auth.auth_checks.get_user_object", mock_get_user): + result = await admin_can_invite_user( + target_user_id="target1", + user_api_key_dict=auth, + prisma_client=None, + ) + assert result is False + mock_get_user.assert_not_called() + + @pytest.mark.asyncio + async def test_org_admin_can_invite_user_in_shared_org(self): + now = datetime.now(timezone.utc) + auth = UserAPIKeyAuth( + user_id="admin1", api_key="sk-x", user_role=LitellmUserRoles.INTERNAL_USER + ) + + def membership(role): + return LiteLLM_OrganizationMembershipTable( + user_id="x", + organization_id="org1", + user_role=role, + created_at=now, + updated_at=now, + ) + + admin_obj = LiteLLM_UserTable( + user_id="admin1", + organization_memberships=[membership(LitellmUserRoles.ORG_ADMIN.value)], + ) + target_obj = LiteLLM_UserTable( + user_id="target1", + organization_memberships=[membership(LitellmUserRoles.INTERNAL_USER.value)], + ) + mock_get_user = AsyncMock(side_effect=[admin_obj, target_obj]) + with patch("litellm.proxy.auth.auth_checks.get_user_object", mock_get_user): + result = await admin_can_invite_user( + target_user_id="target1", + user_api_key_dict=auth, + prisma_client=MagicMock(), + ) + assert result is True + + +class TestTeamAdminCanInviteUserQuery: + @pytest.mark.asyncio + async def test_find_many_queries_admin_teams_with_exact_where(self): + mock_prisma = MagicMock() + mock_auth = MagicMock() + mock_auth.user_id = "admin" + admin_user = LiteLLM_UserTable(user_id="admin", teams=["t1", "t2"]) + target_user = LiteLLM_UserTable(user_id="target", teams=["t2"]) + + def make_team(tid): + obj = MagicMock() + obj.team_id = tid + obj.model_dump = lambda: { + "team_id": tid, + "members_with_roles": [{"user_id": "admin", "role": "admin"}], + } + return obj + + find_many = AsyncMock(return_value=[make_team("t1"), make_team("t2")]) + mock_prisma.db.litellm_teamtable.find_many = find_many + + await _team_admin_can_invite_user( + user_api_key_dict=mock_auth, + admin_user_obj=admin_user, + target_user_obj=target_user, + prisma_client=mock_prisma, + ) + + find_many.assert_awaited_once_with(where={"team_id": {"in": ["t1", "t2"]}}) + + +class TestSetObjectMetadataFieldPremiumArg: + def test_premium_check_receives_the_field_name(self): + team = LiteLLM_TeamTable(team_id="t1", metadata={}) + with patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check" + ) as mock_premium: + _set_object_metadata_field(team, "guardrails", ["g1"]) + mock_premium.assert_called_once_with("guardrails") + + +class TestUpdateMetadataFieldMove: + def test_none_valued_field_is_not_moved_into_metadata(self): + """A None value must leave the field untouched (guard requires non-None).""" + from litellm.proxy.management_endpoints.common_utils import ( + _update_metadata_field, + ) + + updated_kv = {"guardrails": None} + _update_metadata_field(updated_kv=updated_kv, field_name="guardrails") + assert updated_kv == {"guardrails": None} + + def test_set_premium_field_is_moved_into_metadata(self): + updated_kv = {"guardrails": ["g1"]} + with patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check" + ): + _update_metadata_fields(updated_kv) + assert "guardrails" not in updated_kv + assert updated_kv["metadata"]["guardrails"] == ["g1"] diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index a57e18df6ef..bc463d5e75d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -405,3 +405,98 @@ class TestResolveModelForCostLookup: assert resolved_model == "azure/openai/gpt-5.3-codex" assert provider is None + + def test_returns_custom_llm_provider_on_base_model_path(self): + """base_model path: the custom_llm_provider from litellm_params is + returned as the second tuple element, unchanged.""" + from litellm.proxy.management_endpoints.cost_tracking_settings import ( + _resolve_model_for_cost_lookup, + ) + + mock_router = MagicMock() + mock_router.get_model_list.return_value = [ + { + "model_name": "my-azure-model", + "litellm_params": { + "model": "azure/my-deployment", + "base_model": "azure/gpt-4o", + "custom_llm_provider": "azure", + }, + "model_info": {"id": "test-id"}, + } + ] + + with patch("litellm.proxy.proxy_server.llm_router", mock_router): + resolved_model, provider = _resolve_model_for_cost_lookup("my-azure-model") + + assert resolved_model == "azure/gpt-4o" + assert provider == "azure" + + def test_returns_custom_llm_provider_on_resolved_model_path(self): + """resolved-model path (no base_model): the custom_llm_provider from + litellm_params is returned alongside litellm_params.model.""" + from litellm.proxy.management_endpoints.cost_tracking_settings import ( + _resolve_model_for_cost_lookup, + ) + + mock_router = MagicMock() + mock_router.get_model_list.return_value = [ + { + "model_name": "gpt-4", + "litellm_params": { + "model": "openai/gpt-4", + "custom_llm_provider": "openai", + }, + "model_info": {"id": "test-id"}, + } + ] + + with patch("litellm.proxy.proxy_server.llm_router", mock_router): + resolved_model, provider = _resolve_model_for_cost_lookup("gpt-4") + + assert resolved_model == "openai/gpt-4" + assert provider == "openai" + + def test_resolves_base_model_when_deployment_has_no_litellm_params(self): + """A deployment can omit litellm_params entirely; base_model from + model_info must still resolve (the .get default must be {} not None, + else the later litellm_params.get(...) raises and resolution is lost).""" + from litellm.proxy.management_endpoints.cost_tracking_settings import ( + _resolve_model_for_cost_lookup, + ) + + mock_router = MagicMock() + mock_router.get_model_list.return_value = [ + { + "model_name": "my-azure-model", + "model_info": {"base_model": "azure/gpt-4o"}, + } + ] + + with patch("litellm.proxy.proxy_server.llm_router", mock_router): + resolved_model, provider = _resolve_model_for_cost_lookup("my-azure-model") + + assert resolved_model == "azure/gpt-4o" + assert provider is None + + def test_resolves_model_when_deployment_has_no_model_info(self): + """A deployment can omit model_info entirely; litellm_params.model must + still resolve (the .get default must be {} not None, else the earlier + model_info.get(...) raises and resolution is lost).""" + from litellm.proxy.management_endpoints.cost_tracking_settings import ( + _resolve_model_for_cost_lookup, + ) + + mock_router = MagicMock() + mock_router.get_model_list.return_value = [ + { + "model_name": "gpt-4", + "litellm_params": {"model": "openai/gpt-4"}, + } + ] + + with patch("litellm.proxy.proxy_server.llm_router", mock_router): + resolved_model, provider = _resolve_model_for_cost_lookup("gpt-4") + + assert resolved_model == "openai/gpt-4" + assert provider is None diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index db6d3489830..aa24b0199ab 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -529,6 +529,56 @@ async def test_key_generation_with_object_permission(monkeypatch): assert key_insert_calls[0]["data"].get("object_permission_id") == "objperm123" +@pytest.mark.asyncio +async def test_generate_key_debug_log_never_contains_raw_token(monkeypatch, caplog): + """Regression for LIT-4356: /key/generate must never emit the raw virtual key + to a logger, even for short keys that bypass the regex-based + SecretRedactionFilter.""" + import hashlib + import logging + + mock_prisma_client = AsyncMock() + mock_prisma_client.jsonify_object = lambda data: data + mock_prisma_client.db = MagicMock() + + async def _insert_data_side_effect(*args, **kwargs): + if kwargs.get("table_name") == "user": + return MagicMock(models=[], spend=0) + return MagicMock( + token="hashed_token_456", + litellm_budget_table=None, + object_permission=None, + ) + + mock_prisma_client.insert_data = AsyncMock(side_effect=_insert_data_side_effect) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", + AsyncMock(return_value={}), + ) + + from litellm.proxy._types import GenerateKeyRequest, LitellmUserRoles + from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth + from litellm.proxy.management_endpoints.key_management_endpoints import ( + generate_key_fn, + ) + + raw_key = "sk-short-secret" + with caplog.at_level(logging.DEBUG, logger="LiteLLM Proxy"): + await generate_key_fn( + data=GenerateKeyRequest(key=raw_key), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-1234", + user_id="user-1", + ), + ) + + log_text = "\n".join(record.getMessage() for record in caplog.records) + assert raw_key not in log_text + assert hashlib.sha256(raw_key.encode()).hexdigest() in log_text + + @pytest.mark.asyncio @pytest.mark.parametrize( "field,request_kwargs,expected_in_error", @@ -12272,6 +12322,55 @@ async def test_bulk_update_team_keys_team_member_no_permission(monkeypatch): mock.update_data.assert_not_called() +def test_handle_key_type_persists_key_type_and_derives_routes(): + """`handle_key_type` keeps `key_type` in the payload (so it is persisted on + the token) while still deriving the `allowed_routes` preset. Regression for + the UI showing scoped keys as "All Proxy Models": the frontend now reads the + persisted `key_type` instead of reverse-mapping the preset string.""" + from litellm.proxy._types import GenerateKeyRequest, LiteLLMKeyType + from litellm.proxy.management_endpoints.key_management_endpoints import ( + handle_key_type, + ) + + cases = { + LiteLLMKeyType.MANAGEMENT: ("management", ["management_routes"]), + LiteLLMKeyType.READ_ONLY: ("read_only", ["info_routes"]), + LiteLLMKeyType.LLM_API: ("llm_api", ["llm_api_routes"]), + } + for key_type, (expected_type, expected_routes) in cases.items(): + data = GenerateKeyRequest(key_type=key_type) + out = handle_key_type(data, {"key_type": key_type}) + assert out["key_type"] == expected_type + assert out["allowed_routes"] == expected_routes + + +def test_handle_key_type_default_persists_type_without_forcing_routes(): + """`default` is persisted but must not overwrite an explicit `allowed_routes` + (e.g. a SCIM key created with `["/scim/*"]` and no explicit key_type).""" + from litellm.proxy._types import GenerateKeyRequest, LiteLLMKeyType + from litellm.proxy.management_endpoints.key_management_endpoints import ( + handle_key_type, + ) + + data = GenerateKeyRequest(key_type=LiteLLMKeyType.DEFAULT) + out = handle_key_type(data, {"allowed_routes": ["/scim/*"], "key_type": LiteLLMKeyType.DEFAULT}) + assert out["key_type"] == "default" + assert out["allowed_routes"] == ["/scim/*"] + + +def test_handle_key_type_none_drops_key_type(): + """When no `key_type` is supplied the payload must not carry a `key_type` + entry, so old keys stay `null` and the frontend keeps its route fallback.""" + from litellm.proxy._types import GenerateKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import ( + handle_key_type, + ) + + data = GenerateKeyRequest(key_type=None) + out = handle_key_type(data, {"key_type": None}) + assert "key_type" not in out + + # ---- pydantic-layer validation ------------------------------------------- diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 59b08a7ec4e..f7b2df45a85 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -18,6 +18,7 @@ sys.path.insert( from litellm.proxy._types import UserAPIKeyAuth # Import UserAPIKeyAuth from litellm.proxy._types import ( LiteLLM_BudgetTableFull, + LiteLLM_ModelTable, LiteLLM_OrganizationMembershipTable, LiteLLM_OrganizationTable, LiteLLM_OrganizationTableWithMembers, @@ -30,6 +31,7 @@ from litellm.proxy._types import ( ProxyErrorTypes, ProxyException, TeamMemberAddRequest, + UpdateTeamRequest, ) from litellm.proxy.management_endpoints.team_endpoints import ( user_api_key_auth, # Assuming this dependency is needed @@ -40,6 +42,7 @@ from litellm.proxy.management_endpoints.team_endpoints import ( _persist_deleted_team_records, _save_deleted_team_records, _transform_teams_to_deleted_records, + _update_model_table, _validate_and_populate_member_user_info, _verify_team_access, delete_team, @@ -9436,6 +9439,96 @@ async def test_team_info_forwards_key_limit_to_get_data(): assert mock_prisma.get_data.await_args.kwargs["limit"] == 7 +@pytest.mark.asyncio +async def test_team_info_returns_model_aliases(): + """/team/info must join LiteLLM_ModelTable so the response exposes the team's + current model aliases; without the ``litellm_model_table`` include the field + comes back null and the Admin UI can never display them. + """ + from fastapi import Request + + from litellm.proxy.management_endpoints import team_endpoints + + team_row = LiteLLM_TeamTable( + team_id="team-1", + litellm_model_table=LiteLLM_ModelTable( + id=1, + model_aliases={"gpt-4o": "gpt-4o-team-1"}, + created_by="admin", + updated_by="admin", + ), + ) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row) + mock_prisma.get_data = AsyncMock(return_value=[]) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), + patch.object( + team_endpoints, "get_all_team_memberships", AsyncMock(return_value=[]) + ), + ): + response = await team_endpoints.team_info( + http_request=MagicMock(spec=Request), + team_id="team-1", + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + include = mock_prisma.db.litellm_teamtable.find_unique.await_args.kwargs["include"] + assert include["litellm_model_table"] is True + + litellm_model_table = response["team_info"].litellm_model_table + assert litellm_model_table is not None + assert litellm_model_table.model_aliases == {"gpt-4o": "gpt-4o-team-1"} + + +@pytest.mark.asyncio +async def test_update_model_table_clears_aliases_with_empty_map(): + """``model_aliases={}`` on /team/update must persist an empty map (json.dumps({})) + so existing aliases are cleared, while ``model_aliases=None`` must be a no-op that + leaves the model table untouched. + """ + mock_prisma = MagicMock() + mock_prisma.db.litellm_modeltable.create = AsyncMock() + mock_prisma.db.litellm_modeltable.upsert = AsyncMock( + return_value=MagicMock(id="model-123") + ) + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin" + ) + + returned_model_id = await _update_model_table( + data=UpdateTeamRequest(team_id="team-1", model_aliases={}), + model_id="model-123", + prisma_client=mock_prisma, + user_api_key_dict=user_api_key_dict, + litellm_proxy_admin_name="default_user_id", + ) + + mock_prisma.db.litellm_modeltable.upsert.assert_awaited_once() + upsert_kwargs = mock_prisma.db.litellm_modeltable.upsert.await_args.kwargs + assert upsert_kwargs["where"] == {"id": "model-123"} + assert upsert_kwargs["data"]["update"]["model_aliases"] == json.dumps({}) + assert upsert_kwargs["data"]["create"]["model_aliases"] == json.dumps({}) + assert returned_model_id == "model-123" + + mock_prisma.db.litellm_modeltable.create.reset_mock() + mock_prisma.db.litellm_modeltable.upsert.reset_mock() + + noop_model_id = await _update_model_table( + data=UpdateTeamRequest(team_id="team-1", model_aliases=None), + model_id="model-123", + prisma_client=mock_prisma, + user_api_key_dict=user_api_key_dict, + litellm_proxy_admin_name="default_user_id", + ) + + mock_prisma.db.litellm_modeltable.create.assert_not_called() + mock_prisma.db.litellm_modeltable.upsert.assert_not_called() + assert noop_model_id == "model-123" + + class TestEmitTeamMembersMetric: """The _emit_team_members_metric seam between the team handlers and Prometheus.""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 045e15f8b8b..92d1b870d75 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -461,6 +461,53 @@ async def test_get_group_ids_from_service_principal_uses_configured_graph_endpoi ] +@pytest.mark.asyncio +async def test_get_group_ids_from_service_principal_paginates_through_all_pages(): + # Arrange + page_one = { + "@odata.nextLink": "https://graph.microsoft.com/v1.0/servicePrincipals/sp-123/appRoleAssignedTo?$skiptoken=page2", + "value": [ + { + "principalType": "Group", + "principalId": "group-on-page-1", + "principalDisplayName": "Group On Page 1", + } + ], + } + page_two = { + "value": [ + { + "principalType": "Group", + "principalId": "group-on-page-2", + "principalDisplayName": "Group On Page 2", + } + ], + } + responses = [page_one, page_two] + + async def mock_get(url, *args, **kwargs): + mock = MagicMock() + mock.json.return_value = responses.pop(0) + return mock + + async_client = MagicMock() + async_client.get = mock_get + + # Act + group_ids, teams = await MicrosoftSSOHandler.get_group_ids_from_service_principal( + service_principal_id="sp-123", + async_client=async_client, + access_token="mock_token", + ) + + # Assert + assert group_ids == ["group-on-page-1", "group-on-page-2"] + assert [team["principalId"] for team in teams] == [ + "group-on-page-1", + "group-on-page-2", + ] + + def test_get_group_ids_from_graph_api_response(): # Arrange mock_response = MicrosoftGraphAPIUserGroupResponse( @@ -2139,6 +2186,36 @@ class TestCLIKeyRegenerationFlow: assert not _is_valid_cli_sso_login_id("cli-test\x001234567890") assert not _is_valid_cli_sso_login_id("sk-test1234567890") + def test_cli_sso_flow_lookup_tells_legacy_clients_to_upgrade(self): + """Legacy CLIs send self-generated sk- login ids; the 400 must say the CLI is outdated""" + from litellm.proxy.management_endpoints.ui_sso import ( + _get_cli_sso_flow_or_raise, + ) + + mock_cache = MagicMock() + mock_cache.get_cache.return_value = None + + with pytest.raises(HTTPException) as legacy_exc: + _get_cli_sso_flow_or_raise( + login_id="sk-85c789af-fc21-474c-9dc9-b5d794fe07ec", + cache=mock_cache, + ) + assert legacy_exc.value.status_code == 400 + assert "out of date" in legacy_exc.value.detail + assert "pip install" in legacy_exc.value.detail + mock_cache.get_cache.assert_not_called() + + with pytest.raises(HTTPException) as generic_exc: + _get_cli_sso_flow_or_raise(login_id="not-a-valid-id", cache=mock_cache) + assert generic_exc.value.status_code == 400 + assert generic_exc.value.detail == "Invalid CLI login session id" + + with pytest.raises(HTTPException) as expired_exc: + _get_cli_sso_flow_or_raise(login_id="cli-test_1234567890", cache=mock_cache) + assert expired_exc.value.status_code == 400 + assert "session not found or expired" in expired_exc.value.detail + assert "enable_redis_auth_cache" in expired_exc.value.detail + @pytest.mark.asyncio async def test_cli_sso_start_creates_bound_flow(self): """Test CLI SSO start creates a polling secret bound flow""" diff --git a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py new file mode 100644 index 00000000000..9363c50407d --- /dev/null +++ b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py @@ -0,0 +1,458 @@ +""" +Tests for BillableRequestMetricsMiddleware and route classification. + +These verify the metering gate (records only on 2xx to a billable endpoint), +correct category/route classification, model-id extraction, and that the +middleware is a transparent pass-through when no recorder is injected. +""" + +import asyncio +import threading +from typing import List, Optional, Tuple + +import pytest +from starlette.applications import Starlette +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route +from starlette.testclient import TestClient + +from litellm.proxy.middleware.billable_request_metrics_middleware import ( + BillableCategory, + BillableRequestMetricsMiddleware, + _extract_model_id, + classify_billable_request, +) +from litellm.proxy.middleware.in_flight_requests_middleware import ( + InFlightRequestsMiddleware, +) + + +class FakeRecorder: + def __init__(self) -> None: + self.calls: List[dict] = [] + + def record(self, *, category: BillableCategory, route: str, status_code: int, model_id: Optional[str]) -> None: + self.calls.append( + {"category": category, "route": route, "status_code": status_code, "model_id": model_id} + ) + + +def _make_app(recorder: Optional[FakeRecorder], status_code: int = 200, model_id: Optional[str] = None) -> Starlette: + async def handler(request: Request) -> Response: + headers = {"x-litellm-model-id": model_id} if model_id else {} + return JSONResponse({}, status_code=status_code, headers=headers) + + paths = [ + "/v1/chat/completions", + "/chat/completions", + "/v1/embeddings", + "/v1/completions", + "/mcp", + "/github/mcp", + "/toolset/my-tools/mcp", + "/v1/mcp/tools", + "/v1/mcp/server", + "/a2a/agent-1/message/send", + "/v1/a2a/discover", + "/health", + "/ui", + ] + app = Starlette(routes=[Route(p, handler, methods=["GET", "POST"]) for p in paths]) + app.add_middleware(BillableRequestMetricsMiddleware, recorder=recorder) + return app + + +# ── Structure ─────────────────────────────────────────────────────────────── + + +def test_is_pure_asgi_not_base_http_middleware(): + assert not issubclass(BillableRequestMetricsMiddleware, BaseHTTPMiddleware) + + +# ── classify_billable_request ───────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "path,expected", + [ + ("/v1/chat/completions", (BillableCategory.LLM, "/chat/completions")), + ("/chat/completions", (BillableCategory.LLM, "/chat/completions")), + ("/openai/deployments/gpt-4o/chat/completions", (BillableCategory.LLM, "/chat/completions")), + ("/engines/gpt-4o/chat/completions", (BillableCategory.LLM, "/chat/completions")), + ("/v1/completions", (BillableCategory.LLM, "/completions")), + ("/completions", (BillableCategory.LLM, "/completions")), + ("/v1/embeddings", (BillableCategory.LLM, "/embeddings")), + ("/v1/responses", (BillableCategory.LLM, "/responses")), + ("/v1/rerank", (BillableCategory.LLM, "/rerank")), + ("/v1/audio/transcriptions", (BillableCategory.LLM, "/audio/transcriptions")), + # Routes from the metering-bypass finding: authenticated inference + # endpoints that must bill and previously classified as None. + ("/v1/images/edits", (BillableCategory.LLM, "/images/edits")), + ("/images/edits", (BillableCategory.LLM, "/images/edits")), + ("/openai/deployments/dall-e/images/edits", (BillableCategory.LLM, "/images/edits")), + ("/v1/images/variations", (BillableCategory.LLM, "/images/variations")), + ("/v1/messages", (BillableCategory.LLM, "/v1/messages")), + ("/interactions", (BillableCategory.LLM, "/interactions")), + ("/v1beta/interactions", (BillableCategory.LLM, "/v1beta/interactions")), + ("/v1/videos", (BillableCategory.LLM, "/videos")), + ("/v1/videos/video_123/remix", (BillableCategory.LLM, "/remix")), + ("/v1/ocr", (BillableCategory.LLM, "/ocr")), + ("/v1beta/models/gemini-2.5-pro:generateContent", (BillableCategory.LLM, ":generateContent")), + ("/v1beta/models/gemini-2.5-pro:streamGenerateContent", (BillableCategory.LLM, ":streamGenerateContent")), + # SpendLogs-producing routes surfaced by the route-inventory audit + ("/v1/search", (BillableCategory.LLM, "/search")), + ("/v1/vector_stores/vs_1/search", (BillableCategory.LLM, "/search")), + ("/v1/rag/query", (BillableCategory.LLM, "/rag/query")), + ("/rag/ingest", (BillableCategory.LLM, "/rag/ingest")), + # Provider passthrough carries real inference and writes SpendLogs + ("/bedrock/model/anthropic.claude-v2/invoke", (BillableCategory.LLM, "/bedrock")), + ("/vertex-ai/publishers/google/models/gemini:predict", (BillableCategory.LLM, "/vertex-ai")), + ("/cohere/v2/chat", (BillableCategory.LLM, "/cohere")), + # Passthrough inference bills under its provider prefix + ("/anthropic/v1/messages", (BillableCategory.LLM, "/anthropic")), + ("/mcp", (BillableCategory.MCP, "/mcp")), + ("/mcp/", (BillableCategory.MCP, "/mcp")), + ("/mcp/tools/list", (BillableCategory.MCP, "/mcp")), + ("/github/mcp", (BillableCategory.MCP, "/mcp")), + ("/github/mcp/", (BillableCategory.MCP, "/mcp")), + ("/toolset/my-tools/mcp", (BillableCategory.MCP, "/mcp")), + ("/github,slack/mcp", (BillableCategory.MCP, "/mcp")), + # REST wrapper tool execution fires the same MCP spend logging as /mcp + ("/mcp-rest/tools/call", (BillableCategory.MCP, "/mcp")), + ("/a2a/agent-1/message/send", (BillableCategory.A2A, "/a2a")), + ("/v1/a2a/agent-9/message/send", (BillableCategory.A2A, "/a2a")), + ], +) +def test_classify_billable(path: str, expected: Tuple[BillableCategory, str]): + assert classify_billable_request(path) == expected + + +@pytest.mark.parametrize( + "path", + [ + "/health", + "/health/readiness", + "/metrics", + "/ui", + "/", + "/v1/models", + "/key/generate", + "/v1/files", + # tokenization helper, not an inference call + "/v1/messages/count_tokens", + # OpenAI Assistants thread messages write no SpendLogs row + "/v1/threads/thread_abc123/messages", + "/threads/thread_abc123/messages", + # Google Interactions reads and cancel are not inference calls + "/interactions/int_123", + "/v1beta/interactions/int_123", + "/interactions/int_123/cancel", + "/v1beta/interactions/int_123/cancel", + # observability passthrough writes no SpendLogs row + "/langfuse/api/public/ingestion", + # a bare provider prefix is not an inference call + "/bedrock", + "/v1/mcp", + "/v1/mcp/tools", + "/v1/mcp/server", + "/v1/mcp/server/health", + "/v1/mcp/server/some-id", + "/v1/mcp/server/register", + "/v1/mcp/oauth/some-id/authorize", + "/a2a/agent-1/.well-known/agent-card.json", + "/v1/a2a/discover", + "/.well-known/oauth-protected-resource/github/mcp", + "/mcp-rest/tools/list", + "/mcp-rest/test/connection", + "/mcp-rest/test/tools/list", + ], +) +def test_classify_non_billable_returns_none(path: str): + assert classify_billable_request(path) is None + + +@pytest.mark.parametrize( + "path", + [ + "/v1/mcp/tools", + "/v1/mcp/server", + "/v1/mcp/server/register", + "/v1/a2a/discover", + ], +) +def test_classify_management_writes_are_not_billable(path: str): + assert classify_billable_request(path, "POST") is None + + +@pytest.mark.parametrize( + "path", + [ + "/a2a/agent-1", + "/a2a/agent-1/", + "/v1/a2a/agent-1", + ], +) +def test_classify_bare_a2a_route_is_not_billable(path: str): + """ + The bare A2A route multiplexes JSON-RPC methods off the request body. Only + message/send and message/stream write a SpendLogs row; tasks/get, + tasks/cancel and the pushNotificationConfig RPCs are forwarded upstream and + write none. Billing the path would count those task RPCs and push the metric + above the dashboard's successful-request count, so it must stay unbilled. + """ + assert classify_billable_request(path, "POST") is None + + +@pytest.mark.parametrize( + "path", + ["/v1/videos", "/v1/responses", "/v1/chat/completions", "/v1/messages"], +) +def test_classify_get_reads_are_not_billable(path: str): + """GETs on inference resources (list videos, fetch a response) write no + SpendLogs row and must not bill; only POST inference calls count.""" + assert classify_billable_request(path, "GET") is None + + +def test_classify_mcp_not_method_gated(): + assert classify_billable_request("/mcp/tools/list", "GET") == (BillableCategory.MCP, "/mcp") + + +def test_chat_completions_not_misclassified_as_plain_completions(): + """The /chat/completions suffix must win over /completions so the route label is correct.""" + category, route = classify_billable_request("/v1/chat/completions") + assert route == "/chat/completions" + + +# ── _extract_model_id ───────────────────────────────────────────────────────── + + +def test_extract_model_id_present(): + headers = [(b"content-type", b"application/json"), (b"x-litellm-model-id", b"deploy-123")] + assert _extract_model_id(headers) == "deploy-123" + + +def test_extract_model_id_case_insensitive(): + assert _extract_model_id([(b"X-LiteLLM-Model-Id", b"deploy-9")]) == "deploy-9" + + +def test_extract_model_id_absent(): + assert _extract_model_id([(b"content-type", b"application/json")]) is None + + +# ── Middleware recording behaviour ──────────────────────────────────────────── + + +def test_records_once_on_2xx_llm_with_model_id(): + recorder = FakeRecorder() + TestClient(_make_app(recorder, status_code=200, model_id="deploy-7")).post("/v1/chat/completions") + assert recorder.calls == [ + {"category": BillableCategory.LLM, "route": "/chat/completions", "status_code": 200, "model_id": "deploy-7"} + ] + + +def test_records_mcp_category(): + recorder = FakeRecorder() + TestClient(_make_app(recorder)).post("/github/mcp") + assert len(recorder.calls) == 1 and recorder.calls[0]["category"] == BillableCategory.MCP + + +def test_records_a2a_category(): + recorder = FakeRecorder() + TestClient(_make_app(recorder)).post("/a2a/agent-1/message/send") + assert len(recorder.calls) == 1 and recorder.calls[0]["category"] == BillableCategory.A2A + + +def test_does_not_record_mcp_management_read(): + recorder = FakeRecorder() + TestClient(_make_app(recorder)).get("/v1/mcp/tools") + assert recorder.calls == [] + + +def test_does_not_record_mcp_management_write(): + recorder = FakeRecorder() + TestClient(_make_app(recorder)).post("/v1/mcp/server") + assert recorder.calls == [] + + +def test_does_not_record_a2a_discovery(): + recorder = FakeRecorder() + TestClient(_make_app(recorder)).post("/v1/a2a/discover") + assert recorder.calls == [] + + +def test_does_not_record_on_4xx(): + recorder = FakeRecorder() + TestClient(_make_app(recorder, status_code=404)).post("/v1/chat/completions") + assert recorder.calls == [] + + +def test_does_not_record_on_5xx(): + recorder = FakeRecorder() + TestClient(_make_app(recorder, status_code=503)).post("/v1/chat/completions") + assert recorder.calls == [] + + +def test_does_not_record_non_billable_path(): + recorder = FakeRecorder() + TestClient(_make_app(recorder, status_code=200)).get("/health") + assert recorder.calls == [] + + +def test_no_model_id_when_header_absent(): + recorder = FakeRecorder() + TestClient(_make_app(recorder, status_code=200, model_id=None)).post("/github/mcp") + assert recorder.calls[0]["model_id"] is None + + +def test_passthrough_when_recorder_is_none(): + """Non-enterprise: middleware records nothing and does not break the response.""" + response = TestClient(_make_app(None, status_code=200)).post("/v1/chat/completions") + assert response.status_code == 200 + + +def test_record_raising_does_not_fail_the_request(): + """A broken exporter must never surface to the client: the response was + already served when record() runs, so exceptions are swallowed and logged.""" + + class ExplodingRecorder: + def record(self, *, category, route, status_code, model_id): + raise RuntimeError("exporter down") + + app = _make_app(None, status_code=200) + app.user_middleware.clear() + app.add_middleware(BillableRequestMetricsMiddleware, recorder=ExplodingRecorder()) + response = TestClient(app).post("/v1/chat/completions") + assert response.status_code == 200 + + +def test_non_http_scope_is_ignored(): + recorder = FakeRecorder() + + class _Inner: + async def __call__(self, scope, receive, send): + return None + + mw = BillableRequestMetricsMiddleware(_Inner(), recorder=recorder) + asyncio.run(mw({"type": "lifespan"}, None, None)) # type: ignore[arg-type] + assert recorder.calls == [] + + +# ── lazy recorder factory ───────────────────────────────────────────────────── + + +def test_recorder_factory_not_called_at_init(): + """The factory must run on the first request, not at middleware construction: + building at import time captured recorder=None before the YAML config's + environment_variables loaded the license and cert env vars.""" + calls = [] + + def factory(): + calls.append(1) + return FakeRecorder() + + class _Inner: + async def __call__(self, scope, receive, send): + return None + + BillableRequestMetricsMiddleware(_Inner(), recorder_factory=factory) + assert calls == [] + + +def test_recorder_factory_resolved_once_on_first_request(): + recorder = FakeRecorder() + calls = [] + + def factory(): + calls.append(1) + return recorder + + client = TestClient(_make_app_with_factory(factory, status_code=200)) + client.post("/v1/chat/completions") + client.post("/v1/chat/completions") + assert calls == [1] + assert len(recorder.calls) == 2 + + +def test_recorder_factory_returning_none_is_cached(): + calls = [] + + def factory(): + calls.append(1) + return None + + client = TestClient(_make_app_with_factory(factory, status_code=200)) + assert client.post("/v1/chat/completions").status_code == 200 + assert client.post("/v1/chat/completions").status_code == 200 + assert calls == [1] + + +def test_recorder_factory_resolved_once_under_concurrency(): + """Concurrent first requests must not each build a recorder: every extra + build leaks a MeterProvider and its background exporter thread.""" + calls = [] + release = threading.Event() + + def slow_factory(): + calls.append(1) + release.wait(timeout=2) + return FakeRecorder() + + class _Inner: + async def __call__(self, scope, receive, send): + return None + + mw = BillableRequestMetricsMiddleware(_Inner(), recorder_factory=slow_factory) + threads = [threading.Thread(target=mw._resolve_recorder) for _ in range(8)] + for t in threads: + t.start() + release.set() + for t in threads: + t.join(timeout=5) + assert calls == [1] + + +def _make_app_with_factory(factory, status_code: int) -> Starlette: + async def handler(request: Request) -> Response: + return JSONResponse({}, status_code=status_code) + + app = Starlette(routes=[Route("/v1/chat/completions", handler, methods=["POST"])]) + app.add_middleware(BillableRequestMetricsMiddleware, recorder_factory=factory) + return app + + +# ── Shutdown ordering ─────────────────────────────────────────────────────── + + +def test_record_runs_before_request_leaves_the_in_flight_tracker(): + """ + The count is recorded after the inner app returns. If this middleware sat + outside InFlightRequestsMiddleware, a request could be seen as drained while + its record() had not run, letting proxy_shutdown_event flush and stop the + exporter underneath it. Nested inside, the in-flight count still covers it. + """ + observed: List[int] = [] + + class _CountingRecorder: + def record(self, *, category: BillableCategory, route: str, status_code: int, model_id: Optional[str]) -> None: + observed.append(InFlightRequestsMiddleware.get_count()) + + async def inner(scope, receive, send) -> None: + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"{}"}) + + stack = InFlightRequestsMiddleware(BillableRequestMetricsMiddleware(inner, recorder=_CountingRecorder())) + assert TestClient(stack).post("/v1/chat/completions").status_code == 200 + + assert observed == [1] + assert InFlightRequestsMiddleware.get_count() == 0 + + +def test_billable_middleware_is_registered_inside_the_in_flight_tracker(): + """Starlette makes the last-added middleware outermost, so the in-flight + tracker must be registered after the billing middleware to wrap it.""" + from litellm.proxy.proxy_server import app as proxy_app + + classes = [middleware.cls for middleware in proxy_app.user_middleware] + assert classes.index(InFlightRequestsMiddleware) < classes.index(BillableRequestMetricsMiddleware) diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py index 98b270788dc..45d35419680 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -21,6 +21,7 @@ from litellm.proxy.proxy_server import ( _is_remote_module_url, _scrub_db_overlay_remote_module_loads, _scrub_guardrail_inner, + resolve_complexity_router_plugins, ) from .conftest import normalize @@ -112,6 +113,78 @@ def test__scrub_db_overlay_remote_module_loads_invalid_non_dict_returns_input(): assert _scrub_db_overlay_remote_module_loads("litellm_settings", "raw") == "raw" +# --------------------------------------------------------------------------- +# resolve_complexity_router_plugins +# --------------------------------------------------------------------------- + + +def test_resolve_complexity_router_plugins_no_plugins_key_is_a_noop(): + config: Dict[str, Any] = {"tiers": {"SIMPLE": "gpt-4o-mini"}} + resolve_complexity_router_plugins( + model_name="smart-router", complexity_router_config=config, config_file_path=None + ) + assert config == {"tiers": {"SIMPLE": "gpt-4o-mini"}} + + +def test_resolve_complexity_router_plugins_resolves_dotted_path_to_live_instance(tmp_path): + plugin_file = tmp_path / "my_plugin.py" + plugin_file.write_text( + "class _Plugin:\n" + " async def run(self, context):\n" + " return context\n" + "\n" + "my_plugin_instance = _Plugin()\n" + ) + config: Dict[str, Any] = {"plugins": ["my_plugin.my_plugin_instance"]} + + resolve_complexity_router_plugins( + model_name="smart-router", + complexity_router_config=config, + config_file_path=str(tmp_path / "config.yaml"), + ) + + assert len(config["plugins"]) == 1 + assert hasattr(config["plugins"][0], "run") + assert type(config["plugins"][0]).__name__ == "_Plugin" + + +def test_resolve_complexity_router_plugins_rejects_non_routing_plugin_object(tmp_path): + plugin_file = tmp_path / "bad_plugin.py" + plugin_file.write_text("not_a_plugin = object()\n") + config: Dict[str, Any] = {"plugins": ["bad_plugin.not_a_plugin"]} + + with pytest.raises(ValueError, match="does not implement the RoutingPlugin interface"): + resolve_complexity_router_plugins( + model_name="smart-router", + complexity_router_config=config, + config_file_path=str(tmp_path / "config.yaml"), + ) + + +def test_resolve_complexity_router_plugins_rejects_synchronous_run_method(tmp_path): + """Regression: @runtime_checkable only checks that `run` exists as an attribute, + not that it's a coroutine function. A plugin with a synchronous `run` passes a bare + isinstance() check and would only fail at request time with a confusing + `TypeError: object RoutingContext can't be used in 'await' expression`. Reported + by Greptile on PR #33251.""" + plugin_file = tmp_path / "sync_plugin.py" + plugin_file.write_text( + "class _SyncPlugin:\n" + " def run(self, context):\n" + " return context\n" + "\n" + "sync_plugin_instance = _SyncPlugin()\n" + ) + config: Dict[str, Any] = {"plugins": ["sync_plugin.sync_plugin_instance"]} + + with pytest.raises(ValueError, match="does not implement the RoutingPlugin interface"): + resolve_complexity_router_plugins( + model_name="smart-router", + complexity_router_config=config, + config_file_path=str(tmp_path / "config.yaml"), + ) + + # --------------------------------------------------------------------------- # ProxyConfig.__init__ # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/proxy/test_budget_reservation.py b/tests/test_litellm/proxy/test_budget_reservation.py index 3da103683ba..540f017ee88 100644 --- a/tests/test_litellm/proxy/test_budget_reservation.py +++ b/tests/test_litellm/proxy/test_budget_reservation.py @@ -576,6 +576,83 @@ async def test_should_reserve_team_member_and_org_budget_counters(spend_counter_ await release_budget_reservation(reservation) +@pytest.mark.asyncio +async def test_should_reserve_user_budget_counter_for_team_key(spend_counter_state): + """A user's personal budget must be reserved even when the key belongs to a team. + + Regression for GitHub issue #12905: previously the reservation path skipped the + user spend counter whenever the key had a team, so a team key could overshoot the + user's personal max_budget under concurrency. + """ + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + valid_token = UserAPIKeyAuth( + token="key-user-on-team", + spend=0.0, + user_id="user-on-team", + team_id="team-no-budget", + ) + team_object = LiteLLM_TeamTable(team_id="team-no-budget", spend=0.0, max_budget=None) + user_object = LiteLLM_UserTable(user_id="user-on-team", spend=0.0, max_budget=5.0) + + with patch( + "litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost", + return_value=0.3, + ): + reservation = await reserve_budget_for_request( + request_body=_request_body(), + route="/chat/completions", + llm_router=None, + valid_token=valid_token, + team_object=team_object, + user_object=user_object, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + assert counter_cache.in_memory_cache.get_cache(key="spend:user:user-on-team") == pytest.approx(0.3) + + await release_budget_reservation(reservation) + + +@pytest.mark.asyncio +async def test_should_skip_user_budget_counter_for_team_key_when_flag_set(spend_counter_state): + """skip_user_budget_on_team_key=True restores the legacy behavior where a user's + personal budget is not reserved for a team key.""" + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + valid_token = UserAPIKeyAuth( + token="key-user-on-team-skip", + spend=0.0, + user_id="user-on-team-skip", + team_id="team-no-budget-skip", + ) + team_object = LiteLLM_TeamTable(team_id="team-no-budget-skip", spend=0.0, max_budget=None) + user_object = LiteLLM_UserTable(user_id="user-on-team-skip", spend=0.0, max_budget=5.0) + + with patch( + "litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost", + return_value=0.3, + ): + reservation = await reserve_budget_for_request( + request_body=_request_body(), + route="/chat/completions", + llm_router=None, + valid_token=valid_token, + team_object=team_object, + user_object=user_object, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + skip_user_budget_on_team_key=True, + ) + + assert counter_cache.in_memory_cache.get_cache(key="spend:user:user-on-team-skip") is None + + await release_budget_reservation(reservation) + + @pytest.mark.asyncio async def test_should_seed_org_counter_from_with_budget_cache(spend_counter_state): counter_cache, key_cache = spend_counter_state diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 603d5cc15b7..2f0924e9192 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -8649,6 +8649,38 @@ def test_get_config_list_includes_cancel_on_disconnect(monkeypatch): app.dependency_overrides.clear() +def test_get_config_list_includes_skip_user_budget_on_team_key(monkeypatch): + """Related to #12905: the opt-out flag must be discoverable via /config/list so + it renders as a Boolean toggle on the Admin UI General Settings table. This + requires both the ConfigGeneralSettings field and the allowed_args entry.""" + import types + from unittest.mock import AsyncMock, MagicMock + + from fastapi.testclient import TestClient + + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.proxy_server import app + + mock_prisma = MagicMock() + mock_config_table = MagicMock() + mock_config_table.find_first = AsyncMock(return_value=None) + mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table) + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + client = TestClient(app) + resp = client.get("/config/list", params={"config_type": "general_settings"}) + assert resp.status_code == 200, resp.text + fields = {item["field_name"]: item for item in resp.json()} + assert "skip_user_budget_on_team_key" in fields + assert fields["skip_user_budget_on_team_key"]["field_type"] == "Boolean" + finally: + app.dependency_overrides.clear() + + def test_get_config_list_includes_budget_exceeded_throttle_percentage(monkeypatch): """The throttle fraction is a litellm_settings scalar surfaced on the General Settings table as a Float field so it sits with the other global limits; it diff --git a/tests/test_litellm/proxy/test_route_llm_request.py b/tests/test_litellm/proxy/test_route_llm_request.py index 74a0efba43d..303871e3981 100644 --- a/tests/test_litellm/proxy/test_route_llm_request.py +++ b/tests/test_litellm/proxy/test_route_llm_request.py @@ -8,7 +8,7 @@ sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to from unittest.mock import MagicMock -from litellm.proxy.route_llm_request import route_request +from litellm.proxy.route_llm_request import ProxyModelNotFoundError, route_request @pytest.mark.parametrize( @@ -42,6 +42,200 @@ async def test_route_request_dynamic_credentials(route_type): getattr(llm_router, route_type).assert_called_once_with(**data) +@pytest.mark.asyncio +async def test_route_request_proxy_admin_can_call_all_team_scoped_deployments_without_team_id(): + import litellm + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + + router = litellm.Router( + model_list=[ + { + "model_name": "internal-team-azure-east", + "litellm_params": { + "model": "azure/gpt-4o", + "api_key": "fake", + "api_base": "https://east.example.openai.azure.com", + "api_version": "2024-02-15-preview", + "mock_response": "east", + }, + "model_info": { + "id": "team-azure-east", + "team_id": "team-a", + "team_public_model_name": "team-azure", + }, + }, + { + "model_name": "internal-team-azure-west", + "litellm_params": { + "model": "azure/gpt-4o", + "api_key": "fake", + "api_base": "https://west.example.openai.azure.com", + "api_version": "2024-02-15-preview", + "mock_response": "west", + }, + "model_info": { + "id": "team-azure-west", + "team_id": "team-a", + "team_public_model_name": "team-azure", + }, + }, + ] + ) + admin_auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + data = { + "model": "team-azure", + "messages": [{"role": "user", "content": "Hello"}], + "metadata": {"user_api_key_auth": admin_auth}, + } + + llm_call = await route_request( + data=data, + llm_router=router, + user_model=None, + route_type="acompletion", + user_api_key_dict=admin_auth, + ) + response = await llm_call + deployments = await router.async_get_healthy_deployments( + model="team-azure", + request_kwargs=data, + ) + + assert response.choices[0].message.content in {"east", "west"} + assert {deployment["model_info"]["id"] for deployment in deployments} == { + "team-azure-east", + "team-azure-west", + } + + non_admin_auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER) + with pytest.raises(ProxyModelNotFoundError): + await route_request( + data={ + **data, + "metadata": {"user_api_key_auth": non_admin_auth}, + }, + llm_router=router, + user_model=None, + route_type="acompletion", + user_api_key_dict=non_admin_auth, + ) + + from litellm.types.router import Deployment + + router.add_deployment( + Deployment( + model_name="internal-team-only", + litellm_params={ + "model": "azure/gpt-4o", + "api_key": "fake", + "api_base": "https://internal.example.openai.azure.com", + "api_version": "2024-02-15-preview", + }, + model_info={ + "id": "internal-team-only-id", + "team_id": "team-a", + }, + ) + ) + internal_deployments = await router.async_get_healthy_deployments( + model="internal-team-only", + request_kwargs={ + **data, + "model": "internal-team-only", + }, + ) + + assert {deployment["model_info"]["id"] for deployment in internal_deployments} == {"internal-team-only-id"} + + router.add_deployment( + Deployment( + model_name="internal-other-team-azure", + litellm_params={ + "model": "azure/gpt-4o", + "api_key": "fake", + "api_base": "https://other.example.openai.azure.com", + "api_version": "2024-02-15-preview", + "mock_response": "other", + }, + model_info={ + "id": "other-team-azure", + "team_id": "team-b", + "team_public_model_name": "team-azure", + }, + ) + ) + + with pytest.raises(litellm.BadRequestError, match="multiple teams"): + ambiguous_call = await route_request( + data=data, + llm_router=router, + user_model=None, + route_type="acompletion", + user_api_key_dict=admin_auth, + ) + await ambiguous_call + + router.add_deployment( + Deployment( + model_name="team-azure", + litellm_params={ + "model": "azure/gpt-4o", + "api_key": "fake", + "api_base": "https://legacy.example.openai.azure.com", + "api_version": "2024-02-15-preview", + }, + model_info={ + "id": "legacy-team-azure", + "team_id": "team-a", + "team_public_model_name": "team-azure", + }, + ) + ) + router.add_deployment( + Deployment( + model_name="team-azure", + litellm_params={ + "model": "azure/gpt-4o", + "api_key": "fake", + "api_base": "https://other-legacy.example.openai.azure.com", + "api_version": "2024-02-15-preview", + }, + model_info={ + "id": "other-legacy-team-azure", + "team_id": "team-b", + "team_public_model_name": "team-azure", + }, + ) + ) + + with pytest.raises(litellm.BadRequestError, match="multiple teams"): + await router.async_get_healthy_deployments( + model="team-azure", + request_kwargs=data, + ) + + router.add_deployment( + Deployment( + model_name="team-azure", + litellm_params={ + "model": "azure/gpt-4o", + "api_key": "fake", + "api_base": "https://global.example.openai.azure.com", + "api_version": "2024-02-15-preview", + }, + model_info={"id": "global-team-azure"}, + ) + ) + + collision_deployments = await router.async_get_healthy_deployments( + model="team-azure", + request_kwargs=data, + ) + + assert {deployment["model_info"]["id"] for deployment in collision_deployments} == {"global-team-azure"} + + @pytest.mark.asyncio async def test_route_request_no_model_required(): """Test route types that don't require model parameter""" diff --git a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_writes.py b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_writes.py index 4e547b81acc..dd241397e87 100644 --- a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_writes.py +++ b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_writes.py @@ -10,6 +10,7 @@ from __future__ import annotations import hashlib import json +import logging from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -103,6 +104,35 @@ async def test_insert_data_user_organization_fk_raises_400( assert raised.status_code == 400 +@pytest.mark.asyncio +async def test_insert_data_debug_log_hashes_token( + prisma_client: PrismaClient, caplog: pytest.LogCaptureFixture +) -> None: + """Regression for LIT-4356: the raw virtual key must never reach a logger, + even for short/nonstandard key formats that bypass the regex-based + SecretRedactionFilter.""" + token = "sk-short-secret" + expected_hash = hashlib.sha256(token.encode()).hexdigest() + prisma_client.db.litellm_verificationtoken.upsert = AsyncMock(return_value=SimpleNamespace(token=expected_hash)) + with caplog.at_level(logging.DEBUG, logger="LiteLLM Proxy"): + await prisma_client.insert_data(data={"token": token, "key_alias": "redaction-repro"}, table_name="key") + log_text = "\n".join(record.getMessage() for record in caplog.records) + assert token not in log_text + assert expected_hash in log_text + + +@pytest.mark.asyncio +async def test_insert_data_debug_log_tolerates_none_token( + prisma_client: PrismaClient, caplog: pytest.LogCaptureFixture +) -> None: + """A None token must not crash the redacting debug log added for LIT-4356.""" + prisma_client.db.litellm_usertable.upsert = AsyncMock(return_value=SimpleNamespace(user_id="u1")) + with caplog.at_level(logging.DEBUG, logger="LiteLLM Proxy"): + result = await prisma_client.insert_data(data={"user_id": "u1", "token": None}, table_name="user") + assert result.user_id == "u1" + assert any("insert_data" in record.getMessage() for record in caplog.records) + + @pytest.mark.asyncio async def test_insert_data_logs_and_raises_generic_error( prisma_client: PrismaClient, diff --git a/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py b/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py index cdace5f6327..24edf12fffe 100644 --- a/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py +++ b/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py @@ -39,8 +39,13 @@ def _output_item_added_chunk(): return SimpleNamespace(type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED) -def _completed_chunk(output): - response = ResponsesAPIResponse(id="resp-1", created_at=0, output=output) +def _created_chunk(response_id: str): + response = ResponsesAPIResponse(id=response_id, created_at=0, output=[]) + return SimpleNamespace(type=ResponsesAPIStreamEvents.RESPONSE_CREATED, response=response) + + +def _completed_chunk(output, response_id: str = "resp-1"): + response = ResponsesAPIResponse(id=response_id, created_at=0, output=output) return SimpleNamespace(type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, response=response) @@ -52,12 +57,12 @@ def _text_message(text: str): return {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text}]} -def _tool_call_stream(call_id: str, tool_name: str) -> _FakeAsyncStream: - return _FakeAsyncStream([_completed_chunk([_function_call(call_id, tool_name)])]) +def _tool_call_stream(call_id: str, tool_name: str, response_id: str = "resp-1") -> _FakeAsyncStream: + return _FakeAsyncStream([_completed_chunk([_function_call(call_id, tool_name)], response_id=response_id)]) -def _text_only_stream(text: str) -> _FakeAsyncStream: - return _FakeAsyncStream([_completed_chunk([_text_message(text)])]) +def _text_only_stream(text: str, response_id: str = "resp-1") -> _FakeAsyncStream: + return _FakeAsyncStream([_completed_chunk([_text_message(text)], response_id=response_id)]) def _mock_mcp_environment(monkeypatch: pytest.MonkeyPatch) -> AsyncMock: @@ -170,3 +175,85 @@ async def test_tool_call_rounds_are_capped(monkeypatch): for call in aresponses_mock.call_args_list[:-1]: assert "tools" in call.kwargs assert "tools" not in aresponses_mock.call_args_list[-1].kwargs + + +@pytest.mark.asyncio +async def test_continuation_id_is_final_round_not_interim_tool_call(monkeypatch): + """ + Regression test for the broken `previous_response_id` continuation after a + gateway-executed MCP tool call. Each auto-execute round is a distinct + upstream response: the interim round holds only the model's function_call + (no tool output), the final round holds the answer. The client must be + handed the FINAL round's response id, because that is the one whose stored + chain includes the function_call_output. Pinning every event to the interim + round's id made the next turn continue from a response with a dangling + function_call, which the provider rejects with + "No tool output found for function call ...". + """ + _mock_mcp_environment(monkeypatch) + + aresponses_mock = AsyncMock(side_effect=[_text_only_stream("The first item is Alpha.", response_id="resp-final")]) + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + iterator = _make_iterator( + [ + _created_chunk("resp-interim"), + _output_item_added_chunk(), + _completed_chunk([_function_call("call_1", "read_wiki_contents")], response_id="resp-interim"), + ] + ) + + chunks = [chunk async for chunk in iterator] + completed = [c for c in chunks if getattr(c, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED] + + assert completed[-1].response.output[0]["content"][0]["text"] == "The first item is Alpha." + assert completed[-1].response.id == "resp-final" + assert completed[-1].response.id != "resp-interim" + + +@pytest.mark.asyncio +async def test_follow_up_call_failure_emits_terminal_error_event(monkeypatch): + """ + Regression test for the silent-swallow path: when the follow-up LLM call + raises, the stream must emit a terminal `error` event instead of ending + silently after the tool events (which surfaced to clients as a successful + but empty completion). + """ + _mock_mcp_environment(monkeypatch) + + aresponses_mock = AsyncMock(side_effect=RuntimeError("boom from provider")) + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + iterator = _make_iterator( + [ + _output_item_added_chunk(), + _completed_chunk([_function_call("call_1", "read_wiki_contents")]), + ] + ) + + chunks = [chunk async for chunk in iterator] + error_events = [c for c in chunks if getattr(c, "type", None) == ResponsesAPIStreamEvents.ERROR] + + assert len(error_events) == 1 + assert error_events[0].error.type == "mcp_gateway_error" + assert "boom from provider" in error_events[0].error.message + + +@pytest.mark.asyncio +async def test_initial_call_failure_is_stashed_for_eager_reraise(monkeypatch): + """ + Regression test: a failing initial LLM call must be stashed as + `_initial_creation_error` so `aresponses_api_with_mcp` can re-raise it as a + real 4xx before any SSE bytes are written, instead of returning HTTP 200 + with an empty stream. + """ + _mock_mcp_environment(monkeypatch) + + aresponses_mock = AsyncMock(side_effect=RuntimeError("initial boom")) + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + iterator = _make_iterator([_output_item_added_chunk()]) + await iterator._create_initial_response_iterator() + + assert iterator._initial_creation_error is not None + assert "initial boom" in str(iterator._initial_creation_error) diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index da02b774e41..41dc7269372 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -14,13 +14,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from pydantic import ValidationError -sys.path.insert( - 0, os.path.abspath("../../..") -) # Adds the parent directory to the system path +sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path import litellm from litellm import Router from litellm._logging import verbose_router_logger +from litellm.caching.dual_cache import DualCache from litellm.router_strategy.complexity_router.complexity_router import ( ComplexityRouter, DimensionScore, @@ -130,9 +129,7 @@ class TestTokenScoring: tier, score, signals = complexity_router.classify("What is Python?") # Should be classified as SIMPLE due to short length and simple indicator assert tier == ComplexityTier.SIMPLE - assert any("short" in s.lower() for s in signals) or any( - "simple" in s.lower() for s in signals - ) + assert any("short" in s.lower() for s in signals) or any("simple" in s.lower() for s in signals) def test_long_prompt_positive_score(self, complexity_router): """Long prompts should get positive scores (complex indicator).""" @@ -143,9 +140,7 @@ class TestTokenScoring: tier, score, signals = complexity_router.classify(long_prompt) # Should have positive score and detect long token count or technical terms assert score > 0, f"Expected positive score for long prompt, got {score}" - assert any("long" in s.lower() for s in signals) or any( - "technical" in s.lower() for s in signals - ) + assert any("long" in s.lower() for s in signals) or any("technical" in s.lower() for s in signals) class TestCodePresenceScoring: @@ -220,9 +215,7 @@ class TestMultiStepPatterns: def test_first_then_pattern(self, complexity_router): """'First...then' patterns should increase complexity.""" - prompt = ( - "First analyze the data, then create a visualization, then write a report" - ) + prompt = "First analyze the data, then create a visualization, then write a report" tier, score, signals = complexity_router.classify(prompt) assert any("multi-step" in s.lower() for s in signals) @@ -266,9 +259,7 @@ class TestTierAssignment: ) tier, score, signals = complexity_router.classify(prompt) # Should detect technical terms - assert any( - "technical" in s.lower() for s in signals - ), f"Expected technical signals, got {signals}" + assert any("technical" in s.lower() for s in signals), f"Expected technical signals, got {signals}" # Score should be positive due to technical content assert score > 0, f"Expected positive score, got {score}" @@ -468,13 +459,9 @@ class TestConfigOverrides: complexity_router_config=config, ) # With very low thresholds, even neutral prompts should be COMPLEX or higher - tier, score, signals = router.classify( - "Explain how HTTP works with REST APIs and distributed systems" - ) + tier, score, signals = router.classify("Explain how HTTP works with REST APIs and distributed systems") # With boundaries this low, should be at least MEDIUM (anything above -0.5) - assert ( - tier != ComplexityTier.SIMPLE - ), f"Expected non-SIMPLE tier, got {tier} with score {score}" + assert tier != ComplexityTier.SIMPLE, f"Expected non-SIMPLE tier, got {tier} with score {score}" def test_custom_token_thresholds(self, mock_router_instance): """Test custom token thresholds work correctly.""" @@ -499,9 +486,7 @@ class TestConfigOverrides: long_prompt = "This is a test prompt " * 30 # ~120 tokens tier, score, signals = router.classify(long_prompt) # Should get token length signal indicating "long" - assert any( - "long" in s.lower() if s else False for s in signals - ), f"Expected 'long' signal, got {signals}" + assert any("long" in s.lower() if s else False for s in signals), f"Expected 'long' signal, got {signals}" class TestCustomTechnicalKeywords: @@ -516,9 +501,7 @@ class TestCustomTechnicalKeywords: ) assert router.technical_keywords == DEFAULT_TECHNICAL_KEYWORDS + ["udp", "kafka"] - def test_custom_keywords_appended_to_technical_keywords_override( - self, mock_router_instance - ): + def test_custom_keywords_appended_to_technical_keywords_override(self, mock_router_instance): """Custom keywords should be appended to a technical_keywords override.""" router = ComplexityRouter( model_name="test-router", @@ -535,9 +518,7 @@ class TestCustomTechnicalKeywords: router = ComplexityRouter( model_name="test-router", litellm_router_instance=mock_router_instance, - complexity_router_config={ - "custom_technical_keywords": ["TCP", "udp", "UDP", "kafka"] - }, + complexity_router_config={"custom_technical_keywords": ["TCP", "udp", "UDP", "kafka"]}, ) lowered = [kw.lower() for kw in router.technical_keywords] assert lowered == [kw.lower() for kw in DEFAULT_TECHNICAL_KEYWORDS] + [ @@ -560,9 +541,7 @@ class TestCustomTechnicalKeywords: assert router_absent.technical_keywords == DEFAULT_TECHNICAL_KEYWORDS assert router_none.technical_keywords == DEFAULT_TECHNICAL_KEYWORDS - def test_prompt_with_only_custom_keywords_scores_technical( - self, mock_router_instance, basic_config - ): + def test_prompt_with_only_custom_keywords_scores_technical(self, mock_router_instance, basic_config): """A prompt matching only custom keywords should score higher on technicalTerms.""" prompt = "Configure udp multicast between kafka brokers" baseline_router = ComplexityRouter( @@ -581,9 +560,7 @@ class TestCustomTechnicalKeywords: _, baseline_score, baseline_signals = baseline_router.classify(prompt) _, custom_score, custom_signals = custom_router.classify(prompt) assert not any("technical" in s.lower() for s in baseline_signals) - assert any( - "technical" in s.lower() for s in custom_signals - ), f"Expected technical signal, got {custom_signals}" + assert any("technical" in s.lower() for s in custom_signals), f"Expected technical signal, got {custom_signals}" assert custom_score > baseline_score @@ -776,9 +753,7 @@ class TestKeywordFalsePositives: prompt = "What is the capital of France?" tier, score, signals = complexity_router.classify(prompt) # Should NOT detect code presence from 'api' in 'capital' - assert not any( - "code" in s.lower() for s in signals - ), "False positive: got code signal from 'capital'" + assert not any("code" in s.lower() for s in signals), "False positive: got code signal from 'capital'" # Should be SIMPLE (definition question) assert tier == ComplexityTier.SIMPLE @@ -787,9 +762,7 @@ class TestKeywordFalsePositives: prompt = "Explain digital marketing strategies" tier, score, signals = complexity_router.classify(prompt) # Should NOT detect code presence from 'git' in 'digital' - assert not any( - "code" in s.lower() for s in signals - ), "False positive: got code signal from 'digital'" + assert not any("code" in s.lower() for s in signals), "False positive: got code signal from 'digital'" def test_try_not_in_entry(self, complexity_router): """'try' should not match in 'entry'.""" @@ -803,43 +776,33 @@ class TestKeywordFalsePositives: """'error' should not match in 'terrorism'.""" prompt = "The country is dealing with terrorism" tier, score, signals = complexity_router.classify(prompt) - assert not any( - "code" in s.lower() for s in signals - ), "False positive: got code signal from 'terrorism'" + assert not any("code" in s.lower() for s in signals), "False positive: got code signal from 'terrorism'" def test_class_not_in_classical(self, complexity_router): """'class' should not match in 'classical'.""" prompt = "I enjoy listening to classical music" tier, score, signals = complexity_router.classify(prompt) - assert not any( - "code" in s.lower() for s in signals - ), "False positive: got code signal from 'classical'" + assert not any("code" in s.lower() for s in signals), "False positive: got code signal from 'classical'" def test_merge_not_in_emerged(self, complexity_router): """'merge' should not match in 'emerged'.""" prompt = "A new leader emerged from the crowd" tier, score, signals = complexity_router.classify(prompt) - assert not any( - "code" in s.lower() for s in signals - ), "False positive: got code signal from 'emerged'" + assert not any("code" in s.lower() for s in signals), "False positive: got code signal from 'emerged'" def test_actual_api_keyword_detected(self, complexity_router): """Actual 'api' usage should be detected.""" prompt = "How do I call the REST api endpoint?" tier, score, signals = complexity_router.classify(prompt) # Should detect code presence from actual 'api' usage - assert any( - "code" in s.lower() for s in signals - ), f"Expected code signal for 'api', got {signals}" + assert any("code" in s.lower() for s in signals), f"Expected code signal for 'api', got {signals}" def test_actual_git_keyword_detected(self, complexity_router): """Actual 'git' usage should be detected.""" prompt = "How do I use git to commit changes?" tier, score, signals = complexity_router.classify(prompt) # Should detect code presence from actual 'git' usage - assert any( - "code" in s.lower() for s in signals - ), f"Expected code signal for 'git', got {signals}" + assert any("code" in s.lower() for s in signals), f"Expected code signal for 'git', got {signals}" class TestEdgeCases: @@ -859,9 +822,7 @@ class TestEdgeCases: # Should have positive score due to length assert score > 0, f"Expected positive score for very long prompt, got {score}" # Should detect long token count - assert any( - "long" in s.lower() for s in signals - ), f"Expected 'long' signal, got {signals}" + assert any("long" in s.lower() for s in signals), f"Expected 'long' signal, got {signals}" def test_unicode_prompt(self, complexity_router): """Test handling of unicode characters.""" @@ -879,9 +840,7 @@ class TestEdgeCases: """ tier, score, signals = complexity_router.classify(prompt) # The "step N" pattern should be detected - assert any( - "multi-step" in s.lower() for s in signals - ), f"Expected multi-step signal, got {signals}" + assert any("multi-step" in s.lower() for s in signals), f"Expected multi-step signal, got {signals}" class TestRouterComplexityDeploymentMethods: @@ -1019,9 +978,7 @@ class TestAsyncPreRoutingHookMultiFormat: assert result.messages is not None @pytest.mark.asyncio - async def test_should_route_with_responses_api_string_input( - self, complexity_router - ): + async def test_should_route_with_responses_api_string_input(self, complexity_router): """Test routing with Responses API string input via handler dispatch.""" from litellm.llms.openai.responses.guardrail_translation.handler import ( OpenAIResponsesHandler, @@ -1109,9 +1066,7 @@ class TestAsyncPreRoutingHookMultiFormat: assert result.model is not None @pytest.mark.asyncio - async def test_should_return_none_when_no_messages_or_input( - self, complexity_router - ): + async def test_should_return_none_when_no_messages_or_input(self, complexity_router): """Test that None is returned when neither messages nor input is available.""" result = await complexity_router.async_pre_routing_hook( model="test-model", @@ -1122,9 +1077,7 @@ class TestAsyncPreRoutingHookMultiFormat: assert result is None @pytest.mark.asyncio - async def test_should_prefer_original_messages_over_conversion( - self, complexity_router - ): + async def test_should_prefer_original_messages_over_conversion(self, complexity_router): """Test that original messages are used when both messages and input are available.""" messages = [{"role": "user", "content": "What is 2+2?"}] result = await complexity_router.async_pre_routing_hook( @@ -1136,9 +1089,7 @@ class TestAsyncPreRoutingHookMultiFormat: assert result.messages == messages @pytest.mark.asyncio - async def test_should_include_instructions_in_classification( - self, complexity_router - ): + async def test_should_include_instructions_in_classification(self, complexity_router): """Test that Responses API instructions influence classification via system message.""" from litellm.llms.openai.responses.guardrail_translation.handler import ( OpenAIResponsesHandler, @@ -1175,9 +1126,7 @@ class TestExtractUserMessageAndSystemPrompt: {"role": "assistant", "content": "Hi!"}, {"role": "user", "content": "How are you?"}, ] - user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt( - messages - ) + user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt(messages) assert user_msg == "How are you?" assert sys_prompt == "You are helpful." @@ -1187,9 +1136,7 @@ class TestExtractUserMessageAndSystemPrompt: {"role": "system", "content": "You are helpful."}, {"role": "assistant", "content": "Hi!"}, ] - user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt( - messages - ) + user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt(messages) assert user_msg is None assert sys_prompt == "You are helpful." @@ -1207,17 +1154,13 @@ class TestExtractUserMessageAndSystemPrompt: ], } ] - user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt( - messages - ) + user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt(messages) assert user_msg == "Describe this image" assert sys_prompt is None def test_should_handle_empty_messages(self): """Test with empty messages list.""" - user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt( - [] - ) + user_msg, sys_prompt = ComplexityRouter._extract_user_message_and_system_prompt([]) assert user_msg is None assert sys_prompt is None @@ -1282,17 +1225,13 @@ class TestLLMClassifier: assert tier == ComplexityTier.SIMPLE @pytest.mark.asyncio - async def test_aclassify_llm_success_routes_by_llm_verdict( - self, llm_complexity_router, mock_router_instance - ): + async def test_aclassify_llm_success_routes_by_llm_verdict(self, llm_complexity_router, mock_router_instance): """A well-formed structured LLM response should decide the tier directly. Uses a prompt that heuristic scoring alone would classify as SIMPLE, to prove the LLM verdict -- not the heuristic scorer -- is what decided the tier. """ - mock_router_instance.acompletion = AsyncMock( - return_value=_llm_response('{"tier": "COMPLEX"}') - ) + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "COMPLEX"}')) tier, score, signals = await llm_complexity_router.aclassify("hi") assert tier == ComplexityTier.COMPLEX assert "llm-classifier:COMPLEX" in signals @@ -1311,13 +1250,9 @@ class TestLLMClassifier: sees no user_api_key/team_id/user_id and silently drops all spend logging and budget accounting for the classifier call. """ - mock_router_instance.acompletion = AsyncMock( - return_value=_llm_response('{"tier": "SIMPLE"}') - ) + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}')) request_metadata = {"user_api_key": "sk-abc", "user_api_key_team_id": "team-1"} - await llm_complexity_router.aclassify( - "hi", request_kwargs={"litellm_metadata": request_metadata} - ) + await llm_complexity_router.aclassify("hi", request_kwargs={"litellm_metadata": request_metadata}) call_kwargs = mock_router_instance.acompletion.call_args.kwargs assert call_kwargs["metadata"] == request_metadata @@ -1333,18 +1268,14 @@ class TestLLMClassifier: business touching, so it must be stripped while the rest of the attribution metadata (key/team) is preserved. """ - mock_router_instance.acompletion = AsyncMock( - return_value=_llm_response('{"tier": "SIMPLE"}') - ) + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}')) request_metadata = { "user_api_key": "sk-abc", "user_api_key_team_id": "team-1", "user_api_key_budget_reservation": {"reserved_cost": 1.0}, "user_api_key_auth": {"models": ["gpt-4o"], "budget_reservation": {"reserved_cost": 1.0}}, } - await llm_complexity_router.aclassify( - "hi", request_kwargs={"litellm_metadata": request_metadata} - ) + await llm_complexity_router.aclassify("hi", request_kwargs={"litellm_metadata": request_metadata}) call_kwargs = mock_router_instance.acompletion.call_args.kwargs # user_api_key_budget_reservation is stripped (budget enforcement) while # user_api_key_auth is kept so _filter_deployments_by_model_access_groups @@ -1391,13 +1322,9 @@ class TestLLMClassifier: assert tier == ComplexityTier.SIMPLE @pytest.mark.asyncio - async def test_pre_routing_hook_uses_llm_classifier_end_to_end( - self, llm_complexity_router, mock_router_instance - ): + async def test_pre_routing_hook_uses_llm_classifier_end_to_end(self, llm_complexity_router, mock_router_instance): """The full pre-routing hook should route using the LLM classifier's verdict.""" - mock_router_instance.acompletion = AsyncMock( - return_value=_llm_response('{"tier": "REASONING"}') - ) + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "REASONING"}')) request_metadata = {"user_api_key": "sk-abc", "user_api_key_team_id": "team-1"} result = await llm_complexity_router.async_pre_routing_hook( model="test-model", @@ -1410,6 +1337,186 @@ class TestLLMClassifier: assert call_kwargs["metadata"] == request_metadata +class TestRouterPreRoutingAliasOverrides: + """ + Regression tests for: litellm_params configured on a complexity-router alias + entry (e.g. `cache_control_injection_points`, `drop_params`) were silently + dropped, because `async_pre_routing_hook` swaps `model` from the alias name + to the selected tier's model *before* the deployment lookup - so the actual + outbound call only ever merges in the tier deployment's own litellm_params, + never the alias's. + """ + + def _make_router(self) -> Router: + return Router( + model_list=[ + { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "drop_params": True, + "cache_control_injection_points": [{"location": "message", "role": "system"}], + "complexity_router_config": { + "tiers": { + "SIMPLE": "gpt-4o-mini", + "MEDIUM": "gpt-4o", + } + }, + "complexity_router_default_model": "gpt-4o", + }, + }, + { + "model_name": "gpt-4o-mini", + "litellm_params": {"model": "openai/gpt-4o-mini"}, + }, + { + "model_name": "gpt-4o", + "litellm_params": {"model": "openai/gpt-4o"}, + }, + ] + ) + + @pytest.mark.asyncio + async def test_alias_litellm_params_applied_to_request_kwargs(self): + """cache_control_injection_points/drop_params set on the alias entry + reach the outbound request even though the tier deployment is what + actually gets called.""" + router = self._make_router() + request_kwargs: Dict = {} + + result = await router.async_pre_routing_hook( + model="smart-router", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "hi"}], + ) + + assert result is not None + assert request_kwargs["drop_params"] is True + assert request_kwargs["cache_control_injection_points"] == [{"location": "message", "role": "system"}] + + @pytest.mark.asyncio + async def test_alias_overrides_exclude_only_model(self): + """`model` (the alias marker, e.g. auto_router/complexity_router) is + excluded since it's never a real provider model. Router-only fields + like complexity_router_config DO flow through into request_kwargs at + this layer - they're filtered from the actual outbound LLM call + downstream by litellm.types.utils.all_litellm_params instead, not by + the router's pre-routing hook. See test_router_init_only_params_are_ + never_sent_to_a_provider for the guard on that downstream filter.""" + router = self._make_router() + request_kwargs: Dict = {} + + await router.async_pre_routing_hook( + model="smart-router", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "hi"}], + ) + + assert "model" not in request_kwargs + assert request_kwargs["complexity_router_config"] == { + "tiers": { + "SIMPLE": "gpt-4o-mini", + "MEDIUM": "gpt-4o", + } + } + assert request_kwargs["complexity_router_default_model"] == "gpt-4o" + + def test_router_init_only_params_are_never_sent_to_a_provider(self): + """The router's pre-routing hook only excludes `model` (see + test_alias_overrides_exclude_only_model above) - every other alias + litellm_param, including router-init-only fields like + complexity_router_config, flows into request_kwargs unfiltered. That's + only safe because litellm.completion()/acompletion() itself strips + anything listed in all_litellm_params before building the provider + request. If one of these keys is ever removed from that list, it + ships raw to the real provider as extra_body - verified live via + litellm.completion(..., complexity_router_config={...}) landing in + extra_body before this list included it.""" + from litellm.types.utils import all_litellm_params + + router_init_only_params = ( + "auto_router_config_path", + "auto_router_config", + "auto_router_default_model", + "auto_router_embedding_model", + "complexity_router_config", + "complexity_router_default_model", + "adaptive_router_config", + "adaptive_router_default_model", + "quality_router_config", + "quality_router_default_model", + ) + for param in router_init_only_params: + assert param in all_litellm_params, ( + f"{param} must stay in litellm.types.utils.all_litellm_params - " + "removing it means it ships raw to the real provider as extra_body" + ) + + @pytest.mark.asyncio + async def test_caller_supplied_kwargs_are_not_overwritten(self): + """A value the caller already passed for this request takes + precedence over the alias's configured default.""" + router = self._make_router() + request_kwargs: Dict = {"drop_params": False} + + await router.async_pre_routing_hook( + model="smart-router", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "hi"}], + ) + + assert request_kwargs["drop_params"] is False + + @pytest.mark.asyncio + async def test_non_alias_model_is_untouched(self): + """A plain (non-router-alias) model name is not affected by the + alias-override merge at all.""" + router = self._make_router() + request_kwargs: Dict = {} + + result = await router.async_pre_routing_hook( + model="gpt-4o-mini", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "hi"}], + ) + + assert result is None + assert request_kwargs == {} + + @pytest.mark.asyncio + async def test_adaptive_router_alias_overrides_survive_reload(self): + """Alias litellm_params are read fresh from self.model_list at request + time (not cached at init), so a set_model_list() reload (e.g. + /config/reload) - which rebuilds self.model_list but leaves an + already-built AdaptiveRouter alone - can't leave them stale.""" + model_list = [ + { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/adaptive_router", + "drop_params": True, + "adaptive_router_config": {"available_models": ["gpt-4o-mini"]}, + }, + }, + { + "model_name": "gpt-4o-mini", + "litellm_params": {"model": "openai/gpt-4o-mini"}, + }, + ] + router = Router(model_list=model_list) + router.set_model_list(model_list) + assert "smart-router" in router.adaptive_routers + + request_kwargs: Dict = {} + await router.async_pre_routing_hook( + model="smart-router", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "hi"}], + ) + + assert request_kwargs["drop_params"] is True + + class TestAdaptiveSoftFloors: def test_adaptive_defaults_use_cost_weighted_cold_policy(self): config = ComplexityRouterConfig( @@ -1430,9 +1537,7 @@ class TestAdaptiveSoftFloors: "model": "openai/gpt-4o-mini", "input_cost_per_token": 0.00000015, }, - "model_info": { - "adaptive_router_preferences": {"quality_tier": 1, "strengths": []} - }, + "model_info": {"adaptive_router_preferences": {"quality_tier": 1, "strengths": []}}, }, { "model_name": "premium", @@ -1440,9 +1545,7 @@ class TestAdaptiveSoftFloors: "model": "openai/gpt-4o", "input_cost_per_token": 0.000005, }, - "model_info": { - "adaptive_router_preferences": {"quality_tier": 3, "strengths": []} - }, + "model_info": {"adaptive_router_preferences": {"quality_tier": 3, "strengths": []}}, }, ] router.model_name_to_deployment_indices = {"cheap": [0], "premium": [1]} @@ -1467,9 +1570,7 @@ class TestAdaptiveSoftFloors: with pytest.raises(ValidationError): ComplexityRouterConfig(adaptive=True, tiers={"SIMPLE": []}) - def test_cold_start_randomly_samples_unobserved_classified_tier_models( - self, adaptive_router_instance - ): + def test_cold_start_randomly_samples_unobserved_classified_tier_models(self, adaptive_router_instance): cr = ComplexityRouter( model_name="hybrid", litellm_router_instance=adaptive_router_instance, @@ -1498,9 +1599,7 @@ class TestAdaptiveSoftFloors: "premium", } - def test_get_model_for_tier_list_without_adaptive_random_choice( - self, mock_router_instance - ): + def test_get_model_for_tier_list_without_adaptive_random_choice(self, mock_router_instance): router = ComplexityRouter( model_name="test", litellm_router_instance=mock_router_instance, @@ -1519,9 +1618,7 @@ class TestAdaptiveSoftFloors: choice.assert_called_once_with(pool) assert router.get_model_for_tier(ComplexityTier.MEDIUM) == "mid" - def test_soft_floor_prefers_home_tier_when_posteriors_equal( - self, adaptive_router_instance, hybrid_config - ): + def test_soft_floor_prefers_home_tier_when_posteriors_equal(self, adaptive_router_instance, hybrid_config): from litellm.router_strategy.adaptive_router.bandit import BanditCell from litellm.types.router import RequestType @@ -1533,9 +1630,7 @@ class TestAdaptiveSoftFloors: adaptive = cr._ensure_adaptive_router() assert adaptive is not None for model in ("cheap", "premium"): - adaptive._cells[(RequestType.GENERAL, model)] = BanditCell( - alpha=5.0, beta=5.0 - ) + adaptive._cells[(RequestType.GENERAL, model)] = BanditCell(alpha=5.0, beta=5.0) # Equal quality samples; home-tier penalty should favor cheap for SIMPLE. with patch( @@ -1545,9 +1640,7 @@ class TestAdaptiveSoftFloors: picked = cr._soft_floor_pick(ComplexityTier.SIMPLE, "hi") assert picked == "cheap" - def test_soft_floor_allows_cross_tier_when_posterior_dominates( - self, adaptive_router_instance, hybrid_config - ): + def test_soft_floor_allows_cross_tier_when_posterior_dominates(self, adaptive_router_instance, hybrid_config): from litellm.router_strategy.adaptive_router.bandit import BanditCell from litellm.types.router import RequestType @@ -1558,12 +1651,8 @@ class TestAdaptiveSoftFloors: ) adaptive = cr._ensure_adaptive_router() assert adaptive is not None - adaptive._cells[(RequestType.GENERAL, "cheap")] = BanditCell( - alpha=1.0, beta=20.0 - ) - adaptive._cells[(RequestType.GENERAL, "premium")] = BanditCell( - alpha=20.0, beta=1.0 - ) + adaptive._cells[(RequestType.GENERAL, "cheap")] = BanditCell(alpha=1.0, beta=20.0) + adaptive._cells[(RequestType.GENERAL, "premium")] = BanditCell(alpha=20.0, beta=1.0) with patch( "litellm.router_strategy.adaptive_router.bandit.thompson_sample", @@ -1572,9 +1661,7 @@ class TestAdaptiveSoftFloors: picked = cr._soft_floor_pick(ComplexityTier.SIMPLE, "hi") assert picked == "premium" - def test_reused_model_has_zero_distance_in_each_configured_tier( - self, adaptive_router_instance - ): + def test_reused_model_has_zero_distance_in_each_configured_tier(self, adaptive_router_instance): from litellm.router_strategy.adaptive_router.bandit import BanditCell from litellm.types.router import RequestType @@ -1593,9 +1680,7 @@ class TestAdaptiveSoftFloors: adaptive = cr._ensure_adaptive_router() assert adaptive is not None for model in ("cheap", "premium"): - adaptive._cells[(RequestType.GENERAL, model)] = BanditCell( - alpha=6.0, beta=5.0 - ) + adaptive._cells[(RequestType.GENERAL, model)] = BanditCell(alpha=6.0, beta=5.0) request_kwargs: Dict = {"metadata": {}} with patch( @@ -1604,20 +1689,14 @@ class TestAdaptiveSoftFloors: ): cr._soft_floor_pick(ComplexityTier.MEDIUM, "hi", request_kwargs) - candidates = request_kwargs["metadata"]["adaptive_router_decision"][ - "candidates" - ] - assert { - candidate["model"]: candidate["tier_distance"] for candidate in candidates - } == { + candidates = request_kwargs["metadata"]["adaptive_router_decision"]["candidates"] + assert {candidate["model"]: candidate["tier_distance"] for candidate in candidates} == { "cheap": 0, "premium": 0, } @pytest.mark.asyncio - async def test_pre_routing_hook_adaptive_stashes_chosen_model( - self, adaptive_router_instance, hybrid_config - ): + async def test_pre_routing_hook_adaptive_stashes_chosen_model(self, adaptive_router_instance, hybrid_config): cr = ComplexityRouter( model_name="hybrid", litellm_router_instance=adaptive_router_instance, @@ -1631,10 +1710,7 @@ class TestAdaptiveSoftFloors: ) assert result is not None assert result.model in {"cheap", "premium"} - assert ( - request_kwargs["metadata"].get("adaptive_router_chosen_model") - == result.model - ) + assert request_kwargs["metadata"].get("adaptive_router_chosen_model") == result.model decision = request_kwargs["metadata"]["adaptive_router_decision"] assert decision["phase"] == "cold_start" assert decision["classified_tier"] == "SIMPLE" @@ -1657,9 +1733,7 @@ class TestLexicalKeywordTierRules: } @pytest.mark.asyncio - async def test_matching_rule_overrides_scoring( - self, mock_router_instance, rule_config - ): + async def test_matching_rule_overrides_scoring(self, mock_router_instance, rule_config): """A prompt hitting a rule keyword routes to that tier, not the scored tier.""" router = ComplexityRouter( model_name="test-router", @@ -1745,9 +1819,7 @@ class TestLexicalKeywordTierRules: assert router._lexical_tier_override("nothing relevant here") is None @pytest.mark.asyncio - async def test_no_rule_match_falls_back_to_scoring( - self, mock_router_instance, basic_config - ): + async def test_no_rule_match_falls_back_to_scoring(self, mock_router_instance, basic_config): """A prompt that matches no rule is classified by the scorer as usual.""" config = { **basic_config, @@ -1768,9 +1840,7 @@ class TestLexicalKeywordTierRules: assert result is not None assert result.model == "gpt-4o-mini" # SIMPLE via scoring, rule did not fire - def test_word_boundary_avoids_substring_false_positive( - self, mock_router_instance, basic_config - ): + def test_word_boundary_avoids_substring_false_positive(self, mock_router_instance, basic_config): """A single-word rule keyword must not match inside a larger word.""" config = { **basic_config, @@ -1788,10 +1858,7 @@ class TestLexicalKeywordTierRules: def _make_embedding_response(vectors: List[List[float]]) -> "litellm.EmbeddingResponse": return litellm.EmbeddingResponse( model="fake-embed", - data=[ - {"embedding": vec, "index": idx, "object": "embedding"} - for idx, vec in enumerate(vectors) - ], + data=[{"embedding": vec, "index": idx, "object": "embedding"} for idx, vec in enumerate(vectors)], object="list", ) @@ -1818,8 +1885,7 @@ class FakeEmbeddingRouter: def _vectors(self, docs: List[str]) -> List[List[float]]: return [ - [1.0, 0.0] if any(marker in doc.lower() for marker in self._CLUSTER_MARKERS) else [0.0, 1.0] - for doc in docs + [1.0, 0.0] if any(marker in doc.lower() for marker in self._CLUSTER_MARKERS) else [0.0, 1.0] for doc in docs ] @staticmethod @@ -2358,9 +2424,7 @@ class TestRoutingDecisionCauseLogging: verbose_router_logger.removeHandler(caplog.handler) @pytest.mark.asyncio - async def test_literal_keyword_match_logs_its_cause( - self, mock_router_instance, basic_config, router_log_capture - ): + async def test_literal_keyword_match_logs_its_cause(self, mock_router_instance, basic_config, router_log_capture): config = { **basic_config, "keyword_tier_rules": [{"keywords": ["deploy to k8s"], "tier": "REASONING"}], @@ -2406,9 +2470,7 @@ class TestRoutingDecisionCauseLogging: assert "cause=literal_keyword_match" not in router_log_capture.text @pytest.mark.asyncio - async def test_complexity_scorer_logs_its_cause( - self, mock_router_instance, basic_config, router_log_capture - ): + async def test_complexity_scorer_logs_its_cause(self, mock_router_instance, basic_config, router_log_capture): # No keyword rules -> the scorer decides, and its line must be tagged as such. router = ComplexityRouter( model_name="test-router", @@ -2424,3 +2486,464 @@ class TestRoutingDecisionCauseLogging: assert "score=" in router_log_capture.text assert "cause=literal_keyword_match" not in router_log_capture.text assert "cause=semantic_keyword_match" not in router_log_capture.text + + +class TestSessionAffinity: + """Test the opt-in session_affinity sticky-routing behavior.""" + + REASONING_MESSAGE = [ + { + "role": "user", + "content": "Let's think step by step and reason through this problem carefully.", + } + ] + SIMPLE_MESSAGE = [{"role": "user", "content": "Hello!"}] + + @pytest.fixture + def session_affinity_config(self, basic_config) -> Dict: + return {**basic_config, "session_affinity": True} + + @staticmethod + def _request_kwargs(session_id: str) -> Dict: + return {"metadata": {"session_id": session_id}} + + @pytest.mark.asyncio + async def test_disabled_by_default_reclassifies_every_turn(self, mock_router_instance, basic_config): + """Regression: session_affinity defaults to False, so a shared session_id must + not pin the model -- each turn is still classified independently.""" + mock_router_instance.cache = DualCache() + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config=basic_config, + ) + request_kwargs = self._request_kwargs("session-1") + first = await router.async_pre_routing_hook( + model="test-model", request_kwargs=request_kwargs, messages=self.REASONING_MESSAGE + ) + second = await router.async_pre_routing_hook( + model="test-model", request_kwargs=request_kwargs, messages=self.SIMPLE_MESSAGE + ) + assert first.model == "o1-preview" + assert second.model == "gpt-4o-mini" + + @pytest.mark.asyncio + async def test_pins_model_after_first_turn(self, mock_router_instance, session_affinity_config): + mock_router_instance.cache = DualCache() + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config=session_affinity_config, + ) + request_kwargs = self._request_kwargs("session-1") + first = await router.async_pre_routing_hook( + model="test-model", request_kwargs=request_kwargs, messages=self.REASONING_MESSAGE + ) + assert first.model == "o1-preview" + + with patch.object(router, "aclassify", wraps=router.aclassify) as spy_aclassify: + second = await router.async_pre_routing_hook( + model="test-model", request_kwargs=request_kwargs, messages=self.SIMPLE_MESSAGE + ) + spy_aclassify.assert_not_called() + # Pinned to the first turn's model, not re-classified down to SIMPLE. + assert second.model == "o1-preview" + + @pytest.mark.asyncio + async def test_different_sessions_classify_independently(self, mock_router_instance, session_affinity_config): + mock_router_instance.cache = DualCache() + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config=session_affinity_config, + ) + reasoning = await router.async_pre_routing_hook( + model="test-model", request_kwargs=self._request_kwargs("session-a"), messages=self.REASONING_MESSAGE + ) + simple = await router.async_pre_routing_hook( + model="test-model", request_kwargs=self._request_kwargs("session-b"), messages=self.SIMPLE_MESSAGE + ) + assert reasoning.model == "o1-preview" + assert simple.model == "gpt-4o-mini" + + @pytest.mark.asyncio + async def test_respects_ttl_seconds(self, mock_router_instance, basic_config): + cache = AsyncMock() + cache.async_get_cache = AsyncMock(return_value=None) + mock_router_instance.cache = cache + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + **basic_config, + "session_affinity": True, + "session_affinity_ttl_seconds": 120, + }, + ) + await router.async_pre_routing_hook( + model="test-model", request_kwargs=self._request_kwargs("session-1"), messages=self.SIMPLE_MESSAGE + ) + cache.async_set_cache.assert_called_once() + call_kwargs = cache.async_set_cache.call_args.kwargs + assert call_kwargs["ttl"] == 120 + assert call_kwargs["value"] == "gpt-4o-mini" + + @pytest.mark.asyncio + async def test_ttl_refreshed_on_cache_hit(self, mock_router_instance, basic_config): + """Regression: a pinned turn must refresh the TTL, not just the first write -- + otherwise a session outliving session_affinity_ttl_seconds silently loses its pin.""" + cache = AsyncMock() + cache.async_get_cache = AsyncMock(return_value="o1-preview") + mock_router_instance.cache = cache + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + **basic_config, + "session_affinity": True, + "session_affinity_ttl_seconds": 90, + }, + ) + result = await router.async_pre_routing_hook( + model="test-model", request_kwargs=self._request_kwargs("session-1"), messages=self.SIMPLE_MESSAGE + ) + assert result.model == "o1-preview" + cache.async_set_cache.assert_called_once() + call_kwargs = cache.async_set_cache.call_args.kwargs + assert call_kwargs["value"] == "o1-preview" + assert call_kwargs["ttl"] == 90 + + @pytest.mark.asyncio + async def test_different_api_keys_do_not_share_pin(self, mock_router_instance, session_affinity_config): + """A session_id is client-supplied and unauthenticated; two different callers + (API keys) reusing the same session_id must not poison each other's pin.""" + mock_router_instance.cache = DualCache() + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config=session_affinity_config, + ) + caller_a_kwargs = {"metadata": {"session_id": "shared-session", "user_api_key_hash": "key-a"}} + caller_b_kwargs = {"metadata": {"session_id": "shared-session", "user_api_key_hash": "key-b"}} + + pinned_for_a = await router.async_pre_routing_hook( + model="test-model", request_kwargs=caller_a_kwargs, messages=self.REASONING_MESSAGE + ) + assert pinned_for_a.model == "o1-preview" + + # Caller B reuses the same session_id but has a different API key; its trivial + # message must classify fresh, not inherit caller A's REASONING-tier pin. + result_for_b = await router.async_pre_routing_hook( + model="test-model", request_kwargs=caller_b_kwargs, messages=self.SIMPLE_MESSAGE + ) + assert result_for_b.model == "gpt-4o-mini" + + @pytest.mark.asyncio + async def test_no_session_id_falls_back_to_reclassify(self, mock_router_instance, session_affinity_config): + cache = AsyncMock() + mock_router_instance.cache = cache + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config=session_affinity_config, + ) + result = await router.async_pre_routing_hook( + model="test-model", request_kwargs={}, messages=self.SIMPLE_MESSAGE + ) + assert result.model == "gpt-4o-mini" + cache.async_get_cache.assert_not_called() + cache.async_set_cache.assert_not_called() + + @pytest.mark.asyncio + async def test_adaptive_pinned_turn_still_stamps_chosen_model_metadata(self, mock_router_instance): + """Regression: skipping classification on a pinned turn must not break the + adaptive bandit's reward-feedback loop, which only records a turn's outcome + when ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY is present in the request metadata.""" + mock_router_instance.cache = DualCache() + mock_router_instance.model_list = [ + { + "model_name": "cheap", + "litellm_params": {"model": "openai/gpt-4o-mini", "input_cost_per_token": 0.0}, + "model_info": {}, + }, + ] + mock_router_instance.model_name_to_deployment_indices = {"cheap": [0]} + router = ComplexityRouter( + model_name="hybrid", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "adaptive": True, + "session_affinity": True, + "tiers": { + "SIMPLE": ["cheap"], + "MEDIUM": ["cheap"], + "COMPLEX": ["cheap"], + "REASONING": ["cheap"], + }, + "default_model": "cheap", + }, + ) + first = await router.async_pre_routing_hook( + model="hybrid", + request_kwargs=self._request_kwargs("session-1"), + messages=[{"role": "user", "content": "hi"}], + ) + assert first.model == "cheap" + + request_kwargs_2 = self._request_kwargs("session-1") + with patch.object(router, "aclassify", wraps=router.aclassify) as spy_aclassify: + second = await router.async_pre_routing_hook( + model="hybrid", + request_kwargs=request_kwargs_2, + messages=[{"role": "user", "content": "hi again"}], + ) + spy_aclassify.assert_not_called() + assert second.model == "cheap" + assert request_kwargs_2["metadata"]["adaptive_router_chosen_model"] == "cheap" + + +class _DummyPlugin: + async def run(self, context): + return context + + +class TestRoutingPlugins: + """Test the `complexity_router_config.plugins` field: narrows the classified + tier's candidate pool before a model is picked. Discussion: + https://github.com/BerriAI/litellm/discussions/32168""" + + @pytest.mark.asyncio + async def test_plugin_narrows_tier_candidates(self, mock_router_instance): + class ExcludeGpt4oMini: + async def run(self, context): + context.candidate_models = [m for m in context.candidate_models if m != "gpt-4o-mini"] + return context + + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": ["gpt-4o-mini", "gpt-4o-nano"]}, + "plugins": [ExcludeGpt4oMini()], + }, + ) + result = await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "hi"}], + ) + assert result is not None + assert result.model == "gpt-4o-nano" + + @pytest.mark.asyncio + async def test_plugin_narrowing_to_zero_raises_even_with_default_model_configured(self, mock_router_instance): + """Regression: default_model must never be used as an escape hatch around a + plugin's narrowing decision -- it was never checked against the plugins, so + falling back to it would let a tenant/budget policy be silently bypassed. + Reported by Veria AI on PR #33251.""" + + class BlockEverything: + async def run(self, context): + context.candidate_models = [] + return context + + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": "gpt-4o-mini"}, + "default_model": "gpt-4o-fallback", + "plugins": [BlockEverything()], + }, + ) + with pytest.raises(ValueError, match="No candidate models left for tier"): + await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "hi"}], + ) + + @pytest.mark.asyncio + async def test_plugin_narrowing_to_zero_without_default_model_raises(self, mock_router_instance): + class BlockEverything: + async def run(self, context): + context.candidate_models = [] + return context + + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": "gpt-4o-mini"}, + "plugins": [BlockEverything()], + }, + ) + with pytest.raises(ValueError, match="No candidate models left for tier"): + await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "hi"}], + ) + + @pytest.mark.asyncio + async def test_plugin_receives_metadata_from_request_kwargs(self, mock_router_instance): + captured = {} + + class CaptureMetadata: + async def run(self, context): + captured.update(context.metadata) + return context + + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": "gpt-4o-mini"}, + "plugins": [CaptureMetadata()], + }, + ) + await router.async_pre_routing_hook( + model="test-model", + request_kwargs={"metadata": {"tenant": "acme-corp"}}, + messages=[{"role": "user", "content": "hi"}], + ) + assert captured.get("tenant") == "acme-corp" + + @pytest.mark.asyncio + async def test_plugin_applies_to_keyword_tier_override(self, mock_router_instance): + """A policy plugin must not be bypassable via the keyword_tier_rules override path.""" + + class ExcludeGpt4oMini: + async def run(self, context): + context.candidate_models = [m for m in context.candidate_models if m != "gpt-4o-mini"] + return context + + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": ["gpt-4o-mini", "gpt-4o-nano"]}, + "keyword_tier_rules": [{"keywords": ["hello"], "tier": "SIMPLE"}], + "plugins": [ExcludeGpt4oMini()], + }, + ) + result = await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "hello there"}], + ) + assert result is not None + assert result.model == "gpt-4o-nano" + + @pytest.mark.asyncio + async def test_plugin_applies_to_no_user_message_default_tier_path(self, mock_router_instance): + """Regression: `self.config.default_model or await self._pick_model_for_tier(...)` + short-circuited on a truthy default_model, so the no-user-message path never ran + the plugin pipeline at all when default_model was configured. A policy plugin + must not be bypassable via this path either. Reported by Veria AI on PR #33251.""" + + class ExcludeDefaultModel: + async def run(self, context): + context.candidate_models = [m for m in context.candidate_models if m != "gpt-4o-default"] + return context + + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"MEDIUM": ["gpt-4o-default", "gpt-4o-nano"]}, + "default_model": "gpt-4o-default", + "plugins": [ExcludeDefaultModel()], + }, + ) + result = await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[ + {"role": "system", "content": "You are helpful."}, + {"role": "assistant", "content": "Hello!"}, + ], + ) + assert result is not None + assert result.model == "gpt-4o-nano" + + @pytest.mark.asyncio + async def test_no_user_message_prefers_default_model_over_medium_tier_without_plugins( + self, mock_router_instance + ): + """Regression: without plugins configured, the no-user-message path must keep its + pre-existing default_model-first priority over the MEDIUM tier exactly as before -- + closing the plugin-bypass gap must not silently flip model selection for the (much + larger) population of users who don't use plugins at all. Flagged by Greptile on + PR #33251 after the plugin-bypass fix changed this priority unconditionally.""" + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"MEDIUM": ["gpt-4o-medium-tier"]}, + "default_model": "gpt-4o-configured-default", + }, + ) + result = await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[ + {"role": "system", "content": "You are helpful."}, + {"role": "assistant", "content": "Hello!"}, + ], + ) + assert result is not None + assert result.model == "gpt-4o-configured-default" + + def test_plugins_and_adaptive_together_raises(self): + with pytest.raises(ValidationError, match="plugins and adaptive=True cannot both be set"): + ComplexityRouterConfig( + tiers={"SIMPLE": ["gpt-4o-mini"]}, + adaptive=True, + plugins=[_DummyPlugin()], + ) + + @pytest.mark.asyncio + async def test_no_plugins_configured_is_unaffected(self, complexity_router): + """Regression guard: a ComplexityRouter with no `plugins` configured behaves exactly as before.""" + result = await complexity_router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "Hello!"}], + ) + assert result is not None + + @pytest.mark.asyncio + async def test_session_affinity_pin_shortcut_disabled_when_plugins_configured(self, mock_router_instance): + """Regression: the session_affinity cache-pin shortcut returned a stale pinned + model without ever re-running it through plugins, so a policy plugin's decision + (e.g. a budget cap crossed mid-session) was only ever enforced on a session's + first turn. With plugins configured, every turn must go through + _classify_and_route (and therefore the plugin pipeline) again.""" + mock_router_instance.cache = DualCache() + + class AllowAll: + async def run(self, context): + return context + + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": ["gpt-4o-mini"]}, + "session_affinity": True, + "plugins": [AllowAll()], + }, + ) + request_kwargs = {"metadata": {"session_id": "session-1"}} + + with patch.object(router, "_classify_and_route", wraps=router._classify_and_route) as spy: + first = await router.async_pre_routing_hook( + model="test-model", request_kwargs=request_kwargs, messages=[{"role": "user", "content": "hi"}] + ) + second = await router.async_pre_routing_hook( + model="test-model", request_kwargs=request_kwargs, messages=[{"role": "user", "content": "hi again"}] + ) + assert first.model == "gpt-4o-mini" + assert second.model == "gpt-4o-mini" + assert spy.call_count == 2 diff --git a/tests/test_litellm/router_strategy/test_router_routing_plugins.py b/tests/test_litellm/router_strategy/test_router_routing_plugins.py index e9c12d009e2..78ed71f5ffd 100644 --- a/tests/test_litellm/router_strategy/test_router_routing_plugins.py +++ b/tests/test_litellm/router_strategy/test_router_routing_plugins.py @@ -218,3 +218,36 @@ def test_filter_by_routing_plugin_candidates_narrows_and_raises_when_empty(): healthy_deployments=healthy_deployments, request_kwargs={"metadata": {"_routing_plugin_candidate_models": ["nonexistent/model"]}}, ) + + +def test_json_default_stable_id_is_stable_across_instances(): + """_generate_model_id's json.dumps `default=` fallback must not embed an object's + memory address (e.g. plain str() on an object with no custom __repr__ falls back + to object.__repr__'s ``) -- that would make the + deployment id churn on every process restart for any deployment whose + litellm_params contain a live plugin instance.""" + router = Router(model_list=_smart_router_model_list()) + + assert router._json_default_stable_id(LanguageDetector()) == router._json_default_stable_id(LanguageDetector()) + assert router._json_default_stable_id(LanguageDetector()) != router._json_default_stable_id(TenantPolicy()) + + +def test_generate_model_id_is_stable_when_litellm_params_contain_a_plugin_instance(): + """End-to-end: a deployment id built from litellm_params containing a routing + plugin instance (e.g. complexity_router_config.plugins) must be identical across + separate calls, not just non-crashing.""" + router = Router(model_list=_smart_router_model_list()) + litellm_params = { + "model": "auto_router/complexity_router", + "complexity_router_config": {"plugins": [LanguageDetector()]}, + } + + id1 = router._generate_model_id("smart-router", litellm_params) + id2 = router._generate_model_id( + "smart-router", + { + "model": "auto_router/complexity_router", + "complexity_router_config": {"plugins": [LanguageDetector()]}, + }, + ) + assert id1 == id2 diff --git a/tests/test_litellm/test_claude_opus_4_8_config.py b/tests/test_litellm/test_claude_opus_4_8_config.py index 32f7d249e05..8eead8a9c84 100644 --- a/tests/test_litellm/test_claude_opus_4_8_config.py +++ b/tests/test_litellm/test_claude_opus_4_8_config.py @@ -95,6 +95,8 @@ def test_opus_4_8_model_pricing_and_capabilities(): assert info["supports_tool_choice"] is True assert info["supports_vision"] is True + assert model_data["claude-opus-4-8"]["supports_native_structured_output"] is True + def test_opus_4_8_bedrock_regional_model_pricing(): model_data = _load_root_cost_map() @@ -165,6 +167,7 @@ def test_opus_4_8_present_in_bundled_backup(): "azure_ai/claude-opus-4-8", ): assert model_name in backup, f"Missing from backup cost map: {model_name}" + assert backup["claude-opus-4-8"]["supports_native_structured_output"] is True def test_opus_4_8_registered_for_bedrock_converse(): diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 28cf4fa0744..4611aafa3c1 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -2081,3 +2081,79 @@ def test_stream_chunk_builder_text_completion_combines_text_and_usage(): assert response.usage.prompt_tokens > 0 assert response.usage.completion_tokens > 0 assert response.usage.total_tokens == response.usage.prompt_tokens + response.usage.completion_tokens + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "aws_credential_kwargs", + [ + { + "aws_session_name": "litellm-gcp", + "aws_role_name": "arn:aws:iam::123456789012:role/litellm-bedrock-role", + "aws_web_identity_token": "oidc/google/108963886734710037768", + }, + { + "aws_access_key_id": "AKIASTATICKEYFORTEST", + "aws_secret_access_key": "static-secret-key", + "aws_session_token": "static-session-token", + }, + ], + ids=["web_identity", "static_keys"], +) +async def test_acompletion_forwards_aws_credentials_through_responses_bridge( + respx_mock: respx.MockRouter, monkeypatch, aws_credential_kwargs: dict +): + from botocore.credentials import Credentials + + from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM + + original_disable_aiohttp = litellm.disable_aiohttp_transport + try: + litellm.disable_aiohttp_transport = True + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + litellm.in_memory_llm_clients_cache.flush_cache() + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) + monkeypatch.delenv("BEDROCK_MANTLE_API_KEY", raising=False) + + get_credentials_mock = MagicMock(return_value=Credentials("fake-key", "fake-secret")) + monkeypatch.setattr(BaseAWSLLM, "get_credentials", get_credentials_mock) + + respx_mock.post("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses").respond( + json={ + "id": "resp_123", + "object": "response", + "created_at": 1760144904, + "status": "completed", + "model": "openai.gpt-5.4", + "output": [ + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "ok", "annotations": []}], + } + ], + } + ) + + response = await litellm.acompletion( + model="bedrock_mantle/openai.gpt-5.4", + messages=[{"role": "user", "content": "hi"}], + api_base="https://bedrock-mantle.us-east-2.api.aws/v1", + aws_region_name="us-east-2", + num_retries=0, + **aws_credential_kwargs, + ) + + assert response.choices[0].message.content == "ok" + credential_kwargs = get_credentials_mock.call_args.kwargs + assert credential_kwargs["aws_region_name"] == "us-east-2" + for key, value in aws_credential_kwargs.items(): + assert credential_kwargs[key] == value + authorization = respx_mock.calls.last.request.headers["Authorization"] + assert authorization.startswith("AWS4-HMAC-SHA256") + assert "fake-key" in authorization + finally: + litellm.disable_aiohttp_transport = original_disable_aiohttp + litellm.in_memory_llm_clients_cache.flush_cache() diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 1a5c419754b..6d515ecdc73 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -615,6 +615,8 @@ def validate_model_cost_values(model_data, exceptions=None): "input_cost_per_audio_token", "output_cost_per_audio_token", "output_cost_per_image_token", + "input_cost_per_video_token", + "output_cost_per_video_token", "input_cost_per_audio_per_second", "input_cost_per_video_per_second", "input_cost_per_token_above_128k_tokens", @@ -732,6 +734,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "input_cost_per_image": {"type": "number"}, "input_cost_per_image_above_128k_tokens": {"type": "number"}, "input_cost_per_image_token": {"type": "number"}, + "input_cost_per_video_token": {"type": "number"}, "input_cost_per_token_above_200k_tokens": {"type": "number"}, "input_cost_per_token_above_256k_tokens": {"type": "number"}, "input_cost_per_token_above_272k_tokens": {"type": "number"}, @@ -807,6 +810,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "output_cost_per_character_above_128k_tokens": {"type": "number"}, "output_cost_per_image": {"type": "number"}, "output_cost_per_image_token": {"type": "number"}, + "output_cost_per_video_token": {"type": "number"}, "output_cost_per_pixel": {"type": "number"}, "output_cost_per_second": {"type": "number"}, "output_cost_per_second_1080p": {"type": "number"}, diff --git a/ui/litellm-dashboard/e2e_tests/helpers/navigation.ts b/ui/litellm-dashboard/e2e_tests/helpers/navigation.ts index 6b5e7f7bb8d..a58ece16f9c 100644 --- a/ui/litellm-dashboard/e2e_tests/helpers/navigation.ts +++ b/ui/litellm-dashboard/e2e_tests/helpers/navigation.ts @@ -41,7 +41,7 @@ export async function dismissFeedbackPopup(page: PlaywrightPage): Promise /** * Click on a team ID in the table. Team IDs are rendered differently depending * on the component version — try button first (Tremor Button), fall back to - * clickable span (OldTeams Typography.Text). + * clickable span (Teams Typography.Text). */ export async function clickTeamId(page: PlaywrightPage, teamId: string): Promise { const cell = page.locator("td").filter({ hasText: teamId }).first(); diff --git a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/teams.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/teams.spec.ts index b30bb8aca7b..e7f67d7367f 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/teams.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/teams.spec.ts @@ -96,7 +96,9 @@ test.describe("Proxy Admin - Teams", () => { const teamRow = page.locator("tr", { hasText: E2E_TEAM_DELETE_ALIAS }).first(); await expect(teamRow).toBeVisible({ timeout: 10_000 }); - await teamRow.locator("svg, img").last().click(); + // Actions live in a kebab menu: open it, then click "Delete team". + await teamRow.locator('[data-testid^="team-actions-"]').click(); + await page.getByTestId("team-action-delete").click(); const modal = page.locator(".ant-modal:visible"); await expect(modal).toBeVisible({ timeout: 5_000 }); diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 3f6f4da25d0..05ad536b260 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -301,17 +301,6 @@ "count": 1 } }, - "src/app/(dashboard)/guardrails/_components/edit_guardrail_form.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/guardrails/_components/guardrail_info.tsx": { "max-params": { "count": 1 @@ -339,14 +328,6 @@ "count": 1 } }, - "src/app/(dashboard)/guardrails/_components/guardrail_table.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx": { "no-restricted-imports": { "count": 1 @@ -515,6 +496,152 @@ "count": 2 } }, + "src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx": { + "unused-imports/no-unused-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": { + "react-hooks/immutability": { + "count": 2 + } + }, + "src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx": { + "no-nested-ternary": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + }, + "unused-imports/no-unused-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": { + "no-nested-ternary": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": { + "no-nested-ternary": { + "count": 3 + }, + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": { + "no-nested-ternary": { + "count": 2 + } + }, + "src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx": { + "no-nested-ternary": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 4 + } + }, + "src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx": { + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/static-components": { + "count": 4 + } + }, + "src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx": { + "no-nested-ternary": { + "count": 3 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": { + "react-hooks/set-state-in-effect": { + "count": 2 + } + }, + "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": { + "no-nested-ternary": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/immutability": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 5 + } + }, + "src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": { + "no-nested-ternary": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 2 + } + }, + "src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": { + "no-nested-ternary": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 2 + } + }, "src/app/(dashboard)/memory/_components/MemoryView.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -1034,11 +1161,6 @@ "count": 1 } }, - "src/app/(dashboard)/tag-management/_components/TagTable.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx": { "no-restricted-imports": { "count": 1 @@ -1400,22 +1522,6 @@ "count": 1 } }, - "src/components/OldTeams.test.tsx": { - "max-nested-callbacks": { - "count": 4 - } - }, - "src/components/OldTeams.tsx": { - "no-nested-ternary": { - "count": 4 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 4 - } - }, "src/components/SCIM.tsx": { "no-restricted-imports": { "count": 1 @@ -1506,6 +1612,22 @@ "count": 1 } }, + "src/components/Teams.test.tsx": { + "max-nested-callbacks": { + "count": 4 + } + }, + "src/components/Teams.tsx": { + "no-nested-ternary": { + "count": 2 + }, + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 3 + } + }, "src/components/ToolDetail.tsx": { "unused-imports/no-unused-imports": { "count": 2 @@ -1544,14 +1666,6 @@ "count": 1 } }, - "src/components/VirtualKeysPage/VirtualKeysTable.tsx": { - "no-nested-ternary": { - "count": 2 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/activity_metrics.tsx": { "no-nested-ternary": { "count": 1 @@ -1796,11 +1910,6 @@ "count": 1 } }, - "src/components/common_components/user_search_modal.tsx": { - "react-hooks/use-memo": { - "count": 1 - } - }, "src/components/constants.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -1860,45 +1969,11 @@ "count": 1 } }, - "src/components/mcp_tools/ByokCredentialModal.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx": { - "unused-imports/no-unused-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": { - "react-hooks/immutability": { - "count": 2 - } - }, - "src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/components/mcp_tools/MCPToolArgumentsForm.tsx": { "no-nested-ternary": { "count": 5 } }, - "src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - }, - "unused-imports/no-unused-imports": { - "count": 2 - } - }, "src/components/mcp_tools/McpCrudPermissionPanel.tsx": { "no-nested-ternary": { "count": 3 @@ -1907,123 +1982,6 @@ "count": 1 } }, - "src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": { - "no-nested-ternary": { - "count": 3 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": { - "no-nested-ternary": { - "count": 2 - } - }, - "src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 4 - } - }, - "src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/static-components": { - "count": 4 - } - }, - "src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx": { - "no-nested-ternary": { - "count": 3 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": { - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, - "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/immutability": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 5 - } - }, - "src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, - "src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, "src/components/model_add/AddCredentialModal.tsx": { "no-restricted-imports": { "count": 1 @@ -2117,9 +2075,6 @@ "src/components/molecules/filter.tsx": { "no-nested-ternary": { "count": 2 - }, - "react-hooks/use-memo": { - "count": 1 } }, "src/components/molecules/models/columns.test.tsx": { @@ -2181,9 +2136,6 @@ }, "react-hooks/set-state-in-effect": { "count": 4 - }, - "react-hooks/use-memo": { - "count": 1 } }, "src/components/organization/organization_view.tsx": { @@ -2545,4 +2497,4 @@ "count": 1 } } -} +} \ No newline at end of file diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index b8f6265441b..91cf705060e 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -14,7 +14,7 @@ "@base-ui/react": "^1.6.0", "@headlessui/tailwindcss": "0.2.2", "@heroicons/react": "1.0.6", - "@tanstack/react-pacer": "0.2.0", + "@tanstack/react-pacer": "0.22.1", "@tanstack/react-query": "5.100.7", "@tanstack/react-table": "8.21.3", "@tremor/react": "3.18.7", @@ -51,7 +51,6 @@ "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", - "@types/lodash": "4.17.23", "@types/node": "20.19.37", "@types/react": "18.2.48", "@types/react-copy-to-clipboard": "5.0.7", @@ -3619,11 +3618,31 @@ "tailwindcss": "4.3.2" } }, - "node_modules/@tanstack/pacer": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@tanstack/pacer/-/pacer-0.2.0.tgz", - "integrity": "sha512-fUJs3NpSwtAL/tfq8kuYdgvm9HbbJvHsOG6aHY2dFDfff0NBFNwjvyGreWZZRPs2zgoIbr4nOk+rRV7aQgmf+A==", + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.4.tgz", + "integrity": "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==", "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer/-/pacer-0.21.1.tgz", + "integrity": "sha512-hB01dd4rlsYcTCNP7wK186jgAe6K5qimgM1Y5Jtvz+9PUaILvpmeLLjmQNUNSO1l23lIt+CeQR6mO1mjlPvRtQ==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.3", + "@tanstack/store": "^0.11.0" + }, "engines": { "node": ">=18" }, @@ -3643,12 +3662,13 @@ } }, "node_modules/@tanstack/react-pacer": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-pacer/-/react-pacer-0.2.0.tgz", - "integrity": "sha512-KU5GtjkKSeNdYCilen5Dc+Pu/6BPQbsQshKrUUjrg7URyJIiGBCz6ZZFre1QjDz/aeUeqUJWMWSm+2Dsh64v+w==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-pacer/-/react-pacer-0.22.1.tgz", + "integrity": "sha512-CenQqK0GluSPIrnsG1yuD7w5uMSQ/4lI9AcGEFxBrRd66r260boWcYRIsS5+eHtXb238FoZYhKmJPGlhRzmHRw==", "license": "MIT", "dependencies": { - "@tanstack/pacer": "0.2.0" + "@tanstack/pacer": "0.21.1", + "@tanstack/react-store": "^0.11.0" }, "engines": { "node": ">=18" @@ -3678,6 +3698,24 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-store": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.0.tgz", + "integrity": "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.0", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -3715,6 +3753,16 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/store": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.0.tgz", + "integrity": "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -4060,13 +4108,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 759fc8fb2bc..7aea571ea8b 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -30,7 +30,7 @@ "@base-ui/react": "^1.6.0", "@headlessui/tailwindcss": "0.2.2", "@heroicons/react": "1.0.6", - "@tanstack/react-pacer": "0.2.0", + "@tanstack/react-pacer": "0.22.1", "@tanstack/react-query": "5.100.7", "@tanstack/react-table": "8.21.3", "@tremor/react": "3.18.7", @@ -67,7 +67,6 @@ "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", - "@types/lodash": "4.17.23", "@types/node": "20.19.37", "@types/react": "18.2.48", "@types/react-copy-to-clipboard": "5.0.7", @@ -95,7 +94,6 @@ "js-yaml": "4.2.0", "glob": "13.0.0", "minimatch": "10.2.4", - "lodash": "4.18.1", "ws": "8.21.0", "braces": "3.0.3", "axios": "1.13.6", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.test.tsx index dc70aaa409b..4ee6332c54e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "@/../tests/test-utils"; import AgentCardDiscovery from "./agent_card_discovery"; @@ -243,6 +243,54 @@ describe("AgentCardDiscovery", () => { expect(selection.selected_card.capabilities.streaming).toBe(false); }); + it("does not fire discovery before the debounce wait and fires once with the last URL", async () => { + mockDiscover.mockResolvedValue({ + url: "https://last.example.com", + agent_card: sampleCard, + }); + renderWithProviders(); + const input = screen.getByPlaceholderText("https://upstream-agent.example.com"); + + act(() => { + fireEvent.change(input, { target: { value: "https://first.example.com" } }); + }); + act(() => { + vi.advanceTimersByTime(399); + }); + expect(mockDiscover).not.toHaveBeenCalled(); + + act(() => { + fireEvent.change(input, { target: { value: "https://last.example.com" } }); + }); + act(() => { + vi.advanceTimersByTime(399); + }); + expect(mockDiscover).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(mockDiscover).toHaveBeenCalledTimes(1); + expect(mockDiscover).toHaveBeenCalledWith("tok", "https://last.example.com", undefined); + }); + + it("fires no discovery when unmounted mid-wait", () => { + const { unmount } = renderWithProviders(); + const input = screen.getByPlaceholderText("https://upstream-agent.example.com"); + + act(() => { + fireEvent.change(input, { target: { value: "https://first.example.com" } }); + }); + act(() => { + vi.advanceTimersByTime(200); + }); + unmount(); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(mockDiscover).not.toHaveBeenCalled(); + }); + it("blocks discover when no access token is provided", async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.tsx index 5ea7458f643..e979b2dbe3f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_card_discovery.tsx @@ -11,6 +11,7 @@ import { SearchOutlined, } from "@ant-design/icons"; +import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { DiscoveredAgentCard, discoverAgentCardCall } from "@/components/networking"; import { ALLOWED_CAPABILITY_KEYS, @@ -22,6 +23,8 @@ import { const { Text, Paragraph } = Typography; const { Panel } = Collapse; +const DISCOVERY_DEBOUNCE_WAIT_MS = 400; + export interface DiscoveredAgentCardSelection { /** Full upstream card the proxy fetched, unmodified. */ raw_card: DiscoveredAgentCard; @@ -171,6 +174,14 @@ const AgentCardDiscovery: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [accessToken, effectiveUrl, isParentDriven, discoveryMode, discoveryParamsKey]); + const debouncedDiscover = useDebouncedCallback( + () => { + if (!accessToken || !effectiveUrl.trim()) return; + void handleDiscover(); + }, + { wait: DISCOVERY_DEBOUNCE_WAIT_MS }, + ); + // Auto-discover when the URL (or parent plan) becomes available. Debounce // is applied uniformly so rapid changes from a watched parent form (e.g. // typing into a LangGraph api_base / assistant_id field) don't fire one @@ -186,11 +197,8 @@ const AgentCardDiscovery: React.FC = ({ return; } - const timer = window.setTimeout(() => { - void handleDiscover(); - }, 400); - return () => window.clearTimeout(timer); - }, [accessToken, effectiveUrl, handleDiscover]); + debouncedDiscover(); + }, [accessToken, effectiveUrl, handleDiscover, debouncedDiscover]); const toggleSkill = (id: string, checked: boolean) => { setSelectedSkillIds((prev) => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx index adaf7e4c63f..2ad332ae9ed 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx @@ -105,6 +105,6 @@ describe("GuardrailsPanel", () => { expect(screen.getByText("Guardrails")).toBeInTheDocument(); // Activate the Guardrails tab so its content (including the Add button) is rendered fireEvent.click(screen.getByText("Guardrails")); - expect(screen.getByText("+ Add New Guardrail")).toBeInTheDocument(); + expect(screen.getByText("Add New Guardrail")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx index af4c85d0b9f..33c6933634c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx @@ -1,7 +1,15 @@ import React, { useState, useEffect } from "react"; -import { Button, Dropdown, Tabs } from "antd"; -import { DownOutlined, PlusOutlined, CodeOutlined } from "@ant-design/icons"; +import { Tabs } from "antd"; +import { ChevronDown, Code, Plus } from "lucide-react"; import { getGuardrailsList, deleteGuardrailCall } from "@/components/networking"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; import AddGuardrailForm from "./add_guardrail_form"; import GuardrailTable from "./guardrail_table"; import { isAdminRole } from "@/utils/roles"; @@ -133,30 +141,26 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole children: ( <>
- , - label: "Add Provider Guardrail", - onClick: handleAddGuardrail, - }, - { - key: "custom_code", - icon: , - label: "Create Custom Code Guardrail", - onClick: handleAddCustomCodeGuardrail, - }, - ], - }} - trigger={["click"]} - disabled={!accessToken} - > - - + + + + Add New Guardrail + + + + + + Add Provider Guardrail + + + + Create Custom Code Guardrail + + +
{selectedGuardrailId ? ( @@ -171,9 +175,6 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole guardrailsList={guardrailsList} isLoading={isLoading} onDeleteClick={handleDeleteClick} - accessToken={accessToken} - onGuardrailUpdated={fetchGuardrails} - isAdmin={isAdmin} onGuardrailClick={(id) => setSelectedGuardrailId(id)} /> )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx index 07cdcbc888c..4217a765732 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx @@ -1,6 +1,8 @@ "use client"; import React, { useState, useEffect, useCallback } from "react"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { SearchIcon, PlusIcon, @@ -728,6 +730,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { rejected: 0, }); const [search, setSearch] = useState(""); + const [searchDebounced] = useDebouncedValue(search, { wait: DEBOUNCE_WAIT_MS }); const [statusFilter, setStatusFilter] = useState<"all" | GuardrailStatus>("all"); const [selectedId, setSelectedId] = useState(null); const [expandedHeaders, setExpandedHeaders] = useState>(new Set()); @@ -737,16 +740,10 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { } | null>(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [searchDebounced, setSearchDebounced] = useState(""); const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false); const [submitForm] = Form.useForm(); const registerGuardrail = useRegisterGuardrail(); - useEffect(() => { - const t = setTimeout(() => setSearchDebounced(search), 300); - return () => clearTimeout(t); - }, [search]); - const fetchSubmissions = useCallback(async () => { if (!accessToken) { setIsLoading(false); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/edit_guardrail_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/edit_guardrail_form.tsx deleted file mode 100644 index a916984f8d7..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/edit_guardrail_form.tsx +++ /dev/null @@ -1,491 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Form, Typography, Select, Input, Switch, Modal } from "antd"; -import { Button, TextInput } from "@tremor/react"; -import { - guardrail_provider_map, - guardrailLogoMap, - getGuardrailProviders, - getSupportedModesForProvider, - toModeArray, - type SkipSystemMessageChoice, - type SkipToolMessageChoice, -} from "./guardrail_info_helpers"; -import { resolveLogoSrc } from "@/lib/assetPaths"; -import { getGuardrailUISettings, getGlobalLitellmHeaderName } from "@/components/networking"; -import PiiConfiguration from "./pii_configuration"; -import NotificationsManager from "@/components/molecules/notifications_manager"; - -const { Title, Text } = Typography; -const { Option } = Select; - -interface EditGuardrailFormProps { - visible: boolean; - onClose: () => void; - accessToken: string | null; - onSuccess: () => void; - guardrailId: string; - /** Full stored params merged into PUT so optional fields (e.g. content filter) are preserved. */ - fullLitellmParams?: Record | null; - initialValues: { - guardrail_name: string; - provider: string; - mode: string; - default_on: boolean; - pii_entities_config?: { [key: string]: string }; - skip_system_message_choice?: SkipSystemMessageChoice; - skip_tool_message_choice?: SkipToolMessageChoice; - [key: string]: unknown; - }; -} - -interface GuardrailSettings { - supported_entities: string[]; - supported_actions: string[]; - supported_modes: string[]; - supported_modes_by_provider?: Record; - pii_entity_categories: Array<{ - category: string; - entities: string[]; - }>; -} - -const EditGuardrailForm: React.FC = ({ - visible, - onClose, - accessToken, - onSuccess, - guardrailId, - fullLitellmParams, - initialValues, -}) => { - const [form] = Form.useForm(); - const [loading, setLoading] = useState(false); - const [selectedProvider, setSelectedProvider] = useState(initialValues?.provider || null); - const [guardrailSettings, setGuardrailSettings] = useState(null); - const [selectedEntities, setSelectedEntities] = useState([]); - const [selectedActions, setSelectedActions] = useState<{ [key: string]: string }>({}); - - // Fetch guardrail settings when the component mounts - useEffect(() => { - const fetchGuardrailSettings = async () => { - try { - if (!accessToken) return; - - const data = await getGuardrailUISettings(accessToken); - setGuardrailSettings(data); - } catch (error) { - console.error("Error fetching guardrail settings:", error); - NotificationsManager.fromBackend("Failed to load guardrail settings"); - } - }; - - fetchGuardrailSettings(); - }, [accessToken]); - - // Initialize selected entities and actions from initialValues - useEffect(() => { - if (initialValues?.pii_entities_config && Object.keys(initialValues.pii_entities_config).length > 0) { - const entities = Object.keys(initialValues.pii_entities_config); - setSelectedEntities(entities); - setSelectedActions(initialValues.pii_entities_config); - } - }, [initialValues]); - - const handleProviderChange = (value: string) => { - setSelectedProvider(value); - // Reset form fields that are provider-specific - form.setFieldsValue({ - config: undefined, - }); - - // Reset PII selections when changing provider - setSelectedEntities([]); - setSelectedActions({}); - }; - - const handleEntitySelect = (entity: string) => { - setSelectedEntities((prev) => { - if (prev.includes(entity)) { - return prev.filter((e) => e !== entity); - } else { - return [...prev, entity]; - } - }); - }; - - const handleActionSelect = (entity: string, action: string) => { - setSelectedActions((prev) => ({ - ...prev, - [entity]: action, - })); - }; - - const handleSubmit = async () => { - try { - setLoading(true); - const values = await form.validateFields(); - - // Get the guardrail provider value from the map - const guardrailProvider = guardrail_provider_map[values.provider]; - - const litellm_params: Record = - fullLitellmParams && typeof fullLitellmParams === "object" ? { ...fullLitellmParams } : {}; - - litellm_params.guardrail = guardrailProvider; - litellm_params.mode = values.mode; - litellm_params.default_on = values.default_on; - - const skipChoice = values.skip_system_message_choice as SkipSystemMessageChoice | undefined; - if (skipChoice === "yes") { - litellm_params.skip_system_message_in_guardrail = true; - } else if (skipChoice === "no") { - litellm_params.skip_system_message_in_guardrail = false; - } else { - delete litellm_params.skip_system_message_in_guardrail; - } - - const skipToolChoice = values.skip_tool_message_choice as SkipToolMessageChoice | undefined; - if (skipToolChoice === "yes") { - litellm_params.skip_tool_message_in_guardrail = true; - } else if (skipToolChoice === "no") { - litellm_params.skip_tool_message_in_guardrail = false; - } else { - delete litellm_params.skip_tool_message_in_guardrail; - } - - let guardrail_info: Record = {}; - - // For Presidio PII, add the entity and action configurations - if (values.provider === "PresidioPII" && selectedEntities.length > 0) { - const piiEntitiesConfig: { [key: string]: string } = {}; - selectedEntities.forEach((entity) => { - piiEntitiesConfig[entity] = selectedActions[entity] || "MASK"; // Default to MASK if no action selected - }); - - litellm_params.pii_entities_config = piiEntitiesConfig; - } - // Add config values to the guardrail_info if provided - else if (values.config) { - try { - const configObj = JSON.parse(values.config); - // For some guardrails, the config values need to be in litellm_params - // Especially for providers like Bedrock that need guardrailIdentifier and guardrailVersion - if (values.provider === "Bedrock" && configObj) { - if (configObj.guardrail_id) { - litellm_params.guardrailIdentifier = configObj.guardrail_id; - } - if (configObj.guardrail_version) { - litellm_params.guardrailVersion = configObj.guardrail_version; - } - } else { - // For other providers, add the config to guardrail_info - guardrail_info = configObj; - } - } catch (error) { - NotificationsManager.fromBackend("Invalid JSON in configuration"); - setLoading(false); - return; - } - } - - const guardrailData: { - guardrail_id: string; - guardrail: { - guardrail_name: string; - litellm_params: Record; - guardrail_info: Record; - }; - } = { - guardrail_id: guardrailId, - guardrail: { - guardrail_name: values.guardrail_name, - litellm_params, - guardrail_info, - }, - }; - - if (!accessToken) { - throw new Error("No access token available"); - } - - // Call the update endpoint - const url = `/guardrails/${guardrailId}`; - const response = await fetch(url, { - method: "PUT", - headers: { - [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(guardrailData), - }); - - if (!response.ok) { - const errorData = await response.text(); - throw new Error(errorData || "Failed to update guardrail"); - } - - NotificationsManager.success("Guardrail updated successfully"); - - // Reset and close - onSuccess(); - onClose(); - } catch (error) { - console.error("Failed to update guardrail:", error); - NotificationsManager.fromBackend( - "Failed to update guardrail: " + (error instanceof Error ? error.message : String(error)), - ); - } finally { - setLoading(false); - } - }; - - const renderPiiConfiguration = () => { - if (!guardrailSettings || !selectedProvider || selectedProvider !== "PresidioPII") return null; - - return ( - - ); - }; - - const renderProviderSpecificFields = () => { - if (!selectedProvider) return null; - - // For Presidio, we use the new PII configuration UI - if (selectedProvider === "PresidioPII") { - return renderPiiConfiguration(); - } - - switch (selectedProvider) { - case "Aporia": - return ( - - - - ); - case "AimSecurity": - return ( - - - - ); - case "Bedrock": - return ( - - - - ); - case "CatoNetworks": - return ( - - - - ); - case "GuardrailsAI": - return ( - - - - ); - case "LakeraAI": - return ( - - - - ); - case "PromptInjection": - return ( - - - - ); - default: - return ( - - - - ); - } - }; - - return ( - -
- - - - - - - - - - - - - - - - - - - - - - - - - {renderProviderSpecificFields()} - -
- - -
-
-
- ); -}; - -export default EditGuardrailForm; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrailTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrailTableColumns.tsx new file mode 100644 index 00000000000..9ceb6ba244b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrailTableColumns.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { MoreHorizontal, Trash2 } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells"; +import { Guardrail, GuardrailDefinitionLocation } from "@/components/guardrails/types"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; + +import { getGuardrailLogoAndName } from "./guardrail_info_helpers"; + +const CONFIG_DELETE_HINT = "Config guardrails are defined in the config file and cannot be deleted from the dashboard."; + +function GuardrailProviderCell({ provider }: { provider: string }) { + const { logo, displayName } = getGuardrailLogoAndName(provider); + return ( +
+ {logo ? ( + { + (event.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : null} + {displayName} +
+ ); +} + +interface GuardrailRowActionsProps { + guardrail: Guardrail; + onDeleteClick: (guardrailId: string, guardrailName: string) => void; +} + +function GuardrailRowActions({ guardrail, onDeleteClick }: GuardrailRowActionsProps) { + const isConfigGuardrail = guardrail.guardrail_definition_location === GuardrailDefinitionLocation.CONFIG; + + return ( + + + + + + onDeleteClick(guardrail.guardrail_id, guardrail.guardrail_name || "Unnamed Guardrail")} + > + + Delete + + + + ); +} + +interface GuardrailTableColumnsDeps { + onGuardrailClick: (guardrailId: string) => void; + onDeleteClick: (guardrailId: string, guardrailName: string) => void; +} + +export const getGuardrailTableColumns = ({ + onGuardrailClick, + onDeleteClick, +}: GuardrailTableColumnsDeps): ColumnDef[] => [ + { + id: "guardrail_id", + accessorKey: "guardrail_id", + meta: { title: "Guardrail ID" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + cell: ({ row }) => ( + onGuardrailClick(row.original.guardrail_id)} + /> + ), + }, + { + id: "guardrail_name", + accessorKey: "guardrail_name", + meta: { title: "Name" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + cell: ({ row }) => { + const name = row.original.guardrail_name; + return ( + + {name || "-"} + + ); + }, + }, + { + id: "provider", + meta: { title: "Provider" }, + header: "Provider", + size: 180, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "mode", + meta: { title: "Mode" }, + header: "Mode", + size: 130, + enableSorting: false, + cell: ({ row }) => ( + {row.original.litellm_params.mode} + ), + }, + { + id: "default_on", + meta: { title: "Default On" }, + header: "Default On", + size: 120, + enableSorting: false, + cell: ({ row }) => { + const isDefaultOn = !!row.original.litellm_params?.default_on; + return ( + + ); + }, + }, + { + id: "created_at", + accessorKey: "created_at", + meta: { title: "Created At" }, + header: ({ column }) => , + size: 150, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "updated_at", + accessorKey: "updated_at", + meta: { title: "Updated At" }, + header: ({ column }) => , + size: 150, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.test.tsx index 70b5998ad95..4f556e74c16 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.test.tsx @@ -1,56 +1,61 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; + import GuardrailTable from "./guardrail_table"; -import { render } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { GuardrailDefinitionLocation } from "@/components/guardrails/types"; +import { Guardrail, GuardrailDefinitionLocation } from "@/components/guardrails/types"; + +const baseProps = { + isLoading: false, + onDeleteClick: vi.fn(), + onGuardrailClick: vi.fn(), +}; + +const makeGuardrail = (overrides: Partial = {}): Guardrail => ({ + guardrail_id: "gr-1", + guardrail_name: "PII Redaction", + litellm_params: { guardrail: "presidio", mode: "pre_call", default_on: true }, + guardrail_info: null, + created_at: "2021-01-01", + updated_at: "2021-01-02", + guardrail_definition_location: GuardrailDefinitionLocation.DB, + ...overrides, +}); + describe("GuardrailTable", () => { - it("should render", () => { - const { getByText } = render( - {}} - accessToken={null} - onGuardrailUpdated={() => {}} - onGuardrailClick={() => {}} - />, - ); - expect(getByText("Guardrail ID")).toBeInTheDocument(); - expect(getByText("Name")).toBeInTheDocument(); - expect(getByText("Provider")).toBeInTheDocument(); - expect(getByText("Mode")).toBeInTheDocument(); - expect(getByText("Default On")).toBeInTheDocument(); - expect(getByText("Created At")).toBeInTheDocument(); - expect(getByText("Updated At")).toBeInTheDocument(); + it("renders every column header", () => { + render(); + for (const header of ["Guardrail ID", "Name", "Provider", "Mode", "Default On", "Created At", "Updated At"]) { + expect(screen.getByText(header)).toBeInTheDocument(); + } }); - it("should not allow deletion of config guardrails", () => { - const { getByTestId } = render( - {}} - accessToken={null} - onGuardrailUpdated={() => {}} - onGuardrailClick={() => {}} - />, - ); + it("deletes a DB guardrail through the actions menu", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + const guardrail = makeGuardrail({ guardrail_id: "gr-9", guardrail_name: "Toxicity Filter" }); + render(); - const deleteGuardrailButton = getByTestId("config-delete-icon"); - expect(deleteGuardrailButton).toBeInTheDocument(); - expect(deleteGuardrailButton).toHaveClass("cursor-not-allowed text-gray-400"); - expect(deleteGuardrailButton).toHaveAttribute( - "title", - "Config guardrail cannot be deleted on the dashboard. Please delete it from the config file.", - ); + await user.click(screen.getByTestId("guardrail-actions-gr-9")); + await user.click(await screen.findByTestId("guardrail-action-delete")); + + expect(onDeleteClick).toHaveBeenCalledWith("gr-9", "Toxicity Filter"); + }); + + it("disables deletion for config guardrails so they cannot be removed from the dashboard", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + const guardrail = makeGuardrail({ + guardrail_id: "cfg-1", + guardrail_name: "Config Guardrail", + guardrail_definition_location: GuardrailDefinitionLocation.CONFIG, + }); + render(); + + await user.click(screen.getByTestId("guardrail-actions-cfg-1")); + const deleteItem = await screen.findByTestId("guardrail-action-delete"); + + expect(deleteItem).toHaveAttribute("data-disabled"); + expect(onDeleteClick).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx index 4ea4505f506..e6a14b2b2f4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx @@ -1,284 +1,61 @@ -import React, { useState } from "react"; -import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon } from "@tremor/react"; -import { TrashIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline"; -import { Tooltip } from "antd"; -import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getSortedRowModel, - SortingState, - useReactTable, -} from "@tanstack/react-table"; -import { - getGuardrailLogoAndName, - guardrail_provider_map, - skipSystemMessageToChoice, - skipToolMessageToChoice, -} from "./guardrail_info_helpers"; -import EditGuardrailForm from "./edit_guardrail_form"; -import { Guardrail, GuardrailDefinitionLocation } from "@/components/guardrails/types"; +"use client"; + +import { SortingState } from "@tanstack/react-table"; +import { Inbox } from "lucide-react"; +import React, { useMemo, useState } from "react"; + +import { DataTable } from "@/components/shared/DataTable"; +import { Guardrail } from "@/components/guardrails/types"; + +import { getGuardrailTableColumns } from "./guardrailTableColumns"; interface GuardrailTableProps { guardrailsList: Guardrail[]; isLoading: boolean; onDeleteClick: (guardrailId: string, guardrailName: string) => void; - accessToken: string | null; - onGuardrailUpdated: () => void; - isAdmin?: boolean; onGuardrailClick: (id: string) => void; } +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + +function EmptyState() { + return ( +
+
+ +
+
No guardrails yet
+
Add a guardrail to start filtering requests and responses.
+
+ ); +} + const GuardrailTable: React.FC = ({ guardrailsList, isLoading, onDeleteClick, - accessToken, - onGuardrailUpdated, - isAdmin = false, onGuardrailClick, }) => { - const [sorting, setSorting] = useState([{ id: "created_at", desc: true }]); - const [editModalVisible, setEditModalVisible] = useState(false); - const [selectedGuardrail, setSelectedGuardrail] = useState(null); + const [sorting, setSorting] = useState(DEFAULT_SORTING); - const handleEditClick = (guardrail: Guardrail) => { - setSelectedGuardrail(guardrail); - setEditModalVisible(true); - }; - - const handleEditSuccess = () => { - setEditModalVisible(false); - setSelectedGuardrail(null); - onGuardrailUpdated(); - }; - - const columns: ColumnDef[] = [ - { - header: "Guardrail ID", - accessorKey: "guardrail_id", - cell: (info: any) => , - }, - { - header: "Name", - accessorKey: "guardrail_name", - cell: ({ row }) => { - const guardrail = row.original; - return ( - - {guardrail.guardrail_name || "-"} - - ); - }, - }, - { - header: "Provider", - accessorKey: "litellm_params.guardrail", - cell: ({ row }) => { - const guardrail = row.original; - const { logo, displayName } = getGuardrailLogoAndName(guardrail.litellm_params.guardrail); - return ( -
- {logo && ( - {`${displayName} { - // Hide broken image - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - )} - {displayName} -
- ); - }, - }, - { - header: "Mode", - accessorKey: "litellm_params.mode", - cell: ({ row }) => { - const guardrail = row.original; - return {guardrail.litellm_params.mode}; - }, - }, - { - header: "Default On", - accessorKey: "litellm_params.default_on", - cell: ({ row }) => { - const isDefaultOn = !!row.original.litellm_params?.default_on; - return ( - - ); - }, - }, - { - header: "Created At", - accessorKey: "created_at", - cell: ({ row }) => , - }, - { - header: "Updated At", - accessorKey: "updated_at", - cell: ({ row }) => , - }, - { - id: "actions", - header: "Actions", - cell: ({ row }) => { - const guardrail = row.original; - const isConfigGuardrail = guardrail.guardrail_definition_location === GuardrailDefinitionLocation.CONFIG; - return ( -
- {isConfigGuardrail ? ( - - - - ) : ( - - - guardrail.guardrail_id && - onDeleteClick(guardrail.guardrail_id, guardrail.guardrail_name || "Unnamed Guardrail") - } - className="cursor-pointer hover:text-red-500" - /> - - )} -
- ); - }, - }, - ]; - - const table = useReactTable({ - data: guardrailsList, - columns, - state: { - sorting, - }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - enableSorting: true, - }); + const columns = useMemo( + () => getGuardrailTableColumns({ onGuardrailClick, onDeleteClick }), + [onGuardrailClick, onDeleteClick], + ); return ( -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
-
- ))} -
- ))} -
- - {isLoading ? ( - - -
-

Loading...

-
-
-
- ) : guardrailsList.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No guardrails found

-
-
-
- )} -
-
-
- - {/* Edit Modal */} - {selectedGuardrail && ( - setEditModalVisible(false)} - accessToken={accessToken} - onSuccess={handleEditSuccess} - guardrailId={selectedGuardrail.guardrail_id || ""} - fullLitellmParams={selectedGuardrail.litellm_params} - initialValues={{ - guardrail_name: selectedGuardrail.guardrail_name || "", - provider: - Object.keys(guardrail_provider_map).find( - (key) => guardrail_provider_map[key] === selectedGuardrail?.litellm_params.guardrail, - ) || "", - mode: selectedGuardrail.litellm_params.mode, - default_on: selectedGuardrail.litellm_params.default_on, - pii_entities_config: selectedGuardrail.litellm_params.pii_entities_config, - skip_system_message_choice: skipSystemMessageToChoice( - selectedGuardrail.litellm_params?.skip_system_message_in_guardrail, - ), - skip_tool_message_choice: skipToolMessageToChoice( - selectedGuardrail.litellm_params?.skip_tool_message_in_guardrail, - ), - ...selectedGuardrail.guardrail_info, - }} - /> - )} -
+ guardrail.guardrail_id || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + isLoading={isLoading} + loadingMessage="Loading guardrails…" + noDataMessage={} + size="compact" + /> ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts index 490e5e3dad1..f532c44ffd7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts @@ -84,6 +84,24 @@ export const teamListCall = async ( } }; +export const teamsTableKeys = createQueryKeys("teamsTable"); + +export const useTeamsTable = ( + page: number, + pageSize: number, + options: TeamListCallOptions = {}, +): UseQueryResult => { + const { accessToken } = useAuthorized(); + + return useQuery({ + queryKey: teamsTableKeys.list({ page, limit: pageSize, ...options }), + queryFn: async () => await teamListCall(accessToken!, page, pageSize, options), + enabled: Boolean(accessToken), + staleTime: 30000, + placeholderData: keepPreviousData, + }); +}; + const teamKeys = createQueryKeys("teams"); export const useTeams = (): UseQueryResult => { const { accessToken, userId, userRole } = useAuthorized(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx index f359bdee065..fc72981edd0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx @@ -228,7 +228,7 @@ const OAuthFormFields: React.FC = ({ label={ } name="token_storage_ttl_seconds" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx index 3b184cc6f1e..455fe69455d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx @@ -1386,7 +1386,7 @@ const MCPServerEdit: React.FC = ({ label={ Token Storage TTL (seconds, optional) - + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx index 0afb4bd9314..f186fef22da 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx @@ -684,7 +684,6 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) refetch(); setByokModalServer(null); }} - accessToken={accessToken || ""} /> )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index 560a4ec15d0..4bf4af0c7f6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -296,7 +296,7 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te } return ( -
+
{/* Model Management Header */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 576729d18d3..20c9e805a0d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -14,11 +14,13 @@ import { useQueryClient } from "@tanstack/react-query"; import { Grid, TabPanel } from "@tremor/react"; import { Badge, Button, Select, Skeleton, Space, Typography } from "antd"; import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal"; -import debounce from "lodash/debounce"; +import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { useEffect, useMemo, useState } from "react"; import { useModelsInfo } from "../../hooks/models/useModels"; import { transformModelData } from "../utils/modelDataTransformer"; type ModelViewMode = "all" | "current_team"; + +const SEARCH_DEBOUNCE_WAIT_MS = 200; const { Text } = Typography; interface AllModelsTabProps { @@ -59,23 +61,17 @@ const AllModelsTab = ({ const [sorting, setSorting] = useState([]); const [isModelSettingsModalVisible, setIsModelSettingsModalVisible] = useState(false); - // Debounce search input - const debouncedUpdateSearch = useMemo( - () => - debounce((value: string) => { - setDebouncedSearch(value); - // Reset to page 1 when search changes - setCurrentPage(1); - setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); - }, 200), - [], + const debouncedUpdateSearch = useDebouncedCallback( + (value: string) => { + setDebouncedSearch(value); + setCurrentPage(1); + setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); + }, + { wait: SEARCH_DEBOUNCE_WAIT_MS }, ); useEffect(() => { debouncedUpdateSearch(modelNameSearch); - return () => { - debouncedUpdateSearch.cancel(); - }; }, [modelNameSearch, debouncedUpdateSearch]); // Determine teamId to pass to the query - only pass if not "personal" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx index d3af5b62668..87d8010759d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx @@ -191,7 +191,7 @@ const OrganizationsTable: React.FC = ({ } return ( -
+
{(userRole === "Admin" || userRole === "Org Admin") && ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index 689abf66b41..d8f927b9a63 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -77,6 +77,7 @@ import { A2ATaskMetadata, MessageType } from "@/components/chat_ui/types"; import { useCodeInterpreter } from "../../hooks/useCodeInterpreter"; import { useChatHistory } from "../../hooks/useChatHistory"; import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; +import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; const { TextArea } = Input; const { Dragger } = Upload; @@ -99,6 +100,8 @@ interface ChatUIProps { const MCP_SUPPORTED_ENDPOINTS = new Set([EndpointType.CHAT, EndpointType.RESPONSES, EndpointType.MCP]); +const CUSTOM_MODEL_DEBOUNCE_WAIT_MS = 500; + const ChatUI: React.FC = ({ accessToken, token, @@ -185,7 +188,9 @@ const ChatUI: React.FC = ({ const [modelInfo, setModelInfo] = useState([]); const [agentInfo, setAgentInfo] = useState([]); const [selectedAgent, setSelectedAgent] = useState(undefined); - const customModelTimeout = useRef(null); + const debouncedSetSelectedModel = useDebouncedCallback((value: string) => setSelectedModel(value), { + wait: CUSTOM_MODEL_DEBOUNCE_WAIT_MS, + }); const [endpointType, setEndpointType] = useState( () => sessionStorage.getItem("endpointType") || EndpointType.CHAT, ); @@ -1255,16 +1260,7 @@ const ChatUI: React.FC = ({ { - // Using setTimeout to create a simple debounce effect - if (customModelTimeout.current) { - clearTimeout(customModelTimeout.current); - } - - customModelTimeout.current = setTimeout(() => { - setSelectedModel(value); - }, 500); // 500ms delay after typing stops - }} + onValueChange={debouncedSetSelectedModel} /> )}
@@ -2186,7 +2182,6 @@ const ChatUI: React.FC = ({ loadMCPServers(); setByokModalServer(null); }} - accessToken={accessToken || ""} /> )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx index 65157206f21..3b396924aba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx @@ -1,7 +1,9 @@ "use client"; import NotificationsManager from "@/components/molecules/notifications_manager"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { ClearOutlined, DeleteOutlined, FilePdfOutlined, PlusOutlined } from "@ant-design/icons"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { Button, Input, Select, Tooltip } from "antd"; import { useEffect, useMemo, useState } from "react"; import { v4 as uuidv4 } from "uuid"; @@ -105,14 +107,8 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: disabledPersonalKeyCreation ? "custom" : "session", ); const [customApiKey, setCustomApiKey] = useState(""); - const [debouncedCustomApiKey, setDebouncedCustomApiKey] = useState(""); + const [debouncedCustomApiKey] = useDebouncedValue(customApiKey, { wait: DEBOUNCE_WAIT_MS }); const [customProxyBaseUrl] = useState(() => sessionStorage.getItem("customProxyBaseUrl") || ""); - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedCustomApiKey(customApiKey); - }, 300); - return () => clearTimeout(timer); - }, [customApiKey]); useEffect(() => { return () => { if (uploadedFilePreviewUrl) { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/hooks/useChatHistory.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/hooks/useChatHistory.test.ts index b61f27cf717..762e17a6f91 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/hooks/useChatHistory.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/hooks/useChatHistory.test.ts @@ -1,5 +1,5 @@ import { renderHook, act } from "@testing-library/react"; -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useChatHistory } from "./useChatHistory"; describe("useChatHistory", () => { @@ -499,6 +499,80 @@ describe("useChatHistory", () => { }); }); + describe("debounced chatHistory persistence", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it("should not write chatHistory to sessionStorage before the debounce wait elapses", () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("user", "hello"); + }); + act(() => { + vi.advanceTimersByTime(499); + }); + + expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0); + + setItemSpy.mockRestore(); + }); + + it("should write chatHistory exactly once with the last value after the wait", () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + const { result } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("user", "h"); + }); + act(() => { + vi.advanceTimersByTime(300); + }); + act(() => { + result.current.updateTextUI("user", "i"); + }); + act(() => { + vi.advanceTimersByTime(499); + }); + + expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0); + + act(() => { + vi.advanceTimersByTime(1); + }); + + const writes = setItemSpy.mock.calls.filter(([key]) => key === "chatHistory"); + expect(writes).toHaveLength(1); + expect(JSON.parse(writes[0][1])).toEqual([{ role: "user", content: "hi" }]); + + setItemSpy.mockRestore(); + }); + + it("should not write chatHistory when unmounted mid-wait", () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + const { result, unmount } = renderHook(() => useChatHistory({ simplified: false })); + + act(() => { + result.current.updateTextUI("user", "hello"); + }); + unmount(); + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0); + + setItemSpy.mockRestore(); + }); + }); + describe("simplified mode session isolation", () => { it("should not hydrate messageTraceId from sessionStorage in simplified mode", () => { sessionStorage.setItem("messageTraceId", "trace-from-playground"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/hooks/useChatHistory.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/hooks/useChatHistory.ts index 6e2a263f599..7ee30757524 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/hooks/useChatHistory.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/hooks/useChatHistory.ts @@ -1,9 +1,12 @@ import React, { useState, useEffect } from "react"; +import { useDebouncer } from "@tanstack/react-pacer/debouncer"; import { MessageType, A2ATaskMetadata } from "@/components/chat_ui/types"; import { TokenUsage } from "@/components/chat_ui/ResponseMetrics"; import { MCPEvent } from "@/components/mcp_tools/types"; import { truncateString } from "@/utils/textUtils"; +const CHAT_HISTORY_PERSIST_WAIT_MS = 500; + export interface UseChatHistoryReturn { // State chatHistory: MessageType[]; @@ -64,20 +67,20 @@ export function useChatHistory({ simplified }: { simplified: boolean }): UseChat return saved ? JSON.parse(saved) : true; // Default to API session management }); - // Debounced chatHistory persistence - useEffect(() => { - if (simplified) return; // Do not persist chat history in simplified (embedded) mode - // When chatHistory is empty (e.g. after clearChatHistory removed the key), - // don't re-write an empty array back into sessionStorage. - if (chatHistory.length === 0) return; - const handler = setTimeout(() => { - sessionStorage.setItem("chatHistory", JSON.stringify(chatHistory)); - }, 500); // Debounce by 500ms + const persistDebouncer = useDebouncer( + (history: MessageType[]) => { + sessionStorage.setItem("chatHistory", JSON.stringify(history)); + }, + { wait: CHAT_HISTORY_PERSIST_WAIT_MS }, + ); - return () => { - clearTimeout(handler); - }; - }, [chatHistory, simplified]); + useEffect(() => { + if (simplified || chatHistory.length === 0) { + persistDebouncer.cancel(); + return; + } + persistDebouncer.maybeExecute(chatHistory); + }, [chatHistory, simplified, persistDebouncer]); // messageTraceId/responsesSessionId/useApiSessionManagement persistence useEffect(() => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.test.tsx index e9a35d961f9..75b78a99128 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.test.tsx @@ -1,9 +1,12 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; + import { formatCellDate } from "@/components/shared/table_cells"; -import TagTable from "./TagTable"; import { Tag } from "@/components/tag_management/types"; +import TagTable from "./TagTable"; + describe("TagTable", () => { const mockOnEdit = vi.fn(); const mockOnDelete = vi.fn(); @@ -41,28 +44,28 @@ describe("TagTable", () => { vi.clearAllMocks(); }); - it("should render", () => { + it("should render every column header", () => { render(); - expect(screen.getByText("Tag Name")).toBeInTheDocument(); - expect(screen.getByText("Description")).toBeInTheDocument(); - expect(screen.getByText("Allowed Models")).toBeInTheDocument(); - expect(screen.getByText("Created")).toBeInTheDocument(); - expect(screen.getByText("Actions")).toBeInTheDocument(); + for (const header of ["Tag Name", "Description", "Allowed Models", "Created"]) { + expect(screen.getByText(header)).toBeInTheDocument(); + } }); - it("should display no tags found message when data is empty", () => { + it("should display the empty state when data is empty", () => { render(); - expect(screen.getByText("No tags found")).toBeInTheDocument(); + expect(screen.getByText("No tags yet")).toBeInTheDocument(); }); - it("should display tag name", () => { + it("should display tag name and description", () => { render(); expect(screen.getByText("test-tag")).toBeInTheDocument(); + expect(screen.getByText("Test description")).toBeInTheDocument(); }); - it("should display tag description", () => { + it("should display model names from model_info", () => { render(); - expect(screen.getByText("Test description")).toBeInTheDocument(); + expect(screen.getByText("GPT-4")).toBeInTheDocument(); + expect(screen.getByText("Claude-3")).toBeInTheDocument(); }); it("should display All Models badge when models array is empty", () => { @@ -80,30 +83,71 @@ describe("TagTable", () => { expect(screen.getByText(formattedDate)).toBeInTheDocument(); }); - it("should call onSelectTag when tag name is clicked", () => { + it("should sort by created date descending by default", () => { + const olderTag: Tag = { ...mockTag, name: "older-tag", created_at: "2023-01-01T00:00:00Z" }; + const newerTag: Tag = { ...mockTag, name: "newer-tag", created_at: "2025-01-01T00:00:00Z" }; + render(); + const rows = screen.getAllByRole("row").slice(1); + expect(within(rows[0]).getByText("newer-tag")).toBeInTheDocument(); + expect(within(rows[1]).getByText("older-tag")).toBeInTheDocument(); + }); + + it("should call onSelectTag when tag name is clicked", async () => { + const user = userEvent.setup(); render(); - fireEvent.click(screen.getByRole("button", { name: "test-tag" })); + await user.click(screen.getByRole("button", { name: "test-tag" })); expect(mockOnSelectTag).toHaveBeenCalledWith("test-tag"); }); - it("should render tag name as non-clickable for dynamic spend tags", () => { + it("should render tag name as non-clickable and muted for dynamic spend tags", () => { render(); expect(screen.queryByRole("button", { name: "dynamic-spend-tag" })).not.toBeInTheDocument(); - fireEvent.click(screen.getByText("dynamic-spend-tag")); + expect(screen.getByText("dynamic-spend-tag")).toHaveClass("text-muted-foreground"); expect(mockOnSelectTag).not.toHaveBeenCalled(); }); - it("should disable edit icon for dynamic spend tags", () => { - render(); - const editIcon = screen.getByLabelText("Edit tag (disabled)"); - expect(editIcon).toBeInTheDocument(); - expect(editIcon).toHaveClass("cursor-not-allowed"); + it("should truncate long tag names and descriptions", () => { + const longTag: Tag = { + ...mockTag, + name: "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15) Firefox/152.0", + description: "A very long description that would otherwise stretch the column far beyond what users need to see", + }; + render(); + expect(screen.getByText(longTag.name)).toHaveClass("truncate", "text-primary"); + expect(screen.getByText(longTag.description as string)).toHaveClass("truncate", "max-w-72"); }); - it("should disable delete icon for dynamic spend tags", () => { + it("should edit a tag through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("tag-actions-test-tag")); + await user.click(await screen.findByTestId("tag-action-edit")); + expect(mockOnEdit).toHaveBeenCalledWith(mockTag); + }); + + it("should delete a tag through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("tag-actions-test-tag")); + await user.click(await screen.findByTestId("tag-action-delete")); + expect(mockOnDelete).toHaveBeenCalledWith("test-tag"); + }); + + it("should disable edit and delete for dynamic spend tags", async () => { + const user = userEvent.setup(); render(); - const deleteIcon = screen.getByLabelText("Delete tag (disabled)"); - expect(deleteIcon).toBeInTheDocument(); - expect(deleteIcon).toHaveClass("cursor-not-allowed"); + await user.click(screen.getByTestId("tag-actions-dynamic-spend-tag")); + + const editItem = await screen.findByTestId("tag-action-edit"); + const deleteItem = await screen.findByTestId("tag-action-delete"); + + expect(editItem).toHaveAttribute("data-disabled"); + expect(deleteItem).toHaveAttribute("data-disabled"); + + await user.click(editItem); + await user.click(deleteItem); + + expect(mockOnEdit).not.toHaveBeenCalled(); + expect(mockOnDelete).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx index 21bf4657a47..488190a0fdf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx @@ -1,227 +1,54 @@ -import { ChevronDownIcon, ChevronUpIcon, PencilAltIcon, SwitchVerticalIcon, TrashIcon } from "@heroicons/react/outline"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getSortedRowModel, - SortingState, - useReactTable, -} from "@tanstack/react-table"; -import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react"; -import { Tooltip } from "antd"; -import React from "react"; -import { DateCell, IdCell } from "@/components/shared/table_cells"; +"use client"; + +import { SortingState } from "@tanstack/react-table"; +import { Inbox } from "lucide-react"; +import React, { useMemo, useState } from "react"; + +import { DataTable } from "@/components/shared/DataTable"; import { Tag } from "@/components/tag_management/types"; +import { getTagTableColumns } from "./tagTableColumns"; + interface TagTableProps { data: Tag[]; onEdit: (tag: Tag) => void; onDelete: (tagName: string) => void; onSelectTag: (tagName: string) => void; + isLoading?: boolean; } -const DYNAMIC_SPEND_TAG_DESCRIPTION = - "This is just a spend tag that was passed dynamically in a request. It does not control any LLM models."; +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; -const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag }) => { - const [sorting, setSorting] = React.useState([{ id: "created_at", desc: true }]); +function EmptyState() { + return ( +
+
+ +
+
No tags yet
+
Create a tag to start routing and restricting model usage.
+
+ ); +} - const columns: ColumnDef[] = [ - { - header: "Tag Name", - accessorKey: "name", - cell: ({ row }) => { - const tag = row.original; - const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION; - return ( -
- -
- ); - }, - }, - { - header: "Description", - accessorKey: "description", - cell: ({ row }) => { - const tag = row.original; - return ( - - {tag.description || "-"} - - ); - }, - }, - { - header: "Allowed Models", - accessorKey: "models", - cell: ({ row }) => { - const tag = row.original; - return ( -
- {tag?.models?.length === 0 ? ( - - All Models - - ) : ( - tag?.models?.map((modelId) => ( - - - {tag.model_info?.[modelId] || modelId} - - - )) - )} -
- ); - }, - }, - { - header: "Created", - accessorKey: "created_at", - sortingFn: "datetime", - cell: ({ row }) => , - }, - { - id: "actions", - header: "Actions", - cell: ({ row }) => { - const tag = row.original; - const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION; - return ( -
- {isDynamicSpendTag ? ( - - - - ) : ( - - onEdit(tag)} - className="cursor-pointer hover:text-blue-500" - /> - - )} - {isDynamicSpendTag ? ( - - - - ) : ( - - onDelete(tag.name)} - className="cursor-pointer hover:text-red-500" - /> - - )} -
- ); - }, - }, - ]; +const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag, isLoading = false }) => { + const [sorting, setSorting] = useState(DEFAULT_SORTING); - const table = useReactTable({ - data, - columns, - state: { - sorting, - }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - enableSorting: true, - }); + const columns = useMemo(() => getTagTableColumns({ onSelectTag, onEdit, onDelete }), [onSelectTag, onEdit, onDelete]); return ( -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
-
- ))} -
- ))} -
- - {table.getRowModel().rows.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No tags found

-
-
-
- )} -
-
-
-
+ tag.name || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + isLoading={isLoading} + loadingMessage="Loading tags…" + noDataMessage={} + size="compact" + /> ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.test.tsx new file mode 100644 index 00000000000..2530ce9fda7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { tagListCall } from "@/components/networking"; + +import TagManagement from "./index"; + +vi.mock("@/components/networking", () => ({ + tagListCall: vi.fn(), + tagCreateCall: vi.fn(), + tagDeleteCall: vi.fn(), + modelInfoCall: vi.fn(), +})); + +vi.mock("./TagTable", () => ({ + __esModule: true, + default: ({ isLoading }: { isLoading?: boolean }) => ( +
{isLoading ? "table-loading" : "table-loaded"}
+ ), +})); + +vi.mock("./tag_info", () => ({ + __esModule: true, + default: () =>
Mock Tag Info View
, +})); + +vi.mock("./components/CreateTagModal", () => ({ + __esModule: true, + default: () =>
Mock Create Tag Modal
, +})); + +const mockTagListCall = vi.mocked(tagListCall); + +describe("TagManagement loading state", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => { + render(); + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(mockTagListCall).not.toHaveBeenCalled(); + }); + + it("should show the loading state until the tag fetch settles", async () => { + let resolveFetch: (value: Record) => void = () => {}; + mockTagListCall.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + render(); + expect(screen.getByText("table-loading")).toBeInTheDocument(); + + resolveFetch({}); + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(mockTagListCall).toHaveBeenCalledWith("sk-test"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx index 043d229e3c1..16b92bd4553 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx @@ -27,6 +27,7 @@ interface TagProps { const TagManagement: React.FC = ({ accessToken, userID, userRole }) => { const [tags, setTags] = useState([]); + const [isLoadingTags, setIsLoadingTags] = useState(true); const [isCreateModalVisible, setIsCreateModalVisible] = useState(false); const [selectedTagId, setSelectedTagId] = useState(null); const [editTag, setEditTag] = useState(false); @@ -36,13 +37,18 @@ const TagManagement: React.FC = ({ accessToken, userID, userRole }) => const [availableModels, setAvailableModels] = useState([]); const fetchTags = async () => { - if (!accessToken) return; + if (!accessToken) { + setIsLoadingTags(false); + return; + } try { const response = await tagListCall(accessToken); setTags(Object.values(response)); } catch (error) { console.error("Error fetching tags:", error); NotificationsManager.fromBackend("Error fetching tags: " + error); + } finally { + setIsLoadingTags(false); } }; @@ -115,7 +121,7 @@ const TagManagement: React.FC = ({ accessToken, userID, userRole }) => }, [accessToken]); return ( -
+
{selectedTagId ? ( = ({ accessToken, userID, userRole }) => { setSelectedTagId(tag.name); setEditTag(true); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx new file mode 100644 index 00000000000..1c44ae272c6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { CellTooltip, DateCell, IdentityCell } from "@/components/shared/table_cells"; +import { Tag } from "@/components/tag_management/types"; +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; + +export const DYNAMIC_SPEND_TAG_DESCRIPTION = + "This is just a spend tag that was passed dynamically in a request. It does not control any LLM models."; + +const isDynamicSpendTag = (tag: Tag) => tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION; + +function TagNameCell({ tag, onSelectTag }: { tag: Tag; onSelectTag: (tagName: string) => void }) { + if (isDynamicSpendTag(tag)) { + return ( + {tag.name}} + /> + ); + } + return ( + onSelectTag(tag.name)} + /> + ); +} + +function TagModelsCell({ tag }: { tag: Tag }) { + const models = tag.models ?? []; + if (models.length === 0) { + return All Models; + } + return ( +
+ {models.map((modelId) => ( + + {tag.model_info?.[modelId] || modelId} + + } + /> + ))} +
+ ); +} + +interface TagRowActionsProps { + tag: Tag; + onEdit: (tag: Tag) => void; + onDelete: (tagName: string) => void; +} + +function TagRowActions({ tag, onEdit, onDelete }: TagRowActionsProps) { + const isDynamic = isDynamicSpendTag(tag); + + return ( + + + + + + onEdit(tag)} + > + + Edit + + onDelete(tag.name)} + > + + Delete + + + + ); +} + +interface TagTableColumnsDeps { + onSelectTag: (tagName: string) => void; + onEdit: (tag: Tag) => void; + onDelete: (tagName: string) => void; +} + +export const getTagTableColumns = ({ onSelectTag, onEdit, onDelete }: TagTableColumnsDeps): ColumnDef[] => [ + { + id: "name", + accessorKey: "name", + meta: { title: "Tag Name" }, + header: ({ column }) => , + size: 260, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "description", + accessorKey: "description", + meta: { title: "Description" }, + header: "Description", + size: 300, + enableSorting: false, + cell: ({ row }) => { + const description = row.original.description; + return ( + + {description || "-"} + + ); + }, + }, + { + id: "models", + meta: { title: "Allowed Models", skeleton: "chips" }, + header: "Allowed Models", + size: 240, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "created_at", + accessorKey: "created_at", + sortingFn: "datetime", + meta: { title: "Created" }, + header: ({ column }) => , + size: 150, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx index 7d3f019e6e3..2d99041c1ce 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/page.tsx @@ -1,9 +1,9 @@ "use client"; -import OldTeams from "@/components/OldTeams"; +import Teams from "@/components/Teams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function TeamsPage() { const { accessToken, userId, userRole, premiumUser } = useAuthorized(); - return ; + return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index a95d48aa75b..d2f75609d18 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -8,6 +8,7 @@ import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { Card, Col, @@ -94,7 +95,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // Debounced search for user selector const [userSearchInput, setUserSearchInput] = useState(""); const [debouncedUserSearch, setDebouncedUserSearch] = useDebouncedState("", { - wait: 300, + wait: DEBOUNCE_WAIT_MS, }); const { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx index 18709e05df8..db3b17d6af3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx @@ -16,6 +16,7 @@ import { import OnboardingModal, { InvitationLink } from "@/components/onboarding_link"; import { updateExistingKeys } from "@/utils/dataUtils"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -86,7 +87,7 @@ const ViewUserDashboard: React.FC = ({ const [userToDelete, setUserToDelete] = useState(null); const [activeTab, setActiveTab] = useState("users"); const [filters, setFilters] = useState(initialFilters); - const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(filters, { wait: 300 }); + const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(filters, { wait: DEBOUNCE_WAIT_MS }); const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); const [invitationLinkData, setInvitationLinkData] = useState(null); const [baseUrl, setBaseUrl] = useState(null); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx index fd02effc972..565e645f7b5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx @@ -138,7 +138,7 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID />
) : ( -
+

Vector Store Management

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx index e73abfe7cd6..1aee3fcc8ab 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx @@ -4,7 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import WorkflowRuns from "./WorkflowRuns"; -vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" })); +vi.mock("@/components/networking", () => ({ + proxyBaseUrl: "", + getGlobalLitellmHeaderName: () => "x-litellm-api-key", +})); interface FakeRun { run_id: string; @@ -78,4 +81,18 @@ describe("WorkflowRuns (migrated onto shared DataTable)", () => { expect(await screen.findByText("No workflow runs yet")).toBeInTheDocument(); }); + + it("sends the configured litellm key header on every fetch instead of hardcoding Authorization", async () => { + const user = userEvent.setup(); + const fetchSpy = mockFetch(RUNS); + vi.stubGlobal("fetch", fetchSpy); + render(); + + await user.click(await screen.findByText("First run")); + + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(3)); + for (const [url, init] of fetchSpy.mock.calls as [string, RequestInit][]) { + expect(init.headers, url).toEqual({ "x-litellm-api-key": "Bearer tok" }); + } + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx index 9afa07251c2..7354b7479f4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button, Collapse, Drawer, Empty, Spin, Tooltip, Typography } from "antd"; import { ReloadOutlined } from "@ant-design/icons"; import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table"; -import { proxyBaseUrl } from "@/components/networking"; +import { getGlobalLitellmHeaderName, proxyBaseUrl } from "@/components/networking"; import { DataTable, DataTableFilterDrawer, @@ -507,7 +507,7 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { setLoadingRuns(true); try { const res = await fetch(`${proxyBaseUrl ?? ""}/v1/workflows/runs?limit=100`, { - headers: { Authorization: `Bearer ${accessToken}` }, + headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); @@ -531,10 +531,10 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { const base = proxyBaseUrl ?? ""; const [evRes, msgRes] = await Promise.all([ fetch(`${base}/v1/workflows/runs/${run.run_id}/events`, { - headers: { Authorization: `Bearer ${accessToken}` }, + headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` }, }), fetch(`${base}/v1/workflows/runs/${run.run_id}/messages`, { - headers: { Authorization: `Bearer ${accessToken}` }, + headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` }, }), ]); const evData = evRes.ok ? await evRes.json() : { events: [] }; diff --git a/ui/litellm-dashboard/src/app/globals.css b/ui/litellm-dashboard/src/app/globals.css index 0b555fcdd52..4589d0f528a 100644 --- a/ui/litellm-dashboard/src/app/globals.css +++ b/ui/litellm-dashboard/src/app/globals.css @@ -11,6 +11,25 @@ @custom-variant dark (&:where(.dark, .dark *)); +/* shadcn Base UI primitives reference these variants; upstream omits them (shadcn-ui/ui#9196) */ +@custom-variant data-open (&:where([data-state="open"], [data-open]:not([data-open="false"]))); +@custom-variant data-closed (&:where([data-state="closed"], [data-closed]:not([data-closed="false"]))); +@custom-variant data-checked (&:where([data-state="checked"], [data-checked]:not([data-checked="false"]))); +@custom-variant data-unchecked (&:where([data-state="unchecked"], [data-unchecked]:not([data-unchecked="false"]))); +@custom-variant data-selected (&:where([data-selected="true"])); +@custom-variant data-disabled (&:where([data-disabled="true"], [data-disabled]:not([data-disabled="false"]))); +@custom-variant data-active (&:where([data-state="active"], [data-active]:not([data-active="false"]))); +@custom-variant data-horizontal (&:where([data-orientation="horizontal"])); +@custom-variant data-vertical (&:where([data-orientation="vertical"])); + +@utility no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + :root { --radius: 0.5rem; --background: oklch(1 0 0); diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index d3dd914f5c5..eb47f775d90 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -382,7 +382,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, } return ( -
+
{publicPage == false ? (
{/* Header with Title, Description and URL */} diff --git a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx index d42d5ab324a..1d19ba3255d 100644 --- a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx +++ b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx @@ -1,4 +1,5 @@ import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { LoadingOutlined } from "@ant-design/icons"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { Select } from "antd"; @@ -16,7 +17,6 @@ export interface PaginatedKeyAliasSelectProps { } const SCROLL_THRESHOLD = 0.8; -const DEBOUNCE_MS = 300; export const PaginatedKeyAliasSelect = ({ value, @@ -30,7 +30,7 @@ export const PaginatedKeyAliasSelect = ({ }: PaginatedKeyAliasSelectProps) => { const [searchInput, setSearchInput] = useState(""); const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { - wait: DEBOUNCE_MS, + wait: DEBOUNCE_WAIT_MS, }); const teamId = allFilters?.["Team ID"] || undefined; diff --git a/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx index da3ecf77ded..a77b2cf561e 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx @@ -1,4 +1,5 @@ import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { LoadingOutlined } from "@ant-design/icons"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { Select, Space, Typography } from "antd"; @@ -17,7 +18,6 @@ export interface PaginatedModelSelectProps { } const SCROLL_THRESHOLD = 0.8; -const DEBOUNCE_MS = 300; export const PaginatedModelSelect = ({ value, @@ -30,7 +30,7 @@ export const PaginatedModelSelect = ({ }: PaginatedModelSelectProps) => { const [searchInput, setSearchInput] = useState(""); const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { - wait: DEBOUNCE_MS, + wait: DEBOUNCE_WAIT_MS, }); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteModelInfo( diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx deleted file mode 100644 index 0b6e5786aaf..00000000000 --- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx +++ /dev/null @@ -1,1148 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import React from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key"; -import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking"; -import OldTeams from "./OldTeams"; -import { teamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; - -const mockTeamInfoView = vi.fn(); -const mockUseOrganizations = vi.fn(); - -vi.mock("./networking", () => ({ - teamCreateCall: vi.fn(), - teamDeleteCall: vi.fn(), - fetchMCPAccessGroups: vi.fn(), - v2TeamListCall: vi.fn(), - getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [] }), - getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }), -})); - -vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ - teamListCall: vi.fn().mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 0 }), -})); - -vi.mock("./molecules/notifications_manager", () => ({ - default: { - info: vi.fn(), - success: vi.fn(), - error: vi.fn(), - fromBackend: vi.fn(), - }, -})); - -vi.mock("./key_team_helpers/fetch_available_models_team_key", () => ({ - fetchAvailableModelsForTeamOrKey: vi.fn(), - getModelDisplayName: vi.fn((model: string) => model), - unfurlWildcardModelsInList: vi.fn((teamModels: string[], allModels: string[]) => { - const wildcardDisplayNames: string[] = []; - const expandedModels: string[] = []; - - teamModels.forEach((teamModel) => { - if (teamModel.endsWith("/*")) { - const provider = teamModel.replace("/*", ""); - const matchingModels = allModels.filter((model) => model.startsWith(provider + "/")); - expandedModels.push(...matchingModels); - wildcardDisplayNames.push(teamModel); - } else { - expandedModels.push(teamModel); - } - }); - - return [...wildcardDisplayNames, ...expandedModels].filter((item, index, array) => array.indexOf(item) === index); - }), -})); - -vi.mock("@/components/team/TeamInfo", () => ({ - __esModule: true, - default: (props: any) => { - mockTeamInfoView(props); - return
; - }, -})); - -vi.mock("./ModelSelect/ModelSelect", () => { - const ModelSelect = React.forwardRef(({ value, onChange, dataTestId, id }: any, ref: any) => { - return ( - { - if (onChange) { - const newVal = e.target.value - ? e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean) - : []; - onChange(newVal); - } - }} - /> - ); - }); - ModelSelect.displayName = "ModelSelect"; - return { - ModelSelect, - }; -}); - -vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ - useOrganizations: () => mockUseOrganizations(), -})); - -vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ - useAccessGroups: vi.fn().mockReturnValue({ - data: [ - { access_group_id: "ag-1", access_group_name: "Group 1" }, - { access_group_id: "ag-2", access_group_name: "Group 2" }, - ], - isLoading: false, - isError: false, - }), -})); - -vi.mock("./common_components/AccessGroupSelector", () => ({ - default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => ( - onChange?.(e.target.value ? e.target.value.split(",").map((s) => s.trim()) : [])} - /> - ), -})); - -const createQueryClient = () => { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); -}; - -const renderWithQueryClient = (component: React.ReactElement) => { - const queryClient = createQueryClient(); - return render({component}); -}; - -describe("OldTeams - handleCreate organization handling", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockTeamInfoView.mockClear(); - vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); - vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); - vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); - mockUseOrganizations.mockReturnValue({ data: null }); - }); - - it("should not include organization_id when it's an empty string", async () => { - const mockAccessToken = "test-token"; - const formValues: Record = { - team_alias: "Test Team", - organization_id: "", // Empty string - models: [], - }; - - // Simulate the handleCreate logic - let organizationId = formValues?.organization_id || null; - if (organizationId === "" || typeof organizationId !== "string") { - formValues.organization_id = null; - } else { - formValues.organization_id = organizationId.trim(); - } - - expect(formValues.organization_id).toBeNull(); - expect(formValues.organization_id).not.toBe(""); - }); - - it("should set organization_id to null when it's not a string type", async () => { - const formValues: Record = { - team_alias: "Test Team", - organization_id: undefined, - models: [], - }; - - // Simulate the handleCreate logic - let organizationId = formValues?.organization_id || null; - if (organizationId === "" || typeof organizationId !== "string") { - formValues.organization_id = null; - } else { - formValues.organization_id = organizationId.trim(); - } - - expect(formValues.organization_id).toBeNull(); - }); - - it("should trim and keep valid organization_id string", async () => { - const formValues: Record = { - team_alias: "Test Team", - organization_id: " org-123 ", // String with whitespace - models: [], - }; - - // Simulate the handleCreate logic - let organizationId = formValues?.organization_id || null; - if (organizationId === "" || typeof organizationId !== "string") { - formValues.organization_id = null; - } else { - formValues.organization_id = organizationId.trim(); - } - - expect(formValues.organization_id).toBe("org-123"); - }); - - it("should keep valid organization_id without modification", async () => { - const formValues: Record = { - team_alias: "Test Team", - organization_id: "f874bb43-b898-4813-beca-4054d224eafc", - models: [], - }; - - // Simulate the handleCreate logic - let organizationId = formValues?.organization_id || null; - if (organizationId === "" || typeof organizationId !== "string") { - formValues.organization_id = null; - } else { - formValues.organization_id = organizationId.trim(); - } - - expect(formValues.organization_id).toBe("f874bb43-b898-4813-beca-4054d224eafc"); - }); - - it("should not send organization_id field when converting empty string to null", async () => { - const formValues: Record = { - team_alias: "Test Team", - organization_id: "", - models: ["gpt-4"], - max_budget: 100, - }; - - // Simulate the handleCreate logic - let organizationId = formValues?.organization_id || null; - if (organizationId === "" || typeof organizationId !== "string") { - formValues.organization_id = null; - } else { - formValues.organization_id = organizationId.trim(); - } - - // Verify the structure - expect(formValues).toEqual({ - team_alias: "Test Team", - organization_id: null, - models: ["gpt-4"], - max_budget: 100, - }); - - // Verify we're not sending an empty string - expect(formValues.organization_id).not.toBe(""); - - // Verify it's explicitly null, not undefined - expect(formValues.organization_id).toBeNull(); - }); - - it("should handle when currentOrg is used as fallback", async () => { - const currentOrg = { - organization_id: "fallback-org-id", - organization_alias: "Fallback Org", - models: [], - members: [], - }; - - const formValues: Record = { - team_alias: "Test Team", - models: [], - }; - - // Simulate the handleCreate logic with currentOrg fallback - let organizationId = formValues?.organization_id || currentOrg?.organization_id; - if (organizationId === "" || typeof organizationId !== "string") { - formValues.organization_id = null; - } else { - formValues.organization_id = organizationId.trim(); - } - - expect(formValues.organization_id).toBe("fallback-org-id"); - }); - - it("should not include organizations as an empty array in the request payload", async () => { - const mockTeamCreateCall = vi.mocked(teamCreateCall); - const mockAccessToken = "test-token"; - - const formValues = { - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - organizations: [], // This should never be sent - }; - - // Remove organizations key if it's empty - if (Array.isArray(formValues.organizations) && formValues.organizations.length === 0) { - delete (formValues as any).organizations; - } - - // Verify organizations key is removed - expect(formValues).not.toHaveProperty("organizations"); - expect(formValues).toEqual({ - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - }); - }); - - it("should handle organization_id validation for org admins", () => { - // This test simulates the validation that should happen for org admins - const isOrgAdmin = true; - const formValues: Record = { - team_alias: "Test Team", - // organization_id is missing/undefined - }; - - // For org admins, organization_id should be required - const hasOrganization = - formValues.organization_id !== undefined && - formValues.organization_id !== null && - formValues.organization_id !== ""; - - if (isOrgAdmin && !hasOrganization) { - // This should trigger validation error - expect(hasOrganization).toBe(false); - } - }); - - it("should allow null organization_id for global admins", () => { - const isAdmin = true; - const formValues: Record = { - team_alias: "Test Team", - organization_id: null, - models: [], - }; - - // Global admins can create teams without an organization - if (isAdmin) { - expect(formValues.organization_id).toBeNull(); - // This is valid for admins - } - }); - - it("should ensure organization_id is never an empty list", () => { - const invalidFormValues: Record = { - team_alias: "Test Team", - organization_id: [], // Wrong type - should be string or null - }; - - // Type check: organization_id should never be an array - expect(Array.isArray(invalidFormValues.organization_id)).toBe(true); - - // Correct it to null - if (Array.isArray(invalidFormValues.organization_id)) { - invalidFormValues.organization_id = null; - } - - expect(invalidFormValues.organization_id).toBeNull(); - expect(Array.isArray(invalidFormValues.organization_id)).toBe(false); - }); - - it("should clear the delete modal when the cancel button is clicked", async () => { - mockUseOrganizations.mockReturnValue({ data: [] }); - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - await waitFor(() => { - expect(screen.getByTestId("delete-team-button")).toBeInTheDocument(); - }); - const deleteTeamButton = screen.getByTestId("delete-team-button"); - act(() => { - fireEvent.click(deleteTeamButton); - }); - expect(screen.getByText("Delete Team?")).toBeInTheDocument(); - }); -}); - -describe("OldTeams - empty state", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseOrganizations.mockReturnValue({ data: [] }); - }); - - it("should display empty state message when teams array is empty", async () => { - vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("No teams yet")).toBeInTheDocument(); - }); - expect( - screen.getByText("Create your first team to organize members and manage access to models."), - ).toBeInTheDocument(); - }); - - it("should display empty state message when teams is null", async () => { - vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("No teams yet")).toBeInTheDocument(); - }); - expect( - screen.getByText("Create your first team to organize members and manage access to models."), - ).toBeInTheDocument(); - }); - - it("should not display empty state when teams array has items", async () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("Test Team")).toBeInTheDocument(); - }); - expect(screen.queryByText("No teams yet")).not.toBeInTheDocument(); - expect( - screen.queryByText("Create your first team to organize members and manage access to models."), - ).not.toBeInTheDocument(); - }); -}); - -describe("OldTeams - helper functions", () => { - describe("getAdminOrganizations", () => { - it("should return all organizations for Admin role", () => { - const organizations = [ - { - organization_id: "org-1", - organization_alias: "Org 1", - models: [], - members: [], - }, - { - organization_id: "org-2", - organization_alias: "Org 2", - models: [], - members: [], - }, - ]; - - // Simulate getAdminOrganizations logic for Admin - const userRole = "Admin"; - const result = userRole === "Admin" ? organizations : []; - - expect(result).toEqual(organizations); - expect(result.length).toBe(2); - }); - - it("should return only org_admin organizations for Org Admin role", () => { - const userID = "user-123"; - const userRole = "Org Admin"; - const organizations = [ - { - organization_id: "org-1", - organization_alias: "Org 1", - models: [], - members: [{ user_id: "user-123", user_role: "org_admin" }], - }, - { - organization_id: "org-2", - organization_alias: "Org 2", - models: [], - members: [{ user_id: "user-456", user_role: "org_admin" }], - }, - { - organization_id: "org-3", - organization_alias: "Org 3", - models: [], - members: [{ user_id: "user-123", user_role: "member" }], - }, - ]; - - // Simulate getAdminOrganizations logic - const result = organizations.filter((org) => - org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), - ); - - expect(result.length).toBe(1); - expect(result[0].organization_id).toBe("org-1"); - }); - - it("should return empty array when user is not admin of any organization", () => { - const userID = "user-999"; - const organizations = [ - { - organization_id: "org-1", - organization_alias: "Org 1", - models: [], - members: [{ user_id: "user-123", user_role: "org_admin" }], - }, - ]; - - // Simulate getAdminOrganizations logic - const result = organizations.filter((org) => - org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), - ); - - expect(result.length).toBe(0); - }); - }); - - describe("canCreateOrManageTeams", () => { - it("should return true for Admin role", () => { - const userRole = "Admin"; - const result = userRole === "Admin"; - expect(result).toBe(true); - }); - - it("should return true for org_admin in any organization", () => { - const userID = "user-123"; - const organizations = [ - { - organization_id: "org-1", - organization_alias: "Org 1", - models: [], - members: [{ user_id: "user-123", user_role: "org_admin" }], - }, - ]; - - const result = organizations.some((org) => - org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), - ); - - expect(result).toBe(true); - }); - - it("should return false when user has no admin permissions", () => { - const userID = "user-123"; - const userRole: string = "User"; - const organizations = [ - { - organization_id: "org-1", - organization_alias: "Org 1", - models: [], - members: [{ user_id: "user-123", user_role: "member" }], - }, - ]; - - const isAdmin = userRole === "Admin"; - const isOrgAdmin = organizations.some((org) => - org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), - ); - - expect(isAdmin || isOrgAdmin).toBe(false); - }); - }); -}); - -describe("OldTeams - premium props", () => { - beforeEach(() => { - mockTeamInfoView.mockClear(); - vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); - vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); - vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); - mockUseOrganizations.mockReturnValue({ data: [] }); - }); - - it("passes premiumUser flag to TeamInfoView", async () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "team-123456789", - team_alias: "Premium Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - const teamIdElement = await screen.findByText("team-123456789"); - act(() => { - fireEvent.click(teamIdElement); - }); - - await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); - - expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ premiumUser: true })); - }); -}); - -describe("OldTeams - Default Team Settings tab visibility", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseOrganizations.mockReturnValue({ data: [] }); - }); - - it("should show Default Team Settings tab for Admin role", () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument(); - }); - - it("should show Default Team Settings tab for proxy_admin role", () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument(); - }); - - it("should not show Default Team Settings tab for proxy_admin_viewer role", () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument(); - }); - - it("should not show Default Team Settings tab for Admin Viewer role", () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument(); - }); -}); - -describe("OldTeams - access_group_ids in team create", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockTeamInfoView.mockClear(); - vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]); - vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); - vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); - vi.mocked(teamCreateCall).mockResolvedValue({ - team_id: "new-team-1", - team_alias: "Test Team", - models: ["gpt-4"], - organization_id: null, - keys: [], - members_with_roles: [], - spend: 0, - }); - mockUseOrganizations.mockReturnValue({ - data: [{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }], - }); - }); - - it("should pass access_group_ids to teamCreateCall when creating team", async () => { - vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); - renderWithQueryClient(); - - const createButton = screen.getAllByRole("button", { name: /create team/i })[0]; - act(() => { - fireEvent.click(createButton); - }); - - await waitFor(() => { - expect(screen.getByLabelText(/team name/i)).toBeInTheDocument(); - }); - - const teamNameInput = screen.getByLabelText(/team name/i); - fireEvent.change(teamNameInput, { target: { value: "Test Team" } }); - - const modelsInput = screen.getByTestId("create-team-models-select"); - fireEvent.change(modelsInput, { target: { value: "gpt-4" } }); - - const additionalSettingsAccordion = screen.getByText("Additional Settings"); - fireEvent.click(additionalSettingsAccordion); - - await waitFor(() => { - expect(screen.getByTestId("access-group-selector")).toBeInTheDocument(); - }); - - const accessGroupInput = screen.getByTestId("access-group-selector"); - fireEvent.change(accessGroupInput, { target: { value: "ag-1,ag-2" } }); - - const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i }); - const createTeamSubmitButton = createTeamSubmitButtons[createTeamSubmitButtons.length - 1]; - fireEvent.click(createTeamSubmitButton); - - await waitFor(() => { - expect(teamCreateCall).toHaveBeenCalledWith( - "test-token", - expect.objectContaining({ - team_alias: "Test Team", - models: ["gpt-4"], - access_group_ids: ["ag-1", "ag-2"], - }), - ); - }); - }); -}); - -describe("OldTeams - models dropdown options", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]); - mockUseOrganizations.mockReturnValue({ data: [] }); - }); - - it("should not render all-proxy-models option in models select", async () => { - vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]); - - vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); - renderWithQueryClient(); - - await waitFor(() => { - expect(fetchAvailableModelsForTeamOrKey).toHaveBeenCalled(); - }); - - const createButton = screen.getAllByRole("button", { name: /create team/i })[0]; - act(() => { - fireEvent.click(createButton); - }); - - await waitFor(() => { - expect(screen.getByLabelText(/models/i)).toBeInTheDocument(); - }); - const allProxyModelsOption = screen.queryByText("All Proxy Models"); - expect(allProxyModelsOption).not.toBeInTheDocument(); - }); -}); - -describe("OldTeams - organization alias display", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseOrganizations.mockReturnValue({ data: [] }); - }); - - it("should display organization alias instead of organization id", async () => { - const mockOrganizations = [ - { - organization_id: "org-123", - organization_alias: "Test Organization", - budget_id: "budget-1", - metadata: {}, - models: [], - spend: 0, - model_spend: {}, - created_at: new Date().toISOString(), - created_by: "user-1", - updated_at: new Date().toISOString(), - updated_by: "user-1", - litellm_budget_table: null, - teams: null, - users: null, - members: null, - }, - ]; - - mockUseOrganizations.mockReturnValue({ data: mockOrganizations }); - - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("Test Organization")).toBeInTheDocument(); - }); - expect(screen.queryByText("org-123")).not.toBeInTheDocument(); - }); - - it("should display organization id when alias is not found", async () => { - mockUseOrganizations.mockReturnValue({ data: [] }); - - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-unknown", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("org-unknown")).toBeInTheDocument(); - }); - }); - - it("should display N/A when organization_id is null", async () => { - mockUseOrganizations.mockReturnValue({ data: [] }); - - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: null, - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - await waitFor(() => { - // When organization_id is null, the table shows "—" in the Organization column - expect(screen.getAllByText("—").length).toBeGreaterThan(0); - }); - }); -}); - -describe("OldTeams - Resources column keys badge", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseOrganizations.mockReturnValue({ data: [] }); - }); - - it("renders keys_count from the v2 payload in the Resources badge", async () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Team With Keys", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - keys_count: 3, - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - const { container } = renderWithQueryClient( - , - ); - - await waitFor(() => { - expect(screen.getByText("Team With Keys")).toBeInTheDocument(); - }); - const cyanTag = container.querySelector(".ant-tag-cyan"); - expect(cyanTag).not.toBeNull(); - expect(cyanTag?.textContent).toContain("3"); - }); - - it("falls back to keys.length when keys_count is absent", async () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "2", - team_alias: "Legacy Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [{ token: "t1" }, { token: "t2" }], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - const { container } = renderWithQueryClient( - , - ); - - await waitFor(() => { - expect(screen.getByText("Legacy Team")).toBeInTheDocument(); - }); - const cyanTag = container.querySelector(".ant-tag-cyan"); - expect(cyanTag).not.toBeNull(); - expect(cyanTag?.textContent).toContain("2"); - }); -}); - -describe("OldTeams - delete team warning copy", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseOrganizations.mockReturnValue({ data: [] }); - }); - - const openDeleteModal = async (team: any) => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [team], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - await waitFor(() => { - expect(screen.getByTestId("delete-team-button")).toBeInTheDocument(); - }); - act(() => { - fireEvent.click(screen.getByTestId("delete-team-button")); - }); - expect(screen.getByText("Delete Team?")).toBeInTheDocument(); - }; - - const baseTeam = { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - members_with_roles: [], - spend: 0, - }; - - it("warns that the team's models are deleted when the team has keys", async () => { - await openDeleteModal({ ...baseTeam, keys: [], keys_count: 5 }); - - expect(screen.getByText(/Warning: This team has 5 keys associated with it/i)).toHaveTextContent( - /along with any models created for this team/i, - ); - expect(screen.getByText(/Are you sure you want to delete this team/i)).toHaveTextContent( - /any models created for it/i, - ); - }); - - it("still warns about model deletion in the confirmation message when the team has no keys", async () => { - await openDeleteModal({ ...baseTeam, keys: [], keys_count: 0 }); - - expect(screen.queryByText(/Warning: This team has/i)).not.toBeInTheDocument(); - expect(screen.getByText(/Are you sure you want to delete this team/i)).toHaveTextContent( - /any models created for it/i, - ); - }); -}); - -describe("OldTeams - LIT-2530 organization stays optional for proxy admin with a single org", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockTeamInfoView.mockClear(); - vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]); - vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); - vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); - vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); - vi.mocked(teamCreateCall).mockResolvedValue({ - team_id: "new-team-1", - team_alias: "No Org Team", - models: ["gpt-4"], - organization_id: null, - keys: [], - members_with_roles: [], - spend: 0, - }); - mockUseOrganizations.mockReturnValue({ - data: [{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }], - }); - }); - - it("creates a team with no organization when exactly one organization exists", async () => { - renderWithQueryClient(); - - const createButton = screen.getAllByRole("button", { name: /create team/i })[0]; - act(() => { - fireEvent.click(createButton); - }); - - await waitFor(() => { - expect(screen.getByLabelText(/team name/i)).toBeInTheDocument(); - }); - - fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "No Org Team" } }); - fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } }); - - const submitButtons = screen.getAllByRole("button", { name: /create team/i }); - fireEvent.click(submitButtons[submitButtons.length - 1]); - - await waitFor(() => { - expect(teamCreateCall).toHaveBeenCalledWith( - "test-token", - expect.objectContaining({ team_alias: "No Org Team", organization_id: null }), - ); - }); - }); -}); diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx new file mode 100644 index 00000000000..7065b1a5fb6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -0,0 +1,661 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key"; +import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking"; +import Teams from "./Teams"; + +const mockTeamInfoView = vi.fn(); +const mockUseOrganizations = vi.fn(); + +// The teams grid is unit-tested in TeamsPage/TeamsTable.test.tsx. Here we stub it and drive its callbacks +// directly so we can test the Teams shell wiring (delete modal, detail view) without the real DataTable. +let mockTeamsTableProps: any = null; +vi.mock("./TeamsPage/TeamsTable", () => ({ + TeamsTable: (props: any) => { + mockTeamsTableProps = props; + return
; + }, +})); + +vi.mock("./networking", () => ({ + teamCreateCall: vi.fn(), + teamDeleteCall: vi.fn(), + fetchMCPAccessGroups: vi.fn(), + v2TeamListCall: vi.fn(), + getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [] }), + getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }), +})); + +// Teams invalidates teamsTableKeys on mutations; the selected team is passed up from the table. +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + teamsTableKeys: { all: ["teamsTable"] }, +})); + +vi.mock("./molecules/notifications_manager", () => ({ + default: { + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), + fromBackend: vi.fn(), + }, +})); + +vi.mock("./key_team_helpers/fetch_available_models_team_key", () => ({ + fetchAvailableModelsForTeamOrKey: vi.fn(), + getModelDisplayName: vi.fn((model: string) => model), + unfurlWildcardModelsInList: vi.fn((teamModels: string[], allModels: string[]) => { + const wildcardDisplayNames: string[] = []; + const expandedModels: string[] = []; + + teamModels.forEach((teamModel) => { + if (teamModel.endsWith("/*")) { + const provider = teamModel.replace("/*", ""); + const matchingModels = allModels.filter((model) => model.startsWith(provider + "/")); + expandedModels.push(...matchingModels); + wildcardDisplayNames.push(teamModel); + } else { + expandedModels.push(teamModel); + } + }); + + return [...wildcardDisplayNames, ...expandedModels].filter((item, index, array) => array.indexOf(item) === index); + }), +})); + +vi.mock("@/components/team/TeamInfo", () => ({ + __esModule: true, + default: (props: any) => { + mockTeamInfoView(props); + return
; + }, +})); + +vi.mock("./ModelSelect/ModelSelect", () => { + const ModelSelect = React.forwardRef(({ value, onChange, dataTestId, id }: any, ref: any) => { + return ( + { + if (onChange) { + const newVal = e.target.value + ? e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean) + : []; + onChange(newVal); + } + }} + /> + ); + }); + ModelSelect.displayName = "ModelSelect"; + return { + ModelSelect, + }; +}); + +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: () => mockUseOrganizations(), +})); + +vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ + useAccessGroups: vi.fn().mockReturnValue({ + data: [ + { access_group_id: "ag-1", access_group_name: "Group 1" }, + { access_group_id: "ag-2", access_group_name: "Group 2" }, + ], + isLoading: false, + isError: false, + }), +})); + +vi.mock("./common_components/AccessGroupSelector", () => ({ + default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => ( + onChange?.(e.target.value ? e.target.value.split(",").map((s) => s.trim()) : [])} + /> + ), +})); + +const baseTableTeam = { + team_id: "1", + team_alias: "Test Team", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, +}; + +const createQueryClient = () => { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +}; + +const renderWithQueryClient = (component: React.ReactElement) => { + const queryClient = createQueryClient(); + return render({component}); +}; + +// Re-establish safe defaults before every test (clearAllMocks keeps return values, so restore them here). +beforeEach(() => { + mockTeamsTableProps = null; +}); + +describe("Teams - handleCreate organization handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTeamInfoView.mockClear(); + mockTeamsTableProps = null; + vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); + vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); + mockUseOrganizations.mockReturnValue({ data: null }); + }); + + it("should not include organization_id when it's an empty string", async () => { + const formValues: Record = { + team_alias: "Test Team", + organization_id: "", // Empty string + models: [], + }; + + // Simulate the handleCreate logic + let organizationId = formValues?.organization_id || null; + if (organizationId === "" || typeof organizationId !== "string") { + formValues.organization_id = null; + } else { + formValues.organization_id = organizationId.trim(); + } + + expect(formValues.organization_id).toBeNull(); + expect(formValues.organization_id).not.toBe(""); + }); + + it("should set organization_id to null when it's not a string type", async () => { + const formValues: Record = { + team_alias: "Test Team", + organization_id: undefined, + models: [], + }; + + let organizationId = formValues?.organization_id || null; + if (organizationId === "" || typeof organizationId !== "string") { + formValues.organization_id = null; + } else { + formValues.organization_id = organizationId.trim(); + } + + expect(formValues.organization_id).toBeNull(); + }); + + it("should trim and keep valid organization_id string", async () => { + const formValues: Record = { + team_alias: "Test Team", + organization_id: " org-123 ", + models: [], + }; + + let organizationId = formValues?.organization_id || null; + if (organizationId === "" || typeof organizationId !== "string") { + formValues.organization_id = null; + } else { + formValues.organization_id = organizationId.trim(); + } + + expect(formValues.organization_id).toBe("org-123"); + }); + + it("should keep valid organization_id without modification", async () => { + const formValues: Record = { + team_alias: "Test Team", + organization_id: "f874bb43-b898-4813-beca-4054d224eafc", + models: [], + }; + + let organizationId = formValues?.organization_id || null; + if (organizationId === "" || typeof organizationId !== "string") { + formValues.organization_id = null; + } else { + formValues.organization_id = organizationId.trim(); + } + + expect(formValues.organization_id).toBe("f874bb43-b898-4813-beca-4054d224eafc"); + }); + + it("should not send organization_id field when converting empty string to null", async () => { + const formValues: Record = { + team_alias: "Test Team", + organization_id: "", + models: ["gpt-4"], + max_budget: 100, + }; + + let organizationId = formValues?.organization_id || null; + if (organizationId === "" || typeof organizationId !== "string") { + formValues.organization_id = null; + } else { + formValues.organization_id = organizationId.trim(); + } + + expect(formValues).toEqual({ + team_alias: "Test Team", + organization_id: null, + models: ["gpt-4"], + max_budget: 100, + }); + expect(formValues.organization_id).not.toBe(""); + expect(formValues.organization_id).toBeNull(); + }); + + it("should handle when currentOrg is used as fallback", async () => { + const currentOrg = { + organization_id: "fallback-org-id", + organization_alias: "Fallback Org", + models: [], + members: [], + }; + + const formValues: Record = { + team_alias: "Test Team", + models: [], + }; + + let organizationId = formValues?.organization_id || currentOrg?.organization_id; + if (organizationId === "" || typeof organizationId !== "string") { + formValues.organization_id = null; + } else { + formValues.organization_id = organizationId.trim(); + } + + expect(formValues.organization_id).toBe("fallback-org-id"); + }); + + it("opens the delete modal when the table's delete action fires", async () => { + mockUseOrganizations.mockReturnValue({ data: [] }); + renderWithQueryClient(); + + await waitFor(() => expect(mockTeamsTableProps).not.toBeNull()); + await act(async () => { + mockTeamsTableProps.onDeleteTeam(baseTableTeam); + }); + + expect(screen.getByText("Delete Team?")).toBeInTheDocument(); + }); +}); + +describe("Teams - helper functions", () => { + describe("getAdminOrganizations", () => { + it("should return all organizations for Admin role", () => { + const organizations = [ + { organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }, + { organization_id: "org-2", organization_alias: "Org 2", models: [], members: [] }, + ]; + + const userRole = "Admin"; + const result = userRole === "Admin" ? organizations : []; + + expect(result).toEqual(organizations); + expect(result.length).toBe(2); + }); + + it("should return only org_admin organizations for Org Admin role", () => { + const userID = "user-123"; + const organizations = [ + { + organization_id: "org-1", + organization_alias: "Org 1", + models: [], + members: [{ user_id: "user-123", user_role: "org_admin" }], + }, + { + organization_id: "org-2", + organization_alias: "Org 2", + models: [], + members: [{ user_id: "user-456", user_role: "org_admin" }], + }, + { + organization_id: "org-3", + organization_alias: "Org 3", + models: [], + members: [{ user_id: "user-123", user_role: "member" }], + }, + ]; + + const result = organizations.filter((org) => + org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), + ); + + expect(result.length).toBe(1); + expect(result[0].organization_id).toBe("org-1"); + }); + + it("should return empty array when user is not admin of any organization", () => { + const userID = "user-999"; + const organizations = [ + { + organization_id: "org-1", + organization_alias: "Org 1", + models: [], + members: [{ user_id: "user-123", user_role: "org_admin" }], + }, + ]; + + const result = organizations.filter((org) => + org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), + ); + + expect(result.length).toBe(0); + }); + }); + + describe("canCreateOrManageTeams", () => { + it("should return true for Admin role", () => { + const userRole = "Admin"; + expect(userRole === "Admin").toBe(true); + }); + + it("should return true for org_admin in any organization", () => { + const userID = "user-123"; + const organizations = [ + { + organization_id: "org-1", + organization_alias: "Org 1", + models: [], + members: [{ user_id: "user-123", user_role: "org_admin" }], + }, + ]; + + const result = organizations.some((org) => + org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), + ); + + expect(result).toBe(true); + }); + + it("should return false when user has no admin permissions", () => { + const userID = "user-123"; + const userRole: string = "User"; + const organizations = [ + { + organization_id: "org-1", + organization_alias: "Org 1", + models: [], + members: [{ user_id: "user-123", user_role: "member" }], + }, + ]; + + const isAdmin = userRole === "Admin"; + const isOrgAdmin = organizations.some((org) => + org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), + ); + + expect(isAdmin || isOrgAdmin).toBe(false); + }); + }); +}); + +describe("Teams - premium props", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTeamInfoView.mockClear(); + vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); + vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); + mockUseOrganizations.mockReturnValue({ data: [] }); + }); + + it("passes premiumUser flag to TeamInfoView when a team is opened", async () => { + const premiumTeam = { ...baseTableTeam, team_id: "team-123456789", team_alias: "Premium Team" }; + renderWithQueryClient(); + + await waitFor(() => expect(mockTeamsTableProps).not.toBeNull()); + act(() => mockTeamsTableProps.onSelectTeam(premiumTeam)); + + await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); + expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ premiumUser: true })); + }); +}); + +describe("Teams - Create Team CTA is grouped with the tabs on the left", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseOrganizations.mockReturnValue({ data: [] }); + }); + + it("renders the Create Team button inside the tab bar, ahead of the tabs", () => { + const { container } = renderWithQueryClient(); + + const createButton = screen.getByTestId("create-team-button"); + const tabNav = container.querySelector(".ant-tabs-nav"); + + // The CTA lives in the tab bar's left slot, not the standalone page header. + expect(tabNav).not.toBeNull(); + expect(tabNav!.contains(createButton)).toBe(true); + + // It reads as the left end of the cluster: it precedes the first tab in DOM order. + const firstTab = screen.getByRole("tab", { name: "Your Teams" }); + expect(createButton.compareDocumentPosition(firstTab) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("omits the Create Team CTA for a role that cannot manage teams", () => { + renderWithQueryClient(); + expect(screen.queryByTestId("create-team-button")).not.toBeInTheDocument(); + }); +}); + +describe("Teams - Default Team Settings tab visibility", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseOrganizations.mockReturnValue({ data: [] }); + }); + + it("should show Default Team Settings tab for Admin role", () => { + renderWithQueryClient(); + expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument(); + }); + + it("should show Default Team Settings tab for proxy_admin role", () => { + renderWithQueryClient(); + expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument(); + }); + + it("should not show Default Team Settings tab for proxy_admin_viewer role", () => { + renderWithQueryClient(); + expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument(); + }); + + it("should not show Default Team Settings tab for Admin Viewer role", () => { + renderWithQueryClient(); + expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument(); + }); +}); + +describe("Teams - access_group_ids in team create", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTeamInfoView.mockClear(); + vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]); + vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); + vi.mocked(teamCreateCall).mockResolvedValue({ + team_id: "new-team-1", + team_alias: "Test Team", + models: ["gpt-4"], + organization_id: null, + keys: [], + members_with_roles: [], + spend: 0, + }); + mockUseOrganizations.mockReturnValue({ + data: [{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }], + }); + }); + + it("should pass access_group_ids to teamCreateCall when creating team", async () => { + renderWithQueryClient(); + + const createButton = screen.getAllByRole("button", { name: /create team/i })[0]; + act(() => { + fireEvent.click(createButton); + }); + + await waitFor(() => { + expect(screen.getByLabelText(/team name/i)).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } }); + fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } }); + + fireEvent.click(screen.getByText("Additional Settings")); + + await waitFor(() => { + expect(screen.getByTestId("access-group-selector")).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId("access-group-selector"), { target: { value: "ag-1,ag-2" } }); + + const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i }); + fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]); + + await waitFor(() => { + expect(teamCreateCall).toHaveBeenCalledWith( + "test-token", + expect.objectContaining({ + team_alias: "Test Team", + models: ["gpt-4"], + access_group_ids: ["ag-1", "ag-2"], + }), + ); + }); + }); +}); + +describe("Teams - models dropdown options", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]); + mockUseOrganizations.mockReturnValue({ data: [] }); + }); + + it("should not render all-proxy-models option in models select", async () => { + renderWithQueryClient(); + + await waitFor(() => { + expect(fetchAvailableModelsForTeamOrKey).toHaveBeenCalled(); + }); + + const createButton = screen.getAllByRole("button", { name: /create team/i })[0]; + act(() => { + fireEvent.click(createButton); + }); + + await waitFor(() => { + expect(screen.getByLabelText(/models/i)).toBeInTheDocument(); + }); + expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument(); + }); +}); + +describe("Teams - delete team warning copy", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseOrganizations.mockReturnValue({ data: [] }); + }); + + const openDeleteModal = async (team: any) => { + renderWithQueryClient(); + await waitFor(() => expect(mockTeamsTableProps).not.toBeNull()); + await act(async () => { + mockTeamsTableProps.onDeleteTeam(team); + }); + expect(screen.getByText("Delete Team?")).toBeInTheDocument(); + }; + + it("warns that the team's models are deleted when the team has keys", async () => { + await openDeleteModal({ ...baseTableTeam, keys: [], keys_count: 5 }); + + expect(screen.getByText(/Warning: This team has 5 keys associated with it/i)).toHaveTextContent( + /along with any models created for this team/i, + ); + expect(screen.getByText(/Are you sure you want to delete this team/i)).toHaveTextContent( + /any models created for it/i, + ); + }); + + it("still warns about model deletion in the confirmation message when the team has no keys", async () => { + await openDeleteModal({ ...baseTableTeam, keys: [], keys_count: 0 }); + + expect(screen.queryByText(/Warning: This team has/i)).not.toBeInTheDocument(); + expect(screen.getByText(/Are you sure you want to delete this team/i)).toHaveTextContent( + /any models created for it/i, + ); + }); +}); + +describe("Teams - LIT-2530 organization stays optional for proxy admin with a single org", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTeamInfoView.mockClear(); + vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]); + vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); + vi.mocked(teamCreateCall).mockResolvedValue({ + team_id: "new-team-1", + team_alias: "No Org Team", + models: ["gpt-4"], + organization_id: null, + keys: [], + members_with_roles: [], + spend: 0, + }); + mockUseOrganizations.mockReturnValue({ + data: [{ organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }], + }); + }); + + it("creates a team with no organization when exactly one organization exists", async () => { + renderWithQueryClient(); + + const createButton = screen.getAllByRole("button", { name: /create team/i })[0]; + act(() => { + fireEvent.click(createButton); + }); + + await waitFor(() => { + expect(screen.getByLabelText(/team name/i)).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "No Org Team" } }); + fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } }); + + const submitButtons = screen.getAllByRole("button", { name: /create team/i }); + fireEvent.click(submitButtons[submitButtons.length - 1]); + + await waitFor(() => { + expect(teamCreateCall).toHaveBeenCalledWith( + "test-token", + expect.objectContaining({ team_alias: "No Org Team", organization_id: null }), + ); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx similarity index 71% rename from ui/litellm-dashboard/src/components/OldTeams.tsx rename to ui/litellm-dashboard/src/components/Teams.tsx index e83d1acf4e5..2627f2c1d7f 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -3,38 +3,16 @@ import AvailableTeamsPanel from "@/components/team/available_teams"; import TeamInfoView from "@/components/team/TeamInfo"; import TeamSSOSettings from "@/components/TeamSSOSettings"; import { isProxyAdminRole } from "@/utils/roles"; -import { InfoCircleOutlined, PlusOutlined, TeamOutlined, ReloadOutlined } from "@ant-design/icons"; +import { InfoCircleOutlined } from "@ant-design/icons"; import { Accordion, AccordionBody, AccordionHeader, TextInput } from "@tremor/react"; -import { - Button, - Card, - Flex, - Form, - Input, - Layout, - Modal, - Pagination, - Progress, - Select, - Space, - Switch, - Table, - Tabs, - Tag, - theme, - Tooltip, - Typography, - message, -} from "antd"; -import type { ColumnsType } from "antd/es/table"; -import type { SorterResult } from "antd/es/table/interface"; -import { KeyIcon, LayersIcon, SearchIcon, UsersIcon } from "lucide-react"; -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner"; -import { DateCell, IdCell } from "@/components/shared/table_cells"; -import OrganizationDropdown from "./common_components/OrganizationDropdown"; -import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; -import { teamListCall as v2TeamListCall, type TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { Button, Form, Input, Layout, Modal, Select, Switch, Tabs, theme, Tooltip, Typography } from "antd"; +import { Plus, Users } from "lucide-react"; +import React, { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { PageHeader } from "@/components/shared/PageHeader"; +import { Button as UIButton } from "@/components/ui/button"; +import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { TeamsTable } from "./TeamsPage/TeamsTable"; import AccessGroupSelector from "./common_components/AccessGroupSelector"; import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector"; import AgentSelector from "./agent_management/AgentSelector"; @@ -45,7 +23,7 @@ import { fetchAvailableModelsForTeamOrKey, unfurlWildcardModelsInList, } from "./key_team_helpers/fetch_available_models_team_key"; -import type { KeyResponse, Team } from "./key_team_helpers/key_list"; +import type { Team } from "./key_team_helpers/key_list"; import MCPServerSelector from "./mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions"; import NotificationsManager from "./molecules/notifications_manager"; @@ -61,13 +39,6 @@ interface TeamProps { premiumUser?: boolean; } -interface FilterState { - search: string; - organization_id: string; - sort_by: string; - sort_order: "asc" | "desc"; -} - interface EditTeamModalProps { visible: boolean; onCancel: () => void; @@ -75,21 +46,10 @@ interface EditTeamModalProps { onSubmit: (data: FormData) => void; // Assuming FormData is the type of data to be submitted } -import { updateExistingKeys } from "@/utils/dataUtils"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; -import { Member, teamCreateCall } from "./networking"; +import { teamCreateCall } from "./networking"; import { ModelSelect } from "./ModelSelect/ModelSelect"; -interface TeamInfo { - members_with_roles: Member[]; -} - -interface PerTeamInfo { - keys: KeyResponse[]; - keys_count: number; - team_info: TeamInfo; -} - const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { let tempModelsToPick = []; @@ -164,70 +124,17 @@ const getOrganizationAlias = ( const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser = false }) => { const { data: organizationsData } = useOrganizations(); const organizations = organizationsData ?? null; - const [teams, setTeams] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [fetchError, setFetchError] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [totalTeams, setTotalTeams] = useState(0); - const [currentOrg, setCurrentOrg] = useState(null); + const queryClient = useQueryClient(); + const refreshTeams = () => queryClient.invalidateQueries({ queryKey: teamsTableKeys.all }); + const [currentOrg] = useState(null); const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); - const [filters, setFilters] = useState({ - search: "", - organization_id: "", - sort_by: "created_at", - sort_order: "desc", - }); - const searchDebounceRef = useRef | null>(null); - const [isSearching, setIsSearching] = useState(false); - - const fetchTeamsV2 = async ( - opts: { - page?: number; - size?: number; - sortBy?: string; - sortOrder?: string; - organizationID?: string; - search?: string; - } = {}, - ) => { - if (!accessToken) return; - const page = opts.page ?? currentPage; - const size = opts.size ?? pageSize; - const sortBy = opts.sortBy ?? filters.sort_by; - const sortOrder = opts.sortOrder ?? filters.sort_order; - const organizationID = opts.organizationID ?? filters.organization_id; - const search = opts.search ?? filters.search; - - setIsLoading(true); - setFetchError(null); - try { - const response: TeamsResponse = await v2TeamListCall(accessToken, page, size, { - organizationID: organizationID || null, - search: search || null, - userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null, - sortBy: sortBy || null, - sortOrder: sortOrder || null, - }); - setTeams(response.teams ?? []); - setTotalTeams(response.total ?? 0); - } catch (err: any) { - setFetchError(err?.message || "Failed to fetch teams"); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - fetchTeamsV2(); - }, [accessToken]); const [form] = Form.useForm(); const [memberForm] = Form.useForm(); const [value, setValue] = useState(""); const [editModalVisible, setEditModalVisible] = useState(false); - const [selectedTeam, setSelectedTeam] = useState(null); + const [selectedTeam, setSelectedTeam] = useState(null); const [selectedTeamId, setSelectedTeamId] = useState(null); const [editTeam, setEditTeam] = useState(false); @@ -238,7 +145,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [teamToDelete, setTeamToDelete] = useState(null); const [modelsToPick, setModelsToPick] = useState([]); - const [perTeamInfo, setPerTeamInfo] = useState>({}); const [isTeamDeleting, setIsTeamDeleting] = useState(false); // Add this state near the other useState declarations const [guardrailsList, setGuardrailsList] = useState([]); @@ -325,30 +231,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser fetchMcpAccessGroups(); }, [accessToken]); - useEffect(() => { - const fetchTeamInfo = () => { - if (!teams) return; - - const newPerTeamInfo = teams.reduce( - (acc, team) => { - acc[team.team_id] = { - keys: team.keys || [], - keys_count: team.keys_count ?? team.keys?.length ?? 0, - team_info: { - members_with_roles: team.members_with_roles || [], - }, - }; - return acc; - }, - {} as Record, - ); - - setPerTeamInfo(newPerTeamInfo); - }; - - fetchTeamInfo(); - }, [teams]); - const handleOk = () => { setIsTeamModalVisible(false); form.resetFields(); @@ -386,14 +268,14 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser }; const confirmDelete = async () => { - if (teamToDelete == null || teams == null || accessToken == null) { + if (teamToDelete == null || accessToken == null) { return; } try { setIsTeamDeleting(true); await teamDeleteCall(accessToken, teamToDelete.team_id); - await fetchTeamsV2(); + await refreshTeams(); NotificationsManager.success("Team deleted successfully"); } catch (error) { NotificationsManager.fromBackend("Error deleting the team: " + error); @@ -425,13 +307,11 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser }; fetchUserModels(); - }, [accessToken, userID, userRole, teams]); + }, [accessToken, userID, userRole]); const handleCreate = async (formValues: Record) => { try { if (accessToken != null) { - const newTeamAlias = formValues?.team_alias; - const existingTeamAliases = teams?.map((t) => t.team_alias) ?? []; let organizationId = formValues?.organization_id || currentOrg?.organization_id; if (organizationId === "" || typeof organizationId !== "string") { formValues.organization_id = null; @@ -439,11 +319,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser formValues.organization_id = organizationId.trim(); } - // Remove guardrails from top level since it's now in metadata - if (existingTeamAliases.includes(newTeamAlias)) { - throw new Error(`Team alias ${newTeamAlias} already exists, please pick another alias`); - } - NotificationsManager.info("Creating Team"); // Handle logging settings in metadata @@ -565,10 +440,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser await teamCreateCall(accessToken, formValues); NotificationsManager.success("Team created"); - await fetchTeamsV2({ - page: currentPage, - size: pageSize, - }); + await refreshTeams(); form.resetFields(); setLoggingSettings([]); setModelAliases({}); @@ -595,352 +467,31 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser return false; }; - const handleSearchChange = (value: string) => { - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); - setIsSearching(true); - searchDebounceRef.current = setTimeout(async () => { - try { - setFilters((prev) => ({ ...prev, search: value })); - setCurrentPage(1); - await fetchTeamsV2({ page: 1, search: value }); - } finally { - setIsSearching(false); - } - }, 300); - }; - - const handleFilterChange = async (key: keyof FilterState, value: string) => { - const newFilters = { ...filters, [key]: value }; - setFilters(newFilters); - setCurrentPage(1); - if (!accessToken) return; - try { - const response: TeamsResponse = await v2TeamListCall(accessToken, 1, pageSize, { - organizationID: newFilters.organization_id || null, - search: newFilters.search || null, - userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null, - sortBy: newFilters.sort_by || null, - sortOrder: newFilters.sort_order || null, - }); - setTeams(response.teams ?? []); - setTotalTeams(response.total ?? 0); - } catch (error) { - console.error("Error fetching teams:", error); - } - }; - - const handleFilterReset = () => { - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); - setIsSearching(false); - const resetFilters: FilterState = { - search: "", - organization_id: "", - sort_by: "created_at", - sort_order: "desc", - }; - setFilters(resetFilters); - setCurrentPage(1); - fetchTeamsV2({ page: 1, organizationID: "", search: "", sortBy: "created_at", sortOrder: "desc" }); - }; - const { token } = theme.useToken(); - const { Title, Text } = Typography; + const { Text } = Typography; const { Content } = Layout; - const handleRetry = () => { - fetchTeamsV2(); - }; - - const handleTableSort = ( - _pagination: unknown, - _filters: unknown, - sorter: SorterResult | SorterResult[], - ) => { - const s = Array.isArray(sorter) ? sorter[0] : sorter; - const sortBy = s.order ? (s.columnKey as string) : "created_at"; - const sortOrder = s.order === "ascend" ? "asc" : s.order === "descend" ? "desc" : "desc"; - setFilters((prev) => ({ ...prev, sort_by: sortBy, sort_order: sortOrder })); - fetchTeamsV2({ sortBy, sortOrder }); - }; - - const teamColumns: ColumnsType = useMemo( - () => [ - { - title: "Team ID", - dataIndex: "team_id", - key: "team_id", - width: 170, - ellipsis: true, - render: (id: string) => ( - setSelectedTeamId(teamId)} dataTestId="team-id-cell" /> - ), - }, - { - title: "Team Alias", - dataIndex: "team_alias", - key: "team_alias", - ellipsis: true, - sorter: true, - render: (alias: string | undefined) => ( - - {alias || ( - - — - - )} - - ), - }, - { - title: "Organization", - key: "organization", - width: 160, - ellipsis: true, - render: (_: unknown, record: Team) => { - const orgAlias = getOrganizationAlias(record.organization_id, organizations); - return record.organization_id ? ( - - {orgAlias} - - ) : ( - — - ); - }, - }, - { - title: "Resources", - key: "resources", - width: 240, - render: (_: unknown, record: Team) => { - const memberCount = perTeamInfo?.[record.team_id]?.team_info?.members_with_roles?.length ?? 0; - const modelCount = record.models?.length ?? 0; - const keyCount = perTeamInfo?.[record.team_id]?.keys_count ?? 0; - return ( - - - - - - {memberCount} - - - - - - - - {modelCount} - - - - - - - - {keyCount} - - - - - ); - }, - }, - { - title: "Spend / Budget", - key: "spend", - width: 200, - sorter: true, - render: (_: unknown, record: Team) => { - const spendVal = record.spend ?? 0; - const budgetVal = record.max_budget; - const spendStr = `$${spendVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; - const budgetStr = - budgetVal != null - ? `$${budgetVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` - : "Unlimited"; - const percent = budgetVal != null && budgetVal > 0 ? Math.min((spendVal / budgetVal) * 100, 100) : null; - return ( - - - {spendStr} - - {" / "} - {budgetStr} - - - {percent != null && ( - = 90 ? "#ff4d4f" : percent >= 70 ? "#faad14" : "#1677ff"} - style={{ marginBottom: 0 }} - /> - )} - - ); - }, - }, - { - title: "Created", - dataIndex: "created_at", - key: "created_at", - width: 130, - ellipsis: true, - sorter: true, - render: (date: string | undefined) => , - }, - { - title: "Actions", - key: "actions", - width: 120, - align: "right" as const, - render: (_: unknown, record: Team) => ( - - { - navigator.clipboard - .writeText(record.team_id) - .then(() => message.success("Team ID copied")) - .catch(() => message.error("Failed to copy")); - }} - /> - {userRole === "Admin" && ( - <> - { - setSelectedTeamId(record.team_id); - setEditTeam(true); - }} - /> - handleDelete(record)} - /> - - )} - - ), - }, - ], - [userRole, perTeamInfo, organizations], - ); - - const displayTeams = useMemo(() => teams ?? [], [teams]); - - const renderTeamsContent = () => { - if (isLoading) { - return ( - - - - ); - } - - if (fetchError) { - return ( - - - Failed to load teams - - - {fetchError} - - - - ); - } - - return ( - - columns={teamColumns} - dataSource={displayTeams} - rowKey="team_id" - pagination={false} - onChange={handleTableSort} - locale={{ - emptyText: ( -
- -
- No teams yet -
-
- - Create your first team to organize members and manage access to models. - -
- {canCreateOrManageTeams(userRole, userID, organizations) && ( - - )} -
- ), - }} - scroll={{ x: 1000 }} - size="middle" - /> - ); - }; - const tabItems = [ { key: "your-teams", label: "Your Teams", children: ( <> - - - - } - suffix={isSearching ? : null} - placeholder="Search teams by name or ID..." - onChange={(e) => handleSearchChange(e.target.value)} - allowClear - style={{ maxWidth: 400 }} - /> - handleFilterChange("organization_id", value || "")} - loading={isLoading} - /> - - { - setCurrentPage(page); - setPageSize(size); - fetchTeamsV2({ page, size }); - }} - size="small" - showTotal={(total) => `${total} teams`} - showSizeChanger - pageSizeOptions={["10", "20", "50"]} - /> - - - {renderTeamsContent()} - + { + setSelectedTeam(team); + setSelectedTeamId(team.team_id); + setEditTeam(false); + }} + onEditTeam={(team) => { + setSelectedTeam(team); + setSelectedTeamId(team.team_id); + setEditTeam(true); + }} + onDeleteTeam={handleDelete} + /> = ({ accessToken, userID, userRole, premiumUser {selectedTeamId ? ( { - setTeams((teams) => { - if (teams == null) { - return teams; - } - return teams.map((team) => { - if (data.team_id === team.team_id) { - return updateExistingKeys(team, data); - } - return team; - }); - }); - fetchTeamsV2(); + onUpdate={() => { + refreshTeams(); }} onClose={() => { + setSelectedTeam(null); setSelectedTeamId(null); setEditTeam(false); }} accessToken={accessToken} - is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} + is_team_admin={is_team_admin(selectedTeam)} is_proxy_admin={userRole == "Admin"} userModels={userModels} editTeam={editTeam} @@ -1018,27 +559,28 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser /> ) : ( <> - - - - <TeamOutlined style={{ marginRight: 8 }} /> - Teams - - Manage teams, members, and their access to models and budgets - - {canCreateOrManageTeams(userRole, userID, organizations) && ( - - )} - +
+ } + title="Teams" + subtitle="Manage teams, members, and their access to models and budgets" + /> +
- + + setIsTeamModalVisible(true)} data-testid="create-team-button"> + + Create Team + +
+
+ ) : undefined, + }} + /> )} diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx new file mode 100644 index 00000000000..9469be12128 --- /dev/null +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx @@ -0,0 +1,330 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, MockedFunction, vi } from "vitest"; + +import { renderWithProviders } from "../../../tests/test-utils"; +import { Team } from "../key_team_helpers/key_list"; +import { TeamsResponse, useTeamsTable } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { TeamsTable } from "./TeamsTable"; + +// Resolve debounced values synchronously so an applied filter lands in the useTeamsTable query within the test tick. +vi.mock("@tanstack/react-pacer/debouncer", async () => { + const React = await vi.importActual("react"); + return { + useDebouncedValue: (value: unknown) => [value, { cancel: vi.fn(), flush: vi.fn() }], + useDebouncedState: (initial: unknown) => { + const [value, setValue] = React.useState(initial); + return [value, setValue, { cancel: vi.fn(), flush: vi.fn() }]; + }, + }; +}); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: vi.fn(() => ({ + accessToken: "test-token", + userId: "test-user", + userRole: "Admin", + premiumUser: true, + token: "test-token", + })), +})); + +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useTeamsTable: vi.fn(), + teamsTableKeys: { all: ["teamsTable"] }, +})); + +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn().mockReturnValue({ + data: [{ organization_id: "org-1", organization_alias: "Test Organization" }], + }), +})); + +const mockTeam: Team = { + team_id: "team-1", + team_alias: "Acme Team", + models: ["gpt-4", "gpt-3.5-turbo", "claude-3", "claude-3-5-sonnet"], + max_budget: 100, + budget_duration: "1mo", + tpm_limit: 5000, + rpm_limit: 500, + organization_id: "org-1", + created_at: "2024-10-01T10:00:00Z", + updated_at: "2024-11-01T10:00:00Z", + keys: [], + keys_count: 3, + members_with_roles: [ + { user_id: "u1", user_email: "a@x.com", role: "admin" }, + { user_id: "u2", user_email: "b@x.com", role: "user" }, + ] as unknown as Team["members_with_roles"], + spend: 42.5, +}; + +const mockUseTeamsTable = useTeamsTable as MockedFunction; + +const teamsResult = (teams: Team[], data: Partial = {}, extra: Record = {}) => + ({ + data: { + teams, + total: teams.length, + page: 1, + page_size: 50, + total_pages: 1, + ...data, + } as TeamsResponse, + isPending: false, + isFetching: false, + isError: false, + refetch: vi.fn(), + ...extra, + }) as any; + +const noop = () => {}; + +const renderTable = (props: Partial> = {}) => + renderWithProviders( + , + ); + +const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" })); +const lastOptions = () => mockUseTeamsTable.mock.calls[mockUseTeamsTable.mock.calls.length - 1][2] ?? {}; + +beforeEach(() => { + vi.clearAllMocks(); + mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam])); +}); + +it("renders a team row with alias, organization, and spend/budget", async () => { + renderTable(); + + await waitFor(() => { + expect(screen.getByText("Acme Team")).toBeInTheDocument(); + expect(screen.getByText("Test Organization")).toBeInTheDocument(); + expect(screen.getByText("$42.5000")).toBeInTheDocument(); + expect(screen.getByText("of $100")).toBeInTheDocument(); + }); +}); + +it("renders the Resources cell with member, model, and key counts", () => { + renderTable(); + + expect(screen.getByTitle("2 members")).toBeInTheDocument(); + expect(screen.getByTitle("4 models")).toBeInTheDocument(); + expect(screen.getByTitle("3 keys")).toBeInTheDocument(); +}); + +it("shows 'No teams found' when the list is empty", () => { + mockUseTeamsTable.mockReturnValue(teamsResult([])); + renderTable(); + expect(screen.getByText("No teams found")).toBeInTheDocument(); +}); + +it("shows a loading state on initial load and hides the data", () => { + mockUseTeamsTable.mockReturnValue(teamsResult([], {}, { data: null, isPending: true, isFetching: true })); + renderTable(); + + expect(screen.getByText("Loading teams...")).toBeInTheDocument(); + expect(screen.queryByText("Acme Team")).not.toBeInTheDocument(); +}); + +describe("sort contract – only backend-sortable columns are sortable", () => { + it("requests the default created_at descending sort on first render", () => { + renderTable(); + expect(lastOptions()).toMatchObject({ sortBy: "created_at", sortOrder: "desc" }); + }); + + it("sorts by the backend team_alias field (not the label) when the Team header is clicked", async () => { + renderTable(); + fireEvent.click(screen.getByText("Team").closest("button") as HTMLElement); + + await waitFor(() => { + expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: "team_alias" })); + }); + }); + + it("does not make Spend / Budget sortable (the backend rejects sort_by=spend)", () => { + renderTable(); + expect(screen.getByText("Spend / Budget").closest("button")).toBeNull(); + // Team and Created are the only sortable headers. + expect(screen.getByText("Team").closest("button")).not.toBeNull(); + expect(screen.getByText("Created").closest("button")).not.toBeNull(); + }); +}); + +describe("server-side filtering maps controls to the right query params", () => { + it("sends no filter params when nothing is applied", () => { + renderTable(); + expect(lastOptions()).toMatchObject({ organizationID: undefined, team_alias: undefined, teamID: undefined }); + }); + + it("threads an applied Team alias filter into the query", async () => { + renderTable(); + openFilters(); + + fireEvent.change(await screen.findByPlaceholderText(/Enter team alias/), { target: { value: "acme" } }); + fireEvent.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => { + expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ team_alias: "acme" })); + }); + }); + + it("threads an applied Team ID filter into the query", async () => { + renderTable(); + openFilters(); + + fireEvent.change(await screen.findByPlaceholderText(/Enter team ID/), { target: { value: "team-xyz" } }); + fireEvent.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => { + expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ teamID: "team-xyz" })); + }); + }); + + it("threads the toolbar search into the search param", async () => { + renderTable(); + fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "platform" } }); + + await waitFor(() => { + expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: "platform" })); + }); + }); +}); + +describe("non-admin scoping", () => { + it("scopes the list to the current user when the role is not an admin role", () => { + renderTable({ userRole: "Internal User", userID: "user-42" }); + expect(lastOptions()).toMatchObject({ userID: "user-42" }); + }); + + it("does not scope by user for the Admin role", () => { + renderTable({ userRole: "Admin", userID: "admin-1" }); + expect(lastOptions()).toMatchObject({ userID: undefined }); + }); +}); + +describe("row actions", () => { + it("opens the team detail when the team cell is clicked", () => { + const onSelectTeam = vi.fn(); + renderTable({ onSelectTeam }); + + fireEvent.click(screen.getByText("Acme Team")); + expect(onSelectTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" })); + }); + + it("offers Edit and Delete to an Admin and wires them to the callbacks", async () => { + const onEditTeam = vi.fn(); + const onDeleteTeam = vi.fn(); + const user = userEvent.setup(); + renderTable({ userRole: "Admin", onEditTeam, onDeleteTeam }); + + await user.click(screen.getByTestId("team-actions-team-1")); + + await user.click(await screen.findByText("Edit team")); + expect(onEditTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" })); + + await user.click(screen.getByTestId("team-actions-team-1")); + await user.click(await screen.findByText("Delete team")); + expect(onDeleteTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" })); + }); + + it("hides Edit and Delete from a non-admin, leaving only Copy team ID", async () => { + const user = userEvent.setup(); + renderTable({ userRole: "Internal User" }); + + await user.click(screen.getByTestId("team-actions-team-1")); + + expect(await screen.findByText("Copy team ID")).toBeInTheDocument(); + expect(screen.queryByText("Edit team")).not.toBeInTheDocument(); + expect(screen.queryByText("Delete team")).not.toBeInTheDocument(); + }); +}); + +describe("pagination total comes from the query response", () => { + it("shows the total count and page count from the response", async () => { + mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], { total: 137, total_pages: 3 })); + renderTable(); + + await waitFor(() => { + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 137"); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 3"); + }); + }); +}); + +describe("refresh control", () => { + it("calls refetch when clicked", () => { + const refetch = vi.fn(); + mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { refetch })); + renderTable(); + + fireEvent.click(screen.getByTestId("datatable-refresh")); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it("keeps rows visible but disables refresh while a background fetch is in flight", () => { + mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { isFetching: true })); + renderTable(); + + expect(screen.getByTestId("datatable-refresh")).toBeDisabled(); + expect(screen.getByText("Acme Team")).toBeInTheDocument(); + }); +}); + +describe("column rendering details", () => { + it("shows the organization alias when the id resolves, and the raw id when it does not", async () => { + mockUseTeamsTable.mockReturnValue( + teamsResult([ + { ...mockTeam, team_id: "a", organization_id: "org-1" }, + { ...mockTeam, team_id: "b", team_alias: "Orphan Team", organization_id: "org-unknown" }, + ]), + ); + renderTable(); + + await waitFor(() => { + expect(screen.getByText("Test Organization")).toBeInTheDocument(); + expect(screen.getByText("org-unknown")).toBeInTheDocument(); + }); + }); + + it("renders an em dash for a team with no organization", () => { + mockUseTeamsTable.mockReturnValue(teamsResult([{ ...mockTeam, organization_id: null as unknown as string }])); + renderTable(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("falls back to keys.length when keys_count is absent", () => { + mockUseTeamsTable.mockReturnValue( + teamsResult([ + { + ...mockTeam, + keys_count: undefined, + keys: [{ token: "t1" }, { token: "t2" }] as unknown as Team["keys"], + }, + ]), + ); + renderTable(); + expect(screen.getByTitle("2 keys")).toBeInTheDocument(); + }); +}); + +describe("hidden-by-default columns", () => { + it("hides Members, Models, Rate Limits, and Updated until toggled on", async () => { + const user = userEvent.setup(); + renderTable(); + + expect(screen.queryByText("Rate Limits")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Columns" })); + const menu = await screen.findByRole("menu"); + expect(within(menu).getByText("Rate Limits")).toBeInTheDocument(); + expect(within(menu).getByText("Updated")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx new file mode 100644 index 00000000000..3b75db52b16 --- /dev/null +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { useTeamsTable } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableToolbar, +} from "@/components/shared/DataTable"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { Input } from "@/components/ui/input"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; +import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import React, { useCallback, useMemo, useState } from "react"; + +import { Team } from "../key_team_helpers/key_list"; +import { getTeamTableColumns, TEAM_TABLE_HIDDEN_COLUMNS } from "./teamTableColumns"; + +interface TeamsTableProps { + userRole: string | null; + userID: string | null; + onSelectTeam: (team: Team) => void; + onEditTeam: (team: Team) => void; + onDeleteTeam: (team: Team) => void; +} + +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + +const toSortOrder = (sorting: SortingState): "asc" | "desc" | undefined => { + const active = sorting[0]; + if (!active) return undefined; + return active.desc ? "desc" : "asc"; +}; + +const FILTER_LABELS: Record = { + org_id: "Organization", + alias: "Team alias", + team_id: "Team ID", +}; + +export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDeleteTeam }: TeamsTableProps) { + const { data: fetchedOrganizations } = useOrganizations(); + const organizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]); + + const [sorting, setSorting] = useState(DEFAULT_SORTING); + const [tablePagination, setTablePagination] = useState({ pageIndex: 0, pageSize: 50 }); + const [columnFilters, setColumnFilters] = useState([]); + const [filtersOpen, setFiltersOpen] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); + + const getFilterValue = useCallback( + (columnId: string): string | undefined => { + const entry = columnFilters.find((filter) => filter.id === columnId); + return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined; + }, + [columnFilters], + ); + + const isAdminView = userRole === "Admin" || userRole === "Admin Viewer"; + + const teamListOptions = { + organizationID: getFilterValue("org_id"), + team_alias: getFilterValue("alias"), + teamID: getFilterValue("team_id"), + search: searchQuery.trim() || undefined, + userID: isAdminView ? undefined : userID ?? undefined, + sortBy: sorting[0]?.id, + sortOrder: toSortOrder(sorting), + }; + + const { + data: teamsResponse, + isPending: isLoading, + isFetching, + refetch, + } = useTeamsTable(tablePagination.pageIndex + 1, tablePagination.pageSize, teamListOptions); + + const teamList = useMemo(() => teamsResponse?.teams ?? [], [teamsResponse]); + const rowCount = teamsResponse?.total ?? 0; + + const handleSearchChange = useCallback((value: string) => { + setSearchInput(value); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); + + const handleSortingChange = useCallback>((updaterOrValue) => { + setSorting(updaterOrValue); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); + + const handleColumnFiltersChange = useCallback>((updaterOrValue) => { + setColumnFilters(updaterOrValue); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); + + const columns = useMemo(() => { + const columnDeps = { organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam }; + return getTeamTableColumns(columnDeps); + }, [organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam]); + + const orgOptions = useMemo( + () => + organizations + .filter((org) => org.organization_id) + .map((org) => { + const id = org.organization_id as string; + return { label: org.organization_alias || id, value: id, sublabel: org.organization_alias ? id : undefined }; + }), + [organizations], + ); + + const formatFilterValue = useCallback( + (columnId: string, value: unknown): string => { + const raw = String(value); + if (columnId === "org_id") { + return organizations.find((org) => org.organization_id === raw)?.organization_alias || raw; + } + return raw; + }, + [organizations], + ); + + return ( + row.team_id} + defaultColumnVisibility={TEAM_TABLE_HIDDEN_COLUMNS} + sortingMode="server" + sorting={sorting} + onSortingChange={handleSortingChange} + paginationMode="server" + pagination={tablePagination} + onPaginationChange={setTablePagination} + rowCount={rowCount} + filterMode="server" + columnFilters={columnFilters} + onColumnFiltersChange={handleColumnFiltersChange} + enableColumnResizing + columnResizeMode="onChange" + isLoading={isLoading} + loadingMessage="Loading teams..." + noDataMessage="No teams found" + maxBodyHeight="calc(75vh - 210px)" + size="compact" + toolbar={(table) => ( + <> + refetch?.()} + isRefreshing={isFetching} + onOpenFilters={() => setFiltersOpen(true)} + filterLabels={FILTER_LABELS} + formatFilterValue={formatFilterValue} + /> + + {({ get, set }) => ( + <> + + set("org_id", value)} + placeholder="Select an organization…" + emptyText="No organizations found" + /> + + + set("alias", event.target.value)} + placeholder="Enter team alias…" + /> + + + set("team_id", event.target.value)} + placeholder="Enter team ID…" + /> + + + )} + + + )} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx b/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx new file mode 100644 index 00000000000..ecf7387ee83 --- /dev/null +++ b/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, KeyRound, Layers, MoreHorizontal, Pencil, Trash2, Users } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdentityCell, SpendBudgetCell } from "@/components/shared/table_cells"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils"; + +import { Team } from "../key_team_helpers/key_list"; +import { Organization } from "../networking"; + +interface ResourceTone { + icon: typeof Users; + className: string; +} + +const RESOURCE_TONES: Record<"members" | "models" | "keys", ResourceTone> = { + members: { icon: Users, className: "bg-violet-50 text-violet-700 ring-violet-600/20" }, + models: { icon: Layers, className: "bg-sky-50 text-sky-700 ring-sky-600/20" }, + keys: { icon: KeyRound, className: "bg-emerald-50 text-emerald-700 ring-emerald-600/20" }, +}; + +const teamMemberCount = (team: Team): number => team.members_count ?? team.members_with_roles?.length ?? 0; +const teamModelCount = (team: Team): number => team.models?.length ?? 0; +const teamKeyCount = (team: Team): number => team.keys_count ?? team.keys?.length ?? 0; + +function ResourcesCell({ team }: { team: Team }) { + const items = [ + { key: "members" as const, label: "members", count: teamMemberCount(team) }, + { key: "models" as const, label: "models", count: teamModelCount(team) }, + { key: "keys" as const, label: "keys", count: teamKeyCount(team) }, + ]; + + return ( +
+ {items.map((item) => { + const tone = RESOURCE_TONES[item.key]; + const Icon = tone.icon; + return ( + + + {item.count} + + ); + })} +
+ ); +} + +function RateLimitLine({ label, value }: { label: string; value: number | null }) { + return ( +
+ {label} + {value != null ? formatNumberWithCommas(value) : "Unlimited"} +
+ ); +} + +interface TeamRowActionsProps { + team: Team; + canManage: boolean; + onEditTeam: (team: Team) => void; + onDeleteTeam: (team: Team) => void; +} + +function TeamRowActions({ team, canManage, onEditTeam, onDeleteTeam }: TeamRowActionsProps) { + const handleCopy = () => { + void copyToClipboard(team.team_id, "Team ID copied"); + }; + + return ( + + + + + + {canManage && ( + onEditTeam(team)} data-testid="team-action-edit"> + + Edit team + + )} + + + Copy team ID + + {canManage && ( + <> + + onDeleteTeam(team)} data-testid="team-action-delete"> + + Delete team + + + )} + + + ); +} + +interface TeamTableColumnsDeps { + organizations: Organization[]; + userRole: string | null; + onSelectTeam: (team: Team) => void; + onEditTeam: (team: Team) => void; + onDeleteTeam: (team: Team) => void; +} + +export const getTeamTableColumns = ({ + organizations, + userRole, + onSelectTeam, + onEditTeam, + onDeleteTeam, +}: TeamTableColumnsDeps): ColumnDef[] => { + const canManage = userRole === "Admin"; + + return [ + { + id: "team_alias", + accessorKey: "team_alias", + meta: { + title: "Team", + renderSkeleton: () => ( +
+ + +
+ ), + }, + header: ({ column }) => , + size: 260, + enableSorting: true, + cell: ({ row }) => { + const team = row.original; + const hasAlias = Boolean(team.team_alias); + return ( + onSelectTeam(team)} + /> + ); + }, + }, + { + id: "organization_alias", + accessorKey: "organization_id", + meta: { title: "Organization" }, + header: "Organization", + size: 160, + enableSorting: false, + cell: (info) => { + const orgId = info.getValue() as string | null; + if (!orgId) return —; + const org = organizations.find((o) => o.organization_id === orgId); + const displayValue = org?.organization_alias || orgId; + const width = info.cell.column.getSize(); + return ( + + {displayValue} + + ); + }, + }, + { + id: "resources", + meta: { + title: "Resources", + renderSkeleton: () => ( +
+ + + +
+ ), + }, + header: "Resources", + size: 210, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "spend", + accessorKey: "spend", + meta: { title: "Spend / Budget", skeleton: "meter" }, + header: "Spend / Budget", + size: 200, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "created_at", + accessorKey: "created_at", + meta: { title: "Created" }, + header: ({ column }) => , + size: 130, + enableSorting: true, + cell: (info) => , + }, + { + id: "members", + meta: { title: "Members" }, + header: "Members", + size: 110, + enableSorting: false, + cell: ({ row }) => {teamMemberCount(row.original)}, + }, + { + id: "models", + meta: { title: "Models" }, + header: "Models", + size: 100, + enableSorting: false, + cell: ({ row }) => {teamModelCount(row.original)}, + }, + { + id: "rate_limits", + meta: { title: "Rate Limits", skeleton: "twoLine" }, + header: "Rate Limits", + size: 140, + enableSorting: false, + cell: ({ row }) => ( +
+ + +
+ ), + }, + { + id: "updated_at", + accessorKey: "updated_at", + meta: { title: "Updated" }, + header: "Updated", + size: 130, + enableSorting: false, + cell: (info) => , + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 60, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, + ]; +}; + +export const TEAM_TABLE_HIDDEN_COLUMNS: Record = { + members: false, + models: false, + rate_limits: false, + updated_at: false, +}; diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 02f5d588149..513054aae7a 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -16,6 +16,8 @@ vi.mock("@tanstack/react-pacer/debouncer", async () => { const [value, setValue] = React.useState(initial); return [value, setValue, { cancel: vi.fn(), flush: vi.fn() }]; }, + useDebouncedCallback: (fn: (...args: unknown[]) => void) => fn, + useDebouncer: (fn: (...args: unknown[]) => void) => ({ maybeExecute: fn, cancel: vi.fn(), flush: vi.fn() }), }; }); @@ -63,7 +65,7 @@ const mockKey: KeyResponse = { key_alias: "Test Key Alias", spend: 5.5, max_budget: 100, - expires: "2024-12-31T23:59:59Z", + expires: "2999-12-31T23:59:59Z", models: ["gpt-3.5-turbo", "gpt-4"], aliases: {}, config: {}, @@ -154,6 +156,8 @@ const keysResult = (keys: KeyResponse[], data: Partial = {}, extra ...extra, }) as any; +const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" })); + beforeEach(() => { vi.clearAllMocks(); @@ -170,6 +174,21 @@ it("should render VirtualKeysTable component", () => { expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); }); +it("left-anchors the create-key CTA below the title, between the header and the table toolbar", () => { + renderWithProviders(Create New Key} />); + + const heading = screen.getByRole("heading", { name: "Virtual Keys" }); + const ctas = screen.getAllByRole("button", { name: "Create New Key" }); + expect(ctas).toHaveLength(1); + const cta = ctas[0]; + const search = screen.getByPlaceholderText(/Search by key alias/); + + // The CTA follows the title row... + expect(heading.compareDocumentPosition(cta) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // ...and precedes the table's search toolbar, so it sits in its own row above the table. + expect(cta.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); +}); + it("should display key information correctly", async () => { renderWithProviders(); @@ -177,6 +196,7 @@ it("should display key information correctly", async () => { expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); expect(screen.getByText("Test Team")).toBeInTheDocument(); expect(screen.getByText("$5.5000")).toBeInTheDocument(); + expect(screen.getByText("of $100")).toBeInTheDocument(); }); }); @@ -188,14 +208,49 @@ it("should display user email correctly", async () => { }); }); -it("should show loading message only on initial load (isPending)", () => { +it("shows the user alias over the email in the visible cell when both exist", async () => { + mockUseKeys.mockReturnValue( + keysResult([{ ...mockKey, user: { user_id: "user-1", user_email: "user@example.com", user_alias: "The User" } }]), + ); + + renderWithProviders(); + + const row = (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement; + expect(within(row).getByText("The User")).toBeInTheDocument(); + expect(within(row).queryByText("user@example.com")).not.toBeInTheDocument(); +}); + +it("shows created_by_user alias over email in the Created By column when it is enabled", async () => { + mockUseKeys.mockReturnValue( + keysResult([ + { + ...mockKey, + created_by: "some-uuid", + created_by_user: { user_id: "some-uuid", user_email: "creator@example.com", user_alias: "The Creator" }, + }, + ]), + ); + const user = userEvent.setup(); + renderWithProviders(); + + // Created By is hidden by default; turn it on via the Columns menu. + await user.click(screen.getByRole("button", { name: "Columns" })); + await user.click(await screen.findByText("Created By")); + await user.keyboard("{Escape}"); + + const row = (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement; + expect(within(row).getByText("The Creator")).toBeInTheDocument(); + expect(within(row).queryByText("creator@example.com")).not.toBeInTheDocument(); +}); + +it("should show a loading state on the initial load and hide the data", () => { mockUseKeys.mockReturnValue(keysResult([], {}, { data: null, isPending: true, isFetching: true })); renderWithProviders(); - expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument(); + expect(screen.getByText("Loading keys...")).toBeInTheDocument(); + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument(); - expect(screen.queryByText("Test Team")).not.toBeInTheDocument(); }); it("should show 'No keys found' message when the key list is empty", () => { @@ -206,61 +261,98 @@ it("should show 'No keys found' message when the key list is empty", () => { expect(screen.getByText("No keys found")).toBeInTheDocument(); }); -it("should handle models with more than 3 entries to trigger expansion UI", () => { +it("collapses models beyond the visible limit into a '+N more' badge", () => { mockUseKeys.mockReturnValue( keysResult([{ ...mockKey, models: ["gpt-3.5-turbo", "gpt-4", "gpt-4-turbo", "claude-3", "claude-3-5-sonnet"] }]), ); renderWithProviders(); - expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); + expect(screen.getByText("+2 more")).toBeInTheDocument(); }); -it("should render table headers correctly", () => { +it("should render the redesigned table headers", () => { renderWithProviders(); - expect(screen.getByText("Key ID")).toBeInTheDocument(); - expect(screen.getByText("Key Alias")).toBeInTheDocument(); + expect(screen.getByText("Key")).toBeInTheDocument(); expect(screen.getByText("Team")).toBeInTheDocument(); expect(screen.getByText("Models")).toBeInTheDocument(); - expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); + expect(screen.getByText("Spend", { selector: "[data-sort-field='spend']" })).toBeInTheDocument(); + expect(screen.getByText("Budget", { selector: "[data-sort-field='max_budget']" })).toBeInTheDocument(); }); -it("should handle column resizing hover events", () => { +it("sorts by the backend key_alias field (not the column label) when the Key header is clicked", async () => { renderWithProviders(); - const headerCell = document.querySelector("[data-header-id]") as HTMLElement; - expect(headerCell).toBeInTheDocument(); + const keyHeader = screen.getByText("Key").closest("button") as HTMLElement; + fireEvent.click(keyHeader); - const resizer = headerCell?.querySelector(".resizer") as HTMLElement; - expect(resizer).toBeInTheDocument(); - expect(resizer.style.opacity).toBe("0"); - - fireEvent.mouseEnter(headerCell); - expect(resizer.style.opacity).toBe("0.5"); - - fireEvent.mouseLeave(headerCell); - expect(resizer.style.opacity).toBe("0"); + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: "key_alias" })); + }); }); -it("should open KeyInfoView when clicking on a key ID button", async () => { +it("sorts by the backend max_budget field when 'Budget descending' is chosen from the Spend / Budget menu", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("sort-trigger-spend")); + await user.click(await screen.findByText("Budget descending")); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith( + 1, + 50, + expect.objectContaining({ sortBy: "max_budget", sortOrder: "desc" }), + ); + }); +}); + +it("emphasizes the active field in the Spend / Budget header so the sorted column reads without opening the menu", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("sort-trigger-spend")); + await user.click(await screen.findByText("Budget descending")); + + await waitFor(() => { + expect(screen.getByText("Budget", { selector: "[data-sort-field='max_budget']" }).className).toContain( + "font-semibold", + ); + }); + expect(screen.getByText("Spend", { selector: "[data-sort-field='spend']" }).className).toContain( + "text-muted-foreground", + ); +}); + +it("sorts by spend ascending when 'Spend ascending' is chosen from the Spend / Budget menu", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByTestId("sort-trigger-spend")); + await user.click(await screen.findByText("Spend ascending")); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: "spend", sortOrder: "asc" })); + }); +}); + +it("should open KeyInfoView when clicking the key cell", async () => { renderWithProviders(); await waitFor(() => { expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); }); - expect(screen.getByText(/Showing.*results/)).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toBeInTheDocument(); - const keyIdButton = screen.getByText("sk-1234567890abcdef"); - fireEvent.click(keyIdButton); + fireEvent.click(screen.getByText("Test Key Alias")); await waitFor(() => { expect(screen.getByText("Back to Keys")).toBeInTheDocument(); - expect(screen.getByText("Created At")).toBeInTheDocument(); }); - expect(screen.queryByText(/Showing.*results/)).not.toBeInTheDocument(); + expect(screen.queryByTestId("pagination-range")).not.toBeInTheDocument(); }); it("should display 'Default Proxy Admin' for user_id when value is 'default_user_id'", async () => { @@ -282,44 +374,6 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user }); }); -it("should display created_by_user email in 'Created By' column when available", async () => { - mockUseKeys.mockReturnValue( - keysResult([ - { - ...mockKey, - created_by: "some-uuid-1234", - created_by_user: { user_id: "some-uuid-1234", user_email: "creator@example.com", user_alias: null }, - }, - ]), - ); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("creator@example.com")).toBeInTheDocument(); - }); -}); - -it("should display created_by_user alias over email when both are available", async () => { - mockUseKeys.mockReturnValue( - keysResult([ - { - ...mockKey, - created_by: "some-uuid-1234", - created_by_user: { user_id: "some-uuid-1234", user_email: "creator@example.com", user_alias: "The Creator" }, - }, - ]), - ); - - renderWithProviders(); - - // Scope to the key's row so we assert the visible cell value: the hover popover that - // also holds the email is portaled out of the row, not the displayed "Created By" text. - const row = (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement; - expect(within(row).getByText("The Creator")).toBeInTheDocument(); - expect(within(row).queryByText("creator@example.com")).not.toBeInTheDocument(); -}); - it("should render table without crashing when models is null", async () => { mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, models: null as unknown as string[] }])); @@ -327,6 +381,7 @@ it("should render table without crashing when models is null", async () => { await waitFor(() => { expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); + expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); }); }); @@ -341,13 +396,14 @@ it("should display 'Unknown' for last_active when value is null", async () => { }); describe("server-side filtering – the LIT-4080 regression guard", () => { - it("threads an active User ID filter into the useKeys query so any refetch keeps it", async () => { + it("threads an applied User ID filter into the useKeys query so any refetch keeps it", async () => { renderWithProviders(); - fireEvent.click(screen.getByRole("button", { name: "Filters" })); + openFilters(); - const userIdInput = await screen.findByPlaceholderText("Enter User ID..."); + const userIdInput = await screen.findByPlaceholderText(/Enter User ID/); fireEvent.change(userIdInput, { target: { value: "user-42" } }); + fireEvent.click(screen.getByTestId("filter-drawer-apply")); await waitFor(() => { expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" })); @@ -361,18 +417,19 @@ describe("server-side filtering – the LIT-4080 regression guard", () => { expect(lastCall[2] ?? {}).toMatchObject({ userID: undefined, teamID: undefined, keyHash: undefined }); }); - it("drops the filter from the useKeys query when Reset Filters is clicked", async () => { + it("drops the filter from the useKeys query when it is cleared", async () => { renderWithProviders(); - fireEvent.click(screen.getByRole("button", { name: "Filters" })); - const userIdInput = await screen.findByPlaceholderText("Enter User ID..."); + openFilters(); + const userIdInput = await screen.findByPlaceholderText(/Enter User ID/); fireEvent.change(userIdInput, { target: { value: "user-42" } }); + fireEvent.click(screen.getByTestId("filter-drawer-apply")); await waitFor(() => { expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" })); }); - fireEvent.click(screen.getByRole("button", { name: "Reset Filters" })); + fireEvent.click(screen.getByTestId("datatable-clear-filters")); await waitFor(() => { const lastCall = mockUseKeys.mock.calls[mockUseKeys.mock.calls.length - 1]; @@ -388,8 +445,8 @@ describe("pagination display – total count comes from useKeys", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Showing 1 - 50 of 509 results")).toBeInTheDocument(); - expect(screen.getByText("Page 1 of 11")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 509"); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 11"); }); }); @@ -399,57 +456,44 @@ describe("pagination display – total count comes from useKeys", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument(); - expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-1 of 1"); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 1"); }); }); }); -describe("refetch button", () => { - it("should show Fetch button in normal state", () => { +describe("refresh button", () => { + it("renders an enabled refresh control in the normal state", () => { renderWithProviders(); - const fetchButton = screen.getByTitle("Fetch data"); - expect(fetchButton).toBeInTheDocument(); - expect(fetchButton).not.toBeDisabled(); - expect(screen.getByText("Fetch")).toBeInTheDocument(); + const refresh = screen.getByTestId("datatable-refresh"); + expect(refresh).toBeInTheDocument(); + expect(refresh).not.toBeDisabled(); }); - it("should show Fetching state and keep table data visible during refetch", () => { + it("disables the refresh control while a fetch is in flight but keeps data visible", () => { mockUseKeys.mockReturnValue(keysResult([mockKey], {}, { isFetching: true })); renderWithProviders(); - expect(screen.getByText("Fetching")).toBeInTheDocument(); - expect(screen.getByTitle("Fetch data")).toBeDisabled(); + expect(screen.getByTestId("datatable-refresh")).toBeDisabled(); expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); - expect(screen.queryByText("🚅 Loading keys...")).not.toBeInTheDocument(); }); - it("should call refetch when Fetch button is clicked", () => { + it("calls refetch when the refresh control is clicked", () => { const mockRefetch = vi.fn(); mockUseKeys.mockReturnValue(keysResult([mockKey], {}, { refetch: mockRefetch })); renderWithProviders(); - fireEvent.click(screen.getByTitle("Fetch data")); + fireEvent.click(screen.getByTestId("datatable-refresh")); expect(mockRefetch).toHaveBeenCalledTimes(1); }); - - it("should show Fetch button enabled on error so user can retry", () => { - mockUseKeys.mockReturnValue(keysResult([], {}, { data: null, isError: true })); - - renderWithProviders(); - - const fetchButton = screen.getByTitle("Fetch data"); - expect(fetchButton).not.toBeDisabled(); - expect(screen.getByText("Fetch")).toBeInTheDocument(); - }); }); -describe("Status column reflects key.blocked / scim_blocked metadata", () => { - it("should render Active for a non-blocked key", async () => { +describe("Status column reflects blocked / expiry / scim metadata", () => { + it("renders Active for a non-blocked, unexpired key", async () => { mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: false, metadata: {} }])); renderWithProviders(); @@ -459,7 +503,19 @@ describe("Status column reflects key.blocked / scim_blocked metadata", () => { }); }); - it("should render Blocked when key.blocked is true", async () => { + it("renders Expired when the expiry date has passed", async () => { + mockUseKeys.mockReturnValue( + keysResult([{ ...mockKey, blocked: false, metadata: {}, expires: "2020-01-01T00:00:00Z" }]), + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId(`key-status-${mockKey.token_id}`)).toHaveTextContent("Expired"); + }); + }); + + it("renders Blocked when key.blocked is true", async () => { mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: true, metadata: {} }])); renderWithProviders(); @@ -470,7 +526,7 @@ describe("Status column reflects key.blocked / scim_blocked metadata", () => { expect(screen.queryByText(/Blocked by SCIM/i)).not.toBeInTheDocument(); }); - it("should mark a SCIM-blocked key with the SCIM tooltip reason", async () => { + it("marks a SCIM-blocked key with the SCIM tooltip reason", async () => { mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: true, metadata: { scim_blocked: true } }])); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index cae6dc54df5..fe929dc0179 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -1,810 +1,251 @@ "use client"; -import { useKeys, KeyListCallOptions } from "@/app/(dashboard)/hooks/keys/useKeys"; + +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useAllTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; -import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { - ColumnDef, - flexRender, - getCoreRowModel, - PaginationState, - SortingState, - useReactTable, -} from "@tanstack/react-table"; -import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react"; -import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons"; -import { Button as AntButton, Popover, Skeleton, Typography } from "antd"; -import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells"; -import React, { useDeferredValue, useMemo, useState } from "react"; -import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; -import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect"; + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableToolbar, +} from "@/components/shared/DataTable"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { PageHeader } from "@/components/shared/PageHeader"; +import { Input } from "@/components/ui/input"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; +import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import { KeyRound } from "lucide-react"; +import React, { useCallback, useMemo, useState } from "react"; + import { KeyResponse, Team } from "../key_team_helpers/key_list"; -import FilterComponent, { FilterOption } from "../molecules/filter"; -import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; import KeyInfoView from "../templates/key_info_view"; +import { getKeyTableColumns, KEY_TABLE_HIDDEN_COLUMNS } from "./keyTableColumns"; -type KeyFilterState = { - "Team ID": string; - "Organization ID": string; - "Key Alias": string; - "User ID": string; - "Key Hash": string; +interface VirtualKeysTableProps { + headerActions?: React.ReactNode; +} + +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + +const toSortOrder = (sorting: SortingState): "asc" | "desc" | undefined => { + const active = sorting[0]; + if (!active) return undefined; + return active.desc ? "desc" : "asc"; }; -const DEFAULT_KEY_FILTERS: KeyFilterState = { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Key Hash": "", +const FILTER_LABELS: Record = { + team_id: "Team", + org_id: "Organization", + user_id: "User ID", + key_hash: "Key ID", }; -type KeyListFilterOptions = Pick< - KeyListCallOptions, - "teamID" | "organizationID" | "selectedKeyAlias" | "userID" | "keyHash" ->; +export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { + const { data: fetchedOrganizations } = useOrganizations(); + const organizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]); + const { data: fetchedTeams } = useAllTeams(); + const allTeams = useMemo(() => fetchedTeams ?? [], [fetchedTeams]); -const toKeyListFilters = (filters: KeyFilterState): KeyListFilterOptions => ({ - teamID: filters["Team ID"].trim() || undefined, - organizationID: filters["Organization ID"].trim() || undefined, - selectedKeyAlias: filters["Key Alias"].trim() || undefined, - userID: filters["User ID"].trim() || undefined, - keyHash: filters["Key Hash"].trim() || undefined, -}); - -export function VirtualKeysTable() { - const { data: fetchedOrganizations, isLoading: isOrgsLoading } = useOrganizations(); - const resolvedOrganizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]); const [selectedKey, setSelectedKey] = useState(null); - const [sorting, setSorting] = React.useState([{ id: "created_at", desc: true }]); - const [tablePagination, setTablePagination] = React.useState({ - pageIndex: 0, - pageSize: 50, - }); - const [filters, setFilters] = useState(DEFAULT_KEY_FILTERS); - const [debouncedFilters] = useDebouncedValue(filters, { wait: 300 }); + const [sorting, setSorting] = useState(DEFAULT_SORTING); + const [tablePagination, setTablePagination] = useState({ pageIndex: 0, pageSize: 50 }); + const [columnFilters, setColumnFilters] = useState([]); + const [filtersOpen, setFiltersOpen] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); - const sortBy = sorting.length > 0 ? sorting[0].id : null; - const sortOrder = sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : null; + const getFilterValue = useCallback( + (columnId: string): string | undefined => { + const entry = columnFilters.find((filter) => filter.id === columnId); + return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined; + }, + [columnFilters], + ); + + const sortBy = sorting[0]?.id; + const sortOrder = toSortOrder(sorting); + + const keyListOptions = { + teamID: getFilterValue("team_id"), + organizationID: getFilterValue("org_id"), + selectedKeyAlias: searchQuery.trim() || undefined, + userID: getFilterValue("user_id"), + keyHash: getFilterValue("key_hash"), + sortBy, + sortOrder, + expand: "user", + }; const { data: keys, isPending: isLoading, isFetching, - isError, refetch, - } = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, { - ...toKeyListFilters(debouncedFilters), - sortBy: sortBy || undefined, - sortOrder: sortOrder || undefined, - expand: "user", - }); - const [expandedAccordions, setExpandedAccordions] = useState>({}); + } = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, keyListOptions); const keyList = useMemo(() => keys?.keys ?? [], [keys]); + const rowCount = keys?.total_count ?? 0; - const { data: fetchedTeams, isLoading: isTeamsLoading } = useAllTeams(); - const allTeams = useMemo(() => fetchedTeams ?? [], [fetchedTeams]); - - // Defer the transition so the button stays in loading state until the table - // has rendered with the new data (mirrors the spend-logs pattern) - const isFetchingDeferred = useDeferredValue(isFetching); - const isButtonLoading = (isFetching || isFetchingDeferred) && !isError; - - const handleRefresh = () => { - refetch(); - }; - - const handleFilterChange = (newFilters: Record) => { - setFilters({ - "Team ID": newFilters["Team ID"] || "", - "Organization ID": newFilters["Organization ID"] || "", - "Key Alias": newFilters["Key Alias"] || "", - "User ID": newFilters["User ID"] || "", - "Key Hash": newFilters["Key Hash"] || "", - }); + const handleSearchChange = useCallback((value: string) => { + setSearchInput(value); setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); - }; + }, []); - const handleFilterReset = () => { - setFilters(DEFAULT_KEY_FILTERS); + const handleSortingChange = useCallback>((updaterOrValue) => { + setSorting(updaterOrValue); setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); - }; + }, []); - const totalCount = keys?.total_count ?? 0; + const handleColumnFiltersChange = useCallback>((updaterOrValue) => { + setColumnFilters(updaterOrValue); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); - const columns: ColumnDef[] = useMemo( - () => [ - { - id: "expander", - header: () => null, - size: 40, - enableSorting: false, - cell: ({ row }) => - row.getCanExpand() ? ( - - ) : null, - }, - { - id: "token", - accessorKey: "token", - header: "Key ID", - size: 100, - enableSorting: true, - cell: (info) => setSelectedKey(info.row.original)} />, - }, - { - id: "key_alias", - accessorKey: "key_alias", - header: "Key Alias", - size: 150, - enableSorting: true, - cell: (info) => { - const value = info.getValue() as string; - const width = info.cell.column.getSize(); - return ( - - {value ?? "-"} - - ); - }, - }, - { - id: "status", - header: "Status", - size: 100, - enableSorting: false, - cell: ({ row }) => { - const key = row.original; - if (key.blocked !== true) { - return ; - } - const isScimBlocked = (key.metadata as Record | null | undefined)?.scim_blocked === true; - const reason = isScimBlocked - ? "Blocked by SCIM (external identity provider deactivated or deleted the owning user)." - : "Blocked. Requests using this key will be rejected with 401."; - return ( - - ); - }, - }, - { - id: "key_name", - accessorKey: "key_name", - header: "Secret Key", - size: 120, - enableSorting: false, - cell: (info) => {info.getValue() as string}, - }, - { - id: "team_alias", - accessorKey: "team_id", - header: "Team", - size: 120, - enableSorting: false, - cell: (info) => { - const teamId = info.getValue() as string | null; - if (!teamId) return "-"; - const team = allTeams.find((t) => t.team_id === teamId); - const displayValue = team?.team_alias || teamId; - const width = info.cell.column.getSize(); - return ( - - {displayValue} - - ); - }, - }, - { - id: "organization_alias", - accessorKey: "org_id", - header: "Organization", - size: 140, - enableSorting: false, - cell: (info) => { - const orgId = info.getValue() as string | null; - if (!orgId) return "-"; - const org = resolvedOrganizations.find((o) => o.organization_id === orgId); - const displayValue = org?.organization_alias || orgId; - const width = info.cell.column.getSize(); - return ( - - {displayValue} - - ); - }, - }, - { - id: "user", - accessorKey: "user", - header: () => ( - - User - - - - - ), - size: 160, - enableSorting: false, - cell: ({ row }) => { - const key = row.original; - const userAlias = key.user?.user_alias ?? null; - const userEmail = key.user?.user_email ?? key.user_email ?? null; - const userId = key.user_id ?? null; - const isDefaultAdmin = userId === "default_user_id"; - const displayValue = userAlias || userEmail || userId; - const width = 160; - - const popoverContent = ( -
- {[ - { label: "User Alias", value: userAlias }, - { label: "User Email", value: userEmail }, - { label: "User ID", value: userId }, - ].map(({ label, value }) => ( -
- {label} - {value ? ( - - {value} - - ) : ( - - - )} -
- ))} -
- ); - - if (isDefaultAdmin && !userAlias && !userEmail) { - return ( - - - - - - ); - } - - return ( - - - {displayValue || "-"} - - - ); - }, - }, - { - id: "created_at", - accessorKey: "created_at", - header: "Created At", - size: 120, - enableSorting: true, - cell: (info) => , - }, - { - id: "created_by", - accessorKey: "created_by", - header: "Created By", - size: 160, - enableSorting: false, - cell: (info) => { - const userId = info.getValue() as string | null; - if (!userId) return "-"; - const key = info.row.original; - const createdByUser = key.created_by_user; - const userAlias = createdByUser?.user_alias ?? null; - const userEmail = createdByUser?.user_email ?? null; - const isDefaultAdmin = userId === "default_user_id"; - const displayValue = userAlias || userEmail || userId; - const width = 160; - - const popoverContent = ( -
- {[ - { label: "User Alias", value: userAlias }, - { label: "User Email", value: userEmail }, - { label: "User ID", value: userId }, - ].map(({ label, value }) => ( -
- {label} - {value ? ( - - {value} - - ) : ( - - - )} -
- ))} -
- ); - - if (isDefaultAdmin && !userAlias && !userEmail) { - return ( - - - - - - ); - } - - return ( - - - {displayValue} - - - ); - }, - }, - { - id: "updated_at", - accessorKey: "updated_at", - header: "Updated At", - size: 120, - enableSorting: true, - cell: (info) => , - }, - { - id: "last_active", - accessorKey: "last_active", - header: () => ( - - Last Active - - - - - ), - size: 130, - enableSorting: false, - cell: (info) => , - }, - { - id: "expires", - accessorKey: "expires", - header: "Expires", - size: 120, - enableSorting: false, - cell: (info) => , - }, - { - id: "spend", - accessorKey: "spend", - header: "Spend (USD)", - size: 100, - enableSorting: true, - cell: (info) => , - }, - { - id: "max_budget", - accessorKey: "max_budget", - header: "Budget (USD)", - size: 110, - enableSorting: true, - cell: (info) => { - const maxBudget = info.getValue() as number | null; - if (maxBudget !== null) { - return `$${formatNumberWithCommas(maxBudget)}`; - } - const teamId = info.row.original.team_id; - const team = allTeams.find((t) => t.team_id === teamId); - if (team?.max_budget != null) { - return `$${formatNumberWithCommas(team.max_budget)} (Team)`; - } - return "Unlimited"; - }, - }, - { - id: "budget_reset_at", - accessorKey: "budget_reset_at", - header: "Budget Reset", - size: 130, - enableSorting: false, - cell: (info) => , - }, - { - id: "models", - accessorKey: "models", - header: "Models", - size: 200, - enableSorting: false, - cell: (info) => { - const models = info.getValue() as string[]; - return ( -
- {Array.isArray(models) ? ( -
- {models.length === 0 ? ( - - All Proxy Models - - ) : ( - <> -
- {models.length > 3 && ( -
- { - setExpandedAccordions((prev) => ({ - ...prev, - [info.row.id]: !prev[info.row.id], - })); - }} - /> -
- )} -
- {models.slice(0, 3).map((model, index) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} - {models.length > 3 && !expandedAccordions[info.row.id] && ( - - - +{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"} - - - )} - {expandedAccordions[info.row.id] && ( -
- {models.slice(3).map((model, index) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} -
- )} -
-
- - )} -
- ) : null} -
- ); - }, - }, - { - id: "rate_limits", - header: "Rate Limits", - size: 140, - enableSorting: false, - cell: ({ row }) => { - const key = row.original; - return ( -
-
TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}
-
RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}
-
- ); - }, - }, - ], - [allTeams, resolvedOrganizations], + const columns = useMemo( + () => getKeyTableColumns({ allTeams, organizations, onSelectKey: setSelectedKey }), + [allTeams, organizations], ); - const filterOptions: FilterOption[] = [ - { - name: "Team ID", - label: "Team ID", - isSearchable: true, - loading: isTeamsLoading, - searchFn: async (searchText: string) => { - if (!allTeams || allTeams.length === 0) return []; + const teamOptions = useMemo( + () => + allTeams.map((team) => ({ + label: team.team_alias || team.team_id, + value: team.team_id, + sublabel: team.team_alias ? team.team_id : undefined, + })), + [allTeams], + ); - const filteredTeams = allTeams.filter( - (team) => - team.team_id.toLowerCase().includes(searchText.toLowerCase()) || - (team.team_alias && team.team_alias.toLowerCase().includes(searchText.toLowerCase())), - ); + const orgOptions = useMemo( + () => + organizations + .filter((org) => org.organization_id) + .map((org) => { + const id = org.organization_id as string; + return { label: org.organization_alias || id, value: id, sublabel: org.organization_alias ? id : undefined }; + }), + [organizations], + ); - return filteredTeams.map((team) => ({ - label: `${team.team_alias || team.team_id} (${team.team_id})`, - value: team.team_id, - })); - }, + const formatFilterValue = useCallback( + (columnId: string, value: unknown): string => { + const raw = String(value); + if (columnId === "team_id") { + return allTeams.find((team) => team.team_id === raw)?.team_alias || raw; + } + if (columnId === "org_id") { + return organizations.find((org) => org.organization_id === raw)?.organization_alias || raw; + } + return raw; }, - { - name: "Organization ID", - label: "Organization ID", - isSearchable: true, - loading: isOrgsLoading, - searchFn: async (searchText: string) => { - if (!resolvedOrganizations || resolvedOrganizations.length === 0) return []; + [allTeams, organizations], + ); - const filteredOrgs = resolvedOrganizations.filter( - (org) => org.organization_id?.toLowerCase().includes(searchText.toLowerCase()) ?? false, - ); - - return filteredOrgs - .filter((org) => org.organization_id !== null && org.organization_id !== undefined) - .map((org) => ({ - label: `${org.organization_id || "Unknown"} (${org.organization_id})`, - value: org.organization_id as string, - })); - }, - }, - { - name: "Key Alias", - label: "Key Alias", - customComponent: PaginatedKeyAliasSelect, - }, - { - name: "User ID", - label: "User ID", - isSearchable: false, - }, - { - name: "Key Hash", - label: "Key ID", - isSearchable: false, - }, - ]; - - const table = useReactTable({ - data: keyList, - columns: columns.filter((col) => col.id !== "expander"), - columnResizeMode: "onChange", - columnResizeDirection: "ltr", - state: { - sorting, - pagination: tablePagination, - }, - onSortingChange: (updaterOrValue) => { - const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; - setSorting(newSorting); - setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); - }, - onPaginationChange: setTablePagination, - getCoreRowModel: getCoreRowModel(), - enableSorting: true, - manualSorting: true, - manualPagination: true, - pageCount: Math.ceil(totalCount / tablePagination.pageSize), - }); - - const { pageIndex, pageSize } = table.getState().pagination; - const start = pageIndex * pageSize + 1; - const end = Math.min((pageIndex + 1) * pageSize, totalCount); - const rangeLabel = `${start} - ${end}`; - return ( -
- {selectedKey ? ( + if (selectedKey) { + return ( +
setSelectedKey(null)} keyData={selectedKey} teams={allTeams} + onDelete={refetch} /> - ) : ( -
-
- + ); + } + + return ( +
+ } + title="Virtual Keys" + subtitle="Every key that authenticates requests to the gateway." + /> + {headerActions} + row.token} + defaultColumnVisibility={KEY_TABLE_HIDDEN_COLUMNS} + sortingMode="server" + sorting={sorting} + onSortingChange={handleSortingChange} + paginationMode="server" + pagination={tablePagination} + onPaginationChange={setTablePagination} + rowCount={rowCount} + filterMode="server" + columnFilters={columnFilters} + onColumnFiltersChange={handleColumnFiltersChange} + enableColumnResizing + columnResizeMode="onChange" + isLoading={isLoading} + loadingMessage="Loading keys..." + noDataMessage="No keys found" + maxBodyHeight="calc(75vh - 210px)" + size="compact" + toolbar={(table) => ( + <> + refetch?.()} + isRefreshing={isFetching} + onOpenFilters={() => setFiltersOpen(true)} + filterLabels={FILTER_LABELS} + formatFilterValue={formatFilterValue} /> -
- -
-
- {isLoading ? ( - - ) : ( - - Showing {rangeLabel} of {totalCount} results - + + {({ get, set }) => ( + <> + + set("team_id", value)} + placeholder="Select a team…" + emptyText="No teams found" + /> + + + set("org_id", value)} + placeholder="Select an organization…" + emptyText="No organizations found" + /> + + + set("user_id", event.target.value)} + placeholder="Enter User ID…" + /> + + + set("key_hash", event.target.value)} + placeholder="Enter Key ID…" + /> + + )} - - } - onClick={handleRefresh} - disabled={isButtonLoading} - title="Fetch data" - > - {isButtonLoading ? "Fetching" : "Fetch"} - -
- -
- {isLoading ? ( - - ) : ( - - Page {pageIndex + 1} of {table.getPageCount()} - - )} - - {isLoading ? ( - - ) : ( - - )} - - {isLoading ? ( - - ) : ( - - )} -
-
-
-
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - { - const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); - if (resizer) { - (resizer as HTMLElement).style.opacity = "0.5"; - } - }} - onMouseLeave={() => { - const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); - if (resizer && !header.column.getIsResizing()) { - (resizer as HTMLElement).style.opacity = "0"; - } - }} - onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined} - > -
-
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && header.column.getCanSort() && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`} - style={{ - position: "absolute", - right: 0, - top: 0, - height: "100%", - width: "5px", - background: header.column.getIsResizing() ? "#3b82f6" : "transparent", - cursor: "col-resize", - userSelect: "none", - touchAction: "none", - opacity: header.column.getIsResizing() ? 1 : 0, - }} - /> -
- - ))} - - ))} - - - {isLoading ? ( - - -
-

🚅 Loading keys...

-
-
-
- ) : keyList.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - 3 ? "px-0" : ""}`} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No keys found

-
-
-
- )} -
-
-
-
-
-
- )} + + + )} + />
); } diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx new file mode 100644 index 00000000000..133ff89a898 --- /dev/null +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { InfoCircleOutlined } from "@ant-design/icons"; +import { ColumnDef } from "@tanstack/react-table"; +import { Popover, Typography } from "antd"; + +import { DataTableMultiSortHeader, DataTableSortHeader, type DataTableSortField } from "@/components/shared/DataTable"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + DateCell, + IdCell, + IdentityCell, + ModelsCell, + SpendBudgetCell, + StatusBadge, + type StatusTone, +} from "@/components/shared/table_cells"; + +import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; +import { KeyResponse, Team } from "../key_team_helpers/key_list"; +import { Organization } from "../networking"; + +interface KeyStatus { + tone: StatusTone; + label: string; + tooltip?: string; +} + +const SPEND_BUDGET_SORT_FIELDS: DataTableSortField[] = [ + { id: "spend", label: "Spend" }, + { id: "max_budget", label: "Budget" }, +]; + +const getKeyStatus = (key: KeyResponse): KeyStatus => { + if (key.blocked === true) { + const isScimBlocked = (key.metadata as Record | null | undefined)?.scim_blocked === true; + return { + tone: "error", + label: "Blocked", + tooltip: isScimBlocked + ? "Blocked by SCIM (external identity provider deactivated or deleted the owning user)." + : "Blocked. Requests using this key will be rejected with 401.", + }; + } + const expiresAt = key.expires ? Date.parse(key.expires) : Number.NaN; + if (!Number.isNaN(expiresAt) && expiresAt < Date.now()) { + return { tone: "warning", label: "Expired", tooltip: "This key has passed its expiry date." }; + } + return { tone: "success", label: "Active" }; +}; + +const UserPopoverCell = ({ + userAlias, + userEmail, + userId, + width, +}: { + userAlias: string | null; + userEmail: string | null; + userId: string | null; + width: number; +}) => { + const displayValue = userAlias || userEmail || userId; + const isDefaultAdmin = userId === "default_user_id"; + + const popoverContent = ( +
+ {[ + { label: "User Alias", value: userAlias }, + { label: "User Email", value: userEmail }, + { label: "User ID", value: userId }, + ].map(({ label, value }) => ( +
+ {label} + {value ? ( + + {value} + + ) : ( + - + )} +
+ ))} +
+ ); + + if (isDefaultAdmin && !userAlias && !userEmail) { + return ( + + + + + + ); + } + + return ( + + + {displayValue || "-"} + + + ); +}; + +const InfoHeader = ({ label, tooltip }: { label: string; tooltip: string }) => ( + + {label} + + + + +); + +interface KeyTableColumnsDeps { + allTeams: Team[]; + organizations: Organization[]; + onSelectKey: (key: KeyResponse) => void; +} + +export const getKeyTableColumns = ({ + allTeams, + organizations, + onSelectKey, +}: KeyTableColumnsDeps): ColumnDef[] => [ + { + id: "key_alias", + accessorKey: "key_alias", + meta: { + title: "Key", + renderSkeleton: () => ( +
+ +
+ + +
+
+ ), + }, + header: ({ column }) => , + size: 260, + enableSorting: true, + cell: ({ row }) => { + const status = getKeyStatus(row.original); + return ( + + } + onClick={() => onSelectKey(row.original)} + /> + ); + }, + }, + { + id: "token", + accessorKey: "token", + meta: { title: "Key ID" }, + header: ({ column }) => , + size: 120, + enableSorting: true, + cell: (info) => onSelectKey(info.row.original)} />, + }, + { + id: "team_alias", + accessorKey: "team_id", + meta: { title: "Team" }, + header: "Team", + size: 120, + enableSorting: false, + cell: (info) => { + const teamId = info.getValue() as string | null; + if (!teamId) return "-"; + const team = allTeams.find((t) => t.team_id === teamId); + const displayValue = team?.team_alias || teamId; + const width = info.cell.column.getSize(); + return ( + + {displayValue} + + ); + }, + }, + { + id: "organization_alias", + accessorKey: "org_id", + meta: { title: "Organization" }, + header: "Organization", + size: 140, + enableSorting: false, + cell: (info) => { + const orgId = info.getValue() as string | null; + if (!orgId) return "-"; + const org = organizations.find((o) => o.organization_id === orgId); + const displayValue = org?.organization_alias || orgId; + const width = info.cell.column.getSize(); + return ( + + {displayValue} + + ); + }, + }, + { + id: "user", + accessorKey: "user", + meta: { title: "User" }, + header: () => ( + + ), + size: 160, + enableSorting: false, + cell: ({ row }) => { + const key = row.original; + return ( + + ); + }, + }, + { + id: "created_at", + accessorKey: "created_at", + meta: { title: "Created At" }, + header: ({ column }) => , + size: 120, + enableSorting: true, + cell: (info) => , + }, + { + id: "created_by", + accessorKey: "created_by", + meta: { title: "Created By" }, + header: "Created By", + size: 160, + enableSorting: false, + cell: (info) => { + const userId = info.getValue() as string | null; + if (!userId) return "-"; + const createdByUser = info.row.original.created_by_user; + return ( + + ); + }, + }, + { + id: "updated_at", + accessorKey: "updated_at", + meta: { title: "Updated At" }, + header: ({ column }) => , + size: 120, + enableSorting: true, + cell: (info) => , + }, + { + id: "last_active", + accessorKey: "last_active", + meta: { title: "Last Active" }, + header: () => ( + + ), + size: 130, + enableSorting: false, + cell: (info) => , + }, + { + id: "expires", + accessorKey: "expires", + meta: { title: "Expires" }, + header: "Expires", + size: 120, + enableSorting: false, + cell: (info) => , + }, + { + id: "spend", + accessorKey: "spend", + meta: { title: "Spend / Budget", skeleton: "meter" }, + header: ({ table }) => , + size: 180, + enableSorting: true, + cell: ({ row }) => { + const teamId = row.original.team_id; + const team = allTeams.find((t) => t.team_id === teamId); + return ( + + ); + }, + }, + { + id: "budget_reset_at", + accessorKey: "budget_reset_at", + meta: { title: "Budget Reset" }, + header: "Budget Reset", + size: 130, + enableSorting: false, + cell: (info) => , + }, + { + id: "models", + accessorKey: "models", + meta: { title: "Models", skeleton: "chips" }, + header: "Models", + size: 220, + enableSorting: false, + cell: (info) => ( + + ), + }, + { + id: "rate_limits", + meta: { title: "Rate Limits" }, + header: "Rate Limits", + size: 140, + enableSorting: false, + cell: ({ row }) => { + const key = row.original; + return ( +
+
TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}
+
RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}
+
+ ); + }, + }, +]; + +export const KEY_TABLE_HIDDEN_COLUMNS: Record = { + token: false, + organization_alias: false, + created_by: false, + updated_at: false, + expires: false, + budget_reset_at: false, + rate_limits: false, +}; diff --git a/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx b/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx new file mode 100644 index 00000000000..720b6f88db3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/AdaptiveRoutingConfig.tsx @@ -0,0 +1,134 @@ +import { Card, InputNumber, Radio, Slider, Space, Switch, Typography } from "antd"; +import React from "react"; +import { + AdaptiveEligible, + ComplexityRouterConfigValue, + DEFAULT_ADAPTIVE_WEIGHTS, + DEFAULT_TIER_DISTANCE_PENALTY, +} from "./ComplexityRouterConfig"; + +const { Text } = Typography; + +interface AdaptiveRoutingConfigProps { + value: ComplexityRouterConfigValue; + onChange: (value: ComplexityRouterConfigValue) => void; +} + +const AdaptiveRoutingConfig: React.FC = ({ value, onChange }) => { + const adaptiveWeights = value.adaptive_weights ?? DEFAULT_ADAPTIVE_WEIGHTS; + const adaptiveEligible = value.adaptive_eligible ?? "all"; + const tierDistancePenalty = value.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY; + + const handleAdaptiveToggle = (adaptive: boolean) => { + const nextValue: ComplexityRouterConfigValue = { + ...value, + adaptive, + adaptive_weights: adaptiveWeights, + adaptive_eligible: adaptiveEligible, + tier_distance_penalty: tierDistancePenalty, + }; + onChange(nextValue); + }; + + const handleQualityWeightChange = (qualityPercent: number) => { + const quality = qualityPercent / 100; + onChange({ ...value, adaptive_weights: { quality, cost: Math.round((1 - quality) * 100) / 100 } }); + }; + + const handleAdaptiveEligibleChange = (eligible: AdaptiveEligible) => { + onChange({ ...value, adaptive_eligible: eligible }); + }; + + const handleTierDistancePenaltyChange = (penalty: number | null) => { + onChange({ ...value, tier_distance_penalty: penalty ?? DEFAULT_TIER_DISTANCE_PENALTY }); + }; + + return ( + <> +
+ + Enable adaptive bandit selection +
+ + When disabled, each request always uses the model assigned to its classified tier. + + + + + How Adaptive Routing Works + + + It learns from how each conversation actually goes: does the user have to rephrase or correct the model, does + it get stuck repeating itself, does it run out of tool calls, does the user seem satisfied. Combined with + cost, this live feedback shifts future routing toward the models that are actually working well, and improves + as more conversations come in. Until there's enough feedback, it defaults to the classified tier's + model. + + + + {value.adaptive && ( +
+
+ + Quality vs. Cost ({Math.round(adaptiveWeights.quality * 100)}% quality /{" "} + {Math.round(adaptiveWeights.cost * 100)}% cost) + + `${v}% quality / ${100 - (v ?? 0)}% cost` }} + /> + + Higher quality weight favors more capable (pricier) models; higher cost weight favors cheaper models when + the bandit has feedback to act on. Recommended: 30% quality / 70% cost split. + +
+ +
+ + Eligible Model Pool + + handleAdaptiveEligibleChange(e.target.value)} + className="w-full" + > + + + All tiers (soft floor){" "} + — router can pick across tiers, depending on the best fit for the prompt + + + Classified tier only{" "} + — router can only pick models within tier + + + +
+ + {adaptiveEligible === "all" && ( +
+ + Tier Distance Penalty + + + + Score penalty applied per tier-step away from the classified tier. + +
+ )} +
+ )} + + ); +}; + +export default AdaptiveRoutingConfig; diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx new file mode 100644 index 00000000000..92df8029edc --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -0,0 +1,171 @@ +import { InfoCircleOutlined } from "@ant-design/icons"; +import { Select as AntdSelect, Card, InputNumber, Radio, Space, Tooltip, Typography } from "antd"; +import React from "react"; +import { ClassifierType, ComplexityRouterConfigValue, DEFAULT_CLASSIFIER_TIMEOUT_MS } from "./ComplexityRouterConfig"; + +const { Text } = Typography; + +interface ClassificationMethodConfigProps { + value: ComplexityRouterConfigValue; + onChange: (value: ComplexityRouterConfigValue) => void; + modelOptions: { value: string; label: string }[]; + customTechnicalKeywords?: string[]; + onCustomTechnicalKeywordsChange?: (keywords: string[]) => void; + showValidationErrors?: boolean; +} + +const ClassificationMethodConfig: React.FC = ({ + value, + onChange, + modelOptions, + customTechnicalKeywords, + onCustomTechnicalKeywordsChange, + showValidationErrors = false, +}) => { + const classifierModelMissing = + showValidationErrors && value.classifier_type === "llm" && !value.classifier_llm_config?.model; + + const handleClassifierTypeChange = (classifierType: ClassifierType) => { + onChange({ + ...value, + classifier_type: classifierType, + classifier_llm_config: + classifierType === "llm" + ? value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS } + : undefined, + }); + }; + + const handleClassifierModelChange = (model: string) => { + onChange({ + ...value, + classifier_llm_config: { + model, + timeout_ms: value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS, + }, + }); + }; + + const handleClassifierTimeoutChange = (timeoutMs: number | null) => { + onChange({ + ...value, + classifier_llm_config: { + model: value.classifier_llm_config?.model ?? "", + timeout_ms: timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS, + }, + }); + }; + + return ( + <> + handleClassifierTypeChange(e.target.value)} + className="w-full" + > + + + Heuristic{" "} + (default) — rule-based scoring, no API calls, <1ms latency + + + LLM Classifier{" "} + — use a model to decide the tier (e.g. a small/fast model) + + + + + {value.classifier_type === "llm" && ( +
+
+ + Classifier Model + + + {classifierModelMissing && ( + + A classifier model is required + + )} +
+
+ + Timeout (ms) + + + + Falls back to the heuristic scorer if the classifier call errors, times out, or returns an unparseable + response. + +
+
+ )} + + {value.classifier_type === "heuristic" && ( +
+
+ Custom Technical Keywords + + + +
+ + Optional: Add terms to the built-in list to improve classification accuracy on the technical dimension. + (e.g., udp, kafka, terraform). + + onCustomTechnicalKeywordsChange?.(keywords)} + placeholder="Type a keyword and press Enter, or paste a comma-separated list" + tokenSeparators={[","]} + open={false} + suffixIcon={null} + style={{ width: "100%" }} + allowClear + /> +
+ )} + + + + How Classification Works + + + The router scores each request across 7 dimensions: token count, code presence, reasoning markers, technical + terms, simple indicators, multi-step patterns, and question complexity. The weighted score determines the + tier: + +
    +
  • + SIMPLE: Score < 0.15 +
  • +
  • + MEDIUM: Score 0.15 - 0.35 +
  • +
  • + COMPLEX: Score 0.35 - 0.60 +
  • +
  • + REASONING: Score > 0.60 (or 2+ reasoning markers) +
  • +
+
+ + ); +}; + +export default ClassificationMethodConfig; diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx index a34a8709918..e1f90296770 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -12,10 +12,10 @@ const mockModelInfo = [ const defaultValue: ComplexityRouterConfigValue = { tiers: { - SIMPLE: "gpt-3.5-turbo", - MEDIUM: "gpt-3.5-turbo", - COMPLEX: "gpt-4", - REASONING: "claude-3-opus", + SIMPLE: ["gpt-3.5-turbo"], + MEDIUM: ["gpt-3.5-turbo"], + COMPLEX: ["gpt-4"], + REASONING: ["claude-3-opus"], }, classifier_type: "heuristic", }; @@ -58,11 +58,13 @@ describe("ComplexityRouterConfig", () => { it("should display the how classification works section", () => { renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Classification Method")); expect(screen.getByText("How Classification Works")).toBeInTheDocument(); }); it("should show score thresholds in the classification section", () => { renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Classification Method")); expect(screen.getByText(/Score < 0.15/)).toBeInTheDocument(); expect(screen.getByText(/Score 0.15 - 0.35/)).toBeInTheDocument(); expect(screen.getByText(/Score 0.35 - 0.60/)).toBeInTheDocument(); @@ -107,6 +109,7 @@ describe("ComplexityRouterConfig", () => { it("should render the custom technical keywords field", () => { renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Classification Method")); expect(screen.getByText("Custom Technical Keywords")).toBeInTheDocument(); }); @@ -118,6 +121,7 @@ describe("ComplexityRouterConfig", () => { onCustomTechnicalKeywordsChange={vi.fn()} />, ); + fireEvent.click(screen.getByText("Advanced: Classification Method")); expect(screen.getByText("udp")).toBeInTheDocument(); expect(screen.getByText("kafka")).toBeInTheDocument(); }); @@ -132,14 +136,16 @@ describe("ComplexityRouterConfig", () => { onCustomTechnicalKeywordsChange={onCustomTechnicalKeywordsChange} />, ); - const keywordsCard = screen.getByText("Custom Technical Keywords").closest(".ant-card") as HTMLElement; - const input = within(keywordsCard).getByRole("combobox"); + fireEvent.click(screen.getByText("Advanced: Classification Method")); + const keywordsSection = screen.getByText("Custom Technical Keywords").closest("div")?.parentElement as HTMLElement; + const input = within(keywordsSection).getByRole("combobox"); await user.type(input, "udp,"); expect(onCustomTechnicalKeywordsChange).toHaveBeenCalledWith(["udp"]); }); it("should render an empty state when no keyword tier rules exist", () => { renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching")); expect(screen.getByText("Keyword Tier Overrides")).toBeInTheDocument(); expect(screen.getByText("No keyword tier overrides configured")).toBeInTheDocument(); }); @@ -158,6 +164,7 @@ describe("ComplexityRouterConfig", () => { const user = userEvent.setup(); const onKeywordTierRulesChange = vi.fn(); renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching")); await user.click(screen.getByRole("button", { name: /add keyword rule/i })); expect(onKeywordTierRulesChange).toHaveBeenCalledTimes(1); const newRules = onKeywordTierRulesChange.mock.calls[0][0]; @@ -175,6 +182,7 @@ describe("ComplexityRouterConfig", () => { onKeywordTierRulesChange={onKeywordTierRulesChange} />, ); + fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching")); expect(screen.getByText("invoice")).toBeInTheDocument(); expect(screen.getByText("refund")).toBeInTheDocument(); @@ -184,6 +192,7 @@ describe("ComplexityRouterConfig", () => { it("should not show embedding model or match score fields when semantic matching is disabled", () => { renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching")); expect(screen.getByText("Semantic keyword matching")).toBeInTheDocument(); expect(screen.queryByText("Embedding model")).not.toBeInTheDocument(); expect(screen.queryByText("Minimum match score")).not.toBeInTheDocument(); @@ -191,6 +200,7 @@ describe("ComplexityRouterConfig", () => { it("should show embedding model and match score fields when semantic matching is enabled", () => { renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching")); expect(screen.getByText("Embedding model")).toBeInTheDocument(); expect(screen.getByText("Minimum match score")).toBeInTheDocument(); }); @@ -205,6 +215,7 @@ describe("ComplexityRouterConfig", () => { onSemanticMatchingEnabledChange={onSemanticMatchingEnabledChange} />, ); + fireEvent.click(screen.getByText("Advanced: Keyword/Semantic Matching")); await user.click(screen.getByRole("switch")); expect(onSemanticMatchingEnabledChange).toHaveBeenCalledWith(true, expect.anything()); }); @@ -252,7 +263,7 @@ describe("ComplexityRouterConfig", () => { renderWithProviders( , ); diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index c31ee41a6ec..855a1b27df9 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -1,19 +1,22 @@ import { InfoCircleOutlined } from "@ant-design/icons"; -import { Select as AntdSelect, Card, Collapse, Divider, InputNumber, Radio, Space, Tooltip, Typography } from "antd"; +import { Select as AntdSelect, Card, Collapse, Divider, Space, Tooltip, Typography } from "antd"; import React from "react"; import { ModelGroup } from "@/components/llm_calls/fetch_models"; +import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig"; +import ClassificationMethodConfig from "./ClassificationMethodConfig"; import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules"; import SemanticKeywordMatching from "./SemanticKeywordMatching"; const { Text } = Typography; export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 3000; +export const DEFAULT_TIER_DISTANCE_PENALTY = 0.5; export interface ComplexityTiers { - SIMPLE: string; - MEDIUM: string; - COMPLEX: string; - REASONING: string; + SIMPLE: string[]; + MEDIUM: string[]; + COMPLEX: string[]; + REASONING: string[]; } export interface ClassifierLLMConfig { @@ -23,10 +26,23 @@ export interface ClassifierLLMConfig { export type ClassifierType = "heuristic" | "llm"; +export interface AdaptiveRouterWeights { + quality: number; + cost: number; +} + +export const DEFAULT_ADAPTIVE_WEIGHTS: AdaptiveRouterWeights = { quality: 0.3, cost: 0.7 }; + +export type AdaptiveEligible = "all" | "classified_tier"; + export interface ComplexityRouterConfigValue { tiers: ComplexityTiers; classifier_type: ClassifierType; classifier_llm_config?: ClassifierLLMConfig; + adaptive?: boolean; + adaptive_weights?: AdaptiveRouterWeights; + tier_distance_penalty?: number; + adaptive_eligible?: AdaptiveEligible; } interface ComplexityRouterConfigProps { @@ -95,44 +111,10 @@ const ComplexityRouterConfig: React.FC = ({ label: model.model_group, })); - const classifierModelMissing = - showValidationErrors && value.classifier_type === "llm" && !value.classifier_llm_config?.model; - - const handleTierChange = (tier: keyof ComplexityTiers, model: string) => { + const handleTierChange = (tier: keyof ComplexityTiers, models: string[]) => { onChange({ ...value, - tiers: { ...value.tiers, [tier]: model }, - }); - }; - - const handleClassifierTypeChange = (classifierType: ClassifierType) => { - onChange({ - ...value, - classifier_type: classifierType, - classifier_llm_config: - classifierType === "llm" - ? (value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS }) - : undefined, - }); - }; - - const handleClassifierModelChange = (model: string) => { - onChange({ - ...value, - classifier_llm_config: { - model, - timeout_ms: value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS, - }, - }); - }; - - const handleClassifierTimeoutChange = (timeoutMs: number | null) => { - onChange({ - ...value, - classifier_llm_config: { - model: value.classifier_llm_config?.model ?? "", - timeout_ms: timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS, - }, + tiers: { ...value.tiers, [tier]: models }, }); }; @@ -142,20 +124,20 @@ const ComplexityRouterConfig: React.FC = ({ Complexity Tier Configuration - + The complexity router automatically classifies requests by complexity using rule-based scoring (no API calls, - <1ms latency). Configure which model handles each tier. + <1ms latency). Configure which model(s) handle each tier. {(Object.keys(TIER_DESCRIPTIONS) as Array).map((tier, index) => { const tierInfo = TIER_DESCRIPTIONS[tier]; - const tierMissing = showValidationErrors && !value.tiers[tier]; + const tierMissing = showValidationErrors && value.tiers[tier].length === 0; return (
{index > 0 && } @@ -172,14 +154,21 @@ const ComplexityRouterConfig: React.FC = ({ Examples: {tierInfo.examples} handleTierChange(tier, model)} - placeholder={`Select model for ${tierInfo.label.toLowerCase()} queries`} + onChange={(models) => handleTierChange(tier, models)} + placeholder={`Select model(s) for ${tierInfo.label.toLowerCase()} queries`} showSearch style={{ width: "100%" }} options={modelOptions} status={tierMissing ? "error" : undefined} /> + {value.tiers[tier].length > 1 && ( + + Multiple models selected — the router randomly picks among them per request (or Thompson-samples + within the pool when adaptive routing is on). + + )} {tierMissing && ( This tier is required @@ -205,148 +194,61 @@ const ComplexityRouterConfig: React.FC = ({ ), children: ( - <> - handleClassifierTypeChange(e.target.value)} - className="w-full" - > - - - Heuristic{" "} - (default) — rule-based scoring, no API calls, <1ms latency - - - LLM Classifier{" "} - — use a model to decide the tier (e.g. a small/fast model) - - - - - {value.classifier_type === "llm" && ( -
-
- - Classifier Model - - - {classifierModelMissing && ( - - A classifier model is required - - )} -
-
- - Timeout (ms) - - - - Falls back to the heuristic scorer if the classifier call errors, times out, or returns an - unparseable response. - -
-
- )} - + ), }, + { + key: "adaptive", + label: ( + + Advanced: Adaptive Routing + + ), + children: , + }, + ...(onKeywordTierRulesChange || onSemanticMatchingEnabledChange + ? [ + { + key: "keyword-semantic", + label: ( + + Advanced: Keyword/Semantic Matching + + ), + children: ( + <> + {onKeywordTierRulesChange && ( + + )} + {onKeywordTierRulesChange && onSemanticMatchingEnabledChange && ( + + )} + {onSemanticMatchingEnabledChange && ( + + )} + + ), + }, + ] + : []), ]} /> - - - - -
- - Custom Technical Keywords - - - - -
- - Optional: Add terms to the built-in list to improve classification accuracy on the technical dimension. (e.g., - udp, kafka, terraform). - - onCustomTechnicalKeywordsChange?.(keywords)} - placeholder="Type a keyword and press Enter, or paste a comma-separated list" - tokenSeparators={[","]} - open={false} - suffixIcon={null} - style={{ width: "100%" }} - allowClear - /> -
- - - - - - How Classification Works - - - The router scores each request across 7 dimensions: token count, code presence, reasoning markers, technical - terms, simple indicators, multi-step patterns, and question complexity. The weighted score determines the - tier: - -
    -
  • - SIMPLE: Score < 0.15 -
  • -
  • - MEDIUM: Score 0.15 - 0.35 -
  • -
  • - COMPLEX: Score 0.35 - 0.60 -
  • -
  • - REASONING: Score > 0.60 (or 2+ reasoning markers) -
  • -
-
- - {/* Keyword-tier and semantic sections only render when their change handlers are - wired (the add-router flow). The edit-auto-router modal doesn't pass them yet, so - they stay hidden there rather than rendering interactive-but-dead controls. */} - {onKeywordTierRulesChange && ( - <> - - - - )} - - {onSemanticMatchingEnabledChange && ( - <> - - - - )}
); }; diff --git a/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx index c7583427af6..c2252843b29 100644 --- a/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx +++ b/ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx @@ -1,5 +1,5 @@ import { InfoCircleOutlined } from "@ant-design/icons"; -import { Card, InputNumber, Select as AntdSelect, Switch, Tooltip, Typography } from "antd"; +import { InputNumber, Select as AntdSelect, Switch, Tooltip, Typography } from "antd"; import React from "react"; import { ModelGroup } from "@/components/llm_calls/fetch_models"; @@ -36,7 +36,7 @@ const SemanticKeywordMatching: React.FC = ({ const embeddingModelMissing = showValidationErrors && !embeddingModel; return ( - +
@@ -86,7 +86,7 @@ const SemanticKeywordMatching: React.FC = ({
)} - +
); }; diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index a74eab0abdd..8724c27b41a 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -8,7 +8,11 @@ import { all_admin_roles } from "@/utils/roles"; import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; import RouterConfigBuilder from "./RouterConfigBuilder"; -import ComplexityRouterConfig, { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; +import ComplexityRouterConfig, { + ComplexityRouterConfigValue, + DEFAULT_ADAPTIVE_WEIGHTS, + DEFAULT_TIER_DISTANCE_PENALTY, +} from "./ComplexityRouterConfig"; import { KeywordTierRule } from "./KeywordTierRules"; import { DEFAULT_MATCH_THRESHOLD } from "./SemanticKeywordMatching"; import { @@ -38,7 +42,7 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc const [routerType, setRouterType] = useState("recommended"); const [complexityRouterConfig, setComplexityRouterConfig] = useState({ - tiers: { SIMPLE: "", MEDIUM: "", COMPLEX: "", REASONING: "" }, + tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }, classifier_type: "heuristic", }); @@ -89,6 +93,10 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc tiers, classifier_type: classifierType, classifier_llm_config: classifierLlmConfig, + adaptive = false, + adaptive_weights: adaptiveWeights = DEFAULT_ADAPTIVE_WEIGHTS, + tier_distance_penalty: tierDistancePenalty = DEFAULT_TIER_DISTANCE_PENALTY, + adaptive_eligible: adaptiveEligible = "all", } = complexityRouterConfig; const missingTiersError = getMissingTiersError(tiers); @@ -111,7 +119,7 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc return; } - const defaultModel = tiers.MEDIUM || tiers.SIMPLE || tiers.COMPLEX || tiers.REASONING; + const defaultModel = tiers.MEDIUM[0] || tiers.SIMPLE[0] || tiers.COMPLEX[0] || tiers.REASONING[0]; form.setFieldsValue({ custom_llm_provider: "auto_router", @@ -132,6 +140,10 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc semanticMatchingEnabled, embeddingModel, matchThreshold, + adaptive, + adaptiveWeights, + tierDistancePenalty, + adaptiveEligible, }; const submitValues = { diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts index 01b6470f17f..f8ab4bab903 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts @@ -1,10 +1,10 @@ import { buildAutoRouterTestTargets } from "./build_auto_router_test_targets"; const tiers = { - SIMPLE: "gpt-4o-mini", - MEDIUM: "claude-sonnet-4", - COMPLEX: "claude-sonnet-4", - REASONING: "o3", + SIMPLE: ["gpt-4o-mini"], + MEDIUM: ["claude-sonnet-4"], + COMPLEX: ["claude-sonnet-4"], + REASONING: ["o3"], }; describe("buildAutoRouterTestTargets", () => { @@ -17,9 +17,21 @@ describe("buildAutoRouterTestTargets", () => { ]); }); + it("emits a target per model when a tier has more than one, and dedups across tiers", () => { + const targets = buildAutoRouterTestTargets({ + tiers: { SIMPLE: ["gpt-4o-mini", "claude-sonnet-4"], MEDIUM: ["claude-sonnet-4"], COMPLEX: [], REASONING: [] }, + semanticMatchingEnabled: false, + embeddingModel: undefined, + }); + expect(targets).toEqual([ + { labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }, + { labels: ["SIMPLE", "MEDIUM"], modelGroup: "claude-sonnet-4", mode: "chat" }, + ]); + }); + it("drops empty/whitespace tiers", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: "gpt-4o-mini", MEDIUM: "", COMPLEX: " ", REASONING: "" }, + tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [" "], REASONING: [] }, semanticMatchingEnabled: false, embeddingModel: undefined, }); @@ -29,7 +41,7 @@ describe("buildAutoRouterTestTargets", () => { it("returns [] when no tier is configured", () => { expect( buildAutoRouterTestTargets({ - tiers: { SIMPLE: "", MEDIUM: "", COMPLEX: "", REASONING: "" }, + tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }, semanticMatchingEnabled: false, embeddingModel: undefined, }), @@ -38,7 +50,7 @@ describe("buildAutoRouterTestTargets", () => { it("appends an embedding target only when semantic matching is on and a model is set", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: "gpt-4o-mini", MEDIUM: "", COMPLEX: "", REASONING: "" }, + tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, semanticMatchingEnabled: true, embeddingModel: "voyage-3-5", }); @@ -50,7 +62,7 @@ describe("buildAutoRouterTestTargets", () => { it("omits the embedding target when semantic matching is on but no model is chosen", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: "gpt-4o-mini", MEDIUM: "", COMPLEX: "", REASONING: "" }, + tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, semanticMatchingEnabled: true, embeddingModel: undefined, }); @@ -59,7 +71,7 @@ describe("buildAutoRouterTestTargets", () => { it("omits the embedding target when a model is set but semantic matching is off", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: "gpt-4o-mini", MEDIUM: "", COMPLEX: "", REASONING: "" }, + tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, semanticMatchingEnabled: false, embeddingModel: "voyage-3-5", }); diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts index b2a3cc10012..708a25c16f0 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts @@ -29,9 +29,11 @@ export const buildAutoRouterTestTargets = ({ embeddingModel, }: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => { const groupedByModel = TIER_ORDER.reduce>((acc, tier) => { - const modelGroup = tiers[tier]?.trim(); - if (!modelGroup) return acc; - return { ...acc, [modelGroup]: [...(acc[modelGroup] ?? []), tier] }; + return (tiers[tier] ?? []).reduce((tierAcc, rawModel) => { + const modelGroup = rawModel?.trim(); + if (!modelGroup) return tierAcc; + return { ...tierAcc, [modelGroup]: [...(tierAcc[modelGroup] ?? []), tier] }; + }, acc); }, {}); const tierTargets: AutoRouterTestTarget[] = Object.entries(groupedByModel).map(([modelGroup, labels]) => ({ diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts index e5b547d8240..85a15ffad45 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts @@ -6,10 +6,10 @@ import { } from "./build_complexity_router_config"; const tiers = { - SIMPLE: "gpt-4o-mini", - MEDIUM: "gpt-4o", - COMPLEX: "claude-sonnet-4", - REASONING: "o1-preview", + SIMPLE: ["gpt-4o-mini"], + MEDIUM: ["gpt-4o"], + COMPLEX: ["claude-sonnet-4"], + REASONING: ["o1-preview"], }; const baseParams: BuildComplexityRouterConfigParams = { @@ -21,6 +21,10 @@ const baseParams: BuildComplexityRouterConfigParams = { semanticMatchingEnabled: false, embeddingModel: undefined, matchThreshold: 0.5, + adaptive: false, + adaptiveWeights: { quality: 0.3, cost: 0.7 }, + tierDistancePenalty: 0.5, + adaptiveEligible: "all", }; describe("buildComplexityRouterConfig", () => { @@ -29,6 +33,14 @@ describe("buildComplexityRouterConfig", () => { expect(config).toEqual({ tiers, classifier_type: "heuristic" }); }); + it("passes through a tier configured with more than one model as a pool", () => { + const config = buildComplexityRouterConfig({ + ...baseParams, + tiers: { ...tiers, SIMPLE: ["gpt-4o-mini", "gpt-4o", "claude-haiku-4-5"] }, + }); + expect(config.tiers.SIMPLE).toEqual(["gpt-4o-mini", "gpt-4o", "claude-haiku-4-5"]); + }); + it("includes classifier_llm_config only when classifier_type is llm", () => { const config = buildComplexityRouterConfig({ ...baseParams, @@ -123,6 +135,47 @@ describe("buildComplexityRouterConfig", () => { const config = buildComplexityRouterConfig(params); expect(config.keyword_tier_rules).toBeUndefined(); }); + + it("omits adaptive fields when adaptive is disabled even if weights linger in state", () => { + const config = buildComplexityRouterConfig({ + ...baseParams, + adaptive: false, + adaptiveWeights: { quality: 0.9, cost: 0.1 }, + tierDistancePenalty: 2, + adaptiveEligible: "classified_tier", + }); + expect(config.adaptive).toBeUndefined(); + expect(config.adaptive_weights).toBeUndefined(); + expect(config.tier_distance_penalty).toBeUndefined(); + expect(config.adaptive_eligible).toBeUndefined(); + }); + + it("includes tier_distance_penalty when adaptive is enabled with eligible='all'", () => { + const config = buildComplexityRouterConfig({ + ...baseParams, + adaptive: true, + adaptiveWeights: { quality: 0.6, cost: 0.4 }, + tierDistancePenalty: 0.75, + adaptiveEligible: "all", + }); + expect(config.adaptive).toBe(true); + expect(config.adaptive_weights).toEqual({ quality: 0.6, cost: 0.4 }); + expect(config.tier_distance_penalty).toBe(0.75); + expect(config.adaptive_eligible).toBe("all"); + }); + + it("omits tier_distance_penalty when eligible='classified_tier', since the penalty doesn't apply there", () => { + const config = buildComplexityRouterConfig({ + ...baseParams, + adaptive: true, + adaptiveWeights: { quality: 0.6, cost: 0.4 }, + tierDistancePenalty: 0.75, + adaptiveEligible: "classified_tier", + }); + expect(config.adaptive).toBe(true); + expect(config.adaptive_eligible).toBe("classified_tier"); + expect(config.tier_distance_penalty).toBeUndefined(); + }); }); describe("getMissingTiersError", () => { @@ -131,23 +184,27 @@ describe("getMissingTiersError", () => { }); it("names the specific missing tier when only one is blank", () => { - expect(getMissingTiersError({ ...tiers, REASONING: "" })).toBe( + expect(getMissingTiersError({ ...tiers, REASONING: [] })).toBe( "Select a model for the following tier(s): REASONING", ); }); it("names multiple missing tiers in SIMPLE/MEDIUM/COMPLEX/REASONING order", () => { - expect(getMissingTiersError({ ...tiers, SIMPLE: "", REASONING: "" })).toBe( + expect(getMissingTiersError({ ...tiers, SIMPLE: [], REASONING: [] })).toBe( "Select a model for the following tier(s): SIMPLE, REASONING", ); }); it("names all four tiers when none are filled", () => { - const noTiers = { SIMPLE: "", MEDIUM: "", COMPLEX: "", REASONING: "" }; + const noTiers = { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }; expect(getMissingTiersError(noTiers)).toBe( "Select a model for the following tier(s): SIMPLE, MEDIUM, COMPLEX, REASONING", ); }); + + it("treats a tier with more than one model as filled", () => { + expect(getMissingTiersError({ ...tiers, SIMPLE: ["gpt-4o-mini", "gpt-4o"] })).toBeNull(); + }); }); describe("getSemanticConfigError", () => { diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts index a4a8ee6b074..3c3f21163b3 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts @@ -1,12 +1,11 @@ import { KeywordTierRule } from "./KeywordTierRules"; -import { ClassifierLLMConfig, ClassifierType } from "./ComplexityRouterConfig"; - -export interface ComplexityTiers { - SIMPLE: string; - MEDIUM: string; - COMPLEX: string; - REASONING: string; -} +import { + AdaptiveEligible, + AdaptiveRouterWeights, + ClassifierLLMConfig, + ClassifierType, + ComplexityTiers, +} from "./ComplexityRouterConfig"; export interface BuildComplexityRouterConfigParams { tiers: ComplexityTiers; @@ -17,6 +16,10 @@ export interface BuildComplexityRouterConfigParams { semanticMatchingEnabled: boolean; embeddingModel: string | undefined; matchThreshold: number; + adaptive: boolean; + adaptiveWeights: AdaptiveRouterWeights; + tierDistancePenalty: number; + adaptiveEligible: AdaptiveEligible; } export interface ComplexityRouterConfigPayload { @@ -28,12 +31,16 @@ export interface ComplexityRouterConfigPayload { semantic_keyword_matching?: boolean; embedding_model?: string; match_threshold?: number; + adaptive?: boolean; + adaptive_weights?: AdaptiveRouterWeights; + tier_distance_penalty?: number; + adaptive_eligible?: AdaptiveEligible; } const TIER_KEYS: Array = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]; export const getMissingTiersError = (tiers: ComplexityTiers): string | null => { - const missing = TIER_KEYS.filter((tier) => !tiers[tier]); + const missing = TIER_KEYS.filter((tier) => tiers[tier].length === 0); if (missing.length === 0) return null; return `Select a model for the following tier(s): ${missing.join(", ")}`; }; @@ -43,7 +50,8 @@ export const getSemanticConfigError = ({ embeddingModel, keywordTierRules, }: Pick): - string | null => { + | string + | null => { if (!semanticMatchingEnabled) return null; if (!embeddingModel) return "Select an embedding model to use semantic keyword matching"; if (keywordTierRules.length === 0) return "Add at least one keyword tier rule to use semantic keyword matching"; @@ -61,6 +69,10 @@ export const buildComplexityRouterConfig = ({ semanticMatchingEnabled, embeddingModel, matchThreshold, + adaptive, + adaptiveWeights, + tierDistancePenalty, + adaptiveEligible, }: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => { // Trim keywords and drop empty ones; drop any rule left with no keywords. Clicking // "Add keyword rule" seeds a rule with an empty keywords list, so without this an @@ -81,5 +93,11 @@ export const buildComplexityRouterConfig = ({ embedding_model: embeddingModel, match_threshold: matchThreshold, }), + ...(adaptive && { + adaptive: true, + adaptive_weights: adaptiveWeights, + ...(adaptiveEligible === "all" && { tier_distance_penalty: tierDistancePenalty }), + adaptive_eligible: adaptiveEligible, + }), }; }; diff --git a/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.test.tsx b/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.test.tsx index e96936e179a..0fc9d9fd6fb 100644 --- a/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.test.tsx @@ -56,4 +56,23 @@ describe("FilterInput", () => { expect(input.value).toBe("a"); }); + + it("should not call onChange when unmounted mid-debounce", () => { + const onChange = vi.fn(); + const { unmount } = render(); + + const input = screen.getByPlaceholderText("Search..."); + + act(() => { + fireEvent.change(input, { target: { value: "test" } }); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.tsx b/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.tsx index 3590619ba25..abb6d591386 100644 --- a/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.tsx +++ b/ui/litellm-dashboard/src/components/common_components/Filters/FilterInput.tsx @@ -1,8 +1,9 @@ import { cx } from "@/lib/cva.config"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; +import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { Input } from "antd"; -import debounce from "lodash/debounce"; import { LucideIcon } from "lucide-react"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; interface FilterInputProps { placeholder?: string; @@ -13,8 +14,6 @@ interface FilterInputProps { style?: React.CSSProperties; } -const DEBOUNCE_DELAY = 300; - export const FilterInput: React.FC = ({ placeholder, value, onChange, icon: Icon, className }) => { const [localValue, setLocalValue] = useState(value); @@ -22,22 +21,13 @@ export const FilterInput: React.FC = ({ placeholder, value, on setLocalValue(value); }, [value]); - const debouncedOnChange = useMemo(() => debounce((val: string) => onChange(val), DEBOUNCE_DELAY), [onChange]); + const debouncedOnChange = useDebouncedCallback((val: string) => onChange(val), { wait: DEBOUNCE_WAIT_MS }); - useEffect(() => { - return () => { - debouncedOnChange.cancel(); - }; - }, [debouncedOnChange]); - - const handleChange = useCallback( - (e: React.ChangeEvent) => { - const newValue = e.target.value; - setLocalValue(newValue); - debouncedOnChange(newValue); - }, - [debouncedOnChange], - ); + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setLocalValue(newValue); + debouncedOnChange(newValue); + }; return ( ({ + fetchAvailableModels: vi.fn().mockResolvedValue([]), +})); + +const openCustomModelInput = () => { + const selector = document.querySelector(".ant-select-selector"); + expect(selector).toBeTruthy(); + act(() => { + fireEvent.mouseDown(selector!); + }); + act(() => { + fireEvent.click(screen.getByText("Enter custom model")); + }); + return screen.getByPlaceholderText("Enter custom model name"); +}; + +describe("ModelSelector custom model debounce", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + vi.runOnlyPendingTimers(); + }); + vi.useRealTimers(); + }); + + it("does not call onChange before the debounce wait elapses", () => { + const onChange = vi.fn(); + render(); + + const input = openCustomModelInput(); + + act(() => { + fireEvent.change(input, { target: { value: "gpt-4o" } }); + }); + + expect(onChange).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(499); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("calls onChange exactly once with the last typed value after the wait", () => { + const onChange = vi.fn(); + render(); + + const input = openCustomModelInput(); + + act(() => { + fireEvent.change(input, { target: { value: "g" } }); + fireEvent.change(input, { target: { value: "gp" } }); + fireEvent.change(input, { target: { value: "gpt-5.2" } }); + }); + + expect(onChange).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith("gpt-5.2"); + }); + + it("does not call onChange when unmounted mid-wait", () => { + const onChange = vi.fn(); + const { unmount } = render(); + + const input = openCustomModelInput(); + + act(() => { + fireEvent.change(input, { target: { value: "gpt-4o" } }); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx b/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx index 1121647c041..f2621cd1acb 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx @@ -1,9 +1,12 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect } from "react"; import { TextInput, Text } from "@tremor/react"; import { Select } from "antd"; import { RobotOutlined } from "@ant-design/icons"; +import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; +const MODEL_SELECT_DEBOUNCE_MS = 500; + interface ModelSelectorProps { accessToken: string; value?: string; @@ -30,7 +33,6 @@ const ModelSelector: React.FC = ({ const [selectedModel, setSelectedModel] = useState(value); const [showCustomModelInput, setShowCustomModelInput] = useState(false); const [modelInfo, setModelInfo] = useState([]); - const customModelTimeout = useRef(null); useEffect(() => { setSelectedModel(value); @@ -67,19 +69,13 @@ const ModelSelector: React.FC = ({ } }; - const handleCustomModelChange = (value: string) => { - // Using setTimeout to create a simple debounce effect - if (customModelTimeout.current) { - clearTimeout(customModelTimeout.current); - } - - customModelTimeout.current = setTimeout(() => { + const debouncedSelect = useDebouncedCallback( + (value: string) => { setSelectedModel(value); - if (onChange) { - onChange(value); - } - }, 500); // 500ms delay after typing stops - }; + onChange?.(value); + }, + { wait: MODEL_SELECT_DEBOUNCE_MS }, + ); return (
@@ -109,7 +105,7 @@ const ModelSelector: React.FC = ({ )} diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx new file mode 100644 index 00000000000..a70b7602e5b --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx @@ -0,0 +1,97 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm"; +import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "./RouterSettingsAccordion"; + +vi.mock("../networking", () => ({ + getRouterSettingsCall: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/components/llm_calls/fetch_models", () => ({ + fetchAvailableModels: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({ + FallbackSelectionForm: () => null, +})); + +vi.mock("@tremor/react", () => ({ + TabGroup: ({ children }: { children: ReactNode }) =>
{children}
, + TabList: ({ children }: { children: ReactNode }) =>
{children}
, + Tab: ({ children }: { children: ReactNode }) =>
{children}
, + TabPanels: ({ children }: { children: ReactNode }) =>
{children}
, + TabPanel: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("../router_settings/RouterSettingsForm", () => ({ + default: ({ + value, + onChange, + }: { + value: RouterSettingsFormValue; + onChange: (value: RouterSettingsFormValue) => void; + }) => ( +
+ + +
+ ), +})); + +describe("RouterSettingsAccordion", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + const flushInitialPropagation = async (onChange: ReturnType) => { + await act(async () => { + vi.advanceTimersByTime(100); + }); + onChange.mockClear(); + }; + + it("debounces propagation and calls onChange once with the last value", async () => { + const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>(); + render(); + await flushInitialPropagation(onChange); + + fireEvent.click(screen.getByText("set-least-busy")); + act(() => { + vi.advanceTimersByTime(50); + }); + fireEvent.click(screen.getByText("set-usage-based")); + + expect(onChange).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(99); + }); + expect(onChange).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].router_settings.routing_strategy).toBe("usage-based-routing"); + }); + + it("does not call onChange when unmounted mid-wait", async () => { + const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>(); + const { unmount } = render(); + await flushInitialPropagation(onChange); + + fireEvent.click(screen.getByText("set-least-busy")); + unmount(); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx index 0aa274b5749..08b917e302f 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react"; import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react"; +import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { getRouterSettingsCall } from "../networking"; import RouterSettingsForm, { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm"; import { Fallbacks } from "../Settings/RouterSettings/Fallbacks/AddFallbacks"; @@ -35,6 +36,8 @@ export interface RouterSettingsAccordionRef { getValue: () => RouterSettingsAccordionValue; } +const PROPAGATE_WAIT_MS = 100; + const RouterSettingsAccordion = forwardRef( ({ accessToken, value, onChange, modelData }, ref) => { const [formValue, setFormValue] = useState({ @@ -304,21 +307,26 @@ const RouterSettingsAccordion = forwardRef { - if (!onChange) { - return; - } - - const timeoutId = setTimeout(() => { + const debouncedPropagate = useDebouncedCallback( + () => { + if (!onChange) { + return; + } isInternalUpdateRef.current = true; const finalRouterSettings = buildRouterSettings(); onChange({ router_settings: finalRouterSettings, }); - }, 100); + }, + { wait: PROPAGATE_WAIT_MS }, + ); - return () => clearTimeout(timeoutId); + // Update parent when form values change (with debounce to avoid infinite loops) + useEffect(() => { + if (!onChange) { + return; + } + debouncedPropagate(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [formValue, fallbacks]); diff --git a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx index 84140135db7..7d27886c7f5 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx @@ -3,6 +3,7 @@ import { Select, Typography } from "antd"; import { LoadingOutlined } from "@ant-design/icons"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { Team } from "../key_team_helpers/key_list"; const { Text } = Typography; @@ -19,7 +20,6 @@ interface TeamDropdownProps { } const SCROLL_THRESHOLD = 0.8; -const DEBOUNCE_MS = 300; const TeamDropdown: React.FC = ({ value, @@ -31,7 +31,7 @@ const TeamDropdown: React.FC = ({ }) => { const [searchInput, setSearchInput] = useState(""); const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { - wait: DEBOUNCE_MS, + wait: DEBOUNCE_WAIT_MS, }); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams( diff --git a/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx index d91f83c589b..3a48b5f7b50 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx @@ -3,6 +3,7 @@ import { Select, Typography } from "antd"; import { LoadingOutlined } from "@ant-design/icons"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { Team } from "../key_team_helpers/key_list"; const { Text } = Typography; @@ -17,7 +18,6 @@ interface TeamMultiSelectProps { } const SCROLL_THRESHOLD = 0.8; -const DEBOUNCE_MS = 300; const TeamMultiSelect: React.FC = ({ value = [], @@ -29,7 +29,7 @@ const TeamMultiSelect: React.FC = ({ }) => { const [searchInput, setSearchInput] = useState(""); const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { - wait: DEBOUNCE_MS, + wait: DEBOUNCE_WAIT_MS, }); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams( diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx new file mode 100644 index 00000000000..72b0e10e5d6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx @@ -0,0 +1,68 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import UserSearchModal from "./user_search_modal"; +import { userFilterUICall } from "@/components/networking"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; + +vi.mock("@/components/networking", () => ({ + userFilterUICall: vi.fn().mockResolvedValue([]), +})); + +const renderModal = () => + render(); + +const getEmailSearchInput = () => within(screen.getByTestId("member-email-search")).getByRole("combobox"); + +describe("UserSearchModal", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(userFilterUICall).mockClear(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it("debounces the user search and fires exactly once with the last typed value", async () => { + renderModal(); + const input = getEmailSearchInput(); + + act(() => { + fireEvent.change(input, { target: { value: "a" } }); + fireEvent.change(input, { target: { value: "ab" } }); + fireEvent.change(input, { target: { value: "abc" } }); + }); + + act(() => { + vi.advanceTimersByTime(DEBOUNCE_WAIT_MS - 1); + }); + expect(userFilterUICall).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + }); + + expect(userFilterUICall).toHaveBeenCalledTimes(1); + const params = vi.mocked(userFilterUICall).mock.calls[0][1]; + expect(params.get("user_email")).toBe("abc"); + }); + + it("does not fire the search when unmounted mid-wait", () => { + const { unmount } = renderModal(); + const input = getEmailSearchInput(); + + act(() => { + fireEvent.change(input, { target: { value: "abc" } }); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(DEBOUNCE_WAIT_MS * 2); + }); + + expect(userFilterUICall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx index 866d7cbec7f..fafafd8e5d5 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx @@ -1,8 +1,9 @@ -import { useState, useCallback } from "react"; +import { useState } from "react"; import { Modal, Form, Button, Select, Tooltip } from "antd"; import { UserAddOutlined } from "@ant-design/icons"; -import debounce from "lodash/debounce"; +import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { userFilterUICall } from "@/components/networking"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; interface User { user_id: string; user_email: string; @@ -93,9 +94,9 @@ const UserSearchModal: React.FC = ({ } }; - const debouncedSearch = useCallback( - debounce((text: string, fieldName: "user_email" | "user_id") => fetchUsers(text, fieldName), 300), - [], + const debouncedSearch = useDebouncedCallback( + (text: string, fieldName: "user_email" | "user_id") => fetchUsers(text, fieldName), + { wait: DEBOUNCE_WAIT_MS }, ); const handleSearch = (value: string, fieldName: "user_email" | "user_id"): void => { diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.ts new file mode 100644 index 00000000000..cd8093928d5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.ts @@ -0,0 +1,104 @@ +import { buildUpdatedComplexityRouterConfig } from "./edit_auto_router_modal"; + +const storedConfigValue = { + tiers: { + SIMPLE: "old-simple", + MEDIUM: "old-medium", + COMPLEX: "old-complex", + REASONING: "old-reasoning", + }, + classifier_type: "llm", + classifier_llm_config: { model: "old-classifier", timeout_ms: 1200 }, + custom_technical_keywords: ["kafka", "terraform"], + keyword_tier_rules: [{ keywords: ["invoice", "refund"], tier: "MEDIUM" }], + semantic_keyword_matching: true, + embedding_model: "voyage-4-large", + match_threshold: 0.65, + adaptive: true, + adaptive_weights: { quality: 0.3, cost: 0.7 }, + tier_distance_penalty: 0.8, + adaptive_eligible: "all", +}; + +const storedConfig = JSON.stringify(storedConfigValue); + +const tiers = { + SIMPLE: ["gpt-4o-mini"], + MEDIUM: ["gpt-4o-mini"], + COMPLEX: ["anthropic-sonnet-4-5"], + REASONING: ["anthropic-sonnet-4-5"], +}; + +const classifiedTierValue = { + tiers, + classifier_type: "heuristic" as const, + adaptive: true, + adaptive_weights: { quality: 0.4, cost: 0.6 }, + tier_distance_penalty: 0.8, + adaptive_eligible: "classified_tier" as const, +}; + +const expectedClassifiedTierConfig = { + tiers, + classifier_type: "heuristic", + custom_technical_keywords: ["kafka", "terraform"], + keyword_tier_rules: [{ keywords: ["invoice", "refund"], tier: "MEDIUM" }], + semantic_keyword_matching: true, + embedding_model: "voyage-4-large", + match_threshold: 0.65, + adaptive: true, + adaptive_weights: { quality: 0.4, cost: 0.6 }, + adaptive_eligible: "classified_tier", +}; + +const adaptiveDisabledValue = { + tiers, + classifier_type: "heuristic" as const, + adaptive: false, +}; + +const expectedAdaptiveDisabledConfig = { + tiers, + classifier_type: "heuristic", + custom_technical_keywords: ["kafka", "terraform"], + keyword_tier_rules: [{ keywords: ["invoice", "refund"], tier: "MEDIUM" }], + semantic_keyword_matching: true, + embedding_model: "voyage-4-large", + match_threshold: 0.65, +}; + +describe("buildUpdatedComplexityRouterConfig", () => { + it("preserves unrelated options and omits the penalty for classified-tier routing", () => { + const updatedConfig = buildUpdatedComplexityRouterConfig(storedConfig, classifiedTierValue); + + expect(updatedConfig).toEqual(expectedClassifiedTierConfig); + }); + + it("removes managed adaptive and classifier fields when they are disabled", () => { + const updatedConfig = buildUpdatedComplexityRouterConfig(storedConfig, adaptiveDisabledValue); + + expect(updatedConfig).toEqual(expectedAdaptiveDisabledConfig); + }); + + it("updates custom technical keywords when they are edited", () => { + const updatedConfig = buildUpdatedComplexityRouterConfig(storedConfig, classifiedTierValue, ["postgres"]); + + expect(updatedConfig.custom_technical_keywords).toEqual(["postgres"]); + }); + + it("removes custom technical keywords when they are cleared", () => { + const updatedConfig = buildUpdatedComplexityRouterConfig(storedConfig, classifiedTierValue, []); + + expect(updatedConfig.custom_technical_keywords).toBeUndefined(); + }); + + it("preserves a tier configured with more than one model as a pool", () => { + const multiModelValue = { + ...classifiedTierValue, + tiers: { ...tiers, SIMPLE: ["gpt-4o-mini", "claude-haiku-4-5"] }, + }; + const updatedConfig = buildUpdatedComplexityRouterConfig(storedConfig, multiModelValue); + + expect(updatedConfig.tiers).toMatchObject({ SIMPLE: ["gpt-4o-mini", "claude-haiku-4-5"] }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index 9c97809bfde..ec54c9b7bad 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -4,13 +4,23 @@ import { Text, TextInput } from "@tremor/react"; import { modelAvailableCall, modelPatchUpdateCall } from "../networking"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; import RouterConfigBuilder from "../add_model/RouterConfigBuilder"; -import ComplexityRouterConfig, { ComplexityRouterConfigValue } from "../add_model/ComplexityRouterConfig"; +import ComplexityRouterConfig, { + ComplexityRouterConfigValue, + DEFAULT_ADAPTIVE_WEIGHTS, + DEFAULT_TIER_DISTANCE_PENALTY, +} from "../add_model/ComplexityRouterConfig"; import NotificationsManager from "../molecules/notifications_manager"; const isComplexityRouterModel = (modelData: any): boolean => modelData?.litellm_params?.model?.startsWith("auto_router/complexity_router") || modelData?.litellm_params?.complexity_router_config != null; +const normalizeTierModels = (value: unknown): string[] => { + if (Array.isArray(value)) return value; + if (typeof value === "string" && value) return [value]; + return []; +}; + interface EditAutoRouterModalProps { isVisible: boolean; onCancel: () => void; @@ -20,6 +30,57 @@ interface EditAutoRouterModalProps { userRole: string; } +const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([ + "tiers", + "classifier_type", + "classifier_llm_config", + "adaptive", + "adaptive_weights", + "tier_distance_penalty", + "adaptive_eligible", +]); + +const toRecord = (value: unknown): Record => { + const parsed: unknown = typeof value === "string" ? JSON.parse(value) : value; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : {}; +}; + +export const buildUpdatedComplexityRouterConfig = ( + storedConfig: unknown, + value: ComplexityRouterConfigValue, + customTechnicalKeywords?: string[], +): Record => { + const preservedConfig = Object.fromEntries( + Object.entries(toRecord(storedConfig)).filter( + ([key]) => + !MANAGED_COMPLEXITY_ROUTER_KEYS.has(key) && + (customTechnicalKeywords === undefined || key !== "custom_technical_keywords"), + ), + ); + const adaptiveEligible = value.adaptive_eligible ?? "all"; + + return { + ...preservedConfig, + tiers: value.tiers, + classifier_type: value.classifier_type, + ...(value.classifier_type === "llm" ? { classifier_llm_config: value.classifier_llm_config } : {}), + ...(customTechnicalKeywords && + customTechnicalKeywords.length > 0 && { + custom_technical_keywords: customTechnicalKeywords, + }), + ...(value.adaptive && { + adaptive: true, + adaptive_weights: value.adaptive_weights ?? DEFAULT_ADAPTIVE_WEIGHTS, + ...(adaptiveEligible === "all" && { + tier_distance_penalty: value.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY, + }), + adaptive_eligible: adaptiveEligible, + }), + }; +}; + const EditAutoRouterModal: React.FC = ({ isVisible, onCancel, @@ -35,8 +96,9 @@ const EditAutoRouterModal: React.FC = ({ const [showCustomDefaultModel, setShowCustomDefaultModel] = useState(false); const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState(false); const [routerConfig, setRouterConfig] = useState(null); + const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState([]); const [complexityRouterConfig, setComplexityRouterConfig] = useState({ - tiers: { SIMPLE: "", MEDIUM: "", COMPLEX: "", REASONING: "" }, + tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }, classifier_type: "heuristic", }); const isComplexityRouter = isComplexityRouterModel(modelData); @@ -85,14 +147,21 @@ const EditAutoRouterModal: React.FC = ({ setComplexityRouterConfig({ tiers: { - SIMPLE: parsedConfig.tiers?.SIMPLE || "", - MEDIUM: parsedConfig.tiers?.MEDIUM || "", - COMPLEX: parsedConfig.tiers?.COMPLEX || "", - REASONING: parsedConfig.tiers?.REASONING || "", + SIMPLE: normalizeTierModels(parsedConfig.tiers?.SIMPLE), + MEDIUM: normalizeTierModels(parsedConfig.tiers?.MEDIUM), + COMPLEX: normalizeTierModels(parsedConfig.tiers?.COMPLEX), + REASONING: normalizeTierModels(parsedConfig.tiers?.REASONING), }, classifier_type: parsedConfig.classifier_type || "heuristic", classifier_llm_config: parsedConfig.classifier_llm_config, + adaptive: parsedConfig.adaptive || false, + adaptive_weights: parsedConfig.adaptive_weights, + tier_distance_penalty: parsedConfig.tier_distance_penalty, + adaptive_eligible: parsedConfig.adaptive_eligible || "all", }); + setCustomTechnicalKeywords( + Array.isArray(parsedConfig.custom_technical_keywords) ? parsedConfig.custom_technical_keywords : [], + ); form.setFieldsValue({ auto_router_name: modelData.model_name, @@ -138,7 +207,7 @@ const EditAutoRouterModal: React.FC = ({ if (isComplexityRouter) { const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig; - if (Object.values(tiers).filter(Boolean).length === 0) { + if (Object.values(tiers).every((models) => models.length === 0)) { NotificationsManager.fromBackend("Please select at least one model for a complexity tier"); return; } @@ -147,14 +216,14 @@ const EditAutoRouterModal: React.FC = ({ return; } - const defaultModel = tiers.MEDIUM || tiers.SIMPLE || tiers.COMPLEX || tiers.REASONING; + const defaultModel = tiers.MEDIUM[0] || tiers.SIMPLE[0] || tiers.COMPLEX[0] || tiers.REASONING[0]; const updatedLitellmParams = { ...modelData.litellm_params, - complexity_router_config: { - tiers, - classifier_type, - ...(classifier_type === "llm" ? { classifier_llm_config } : {}), - }, + complexity_router_config: buildUpdatedComplexityRouterConfig( + modelData.litellm_params?.complexity_router_config, + complexityRouterConfig, + customTechnicalKeywords, + ), complexity_router_default_model: defaultModel, }; const updatedModelInfo = { @@ -264,6 +333,8 @@ const EditAutoRouterModal: React.FC = ({ onChange={(config) => { setComplexityRouterConfig(config); }} + customTechnicalKeywords={customTechnicalKeywords} + onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords} />
) : ( diff --git a/ui/litellm-dashboard/src/components/key_scope.test.ts b/ui/litellm-dashboard/src/components/key_scope.test.ts new file mode 100644 index 00000000000..7c20a1baece --- /dev/null +++ b/ui/litellm-dashboard/src/components/key_scope.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { deriveKeyModelScope } from "./key_scope"; + +describe("deriveKeyModelScope", () => { + it("treats unrestricted keys (null/empty allowed_routes) as full model access", () => { + expect(deriveKeyModelScope(null)).toEqual({ hasModelAccess: true, label: null }); + expect(deriveKeyModelScope(undefined)).toEqual({ hasModelAccess: true, label: null }); + expect(deriveKeyModelScope([])).toEqual({ hasModelAccess: true, label: null }); + }); + + it("classifies SCIM keys as no model access", () => { + expect(deriveKeyModelScope(["/scim/*"])).toEqual({ hasModelAccess: false, label: "SCIM" }); + expect(deriveKeyModelScope(["/scim/v2/Users", "/scim/v2/Groups"])).toEqual({ + hasModelAccess: false, + label: "SCIM", + }); + }); + + it("classifies management-only keys as no model access", () => { + expect(deriveKeyModelScope(["management_routes"])).toEqual({ hasModelAccess: false, label: "Management" }); + }); + + it("classifies read-only keys as no model access", () => { + expect(deriveKeyModelScope(["info_routes"])).toEqual({ hasModelAccess: false, label: "Read-only" }); + }); + + it("leaves LLM-API and custom scopes with model access (default rendering)", () => { + expect(deriveKeyModelScope(["llm_api_routes"])).toEqual({ hasModelAccess: true, label: null }); + expect(deriveKeyModelScope(["/chat/completions"])).toEqual({ hasModelAccess: true, label: null }); + expect(deriveKeyModelScope(["management_routes", "llm_api_routes"])).toEqual({ + hasModelAccess: true, + label: null, + }); + }); + + it("prefers a persisted key_type over allowed_routes for the no-inference buckets", () => { + expect(deriveKeyModelScope([], "management")).toEqual({ hasModelAccess: false, label: "Management" }); + expect(deriveKeyModelScope([], "read_only")).toEqual({ hasModelAccess: false, label: "Read-only" }); + expect(deriveKeyModelScope(["some_future_mgmt_preset"], "management")).toEqual({ + hasModelAccess: false, + label: "Management", + }); + }); + + it("falls back to allowed_routes for null/default/llm_api key_type", () => { + expect(deriveKeyModelScope(["/scim/*"], null)).toEqual({ hasModelAccess: false, label: "SCIM" }); + expect(deriveKeyModelScope(["/scim/*"], "default")).toEqual({ hasModelAccess: false, label: "SCIM" }); + expect(deriveKeyModelScope([], "default")).toEqual({ hasModelAccess: true, label: null }); + expect(deriveKeyModelScope([], "llm_api")).toEqual({ hasModelAccess: true, label: null }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/key_scope.ts b/ui/litellm-dashboard/src/components/key_scope.ts new file mode 100644 index 00000000000..01dc595b4f1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/key_scope.ts @@ -0,0 +1,49 @@ +export interface KeyModelScope { + hasModelAccess: boolean; + label: string | null; +} + +const MANAGEMENT_ROUTES_PRESET = "management_routes"; +const INFO_ROUTES_PRESET = "info_routes"; +const SCIM_ROUTE_PREFIX = "/scim"; + +const MANAGEMENT_SCOPE: KeyModelScope = { hasModelAccess: false, label: "Management" }; +const READ_ONLY_SCOPE: KeyModelScope = { hasModelAccess: false, label: "Read-only" }; +const SCIM_SCOPE: KeyModelScope = { hasModelAccess: false, label: "SCIM" }; +const FULL_MODEL_ACCESS: KeyModelScope = { hasModelAccess: true, label: null }; + +const isScimRoute = (route: string): boolean => route.startsWith(SCIM_ROUTE_PREFIX); + +const isOnlyPreset = (allowedRoutes: string[], preset: string): boolean => + allowedRoutes.length === 1 && allowedRoutes[0] === preset; + +export const deriveKeyModelScope = ( + allowedRoutes: string[] | null | undefined, + keyType?: string | null, +): KeyModelScope => { + if (keyType === "management") { + return MANAGEMENT_SCOPE; + } + + if (keyType === "read_only") { + return READ_ONLY_SCOPE; + } + + if (!Array.isArray(allowedRoutes) || allowedRoutes.length === 0) { + return FULL_MODEL_ACCESS; + } + + if (allowedRoutes.every(isScimRoute)) { + return SCIM_SCOPE; + } + + if (isOnlyPreset(allowedRoutes, MANAGEMENT_ROUTES_PRESET)) { + return MANAGEMENT_SCOPE; + } + + if (isOnlyPreset(allowedRoutes, INFO_ROUTES_PRESET)) { + return READ_ONLY_SCOPE; + } + + return FULL_MODEL_ACCESS; +}; diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index 285daa8f156..e1c1fcb232c 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -12,8 +12,10 @@ export interface Team { rpm_limit: number | null; organization_id: string; created_at: string; + updated_at?: string | null; keys: KeyResponse[]; keys_count?: number; + members_count?: number; members_with_roles: Member[]; spend: number; access_group_ids?: string[]; @@ -45,6 +47,7 @@ export interface KeyResponse { budget_reset_at: string; allowed_cache_controls: string[]; allowed_routes: string[]; + key_type: string | null; permissions: Record; model_spend: Record; model_max_budget: Record; diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index c24cebe7e7d..bf3440e7ab9 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -7,9 +7,9 @@ import { getProxyBaseUrl } from "@/components/networking"; import { useTheme } from "@/contexts/ThemeContext"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Sidebar, - SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, @@ -247,13 +247,13 @@ const menuGroups: MenuGroup[] = [ icon: , external_url: "https://models.litellm.ai/cookbook", }, + { key: "caching", page: "caching", label: "Caching", icon: , roles: all_admin_roles }, { key: "experimental", page: "experimental", label: "Experimental", icon: , children: [ - { key: "caching", page: "caching", label: "Caching", icon: , roles: all_admin_roles }, { key: "prompts", page: "prompts", label: "Prompts", icon: , roles: all_admin_roles }, { key: "transform-request", @@ -608,15 +608,17 @@ const Sidebar_: React.FC = ({
- - {visibleGroups.map((group, gi) => ( - - {gi > 0 && } - {group.groupLabel} - {group.items.map((item) => renderItem(item))} - - ))} - + + + {isAdminRole(userRole) && ( diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.test.tsx new file mode 100644 index 00000000000..021aec5f85f --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.test.tsx @@ -0,0 +1,72 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { registerAuthHeaderNameGetter, registerAuthTokenGetter, registerBaseUrlGetter } from "@/lib/http/runtime"; +import { ByokCredentialModal } from "./ByokCredentialModal"; +import type { MCPServer } from "./types"; + +const fetchSpy = vi.hoisted(() => { + const spy = vi.fn<(request: Request) => Promise>(); + vi.stubGlobal("fetch", spy); + return spy; +}); + +vi.mock("@/components/molecules/message_manager", () => ({ + default: { success: vi.fn(), error: vi.fn() }, +})); + +const SERVER = { server_id: "srv-1", alias: "Linear", server_name: "Linear" } as MCPServer; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +async function fillAndSubmit(user: ReturnType) { + await user.click(screen.getByText("Continue to Authentication")); + await user.type(screen.getByPlaceholderText("Enter your API key"), "linear-key"); + await user.click(screen.getByRole("button", { name: /Connect & Authorize/ })); +} + +beforeEach(() => { + fetchSpy.mockReset(); + registerBaseUrlGetter(() => ""); + registerAuthTokenGetter(() => "sk-session"); +}); + +describe("ByokCredentialModal", () => { + it("saves the credential with the session's configured litellm key header, not a hardcoded Authorization", async () => { + registerAuthHeaderNameGetter(() => "x-litellm-api-key"); + fetchSpy.mockResolvedValue(jsonResponse({ server_id: "srv-1", has_credential: true })); + const onSuccess = vi.fn(); + const user = userEvent.setup(); + render( {}} onSuccess={onSuccess} />); + + await fillAndSubmit(user); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledWith("srv-1")); + const request = fetchSpy.mock.calls[0][0]; + expect(request.method).toBe("POST"); + expect(new URL(request.url).pathname).toBe("/v1/mcp/server/srv-1/user-credential"); + expect(request.headers.get("x-litellm-api-key")).toBe("Bearer sk-session"); + expect(request.headers.get("Authorization")).toBeNull(); + expect(await request.json()).toEqual({ credential: "linear-key", save: true }); + }); + + it("surfaces the backend's detail.error message when the save fails", async () => { + registerAuthHeaderNameGetter(() => "Authorization"); + fetchSpy.mockResolvedValue( + jsonResponse({ detail: { error: "This MCP server does not support BYOK credentials" } }, 400), + ); + const MessageManager = (await import("@/components/molecules/message_manager")).default; + const onSuccess = vi.fn(); + const user = userEvent.setup(); + render( {}} onSuccess={onSuccess} />); + + await fillAndSubmit(user); + + await waitFor(() => + expect(MessageManager.error).toHaveBeenCalledWith("This MCP server does not support BYOK credentials"), + ); + expect(onSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx index cb07db871fd..f36de019aa5 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx @@ -3,6 +3,8 @@ import React, { useState } from "react"; import { Modal, Input, Switch } from "antd"; import MessageManager from "@/components/molecules/message_manager"; +import { fetchClient } from "@/lib/http/api"; +import { ApiError } from "@/lib/http/client"; import { KeyOutlined, LockOutlined, @@ -14,21 +16,22 @@ import { } from "@ant-design/icons"; import { MCPServer } from "./types"; +const byokSaveErrorMessage = (e: unknown): string => { + if (e instanceof ApiError) { + const detail = (e.body as { detail?: { error?: string } } | null)?.detail?.error; + if (detail) return detail; + } + return e instanceof Error && e.message ? e.message : "Failed to connect"; +}; + interface ByokCredentialModalProps { server: MCPServer; open: boolean; onClose: () => void; onSuccess: (serverId: string) => void; - accessToken: string; } -export const ByokCredentialModal: React.FC = ({ - server, - open, - onClose, - onSuccess, - accessToken, -}) => { +export const ByokCredentialModal: React.FC = ({ server, open, onClose, onSuccess }) => { const [step, setStep] = useState<1 | 2>(1); const [apiKey, setApiKey] = useState(""); const [saveKey, setSaveKey] = useState(true); @@ -52,23 +55,15 @@ export const ByokCredentialModal: React.FC = ({ } setLoading(true); try { - const response = await fetch(`/v1/mcp/server/${server.server_id}/user-credential`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ credential: apiKey.trim(), save: saveKey }), + await fetchClient.POST("/v1/mcp/server/{server_id}/user-credential", { + params: { path: { server_id: server.server_id } }, + body: { credential: apiKey.trim(), save: saveKey }, }); - if (!response.ok) { - const err = await response.json(); - throw new Error(err?.detail?.error || "Failed to save credential"); - } MessageManager.success(`Connected to ${serverDisplayName}`); onSuccess(server.server_id); handleClose(); - } catch (e: any) { - MessageManager.error(e.message || "Failed to connect"); + } catch (e) { + MessageManager.error(byokSaveErrorMessage(e)); } finally { setLoading(false); } diff --git a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx b/ui/litellm-dashboard/src/components/molecules/filter.test.tsx index d956cd93168..66c671b12d4 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.test.tsx @@ -547,6 +547,44 @@ describe("FilterComponent", () => { }); }); + it("cancels a pending debounced search when the component unmounts mid-type", async () => { + const user = userEvent.setup({ delay: null }); + const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Result", value: "result" }]); + + const options: FilterOption[] = [ + { + name: "model", + label: "Model", + isSearchable: true, + searchFn: mockSearchFn, + }, + ]; + + const { unmount } = renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: "Filters" })); + + await waitFor(() => { + expect(mockSearchFn).toHaveBeenCalledWith(""); + }); + + vi.clearAllMocks(); + + const modelLabel = screen.getByText("Model"); + const modelSelect = within(modelLabel.closest("div")!).getByRole("combobox"); + await user.click(modelSelect); + await user.type(modelSelect, "test"); + + expect(mockSearchFn).not.toHaveBeenCalled(); + + unmount(); + + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(mockSearchFn).not.toHaveBeenCalled(); + }); + it("should reset all filter values when reset button is clicked", async () => { const user = userEvent.setup({ delay: null }); renderWithProviders( diff --git a/ui/litellm-dashboard/src/components/molecules/filter.tsx b/ui/litellm-dashboard/src/components/molecules/filter.tsx index 45ad3a6b9ca..8218de41a19 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.tsx @@ -1,6 +1,7 @@ +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { FilterIcon } from "@heroicons/react/outline"; +import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { Button, Input, Select } from "antd"; -import debounce from "lodash/debounce"; import React, { useCallback, useEffect, useState } from "react"; export interface FilterOptionCustomComponentProps { @@ -54,8 +55,8 @@ const FilterComponent: React.FC = ({ [key: string]: boolean; }>({}); - const debouncedSearch = useCallback( - debounce(async (value: string, option: FilterOption) => { + const debouncedSearch = useDebouncedCallback( + async (value: string, option: FilterOption) => { if (!option.isSearchable || !option.searchFn) return; setSearchLoadingMap((prev) => ({ ...prev, [option.name]: true })); @@ -68,8 +69,8 @@ const FilterComponent: React.FC = ({ } finally { setSearchLoadingMap((prev) => ({ ...prev, [option.name]: false })); } - }, 300), - [], + }, + { wait: DEBOUNCE_WAIT_MS }, ); // Load initial options for searchable filters diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index b9d04a00f61..e6ee4de2735 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -530,3 +530,67 @@ describe("buildModelGroupTestRequest", () => { expect(body).toEqual({ model: "text-embedding-3-small", input: "test from litellm" }); }); }); + +describe("testMCPToolsListRequest auth headers", () => { + const originalFetch = global.fetch; + + const captureFetch = () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => "application/json" }, + json: vi.fn().mockResolvedValue({ tools: [] }), + } as any); + global.fetch = mockFetch as any; + return mockFetch; + }; + + const sentHeaders = (mockFetch: ReturnType): Record => + (mockFetch.mock.calls[0][1] as RequestInit).headers as Record; + + afterEach(() => { + Networking.setGlobalLitellmHeaderName("Authorization"); + global.fetch = originalFetch; + }); + + it("sends the litellm key under a custom litellm_key_header_name even when an upstream OAuth token uses Authorization", async () => { + Networking.setGlobalLitellmHeaderName("x-litellm-key"); + const mockFetch = captureFetch(); + + await Networking.testMCPToolsListRequest("sk-key", {}, "upstream-oauth-token"); + + const headers = sentHeaders(mockFetch); + expect(headers["x-litellm-key"]).toBe("Bearer sk-key"); + expect(headers["Authorization"]).toBe("Bearer upstream-oauth-token"); + }); + + it("Bearer-prefixes x-litellm-api-key when it is the configured key header (raw values fail _get_bearer_token)", async () => { + Networking.setGlobalLitellmHeaderName("x-litellm-api-key"); + const mockFetch = captureFetch(); + + await Networking.testMCPToolsListRequest("sk-key", {}, "upstream-oauth-token"); + + const headers = sentHeaders(mockFetch); + expect(headers["x-litellm-api-key"]).toBe("Bearer sk-key"); + expect(headers["Authorization"]).toBe("Bearer upstream-oauth-token"); + }); + + it("never clobbers the upstream OAuth token on default deployments", async () => { + const mockFetch = captureFetch(); + + await Networking.testMCPToolsListRequest("sk-key", {}, "upstream-oauth-token"); + + const headers = sentHeaders(mockFetch); + expect(headers["Authorization"]).toBe("Bearer upstream-oauth-token"); + expect(headers["x-litellm-api-key"]).toBe("sk-key"); + }); + + it("sends the litellm key as the bearer on default deployments without an OAuth token", async () => { + const mockFetch = captureFetch(); + + await Networking.testMCPToolsListRequest("sk-key", {}); + + const headers = sentHeaders(mockFetch); + expect(headers["Authorization"]).toBe("Bearer sk-key"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 10d7f12604d..7e3af46b931 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -6650,6 +6650,9 @@ export const testMCPToolsListRequest = async ( }; if (accessToken) { headers["x-litellm-api-key"] = accessToken; + if (globalLitellmHeaderName.toLowerCase() !== "authorization") { + headers[globalLitellmHeaderName] = `Bearer ${accessToken}`; + } } if (oauthAccessToken) { headers["Authorization"] = `Bearer ${oauthAccessToken}`; @@ -6844,7 +6847,11 @@ export const exchangeMcpOAuthToken = async ({ const data = await response.json(); if (!response.ok) { - const errorMessage = deriveErrorMessage(data) || data?.detail || "OAuth token exchange failed"; + const oauthErrorMessage = + typeof data?.error === "string" && typeof data?.error_description === "string" + ? `${data.error}: ${data.error_description}` + : undefined; + const errorMessage = oauthErrorMessage || deriveErrorMessage(data) || data?.detail || "OAuth token exchange failed"; throw new Error(errorMessage); } return data; diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx index 0fe8adb70e1..f84b95b8d4d 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx @@ -1,7 +1,8 @@ import { act, fireEvent, within } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; import { Team } from "../key_team_helpers/key_list"; +import { userFilterUICall } from "../networking"; import CreateKey from "./create_key_button"; const { formMock, setFieldsValueMock, radioGroupValueRef, formStateRef, mockKeyCreateCall, teamDropdownTeamsRef } = @@ -134,14 +135,16 @@ vi.mock("antd", () => { const Select = ({ children, onChange, + onSearch, options, ...props }: { children?: any; onChange?: (value: string) => void; + onSearch?: (value: string) => void; options?: Array<{ value: string; label: string }>; - }) => - React.createElement( + }) => { + const select = React.createElement( "select", { ...props, @@ -151,6 +154,21 @@ vi.mock("antd", () => { options?.map((opt: any) => React.createElement("option", { key: opt.value, value: opt.value }, opt.label)), ); + if (!onSearch) { + return select; + } + + return React.createElement( + React.Fragment, + null, + React.createElement("input", { + "data-testid": "select-search-input", + onChange: (event: React.ChangeEvent) => onSearch(event.target.value), + }), + select, + ); + }; + Select.Option = ({ children, ...props }: { children?: any }) => React.createElement("option", props, children); const Input = (props: any) => React.createElement("input", props); @@ -641,6 +659,80 @@ describe("CreateKey", () => { }); }); + describe("user search debounce", () => { + const mockUserFilterUICall = vi.mocked(userFilterUICall); + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + const renderUserSearch = () => { + const view = renderWithProviders( + , + ); + return { input: screen.getByTestId("select-search-input"), unmount: view.unmount }; + }; + + it("should not fire the search before the wait elapses", () => { + const { input } = renderUserSearch(); + + act(() => { + fireEvent.change(input, { target: { value: "alice" } }); + }); + + expect(mockUserFilterUICall).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(299); + }); + + expect(mockUserFilterUICall).not.toHaveBeenCalled(); + }); + + it("should fire exactly one search carrying the last value after the wait", async () => { + const { input } = renderUserSearch(); + + act(() => { + fireEvent.change(input, { target: { value: "a" } }); + vi.advanceTimersByTime(100); + fireEvent.change(input, { target: { value: "al" } }); + vi.advanceTimersByTime(100); + fireEvent.change(input, { target: { value: "alice" } }); + }); + + expect(mockUserFilterUICall).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + + expect(mockUserFilterUICall).toHaveBeenCalledTimes(1); + const params = mockUserFilterUICall.mock.calls[0][1] as URLSearchParams; + expect(params.get("user_email")).toBe("alice"); + }); + + it("should fire nothing when unmounted mid-wait", () => { + const { input, unmount } = renderUserSearch(); + + act(() => { + fireEvent.change(input, { target: { value: "alice" } }); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(mockUserFilterUICall).not.toHaveBeenCalled(); + }); + }); + describe("tags dropdown", () => { it("should populate tags dropdown with options from useTags hook", async () => { renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index ef2ddab70ed..4bfd869f17a 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -10,8 +10,9 @@ import { InfoCircleOutlined } from "@ant-design/icons"; import { useQueryClient } from "@tanstack/react-query"; import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react"; import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip, Typography } from "antd"; -import debounce from "lodash/debounce"; -import React, { useCallback, useEffect, useState } from "react"; +import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; +import React, { useEffect, useState } from "react"; import { rolesWithWriteAccess } from "../../utils/roles"; import AgentSelector from "../agent_management/AgentSelector"; import { mapDisplayToInternalNames } from "../callback_info_helpers"; @@ -688,14 +689,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp } }; - const debouncedSearch = useCallback( - debounce((text: string) => fetchUsers(text), 300), - [accessToken], - ); - - const handleUserSearch = (value: string): void => { - debouncedSearch(value); - }; + const handleUserSearch = useDebouncedCallback((text: string) => fetchUsers(text), { wait: DEBOUNCE_WAIT_MS }); const handleUserSelect = (_value: string, option: UserOption): void => { const selectedUser = option.user; diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 6f4ad17313b..46811e0106b 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -566,7 +566,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, } return ( -
+
diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx index ef0d842ad3e..60547415908 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { describe, expect, it, vi } from "vitest"; import { DataTable } from "./DataTable"; -import { DataTableSortHeader } from "./DataTableSortHeader"; +import { DataTableMultiSortHeader, DataTableSortHeader } from "./DataTableSortHeader"; import { DataTableViewOptions } from "./DataTableViewOptions"; interface Person { @@ -55,6 +55,23 @@ const dropdownSortColumns: ColumnDef[] = [ }, ]; +const multiSortColumns: ColumnDef[] = [ + { + id: "spend", + accessorKey: "name", + header: ({ table }) => ( + + ), + cell: ({ row }) => {row.original.name}, + }, +]; + const nameEmailColumns: ColumnDef[] = [ { accessorKey: "name", @@ -165,6 +182,60 @@ describe("DataTable sorting", () => { await user.click(await screen.findByText("Reset")); expect(names()).toEqual(["Charlie", "Alice", "Bob"]); }); + + it("multi-sort header emits the chosen field id (not the column id) as the sort key", async () => { + const user = userEvent.setup(); + const onSortingChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByTestId("sort-trigger-spend")); + await user.click(await screen.findByText("Budget descending")); + expect(onSortingChange).toHaveBeenLastCalledWith([{ id: "max_budget", desc: true }]); + + await user.click(screen.getByTestId("sort-trigger-spend")); + await user.click(await screen.findByText("Spend ascending")); + expect(onSortingChange).toHaveBeenLastCalledWith([{ id: "spend", desc: false }]); + }); + + it("multi-sort header reflects the active field and direction, and Reset clears it", async () => { + const user = userEvent.setup(); + const onSortingChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByTestId("sort-trigger-spend")); + // The header trigger shows the active (descending) indicator while sorted by a field it owns. + expect(screen.getByTestId("sort-trigger-spend").querySelector("[data-sort-indicator='desc']")).not.toBeNull(); + + await user.click(await screen.findByText("Reset")); + expect(onSortingChange).toHaveBeenLastCalledWith([]); + }); +}); + +describe("DataTable layout", () => { + it("stretches the table to fill the container when resizing is on, so hidden columns leave no right-side gap", () => { + const { container } = render(); + + const table = container.querySelector("table"); + expect(table).not.toBeNull(); + // width pins the natural column total (horizontal scroll on overflow); minWidth:100% fills the gap on underflow. + expect(table?.style.minWidth).toBe("100%"); + }); }); describe("DataTable pagination", () => { @@ -287,6 +358,23 @@ describe("DataTable loading", () => { expect(names()).toEqual(["Charlie", "Alice", "Bob"]); }); + it("gives compact skeleton rows the same height as loaded rows so loading does not shrink the table", () => { + const { rerender } = render( + , + ); + const skeletonRow = screen.getAllByTestId("skeleton-row").at(0); + const loadedRowHeight = "h-8"; + expect(skeletonRow?.className).toContain(loadedRowHeight); + + rerender(); + expect(document.querySelector("[data-row-id]")?.className).toContain(loadedRowHeight); + }); + + it("does not force the compact height on default-size skeleton rows", () => { + render(); + expect(screen.getAllByTestId("skeleton-row").at(0)?.className).not.toContain("h-8"); + }); + it("varies skeleton shape and width per column instead of one fixed bar", () => { const columns: ColumnDef[] = [ { accessorKey: "name", header: "Name", meta: { skeleton: "twoLine" }, cell: () => null }, @@ -303,6 +391,38 @@ describe("DataTable loading", () => { // per-column widths differ instead of every cell sharing one fixed width expect(new Set(bars.map((bar) => bar.className)).size).toBeGreaterThan(1); }); + + it("renders shape-specific skeletons for badge, chips, and meter columns", () => { + const columns: ColumnDef[] = [ + { id: "badge", header: "Badge", meta: { skeleton: "badge" }, cell: () => null }, + { id: "chips", header: "Chips", meta: { skeleton: "chips" }, cell: () => null }, + { id: "meter", header: "Meter", meta: { skeleton: "meter" }, cell: () => null }, + ]; + render(); + + const firstRow = screen.getAllByTestId("skeleton-row").at(0); + const cells = Array.from(firstRow?.querySelectorAll("td") ?? []); + const barsIn = (cell: Element | undefined) => cell?.querySelectorAll('[data-slot="skeleton"]').length ?? 0; + + // badge = a single pill, chips = three pills, meter = value bar + track bar + expect(barsIn(cells[0])).toBe(1); + expect(cells[0]?.querySelector('[data-slot="skeleton"]')?.className).toContain("rounded-full"); + expect(barsIn(cells[1])).toBe(3); + expect(barsIn(cells[2])).toBe(2); + }); + + it("uses a column's renderSkeleton override when provided", () => { + const columns: ColumnDef[] = [ + { + id: "custom", + header: "Custom", + meta: { renderSkeleton: () =>
loading
}, + cell: () => null, + }, + ]; + render(); + expect(screen.getAllByTestId("custom-skeleton").length).toBeGreaterThan(0); + }); }); describe("DataTable column visibility", () => { diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx index 758ca5a597b..fa0e672026e 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -338,7 +338,11 @@ const SKELETON_WIDTHS = ["w-[58%]", "w-[44%]", "w-[70%]", "w-[50%]", "w-[64%]", function SkeletonCell({ column, index }: { column: Column | undefined; index: number }) { const meta = column?.columnDef.meta; const width = SKELETON_WIDTHS[index % SKELETON_WIDTHS.length]; - if (meta?.skeleton === "twoLine") { + const shape = meta?.skeleton; + if (meta?.renderSkeleton !== undefined) { + return <>{meta.renderSkeleton()}; + } + if (shape === "twoLine") { return (
@@ -346,6 +350,26 @@ function SkeletonCell({ column, index }: { column: Column
); } + if (shape === "badge") { + return ; + } + if (shape === "chips") { + return ( +
+ + + +
+ ); + } + if (shape === "meter") { + return ( +
+ + +
+ ); + } return ; } @@ -365,7 +389,11 @@ function SkeletonRows({ return ( {rowKeys.map((rowKey) => ( - + {cells.map((column, columnKey) => ( @@ -508,7 +536,7 @@ export function DataTable(props: DataTableProps { if (paginationSlot !== undefined) { diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx index 1cf09ce4f47..8988bd6c2d0 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx @@ -1,8 +1,8 @@ "use client"; import { Menu } from "@base-ui/react/menu"; -import type { Column, SortDirection } from "@tanstack/react-table"; -import { ChevronDown, ChevronsUpDown, ChevronUp, X } from "lucide-react"; +import type { Column, SortDirection, Table } from "@tanstack/react-table"; +import { Check, ChevronDown, ChevronsUpDown, ChevronUp, X } from "lucide-react"; import type * as React from "react"; import { cn } from "@/lib/cva.config"; @@ -94,3 +94,101 @@ export function DataTableSortHeader({ ); } + +export interface DataTableSortField { + /** Backend sort column, sent verbatim as the sorting state id (e.g. "spend", "max_budget"). */ + id: string; + label: string; +} + +interface DataTableMultiSortHeaderProps { + table: Table; + fields: DataTableSortField[]; + className?: string; +} + +/** + * Sort header for a column that merges several backend-sortable fields into one cell + * (e.g. a combined Spend / Budget cell). The header label is the field labels joined by " / ", + * with the field currently driving the sort emphasized so the active column reads at a glance + * without opening the menu. The chevron opens a menu offering each field in both directions. + */ +export function DataTableMultiSortHeader({ table, fields, className }: DataTableMultiSortHeaderProps) { + const active = table.getState().sorting[0]; + const activeField = active !== undefined && fields.some((field) => field.id === active.id) ? active : undefined; + const activeDirection: SortDirection = activeField?.desc === true ? "desc" : "asc"; + const sorted: false | SortDirection = activeField === undefined ? false : activeDirection; + + const options = fields.flatMap((field) => [ + { key: `${field.id}-asc`, id: field.id, desc: false, label: `${field.label} ascending`, Icon: ChevronUp }, + { key: `${field.id}-desc`, id: field.id, desc: true, label: `${field.label} descending`, Icon: ChevronDown }, + ]); + + const segmentClass = (isActive: boolean): string => { + if (isActive) return "font-semibold text-foreground"; + if (activeField) return "text-muted-foreground"; + return ""; + }; + + const labelSegments = fields.flatMap((field, index) => { + const isActive = activeField?.id === field.id; + const segment = ( + + {field.label} + + ); + if (index === 0) return [segment]; + return [ + + {" / "} + , + segment, + ]; + }); + + return ( +
+ {labelSegments} + + field.label).join(" or ")}`} + onClick={(event) => event.stopPropagation()} + className={cn( + "inline-flex size-6 items-center justify-center rounded-md hover:bg-muted", + sorted ? "text-primary" : "text-muted-foreground", + )} + > + + + } + /> + + + + {options.map((option) => { + const isActive = activeField?.id === option.id && activeField.desc === option.desc; + return ( + table.setSorting([{ id: option.id, desc: option.desc }])} + > + {option.label} + {isActive && } + + ); + })} + table.setSorting([])}> + Reset + + + + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts b/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts index 0f14c277c6f..eff4e0cb7db 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts +++ b/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts @@ -1,4 +1,5 @@ import type { RowData } from "@tanstack/react-table"; +import type * as React from "react"; import type { ColumnPinnedSide, DataTableSkeletonShape } from "./types"; @@ -10,5 +11,7 @@ declare module "@tanstack/react-table" { title?: string; pinned?: ColumnPinnedSide; skeleton?: DataTableSkeletonShape; + /** Full control over this column's loading skeleton, for cells the built-in shapes can't mirror. */ + renderSkeleton?: () => React.ReactNode; } } diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/index.ts b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts index c4218f6051a..1ee1eed1258 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/index.ts +++ b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts @@ -5,7 +5,12 @@ export { DataTableFilterDrawer, DataTableFilterField, type FilterDraft } from ". export { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination"; export { DataTableToolbar } from "./DataTableToolbar"; export { DataTableViewOptions } from "./DataTableViewOptions"; -export { DataTableSortHeader, type DataTableSortVariant } from "./DataTableSortHeader"; +export { + DataTableSortHeader, + DataTableMultiSortHeader, + type DataTableSortVariant, + type DataTableSortField, +} from "./DataTableSortHeader"; export type { DataTablePaginationProps } from "./DataTablePagination"; export type { ColumnPinnedSide, diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts index f5130b4c823..672ab512ef4 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts +++ b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts @@ -18,7 +18,7 @@ export type FilterMode = "none" | "client" | "server"; export type ColumnResizeMode = "onEnd" | "onChange"; export type DataTableSize = "compact" | "default"; export type ColumnPinnedSide = "left" | "right"; -export type DataTableSkeletonShape = "text" | "twoLine"; +export type DataTableSkeletonShape = "text" | "twoLine" | "badge" | "chips" | "meter"; export interface DataTableProps { data: TData[]; diff --git a/ui/litellm-dashboard/src/components/shared/PageHeader.test.tsx b/ui/litellm-dashboard/src/components/shared/PageHeader.test.tsx new file mode 100644 index 00000000000..f7a313271da --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/PageHeader.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { PageHeader } from "./PageHeader"; + +describe("PageHeader", () => { + it("renders the title as a heading", () => { + render(); + expect(screen.getByRole("heading", { name: "Virtual Keys" })).toBeInTheDocument(); + }); + + it("renders the subtitle, icon, and actions when provided", () => { + render( + } + actions={} + />, + ); + expect(screen.getByText("Every key that authenticates requests")).toBeInTheDocument(); + expect(screen.getByTestId("icon")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create New Key" })).toBeInTheDocument(); + }); + + it("omits the optional slots when not provided", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(document.querySelector("p")).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/PageHeader.tsx b/ui/litellm-dashboard/src/components/shared/PageHeader.tsx new file mode 100644 index 00000000000..e314e8e8bc2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/PageHeader.tsx @@ -0,0 +1,25 @@ +"use client"; + +import * as React from "react"; + +interface PageHeaderProps { + title: React.ReactNode; + subtitle?: React.ReactNode; + icon?: React.ReactNode; + actions?: React.ReactNode; +} + +export function PageHeader({ title, subtitle, icon, actions }: PageHeaderProps) { + return ( +
+
+ {icon != null && {icon}} +
+

{title}

+ {subtitle != null &&

{subtitle}

} +
+
+ {actions != null &&
{actions}
} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx new file mode 100644 index 00000000000..acf50d282b4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { SearchSelect } from "./SearchSelect"; + +const OPTIONS = [ + { label: "Acme Prod", value: "team-1" }, + { label: "Growth", value: "team-2" }, + { label: "Data Team", value: "team-3" }, +]; + +describe("SearchSelect", () => { + it("renders the placeholder when nothing is selected", () => { + render(); + expect(screen.getByPlaceholderText("Select Team…")).toBeInTheDocument(); + }); + + it("shows the selected option's label in the field", () => { + render(); + expect(screen.getByRole("combobox")).toHaveValue("Growth"); + }); + + it("shows a clear control only when a value is selected", () => { + const { rerender } = render(); + expect(document.querySelector('[data-slot="combobox-clear"]')).toBeNull(); + rerender(); + expect(document.querySelector('[data-slot="combobox-clear"]')).not.toBeNull(); + }); + + it("filters the options client-side as you type", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole("combobox"); + await user.click(input); + await user.type(input, "grow"); + expect(await screen.findByText("Growth")).toBeInTheDocument(); + expect(screen.queryByText("Acme Prod")).not.toBeInTheDocument(); + }); + + it("renders a muted sublabel and matches it when searching", async () => { + const user = userEvent.setup(); + render( + , + ); + const input = screen.getByRole("combobox"); + await user.click(input); + expect(await screen.findByText("team-abc-123")).toBeInTheDocument(); + await user.type(input, "abc-123"); + expect(await screen.findByText("Acme Prod")).toBeInTheDocument(); + }); + + it("selects an option and reports its value", async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Growth")); + expect(onValueChange).toHaveBeenCalledWith("team-2"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx new file mode 100644 index 00000000000..eff5cfc804d --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; + +export interface SearchSelectOption { + label: string; + value: string; + /** Optional muted second line (e.g. an id); also matched when searching. */ + sublabel?: string; +} + +interface SearchSelectProps { + options: SearchSelectOption[]; + value?: string; + onValueChange: (value: string) => void; + placeholder?: string; + emptyText?: string; + disabled?: boolean; + className?: string; +} + +const matchesQuery = (option: SearchSelectOption, query: string): boolean => { + const q = query.trim().toLowerCase(); + if (!q) return true; + return option.label.toLowerCase().includes(q) || (option.sublabel?.toLowerCase().includes(q) ?? false); +}; + +export function SearchSelect({ + options, + value, + onValueChange, + placeholder = "Select…", + emptyText = "No results", + disabled = false, + className, +}: SearchSelectProps) { + const selected = options.find((option) => option.value === value) ?? null; + + return ( + onValueChange(item?.value ?? "")} + isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value} + itemToStringLabel={(item: SearchSelectOption) => item.label} + filter={matchesQuery} + disabled={disabled} + > + + + {emptyText} + + {(item: SearchSelectOption) => ( + + + {item.label} + {item.sublabel != null && item.sublabel !== "" && ( + {item.sublabel} + )} + + + )} + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/identity_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/identity_cell.test.tsx new file mode 100644 index 00000000000..f3ec203adbb --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/identity_cell.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { IdentityCell } from "./identity_cell"; + +describe("IdentityCell", () => { + it("renders the title", () => { + render(); + expect(screen.getByText("prod-gateway")).toBeInTheDocument(); + }); + + it("renders the subtitle and an inline badge together", () => { + render(Active} />); + expect(screen.getByText("sk-...v0Pw")).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + + it("omits the subtitle row when there is no subtitle or badge", () => { + render(); + expect(document.querySelector("span.font-mono")).toBeNull(); + }); + + it("renders a static div (no button) when not clickable", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("renders a clickable button that signals interactivity and fires onClick", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button"); + expect(button.querySelector(".lucide-chevron-right")).not.toBeNull(); + // The clickable area must read as clickable: a hover background and a pointer cursor. + expect(button.className).toContain("hover:bg-muted"); + expect(button.className).toContain("cursor-pointer"); + await user.click(button); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/identity_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/identity_cell.tsx new file mode 100644 index 00000000000..97263436454 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/identity_cell.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/cva.config"; + +interface IdentityCellProps { + title: React.ReactNode; + subtitle?: React.ReactNode; + badge?: React.ReactNode; + onClick?: () => void; + className?: string; + titleClassName?: string; +} + +export function IdentityCell({ title, subtitle, badge, onClick, className, titleClassName }: IdentityCellProps) { + const hasSubtitleRow = (subtitle != null && subtitle !== "") || badge != null; + + const body = ( +
+ {title} + {hasSubtitleRow && ( + + {subtitle != null && subtitle !== "" && ( + {subtitle} + )} + {badge} + + )} +
+ ); + + if (onClick != null) { + return ( + + ); + } + + return
{body}
; +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/index.ts b/ui/litellm-dashboard/src/components/shared/table_cells/index.ts index e189413d43d..9fdd04d169c 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/index.ts +++ b/ui/litellm-dashboard/src/components/shared/table_cells/index.ts @@ -1,5 +1,8 @@ export { CellTooltip } from "./cell_tooltip"; export { DateCell, formatCellDate, formatFullTimestamp, type DatePrecision } from "./date_cell"; export { IdCell, type IdCellVariant } from "./id_cell"; +export { IdentityCell } from "./identity_cell"; +export { ModelsCell } from "./models_cell"; export { MoneyCell } from "./money_cell"; +export { SpendBudgetCell } from "./spend_budget_cell"; export { StatusBadge, type StatusTone } from "./status_badge"; diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.test.tsx new file mode 100644 index 00000000000..3a618114613 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { ModelsCell } from "./models_cell"; + +describe("ModelsCell", () => { + it("shows 'All Proxy Models' when the list is empty, null, or undefined", () => { + const { rerender } = render(); + expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); + }); + + it("shows 'No model access' for scope-restricted keys with an empty model list", () => { + const { rerender } = render(); + expect(screen.getByText("No model access")).toBeInTheDocument(); + expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText("No model access")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("No model access")).toBeInTheDocument(); + }); + + it("still shows 'All Proxy Models' for empty models when the key is not scope-restricted", () => { + render(); + expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); + expect(screen.queryByText("No model access")).not.toBeInTheDocument(); + }); + + it("uses a persisted key_type to render 'No model access' regardless of allowed_routes", () => { + const { rerender } = render(); + expect(screen.getByText("No model access")).toBeInTheDocument(); + expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText("No model access")).toBeInTheDocument(); + }); + + it("renders every model with no overflow badge when at or below the limit", () => { + render(); + expect(screen.getByText("gpt-4o")).toBeInTheDocument(); + expect(screen.getByText("claude-sonnet-4-5")).toBeInTheDocument(); + expect(screen.getByText("o3-mini")).toBeInTheDocument(); + expect(screen.queryByText(/more$/)).not.toBeInTheDocument(); + }); + + it("collapses models beyond the limit into a '+N more' badge", () => { + render(); + expect(screen.getByText("a")).toBeInTheDocument(); + expect(screen.getByText("b")).toBeInTheDocument(); + expect(screen.queryByText("c")).not.toBeInTheDocument(); + expect(screen.getByText("+3 more")).toBeInTheDocument(); + }); + + it("reveals the hidden models in a tooltip on hover", async () => { + const user = userEvent.setup(); + render(); + await user.hover(screen.getByText("+2 more")); + expect(await screen.findByText("c")).toBeInTheDocument(); + expect(await screen.findByText("d")).toBeInTheDocument(); + }); + + it("labels the all-proxy-models wildcard", () => { + render(); + expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.tsx new file mode 100644 index 00000000000..81928d6fe87 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { deriveKeyModelScope } from "@/components/key_scope"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import { Badge } from "@/components/ui/badge"; + +import { CellTooltip } from "./cell_tooltip"; + +interface ModelsCellProps { + models: string[] | null | undefined; + maxVisible?: number; + allowedRoutes?: string[] | null; + keyType?: string | null; +} + +const WILDCARD_MODEL = "all-proxy-models"; + +const formatModel = (model: string): string => { + if (model === WILDCARD_MODEL) { + return "All Proxy Models"; + } + const name = getModelDisplayName(model); + return name.length > 30 ? `${name.slice(0, 30)}...` : name; +}; + +export function ModelsCell({ models, maxVisible = 3, allowedRoutes, keyType }: ModelsCellProps) { + if (!Array.isArray(models) || models.length === 0) { + const scope = deriveKeyModelScope(allowedRoutes, keyType); + if (!scope.hasModelAccess) { + return ( + + No model access + + } + /> + ); + } + return All Proxy Models; + } + + const visible = models.slice(0, maxVisible); + const overflow = models.slice(maxVisible); + + return ( +
+ {visible.map((model, index) => ( + + {formatModel(model)} + + ))} + {overflow.length > 0 && ( + + {overflow.map((model, index) => ( + {formatModel(model)} + ))} +
+ } + trigger={ + + +{overflow.length} more + + } + /> + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/spend_budget_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/spend_budget_cell.test.tsx new file mode 100644 index 00000000000..707441aef1d --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/spend_budget_cell.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SpendBudgetCell } from "./spend_budget_cell"; + +const indicator = (container: HTMLElement) => container.querySelector('[data-slot="meter-indicator"]'); + +describe("SpendBudgetCell", () => { + it("shows Unlimited and renders no meter when there is no budget", () => { + const { container } = render(); + expect(screen.getByText("· Unlimited")).toBeInTheDocument(); + expect(screen.queryByRole("meter")).not.toBeInTheDocument(); + expect(indicator(container)).toBeNull(); + }); + + it("shows $0.00 for zero or undefined spend, never a hyphen", () => { + const { rerender } = render(); + expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.queryByText("-")).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.queryByText("-")).not.toBeInTheDocument(); + }); + + it("renders a meter carrying the spend and budget when a budget exists", () => { + render(); + const meter = screen.getByRole("meter"); + expect(meter).toHaveAttribute("aria-valuenow", "25"); + expect(meter).toHaveAttribute("aria-valuemax", "100"); + expect(screen.getByText("of $100")).toBeInTheDocument(); + }); + + it("keeps the default tone below 80% usage", () => { + const { container } = render(); + expect(indicator(container)?.className).toContain("bg-primary"); + }); + + it("switches to the warning tone at 80% usage", () => { + const { container } = render(); + expect(indicator(container)?.className).toContain("bg-amber-500"); + }); + + it("switches to the over tone above 100% usage", () => { + const { container } = render(); + expect(indicator(container)?.className).toContain("bg-destructive"); + }); + + it("falls back to the team budget and labels it", () => { + render(); + expect(screen.getByText("of $200 (Team)")).toBeInTheDocument(); + expect(screen.getByRole("meter")).toHaveAttribute("aria-valuemax", "200"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/spend_budget_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/spend_budget_cell.tsx new file mode 100644 index 00000000000..10956f23b1c --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/spend_budget_cell.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter"; +import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils"; + +interface SpendBudgetCellProps { + spend: number | null | undefined; + maxBudget: number | null | undefined; + teamMaxBudget?: number | null; +} + +const meterTone = (pct: number): "default" | "warning" | "over" => { + if (pct > 100) return "over"; + if (pct >= 80) return "warning"; + return "default"; +}; + +export function SpendBudgetCell({ spend, maxBudget, teamMaxBudget }: SpendBudgetCellProps) { + const spendValue = typeof spend === "number" && !Number.isNaN(spend) ? spend : 0; + const budget = maxBudget ?? teamMaxBudget ?? null; + const isTeamBudget = maxBudget == null && teamMaxBudget != null; + const hasBudget = typeof budget === "number" && budget > 0; + const pct = hasBudget ? (spendValue / budget) * 100 : 0; + + const spendText = spendValue > 0 ? getSpendString(spendValue, 4) : "$0.00"; + const budgetLabel = + budget === null ? "· Unlimited" : `of $${formatNumberWithCommas(budget)}${isTeamBudget ? " (Team)" : ""}`; + + return ( +
+
+ {spendText}{" "} + {budgetLabel} +
+ {hasBudget && ( + + + + + + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index f0e27607ffb..a57676d07b8 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -91,6 +91,16 @@ vi.mock("@/components/team/member_permissions", () => ({ default: vi.fn(() =>
Member Permissions
), })); +vi.mock("@/components/common_components/ModelAliasManager", () => ({ + default: vi.fn(({ initialModelAliases, onAliasUpdate }) => ( +
+
{JSON.stringify(initialModelAliases)}
+ + +
+ )), +})); + vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ useAccessGroups: vi.fn().mockReturnValue({ data: [ @@ -858,4 +868,160 @@ describe("TeamInfoView", () => { }); }); }); + + describe("model aliases", () => { + const openSettingsEditor = async (user: ReturnType) => { + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + await user.click(screen.getByRole("tab", { name: "Settings" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /edit settings/i })); + + await waitFor(() => { + expect(screen.getByLabelText("Team Name")).toBeInTheDocument(); + }); + }; + + it("should render existing model aliases in the read-only settings view", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + litellm_model_table: { model_aliases: { "my-smart-model": "gpt-4", "my-fast-model": "gpt-3.5-turbo" } }, + }), + ); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + await user.click(screen.getByRole("tab", { name: "Settings" })); + + await waitFor(() => { + expect(screen.getByText("Team Settings")).toBeInTheDocument(); + }); + + expect(screen.getByText("Model Aliases")).toBeInTheDocument(); + expect(screen.getByText("my-smart-model")).toBeInTheDocument(); + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByText("my-fast-model")).toBeInTheDocument(); + expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument(); + }); + + it("should show an empty state when the team has no model aliases", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ litellm_model_table: null })); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + await user.click(screen.getByRole("tab", { name: "Settings" })); + + await waitFor(() => { + expect(screen.getByText("Team Settings")).toBeInTheDocument(); + }); + + expect(screen.getByText("No model aliases configured")).toBeInTheDocument(); + }); + + it("should seed the alias editor from existing team aliases", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + models: ["gpt-4"], + litellm_model_table: { model_aliases: { "my-smart-model": "gpt-4" } }, + }), + ); + + renderWithProviders(); + + await openSettingsEditor(user); + + expect(screen.getByTestId("alias-editor-initial")).toHaveTextContent( + JSON.stringify({ "my-smart-model": "gpt-4" }), + ); + }); + + it("should pass model_aliases to teamUpdateCall when aliases are added", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["gpt-4"] })); + vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any); + + renderWithProviders(); + + await openSettingsEditor(user); + + await user.click(screen.getByRole("button", { name: "Set Alias" })); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(networking.teamUpdateCall).toHaveBeenCalledWith( + "test-token", + expect.objectContaining({ + team_id: "123", + model_aliases: { "gpt-4o": "gpt-4" }, + }), + ); + }); + }); + + it("should send an empty model_aliases map to clear existing aliases", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + models: ["gpt-4"], + litellm_model_table: { model_aliases: { "my-smart-model": "gpt-4" } }, + }), + ); + vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any); + + renderWithProviders(); + + await openSettingsEditor(user); + + await user.click(screen.getByRole("button", { name: "Clear Aliases" })); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(networking.teamUpdateCall).toHaveBeenCalled(); + }); + + const payload = vi.mocked(networking.teamUpdateCall).mock.calls[0][1] as Record; + expect(payload.model_aliases).toEqual({}); + }); + + it("should not include model_aliases when the team has none and the editor is untouched", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ models: ["gpt-4"], litellm_model_table: null }), + ); + vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any); + + renderWithProviders(); + + await openSettingsEditor(user); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(networking.teamUpdateCall).toHaveBeenCalled(); + }); + + const payload = vi.mocked(networking.teamUpdateCall).mock.calls[0][1] as Record; + expect(payload).not.toHaveProperty("model_aliases"); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 3b80c598d34..74f201b40c6 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -34,6 +34,7 @@ import { CheckIcon, CopyIcon } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils"; import AccessGroupSelector from "../common_components/AccessGroupSelector"; +import ModelAliasManager from "../common_components/ModelAliasManager"; import AgentSelector from "../agent_management/AgentSelector"; import DeleteResourceModal from "../common_components/DeleteResourceModal"; import DurationSelect from "../common_components/DurationSelect"; @@ -106,7 +107,7 @@ export interface TeamData { budget_reset_at: string | null; model_id: string | null; litellm_model_table: { - model_aliases: Record; + model_aliases: Record | null; } | null; created_at: string; access_group_ids?: string[]; @@ -206,6 +207,7 @@ const TeamInfoView: React.FC = ({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isTeamSaving, setIsTeamSaving] = useState(false); + const [teamModelAliases, setTeamModelAliases] = useState>({}); const routerSettingsRef = React.useRef(null); const [organization, setOrganization] = useState(null); const { userRole, userId } = useAuthorized(); @@ -619,6 +621,11 @@ const TeamInfoView: React.FC = ({ updateData.default_team_member_models = values.default_team_member_models; } + const previousModelAliases = info.litellm_model_table?.model_aliases ?? {}; + if (Object.keys(teamModelAliases).length > 0 || Object.keys(previousModelAliases).length > 0) { + updateData.model_aliases = teamModelAliases; + } + // Handle router_settings - read fresh values from DOM at save time. const currentRouterSettings = routerSettingsRef.current?.getValue(); if (currentRouterSettings?.router_settings) { @@ -905,7 +912,13 @@ const TeamInfoView: React.FC = ({
Team Settings {canEditTeam && !isEditing && ( - )} @@ -1026,6 +1039,24 @@ const TeamInfoView: React.FC = ({ /> + + Model Aliases{" "} + + + + + } + > + + + @@ -1520,6 +1551,26 @@ const TeamInfoView: React.FC = ({
)} +
+ Model Aliases + {(() => { + const aliasEntries = Object.entries(info.litellm_model_table?.model_aliases ?? {}); + if (aliasEntries.length === 0) { + return
No model aliases configured
; + } + return ( +
+ {aliasEntries.map(([alias, target]) => ( +
+ {alias} + {" -> "} + {target} +
+ ))} +
+ ); + })()} +
Rate Limits
TPM: {info.tpm_limit || "Unlimited"}
diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 207e6f2ccfe..66690e5478f 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -9,6 +9,7 @@ import { DataTableToolbar, } from "@/components/shared/DataTable"; import { Input } from "@/components/ui/input"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { ColumnDef, ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; @@ -17,6 +18,7 @@ import { Popover, Tooltip, Typography } from "antd"; import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; +import { deriveKeyModelScope } from "../key_scope"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import { Organization } from "../networking"; import KeyInfoView from "../templates/key_info_view"; @@ -43,7 +45,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi const [columnFilters, setColumnFilters] = useState([]); const [filtersOpen, setFiltersOpen] = useState(false); const [searchInput, setSearchInput] = useState(""); - const [searchQuery] = useDebouncedValue(searchInput, { wait: 300 }); + const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); const handleSearchChange = useCallback((value: string) => { setSearchInput(value); @@ -338,14 +340,24 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi enableSorting: false, cell: (info) => { const models = info.getValue() as string[]; + const scope = deriveKeyModelScope(info.row.original.allowed_routes, info.row.original.key_type); + const emptyModelsBadge = !scope.hasModelAccess ? ( + + + No model access + + + ) : ( + + All Proxy Models + + ); return (
{Array.isArray(models) ? (
{models.length === 0 ? ( - - All Proxy Models - + emptyModelsBadge ) : ( <>
diff --git a/ui/litellm-dashboard/src/components/ui/combobox.tsx b/ui/litellm-dashboard/src/components/ui/combobox.tsx new file mode 100644 index 00000000000..2854928140e --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/combobox.tsx @@ -0,0 +1,266 @@ +"use client"; + +import * as React from "react"; +import { Combobox as ComboboxPrimitive } from "@base-ui/react"; + +import { cn } from "@/lib/cva.config"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"; + +const Combobox = ComboboxPrimitive.Root; + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return ; +} + +const ComboboxTrigger = React.forwardRef< + React.ComponentRef, + ComboboxPrimitive.Trigger.Props +>(({ className, children, ...props }, ref) => { + return ( + + {children} + + + ); +}); +ComboboxTrigger.displayName = "ComboboxTrigger"; + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ); +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean; + showClear?: boolean; +}) { + return ( + + } {...props} /> + + {showTrigger && ( + } + data-slot="input-group-button" + className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent" + disabled={disabled} + /> + )} + {showClear && } + + {children} + + ); +} + +function ComboboxContent({ + className, + side = "bottom", + sideOffset = 6, + align = "start", + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick) { + return ( + + + + + + ); +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ); +} + +function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) { + return ( + + {children} + } + > + + + + ); +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ; +} + +function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ); +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ; +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( + + ); +} + +function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) { + return ( + + ); +} + +function ComboboxChips({ + className, + ...props +}: React.ComponentPropsWithRef & ComboboxPrimitive.Chips.Props) { + return ( + + ); +} + +function ComboboxChip({ + className, + children, + showRemove = true, + ...props +}: ComboboxPrimitive.Chip.Props & { + showRemove?: boolean; +}) { + return ( + + {children} + {showRemove && ( + } + className="-ml-1 opacity-50 hover:opacity-100" + data-slot="combobox-chip-remove" + > + + + )} + + ); +} + +function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) { + return ( + + ); +} + +function useComboboxAnchor() { + return React.useRef(null); +} + +export { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxGroup, + ComboboxLabel, + ComboboxCollection, + ComboboxEmpty, + ComboboxSeparator, + ComboboxChips, + ComboboxChip, + ComboboxChipsInput, + ComboboxTrigger, + ComboboxValue, + useComboboxAnchor, +}; diff --git a/ui/litellm-dashboard/src/components/ui/dropdown-menu.tsx b/ui/litellm-dashboard/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000000..03fefba7d19 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,254 @@ +"use client"; + +import * as React from "react"; +import { Menu as MenuPrimitive } from "@base-ui/react/menu"; + +import { cn } from "@/lib/cva.config"; +import { ChevronRightIcon, CheckIcon } from "lucide-react"; + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return ; +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return ; +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return ; +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & Pick) { + return ( + + + + + + ); +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return ; +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/ui/litellm-dashboard/src/components/ui/input-group.tsx b/ui/litellm-dashboard/src/components/ui/input-group.tsx new file mode 100644 index 00000000000..8ee9b7f17bd --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/input-group.tsx @@ -0,0 +1,140 @@ +"use client"; + +import * as React from "react"; +import { type VariantProps } from "cva"; + +import { cn, cva } from "@/lib/cva.config"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className, + )} + {...props} + /> + ); +} + +const inputGroupAddonVariants = cva({ + base: "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + variants: { + align: { + "inline-start": "order-first pl-2 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]", + "inline-end": "order-last pr-2 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]", + "block-start": + "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", + "block-end": "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", + }, + }, + defaultVariants: { + align: "inline-start", + }, +}); + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return; + } + e.currentTarget.parentElement?.querySelector("input")?.focus(); + }} + {...props} + /> + ); +} + +const inputGroupButtonVariants = cva({ + base: "flex items-center gap-2 text-sm shadow-none", + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: "", + "icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, +}); + +const InputGroupButton = React.forwardRef< + React.ComponentRef, + Omit, "size" | "type"> & + VariantProps & { + type?: "button" | "submit" | "reset"; + } +>(({ className, type = "button", variant = "ghost", size = "xs", ...props }, ref) => { + return ( +