diff --git a/.github/actions/setup-uv-with-retries/action.yml b/.github/actions/setup-uv-with-retries/action.yml index 1627038dc3d..98ff91f0283 100644 --- a/.github/actions/setup-uv-with-retries/action.yml +++ b/.github/actions/setup-uv-with-retries/action.yml @@ -1,11 +1,7 @@ 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. + Install uv via astral-sh/setup-uv, retrying the full setup step so manifest + resolution and binary downloads get fresh attempts after transient failures. inputs: version: @@ -18,7 +14,7 @@ runs: - name: Set up uv (attempt 1) id: attempt-1 continue-on-error: true - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version: ${{ inputs.version }} @@ -31,7 +27,7 @@ runs: id: attempt-2 if: steps.attempt-1.outcome == 'failure' continue-on-error: true - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version: ${{ inputs.version }} @@ -42,6 +38,6 @@ runs: - name: Set up uv (attempt 3) if: steps.attempt-2.outcome == 'failure' - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version: ${{ inputs.version }} diff --git a/.github/ci-coverage-allowlist.yml b/.github/ci-coverage-allowlist.yml index 32232de381c..0da07038152 100644 --- a/.github/ci-coverage-allowlist.yml +++ b/.github/ci-coverage-allowlist.yml @@ -79,6 +79,11 @@ test_paths: - tests/load_tests/test_otel_load_test.py - tests/load_tests/test_vertex_embeddings_load_test.py - tests/load_tests/test_vertex_load_tests.py + - reason: >- + Env-gated saturation benchmark requires a live proxy and provider credentials, so it is run + locally rather than in pull-request jobs + paths: + - tests/load_tests/test_granian_admission_saturation.py - reason: >- A local-only agent rig: test_a2a_completion_bridge.py needs a LangGraph server on localhost:2024 and test_a2a.py drives a live A2A endpoint, so neither can run in a diff --git a/.github/workflows/test-litellm-ui-build.yml b/.github/workflows/test-litellm-ui-build.yml index b3a07a6e0ff..4eb6b272c43 100644 --- a/.github/workflows/test-litellm-ui-build.yml +++ b/.github/workflows/test-litellm-ui-build.yml @@ -19,9 +19,6 @@ jobs: build-ui: runs-on: ubuntu-latest timeout-minutes: 10 - defaults: - run: - working-directory: ui/litellm-dashboard steps: - name: Checkout repository @@ -35,18 +32,11 @@ jobs: with: category: ui - - name: Setup Node.js + # Built through the image stage rather than the checkout, because the + # stage copies ui/litellm-dashboard/ alone: an import reaching above the + # dashboard root resolves in a checkout and fails in every image we ship. + # Dockerfile, docker/Dockerfile.non_root and ui/Dockerfile share this + # stage verbatim, so building one covers all three. + - name: Build the dashboard as the shipped images build it if: steps.changes.outputs.decision != 'skip' - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 - with: - node-version-file: ui/litellm-dashboard/.nvmrc - cache: "npm" - cache-dependency-path: ui/litellm-dashboard/package-lock.json - - - name: Install dependencies - if: steps.changes.outputs.decision != 'skip' - run: npm ci - - - name: Build - if: steps.changes.outputs.decision != 'skip' - run: npm run build + run: docker build --target ui-builder -f Dockerfile . diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml index 1b71232bc2e..9b8b132df62 100644 --- a/.github/workflows/test-rust.yml +++ b/.github/workflows/test-rust.yml @@ -74,12 +74,19 @@ jobs: - name: Run Clippy with Bedrock auth run: cargo clippy -p litellm-core --all-targets --features bedrock-auth --locked -- -D warnings + - name: Run Clippy with all gateway features + run: cargo clippy -p litellm-ai-gateway --all-targets --all-features --locked -- -D warnings + - name: Run Rust tests run: cargo test --workspace --locked - name: Run core tests with Bedrock auth run: cargo test -p litellm-core --features bedrock-auth --locked + # Not --all-features: python-config links libpython, which this job does not install. + - name: Run gateway tests with the server feature + run: cargo test -p litellm-ai-gateway --features server --locked + release-wheel: name: release wheel runs-on: ubuntu-latest diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 6da5fc07e80..33245ec5b5f 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -96,7 +96,6 @@ jobs: - shard: misc artifact-name: misc test-path: >- - tests/sdk_function_trace tests/test_litellm/batches tests/test_litellm/secret_managers tests/test_litellm/a2a_protocol @@ -151,6 +150,7 @@ jobs: tests/test_litellm/proxy/google_endpoints tests/test_litellm/proxy/openai_files_endpoint tests/test_litellm/proxy/batches_endpoints + tests/test_litellm/proxy/container_endpoints tests/test_litellm/proxy/fine_tuning_endpoints tests/test_litellm/proxy/vector_store_files_endpoints tests/test_litellm/proxy/video_endpoints diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index a37cb194757..cb3756575f7 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -3,7 +3,7 @@ "limit": 14074 }, "reportArgumentType": { - "limit": 2215 + "limit": 2206 }, "reportAssignmentType": { "limit": 319 @@ -42,7 +42,7 @@ "limit": 12 }, "reportIndexIssue": { - "limit": 25 + "limit": 24 }, "reportInvalidTypeForm": { "limit": 34 @@ -57,7 +57,7 @@ "limit": 5601 }, "reportMissingTypeArgument": { - "limit": 15287 + "limit": 15285 }, "reportMissingTypeStubs": { "limit": 40 @@ -99,19 +99,19 @@ "limit": 0 }, "reportUnknownArgumentType": { - "limit": 44362 + "limit": 44360 }, "reportUnknownLambdaType": { "limit": 109 }, "reportUnknownMemberType": { - "limit": 38323 + "limit": 38311 }, "reportUnknownParameterType": { "limit": 19624 }, "reportUnknownVariableType": { - "limit": 29861 + "limit": 29847 }, "reportUnnecessaryCast": { "limit": 111 diff --git a/docker/build_from_pip/Dockerfile.build_from_pip b/docker/build_from_pip/Dockerfile.build_from_pip index e27e8f26cc2..a5733f0e1a0 100644 --- a/docker/build_from_pip/Dockerfile.build_from_pip +++ b/docker/build_from_pip/Dockerfile.build_from_pip @@ -55,7 +55,10 @@ RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/ ENV PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries +COPY docker/prod_entrypoint.sh /app/docker/prod_entrypoint.sh +RUN sed -i 's/\r$//' /app/docker/prod_entrypoint.sh && chmod +x /app/docker/prod_entrypoint.sh + EXPOSE 4000/tcp -ENTRYPOINT ["litellm"] +ENTRYPOINT ["/app/docker/prod_entrypoint.sh"] CMD ["--port", "4000"] diff --git a/docker/component_entrypoint.sh b/docker/component_entrypoint.sh index 1748f1e13a7..413957b9929 100755 --- a/docker/component_entrypoint.sh +++ b/docker/component_entrypoint.sh @@ -1,8 +1,10 @@ #!/bin/sh -if [ "$USE_DDTRACE" = "true" ]; then - export DD_TRACE_OPENAI_ENABLED="False" - exec ddtrace-run "$@" -fi +case "$USE_DDTRACE" in + [Tt][Rr][Uu][Ee]) + export DD_TRACE_OPENAI_ENABLED="False" + exec ddtrace-run "$@" + ;; +esac exec "$@" diff --git a/docker/prod_entrypoint.sh b/docker/prod_entrypoint.sh index bd78bf6687b..630eb6b065b 100644 --- a/docker/prod_entrypoint.sh +++ b/docker/prod_entrypoint.sh @@ -1,8 +1,10 @@ #!/bin/sh -if [ "$USE_DDTRACE" = "true" ]; then - export DD_TRACE_OPENAI_ENABLED="False" - exec ddtrace-run litellm "$@" -else - exec litellm "$@" -fi +case "$USE_DDTRACE" in + [Tt][Rr][Uu][Ee]) + export DD_TRACE_OPENAI_ENABLED="False" + exec ddtrace-run litellm "$@" + ;; +esac + +exec litellm "$@" diff --git a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py index b6f8bf2dc5b..7df14565c3f 100644 --- a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py @@ -48,6 +48,18 @@ def _build_json_field_or_condition(json_key: str, value: str) -> dict[str, objec } +def _build_search_condition(search: str) -> dict[str, object]: + """Match a row whose id, changed_by, object_id, or changed_by_api_key equals the search value.""" + return { + "OR": ( + {"id": search}, + {"changed_by": search}, + {"object_id": search}, + {"changed_by_api_key": search}, + ) + } + + @router.get( "/audit", tags=["Audit Logging"], @@ -83,6 +95,10 @@ async def get_audit_logs( None, description="Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only)", ), + search: str | None = Query( + None, + description="Match a row whose id, object_id, changed_by, or changed_by_api_key equals this value", + ), # Sorting parameters sort_by: str | None = Query( None, @@ -118,6 +134,11 @@ async def get_audit_logs( *([_build_json_field_or_condition("token", object_key_hash)] if object_key_hash else []), ] + and_conditions: Final[tuple[dict[str, object], ...]] = ( + *json_field_conditions, + *((_build_search_condition(search),) if search else ()), + ) + # Build filter conditions where_conditions: Final[dict[str, object]] = { **({"changed_by": changed_by} if changed_by else {}), @@ -126,14 +147,14 @@ async def get_audit_logs( **({"table_name": table_name} if table_name else {}), **({"object_id": object_id} if object_id else {}), **({"updated_at": date_filter} if start_date or end_date else {}), - **({"AND": json_field_conditions} if json_field_conditions else {}), + **({"AND": and_conditions} if and_conditions else {}), } order_by: Final[dict[str, str]] = ( {sort_by: sort_order} if sort_by and isinstance(sort_by, str) else {"updated_at": sort_order} ) - audit_log_table: Final[TableActions["prisma_models.LiteLLM_AuditLog"]] = AuditLogRepository(prisma_client).table + audit_log_table: Final[TableActions[prisma_models.LiteLLM_AuditLog]] = AuditLogRepository(prisma_client).table # Get paginated results audit_logs: Final = await audit_log_table.find_many( @@ -195,7 +216,7 @@ async def get_audit_log_by_id( detail={"message": CommonProxyErrors.db_not_connected_error.value}, ) - audit_log_table: Final[TableActions["prisma_models.LiteLLM_AuditLog"]] = AuditLogRepository(prisma_client).table + audit_log_table: Final[TableActions[prisma_models.LiteLLM_AuditLog]] = AuditLogRepository(prisma_client).table # Get the audit log by ID audit_log: Final = await audit_log_table.find_unique(where={"id": id}) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 5cfcf6129f0..e11c6e70540 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -50,10 +50,12 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( MAX_FILE_LIST_LIMIT, _is_base64_encoded_unified_file_id, apply_unified_file_ids, + decode_model_from_file_id, ensure_batch_response_managed_file_ids, get_batch_id_from_unified_batch_id, get_content_type_from_file_object, get_model_id_from_unified_batch_id, + get_original_file_id, map_raw_file_ids_to_unified, normalize_mime_type_for_provider, resolve_managed_output_file_model_name, @@ -427,6 +429,103 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): detail=f"Object not found: {unified_object_id}", ) + async def enforce_batch_object_access( + self, object_id: str, user_api_key_dict: UserAPIKeyAuth + ) -> None: + """Deny access to a provider-format batch id owned by another caller. + + Ids with no ownership row (batches created before ownership tracking, + or directly on the provider account) stay accessible so pass-through + reads keep working. + """ + if self.prisma_client is None: + return + managed_object = ( + await self.prisma_client.db.litellm_managedobjecttable.find_first( + where={"OR": [{"unified_object_id": object_id}, {"model_object_id": object_id}]} + ) + ) + if managed_object is None: + return + if not can_access_resource( + user_api_key_dict=user_api_key_dict, + created_by=managed_object.created_by, + resource_team_id=managed_object.team_id, + ): + raise HTTPException( + status_code=403, + detail=f"User {user_api_key_dict.user_id} does not have access to the object {object_id}", + ) + + async def enforce_provider_file_access( + self, file_id: str, user_api_key_dict: UserAPIKeyAuth + ) -> None: + """Deny access to a provider-format file id owned by another caller. + + Ownership rows for provider-format ids are written when a managed + batch's output/error files are first synced; ids with no row stay + accessible so pass-through reads keep working. + """ + if self.prisma_client is None: + return + managed_file = ( + await self.prisma_client.db.litellm_managedfiletable.find_first( + where={"OR": [{"unified_file_id": file_id}, {"flat_model_file_ids": {"has": file_id}}]} + ) + ) + if managed_file is None: + return + if not can_access_resource( + user_api_key_dict=user_api_key_dict, + created_by=managed_file.created_by, + resource_team_id=managed_file.team_id, + ): + raise HTTPException( + status_code=403, + detail=f"User {user_api_key_dict.user_id} does not have access to the file {file_id}", + ) + + async def store_batch_output_file_ownership( + self, response: LiteLLMBatch, litellm_parent_otel_span: Optional[Span] + ) -> None: + """Record ownership rows for a batch's provider-format output/error + file ids, inherited from the owning batch row (never the caller), so + file reads can be isolation-checked.""" + provider_file_ids = tuple( + file_id + for file_id in ( + getattr(response, "output_file_id", None), + getattr(response, "error_file_id", None), + ) + if file_id and not _is_base64_encoded_unified_file_id(file_id) + ) + if not provider_file_ids: + return + if self.prisma_client is None: + return + batch_row = ( + await self.prisma_client.db.litellm_managedobjecttable.find_first( + where={"unified_object_id": response.id} + ) + ) + if batch_row is None or ( + batch_row.created_by is None and batch_row.team_id is None + ): + return + owner_identity = UserAPIKeyAuth( + user_id=batch_row.created_by, team_id=batch_row.team_id + ) + for file_id in provider_file_ids: + model_name = decode_model_from_file_id(file_id) + raw_file_id = get_original_file_id(file_id) + await self.store_unified_file_id( + file_id=file_id, + file_object=None, + litellm_parent_otel_span=litellm_parent_otel_span, + model_mappings={model_name: raw_file_id} if model_name else {}, + user_api_key_dict=owner_identity, + ) + async def list_user_batches( self, user_api_key_dict: UserAPIKeyAuth, @@ -613,6 +712,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): status_code=403, detail=f"User {user_api_key_dict.user_id} does not have access to the file {retrieve_file_id}", ) + if retrieve_file_id: + await self.enforce_provider_file_access( + retrieve_file_id, user_api_key_dict + ) return False async def check_file_ids_access(self, file_ids: List[str], user_api_key_dict: UserAPIKeyAuth) -> None: @@ -765,6 +868,8 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): ) data["model"] = potential_model_id data[accessor_key] = get_batch_id_from_unified_batch_id(potential_llm_object_id) + elif retrieve_object_id and accessor_key == "batch_id": + await self.enforce_batch_object_access(retrieve_object_id, user_api_key_dict) elif call_type == CallTypes.acreate_fine_tuning_job.value: input_file_id = cast(Optional[str], data.get("training_file")) if input_file_id: @@ -1297,7 +1402,13 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): user_api_key_dict=user_api_key_dict, request_tags=request_tags_from_metadata(request_metadata if isinstance(request_metadata, dict) else {}), persist_attribution=is_batch_create, + create_if_missing=is_batch_create, ) + if not is_batch_create: + await self.store_batch_output_file_ownership( + response=response, + litellm_parent_otel_span=user_api_key_dict.parent_otel_span, + ) # Only record batch creation metric on actual create (not retrieve/cancel). # unified_file_id in _hidden_params is only set by the create_batch endpoint. diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl index 72f7f74bcf6..be6b9093f53 100644 --- a/helm/litellm/templates/_helpers.tpl +++ b/helm/litellm/templates/_helpers.tpl @@ -428,3 +428,16 @@ envFrom: {{- end }} {{- end }} {{- end -}} + +{{/* +ingress-nginx's admission webhook rejects a dot in an Exact or Prefix path +(strict-validate-path-type) and serves ImplementationSpecific as a plain +prefix location, so a dotted path takes that type there. +*/}} +{{- define "litellm.ingress.pathType" -}} +{{- if and (eq .controller "nginx") (contains "." .path) -}} +ImplementationSpecific +{{- else -}} +{{- .pathType -}} +{{- end -}} +{{- end -}} diff --git a/helm/litellm/templates/ingress.yaml b/helm/litellm/templates/ingress.yaml index f77ef537b02..732564b280f 100644 --- a/helm/litellm/templates/ingress.yaml +++ b/helm/litellm/templates/ingress.yaml @@ -5,6 +5,10 @@ {{- $gatewayPort := .Values.gateway.service.port -}} {{- $backendPort := .Values.backend.service.port -}} {{- $uiPort := .Values.ui.service.port -}} +{{- $controller := .Values.ingress.controller | default "alb" -}} +{{- if not (has $controller (list "alb" "nginx")) }} +{{- fail (printf "ingress.controller: unknown controller %q, expected one of alb, nginx" $controller) }} +{{- end }} {{/* Backends addressable from ingress.extraPaths, keyed by the `service` field. */}} @@ -27,10 +31,11 @@ /litellm-asset-prefix, so without /*.txt they fall to the backend catch-all → 404 → client-side navigation never settles and the login flow spins in an infinite redirect loop (/ ⇄ /ui/login). ui/nginx.conf already serves *.txt - from the export; the rule only routes the request to it. Needs an ingress - controller whose ImplementationSpecific path is a wildcard pattern - (AWS ALB: `*` = 0+ chars); this chart targets the AWS Load Balancer - Controller. + from the export; the rule only routes the request to it. It needs an + ingress controller whose ImplementationSpecific path is a wildcard pattern + (AWS ALB: `*` = 0+ chars), so it is rendered for ingress.controller=alb + only: ingress-nginx serves ImplementationSpecific as a literal prefix + location, where /*.txt can never match. */}} {{- $uiPaths := list (dict "path" "/" "pathType" "Exact") @@ -38,8 +43,10 @@ (dict "path" "/litellm-asset-prefix" "pathType" "Prefix") (dict "path" "/_next" "pathType" "Prefix") (dict "path" "/ui" "pathType" "Prefix") - (dict "path" "/*.txt" "pathType" "ImplementationSpecific") -}} +{{- if eq $controller "alb" }} +{{- $uiPaths = append $uiPaths (dict "path" "/*.txt" "pathType" "ImplementationSpecific") }} +{{- end }} {{/* Gateway data-plane prefixes — must mirror gateway/routes/allowlist.py. Versioned paths are listed explicitly to avoid routing management routes @@ -83,12 +90,6 @@ adding to it. */}} {{- $builtinPathKeys := list "/test|Exact" "/|Prefix" -}} -{{- range $uiPaths }} -{{- $builtinPathKeys = append $builtinPathKeys (printf "%s|%s" .path .pathType) }} -{{- end }} -{{- range $gatewayPrefixes }} -{{- $builtinPathKeys = append $builtinPathKeys (printf "%s|Prefix" .) }} -{{- end }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: @@ -115,8 +116,10 @@ spec: paths: # --- UI (Next.js static export) --- {{- range $uiPaths }} + {{- $pathType := include "litellm.ingress.pathType" (dict "controller" $controller "path" .path "pathType" .pathType) }} + {{- $builtinPathKeys = append $builtinPathKeys (printf "%s|%s" .path $pathType) }} - path: {{ .path }} - pathType: {{ .pathType }} + pathType: {{ $pathType }} backend: service: name: {{ $uiName }} @@ -134,8 +137,10 @@ spec: port: number: {{ $gatewayPort }} {{- range $gatewayPrefixes }} + {{- $pathType := include "litellm.ingress.pathType" (dict "controller" $controller "path" . "pathType" "Prefix") }} + {{- $builtinPathKeys = append $builtinPathKeys (printf "%s|%s" . $pathType) }} - path: {{ . }} - pathType: Prefix + pathType: {{ $pathType }} backend: service: name: {{ $gatewayName }} @@ -147,10 +152,11 @@ spec: Rendered after every built-in path so an entry can never take precedence over a default, and before the backend catch-all. Position only decides the match on controllers that honour manifest - order: the AWS Load Balancer Controller this chart targets sorts - Exact paths first and Prefix paths longest-first, but keeps + order: the AWS Load Balancer Controller (ingress.controller=alb) + sorts Exact paths first and Prefix paths longest-first, but keeps ImplementationSpecific paths in manifest order, which is what the - /*.txt rule above already depends on. + /*.txt rule above already depends on. ingress-nginx ignores order + and serves the longest matching location. */}} {{- range $idx, $extra := .Values.ingress.extraPaths }} {{- if not (kindIs "map" $extra) }} @@ -164,10 +170,11 @@ spec: {{- if not $target }} {{- fail (printf "ingress.extraPaths[%d] (path %s): unknown service %q, expected one of backend, gateway, ui" $idx $extra.path $service) }} {{- end }} - {{- $pathType := $extra.pathType | default "Prefix" }} - {{- if not (has $pathType (list "Prefix" "Exact" "ImplementationSpecific")) }} - {{- fail (printf "ingress.extraPaths[%d] (path %s): unknown pathType %q, expected one of Exact, ImplementationSpecific, Prefix" $idx $extra.path $pathType) }} + {{- $requestedPathType := $extra.pathType | default "Prefix" }} + {{- if not (has $requestedPathType (list "Prefix" "Exact" "ImplementationSpecific")) }} + {{- fail (printf "ingress.extraPaths[%d] (path %s): unknown pathType %q, expected one of Exact, ImplementationSpecific, Prefix" $idx $extra.path $requestedPathType) }} {{- end }} + {{- $pathType := include "litellm.ingress.pathType" (dict "controller" $controller "path" $extra.path "pathType" $requestedPathType) }} {{- if eq $extra.path "/" }} {{- fail (printf "ingress.extraPaths[%d]: path / is already routed in both directions, Exact to ui and Prefix to backend, so no pathType leaves a request for an entry here to capture" $idx) }} {{- end }} diff --git a/helm/litellm/tests/ingress_controller_tests.yaml b/helm/litellm/tests/ingress_controller_tests.yaml new file mode 100644 index 00000000000..40790ba674a --- /dev/null +++ b/helm/litellm/tests/ingress_controller_tests.yaml @@ -0,0 +1,205 @@ +suite: test ingress.controller +templates: + - ingress.yaml +values: + - ./values/required.yaml +tests: + - it: keeps the AWS Load Balancer Controller path types by default + set: + ingress.enabled: true + asserts: + - contains: + path: spec.rules[0].http.paths + content: + path: /favicon.ico + pathType: Exact + backend: + service: + name: RELEASE-NAME-litellm-ui + port: + number: 3000 + - contains: + path: spec.rules[0].http.paths + content: + path: /eu.assemblyai + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + - contains: + path: spec.rules[0].http.paths + content: + path: /*.txt + pathType: ImplementationSpecific + backend: + service: + name: RELEASE-NAME-litellm-ui + port: + number: 3000 + + - it: renders no dotted Exact or Prefix path for ingress-nginx, whose admission webhook rejects them + set: + ingress.enabled: true + ingress.controller: nginx + asserts: + - notMatchRegexRaw: + pattern: 'path: /\S*\.\S*\n\s+pathType: (Exact|Prefix)\n' + - contains: + path: spec.rules[0].http.paths + content: + path: /favicon.ico + pathType: ImplementationSpecific + backend: + service: + name: RELEASE-NAME-litellm-ui + port: + number: 3000 + - contains: + path: spec.rules[0].http.paths + content: + path: /eu.assemblyai + pathType: ImplementationSpecific + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + + - it: drops the /*.txt wildcard for ingress-nginx and keeps every other route as is + set: + ingress.enabled: true + ingress.controller: nginx + asserts: + - notContains: + path: spec.rules[0].http.paths + content: + path: /*.txt + any: true + - contains: + path: spec.rules[0].http.paths + content: + path: /ui + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-ui + port: + number: 3000 + - contains: + path: spec.rules[0].http.paths + content: + path: /test + pathType: Exact + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + - equal: + path: spec.rules[0].http.paths[-1] + value: + path: / + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-backend + port: + number: 4001 + + - it: rejects an extraPaths entry that repeats a built-in path at the pathType ingress-nginx renders it with + set: + ingress.enabled: true + ingress.controller: nginx + ingress.extraPaths: + - path: /favicon.ico + service: ui + pathType: ImplementationSpecific + asserts: + - failedTemplate: + errorMessage: "ingress.extraPaths[0]: path /favicon.ico with pathType ImplementationSpecific is already routed by this chart, and a duplicate would take it over rather than add to it" + + - it: rejects a controller it has no path types for + set: + ingress.enabled: true + ingress.controller: traefik + asserts: + - failedTemplate: + errorMessage: 'ingress.controller: unknown controller "traefik", expected one of alb, nginx' + + - it: rejects an extraPaths entry that repeats a built-in path once ingress-nginx normalizes its pathType + set: + ingress.enabled: true + ingress.controller: nginx + ingress.extraPaths: + - path: /favicon.ico + service: ui + asserts: + - failedTemplate: + errorMessage: "ingress.extraPaths[0]: path /favicon.ico with pathType ImplementationSpecific is already routed by this chart, and a duplicate would take it over rather than add to it" + + - it: renders a dotted extraPaths entry as ImplementationSpecific for ingress-nginx + set: + ingress.enabled: true + ingress.controller: nginx + ingress.extraPaths: + - path: /eu.assemblyai.custom + service: gateway + - path: /robots.txt + service: ui + pathType: Exact + asserts: + - notMatchRegexRaw: + pattern: 'path: "?/\S*\.\S*"?\n\s+pathType: (Exact|Prefix)\n' + - contains: + path: spec.rules[0].http.paths + content: + path: /eu.assemblyai.custom + pathType: ImplementationSpecific + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + - contains: + path: spec.rules[0].http.paths + content: + path: /robots.txt + pathType: ImplementationSpecific + backend: + service: + name: RELEASE-NAME-litellm-ui + port: + number: 3000 + + - it: keeps the requested pathType of a dotted extraPaths entry for the AWS Load Balancer Controller + set: + ingress.enabled: true + ingress.extraPaths: + - path: /eu.assemblyai.custom + service: gateway + - path: /robots.txt + service: ui + pathType: Exact + asserts: + - contains: + path: spec.rules[0].http.paths + content: + path: /eu.assemblyai.custom + pathType: Prefix + backend: + service: + name: RELEASE-NAME-litellm-gateway + port: + number: 4000 + - contains: + path: spec.rules[0].http.paths + content: + path: /robots.txt + pathType: Exact + backend: + service: + name: RELEASE-NAME-litellm-ui + port: + number: 3000 diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml index 378c3b7a618..461330ba491 100644 --- a/helm/litellm/values.yaml +++ b/helm/litellm/values.yaml @@ -10,6 +10,18 @@ imagePullSecrets: [] ingress: enabled: false className: "" + # Which ingress controller serves this Ingress. Controllers disagree on the + # pathTypes they accept, so this picks the pathType of the dotted paths, the + # built-in ones and any dotted extraPaths entry alike: + # alb AWS Load Balancer Controller (default): Exact and Prefix paths plus + # the /*.txt wildcard that routes the UI's RSC payloads. + # nginx ingress-nginx: its admission webhook rejects a dot in an Exact or + # Prefix path (strict-validate-path-type, on by default from v1.12.0 + # until v1.12.6 / v1.13.2 allowed dots again), so /favicon.ico and + # /eu.assemblyai render as ImplementationSpecific, which nginx serves + # as a plain prefix location. /*.txt is dropped: nginx has no + # wildcard pathType, so that rule could never match there. + controller: alb annotations: {} host: "" # optional; if set, becomes the rule's host tls: [] @@ -26,7 +38,8 @@ ingress: # # path required; the HTTP path to route # service which component serves it: gateway (default), backend, or ui - # pathType Prefix (default), Exact, or ImplementationSpecific + # pathType Prefix (default), Exact, or ImplementationSpecific; a dotted + # path renders as ImplementationSpecific when controller is nginx # # The target component only answers paths its own route allowlist keeps, so # a path here still has to be one that component serves. diff --git a/litellm-proxy-extras/litellm_proxy_extras/prisma_toolchain.py b/litellm-proxy-extras/litellm_proxy_extras/prisma_toolchain.py index 2283814ab35..b51de9609d3 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/prisma_toolchain.py +++ b/litellm-proxy-extras/litellm_proxy_extras/prisma_toolchain.py @@ -18,10 +18,15 @@ recoverable one. constant: it grows with the number of pending migrations, so a fresh database that has to replay every migration this package ships overruns a per-command budget sized for the short bookkeeping commands, on a laptop as much as on a -slow CI runner. The Python ``prisma`` wrapper spawns Node and the schema engine -as separate children, so killing the wrapper on timeout leaves them running: -the retry then contends with that orphan for Prisma's advisory lock and cannot -finish any sooner. Migrate deploy therefore runs under its own budget. +slow CI runner. Migrate deploy therefore runs under its own budget. + +The Python ``prisma`` wrapper spawns Node, which spawns the Rust schema +engine, so killing only the wrapper on timeout leaves the engine running with +no parent: it keeps mutating the database after the proxy has given up, holds +Prisma's advisory lock so every retry and every later boot queues behind it, +and dies mid-migration once its pipes close, leaving a half-applied ledger row. +Every Prisma command therefore runs in a process group of its own, and a +timeout kills the whole group. All three budgets are overridable so an operator can widen them without a release: ``LITELLM_PRISMA_BOOTSTRAP_TIMEOUT`` for the toolchain install, @@ -35,10 +40,12 @@ the deploy override says otherwise. import math import os import shutil +import signal import subprocess +from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import IO, Optional, Union from litellm_proxy_extras._logging import logger @@ -167,6 +174,49 @@ def heal_incomplete_nodeenv_cache() -> bool: return True +def _kill_process_group(process: "subprocess.Popen[str]") -> None: + if os.name == "nt": + process.kill() + return + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + return + + +def run_prisma( + argv: Sequence[str], + *, + timeout: float, + env: Mapping[str, str], + stdout: Union[IO[str], int, None] = subprocess.PIPE, + stderr: Optional[int] = subprocess.PIPE, +) -> "subprocess.CompletedProcess[str]": + """Run one Prisma CLI command in its own process group, bounded by ``timeout``. + + Raises ``subprocess.TimeoutExpired`` once the budget is spent, after killing + the command together with every process it spawned, and + ``subprocess.CalledProcessError`` on a non-zero exit. Output is captured as + text unless ``stdout``/``stderr`` say otherwise. + """ + with subprocess.Popen( + argv, + env=env, + stdout=stdout, + stderr=stderr, + text=True, + start_new_session=True, + ) as process: + try: + out, err = process.communicate(timeout=timeout) + except BaseException: + _kill_process_group(process) + raise + if process.returncode: + raise subprocess.CalledProcessError(process.returncode, process.args, out, err) + return subprocess.CompletedProcess(process.args, process.returncode, out, err) + + def ensure_prisma_toolchain( prisma_command: str, prisma_env: dict[str, str] ) -> ToolchainBootstrap: @@ -179,14 +229,7 @@ def ensure_prisma_toolchain( timeout = prisma_bootstrap_timeout() logger.info("Preparing the Prisma CLI toolchain (timeout %ss)", timeout) try: - subprocess.run( - [prisma_command, BOOTSTRAP_ARG], - timeout=timeout, - check=True, - capture_output=True, - text=True, - env=prisma_env, - ) + run_prisma([prisma_command, BOOTSTRAP_ARG], timeout=timeout, env=prisma_env) except subprocess.TimeoutExpired: logger.warning( "Preparing the Prisma CLI toolchain timed out after %ss. Raise %s " diff --git a/litellm-proxy-extras/litellm_proxy_extras/replica_identity.py b/litellm-proxy-extras/litellm_proxy_extras/replica_identity.py index 157d595404e..3a5865a54cd 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/replica_identity.py +++ b/litellm-proxy-extras/litellm_proxy_extras/replica_identity.py @@ -16,7 +16,7 @@ import tempfile from pathlib import Path from litellm_proxy_extras._logging import logger -from litellm_proxy_extras.prisma_toolchain import prisma_command_timeout +from litellm_proxy_extras.prisma_toolchain import prisma_command_timeout, run_prisma REPLICA_IDENTITY_FULL_ENV_VAR = "LITELLM_SET_REPLICA_IDENTITY_FULL" @@ -66,7 +66,7 @@ def apply_replica_identity_full( with tempfile.TemporaryDirectory(prefix="litellm_replica_identity_") as tmp_dir: sql_path = Path(tmp_dir) / "replica_identity_full.sql" sql_path.write_text(REPLICA_IDENTITY_FULL_SQL) - subprocess.run( + run_prisma( [ prisma_command, "db", @@ -77,9 +77,6 @@ def apply_replica_identity_full( schema_path, ], timeout=prisma_command_timeout(), - check=True, - capture_output=True, - text=True, env=prisma_env, ) except subprocess.CalledProcessError as e: diff --git a/litellm-proxy-extras/litellm_proxy_extras/utils.py b/litellm-proxy-extras/litellm_proxy_extras/utils.py index a4ea4789b49..71e7e9c683b 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/utils.py +++ b/litellm-proxy-extras/litellm_proxy_extras/utils.py @@ -10,6 +10,7 @@ from dataclasses import dataclass, replace from pathlib import Path from typing import Optional +from litellm_proxy_extras import prisma_toolchain from litellm_proxy_extras._logging import logger from litellm_proxy_extras.replica_identity import ( REPLICA_IDENTITY_FULL_ENV_VAR, @@ -231,7 +232,7 @@ class ProxyExtrasDBManager: # 1. Generate migration SQL file by comparing empty state to current db state logger.info("Generating baseline migration...") migration_file = init_dir / "migration.sql" - subprocess.run( + prisma_toolchain.run_prisma( [ _get_prisma_command(), "migrate", @@ -242,14 +243,13 @@ class ProxyExtrasDBManager: "--script", ], stdout=open(migration_file, "w"), - check=True, timeout=prisma_command_timeout(), env=prisma_env, ) # 3. Mark the migration as applied since it represents current state logger.info("Marking baseline migration as applied...") - subprocess.run( + prisma_toolchain.run_prisma( [ _get_prisma_command(), "migrate", @@ -257,7 +257,6 @@ class ProxyExtrasDBManager: "--applied", "0_init", ], - check=True, timeout=prisma_command_timeout(), env=prisma_env, ) @@ -286,7 +285,7 @@ class ProxyExtrasDBManager: """Mark a specific migration as rolled back""" # Set up environment for offline mode if configured prisma_env = _get_prisma_env() - subprocess.run( + prisma_toolchain.run_prisma( [ _get_prisma_command(), "migrate", @@ -295,8 +294,6 @@ class ProxyExtrasDBManager: migration_name, ], timeout=prisma_command_timeout(), - check=True, - capture_output=True, env=prisma_env, ) @@ -348,11 +345,9 @@ class ProxyExtrasDBManager: def _resolve_specific_migration(migration_name: str): """Mark a specific migration as applied""" prisma_env = _get_prisma_env() - subprocess.run( + prisma_toolchain.run_prisma( [_get_prisma_command(), "migrate", "resolve", "--applied", migration_name], timeout=prisma_command_timeout(), - check=True, - capture_output=True, env=prisma_env, ) @@ -436,7 +431,7 @@ class ProxyExtrasDBManager: try: logger.info("Generating migration diff between DB and schema.prisma...") with open(diff_sql_path, "w") as f: - subprocess.run( + prisma_toolchain.run_prisma( [ _get_prisma_command(), "migrate", @@ -447,7 +442,6 @@ class ProxyExtrasDBManager: schema_path, "--script", ], - check=True, timeout=prisma_command_timeout(), stdout=f, env=_get_prisma_env(), @@ -470,7 +464,7 @@ class ProxyExtrasDBManager: migration_files = sorted(Path(migrations_dir).glob("*/migration.sql")) for mig_file in migration_files: try: - subprocess.run( + prisma_toolchain.run_prisma( [ _get_prisma_command(), "db", @@ -481,9 +475,6 @@ class ProxyExtrasDBManager: schema_path, ], timeout=prisma_command_timeout(), - check=True, - capture_output=True, - text=True, env=_get_prisma_env(), ) logger.info(f"Applied migration: {mig_file.parent.name}") @@ -516,7 +507,7 @@ class ProxyExtrasDBManager: applied_ok = False try: logger.info("Running prisma db execute to apply the migration diff...") - result = subprocess.run( + result = prisma_toolchain.run_prisma( [ _get_prisma_command(), "db", @@ -527,9 +518,6 @@ class ProxyExtrasDBManager: schema_path, ], timeout=prisma_command_timeout(), - check=True, - capture_output=True, - text=True, env=_get_prisma_env(), ) logger.info(f"prisma db execute stdout: {result.stdout}") @@ -558,7 +546,7 @@ class ProxyExtrasDBManager: for migration_name in migration_names: try: logger.info(f"Resolving migration: {migration_name}") - subprocess.run( + prisma_toolchain.run_prisma( [ _get_prisma_command(), "migrate", @@ -567,9 +555,6 @@ class ProxyExtrasDBManager: migration_name, ], timeout=prisma_command_timeout(), - check=True, - capture_output=True, - text=True, env=_get_prisma_env(), ) logger.debug(f"Resolved migration: {migration_name}") @@ -762,11 +747,12 @@ class ProxyExtrasDBManager: original_dir = os.getcwd() os.chdir(migrations_dir) try: - subprocess.run( + prisma_toolchain.run_prisma( [_get_prisma_command(), "db", "push", "--accept-data-loss"], timeout=prisma_command_timeout(), - check=True, env=_get_prisma_env(), + stdout=None, + stderr=None, ) return True except ( @@ -789,12 +775,9 @@ class ProxyExtrasDBManager: try: while not budget.exhausted: try: - result = subprocess.run( + result = prisma_toolchain.run_prisma( [_get_prisma_command(), "migrate", "deploy"], timeout=deploy_timeout, - check=True, - capture_output=True, - text=True, env=_get_prisma_env(), ) logger.info(f"prisma migrate deploy stdout: {result.stdout}") @@ -1031,12 +1014,9 @@ class ProxyExtrasDBManager: logger.info("Running prisma migrate deploy") try: # Set migrations directory for Prisma - result = subprocess.run( + result = prisma_toolchain.run_prisma( [_get_prisma_command(), "migrate", "deploy"], timeout=prisma_migrate_deploy_timeout(), - check=True, - capture_output=True, - text=True, env=_get_prisma_env(), ) logger.info(f"prisma migrate deploy stdout: {result.stdout}") @@ -1108,7 +1088,7 @@ class ProxyExtrasDBManager: f"Found failed migration: {failed_migration}, marking as rolled back" ) # Mark the failed migration as rolled back - subprocess.run( + prisma_toolchain.run_prisma( [ _get_prisma_command(), "migrate", @@ -1117,9 +1097,6 @@ class ProxyExtrasDBManager: failed_migration, ], timeout=prisma_command_timeout(), - check=True, - capture_output=True, - text=True, env=_get_prisma_env(), ) logger.info( @@ -1244,10 +1221,12 @@ class ProxyExtrasDBManager: if ProxyExtrasDBManager.spend_logs_is_partitioned(): raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR) # Use prisma db push with increased timeout - subprocess.run( + prisma_toolchain.run_prisma( [_get_prisma_command(), "db", "push", "--accept-data-loss"], timeout=prisma_command_timeout(), - check=True, + stdout=None, + stderr=None, + env=_get_prisma_env(), ) return True except subprocess.TimeoutExpired: diff --git a/litellm-proxy-extras/tests/test_setup_database_fail_fast.py b/litellm-proxy-extras/tests/test_setup_database_fail_fast.py index 406f07eb792..040d67d25e4 100644 --- a/litellm-proxy-extras/tests/test_setup_database_fail_fast.py +++ b/litellm-proxy-extras/tests/test_setup_database_fail_fast.py @@ -42,7 +42,7 @@ def test_v2_p3018_permission_error_raises_runtime_error(monkeypatch, tmp_path): "Error: P3018\nMigration name: 20250326162113_baseline\n" "Database error code: 42501\npermission denied for schema public" ) - with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)): with pytest.raises(RuntimeError, match="permission"): ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) @@ -60,7 +60,7 @@ def test_v2_non_idempotent_p3009_raises_runtime_error(monkeypatch, tmp_path): "Error: P3009\nMigration `20260101000000_genuinely_broken` failed\n" 'Reason: syntax error at or near "BRKN" LINE 42' ) - with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)): with pytest.raises(RuntimeError, match="cannot be auto-recovered"): ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) @@ -124,7 +124,7 @@ def test_v1_default_still_calls_resolve_all_migrations(monkeypatch, tmp_path): def fake_resolve(*args, **kwargs): resolve_called["n"] += 1 - monkeypatch.setattr("subprocess.run", fake_run) + monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", fake_run) monkeypatch.setattr(ProxyExtrasDBManager, "_resolve_all_migrations", fake_resolve) ok = ProxyExtrasDBManager.setup_database(use_migrate=True) # v2 flag NOT set @@ -139,7 +139,7 @@ def test_v2_db_push_wraps_subprocess_error_as_runtime_error(monkeypatch, tmp_pat (tmp_path / "schema.prisma").write_text("// stub") stderr = "db push error" - with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)): with pytest.raises(RuntimeError, match="prisma db push failed"): ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True) @@ -209,7 +209,7 @@ def test_v2_resolve_specific_migration_failure_raises_runtime_error( "Error: P3009\nMigration `20260101000000_some_migration` failed\n" "relation already exists" ) - with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)): with pytest.raises( RuntimeError, match="Failed to mark migration .* as applied" ): @@ -228,7 +228,7 @@ def test_v2_does_not_call_resolve_all_migrations(monkeypatch, tmp_path): stdout = "Applied migration.\n" stderr = "" - monkeypatch.setattr("subprocess.run", lambda *a, **kw: FakeResult()) + monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", lambda *a, **kw: FakeResult()) resolve_called = {"n": 0} monkeypatch.setattr( @@ -296,7 +296,7 @@ def test_v2_p3018_deadlock_rolls_back_and_retries(monkeypatch, tmp_path): "_resolve_specific_migration", lambda name: pytest.fail("a deadlocked migration must never be marked applied"), ) - monkeypatch.setattr("subprocess.run", _succeed_after(1, _DEADLOCK_P3018_STDERR)) + monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, _DEADLOCK_P3018_STDERR)) ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) assert ok is True @@ -309,7 +309,7 @@ def test_v2_p3018_persistent_deadlock_exhausts_attempts(monkeypatch, tmp_path): monkeypatch.setattr(ProxyExtrasDBManager, "_roll_back_migration", lambda name: None) with patch( - "subprocess.run", + "litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, _DEADLOCK_P3018_STDERR), ): with pytest.raises(RuntimeError, match="after 4 attempts"): @@ -343,7 +343,7 @@ def test_v2_p3009_deadlocked_ledger_row_rolls_back_and_retries(monkeypatch, tmp_ "_resolve_specific_migration", lambda name: pytest.fail("a deadlocked migration must never be marked applied"), ) - monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr)) + monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, stderr)) ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) assert ok is True @@ -372,7 +372,7 @@ def test_v2_p3009_empty_ledger_logs_rolls_back_and_retries(monkeypatch, tmp_path "_resolve_specific_migration", lambda name: pytest.fail("a deadlocked migration must never be marked applied"), ) - monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr)) + monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, stderr)) ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) assert ok is True @@ -395,7 +395,7 @@ def test_v2_p3009_unreadable_ledger_still_raises(monkeypatch, tmp_path): "_roll_back_migration", lambda name: pytest.fail("an unreadable ledger must not trigger a retry"), ) - monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr)) + monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, stderr)) with pytest.raises(RuntimeError, match="cannot be auto-recovered"): ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) @@ -417,7 +417,7 @@ def test_v2_p3009_non_deadlock_ledger_row_still_raises(monkeypatch, tmp_path): lambda name: 'ERROR: syntax error at or near "BRKN"', ) - with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)): + with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)): with pytest.raises(RuntimeError, match="cannot be auto-recovered"): ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) @@ -427,7 +427,7 @@ def test_v2_bare_deadlock_stderr_retries(monkeypatch, tmp_path): waiter as victim) is retried, not fatal.""" _stub_v2_env(monkeypatch, tmp_path) monkeypatch.setattr( - "subprocess.run", _succeed_after(1, "Database error: deadlock detected") + "litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, "Database error: deadlock detected") ) ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) @@ -446,7 +446,10 @@ def test_v2_advisory_lock_timeout_retries(monkeypatch, tmp_path): """v2: the advisory-lock waiter that times out while a peer's retry holds the lock retries instead of dying.""" _stub_v2_env(monkeypatch, tmp_path) - monkeypatch.setattr("subprocess.run", _succeed_after(2, _P1002_ADVISORY_LOCK_STDERR)) + monkeypatch.setattr( + "litellm_proxy_extras.prisma_toolchain.run_prisma", + _succeed_after(2, _P1002_ADVISORY_LOCK_STDERR), + ) ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) assert ok is True @@ -456,7 +459,7 @@ def test_v2_p1002_without_advisory_lock_context_still_raises(monkeypatch, tmp_pa """v2: a plain P1002 (database unreachable) stays fatal.""" _stub_v2_env(monkeypatch, tmp_path) stderr = "Error: P1002\n\nThe database server at `db`:`5432` was reached but timed out." - monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr)) + monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, stderr)) with pytest.raises(RuntimeError, match="cannot be auto-recovered"): ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) diff --git a/litellm-rust/ADDING_A_PROVIDER.md b/litellm-rust/ADDING_A_PROVIDER.md index 857a744e014..ae8ae5a6870 100644 --- a/litellm-rust/ADDING_A_PROVIDER.md +++ b/litellm-rust/ADDING_A_PROVIDER.md @@ -26,4 +26,4 @@ variants of it. The test for a good abstraction is that adding the next provider is a few declarative lines, not a new file of duplicated flow. Only diverge from the base when behavior is genuinely different, and say so explicitly in the PR. -**Calling:** hosts invoke the core entrypoint — the Python bridge and the `ai-gateway` route service both call `litellm_core::messages::messages`. Never add a provider handler to `ai-gateway`. Register new modules in `lib.rs` / `mod.rs`, then run `cargo fmt && cargo clippy --workspace -- -D warnings && cargo test --workspace`. +**Calling:** hosts invoke the core entrypoint — the Python bridge and the `ai-gateway` route service both call `litellm_core::messages::messages`. Never add a provider handler to `ai-gateway`. Register new modules in `lib.rs` / `mod.rs`, then run the commands under "Checks" in [CLAUDE.md](CLAUDE.md). diff --git a/litellm-rust/CLAUDE.md b/litellm-rust/CLAUDE.md index 3dcf1853efc..d9c944529df 100644 --- a/litellm-rust/CLAUDE.md +++ b/litellm-rust/CLAUDE.md @@ -174,10 +174,14 @@ for changes under `litellm-rust/`. ```bash cd litellm-rust cargo fmt --check +cargo clippy --workspace --all-targets -- -D warnings +cargo clippy -p litellm-core --all-targets --features bedrock-auth -- -D warnings # the ai-gateway binary + server code is behind the `server` feature -cargo clippy -p litellm-ai-gateway --all-targets --features server -- -D warnings -cargo clippy -p litellm-core -p litellm-python-interop -p litellm-python-bridge --all-targets -- -D warnings +cargo clippy -p litellm-ai-gateway --all-targets --all-features -- -D warnings cargo test --workspace +cargo test -p litellm-core --features bedrock-auth +# the `auth`, `routes`, `state` and `realtime` tests only exist under `server` +cargo test -p litellm-ai-gateway --features server ``` When a Rust path is exposed through Python, add Python parity tests that compare diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index b3dac5ca935..803df633c27 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -1435,14 +1435,17 @@ dependencies = [ "aws-sigv4", "aws-smithy-runtime-api", "aws-types", + "base64", "rand 0.8.7", "reqwest", + "rstest", "serde", "serde_json", "sha2 0.10.9", "thiserror 2.0.19", "tokio", "tracing", + "tracing-subscriber", ] [[package]] @@ -1461,7 +1464,6 @@ dependencies = [ "tokio", "tokio-tungstenite", "tracing", - "tracing-subscriber", ] [[package]] @@ -1511,6 +1513,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.2" @@ -1969,6 +1981,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -2736,6 +2749,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 643ad985251..62f62872dd7 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -24,7 +24,7 @@ pyo3 = "0.29.2" pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] } pythonize = "0.29.0" rand = "0.8" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "http2", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "http2", "stream"] } rstest = "0.26.1" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["float_roundtrip"] } diff --git a/litellm-rust/README.md b/litellm-rust/README.md index a0d79c6f0a5..e43dc7ea6ad 100644 --- a/litellm-rust/README.md +++ b/litellm-rust/README.md @@ -49,11 +49,6 @@ function per top-level route, mirroring the core entrypoints. ## Checks -Run these before pushing Rust changes. GitHub Actions runs the same checks for -changes under `litellm-rust/`. - -```bash -cargo fmt --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace -``` +Run the commands under "Checks" in [CLAUDE.md](CLAUDE.md) before pushing Rust +changes. That list is the single source of truth and matches what GitHub Actions +runs for changes under `litellm-rust/`. diff --git a/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md b/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md index 4a689cb9579..c0a29ab14bc 100644 --- a/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md +++ b/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md @@ -49,11 +49,5 @@ Rules for adding or changing an LLM provider/route in `litellm-rust`. `messages` ## Checks before push -25. Run, and keep green: - ```bash - cd litellm-rust - cargo fmt --check - cargo clippy -p litellm-ai-gateway --all-targets --features server -- -D warnings - cargo clippy -p litellm-core -p litellm-python-interop -p litellm-python-bridge --all-targets -- -D warnings - cargo test --workspace - ``` +25. Run, and keep green, the commands under "Checks" in `litellm-rust/CLAUDE.md`. + That list is the single source of truth and matches what GitHub Actions runs. diff --git a/litellm-rust/crates/ai-gateway/Cargo.toml b/litellm-rust/crates/ai-gateway/Cargo.toml index e3dbdf24ce6..eef2bf55a07 100644 --- a/litellm-rust/crates/ai-gateway/Cargo.toml +++ b/litellm-rust/crates/ai-gateway/Cargo.toml @@ -32,6 +32,7 @@ subtle = { workspace = true, optional = true } # SHA-256 hash_token) so the plaintext credential never enters a log payload. sha2 = { workspace = true, optional = true } pyo3 = { workspace = true, features = ["auto-initialize"], optional = true } +tower = { version = "0.5.3", features = ["util"], optional = true } [features] default = [] @@ -39,6 +40,7 @@ server = ["dep:axum", "dep:subtle", "dep:sha2"] # Build the gateway's config from the proxy YAML via an embedded Python # interpreter (links libpython; requires `litellm` importable at runtime). python-config = ["dep:pyo3"] +trace-parity = ["server", "dep:tower", "litellm-core/observability"] [dev-dependencies] futures-channel = "0.3" diff --git a/litellm-rust/crates/ai-gateway/Dockerfile b/litellm-rust/crates/ai-gateway/Dockerfile index adf6fca0741..2bc3c05ad7e 100644 --- a/litellm-rust/crates/ai-gateway/Dockerfile +++ b/litellm-rust/crates/ai-gateway/Dockerfile @@ -36,12 +36,12 @@ FROM chef AS builder # whenever only gateway source changes. COPY --from=planner /build/litellm-rust/recipe.json recipe.json RUN cargo chef cook --locked --release \ - -p litellm-ai-gateway --features python-config \ + -p litellm-ai-gateway --features server,python-config \ --recipe-path recipe.json # Now copy the real sources and build the gateway binary. Deps are already cooked # above, so this step only recompiles the gateway crate. COPY litellm-rust/ . -RUN cargo build --locked --release -p litellm-ai-gateway --features python-config +RUN cargo build --locked --release -p litellm-ai-gateway --bin litellm-ai-gateway --features server,python-config # ---- Runtime ---------------------------------------------------------------- # python:3.11-slim-bookworm ships libpython3.11, matching the builder's PyO3 diff --git a/litellm-rust/crates/ai-gateway/README.md b/litellm-rust/crates/ai-gateway/README.md index 5cbb47220be..1675e6f1b16 100644 --- a/litellm-rust/crates/ai-gateway/README.md +++ b/litellm-rust/crates/ai-gateway/README.md @@ -100,7 +100,7 @@ Worker tuning, rarely needed: `LITELLM_LOG_CHANNEL_CAPACITY` (4096), ## Build & run with Docker -The image is built `--features python-config` and installs litellm **from this +The image is built `--features server,python-config` and installs litellm **from this repo's source** (the config reader is newer than any PyPI release), so the build **context is the repo root**: @@ -135,10 +135,10 @@ docker run --rm -p 4001:4001 \ ```bash # config.yaml mode — needs litellm importable in the active python env LITELLM_CONFIG_PATH=./crates/ai-gateway/config.yaml \ - cargo run --release -p litellm-ai-gateway --features python-config + cargo run --release -p litellm-ai-gateway --features server,python-config # env stand-in mode — no python, no config -cargo run --release -p litellm-ai-gateway +cargo run --release -p litellm-ai-gateway --features server ``` ## Deploy on Render diff --git a/litellm-rust/crates/ai-gateway/src/lib.rs b/litellm-rust/crates/ai-gateway/src/lib.rs index 057db6457c4..a2950748afc 100644 --- a/litellm-rust/crates/ai-gateway/src/lib.rs +++ b/litellm-rust/crates/ai-gateway/src/lib.rs @@ -28,6 +28,8 @@ pub mod auth; pub mod routes; #[cfg(feature = "server")] pub mod state; +#[cfg(feature = "trace-parity")] +pub mod trace_parity; mod constants; pub mod integrations; diff --git a/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs b/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs index c1fb328893b..d2be17260a3 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs @@ -12,6 +12,7 @@ use litellm_core::providers::azure_ai::ocr::transformation::{ AZURE_AI_OCR_CONFIG, AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG, }; use litellm_core::providers::mistral::ocr::transformation::MISTRAL_OCR_CONFIG; +use litellm_core::providers::reducto::ocr::transformation as reducto; use litellm_core::providers::vertex_ai::ocr::transformation as vertex_ai; use litellm_core::providers::vertex_ai::ocr::transformation::{ VERTEX_AI_DEEPSEEK_OCR_CONFIG, VERTEX_AI_OCR_CONFIG, @@ -39,6 +40,7 @@ pub(super) fn ocr_provider_config( ) -> Option<&'static dyn OcrProviderConfig> { match provider { "mistral" => Some(&MISTRAL_OCR_CONFIG), + "reducto" => reducto::config_for_model(model), "azure_ai" if is_azure_document_intelligence_model(model) => { Some(&AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG) } @@ -334,6 +336,7 @@ fn operation_status(response_json: &Value) -> Result<&str, Error> { } } +#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] pub(super) async fn poll_document_intelligence( operation_url: &str, original_url: &str, @@ -392,9 +395,11 @@ pub(super) async fn poll_document_intelligence( #[cfg(test)] mod tests { - use super::*; + use litellm_core::ocr::transformation::OcrResponseHandling; use serde_json::json; + use super::*; + #[test] fn blocks_private_and_metadata_ips() { assert!(is_blocked_ip("127.0.0.1".parse().unwrap())); @@ -438,4 +443,87 @@ mod tests { assert_eq!(transformed, document); } + + #[test] + fn truncate_error_body_passes_short_strings_through() { + let body = "Unauthorized"; + assert_eq!(truncate_error_body(body), "Unauthorized"); + } + + #[test] + fn truncate_error_body_caps_long_payloads() { + let body = "x".repeat(306); + let truncated = truncate_error_body(&body); + + assert!(truncated.ends_with("... (truncated)")); + let prefix_chars = truncated + .strip_suffix("... (truncated)") + .expect("truncated marker present") + .chars() + .count(); + assert_eq!(prefix_chars, 256); + } + + #[test] + fn truncate_error_body_does_not_split_multibyte_chars() { + let body = "é".repeat(266); + let truncated = truncate_error_body(&body); + assert!(truncated.is_char_boundary(truncated.len())); + } + + #[test] + fn ocr_dispatch_supports_migrated_providers() { + assert!(ocr_provider_config("mistral", "mistral-ocr-latest").is_some()); + assert!( + ocr_provider_config("azure_ai", "pixtral-12b-2409") + .expect("azure ai config resolves") + .requires_data_uri_document() + ); + assert_eq!( + ocr_provider_config("azure_ai", "doc-intelligence/prebuilt-read") + .expect("document intelligence config resolves") + .response_handling(), + OcrResponseHandling::AzureDocumentIntelligencePoll + ); + assert!( + ocr_provider_config("vertex_ai", "deepseek-ocr-maas") + .expect("vertex deepseek config resolves") + .supported_ocr_params() + .contains(&"temperature") + ); + assert!(ocr_provider_config("openai", "gpt-4o").is_none()); + } + + #[test] + fn string_headers_accepts_string_values() { + let headers = json!({ + "x-trace-id": "trace-1" + }) + .as_object() + .unwrap() + .clone(); + + assert_eq!( + string_headers(Some(headers)).expect("string headers accepted"), + vec![("x-trace-id".to_string(), "trace-1".to_string())] + ); + } + + #[test] + fn string_headers_rejects_non_string_values() { + let headers = json!({ + "x-retry-count": 3 + }) + .as_object() + .unwrap() + .clone(); + + let err = string_headers(Some(headers)).expect_err("non-string header rejected"); + assert_eq!( + err, + Error::InvalidRequest( + "OCR extra_headers.x-retry-count must be a string, got number".to_string() + ) + ); + } } diff --git a/litellm-rust/crates/ai-gateway/src/ocr/handler.rs b/litellm-rust/crates/ai-gateway/src/ocr/handler.rs index 856d9571201..6c6e12724cd 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/handler.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/handler.rs @@ -50,7 +50,11 @@ pub(crate) async fn execute_ocr_provider_call( .await?; return Ok(request .config - .transform_ocr_response(&request.model, response_json)? + .transform_ocr_response_with_params( + &request.model, + response_json, + &request.optional_params, + )? .into_json()); } @@ -71,6 +75,10 @@ pub(crate) async fn execute_ocr_provider_call( Ok(request .config - .transform_ocr_response(&request.model, response_json)? + .transform_ocr_response_with_params( + &request.model, + response_json, + &request.optional_params, + )? .into_json()) } diff --git a/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs b/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs index f8c4f8fe8c5..446b323db3a 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs @@ -1,11 +1,15 @@ use litellm_core::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming}; use litellm_core::error::Error; +use litellm_core::providers::reducto::ocr::transformation::{ + build_upload_request, extract_document_source, extract_upload_file_id, +}; use serde_json::{Map, Value, json}; use std::future::Future; use std::pin::Pin; -use super::common_utils::{convert_document_url_to_data_uri, string_headers}; +use super::common_utils::{convert_document_url_to_data_uri, string_headers, truncate_error_body}; use super::types::{PreparedOcrRequest, ProviderOcrRequest}; +use crate::client::http_client; use crate::integrations::custom_guardrail::{ CustomGuardrailRunner, GuardrailContext, GuardrailError, GuardrailRequest, }; @@ -89,22 +93,39 @@ impl OcrLifecycleHooks { )?; let model = request.model.clone(); let custom_llm_provider = request.custom_llm_provider.clone(); - let document = if config.requires_data_uri_document() { + let is_reducto = custom_llm_provider == "reducto"; + let document = if is_reducto { + let guarded_document = self + .run_during_call_guardrails(&model, &custom_llm_provider, &url, request.document) + .await?; + upload_reducto_document( + &guarded_document, + request.api_base.as_deref(), + request.timeout, + &upstream_headers, + ) + .await? + } else if config.requires_data_uri_document() { convert_document_url_to_data_uri(request.document).await? } else { request.document }; + let optional_params = request.optional_params; let body = config - .transform_ocr_request(&request.model, document, request.optional_params)? + .transform_ocr_request(&request.model, document, optional_params.clone())? .data; - let body = self - .run_during_call_guardrails(&model, &custom_llm_provider, &url, body) - .await?; + let body = if is_reducto { + body + } else { + self.run_during_call_guardrails(&model, &custom_llm_provider, &url, body) + .await? + }; Ok(ProviderOcrRequest { model, config, url, body, + optional_params, upstream_headers, timeout: request.timeout, }) @@ -165,6 +186,63 @@ impl OcrLifecycleHooks { } } +async fn upload_reducto_document( + document: &Value, + api_base: Option<&str>, + timeout: Option, + upstream_headers: &[(String, String)], +) -> Result { + let source = extract_document_source(document)?; + let Some(authorization) = upstream_headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("authorization")) + .map(|(_, value)| value.as_str()) + else { + return Err(Error::Auth( + "Reducto upload requires an Authorization header".to_string(), + )); + }; + let Some(upload) = build_upload_request(source, authorization, api_base) else { + return Ok(document.clone()); + }; + let part = reqwest::multipart::Part::bytes(upload.bytes) + .file_name(upload.file_name) + .mime_str(&upload.mime_type) + .map_err(|error| Error::InvalidRequest(error.to_string()))?; + let form = reqwest::multipart::Form::new().part("file", part); + let mut request_builder = http_client().post(upload.url).multipart(form); + for (name, value) in upstream_headers { + if !name.eq_ignore_ascii_case("content-type") + && !name.eq_ignore_ascii_case("content-length") + { + request_builder = request_builder.header(name, value); + } + } + if let Some(timeout) = timeout { + request_builder = request_builder.timeout(timeout); + } + let response = request_builder + .send() + .await + .map_err(|error| Error::Network(error.to_string()))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| Error::Network(error.to_string()))?; + if !status.is_success() { + return Err(Error::Http { + status: status.as_u16(), + body: truncate_error_body(&body), + }); + } + let response_json: Value = serde_json::from_str(&body).map_err(|error| { + Error::InvalidResponse(format!("invalid Reducto upload response JSON: {error}")) + })?; + let file_id = extract_upload_file_id(&response_json)?; + Ok(json!({"type": "document_url", "document_url": file_id})) +} + impl CallLifecycleHooks for OcrLifecycleHooks { type PreCallFuture<'a> = OcrFuture<'a, PreparedOcrRequest>; type DuringCallFuture<'a> = OcrFuture<'a, PreparedOcrRequest>; diff --git a/litellm-rust/crates/ai-gateway/src/ocr/mod.rs b/litellm-rust/crates/ai-gateway/src/ocr/mod.rs index d9230af1c59..2acdd232c80 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/mod.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/mod.rs @@ -24,4 +24,151 @@ pub async fn ocr(request: OcrRequest<'_>) -> Result { } #[cfg(test)] -mod tests; +mod tests { + use serde_json::{Map, json}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpListener, TcpStream}; + + use super::{OcrRequest, ocr}; + use crate::integrations::types::RequestMetadata; + + async fn read_http_request(socket: &mut TcpStream) -> String { + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + let header_end = loop { + let n = socket.read(&mut buffer).await.expect("reads request"); + if n == 0 { + break request.len(); + } + request.extend_from_slice(&buffer[..n]); + if let Some(position) = request.windows(4).position(|window| window == b"\r\n\r\n") { + break position + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + while request.len().saturating_sub(header_end) < content_length { + let n = socket.read(&mut buffer).await.expect("reads body"); + if n == 0 { + break; + } + request.extend_from_slice(&buffer[..n]); + } + String::from_utf8(request).expect("request is utf8") + } + + fn base_ocr_request(model: &str) -> OcrRequest<'_> { + OcrRequest { + model, + document: json!({ + "type": "document_url", + "document_url": "https://example.com/doc.pdf" + }), + api_key: Some("sk-test"), + api_base: None, + custom_llm_provider: None, + extra_headers: None, + optional_params: Map::new(), + timeout: None, + callbacks: Vec::new(), + guardrails: Vec::new(), + request_metadata: RequestMetadata::default(), + litellm_call_id: None, + } + } + + #[tokio::test] + async fn reducto_file_upload_then_parse_maps_response() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let address = listener.local_addr().expect("listener has local address"); + let server = tokio::spawn(async move { + let (mut upload_socket, _) = listener.accept().await.expect("accepts upload request"); + let upload_request = read_http_request(&mut upload_socket).await; + let upload_body = r#"{"file_id":"reducto://uploaded.pdf"}"#; + let upload_response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + upload_body.len(), + upload_body + ); + upload_socket + .write_all(upload_response.as_bytes()) + .await + .expect("writes upload response"); + + let (mut parse_socket, _) = listener.accept().await.expect("accepts parse request"); + let parse_request = read_http_request(&mut parse_socket).await; + let parse_body = r#"{"job_id":"job_123","usage":{"num_pages":3,"credits":3},"result":{"chunks":[{"content":"Page 1 block A","blocks":[{"content":"Page 1 block A","bbox":{"page":1},"kind":"text"}]},{"content":"Page 2 block A","blocks":[{"content":"Page 2 block A","bbox":{"page":2},"kind":"table"}]},{"content":"Page 1 block B","blocks":[{"content":"Page 1 block B","bbox":{"page":1},"kind":"text"}]},{"content":"Page 3 block A","blocks":[{"content":"Page 3 block A","bbox":{"page":3},"kind":"figure"}]}]}}"#; + let parse_response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + parse_body.len(), + parse_body + ); + parse_socket + .write_all(parse_response.as_bytes()) + .await + .expect("writes parse response"); + (upload_request, parse_request) + }); + let api_base = format!("http://{address}"); + let mut request = base_ocr_request("reducto/parse-v3"); + request.api_base = Some(&api_base); + request.api_key = None; + request.extra_headers = Some(Map::from_iter([ + ("Authorization".to_string(), json!("Bearer test-key")), + ("x-trace-id".to_string(), json!("trace-1")), + ])); + request.document = json!({ + "type": "document_url", + "document_url": "data:application/pdf;base64,JVBERi0xLjQ=" + }); + request.optional_params = Map::from_iter([ + ( + "formatting".to_string(), + json!({"table_output_format": "html"}), + ), + ("retrieval".to_string(), json!({"chunk_mode": "section"})), + ("settings".to_string(), json!({"ocr_system": "standard"})), + ]); + + let response = ocr(request).await.expect("Reducto OCR succeeds"); + + assert_eq!(response["pages"].as_array().map(Vec::len), Some(3)); + assert_eq!( + response["pages"][0]["markdown"], + "Page 1 block A\n\nPage 1 block B" + ); + assert_eq!(response["pages"][1]["markdown"], "Page 2 block A"); + assert_eq!(response["pages"][2]["markdown"], "Page 3 block A"); + assert_eq!(response["usage_info"]["pages_processed"], 3); + assert_eq!(response["usage_info"]["credits"], 3); + assert_eq!(response["provider_native_response"]["job_id"], "job_123"); + let (upload_request, parse_request) = server.await.expect("server task completes"); + assert!( + upload_request + .to_ascii_lowercase() + .contains("authorization: bearer test-key") + ); + assert!(upload_request.contains("application/pdf")); + assert!(upload_request.contains("%PDF-1.4")); + assert!(upload_request.contains("x-trace-id: trace-1")); + assert!( + parse_request + .to_ascii_lowercase() + .contains("authorization: bearer test-key") + ); + assert!(parse_request.contains(r#""input":"reducto://uploaded.pdf""#)); + assert!(parse_request.contains(r#""table_output_format":"html""#)); + assert!(parse_request.contains(r#""chunk_mode":"section""#)); + assert!(parse_request.contains(r#""ocr_system":"standard""#)); + } +} diff --git a/litellm-rust/crates/ai-gateway/src/ocr/prepare.rs b/litellm-rust/crates/ai-gateway/src/ocr/prepare.rs index fedacc62760..fa9ca1a193e 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/prepare.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/prepare.rs @@ -2,6 +2,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use litellm_core::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider}; +use serde_json::{Map, Value}; use super::common_utils::ocr_provider_config; use super::hooks::OcrLifecycleHooks; @@ -28,17 +29,33 @@ pub(crate) fn prepare_ocr_call(request: OcrRequest<'_>) -> PreparedOcrCall { let model = provider_info.model.to_string(); let custom_llm_provider = provider_info.custom_llm_provider.to_string(); let config = ocr_provider_config(&custom_llm_provider, &model) - .ok_or_else(|| litellm_core::Error::InvalidProvider(custom_llm_provider.clone())); + .ok_or_else(|| litellm_core::Error::InvalidProvider(custom_llm_provider.clone())) + .and_then(|config| { + validate_request_format(config, &request.optional_params, &custom_llm_provider)?; + Ok(config) + }); let optional_params = match &config { Ok(config) => { let supported = config.supported_ocr_params(); - config.map_ocr_params( + let mut mapped = config.map_ocr_params( &request .optional_params - .into_iter() + .iter() .filter(|(name, _)| supported.contains(&name.as_str())) + .map(|(name, value)| (name.clone(), value.clone())) .collect(), - ) + ); + for name in [ + "vertex_project", + "vertex_ai_project", + "vertex_location", + "vertex_ai_location", + ] { + if let Some(value) = request.optional_params.get(name) { + mapped.insert(name.to_string(), value.clone()); + } + } + mapped } Err(_) => request.optional_params, }; @@ -64,6 +81,26 @@ pub(crate) fn prepare_ocr_call(request: OcrRequest<'_>) -> PreparedOcrCall { } } +fn validate_request_format( + config: &'static dyn litellm_core::ocr::transformation::OcrProviderConfig, + optional_params: &Map, + provider: &str, +) -> Result<(), litellm_core::Error> { + let Some(format) = optional_params.get("req_format") else { + return Ok(()); + }; + match format.as_str() { + Some("litellm") => Ok(()), + Some("native") if config.supported_ocr_params().contains(&"req_format") => Ok(()), + Some("native") => Err(litellm_core::Error::InvalidRequest(format!( + "`req_format=native` is not supported for provider {provider}" + ))), + _ => Err(litellm_core::Error::InvalidRequest(format!( + "Invalid `req_format`: {format}. Expected `litellm` or `native`" + ))), + } +} + fn new_ocr_call_id() -> String { static COUNTER: AtomicU64 = AtomicU64::new(1); let sequence = COUNTER.fetch_add(1, Ordering::Relaxed); @@ -73,3 +110,54 @@ fn new_ocr_call_id() -> String { .unwrap_or(0); format!("ocr-{timestamp}-{sequence}") } + +#[cfg(test)] +mod tests { + use litellm_core::error::Error; + use serde_json::{Map, json}; + + use super::{OcrRequest, prepare_ocr_call}; + use crate::integrations::types::RequestMetadata; + + fn base_ocr_request(model: &str) -> OcrRequest<'_> { + OcrRequest { + model, + document: json!({ + "type": "document_url", + "document_url": "https://example.com/doc.pdf" + }), + api_key: Some("sk-test"), + api_base: None, + custom_llm_provider: None, + extra_headers: None, + optional_params: Map::new(), + timeout: None, + callbacks: Vec::new(), + guardrails: Vec::new(), + request_metadata: RequestMetadata::default(), + litellm_call_id: None, + } + } + + fn request_with_format(format: &str) -> OcrRequest<'_> { + let mut request = base_ocr_request("mistral/mistral-ocr-latest"); + request.optional_params = Map::from_iter([("req_format".to_string(), json!(format))]); + request + } + + #[test] + fn native_format_rejected_for_provider_without_support_as_bad_request() { + let prepared = prepare_ocr_call(request_with_format("native")); + assert!( + matches!(prepared.request.config, Err(Error::InvalidRequest(message)) if message.contains("not supported for provider")) + ); + } + + #[test] + fn unknown_format_rejected_for_provider_without_support_as_bad_request() { + let prepared = prepare_ocr_call(request_with_format("raw")); + assert!( + matches!(prepared.request.config, Err(Error::InvalidRequest(message)) if message.contains("Invalid `req_format`")) + ); + } +} diff --git a/litellm-rust/crates/ai-gateway/src/ocr/types.rs b/litellm-rust/crates/ai-gateway/src/ocr/types.rs index 95e551d79ca..75a8e61ddbf 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/types.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/types.rs @@ -53,6 +53,7 @@ pub(crate) struct ProviderOcrRequest { pub(crate) config: &'static dyn OcrProviderConfig, pub(crate) url: String, pub(crate) body: Value, + pub(crate) optional_params: Map, pub(crate) upstream_headers: Vec<(String, String)>, pub(crate) timeout: Option, } diff --git a/litellm-rust/crates/ai-gateway/src/realtime/streaming.rs b/litellm-rust/crates/ai-gateway/src/realtime/streaming.rs index c32e727de54..c0d72e90b77 100644 --- a/litellm-rust/crates/ai-gateway/src/realtime/streaming.rs +++ b/litellm-rust/crates/ai-gateway/src/realtime/streaming.rs @@ -106,16 +106,16 @@ impl RealTimeStreaming { /// `litellm_call_id`, replacing the gateway-generated fallback. fn on_session(&mut self, event: &RealtimeEvent) { let session = event.data.get("session").and_then(Value::as_object); - if let Some(id) = session.and_then(|s| s.get("id")).and_then(Value::as_str) { - if !id.is_empty() { - self.id = id.to_string(); - self.litellm_call_id = id.to_string(); - } + if let Some(id) = session.and_then(|s| s.get("id")).and_then(Value::as_str) + && !id.is_empty() + { + self.id = id.to_string(); + self.litellm_call_id = id.to_string(); } - if let Some(model) = session.and_then(|s| s.get("model")).and_then(Value::as_str) { - if !model.is_empty() { - self.model = model.to_string(); - } + if let Some(model) = session.and_then(|s| s.get("model")).and_then(Value::as_str) + && !model.is_empty() + { + self.model = model.to_string(); } } @@ -323,6 +323,32 @@ mod tests { assert_eq!(streaming.dropped(), 0); } + #[test] + fn blank_session_id_and_model_keep_the_gateway_fallbacks() { + let mut streaming = RealTimeStreaming::new( + Vec::new(), + "call_fallback".to_string(), + "gpt-realtime".to_string(), + RequestMetadata::default(), + ); + + streaming.observe(&event( + r#"{"type":"session.created","session":{"id":"","model":""}}"#, + )); + let payload = streaming.build_payload(); + assert_eq!(payload.id, "call_fallback"); + assert_eq!(payload.litellm_call_id, "call_fallback"); + assert_eq!(payload.model, "gpt-realtime"); + + streaming.observe(&event( + r#"{"type":"session.updated","session":{"id":"sess_002","model":""}}"#, + )); + let payload = streaming.build_payload(); + assert_eq!(payload.id, "sess_002"); + assert_eq!(payload.litellm_call_id, "sess_002"); + assert_eq!(payload.model, "gpt-realtime"); + } + #[test] fn payload_serializes_with_camelcase_times_and_realtime_call_type() { let mut streaming = RealTimeStreaming::new( diff --git a/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs b/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs index e9f8c477f36..bb9f3851a77 100644 --- a/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs +++ b/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs @@ -21,6 +21,12 @@ pub fn router() -> Router { Router::new().route(MESSAGES_ROUTE_PATH, post(handle)) } +#[tracing::instrument( + name = "messages_gateway_route", + target = "litellm::function_trace", + level = "trace", + skip_all +)] async fn handle( _auth: RequireMasterKey, State(state): State, diff --git a/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs b/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs index 4fd29db05d6..5434719987b 100644 --- a/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs +++ b/litellm-rust/crates/ai-gateway/src/routes/messages/service.rs @@ -12,6 +12,12 @@ pub(crate) enum MessagesResponse { Stream(reqwest::Response), } +#[tracing::instrument( + name = "messages_gateway_service", + target = "litellm::function_trace", + level = "trace", + skip_all +)] pub async fn run( router: &Arc, body: Value, diff --git a/litellm-rust/crates/ai-gateway/src/routes/realtime/service.rs b/litellm-rust/crates/ai-gateway/src/routes/realtime/service.rs index b8ee77c4269..f7bbb37dff4 100644 --- a/litellm-rust/crates/ai-gateway/src/routes/realtime/service.rs +++ b/litellm-rust/crates/ai-gateway/src/routes/realtime/service.rs @@ -50,18 +50,17 @@ where provider_model, params.api_key.as_deref(), params.api_base.as_deref(), - ) { - if let Some(handoff) = pool.take(&key) { - return crate::io::realtime::realtime_warm( - provider_model, - handoff, - idle_timeout, - observe, - client_in, - client_out, - ) - .await; - } + ) && let Some(handoff) = pool.take(&key) + { + return crate::io::realtime::realtime_warm( + provider_model, + handoff, + idle_timeout, + observe, + client_in, + client_out, + ) + .await; } // Cold path: fresh dial (the original behavior). diff --git a/litellm-rust/crates/ai-gateway/src/trace_parity.rs b/litellm-rust/crates/ai-gateway/src/trace_parity.rs new file mode 100644 index 00000000000..614852c541d --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/trace_parity.rs @@ -0,0 +1,65 @@ +//! Harness-only in-process adapters. Never mounted as production routes. + +use std::sync::Arc; + +use axum::body::{Body, to_bytes}; +use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; +use axum::http::{Request, StatusCode}; +use litellm_core::Error; +use litellm_core::router::{Deployment, LiteLLMParams, Router as ModelRouter}; +use serde::Serialize; +use serde_json::Value; +use tower::ServiceExt; + +use crate::io::realtime_pool::RealtimePool; +use crate::routes; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct GatewayResponse { + pub status: u16, + pub body: Value, +} + +pub async fn messages_request( + model_alias: String, + provider_model: String, + api_base: String, + body: Value, +) -> Result { + let state = AppState { + router: Arc::new(ModelRouter::new(vec![Deployment { + model_name: model_alias, + litellm_params: LiteLLMParams { + model: provider_model, + api_key: Some("trace-provider-key".to_string()), + api_base: Some(api_base), + }, + }])), + master_key: Some(Arc::from("trace-master-key")), + loggers: Arc::new(Vec::new()), + realtime_pool: RealtimePool::disabled(), + }; + let request = Request::builder() + .method("POST") + .uri("/v1/messages") + .header(AUTHORIZATION, "Bearer trace-master-key") + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .map_err(|error| Error::InvalidRequest(error.to_string()))?; + let response = routes::app(state) + .oneshot(request) + .await + .map_err(|error| match error {})?; + let status: StatusCode = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .map_err(|error| Error::InvalidResponse(error.to_string()))?; + let body = serde_json::from_slice(&bytes).map_err(|error| { + Error::InvalidResponse(format!("gateway returned invalid JSON: {error}")) + })?; + Ok(GatewayResponse { + status: status.as_u16(), + body, + }) +} diff --git a/litellm-rust/crates/ai-gateway/src/ocr/tests.rs b/litellm-rust/crates/ai-gateway/tests/ocr_lifecycle.rs similarity index 82% rename from litellm-rust/crates/ai-gateway/src/ocr/tests.rs rename to litellm-rust/crates/ai-gateway/tests/ocr_lifecycle.rs index 85e4c408045..c3a89f4394d 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/tests.rs +++ b/litellm-rust/crates/ai-gateway/tests/ocr_lifecycle.rs @@ -1,23 +1,19 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; -use litellm_core::error::Error; -use litellm_core::http_utils::has_header; -use litellm_core::ocr::transformation::OcrResponseHandling; -use serde_json::{Map, Value, json}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; - -use super::common_utils::{ocr_provider_config, string_headers, truncate_error_body}; -use super::{OcrRequest, ocr}; -use crate::integrations::custom_guardrail::{ +use litellm_ai_gateway::integrations::custom_guardrail::{ CustomGuardrail, GuardrailContext, GuardrailDecision, GuardrailError, GuardrailEventHook, GuardrailFuture, GuardrailRequest, }; -use crate::integrations::custom_logger::{ +use litellm_ai_gateway::integrations::custom_logger::{ CallbackTiming, CallbackValue, CustomLogger, LogFuture, ModelCallDetails, }; -use crate::integrations::types::RequestMetadata; +use litellm_ai_gateway::integrations::types::RequestMetadata; +use litellm_ai_gateway::ocr::{OcrRequest, ocr}; +use litellm_core::error::Error; +use serde_json::{Map, Value, json}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; async fn read_http_headers(socket: &mut TcpStream) -> String { let mut request = Vec::new(); @@ -136,6 +132,7 @@ struct RecordingOcrGuardrail { hooks: Vec, events: Mutex>, block_pre_call: bool, + block_during_call: bool, } impl RecordingOcrGuardrail { @@ -144,6 +141,7 @@ impl RecordingOcrGuardrail { hooks, events: Mutex::new(Vec::new()), block_pre_call: false, + block_during_call: false, } } @@ -152,6 +150,16 @@ impl RecordingOcrGuardrail { hooks: vec![GuardrailEventHook::PreCall], events: Mutex::new(Vec::new()), block_pre_call: true, + block_during_call: false, + } + } + + fn blocking_during_call() -> Self { + Self { + hooks: vec![GuardrailEventHook::DuringCall], + events: Mutex::new(Vec::new()), + block_pre_call: false, + block_during_call: true, } } @@ -193,91 +201,95 @@ impl CustomGuardrail for RecordingOcrGuardrail { ) -> GuardrailFuture<'a> { Box::pin(async move { self.events.lock().unwrap().push("async_moderation_hook"); + if self.block_during_call { + return Ok(GuardrailDecision::Block(GuardrailError::blocked( + "blocked before provider", + ))); + } request.data["body"]["guarded_during"] = json!(true); Ok(GuardrailDecision::Mask(request)) }) } } -#[test] -fn truncate_error_body_passes_short_strings_through() { - let body = "Unauthorized"; - assert_eq!(truncate_error_body(body), "Unauthorized"); +fn base_ocr_request(model: &str) -> OcrRequest<'_> { + OcrRequest { + model, + document: json!({ + "type": "document_url", + "document_url": "https://example.com/doc.pdf" + }), + api_key: Some("sk-test"), + api_base: None, + custom_llm_provider: None, + extra_headers: None, + optional_params: Map::new(), + timeout: None, + callbacks: Vec::new(), + guardrails: Vec::new(), + request_metadata: RequestMetadata::default(), + litellm_call_id: None, + } } -#[test] -fn truncate_error_body_caps_long_payloads() { - let body = "x".repeat(306); - let truncated = truncate_error_body(&body); +#[tokio::test] +async fn reducto_during_call_guardrail_blocks_before_upload() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let address = listener.local_addr().expect("listener has local address"); + let api_base = format!("http://{address}"); + let guardrail = Arc::new(RecordingOcrGuardrail::blocking_during_call()); + let mut request = base_ocr_request("reducto/parse-v3"); + request.api_base = Some(&api_base); + request.document = json!({ + "type": "document_url", + "document_url": "data:application/pdf;base64,JVBERi0xLjQ=" + }); + request.guardrails = vec![guardrail.clone()]; - assert!(truncated.ends_with("... (truncated)")); - let prefix_chars = truncated - .strip_suffix("... (truncated)") - .expect("truncated marker present") - .chars() - .count(); - assert_eq!(prefix_chars, 256); + let error = ocr(request).await.expect_err("guardrail blocks upload"); + + assert!(matches!(error, Error::InvalidRequest(_))); + assert_eq!(guardrail.events(), vec!["async_moderation_hook"]); + let accepted = tokio::time::timeout(Duration::from_millis(100), listener.accept()).await; + assert!(accepted.is_err(), "upload socket should not be touched"); } -#[test] -fn truncate_error_body_does_not_split_multibyte_chars() { - let body = "é".repeat(266); - let truncated = truncate_error_body(&body); - assert!(truncated.is_char_boundary(truncated.len())); -} +#[tokio::test] +async fn reducto_upload_error_body_is_truncated() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let address = listener.local_addr().expect("listener has local address"); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accepts upload request"); + let _request = read_http_request(&mut socket).await; + let body = "x".repeat(300); + let response = format!( + "HTTP/1.1 500 Internal Server Error\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + socket + .write_all(response.as_bytes()) + .await + .expect("writes upload response"); + }); + let api_base = format!("http://{address}"); + let mut request = base_ocr_request("reducto/parse-v3"); + request.api_base = Some(&api_base); + request.document = json!({ + "type": "document_url", + "document_url": "data:application/pdf;base64,JVBERi0xLjQ=" + }); + + let error = ocr(request).await.expect_err("upload should fail"); -#[test] -fn ocr_dispatch_supports_migrated_providers() { - assert!(ocr_provider_config("mistral", "mistral-ocr-latest").is_some()); assert!( - ocr_provider_config("azure_ai", "pixtral-12b-2409") - .expect("azure ai config resolves") - .requires_data_uri_document() + matches!(error, Error::Http { status: 500, body } if body.chars().count() < 300 && body.ends_with("... (truncated)")) ); - assert_eq!( - ocr_provider_config("azure_ai", "doc-intelligence/prebuilt-read") - .expect("document intelligence config resolves") - .response_handling(), - OcrResponseHandling::AzureDocumentIntelligencePoll - ); - assert!( - ocr_provider_config("vertex_ai", "deepseek-ocr-maas") - .expect("vertex deepseek config resolves") - .supported_ocr_params() - .contains(&"temperature") - ); - assert!(ocr_provider_config("openai", "gpt-4o").is_none()); -} - -#[test] -fn string_headers_accepts_string_values() { - let headers = json!({ - "x-trace-id": "trace-1" - }) - .as_object() - .unwrap() - .clone(); - - assert_eq!( - string_headers(Some(headers)).expect("string headers accepted"), - vec![("x-trace-id".to_string(), "trace-1".to_string())] - ); -} - -#[test] -fn auth_header_detection_is_case_insensitive() { - let headers = vec![ - ("x-trace-id".to_string(), "trace-1".to_string()), - ("authorization".to_string(), "Bearer sk-test".to_string()), - ]; - - assert!(has_header(&headers, "authorization")); - - let headers = vec![("Authorization".to_string(), "Bearer sk-test".to_string())]; - assert!(has_header(&headers, "authorization")); - - let headers = vec![("x-trace-id".to_string(), "trace-1".to_string())]; - assert!(!has_header(&headers, "authorization")); + server.await.expect("server task completes"); } #[tokio::test] @@ -595,21 +607,3 @@ async fn document_intelligence_poll_uses_resolved_subscription_key() { "{poll_request}" ); } - -#[test] -fn string_headers_rejects_non_string_values() { - let headers = json!({ - "x-retry-count": 3 - }) - .as_object() - .unwrap() - .clone(); - - let err = string_headers(Some(headers)).expect_err("non-string header rejected"); - assert_eq!( - err, - Error::InvalidRequest( - "OCR extra_headers.x-retry-count must be a string, got number".to_string() - ) - ); -} diff --git a/litellm-rust/crates/core/Cargo.toml b/litellm-rust/crates/core/Cargo.toml index 389dbd49505..c0de7ff3977 100644 --- a/litellm-rust/crates/core/Cargo.toml +++ b/litellm-rust/crates/core/Cargo.toml @@ -6,12 +6,14 @@ license.workspace = true repository.workspace = true [dependencies] +base64.workspace = true rand.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true tracing.workspace = true +tracing-subscriber = { workspace = true, optional = true } sha2.workspace = true aws-config = { version = "1.9.0", default-features = false, features = ["rustls", "rt-tokio"], optional = true } aws-credential-types = { version = "1.3.0", features = ["hardcoded-credentials"], optional = true } @@ -30,6 +32,9 @@ bedrock-auth = [ "dep:aws-types", "dep:aws-smithy-runtime-api", ] +observability = ["dep:tracing-subscriber"] [dev-dependencies] +rstest.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tracing-subscriber.workspace = true diff --git a/litellm-rust/crates/core/src/constants.rs b/litellm-rust/crates/core/src/constants.rs index a73961060eb..fc81f4fa029 100644 --- a/litellm-rust/crates/core/src/constants.rs +++ b/litellm-rust/crates/core/src/constants.rs @@ -41,3 +41,5 @@ pub const CHAT_COMPLETION_OBJECT: &str = "chat.completion"; /// `litellm/litellm_core_utils/prompt_templates/factory.py`. pub const EMPTY_TEXT_PLACEHOLDER: &str = "[System: Empty message content sanitised to satisfy protocol]"; + +pub const FUNCTION_TRACE_TARGET: &str = "litellm::function_trace"; diff --git a/litellm-rust/crates/core/src/http_utils.rs b/litellm-rust/crates/core/src/http_utils.rs index 3633130528d..cb472dd5a57 100644 --- a/litellm-rust/crates/core/src/http_utils.rs +++ b/litellm-rust/crates/core/src/http_utils.rs @@ -101,6 +101,23 @@ mod tests { assert!(!has_header(&headers, "authorization")); } + #[test] + fn auth_header_detection_is_case_insensitive() { + let headers = vec![ + ("x-trace-id".to_string(), "trace-1".to_string()), + ("authorization".to_string(), "Bearer sk-test".to_string()), + ]; + + assert!(has_header(&headers, "authorization")); + + let headers = vec![("Authorization".to_string(), "Bearer sk-test".to_string())]; + + assert!(has_header(&headers, "authorization")); + + let headers = vec![("x-trace-id".to_string(), "trace-1".to_string())]; + assert!(!has_header(&headers, "authorization")); + } + #[test] fn bearer_detection_requires_a_non_empty_token() { assert!(has_bearer_auth(&[( diff --git a/litellm-rust/crates/core/src/lib.rs b/litellm-rust/crates/core/src/lib.rs index 0e18d24e5d8..b93e084f57e 100644 --- a/litellm-rust/crates/core/src/lib.rs +++ b/litellm-rust/crates/core/src/lib.rs @@ -6,6 +6,8 @@ pub mod constants; pub mod error; pub mod http_utils; pub mod messages; +#[cfg(any(feature = "observability", test))] +pub mod observability; pub mod ocr; pub mod providers; pub mod realtime; diff --git a/litellm-rust/crates/core/src/observability/function_trace.rs b/litellm-rust/crates/core/src/observability/function_trace.rs new file mode 100644 index 00000000000..2031e35901c --- /dev/null +++ b/litellm-rust/crates/core/src/observability/function_trace.rs @@ -0,0 +1,215 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use tracing::span::{Attributes, Id}; +use tracing::{Dispatch, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::prelude::*; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::{Layer, Registry}; + +use super::function_trace_filter; + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct FunctionTraceEvent { + pub id: usize, + pub parent_id: Option, + pub function: &'static str, + pub module_path: Option<&'static str>, + pub file: Option<&'static str>, + pub line: Option, +} + +#[derive(Clone, Default)] +pub struct FunctionTrace { + events: Arc>>, + span_events: Arc>>, +} + +impl FunctionTrace { + pub fn dispatcher(&self) -> Dispatch { + Dispatch::new( + Registry::default().with( + FunctionTraceLayer { + trace: self.clone(), + } + .with_filter(function_trace_filter()), + ), + ) + } + + pub fn events(&self) -> Vec { + self.events + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } +} + +struct FunctionTraceLayer { + trace: FunctionTrace, +} + +impl Layer for FunctionTraceLayer +where + S: Subscriber + for<'lookup> LookupSpan<'lookup>, +{ + fn on_new_span(&self, attributes: &Attributes<'_>, id: &Id, context: Context<'_, S>) { + let parent_id = context.span(id).and_then(|span| { + let span_events = self + .trace + .span_events + .lock() + .unwrap_or_else(|error| error.into_inner()); + span.scope() + .skip(1) + .find_map(|ancestor| span_events.get(&ancestor.id()).copied()) + }); + let mut events = self + .trace + .events + .lock() + .unwrap_or_else(|error| error.into_inner()); + let event_id = events.len(); + events.push(FunctionTraceEvent { + id: event_id, + parent_id, + function: attributes.metadata().name(), + module_path: attributes.metadata().module_path(), + file: attributes.metadata().file(), + line: attributes.metadata().line(), + }); + self.trace + .span_events + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(id.clone(), event_id); + } +} + +#[cfg(test)] +mod tests { + use crate::constants::FUNCTION_TRACE_TARGET; + + use super::*; + + fn event( + id: usize, + parent_id: Option, + function: &'static str, + ) -> (usize, Option, &'static str) { + (id, parent_id, function) + } + + fn structural_events( + events: &[FunctionTraceEvent], + ) -> Vec<(usize, Option, &'static str)> { + events + .iter() + .map(|event| (event.id, event.parent_id, event.function)) + .collect() + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + async fn outer() { + tokio::task::yield_now().await; + inner().await; + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + async fn inner() { + tokio::task::yield_now().await; + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + async fn concurrent_parent() { + tokio::join!(inner(), inner()); + } + + #[tokio::test] + async fn concurrent_futures_keep_separate_traces_across_yields() { + use tracing::instrument::WithSubscriber; + + let first = FunctionTrace::default(); + let second = FunctionTrace::default(); + let outside = FunctionTrace::default(); + + async { + tokio::join!( + outer().with_subscriber(first.dispatcher()), + inner().with_subscriber(second.dispatcher()), + ); + inner().await; + } + .with_subscriber(outside.dispatcher()) + .await; + + assert_eq!( + structural_events(&first.events()), + vec![event(0, None, "outer"), event(1, Some(0), "inner")], + ); + assert_eq!( + structural_events(&second.events()), + vec![event(0, None, "inner")], + ); + assert_eq!( + structural_events(&outside.events()), + vec![event(0, None, "inner")], + ); + } + + #[tokio::test] + async fn concurrent_siblings_keep_the_same_parent() { + use tracing::instrument::WithSubscriber; + + let trace = FunctionTrace::default(); + concurrent_parent() + .with_subscriber(trace.dispatcher()) + .await; + + assert_eq!( + structural_events(&trace.events()), + vec![ + event(0, None, "concurrent_parent"), + event(1, Some(0), "inner"), + event(2, Some(0), "inner"), + ] + ); + } + + #[test] + fn records_matching_spans_in_creation_order() { + let trace = FunctionTrace::default(); + let dispatch = trace.dispatcher(); + + tracing::dispatcher::with_default(&dispatch, || { + let _ignored = tracing::trace_span!(target: "other", "ignored"); + let _first = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name"); + let _wrong_level = tracing::debug_span!(target: FUNCTION_TRACE_TARGET, "wrong_level"); + let _second = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name"); + }); + + assert_eq!( + structural_events(&trace.events()), + vec![event(0, None, "same_name"), event(1, None, "same_name")] + ); + } + + #[test] + fn records_matching_span_nesting_depth() { + let trace = FunctionTrace::default(); + let dispatch = trace.dispatcher(); + + tracing::dispatcher::with_default(&dispatch, || { + let outer = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "outer"); + let _outer_guard = outer.enter(); + let _inner = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "inner"); + }); + + assert_eq!( + structural_events(&trace.events()), + vec![event(0, None, "outer"), event(1, Some(0), "inner")] + ); + } +} diff --git a/litellm-rust/crates/core/src/observability/mod.rs b/litellm-rust/crates/core/src/observability/mod.rs new file mode 100644 index 00000000000..3f9da8e2bb4 --- /dev/null +++ b/litellm-rust/crates/core/src/observability/mod.rs @@ -0,0 +1,59 @@ +use tracing::span::Id; +use tracing::{Level, Metadata, Subscriber}; +use tracing_subscriber::filter::{FilterFn, LevelFilter, filter_fn}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::registry::LookupSpan; + +use crate::constants::FUNCTION_TRACE_TARGET; + +pub mod function_trace; + +pub use function_trace::{FunctionTrace, FunctionTraceEvent}; + +pub fn function_trace_filter() -> FilterFn) -> bool> { + filter_fn(|metadata| { + metadata.is_span() + && metadata.target() == FUNCTION_TRACE_TARGET + && *metadata.level() == Level::TRACE + }) + .with_max_level_hint(LevelFilter::TRACE) +} + +pub fn span_depth(context: &Context<'_, S>, id: &Id) -> usize +where + S: Subscriber + for<'lookup> LookupSpan<'lookup>, +{ + context + .span(id) + .map(|span| span.scope().skip(1).count()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use tracing::instrument::WithSubscriber; + + use super::*; + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + async fn instrumented_with_literal_target() {} + + #[tokio::test] + async fn literal_instrument_target_matches_filter_constant() { + assert_eq!(FUNCTION_TRACE_TARGET, "litellm::function_trace"); + + let trace = FunctionTrace::default(); + instrumented_with_literal_target() + .with_subscriber(trace.dispatcher()) + .await; + + let events = trace.events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].id, 0); + assert_eq!(events[0].parent_id, None); + assert_eq!(events[0].function, "instrumented_with_literal_target"); + assert_eq!(events[0].module_path, Some(module_path!())); + assert_eq!(events[0].file, Some(file!())); + assert!(events[0].line.is_some()); + } +} diff --git a/litellm-rust/crates/core/src/ocr/transformation.rs b/litellm-rust/crates/core/src/ocr/transformation.rs index ad484c8f968..62299faf9ed 100644 --- a/litellm-rust/crates/core/src/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/ocr/transformation.rs @@ -51,6 +51,15 @@ pub trait OcrProviderConfig: Sync { response_json: Value, ) -> Result; + fn transform_ocr_response_with_params( + &self, + model: &str, + response_json: Value, + _optional_params: &Map, + ) -> Result { + self.transform_ocr_response(model, response_json) + } + fn complete_url( &self, api_base: Option<&str>, diff --git a/litellm-rust/crates/core/src/ocr/types.rs b/litellm-rust/crates/core/src/ocr/types.rs index 1a72b8f1d66..71cdb232a87 100644 --- a/litellm-rust/crates/core/src/ocr/types.rs +++ b/litellm-rust/crates/core/src/ocr/types.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct OcrRequestData { @@ -14,16 +14,25 @@ pub struct OcrResponseData { pub document_annotation: Option, pub usage_info: Option, pub object: String, + pub extra_fields: Map, + pub provider_native_response: Option, } impl OcrResponseData { pub fn into_json(self) -> Value { - serde_json::json!({ + let mut response = serde_json::json!({ "pages": self.pages, "model": self.model, "document_annotation": self.document_annotation, "usage_info": self.usage_info, "object": self.object, - }) + }); + if let Value::Object(object) = &mut response { + object.extend(self.extra_fields); + if let Some(native_response) = self.provider_native_response { + object.insert("provider_native_response".to_string(), native_response); + } + } + response } } diff --git a/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs b/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs index 641a019476e..d15c032f0bc 100644 --- a/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs @@ -14,7 +14,8 @@ const AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT_ENV: &str = "AZURE_DOCUMENT_INTELLIGE const AZURE_DOCUMENT_INTELLIGENCE_API_VERSION: &str = "2024-11-30"; const AZURE_DOCUMENT_INTELLIGENCE_DEFAULT_DPI: i64 = 96; -const AZURE_DOCUMENT_INTELLIGENCE_SUPPORTED_OCR_PARAMS: &[&str] = &["pages", "features"]; +const AZURE_DOCUMENT_INTELLIGENCE_SUPPORTED_OCR_PARAMS: &[&str] = + &["pages", "features", "req_format"]; pub struct AzureAiOcrConfig; pub struct AzureDocumentIntelligenceOcrConfig; @@ -98,9 +99,76 @@ pub fn resolve_document_intelligence_endpoint( ) } -fn encode_model_id(model: &str) -> String { +fn prepend_auth_header( + headers: Vec<(String, String)>, + name: &str, + value: String, +) -> Vec<(String, String)> { + std::iter::once((name.to_string(), value)) + .chain(headers) + .collect() +} + +pub fn validate_azure_ai_environment( + headers: Vec<(String, String)>, + api_key: Option<&str>, + azure_ad_token: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> Result, Error> { + if crate::http_utils::has_header(&headers, "Authorization") + || crate::http_utils::has_header(&headers, "Api-Key") + { + return Ok(headers); + } + if let Ok(api_key) = resolve_azure_ai_api_key(api_key, env_lookup) { + return Ok(prepend_auth_header(headers, "Api-Key", api_key)); + } + non_empty(azure_ad_token) + .map(|token| prepend_auth_header(headers, "Authorization", format!("Bearer {token}"))) + .ok_or_else(|| { + Error::Auth( + "Missing Azure AI credentials - set AZURE_AI_API_KEY or provide azure_ad_token" + .to_string(), + ) + }) +} + +pub fn validate_document_intelligence_environment( + headers: Vec<(String, String)>, + api_key: Option<&str>, + azure_ad_token: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> Result, Error> { + if crate::http_utils::has_header(&headers, "Authorization") + || crate::http_utils::has_header(&headers, "Ocp-Apim-Subscription-Key") + { + return Ok(headers); + } + if let Ok(api_key) = resolve_document_intelligence_api_key(api_key, env_lookup) { + return Ok(prepend_auth_header( + headers, + "Ocp-Apim-Subscription-Key", + api_key, + )); + } + non_empty(azure_ad_token) + .map(|token| prepend_auth_header(headers, "Authorization", format!("Bearer {token}"))) + .ok_or_else(|| { + Error::Auth( + "Missing Azure Document Intelligence credentials - set AZURE_DOCUMENT_INTELLIGENCE_API_KEY or provide azure_ad_token" + .to_string(), + ) + }) +} + +fn encode_model_id(model: &str) -> Result { let model_id = model.rsplit('/').next().unwrap_or(model); - model_id + if matches!(model_id, "." | "..") { + return Err(Error::InvalidRequest( + "model_id cannot be a dot path segment".to_string(), + )); + } + Ok(model_id .bytes() .flat_map(|byte| match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { @@ -108,7 +176,7 @@ fn encode_model_id(model: &str) -> String { } _ => format!("%{byte:02X}").chars().collect(), }) - .collect() + .collect()) } fn pages_token_is_valid(token: &str) -> bool { @@ -147,6 +215,11 @@ fn normalize_pages_param(pages: &Value) -> Result, Error> { if values.is_empty() { return Ok(None); } + if values.iter().any(Value::is_boolean) { + return Err(Error::InvalidRequest( + "`pages` must be integers, not booleans".to_string(), + )); + } if values.iter().all(Value::is_i64) { let mut pages = BTreeSet::new(); for value in values { @@ -232,6 +305,38 @@ fn normalize_features_param(features: &Value) -> Result, Error> { } } +fn normalize_req_format(req_format: &Value) -> Result { + match req_format.as_str() { + Some(value @ ("native" | "litellm")) => Ok(value.to_string()), + _ => Err(Error::InvalidRequest(format!( + "Invalid `req_format` for Azure Document Intelligence: {req_format:?}. Expected 'native' or 'litellm'." + ))), + } +} + +pub fn map_document_intelligence_ocr_params( + non_default_params: &Map, +) -> Result, Error> { + let mut mapped = Map::new(); + if let Some(pages) = non_default_params.get("pages") + && let Some(normalized) = normalize_pages_param(pages)? + { + mapped.insert("pages".to_string(), Value::String(normalized)); + } + if let Some(features) = non_default_params.get("features") + && let Some(normalized) = normalize_features_param(features)? + { + mapped.insert("features".to_string(), Value::String(normalized)); + } + if let Some(req_format) = non_default_params.get("req_format") { + mapped.insert( + "req_format".to_string(), + Value::String(normalize_req_format(req_format)?), + ); + } + Ok(mapped) +} + pub fn complete_document_intelligence_url( api_base: Option<&str>, model: &str, @@ -242,7 +347,7 @@ pub fn complete_document_intelligence_url( let mut url = format!( "{}/documentintelligence/documentModels/{}:analyze?api-version={}", endpoint.trim_end_matches('/'), - encode_model_id(model), + encode_model_id(model)?, AZURE_DOCUMENT_INTELLIGENCE_API_VERSION ); @@ -260,6 +365,10 @@ pub fn complete_document_intelligence_url( url.push_str(&normalized); } + if let Some(req_format) = optional_params.get("req_format") { + normalize_req_format(req_format)?; + } + Ok(url) } @@ -327,11 +436,78 @@ fn page_dimensions(page: &Map) -> Value { }) } +fn transform_document_intelligence_response( + model: &str, + response_json: Value, + preserve_native_response: bool, +) -> Result { + let response = response_json + .as_object() + .ok_or_else(|| Error::InvalidType { + expected: "object", + actual: json_type_name(&response_json), + })?; + let status = response + .get("status") + .and_then(Value::as_str) + .ok_or(Error::MissingField("status"))?; + if status != "succeeded" { + return Err(Error::InvalidResponse(format!( + "Azure Document Intelligence analysis failed with status: {status}" + ))); + } + + let analyze_result = response.get("analyzeResult").and_then(Value::as_object); + let azure_pages = analyze_result + .and_then(|result| result.get("pages")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let pages = azure_pages + .iter() + .filter_map(Value::as_object) + .map(|page| { + let page_number = page.get("pageNumber").and_then(Value::as_i64).unwrap_or(1); + json!({ + "index": page_number - 1, + "markdown": page_markdown(page), + "dimensions": page_dimensions(page), + }) + }) + .collect::>(); + let extra_fields = ["content", "tables", "keyValuePairs"] + .into_iter() + .map(|field| { + ( + field.to_string(), + analyze_result + .and_then(|result| result.get(field)) + .cloned() + .unwrap_or(Value::Null), + ) + }) + .collect(); + + Ok(OcrResponseData { + usage_info: Some(json!({ + "pages_processed": pages.len(), + "doc_size_bytes": null, + })), + pages, + model: model.to_string(), + document_annotation: None, + object: "ocr".to_string(), + extra_fields, + provider_native_response: preserve_native_response.then_some(response_json), + }) +} + impl OcrProviderConfig for AzureAiOcrConfig { fn supported_ocr_params(&self) -> &'static [&'static str] { MISTRAL_OCR_CONFIG.supported_ocr_params() } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_request( &self, model: &str, @@ -349,6 +525,7 @@ impl OcrProviderConfig for AzureAiOcrConfig { MISTRAL_OCR_CONFIG.transform_ocr_response(model, response_json) } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn complete_url( &self, api_base: Option<&str>, @@ -373,10 +550,25 @@ impl OcrProviderConfig for AzureAiOcrConfig { } impl OcrProviderConfig for AzureDocumentIntelligenceOcrConfig { + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn supported_ocr_params(&self) -> &'static [&'static str] { AZURE_DOCUMENT_INTELLIGENCE_SUPPORTED_OCR_PARAMS } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + fn map_ocr_params(&self, non_default_params: &Map) -> Map { + map_document_intelligence_ocr_params(non_default_params).unwrap_or_else(|_| { + non_default_params + .iter() + .filter(|(name, _)| { + AZURE_DOCUMENT_INTELLIGENCE_SUPPORTED_OCR_PARAMS.contains(&name.as_str()) + }) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() + }) + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_request( &self, _model: &str, @@ -402,59 +594,29 @@ impl OcrProviderConfig for AzureDocumentIntelligenceOcrConfig { }) } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_response( &self, model: &str, response_json: Value, ) -> Result { - let response = response_json - .as_object() - .ok_or_else(|| Error::InvalidType { - expected: "object", - actual: json_type_name(&response_json), - })?; - let status = response - .get("status") - .and_then(Value::as_str) - .ok_or(Error::MissingField("status"))?; - if status != "succeeded" { - return Err(Error::InvalidResponse(format!( - "Azure Document Intelligence analysis failed with status: {status}" - ))); - } - - let azure_pages = response - .get("analyzeResult") - .and_then(|result| result.get("pages")) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - - let pages = azure_pages - .iter() - .filter_map(Value::as_object) - .map(|page| { - let page_number = page.get("pageNumber").and_then(Value::as_i64).unwrap_or(1); - json!({ - "index": page_number - 1, - "markdown": page_markdown(page), - "dimensions": page_dimensions(page), - }) - }) - .collect::>(); - - Ok(OcrResponseData { - usage_info: Some(json!({ - "pages_processed": pages.len(), - "doc_size_bytes": null, - })), - pages, - model: model.to_string(), - document_annotation: None, - object: "ocr".to_string(), - }) + transform_document_intelligence_response(model, response_json, false) } + fn transform_ocr_response_with_params( + &self, + model: &str, + response_json: Value, + optional_params: &Map, + ) -> Result { + transform_document_intelligence_response( + model, + response_json, + optional_params.get("req_format").and_then(Value::as_str) == Some("native"), + ) + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn complete_url( &self, api_base: Option<&str>, @@ -485,6 +647,101 @@ impl OcrProviderConfig for AzureDocumentIntelligenceOcrConfig { #[cfg(test)] mod tests { use super::*; + use rstest::{fixture, rstest}; + + const ENDPOINT: &str = "https://example.cognitiveservices.azure.com"; + + #[fixture] + fn document_intelligence_config() -> AzureDocumentIntelligenceOcrConfig { + AzureDocumentIntelligenceOcrConfig + } + + fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(header_name, _)| header_name.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } + + #[fixture] + fn native_operation() -> Value { + json!({ + "status": "succeeded", + "createdDateTime": "2026-07-02T00:00:00Z", + "lastUpdatedDateTime": "2026-07-02T00:00:05Z", + "analyzeResult": { + "content": "Invoice\nInvoice No: INV-12345\nTotal: $100.00", + "pages": [{ + "pageNumber": 1, + "width": 8.5, + "height": 11, + "unit": "inch", + "angle": 0.13, + "lines": [ + {"content": "Invoice"}, + {"content": "Invoice No: INV-12345"}, + {"content": "Total: $100.00"} + ], + "words": [{"content": "Invoice", "confidence": 0.994}] + }], + "tables": [ + { + "rowCount": 2, + "columnCount": 2, + "cells": [ + {"kind": "columnHeader", "rowIndex": 0, "columnIndex": 0, "content": "Item"}, + {"kind": "columnHeader", "rowIndex": 0, "columnIndex": 1, "content": "Price"}, + {"rowIndex": 1, "columnIndex": 0, "content": "Widget"}, + {"rowIndex": 1, "columnIndex": 1, "content": "$100.00"} + ] + }, + { + "rowCount": 1, + "columnCount": 1, + "cells": [{"rowIndex": 0, "columnIndex": 0, "content": "Totals"}] + } + ], + "keyValuePairs": [ + { + "key": {"content": "Invoice No"}, + "value": {"content": "INV-12345"}, + "confidence": 0.98 + }, + { + "key": {"content": "Total"}, + "value": {"content": "$100.00"}, + "confidence": 0.95 + } + ], + "paragraphs": [{"content": "Invoice"}] + } + }) + } + + fn assert_native_fields_preserved(response: &OcrResponseData, operation: &Value) { + let analyze_result = &operation["analyzeResult"]; + + assert_eq!(response.extra_fields["content"], analyze_result["content"]); + assert_eq!(response.extra_fields["tables"], analyze_result["tables"]); + assert_eq!( + response.extra_fields["keyValuePairs"], + analyze_result["keyValuePairs"] + ); + assert_eq!(response.object, "ocr"); + assert_eq!( + response.usage_info, + Some(json!({"pages_processed": 1, "doc_size_bytes": null})) + ); + assert_eq!(response.pages[0]["index"], 0); + assert_eq!( + response.pages[0]["markdown"], + "Invoice\nInvoice No: INV-12345\nTotal: $100.00" + ); + assert_eq!( + response.pages[0]["dimensions"], + json!({"width": 816, "height": 1056, "dpi": 96}) + ); + } #[test] fn azure_ai_reuses_mistral_body_transform() { @@ -568,6 +825,11 @@ mod tests { #[test] fn document_intelligence_url_omits_empty_feature_list() { let params = serde_json::Map::from_iter([("features".to_string(), json!([]))]); + assert!( + map_document_intelligence_ocr_params(¶ms) + .expect("empty features map") + .is_empty() + ); let url = complete_document_intelligence_url( Some("https://example.cognitiveservices.azure.com"), "prebuilt-layout", @@ -582,40 +844,43 @@ mod tests { ); } - #[test] - fn document_intelligence_url_rejects_invalid_features() { - for features in [ - json!("keyValuePairs&pages=9"), - json!(""), - json!(["keyValuePairs", 1]), - json!({"feature": "keyValuePairs"}), - ] { - let params = serde_json::Map::from_iter([("features".to_string(), features.clone())]); - let error = complete_document_intelligence_url( - Some("https://example.cognitiveservices.azure.com"), - "prebuilt-layout", - ¶ms, - &|_| None, - ) - .expect_err("invalid features must fail"); + #[rstest] + #[case::query_injection(json!("keyValuePairs&pages=9"))] + #[case::spaces(json!("key value pairs"))] + #[case::empty_string(json!(""))] + #[case::integer_list(json!([1, 2]))] + #[case::nested_list(json!([["keyValuePairs"]]))] + #[case::object(json!({"feature": "keyValuePairs"}))] + #[case::number(json!(5))] + fn document_intelligence_mapping_rejects_invalid_features(#[case] features: Value) { + let params = serde_json::Map::from_iter([("features".to_string(), features)]); + let error = + map_document_intelligence_ocr_params(¶ms).expect_err("invalid features must fail"); - assert!( - matches!(error, Error::InvalidRequest(message) if message.contains("Invalid `features`")), - "features={features:?}" - ); - } + assert!(matches!( + error, + Error::InvalidRequest(message) if message.contains("Invalid `features`") + )); } - #[test] - fn document_intelligence_maps_features() { + #[rstest] + #[case::single_list(json!(["keyValuePairs"]), "keyValuePairs")] + #[case::multiple_list( + json!(["keyValuePairs", "languages"]), + "keyValuePairs,languages" + )] + #[case::single_string(json!("keyValuePairs"), "keyValuePairs")] + #[case::comma_separated(json!("keyValuePairs,languages"), "keyValuePairs,languages")] + #[case::spaces(json!("keyValuePairs, languages"), "keyValuePairs,languages")] + fn document_intelligence_maps_features(#[case] features: Value, #[case] expected: &str) { let params = Map::from_iter([ - ("features".to_string(), json!(["keyValuePairs"])), + ("features".to_string(), features), ("unsupported".to_string(), json!(true)), ]); assert_eq!( AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG.map_ocr_params(¶ms), - Map::from_iter([("features".to_string(), json!(["keyValuePairs"]))]) + Map::from_iter([("features".to_string(), json!(expected))]) ); } @@ -633,32 +898,484 @@ mod tests { assert_eq!(body, json!({"base64Source": "abc123"})); } + #[rstest] + fn document_intelligence_response_normalizes_pages(native_operation: Value) { + let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response("prebuilt-layout", native_operation.clone()) + .expect("response transforms"); + + assert_native_fields_preserved(&response, &native_operation); + } + #[test] - fn document_intelligence_response_normalizes_pages() { + fn azure_document_intelligence_model_id_is_encoded() { + let url = complete_document_intelligence_url( + Some(ENDPOINT), + "prebuilt-layout?x=1#frag", + &Map::new(), + &|_| None, + ) + .expect("url builds"); + + assert_eq!( + url, + "https://example.cognitiveservices.azure.com/documentintelligence/documentModels/prebuilt-layout%3Fx%3D1%23frag:analyze?api-version=2024-11-30" + ); + } + + #[test] + fn azure_document_intelligence_dot_segment_model_id_is_rejected() { + let error = complete_document_intelligence_url( + Some(ENDPOINT), + "azure_ai/doc-intelligence/..", + &Map::new(), + &|_| None, + ) + .expect_err("dot segment must fail"); + + assert_eq!( + error, + Error::InvalidRequest("model_id cannot be a dot path segment".to_string()) + ); + } + + #[rstest] + fn document_intelligence_async_response_preserves_normalized_fields(native_operation: Value) { let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG .transform_ocr_response( - "prebuilt-layout", + "azure_ai/doc-intelligence/prebuilt-layout", + native_operation.clone(), + ) + .expect("response transforms"); + + assert_native_fields_preserved(&response, &native_operation); + } + + #[test] + fn document_intelligence_response_tolerates_missing_native_fields() { + let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response( + "azure_ai/doc-intelligence/prebuilt-read", json!({ "status": "succeeded", "analyzeResult": { "pages": [{ - "pageNumber": 2, + "pageNumber": 1, "width": 8.5, "height": 11, "unit": "inch", - "lines": [{"content": "hello"}, {"content": "world"}] + "lines": [{"content": "hello"}] }] } }), ) + .expect("missing optional fields are allowed"); + + assert_eq!(response.pages[0]["markdown"], "hello"); + assert_eq!(response.extra_fields["content"], Value::Null); + assert_eq!(response.extra_fields["tables"], Value::Null); + assert_eq!(response.extra_fields["keyValuePairs"], Value::Null); + } + + #[test] + fn document_intelligence_non_succeeded_status_is_rejected() { + let error = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response( + "azure_ai/doc-intelligence/prebuilt-layout", + json!({"status": "failed"}), + ) + .expect_err("failed status must fail"); + + assert_eq!( + error, + Error::InvalidResponse( + "Azure Document Intelligence analysis failed with status: failed".to_string() + ) + ); + } + + #[test] + fn document_intelligence_supported_params_include_features() { + assert_eq!( + AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG.supported_ocr_params(), + &["pages", "features", "req_format"] + ); + } + + #[rstest] + fn document_intelligence_native_format_carries_raw_operation(native_operation: Value) { + let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response_with_params( + "azure_ai/doc-intelligence/prebuilt-layout", + native_operation.clone(), + &Map::from_iter([("req_format".to_string(), json!("native"))]), + ) + .expect("native response transforms"); + + assert_eq!( + response.provider_native_response, + Some(native_operation.clone()) + ); + assert_native_fields_preserved(&response, &native_operation); + } + + #[rstest] + fn document_intelligence_async_native_format_carries_raw_operation(native_operation: Value) { + let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response_with_params( + "azure_ai/doc-intelligence/prebuilt-layout", + native_operation.clone(), + &Map::from_iter([("req_format".to_string(), json!("native"))]), + ) + .expect("native response transforms"); + + assert_eq!( + response.provider_native_response, + Some(native_operation.clone()) + ); + assert_native_fields_preserved(&response, &native_operation); + } + + #[rstest] + #[case::default(Map::new())] + #[case::litellm(Map::from_iter([("req_format".to_string(), json!("litellm"))]))] + fn document_intelligence_default_format_omits_raw_operation( + #[case] optional_params: Map, + native_operation: Value, + ) { + let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_response_with_params( + "azure_ai/doc-intelligence/prebuilt-layout", + native_operation.clone(), + &optional_params, + ) .expect("response transforms"); - assert_eq!(response.pages[0]["index"], 1); - assert_eq!(response.pages[0]["markdown"], "hello\nworld"); - assert_eq!(response.pages[0]["dimensions"]["width"], 816); + assert_eq!(response.provider_native_response, None); + assert_native_fields_preserved(&response, &native_operation); + } + + #[rstest] + #[case::native("native")] + #[case::litellm("litellm")] + fn document_intelligence_maps_req_format(#[case] req_format: &str) { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "req_format".to_string(), + json!(req_format), + )])) + .expect("req_format maps"); + assert_eq!( - response.usage_info, - Some(json!({"pages_processed": 1, "doc_size_bytes": null})) + mapped, + Map::from_iter([("req_format".to_string(), json!(req_format))]) + ); + } + + #[test] + fn document_intelligence_rejects_unknown_req_format() { + let error = map_document_intelligence_ocr_params(&Map::from_iter([( + "req_format".to_string(), + json!("azure"), + )])) + .expect_err("unknown req_format must fail"); + + assert!( + matches!(error, Error::InvalidRequest(message) if message.contains("Invalid `req_format`")) + ); + } + + #[test] + fn document_intelligence_url_omits_req_format() { + let url = complete_document_intelligence_url( + Some(ENDPOINT), + "prebuilt-layout", + &Map::from_iter([("req_format".to_string(), json!("native"))]), + &|_| None, + ) + .expect("url builds"); + + assert!(!url.contains("req_format")); + } + + #[test] + fn document_intelligence_validate_environment_uses_subscription_key() { + let headers = + validate_document_intelligence_environment(Vec::new(), Some("my-key"), None, &|_| None) + .expect("api key authenticates"); + + assert_eq!( + header_value(&headers, "Ocp-Apim-Subscription-Key"), + Some("my-key") + ); + } + + #[test] + fn document_intelligence_validate_environment_falls_back_to_entra_token() { + let headers = validate_document_intelligence_environment( + Vec::new(), + None, + Some("entra-token"), + &|_| None, + ) + .expect("Entra token authenticates"); + + assert_eq!( + header_value(&headers, "Authorization"), + Some("Bearer entra-token") + ); + assert_eq!(header_value(&headers, "Ocp-Apim-Subscription-Key"), None); + } + + #[test] + fn document_intelligence_supported_params_include_pages_features_and_req_format() { + assert_eq!( + AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG.supported_ocr_params(), + &["pages", "features", "req_format"] + ); + } + + #[test] + fn document_intelligence_maps_zero_based_page_list() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([0, 1, 2]), + )])) + .expect("pages map"); + + assert_eq!( + mapped, + Map::from_iter([("pages".to_string(), json!("1,2,3"))]) + ); + } + + #[test] + fn document_intelligence_page_mapping_dedupes_and_sorts() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([2, 0, 0, 1]), + )])) + .expect("pages map"); + + assert_eq!(mapped["pages"], "1,2,3"); + } + + #[test] + fn document_intelligence_page_mapping_omits_empty_list() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([]), + )])) + .expect("empty pages map"); + + assert!(mapped.is_empty()); + } + + #[test] + fn document_intelligence_page_mapping_accepts_native_range() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!("3-9"), + )])) + .expect("range maps"); + + assert_eq!(mapped["pages"], "3-9"); + } + + #[test] + fn document_intelligence_page_mapping_strips_spaces() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!("1-3, 5"), + )])) + .expect("range maps"); + + assert_eq!(mapped["pages"], "1-3,5"); + } + + #[test] + fn document_intelligence_page_mapping_accepts_string_tokens() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!(["1", "3-5"]), + )])) + .expect("tokens map"); + + assert_eq!(mapped["pages"], "1,3-5"); + } + + #[test] + fn document_intelligence_page_mapping_rejects_invalid_string() { + let error = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!("a,b"), + )])) + .expect_err("invalid pages must fail"); + + assert!( + matches!(error, Error::InvalidRequest(message) if message.contains("Invalid `pages` string")) + ); + } + + #[test] + fn document_intelligence_page_mapping_rejects_negative_index() { + let error = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([-1]), + )])) + .expect_err("negative pages must fail"); + + assert!( + matches!(error, Error::InvalidRequest(message) if message.contains("must be >= 0")) + ); + } + + #[test] + fn document_intelligence_page_mapping_rejects_bool_list() { + let error = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([true, false]), + )])) + .expect_err("boolean pages must fail"); + + assert!( + matches!(error, Error::InvalidRequest(message) if message.contains("integers, not booleans")) + ); + } + + #[test] + fn document_intelligence_page_mapping_rejects_unsupported_type() { + let error = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!(5), + )])) + .expect_err("unsupported pages must fail"); + + assert!( + matches!(error, Error::InvalidRequest(message) if message.contains("Mistral-style")) + ); + } + + #[test] + fn document_intelligence_url_appends_pages_query() { + let url = complete_document_intelligence_url( + Some("https://example.cognitiveservices.azure.com/"), + "azure_ai/doc-intelligence/prebuilt-layout", + &Map::from_iter([("pages".to_string(), json!("1-3,5"))]), + &|_| None, + ) + .expect("url builds"); + + assert!(url.contains("api-version=2024-11-30")); + assert!(url.contains("pages=1-3,5")); + assert!(url.contains("/documentintelligence/documentModels/prebuilt-layout:analyze")); + } + + #[test] + fn document_intelligence_url_has_no_pages_when_params_are_empty() { + let url = complete_document_intelligence_url( + Some(ENDPOINT), + "prebuilt-layout", + &Map::new(), + &|_| None, + ) + .expect("url builds"); + + assert!(!url.contains("pages=")); + } + + #[rstest] + fn document_intelligence_request_keeps_pages_out_of_body( + document_intelligence_config: AzureDocumentIntelligenceOcrConfig, + ) { + let request = document_intelligence_config + .transform_ocr_request( + "prebuilt-layout", + json!({"type": "document_url", "document_url": "https://example.com/x.pdf"}), + Map::from_iter([("pages".to_string(), json!("1,2,3"))]), + ) + .expect("request transforms"); + + assert_eq!( + request.data, + json!({"urlSource": "https://example.com/x.pdf"}) + ); + } + + #[test] + fn document_intelligence_mistral_pages_flow_to_query_only() { + let mapped = map_document_intelligence_ocr_params(&Map::from_iter([( + "pages".to_string(), + json!([2, 3, 4, 5, 6, 7, 8]), + )])) + .expect("pages map"); + let url = + complete_document_intelligence_url(Some(ENDPOINT), "prebuilt-layout", &mapped, &|_| { + None + }) + .expect("url builds"); + let request = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG + .transform_ocr_request( + "prebuilt-layout", + json!({"type": "document_url", "document_url": "https://example.com/x.pdf"}), + mapped, + ) + .expect("request transforms"); + + assert!(url.contains("pages=3,4,5,6,7,8,9")); + assert_eq!( + request.data, + json!({"urlSource": "https://example.com/x.pdf"}) + ); + } + + #[test] + fn document_intelligence_endpoint_ignores_generic_azure_ai_base() { + let resolved = resolve_document_intelligence_endpoint(None, &|name| match name { + AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT_ENV => Some(ENDPOINT.to_string()), + AZURE_AI_API_BASE_ENV => Some("https://generic.example.com".to_string()), + _ => None, + }) + .expect("endpoint resolves"); + + assert_eq!(resolved, ENDPOINT); + } + + #[test] + fn document_intelligence_endpoint_honors_explicit_api_base() { + let resolved = resolve_document_intelligence_endpoint( + Some("https://my-di.cognitiveservices.azure.com"), + &|name| match name { + AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT_ENV => Some(ENDPOINT.to_string()), + AZURE_AI_API_BASE_ENV => Some("https://generic.example.com".to_string()), + _ => None, + }, + ) + .expect("endpoint resolves"); + + assert_eq!(resolved, "https://my-di.cognitiveservices.azure.com"); + } + + #[test] + fn azure_ai_mistral_ocr_uses_generic_api_base() { + let resolved = resolve_azure_ai_api_base(None, &|name| match name { + AZURE_AI_API_BASE_ENV => Some("https://generic-azure-ai.example.com".to_string()), + AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT_ENV => Some(ENDPOINT.to_string()), + _ => None, + }) + .expect("api base resolves"); + + assert_eq!(resolved, "https://generic-azure-ai.example.com"); + } + + #[test] + fn azure_ai_ocr_authenticates_with_entra_token() { + let headers = + validate_azure_ai_environment(Vec::new(), None, Some("entra-token"), &|_| None) + .expect("Entra token authenticates"); + + assert_eq!( + header_value(&headers, "Authorization"), + Some("Bearer entra-token") ); } } diff --git a/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs b/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs index 0125886aac1..11e8fe7db18 100644 --- a/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs @@ -134,6 +134,8 @@ impl OcrProviderConfig for MistralOcrConfig { document_annotation, usage_info, object: "ocr".to_string(), + extra_fields: Map::new(), + provider_native_response: None, }) } diff --git a/litellm-rust/crates/core/src/providers/mod.rs b/litellm-rust/crates/core/src/providers/mod.rs index 805600d6dbe..c0c2c69831b 100644 --- a/litellm-rust/crates/core/src/providers/mod.rs +++ b/litellm-rust/crates/core/src/providers/mod.rs @@ -4,4 +4,5 @@ pub mod azure_ai; pub mod bedrock; pub mod mistral; pub mod openai; +pub mod reducto; pub mod vertex_ai; diff --git a/litellm-rust/crates/core/src/providers/reducto/mod.rs b/litellm-rust/crates/core/src/providers/reducto/mod.rs new file mode 100644 index 00000000000..3621ff6a2fd --- /dev/null +++ b/litellm-rust/crates/core/src/providers/reducto/mod.rs @@ -0,0 +1 @@ +pub mod ocr; diff --git a/litellm-rust/crates/core/src/providers/reducto/ocr/mod.rs b/litellm-rust/crates/core/src/providers/reducto/ocr/mod.rs new file mode 100644 index 00000000000..8acee8f770c --- /dev/null +++ b/litellm-rust/crates/core/src/providers/reducto/ocr/mod.rs @@ -0,0 +1,4 @@ +pub mod transformation; + +#[cfg(test)] +mod tests; diff --git a/litellm-rust/crates/core/src/providers/reducto/ocr/tests.rs b/litellm-rust/crates/core/src/providers/reducto/ocr/tests.rs new file mode 100644 index 00000000000..2b66d058b5d --- /dev/null +++ b/litellm-rust/crates/core/src/providers/reducto/ocr/tests.rs @@ -0,0 +1,202 @@ +use rstest::{fixture, rstest}; +use serde_json::{Value, json}; + +use super::transformation::*; +use crate::ocr::transformation::OcrProviderConfig; + +#[fixture] +fn parse_response() -> Value { + json!({ + "job_id": "job_123", + "usage": {"num_pages": 3, "credits": 3}, + "result": { + "chunks": [ + { + "content": "Page 1 block A", + "blocks": [{ + "content": "Page 1 block A", + "bbox": {"page": 1}, + "kind": "text", + }], + }, + { + "content": "Page 2 block A", + "blocks": [{ + "content": "Page 2 block A", + "bbox": {"page": 2}, + "kind": "table", + }], + }, + { + "content": "Page 1 block B", + "blocks": [{ + "content": "Page 1 block B", + "bbox": {"page": 1}, + "kind": "text", + }], + }, + { + "content": "Page 3 block A", + "blocks": [{ + "content": "Page 3 block A", + "bbox": {"page": 3}, + "kind": "figure", + }], + }, + ], + }, + }) +} + +#[rstest] +fn test_parse_v3_file_upload_and_response_mapping(parse_response: Value) { + let source = classify_document_source("data:application/pdf;base64,JVBERi0xLjQ=") + .expect("PDF data URI should be valid"); + let upload = build_upload_request( + source, + "Bearer test-key", + Some("https://platform.reducto.ai"), + ) + .expect("data URI should require upload"); + assert_eq!(upload.url, "https://platform.reducto.ai/upload"); + assert_eq!(upload.authorization, "Bearer test-key"); + assert_eq!(upload.file_name, "document"); + assert_eq!(upload.mime_type, "application/pdf"); + assert_eq!(upload.bytes, b"%PDF-1.4"); + + let optional_params = json!({ + "formatting": {"table_output_format": "html"}, + "retrieval": {"chunk_mode": "section"}, + "settings": {"ocr_system": "standard"}, + }) + .as_object() + .expect("params should be an object") + .clone(); + let request = build_parse_v3_request("reducto://uploaded.pdf", optional_params); + assert_eq!( + request.data, + json!({ + "input": "reducto://uploaded.pdf", + "formatting": {"table_output_format": "html"}, + "retrieval": {"chunk_mode": "section"}, + "settings": {"ocr_system": "standard"}, + }) + ); + + let transformed = transform_reducto_response("parse-v3", parse_response.clone()) + .expect("response should transform"); + assert_eq!( + transformed.usage_info, + Some(json!({"pages_processed": 3, "credits": 3})) + ); + assert_eq!(transformed.pages.len(), 3); + assert_eq!( + transformed.pages[0], + json!({ + "index": 0, + "markdown": "Page 1 block A\n\nPage 1 block B", + "blocks": [ + {"content": "Page 1 block A", "bbox": {"page": 1}, "kind": "text"}, + {"content": "Page 1 block B", "bbox": {"page": 1}, "kind": "text"}, + ], + }) + ); + assert_eq!(transformed.pages[1]["markdown"], "Page 2 block A"); + assert_eq!(transformed.pages[2]["markdown"], "Page 3 block A"); + assert_eq!(transformed.provider_native_response, Some(parse_response)); +} + +#[rstest] +fn test_parse_v3_reducto_id_passthrough_skips_upload(parse_response: Value) { + let document = json!({ + "type": "document_url", + "document_url": "reducto://already-uploaded.pdf", + }); + let source = extract_document_source(&document).expect("Reducto ID should be valid"); + assert!(build_upload_request(source.clone(), "Bearer test-key", None).is_none()); + assert_eq!( + source, + ReductoDocumentSource::FileId("reducto://already-uploaded.pdf".to_string()) + ); + + let request = REDUCTO_PARSE_V3_CONFIG + .transform_ocr_request( + "parse-v3", + document, + json!({"retrieval": {"chunk_mode": "section"}}) + .as_object() + .expect("params should be object") + .clone(), + ) + .expect("direct ID should transform"); + assert_eq!(request.data["input"], "reducto://already-uploaded.pdf"); + assert_eq!(request.data["retrieval"]["chunk_mode"], "section"); + + let response = REDUCTO_PARSE_V3_CONFIG + .transform_ocr_response("parse-v3", parse_response) + .expect("response should transform"); + assert!( + response.pages[0]["markdown"] + .as_str() + .expect("markdown should be string") + .starts_with("Page 1 block A") + ); +} + +#[rstest] +fn test_parse_legacy_wraps_enhance_under_options() { + let request = build_parse_legacy_request( + "reducto://legacy.pdf", + json!({"enhance": {"agentic": [{"type": "table"}]}}) + .as_object() + .expect("params should be object"), + ); + assert_eq!( + request.data, + json!({ + "document_url": "reducto://legacy.pdf", + "options": {"enhance": {"agentic": [{"type": "table"}]}}, + }) + ); +} + +#[rstest] +fn test_parse_v3_image_data_uri_upload_uses_image_mime() { + let source = classify_document_source("data:image/png;base64,iVBORw0KGgo=") + .expect("PNG data URI should be valid"); + let upload = build_upload_request( + source, + "Bearer programmatic-key", + Some("https://custom.reducto.test/"), + ) + .expect("data URI should require upload"); + assert_eq!(upload.url, "https://custom.reducto.test/upload"); + assert_eq!(upload.authorization, "Bearer programmatic-key"); + assert_eq!(upload.mime_type, "image/png"); + assert_eq!(upload.bytes, b"\x89PNG\r\n\x1a\n"); +} + +#[rstest] +#[case::http("http://example.com/document.pdf")] +#[case::https("https://example.com/document.pdf")] +fn test_parse_v3_rejects_plain_http_urls(#[case] source: &str) { + let error = classify_document_source(source).expect_err("plain URL should be rejected"); + assert!(error.to_string().contains("upload the file first")); +} + +#[rstest] +fn test_parse_v3_uses_programmatic_api_key_over_env() { + let key = resolve_api_key(Some("passed-key"), &|_| Some("env-reducto-key".to_string())) + .expect("explicit key should resolve"); + assert_eq!(key, "passed-key"); + + let headers = REDUCTO_PARSE_V3_CONFIG + .validate_environment(Vec::new(), Some("passed-key"), &|_| { + Some("env-reducto-key".to_string()) + }) + .expect("headers should validate"); + assert_eq!( + headers, + vec![("Authorization".to_string(), "Bearer passed-key".to_string())] + ); +} diff --git a/litellm-rust/crates/core/src/providers/reducto/ocr/transformation.rs b/litellm-rust/crates/core/src/providers/reducto/ocr/transformation.rs new file mode 100644 index 00000000000..b8507541e18 --- /dev/null +++ b/litellm-rust/crates/core/src/providers/reducto/ocr/transformation.rs @@ -0,0 +1,407 @@ +use std::collections::BTreeMap; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde_json::{Map, Value, json}; + +use crate::error::{Error, json_type_name}; +use crate::ocr::transformation::OcrProviderConfig; +use crate::ocr::types::{OcrRequestData, OcrResponseData}; + +pub const REDUCTO_API_BASE: &str = "https://platform.reducto.ai"; +pub const REDUCTO_API_KEY_ENV: &str = "REDUCTO_API_KEY"; +pub const REDUCTO_ID_PREFIX: &str = "reducto://"; + +const PARSE_V3_SUPPORTED_OCR_PARAMS: &[&str] = &["formatting", "retrieval", "settings"]; +const PARSE_LEGACY_SUPPORTED_OCR_PARAMS: &[&str] = &["enhance"]; +const MISSING_KEY_MESSAGE: &str = "Missing REDUCTO_API_KEY - set it in the environment or pass api_key to litellm.ocr()/litellm.aocr()"; +const DATA_URI_UPLOAD_REQUIRED: &str = + "Reducto data URI upload must complete before OCR request transformation"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReductoDocumentSource { + FileId(String), + Upload { bytes: Vec, mime_type: String }, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ReductoUploadRequest { + pub url: String, + pub authorization: String, + pub file_name: &'static str, + pub bytes: Vec, + pub mime_type: String, +} + +pub struct ReductoParseV3Config; +pub struct ReductoParseLegacyConfig; + +pub const REDUCTO_PARSE_V3_CONFIG: ReductoParseV3Config = ReductoParseV3Config; +pub const REDUCTO_PARSE_LEGACY_CONFIG: ReductoParseLegacyConfig = ReductoParseLegacyConfig; + +pub fn config_for_model(model: &str) -> Option<&'static dyn OcrProviderConfig> { + match model { + "parse-v3" => Some(&REDUCTO_PARSE_V3_CONFIG), + "parse-legacy" => Some(&REDUCTO_PARSE_LEGACY_CONFIG), + _ => None, + } +} + +pub fn normalize_api_base(api_base: Option<&str>) -> String { + api_base + .map(str::trim) + .filter(|base| !base.is_empty()) + .unwrap_or(REDUCTO_API_BASE) + .trim_end_matches('/') + .to_string() +} + +pub fn parse_url(api_base: Option<&str>) -> String { + format!("{}/parse", normalize_api_base(api_base)) +} + +pub fn upload_url(api_base: Option<&str>) -> String { + format!("{}/upload", normalize_api_base(api_base)) +} + +pub fn resolve_api_key( + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> Result { + api_key + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string) + .or_else(|| { + env_lookup(REDUCTO_API_KEY_ENV) + .map(|key| key.trim().to_string()) + .filter(|key| !key.is_empty()) + }) + .ok_or_else(|| Error::Auth(MISSING_KEY_MESSAGE.to_string())) +} + +pub fn extract_document_source(document: &Value) -> Result { + let document = document.as_object().ok_or_else(|| Error::InvalidType { + expected: "object", + actual: json_type_name(document), + })?; + let source = document + .get("document_url") + .and_then(Value::as_str) + .filter(|source| !source.is_empty()) + .or_else(|| document.get("image_url").and_then(Value::as_str)) + .ok_or_else(|| { + Error::InvalidRequest( + "Reducto expected OCR preprocessing to produce document_url or image_url" + .to_string(), + ) + })?; + classify_document_source(source) +} + +pub fn classify_document_source(source: &str) -> Result { + if source.starts_with(REDUCTO_ID_PREFIX) { + return Ok(ReductoDocumentSource::FileId(source.to_string())); + } + if source.starts_with("http://") || source.starts_with("https://") { + return Err(Error::InvalidRequest( + "Reducto requires type='file' (auto-uploaded) or a reducto:// id. Plain http(s) URLs are not supported; upload the file first." + .to_string(), + )); + } + if !source.starts_with("data:") { + return Err(Error::InvalidRequest( + "Reducto requires a reducto:// id or a base64 data URI after OCR preprocessing." + .to_string(), + )); + } + + let (header, encoded) = source + .split_once(',') + .ok_or_else(|| Error::InvalidRequest("Invalid Reducto data URI provided.".to_string()))?; + if !header.split(';').any(|part| part == "base64") { + return Err(Error::InvalidRequest( + "Reducto only supports base64-encoded data URIs.".to_string(), + )); + } + + let mime_type = header + .strip_prefix("data:") + .and_then(|header| header.split(';').next()) + .filter(|mime| !mime.is_empty()) + .unwrap_or("application/octet-stream") + .to_string(); + let bytes = BASE64_STANDARD.decode(encoded).map_err(|_| { + Error::InvalidRequest("Invalid Reducto base64 payload provided.".to_string()) + })?; + + Ok(ReductoDocumentSource::Upload { bytes, mime_type }) +} + +pub fn build_upload_request( + source: ReductoDocumentSource, + authorization: &str, + api_base: Option<&str>, +) -> Option { + let ReductoDocumentSource::Upload { bytes, mime_type } = source else { + return None; + }; + + Some(ReductoUploadRequest { + url: upload_url(api_base), + authorization: authorization.to_string(), + file_name: "document", + bytes, + mime_type, + }) +} + +pub fn extract_upload_file_id(response_json: &Value) -> Result<&str, Error> { + response_json + .as_object() + .and_then(|response| response.get("file_id")) + .and_then(Value::as_str) + .filter(|file_id| !file_id.is_empty()) + .ok_or_else(|| { + Error::InvalidResponse(format!( + "Reducto /upload returned 200 without a file_id; got payload={response_json}" + )) + }) +} + +pub fn build_parse_v3_request( + file_id: &str, + optional_params: Map, +) -> OcrRequestData { + let data = std::iter::once(("input".to_string(), Value::String(file_id.to_string()))) + .chain(optional_params) + .collect(); + OcrRequestData { + data: Value::Object(data), + files: None, + } +} + +pub fn build_parse_legacy_request( + file_id: &str, + optional_params: &Map, +) -> OcrRequestData { + let options = optional_params + .get("enhance") + .filter(|enhance| !enhance.is_null()) + .map(|enhance| json!({"options": {"enhance": enhance}})); + let data = match options { + Some(Value::Object(options)) => std::iter::once(( + "document_url".to_string(), + Value::String(file_id.to_string()), + )) + .chain(options) + .collect(), + _ => Map::from_iter([( + "document_url".to_string(), + Value::String(file_id.to_string()), + )]), + }; + OcrRequestData { + data: Value::Object(data), + files: None, + } +} + +fn source_file_id(document: &Value) -> Result { + match extract_document_source(document)? { + ReductoDocumentSource::FileId(file_id) => Ok(file_id), + ReductoDocumentSource::Upload { .. } => Err(Error::Unsupported(DATA_URI_UPLOAD_REQUIRED)), + } +} + +fn page_number(block: &Map) -> Option { + let page = block.get("bbox")?.as_object()?.get("page")?; + page.as_i64() + .or_else(|| page.as_u64().and_then(|page| i64::try_from(page).ok())) + .or_else(|| page.as_str().and_then(|page| page.parse().ok())) +} + +fn chunks(result: &Map) -> &[Value] { + result + .get("chunks") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() +} + +fn build_pages(result: &Map) -> Vec { + let blocks_by_page = chunks(result) + .iter() + .filter_map(Value::as_object) + .filter_map(|chunk| chunk.get("blocks").and_then(Value::as_array)) + .flatten() + .filter_map(|block| block.as_object().map(|object| (block, object))) + .filter_map(|(block, object)| page_number(object).map(|page| (page, block.clone()))) + .fold( + BTreeMap::>::new(), + |mut pages, (page, block)| { + pages.entry(page).or_default().push(block); + pages + }, + ); + + if blocks_by_page.is_empty() { + let markdown = chunks(result) + .iter() + .filter_map(Value::as_object) + .filter_map(|chunk| chunk.get("content").and_then(Value::as_str)) + .filter(|content| !content.is_empty()) + .collect::>() + .join("\n\n"); + return if markdown.is_empty() { + Vec::new() + } else { + vec![json!({"index": 0, "markdown": markdown})] + }; + } + + blocks_by_page + .into_iter() + .map(|(page, blocks)| { + let markdown = blocks + .iter() + .filter_map(Value::as_object) + .filter_map(|block| block.get("content").and_then(Value::as_str)) + .filter(|content| !content.is_empty()) + .collect::>() + .join("\n\n"); + json!({ + "index": page.saturating_sub(1).max(0), + "markdown": markdown, + "blocks": blocks, + }) + }) + .collect() +} + +pub fn transform_reducto_response( + model: &str, + response_json: Value, +) -> Result { + let response = response_json + .as_object() + .ok_or_else(|| Error::InvalidType { + expected: "object", + actual: json_type_name(&response_json), + })?; + let empty_result = Map::new(); + let result = match response.get("result") { + Some(Value::Object(result)) => result, + Some(Value::Null) => &empty_result, + Some(_) => { + return Err(Error::InvalidResponse( + "Reducto result must be an object".to_string(), + )); + } + None => response, + }; + let usage = response + .get("usage") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let usage_info = Some(json!({ + "pages_processed": usage.get("num_pages").cloned().unwrap_or(Value::Null), + "credits": usage.get("credits").cloned().unwrap_or(Value::Null), + })); + + Ok(OcrResponseData { + pages: build_pages(result), + model: model.to_string(), + document_annotation: None, + usage_info, + object: "ocr".to_string(), + extra_fields: Map::new(), + provider_native_response: Some(response_json), + }) +} + +impl OcrProviderConfig for ReductoParseV3Config { + fn supported_ocr_params(&self) -> &'static [&'static str] { + PARSE_V3_SUPPORTED_OCR_PARAMS + } + + fn transform_ocr_request( + &self, + _model: &str, + document: Value, + optional_params: Map, + ) -> Result { + let file_id = source_file_id(&document)?; + Ok(build_parse_v3_request(&file_id, optional_params)) + } + + fn transform_ocr_response( + &self, + model: &str, + response_json: Value, + ) -> Result { + transform_reducto_response(model, response_json) + } + + fn complete_url( + &self, + api_base: Option<&str>, + _model: &str, + _optional_params: &Map, + _env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + Ok(parse_url(api_base)) + } + + fn resolve_api_key( + &self, + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + resolve_api_key(api_key, env_lookup) + } +} + +impl OcrProviderConfig for ReductoParseLegacyConfig { + fn supported_ocr_params(&self) -> &'static [&'static str] { + PARSE_LEGACY_SUPPORTED_OCR_PARAMS + } + + fn transform_ocr_request( + &self, + _model: &str, + document: Value, + optional_params: Map, + ) -> Result { + let file_id = source_file_id(&document)?; + Ok(build_parse_legacy_request(&file_id, &optional_params)) + } + + fn transform_ocr_response( + &self, + model: &str, + response_json: Value, + ) -> Result { + transform_reducto_response(model, response_json) + } + + fn complete_url( + &self, + api_base: Option<&str>, + _model: &str, + _optional_params: &Map, + _env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + Ok(parse_url(api_base)) + } + + fn resolve_api_key( + &self, + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + resolve_api_key(api_key, env_lookup) + } +} diff --git a/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs b/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs index ee095447028..c324de8cb45 100644 --- a/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs +++ b/litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs @@ -212,6 +212,7 @@ impl OcrProviderConfig for VertexAiOcrConfig { MISTRAL_OCR_CONFIG.supported_ocr_params() } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_request( &self, model: &str, @@ -229,6 +230,7 @@ impl OcrProviderConfig for VertexAiOcrConfig { MISTRAL_OCR_CONFIG.transform_ocr_response(model, response_json) } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn complete_url( &self, api_base: Option<&str>, @@ -253,10 +255,21 @@ impl OcrProviderConfig for VertexAiOcrConfig { } impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn supported_ocr_params(&self) -> &'static [&'static str] { DEEPSEEK_SUPPORTED_OCR_PARAMS } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + fn map_ocr_params(&self, non_default_params: &Map) -> Map { + non_default_params + .iter() + .filter(|(name, _)| DEEPSEEK_SUPPORTED_OCR_PARAMS.contains(&name.as_str())) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_request( &self, model: &str, @@ -283,6 +296,7 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { }) } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn transform_ocr_response( &self, model: &str, @@ -335,9 +349,12 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { document_annotation: object.get("document_annotation").cloned(), usage_info, object: "ocr".to_string(), + extra_fields: Map::new(), + provider_native_response: None, }) } + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] fn complete_url( &self, api_base: Option<&str>, @@ -360,6 +377,7 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig { #[cfg(test)] mod tests { use super::*; + use rstest::rstest; #[test] fn vertex_mistral_url_uses_project_location_and_model() { @@ -411,6 +429,22 @@ mod tests { ); } + #[rstest] + #[case::bare_model("deepseek-ocr-maas")] + #[case::namespaced_model("deepseek-ai/deepseek-ocr-maas")] + fn vertex_deepseek_request_uses_single_provider_namespace(#[case] model: &str) { + let body = VERTEX_AI_DEEPSEEK_OCR_CONFIG + .transform_ocr_request( + model, + json!({"type": "image_url", "image_url": "data:image/png;base64,AA=="}), + Map::new(), + ) + .expect("request transforms") + .data; + + assert_eq!(body["model"], "deepseek-ai/deepseek-ocr-maas"); + } + #[test] fn vertex_deepseek_response_wraps_markdown_content() { let response = VERTEX_AI_DEEPSEEK_OCR_CONFIG diff --git a/litellm-rust/crates/python-bridge/Cargo.toml b/litellm-rust/crates/python-bridge/Cargo.toml index 637e5580170..bda09a7d840 100644 --- a/litellm-rust/crates/python-bridge/Cargo.toml +++ b/litellm-rust/crates/python-bridge/Cargo.toml @@ -14,11 +14,15 @@ default = ["abi3"] abi3 = ["pyo3/abi3-py310"] extension-module = ["pyo3/extension-module"] panic-test = [] +trace-parity = [ + "dep:tracing", + "litellm-core/observability", + "litellm-ai-gateway/trace-parity", +] [dependencies] futures-util.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true +tracing = { workspace = true, optional = true } litellm-core = { workspace = true, features = ["bedrock-auth"] } litellm-ai-gateway = { workspace = true, default-features = false } litellm-python-interop.workspace = true @@ -31,6 +35,7 @@ tokio.workspace = true [dev-dependencies] criterion = "0.8.2" tokio-tungstenite.workspace = true +tracing.workspace = true [[bench]] name = "serialization" diff --git a/litellm-rust/crates/python-bridge/src/constants.rs b/litellm-rust/crates/python-bridge/src/constants.rs deleted file mode 100644 index 07b2836b838..00000000000 --- a/litellm-rust/crates/python-bridge/src/constants.rs +++ /dev/null @@ -1 +0,0 @@ -pub(crate) const FUNCTION_TRACE_TARGET: &str = "litellm::function_trace"; diff --git a/litellm-rust/crates/python-bridge/src/function_trace.rs b/litellm-rust/crates/python-bridge/src/function_trace.rs index 420d237c79d..ea7d9f4993e 100644 --- a/litellm-rust/crates/python-bridge/src/function_trace.rs +++ b/litellm-rust/crates/python-bridge/src/function_trace.rs @@ -1,216 +1,22 @@ use std::future::Future; -use std::sync::{Arc, Mutex}; +use litellm_core::observability::{FunctionTrace, FunctionTraceEvent}; use serde::Serialize; use tracing::instrument::WithSubscriber; -use tracing::span::{Attributes, Id}; -use tracing::{Dispatch, Level, Subscriber}; -use tracing_subscriber::filter::{LevelFilter, filter_fn}; -use tracing_subscriber::layer::Context; -use tracing_subscriber::prelude::*; -use tracing_subscriber::registry::LookupSpan; -use tracing_subscriber::{Layer, Registry}; - -use crate::constants::FUNCTION_TRACE_TARGET; #[derive(Serialize)] -#[serde(untagged)] -pub(crate) enum TraceResponse { - Plain(T), - Traced { - response: T, - trace: Vec, - }, +pub(crate) struct TracedResponse { + response: T, + trace: Vec, } -pub(crate) async fn trace_call( +pub(crate) async fn capture( future: impl Future>, - enabled: bool, -) -> Result, E> { - if !enabled { - return future.await.map(TraceResponse::Plain); - } +) -> Result, E> { let trace = FunctionTrace::default(); let response = future.with_subscriber(trace.dispatcher()).await?; - Ok(TraceResponse::Traced { + Ok(TracedResponse { response, trace: trace.events(), }) } - -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct FunctionTraceEvent { - pub function: &'static str, - pub depth: usize, -} - -#[derive(Clone, Default)] -pub struct FunctionTrace { - events: Arc>>, -} - -impl FunctionTrace { - pub fn dispatcher(&self) -> Dispatch { - let filter = filter_fn(|metadata| { - metadata.is_span() - && metadata.target() == FUNCTION_TRACE_TARGET - && *metadata.level() == Level::TRACE - }) - .with_max_level_hint(LevelFilter::TRACE); - Dispatch::new( - Registry::default().with( - FunctionTraceLayer { - trace: self.clone(), - } - .with_filter(filter), - ), - ) - } - - pub fn events(&self) -> Vec { - self.events - .lock() - .unwrap_or_else(|error| error.into_inner()) - .clone() - } -} - -struct FunctionTraceLayer { - trace: FunctionTrace, -} - -impl Layer for FunctionTraceLayer -where - S: Subscriber + for<'lookup> LookupSpan<'lookup>, -{ - fn on_new_span(&self, attributes: &Attributes<'_>, id: &Id, context: Context<'_, S>) { - let depth = context - .span(id) - .map(|span| span.scope().skip(1).count()) - .unwrap_or_default(); - self.trace - .events - .lock() - .unwrap_or_else(|error| error.into_inner()) - .push(FunctionTraceEvent { - function: attributes.metadata().name(), - depth, - }); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] - async fn outer() { - tokio::task::yield_now().await; - inner().await; - } - - #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] - async fn inner() { - tokio::task::yield_now().await; - } - - #[tokio::test] - async fn concurrent_futures_keep_separate_traces_across_yields() { - use tracing::instrument::WithSubscriber; - - let first = FunctionTrace::default(); - let second = FunctionTrace::default(); - let outside = FunctionTrace::default(); - - async { - tokio::join!( - outer().with_subscriber(first.dispatcher()), - inner().with_subscriber(second.dispatcher()), - ); - inner().await; - } - .with_subscriber(outside.dispatcher()) - .await; - - assert_eq!( - first.events(), - vec![ - FunctionTraceEvent { - function: "outer", - depth: 0 - }, - FunctionTraceEvent { - function: "inner", - depth: 1 - }, - ], - ); - assert_eq!( - second.events(), - vec![FunctionTraceEvent { - function: "inner", - depth: 0 - }], - ); - assert_eq!( - outside.events(), - vec![FunctionTraceEvent { - function: "inner", - depth: 0 - }], - ); - } - - #[test] - fn records_matching_spans_in_creation_order() { - let trace = FunctionTrace::default(); - let dispatch = trace.dispatcher(); - - tracing::dispatcher::with_default(&dispatch, || { - let _ignored = tracing::trace_span!(target: "other", "ignored"); - let _first = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name"); - let _wrong_level = tracing::debug_span!(target: FUNCTION_TRACE_TARGET, "wrong_level"); - let _second = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name"); - }); - - assert_eq!( - trace.events(), - vec![ - FunctionTraceEvent { - function: "same_name", - depth: 0, - }, - FunctionTraceEvent { - function: "same_name", - depth: 0, - }, - ] - ); - } - - #[test] - fn records_matching_span_nesting_depth() { - let trace = FunctionTrace::default(); - let dispatch = trace.dispatcher(); - - tracing::dispatcher::with_default(&dispatch, || { - let outer = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "outer"); - let _outer_guard = outer.enter(); - let _inner = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "inner"); - }); - - assert_eq!( - trace.events(), - vec![ - FunctionTraceEvent { - function: "outer", - depth: 0, - }, - FunctionTraceEvent { - function: "inner", - depth: 1, - }, - ] - ); - } -} diff --git a/litellm-rust/crates/python-bridge/src/lib.rs b/litellm-rust/crates/python-bridge/src/lib.rs index 5f36a22370a..384f0be5a1b 100644 --- a/litellm-rust/crates/python-bridge/src/lib.rs +++ b/litellm-rust/crates/python-bridge/src/lib.rs @@ -1,8 +1,8 @@ -mod constants; mod diagnostics; mod errors; mod execution; -pub mod function_trace; +#[cfg(feature = "trace-parity")] +mod function_trace; mod marshal; mod routes; @@ -115,9 +115,43 @@ mod tests { .extract::>() .expect("module names should be strings") .into_iter() - .filter(|name| !name.starts_with("__")) + .filter(|name| !name.starts_with('_')) .collect(); assert_eq!(public_names, expected); + + #[cfg(not(feature = "trace-parity"))] + assert!(!module.hasattr("_trace").expect("module lookup should work")); + + #[cfg(feature = "trace-parity")] + { + let trace = module + .getattr("_trace") + .expect("trace build should expose its diagnostic namespace"); + let trace_names: Vec = trace + .cast::() + .expect("trace namespace should be a module") + .dict() + .keys() + .extract::>() + .expect("trace names should be strings") + .into_iter() + .filter(|name| !name.starts_with("__")) + .collect(); + assert_eq!( + trace_names, + [ + "ocr", + "aocr", + "transcription", + "atranscription", + "messages", + "amessages", + "chat_completions", + "achat_completions", + "gateway_messages", + ] + ); + } }); } diff --git a/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs b/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs index 10b86132be7..af60515b0e2 100644 --- a/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs +++ b/litellm-rust/crates/python-bridge/src/routes/audio_transcription.rs @@ -54,16 +54,16 @@ bridge_route! { required = { model: String, #[pyo3(from_py_with = litellm_python_interop::from_py)] - audio: Value, + audio: serde_json::Value, }, optional = { api_key: Option, api_base: Option, custom_llm_provider: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, + extra_headers: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - optional_params: Option, + optional_params: Option, timeout_seconds: Option, }, prepare = prepare_transcription, diff --git a/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs b/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs index 68b7762cb10..08ab476005c 100644 --- a/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs +++ b/litellm-rust/crates/python-bridge/src/routes/chat_completions.rs @@ -73,16 +73,16 @@ bridge_route! { required = { model: String, #[pyo3(from_py_with = litellm_python_interop::from_py)] - messages: Value, + messages: serde_json::Value, }, optional = { #[pyo3(from_py_with = litellm_python_interop::from_py)] - optional_params: Option, + optional_params: Option, api_key: Option, api_base: Option, custom_llm_provider: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, + extra_headers: Option, timeout_seconds: Option, }, prepare = prepare_chat_completions, diff --git a/litellm-rust/crates/python-bridge/src/routes/definition.rs b/litellm-rust/crates/python-bridge/src/routes/definition.rs index 21a7fd5a766..3285da14d5f 100644 --- a/litellm-rust/crates/python-bridge/src/routes/definition.rs +++ b/litellm-rust/crates/python-bridge/src/routes/definition.rs @@ -20,43 +20,33 @@ macro_rules! bridge_route { } #[pyfunction] - #[pyo3(signature = ($($required_name),*, $($optional_name=None,)* trace=false))] + #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] #[allow(clippy::too_many_arguments)] fn $sync_name( py: pyo3::Python<'_>, $($(#[$required_attr])* $required_name: $required_type,)* $($(#[$optional_attr])* $optional_name: $optional_type,)* - trace: bool, ) -> pyo3::PyResult> { let future = $prepare($inputs { $($required_name,)* $($optional_name),* })?; - $crate::execution::run_sync( - py, - $crate::function_trace::trace_call(future, trace), - $map_error, - ) + $crate::execution::run_sync(py, future, $map_error) } #[pyfunction] - #[pyo3(signature = ($($required_name),*, $($optional_name=None,)* trace=false))] + #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] #[allow(clippy::too_many_arguments)] fn $async_name( py: pyo3::Python<'_>, $($(#[$required_attr])* $required_name: $required_type,)* $($(#[$optional_attr])* $optional_name: $optional_type,)* - trace: bool, ) -> pyo3::PyResult> { let future = $prepare($inputs { $($required_name,)* $($optional_name),* })?; - $crate::execution::run_async( - py, - $crate::function_trace::trace_call(future, trace), - $map_error, - ) + $crate::execution::run_async(py, future, $map_error) } pub(super) fn register( @@ -67,6 +57,71 @@ macro_rules! bridge_route { $crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($async_name, module)?)?; Ok(()) } + + #[cfg(feature = "trace-parity")] + mod trace { + use pyo3::prelude::*; + use super::{$inputs, $map_error, $prepare}; + + #[pyfunction] + #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] + #[allow(clippy::too_many_arguments)] + fn $sync_name( + py: pyo3::Python<'_>, + $($(#[$required_attr])* $required_name: $required_type,)* + $($(#[$optional_attr])* $optional_name: $optional_type,)* + ) -> pyo3::PyResult> { + let future = $prepare($inputs { + $($required_name,)* + $($optional_name),* + })?; + $crate::execution::run_sync( + py, + $crate::function_trace::capture(future), + $map_error, + ) + } + + #[pyfunction] + #[pyo3(signature = ($($required_name),*, $($optional_name=None),*))] + #[allow(clippy::too_many_arguments)] + fn $async_name( + py: pyo3::Python<'_>, + $($(#[$required_attr])* $required_name: $required_type,)* + $($(#[$optional_attr])* $optional_name: $optional_type,)* + ) -> pyo3::PyResult> { + let future = $prepare($inputs { + $($required_name,)* + $($optional_name),* + })?; + $crate::execution::run_async( + py, + $crate::function_trace::capture(future), + $map_error, + ) + } + + pub(super) fn register( + module: &pyo3::Bound<'_, pyo3::types::PyModule>, + ) -> pyo3::PyResult<()> { + $crate::routes::definition::add_function( + module, + pyo3::wrap_pyfunction!($sync_name, module)?, + )?; + $crate::routes::definition::add_function( + module, + pyo3::wrap_pyfunction!($async_name, module)?, + )?; + Ok(()) + } + } + + #[cfg(feature = "trace-parity")] + pub(super) fn register_trace( + module: &pyo3::Bound<'_, pyo3::types::PyModule>, + ) -> pyo3::PyResult<()> { + trace::register(module) + } }; } @@ -130,20 +185,26 @@ mod tests { ) -> PyResult> + Send + 'static> { FUTURE_DROPPED.store(false, Ordering::SeqCst); let drop_guard = (inputs.value == "pending").then_some(DropGuard); - Ok(async move { - let _drop_guard = drop_guard; - tokio::task::yield_now().await; - match inputs.value.as_str() { - "error" => Err(Error::InvalidRequest("synthetic error".to_string())), - "map_panic" => Err(Error::InvalidRequest("panic in mapper".to_string())), - "panic" => panic!("synthetic panic"), - "pending" => { - pending::<()>().await; - unreachable!() - } - _ => Ok(inputs.value), + Ok(execute_echo(inputs, drop_guard)) + } + + #[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)] + async fn execute_echo( + inputs: EchoInputs, + drop_guard: Option, + ) -> Result { + let _drop_guard = drop_guard; + tokio::task::yield_now().await; + match inputs.value.as_str() { + "error" => Err(Error::InvalidRequest("synthetic error".to_string())), + "map_panic" => Err(Error::InvalidRequest("panic in mapper".to_string())), + "panic" => panic!("synthetic panic"), + "pending" => { + pending::<()>().await; + unreachable!() } - }) + _ => Ok(inputs.value), + } } fn map_error(error: Error) -> PyErr { @@ -164,22 +225,22 @@ mod tests { ( "ocr", "aocr", - "(model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None, trace=False)", + "(model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None)", ), ( "transcription", "atranscription", - "(model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None, trace=False)", + "(model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None)", ), ( "messages", "amessages", - "(model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None, trace=False)", + "(model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", ), ( "chat_completions", "achat_completions", - "(model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None, trace=False)", + "(model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)", ), ]; @@ -411,6 +472,32 @@ asyncio.run(exercise()) }); } + #[cfg(feature = "trace-parity")] + #[test] + fn diagnostic_route_returns_the_response_and_filtered_trace() { + Python::initialize(); + Python::attach(|py| { + let module = PyModule::new(py, "synthetic").expect("module should be created"); + synthetic::register_trace(&module).expect("trace routes should register"); + let locals = PyDict::new(py); + locals + .set_item("routes", &module) + .expect("module should enter Python locals"); + let code = CString::new( + r#" +result = routes.echo("traced") +assert result == { + "response": "traced", + "trace": [{"function": "execute_echo", "depth": 0}], +} +"#, + ) + .expect("Python source should not contain null bytes"); + py.run(&code, Some(&locals), Some(&locals)) + .expect("diagnostic route should return its response and trace"); + }); + } + #[test] fn route_registration_rejects_duplicate_python_names() { Python::initialize(); diff --git a/litellm-rust/crates/python-bridge/src/routes/gateway_messages.rs b/litellm-rust/crates/python-bridge/src/routes/gateway_messages.rs new file mode 100644 index 00000000000..97ff93f299a --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/routes/gateway_messages.rs @@ -0,0 +1,29 @@ +use pyo3::prelude::*; +use serde_json::Value; + +use crate::errors::core_error_to_pyerr; + +#[pyfunction] +fn gateway_messages<'py>( + py: Python<'py>, + model_alias: String, + provider_model: String, + api_base: String, + #[pyo3(from_py_with = litellm_python_interop::from_py)] body: Value, +) -> PyResult> { + let future = litellm_ai_gateway::trace_parity::messages_request( + model_alias, + provider_model, + api_base, + body, + ); + crate::execution::run_async( + py, + crate::function_trace::capture(future), + core_error_to_pyerr, + ) +} + +pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> { + super::definition::add_function(module, wrap_pyfunction!(gateway_messages, module)?) +} diff --git a/litellm-rust/crates/python-bridge/src/routes/messages.rs b/litellm-rust/crates/python-bridge/src/routes/messages.rs index 2bb64a7a763..f69b5e9251d 100644 --- a/litellm-rust/crates/python-bridge/src/routes/messages.rs +++ b/litellm-rust/crates/python-bridge/src/routes/messages.rs @@ -50,14 +50,14 @@ bridge_route! { required = { model: String, #[pyo3(from_py_with = litellm_python_interop::from_py)] - body: Value, + body: serde_json::Value, }, optional = { api_key: Option, api_base: Option, custom_llm_provider: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, + extra_headers: Option, timeout_seconds: Option, }, prepare = prepare_messages, diff --git a/litellm-rust/crates/python-bridge/src/routes/mod.rs b/litellm-rust/crates/python-bridge/src/routes/mod.rs index bf611c26d44..7e81f2ffe9b 100644 --- a/litellm-rust/crates/python-bridge/src/routes/mod.rs +++ b/litellm-rust/crates/python-bridge/src/routes/mod.rs @@ -3,6 +3,9 @@ use pyo3::prelude::*; #[macro_use] mod definition; +#[cfg(feature = "trace-parity")] +mod gateway_messages; + mod audio_transcription; mod chat_completions; mod messages; @@ -12,5 +15,16 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { ocr::register(module)?; audio_transcription::register(module)?; messages::register(module)?; - chat_completions::register(module) + chat_completions::register(module)?; + #[cfg(feature = "trace-parity")] + { + let trace = PyModule::new(module.py(), "_trace")?; + ocr::register_trace(&trace)?; + audio_transcription::register_trace(&trace)?; + messages::register_trace(&trace)?; + chat_completions::register_trace(&trace)?; + gateway_messages::register_trace(&trace)?; + module.add_submodule(&trace)?; + } + Ok(()) } diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr.rs b/litellm-rust/crates/python-bridge/src/routes/ocr.rs index 5cc8804238b..cc2f8e43cea 100644 --- a/litellm-rust/crates/python-bridge/src/routes/ocr.rs +++ b/litellm-rust/crates/python-bridge/src/routes/ocr.rs @@ -56,16 +56,16 @@ bridge_route! { required = { model: String, #[pyo3(from_py_with = litellm_python_interop::from_py)] - document: Value, + document: serde_json::Value, }, optional = { api_key: Option, api_base: Option, custom_llm_provider: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - extra_headers: Option, + extra_headers: Option, #[pyo3(from_py_with = litellm_python_interop::from_py)] - optional_params: Option, + optional_params: Option, timeout_seconds: Option, }, prepare = prepare_ocr, diff --git a/litellm-rust/crates/python-bridge/src/routes/runtime.rs b/litellm-rust/crates/python-bridge/src/routes/runtime.rs deleted file mode 100644 index 87a0c3e0104..00000000000 --- a/litellm-rust/crates/python-bridge/src/routes/runtime.rs +++ /dev/null @@ -1,423 +0,0 @@ -use std::future::Future; -use std::panic::AssertUnwindSafe; -use std::time::Duration; - -use futures_util::FutureExt; -use litellm_core::error::Error; -use litellm_python_interop::{Pythonized, panic_to_pyerr, release_gil}; -use pyo3::exceptions::PyRuntimeError; -use pyo3::prelude::*; -use serde::Serialize; -use tokio::runtime::{Handle, Runtime}; -use tokio::time::{self, MissedTickBehavior}; - -pub(super) fn run_sync( - py: Python<'_>, - future: F, - map_error: fn(Error) -> PyErr, -) -> PyResult> -where - T: Serialize + Send + 'static, - F: Future> + Send + 'static, -{ - run_sync_on( - py, - pyo3_async_runtimes::tokio::get_runtime(), - future, - map_error, - ) -} - -fn run_sync_on( - py: Python<'_>, - runtime: &Runtime, - future: F, - map_error: fn(Error) -> PyErr, -) -> PyResult> -where - T: Serialize + Send + 'static, - F: Future> + Send + 'static, -{ - if Handle::try_current().is_ok() { - return Err(PyRuntimeError::new_err( - "synchronous native routes cannot run from a Tokio context; use the async route", - )); - } - - let result = release_gil(py, move || runtime.block_on(wait_for_sync_result(future)))?; - let result = map_core_result(result, map_error)?; - Pythonized(result).into_pyobject(py).map(Bound::unbind) -} - -pub(super) fn run_async( - py: Python<'_>, - future: F, - map_error: fn(Error) -> PyErr, -) -> PyResult> -where - T: Serialize + Send + 'static, - F: Future> + Send + 'static, -{ - pyo3_async_runtimes::tokio::future_into_py(py, async move { - let result = catch_route_panic(future).await?; - let result = map_core_result(result, map_error)?; - Ok(Pythonized(result)) - }) -} - -fn map_core_result(result: Result, map_error: fn(Error) -> PyErr) -> PyResult { - match result { - Ok(value) => Ok(value), - Err(error) => Err( - std::panic::catch_unwind(AssertUnwindSafe(|| map_error(error))) - .map_err(panic_to_pyerr)?, - ), - } -} - -async fn catch_route_panic(future: F) -> PyResult> -where - F: Future>, -{ - AssertUnwindSafe(future) - .catch_unwind() - .await - .map_err(panic_to_pyerr) -} - -async fn wait_for_sync_result(future: F) -> PyResult> -where - F: Future>, -{ - let future = catch_route_panic(future); - tokio::pin!(future); - - let signal_interval = Duration::from_millis(50); - let mut signal_checks = - time::interval_at(time::Instant::now() + signal_interval, signal_interval); - signal_checks.set_missed_tick_behavior(MissedTickBehavior::Delay); - loop { - tokio::select! { - result = &mut future => return result, - _ = signal_checks.tick() => Python::attach(|py| py.check_signals())?, - } - } -} - -#[cfg(test)] -mod tests { - use std::ffi::CString; - use std::future::poll_fn; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::{Arc, mpsc}; - use std::task::Poll; - use std::thread; - use std::time::Instant; - - use pyo3::panic::PanicException; - use pyo3::types::{PyDict, PyModule}; - use serde::Serializer; - use tokio::runtime::Builder; - - use super::*; - - fn runtime_error(error: Error) -> PyErr { - PyRuntimeError::new_err(error.to_string()) - } - - fn panicking_error_mapper(_error: Error) -> PyErr { - panic!("error mapper panicked") - } - - struct PanickingOutput; - - static ASYNC_PROBE_COMPLETED: AtomicUsize = AtomicUsize::new(0); - - impl Serialize for PanickingOutput { - fn serialize(&self, _serializer: S) -> Result - where - S: Serializer, - { - panic!("serializer panicked") - } - } - - #[pyfunction] - fn async_serialization_panic(py: Python<'_>) -> PyResult> { - run_async(py, async { Ok(PanickingOutput) }, runtime_error) - } - - #[pyfunction] - fn async_runtime_probe(py: Python<'_>) -> PyResult> { - run_async( - py, - async { - ASYNC_PROBE_COMPLETED.fetch_add(1, Ordering::SeqCst); - Ok(true) - }, - runtime_error, - ) - } - - #[pyfunction] - fn runtime_worker_count() -> usize { - pyo3_async_runtimes::tokio::get_runtime() - .metrics() - .num_workers() - } - - #[pyfunction] - fn runtime_is_responsive(_py: Python<'_>, expected_completions: usize) -> bool { - let completion_deadline = Instant::now() + Duration::from_secs(2); - while ASYNC_PROBE_COMPLETED.load(Ordering::SeqCst) < expected_completions { - if Instant::now() >= completion_deadline { - return false; - } - thread::sleep(Duration::from_millis(1)); - } - - let (heartbeat_tx, heartbeat_rx) = mpsc::sync_channel(1); - pyo3_async_runtimes::tokio::get_runtime().spawn(async move { - let _ = heartbeat_tx.send(()); - }); - heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok() - } - - fn extract_bool(py: Python<'_>, result: PyResult>) -> bool { - result - .expect("route should complete") - .bind(py) - .extract() - .expect("result should convert") - } - - #[test] - fn sync_runner_polls_future_on_the_caller_thread() { - Python::initialize(); - Python::attach(|py| { - let caller_thread = std::thread::current().id(); - let result = run_sync( - py, - async move { Ok(std::thread::current().id() == caller_thread) }, - runtime_error, - ); - - assert!(extract_bool(py, result)); - }); - } - - #[test] - fn sync_runner_releases_gil_while_waiting() { - Python::initialize(); - Python::attach(|py| { - let result = run_sync( - py, - async { - let gil_acquired = tokio::time::timeout( - Duration::from_secs(2), - tokio::task::spawn_blocking(|| Python::attach(|_| true)), - ) - .await; - Ok(matches!(gil_acquired, Ok(Ok(true)))) - }, - runtime_error, - ); - - assert!(extract_bool(py, result)); - }); - } - - #[test] - fn sync_runner_rejects_calls_from_a_tokio_context() { - Python::initialize(); - let runtime = Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime should build"); - - let error = runtime.block_on(async { - Python::attach(|py| { - run_sync::(py, async { Ok(true) }, runtime_error) - .expect_err("sync route should reject a nested Tokio runtime") - }) - }); - - assert_eq!( - error.to_string(), - "RuntimeError: synchronous native routes cannot run from a Tokio context; use the async route" - ); - } - - #[test] - fn sync_runner_can_drive_a_current_thread_runtime() { - Python::initialize(); - let runtime = Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime should build"); - Python::attach(|py| { - let result = run_sync_on( - py, - &runtime, - async { - tokio::task::yield_now().await; - Ok(true) - }, - runtime_error, - ); - assert!(extract_bool(py, result)); - }); - } - - #[test] - fn sync_runner_maps_a_panicked_future() { - Python::initialize(); - Python::attach(|py| { - let error = run_sync::( - py, - poll_fn(|_| -> Poll> { panic!("route future panicked") }), - runtime_error, - ) - .expect_err("panicked route should become a Python exception"); - - assert!(error.is_instance_of::(py)); - assert_eq!(error.to_string(), "PanicException: route future panicked"); - }); - } - - #[test] - fn sync_runner_maps_a_panicked_error_mapper() { - Python::initialize(); - Python::attach(|py| { - let error = run_sync::( - py, - async { Err(Error::InvalidRequest("invalid".to_string())) }, - panicking_error_mapper, - ) - .expect_err("panicked mapper should become a Python exception"); - - assert!(error.is_instance_of::(py)); - assert_eq!(error.to_string(), "PanicException: error mapper panicked"); - }); - } - - #[test] - fn sync_runner_surfaces_serializer_panics() { - Python::initialize(); - Python::attach(|py| { - let error = run_sync(py, async { Ok(PanickingOutput) }, runtime_error) - .expect_err("serializer panic should become a Python exception"); - - assert!(error.is_instance_of::(py)); - assert_eq!(error.to_string(), "PanicException: serializer panicked"); - }); - } - - #[test] - fn sync_runner_supports_concurrent_callers_on_the_shared_runtime() { - Python::initialize(); - let barrier = Arc::new(tokio::sync::Barrier::new(2)); - let callers: Vec<_> = (0..2) - .map(|_| { - let barrier = Arc::clone(&barrier); - thread::spawn(move || { - Python::attach(|py| { - extract_bool( - py, - run_sync( - py, - async move { - Ok(tokio::time::timeout(Duration::from_secs(2), barrier.wait()) - .await - .is_ok()) - }, - runtime_error, - ), - ) - }) - }) - }) - .collect(); - let results: Vec<_> = callers - .into_iter() - .map(|caller| caller.join().expect("caller should not panic")) - .collect(); - - assert_eq!(results, vec![true, true]); - } - - #[test] - fn async_runner_surfaces_serializer_panics() { - Python::initialize(); - Python::attach(|py| { - let module = PyModule::new(py, "runtime").expect("module should be created"); - module - .add_function( - wrap_pyfunction!(async_serialization_panic, &module) - .expect("function should wrap"), - ) - .expect("function should register"); - let locals = PyDict::new(py); - locals - .set_item("runtime", &module) - .expect("module should enter Python locals"); - let code = CString::new( - r#" -import asyncio - -async def exercise(): - try: - await runtime.async_serialization_panic() - except BaseException as error: - assert type(error).__name__ == "PanicException" - assert str(error) == "serializer panicked" - else: - raise AssertionError("serializer panic was not raised") - -asyncio.run(exercise()) -"#, - ) - .expect("Python source should not contain null bytes"); - py.run(&code, Some(&locals), Some(&locals)) - .expect("serializer panic should reach the Python awaiter"); - }); - } - - #[test] - fn async_result_delivery_does_not_stall_tokio_workers() { - Python::initialize(); - ASYNC_PROBE_COMPLETED.store(0, Ordering::SeqCst); - Python::attach(|py| { - let module = PyModule::new(py, "runtime").expect("module should be created"); - for function in [ - wrap_pyfunction!(async_runtime_probe, &module).expect("function should wrap"), - wrap_pyfunction!(runtime_worker_count, &module).expect("function should wrap"), - wrap_pyfunction!(runtime_is_responsive, &module).expect("function should wrap"), - ] { - module - .add_function(function) - .expect("function should register"); - } - let locals = PyDict::new(py); - locals - .set_item("runtime", &module) - .expect("module should enter Python locals"); - let code = CString::new( - r#" -import asyncio - -async def exercise(): - worker_count = runtime.runtime_worker_count() - awaitables = [runtime.async_runtime_probe() for _ in range(worker_count)] - assert runtime.runtime_is_responsive(worker_count) - assert await asyncio.gather(*awaitables) == [True] * worker_count - -asyncio.run(exercise()) -"#, - ) - .expect("Python source should not contain null bytes"); - py.run(&code, Some(&locals), Some(&locals)) - .expect("result delivery should leave Tokio workers responsive"); - }); - } -} diff --git a/litellm/_service_logger.py b/litellm/_service_logger.py index 42a86763b6d..fc27d3a118a 100644 --- a/litellm/_service_logger.py +++ b/litellm/_service_logger.py @@ -1,4 +1,5 @@ import asyncio +from collections.abc import Callable, Coroutine from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, Final @@ -83,6 +84,47 @@ class ServiceLogging(CustomLogger): return open_telemetry_logger return None + @staticmethod + def _sync_dispatch_loop() -> asyncio.AbstractEventLoop | None: + """The event loop a blocking caller can dispatch on, or ``None`` if it has none.""" + try: + loop: Final = asyncio.get_event_loop() + except RuntimeError: + return None + return None if loop.is_closed() else loop + + @staticmethod + async def _emit_guarded(hook: Callable[[], Coroutine[object, object, None]]) -> None: + """Emit one service event, absorbing anything the callbacks raise. + + Monitoring must not break the call it monitors. Sync callers are the ones that + swallow their own service failures (a Redis batch read returns an empty dict), + so an exception from a misconfigured callback would replace a Redis outage with + a callback error and skip the caller's fallback handling. + """ + try: + await hook() + except Exception as e: + verbose_logger.exception("Error emitting service event - %s", e) + + @staticmethod + def _dispatch_from_sync(hook: Callable[[], Coroutine[object, object, None]]) -> None: + """Run an async service hook from a blocking caller, whatever event loop it holds. + + Takes a factory rather than a coroutine so the hook is built on the path that + runs it, and only ever once. + """ + loop: Final = ServiceLogging._sync_dispatch_loop() + try: + if loop is None: + asyncio.run(ServiceLogging._emit_guarded(hook)) + elif loop.is_running(): + loop.create_task(ServiceLogging._emit_guarded(hook)) + else: + loop.run_until_complete(ServiceLogging._emit_guarded(hook)) + except Exception as e: + verbose_logger.exception("Error dispatching service event - %s", e) + def service_success_hook( self, service: ServiceTypes, @@ -99,54 +141,45 @@ class ServiceLogging(CustomLogger): if self.mock_testing: self.mock_testing_sync_success_hook += 1 - try: - # Try to get the current event loop - loop: Final = asyncio.get_event_loop() - # Check if the loop is running - if loop.is_running(): - # If we're in a running loop, create a task - loop.create_task( - self.async_service_success_hook( - service=service, - duration=duration, - call_type=call_type, - parent_otel_span=parent_otel_span, - start_time=start_time, - end_time=end_time, - ) - ) - else: - # Loop exists but not running, we can use run_until_complete - loop.run_until_complete( - self.async_service_success_hook( - service=service, - duration=duration, - call_type=call_type, - parent_otel_span=parent_otel_span, - start_time=start_time, - end_time=end_time, - ) - ) - except RuntimeError: - # No event loop exists, create a new one and run - asyncio.run( - self.async_service_success_hook( - service=service, - duration=duration, - call_type=call_type, - parent_otel_span=parent_otel_span, - start_time=start_time, - end_time=end_time, - ) + self._dispatch_from_sync( + lambda: self.async_service_success_hook( + service=service, + duration=duration, + call_type=call_type, + parent_otel_span=parent_otel_span, + start_time=start_time, + end_time=end_time, ) + ) - def service_failure_hook(self, service: ServiceTypes, duration: float, error: Exception, call_type: str): + def service_failure_hook( + self, + service: ServiceTypes, + duration: float, + error: Exception, + call_type: str, + parent_otel_span: Span | None = None, + start_time: datetime | float | None = None, + end_time: float | datetime | None = None, + ): """ - [TODO] Not implemented for sync calls yet. V0 is focused on async monitoring (used by proxy). + Handles both sync and async monitoring by checking for existing event loop. """ if self.mock_testing: self.mock_testing_sync_failure_hook += 1 + self._dispatch_from_sync( + lambda: self.async_service_failure_hook( + service=service, + duration=duration, + error=error, + call_type=call_type, + parent_otel_span=parent_otel_span, + start_time=start_time, + end_time=end_time, + ) + ) + async def async_service_success_hook( self, service: ServiceTypes, diff --git a/litellm/anthropic_interface/exceptions/exceptions.py b/litellm/anthropic_interface/exceptions/exceptions.py index ae333d1f4ad..91bcf82f455 100644 --- a/litellm/anthropic_interface/exceptions/exceptions.py +++ b/litellm/anthropic_interface/exceptions/exceptions.py @@ -1,8 +1,9 @@ """Anthropic error format type definitions.""" +from collections.abc import Mapping from typing import Literal -from typing_extensions import Required, TypedDict +from typing_extensions import NotRequired, ReadOnly, Required, TypedDict # Known Anthropic error types # Source: https://docs.anthropic.com/en/api/errors @@ -23,6 +24,7 @@ class AnthropicErrorDetail(TypedDict): type: AnthropicErrorType message: str + provider_specific_fields: NotRequired[ReadOnly[Mapping[str, object]]] class AnthropicErrorResponse(TypedDict, total=False): diff --git a/litellm/caching/caching.py b/litellm/caching/caching.py index 754815fce47..884d095793c 100644 --- a/litellm/caching/caching.py +++ b/litellm/caching/caching.py @@ -100,6 +100,7 @@ class Cache: qdrant_semantic_cache_vector_size: int | None = None, semantic_cache_embedding_max_input_tokens: int | None = None, semantic_cache_embedding_timeout: float | None = None, + semantic_cache_scope: str = SemanticCacheScope.KEY.value, # GCP IAM authentication parameters gcp_service_account: str | None = None, gcp_ssl_ca_certs: str | None = None, @@ -127,6 +128,7 @@ class Cache: similarity_threshold (float, optional): The similarity threshold for semantic-caching, Required if type is "redis-semantic" or "qdrant-semantic". semantic_cache_embedding_max_input_tokens (int, optional): Truncate prompts to this many tokens before embedding them for semantic caching. Defaults to the embedding deployment's configured max_input_tokens. semantic_cache_embedding_timeout (float, optional): Seconds a semantic-cache lookup may spend embedding the prompt before it gives up and lets the request continue to the LLM. Defaults to SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS. + semantic_cache_scope (str, optional): "key" isolates semantic-cache buckets per key/team/org. "end_user" additionally isolates per end user (falls back to the key scope when the request carries no end-user id). Defaults to "key". # Disk Cache Args disk_cache_dir (str, optional): The directory for the disk cache. Defaults to None. @@ -274,6 +276,7 @@ class Cache: self.redis_flush_size = redis_flush_size self.ttl = ttl self.mode: CacheMode = mode or CacheMode.default_on + self.semantic_cache_scope: str = SemanticCacheScope(semantic_cache_scope).value if self.type == LiteLLMCacheType.LOCAL and default_in_memory_ttl is not None: self.ttl = default_in_memory_ttl @@ -301,6 +304,7 @@ class Cache: "user_api_key_team_id", "user_api_key_org_id", ) + _SEMANTIC_CACHE_END_USER_SCOPE_FIELD: Final = "user_api_key_end_user_id" def _is_semantic_cache(self) -> bool: return self.type in ( @@ -309,19 +313,21 @@ class Cache: LiteLLMCacheType.VALKEY_SEMANTIC, ) - def _get_semantic_cache_tenant_scope(self, kwargs: dict) -> str: - metadata: Final[dict] = kwargs.get("metadata") or {} - litellm_params: Final[dict] = kwargs.get("litellm_params") or {} - metadata_in_litellm_params: Final[dict] = litellm_params.get("metadata") or {} + def _semantic_cache_scope_fields(self) -> tuple[str, ...]: + if self.semantic_cache_scope == SemanticCacheScope.END_USER: + return (*self._SEMANTIC_CACHE_TENANT_SCOPE_FIELDS, self._SEMANTIC_CACHE_END_USER_SCOPE_FIELD) + return self._SEMANTIC_CACHE_TENANT_SCOPE_FIELDS - scope = "" - for field in self._SEMANTIC_CACHE_TENANT_SCOPE_FIELDS: - value = metadata.get(field) - if value is None: - value = metadata_in_litellm_params.get(field) - if value is not None: - scope += f"{field}: {value}" - return scope + def _get_semantic_cache_tenant_scope(self, kwargs: dict) -> str: + litellm_params: Final[dict] = kwargs.get("litellm_params") or {} + metadata_sources: Final[tuple[dict, ...]] = tuple( + source.get(key) or {} for source in (kwargs, litellm_params) for key in ("metadata", "litellm_metadata") + ) + scope_values: Final = ( + (field, next((source[field] for source in metadata_sources if source.get(field) is not None), None)) + for field in self._semantic_cache_scope_fields() + ) + return "".join(f"{field}: {value}" for field, value in scope_values if value is not None) def get_cache_key(self, **kwargs) -> str: """ diff --git a/litellm/caching/dual_cache.py b/litellm/caching/dual_cache.py index 598c9e67faf..df67ba08416 100644 --- a/litellm/caching/dual_cache.py +++ b/litellm/caching/dual_cache.py @@ -8,10 +8,9 @@ Has 4 primary methods: - async_get_cache """ -import asyncio import time import traceback -from concurrent.futures import ThreadPoolExecutor +from collections.abc import Sequence from threading import Lock from typing import TYPE_CHECKING, Any, Final @@ -188,31 +187,38 @@ class DualCache(BaseCache): local_only: bool = False, **kwargs, ): - received_args: Final = locals() - received_args.pop("self") - - def run_in_new_loop(): - """Run the coroutine in a new event loop within this thread.""" - new_loop: Final = asyncio.new_event_loop() - try: - asyncio.set_event_loop(new_loop) - return new_loop.run_until_complete(self.async_batch_get_cache(**received_args)) - finally: - new_loop.close() - asyncio.set_event_loop(None) - try: - # First, try to get the current event loop - _ = asyncio.get_running_loop() - # If we're already in an event loop, run in a separate thread - # to avoid nested event loop issues - with ThreadPoolExecutor(max_workers=1) as executor: - future: Final = executor.submit(run_in_new_loop) - return future.result() + in_memory_result: Final = ( + self.in_memory_cache.batch_get_cache(keys, **kwargs) if self.in_memory_cache is not None else None + ) + result: Final = in_memory_result if in_memory_result is not None else tuple(None for _ in keys) - except RuntimeError: - # No running event loop, we can safely run in this thread - return run_in_new_loop() + if None not in result or self.redis_cache is None or local_only: + return result + + sublist_keys, previous_access_times = self._reserve_redis_batch_keys(time.time(), keys, result) + if len(sublist_keys) == 0: + return result + + try: + redis_result: Final = self.redis_cache.batch_get_cache( + key_list=sublist_keys, parent_otel_span=parent_otel_span + ) + except Exception: + # Do not throttle subsequent callers if the Redis read fails. + self._rollback_redis_batch_key_reservations(previous_access_times) + raise + + if self.in_memory_cache is not None: + for key, value in redis_result.items(): + if value is not None: + self.in_memory_cache.set_cache(key, value, **self._backfill_kwargs(kwargs)) + + return list( # mutable-ok: public list contract + redis_result.get(key) if value is None else value for key, value in zip(keys, result) + ) + except Exception: + verbose_logger.error(traceback.format_exc()) async def async_get_cache( self, @@ -251,7 +257,7 @@ class DualCache(BaseCache): self, current_time: float, keys: list[str], - result: list[Any], + result: Sequence[Any], ) -> tuple[list[str], dict[str, float | None]]: """ Atomically choose keys to fetch from Redis and reserve their access time. diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py index 2b04a075114..aaee7188d86 100644 --- a/litellm/caching/redis_cache.py +++ b/litellm/caching/redis_cache.py @@ -27,6 +27,7 @@ from litellm.constants import ( REDIS_CIRCUIT_BREAKER_ENABLED, REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD, REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT, + REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION, ) from litellm.litellm_core_utils.core_helpers import _get_parent_otel_span_from_kwargs from litellm.litellm_core_utils.coroutine_checker import coroutine_checker @@ -41,6 +42,8 @@ from .base_cache import BaseCache if TYPE_CHECKING: from opentelemetry.trace import Span as _Span + from prometheus_client import Counter as _PromCounter + from prometheus_client import Gauge as _PromGauge from redis.asyncio import Redis, RedisCluster from redis.asyncio.client import Pipeline from redis.asyncio.cluster import ClusterPipeline @@ -78,10 +81,18 @@ class _AsyncRedisCommands(Protocol): def pipeline(self, transaction: bool = True) -> "Pipeline[bytes]": ... +_BREAKER_GUARD_FRAME_NAMES: Final = frozenset( + {"", "wrapper", "_run_under_circuit_breaker", "_run_under_circuit_breaker_sync"} +) + + def _get_call_stack_info(num_frames: int = 2) -> str: """ Get the function names from the previous 1-2 functions in the call stack. + Frames belonging to this module's circuit-breaker guards are skipped so the + reported callers stay the real ones even on guarded methods. + Args: num_frames: Number of previous frames to include (default: 2) @@ -102,11 +113,11 @@ def _get_call_stack_info(num_frames: int = 2) -> str: return "unknown" function_names: Final = [] - for _ in range(num_frames): - if frame is None: - break - func_name = frame.f_code.co_name - function_names.append(func_name) + while frame is not None and len(function_names) < num_frames: + if frame.f_code.co_name in _BREAKER_GUARD_FRAME_NAMES and frame.f_globals.get("__name__") == __name__: + frame = frame.f_back + continue + function_names.append(frame.f_code.co_name) frame = frame.f_back if not function_names: @@ -127,10 +138,20 @@ class RedisCircuitBreaker: HALF_OPEN - recovery probe: allow one request through Transitions: - CLOSED -> OPEN after failure_threshold consecutive failures + CLOSED -> OPEN after failure_threshold consecutive hard connectivity + failures, or after an unbroken run of timeout failures + (no success or hard failure in between) that reaches + failure_threshold and spans timeout_min_duration seconds OPEN -> HALF_OPEN after recovery_timeout seconds HALF_OPEN -> CLOSED on success HALF_OPEN -> OPEN on failure (resets timer) + + Timeouts are accounted separately from hard connectivity failures because the async + Redis timeout includes time waiting for the worker event loop to resume: one loop + stall makes every in-flight operation time out together, which satisfies a purely + consecutive threshold instantly even though Redis is healthy. Requiring a + timeout-only streak to also span timeout_min_duration filters such bursts while a + real outage that surfaces as timeouts still opens the breaker after that duration. """ CLOSED = "closed" @@ -142,13 +163,19 @@ class RedisCircuitBreaker: failure_threshold: int, recovery_timeout: int, enabled: bool = True, + timeout_min_duration: float = REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION, ) -> None: self.failure_threshold = failure_threshold self.recovery_timeout = recovery_timeout self.enabled = enabled + self.timeout_min_duration = timeout_min_duration self._failure_count = 0 + self._hard_failure_count = 0 + self._timeout_count = 0 + self._timeout_streak_started_at: float | None = None self._opened_at: float | None = None self._state = self.CLOSED + _breaker_metrics().record_state_change(None, self._state) def is_open(self) -> bool: """Returns True if Redis calls should be skipped.""" @@ -161,24 +188,45 @@ class RedisCircuitBreaker: return True if self._state == self.OPEN: if time.time() - (self._opened_at or 0) > self.recovery_timeout: - self._state = self.HALF_OPEN + self._set_state(self.HALF_OPEN) return False # this caller is the designated probe return True return False - def record_failure(self) -> None: + def _should_open(self, now: float) -> bool: + if self._state == self.HALF_OPEN: + return True + if self._hard_failure_count >= self.failure_threshold: + return True + if self._timeout_count < self.failure_threshold: + return False + return now - (self._timeout_streak_started_at or now) >= self.timeout_min_duration + + def record_failure(self, is_timeout: bool = False) -> None: if not self.enabled: return + now: Final = time.time() self._failure_count += 1 - self._opened_at = time.time() - if self._failure_count >= self.failure_threshold: + if is_timeout: + self._timeout_count += 1 + if self._timeout_streak_started_at is None: + self._timeout_streak_started_at = now + else: + self._hard_failure_count += 1 + self._timeout_count = 0 + self._timeout_streak_started_at = None + self._opened_at = now + _breaker_metrics().record_failure("timeout" if is_timeout else "connectivity") + if self._should_open(now): if self._state != self.OPEN: verbose_logger.warning( - "Redis circuit breaker OPENED after %d consecutive failures — fast-failing Redis calls for %ds", + "Redis circuit breaker OPENED after %d consecutive failures" + " (%d hard connectivity) — fast-failing Redis calls for %ds", self._failure_count, + self._hard_failure_count, self.recovery_timeout, ) - self._state = self.OPEN + self._set_state(self.OPEN) def record_success(self) -> None: if not self.enabled: @@ -186,7 +234,17 @@ class RedisCircuitBreaker: if self._state == self.HALF_OPEN: verbose_logger.info("Redis circuit breaker CLOSED — Redis recovered") self._failure_count = 0 - self._state = self.CLOSED + self._hard_failure_count = 0 + self._timeout_count = 0 + self._timeout_streak_started_at = None + self._set_state(self.CLOSED) + + def _set_state(self, state: str) -> None: + if state == self._state: + return + _breaker_metrics().record_transition(state) + _breaker_metrics().record_state_change(self._state, state) + self._state = state _RedisCallResult = TypeVar("_RedisCallResult") @@ -226,6 +284,78 @@ def _is_redis_health_failure(exc: BaseException) -> bool: return True +@functools.lru_cache(maxsize=1) +def _redis_timeout_error_types() -> tuple[type, ...]: + """Health failures that are timeouts rather than unambiguous connectivity errors. + + ``builtins.TimeoutError`` covers ``asyncio.TimeoutError`` and ``socket.timeout`` + (aliases since py3.11 / py3.10). ``redis.exceptions.TimeoutError`` does not subclass + either, so it is listed explicitly. + """ + try: + from redis.exceptions import TimeoutError as RedisTimeoutError + except ImportError: + return (TimeoutError,) + return (RedisTimeoutError, TimeoutError) + + +def _is_redis_timeout_failure(exc: BaseException) -> bool: + return isinstance(exc, _redis_timeout_error_types()) + + +class _BreakerMetrics: + """Prometheus metrics for the Redis circuit breaker; no-ops when the client is absent. + + Registered lazily on the default registry (which /metrics serves) via the module-level + ``_breaker_metrics`` singleton so repeated RedisCache construction never re-registers. + """ + + def __init__(self) -> None: + self._state_gauge: _PromGauge | None = None + self._transitions: _PromCounter | None = None + self._failures: _PromCounter | None = None + try: + from prometheus_client import Counter as PromCounter + from prometheus_client import Gauge + except ImportError: + return + self._state_gauge = Gauge( + "litellm_redis_circuit_breaker_state", + "Number of Redis circuit breakers currently in each state", + labelnames=("state",), + ) + self._transitions = PromCounter( + "litellm_redis_circuit_breaker_transitions", + "Redis circuit breaker state transitions", + labelnames=("state",), + ) + self._failures = PromCounter( + "litellm_redis_circuit_breaker_failures", + "Redis health failures counted by the circuit breaker", + labelnames=("failure_class",), + ) + + def record_state_change(self, old_state: str | None, new_state: str) -> None: + if self._state_gauge is None: + return + if old_state is not None: + self._state_gauge.labels(old_state).dec() + self._state_gauge.labels(new_state).inc() + + def record_transition(self, state: str) -> None: + if self._transitions is not None: + self._transitions.labels(state).inc() + + def record_failure(self, failure_class: str) -> None: + if self._failures is not None: + self._failures.labels(failure_class).inc() + + +@functools.lru_cache(maxsize=1) +def _breaker_metrics() -> _BreakerMetrics: + return _BreakerMetrics() + + def _record_swallowed_redis_failure(breaker: RedisCircuitBreaker, exc: BaseException) -> None: """Record a Redis failure that the calling method is about to swallow. @@ -237,10 +367,27 @@ def _record_swallowed_redis_failure(breaker: RedisCircuitBreaker, exc: BaseExcep """ if not _is_redis_health_failure(exc): return - breaker.record_failure() + breaker.record_failure(is_timeout=_is_redis_timeout_failure(exc)) _swallowed_redis_failures.set(_swallowed_redis_failures.get() + 1) +def _enter_circuit_breaker(breaker: RedisCircuitBreaker, name: str) -> int: + """Reject the call if the breaker is open, else return the swallowed-failure count to compare against.""" + if breaker.is_open(): + raise Exception(f"Redis circuit breaker is open — skipping {name}") + return _swallowed_redis_failures.get() + + +def _exit_circuit_breaker(breaker: RedisCircuitBreaker, swallowed_before: int) -> None: + """Record success only when nothing failed while the call ran. + + Several Redis methods catch their own connection errors and return a default, so a + method that returned is not on its own proof of a healthy Redis. + """ + if _swallowed_redis_failures.get() == swallowed_before: + breaker.record_success() + + async def _run_under_circuit_breaker( breaker: RedisCircuitBreaker, name: str, @@ -249,20 +396,33 @@ async def _run_under_circuit_breaker( """Run one Redis coroutine under a circuit breaker. Shared by the method decorator and the Lua script executor so both feed the same - health signal. Success is recorded only when nothing failed while ``call`` ran, - because several Redis methods catch their own connection errors and return a default. + health signal. """ - if breaker.is_open(): - raise Exception(f"Redis circuit breaker is open — skipping {name}") - swallowed_before: Final = _swallowed_redis_failures.get() + swallowed_before: Final = _enter_circuit_breaker(breaker, name) try: result: Final = await call() + except Exception as e: + if _is_redis_health_failure(e): + breaker.record_failure(is_timeout=_is_redis_timeout_failure(e)) + raise + _exit_circuit_breaker(breaker, swallowed_before) + return result + + +def _run_under_circuit_breaker_sync( + breaker: RedisCircuitBreaker, + name: str, + call: Callable[[], _RedisCallResult], +) -> _RedisCallResult: + """Run one blocking Redis call under a circuit breaker, feeding the same health signal as the async path.""" + swallowed_before: Final = _enter_circuit_breaker(breaker, name) + try: + result: Final = call() except Exception as e: if _is_redis_health_failure(e): breaker.record_failure() raise - if _swallowed_redis_failures.get() == swallowed_before: - breaker.record_success() + _exit_circuit_breaker(breaker, swallowed_before) return result @@ -288,6 +448,14 @@ def _redis_circuit_breaker_guard(method): return wrapper +def _redis_circuit_breaker_guard_sync(method: Callable[..., _RedisCallResult]) -> Callable[..., _RedisCallResult]: + return functools.wraps(method)( + lambda self, *args, **kwargs: _run_under_circuit_breaker_sync( + self._circuit_breaker, method.__name__, lambda: method(self, *args, **kwargs) + ) + ) + + class RedisCache(BaseCache): # if users don't provider one, use the default litellm cache @@ -1146,14 +1314,13 @@ class RedisCache(BaseCache): """ key_value_dict = {} _key_list: Final = [key for key in key_list if key is not None] + start_time: Final = time.time() try: - _keys: Final = [] - for cache_key in _key_list: - cache_key = self.check_and_fix_namespace(key=cache_key or "") - _keys.append(cache_key) - start_time: Final = time.time() + swallowed_before: Final = _enter_circuit_breaker(self._circuit_breaker, "batch_get_cache") + _keys: Final = [self.check_and_fix_namespace(key=cache_key or "") for cache_key in _key_list] results: Final = self._run_redis_mget_operation(keys=_keys) + _exit_circuit_breaker(self._circuit_breaker, swallowed_before) end_time: Final = time.time() _duration: Final = end_time - start_time self.service_logger_obj.service_success_hook( @@ -1178,7 +1345,18 @@ class RedisCache(BaseCache): return decoded_results except Exception as e: + failed_at: Final = time.time() + self.service_logger_obj.service_failure_hook( + service=ServiceTypes.REDIS, + duration=failed_at - start_time, + error=e, + call_type=f"batch_get_cache <- {_get_call_stack_info()}", + start_time=start_time, + end_time=failed_at, + parent_otel_span=parent_otel_span, + ) verbose_logger.error("Error occurred in batch get cache - %s", e) + _record_swallowed_redis_failure(self._circuit_breaker, e) return key_value_dict @_redis_circuit_breaker_guard diff --git a/litellm/caching/redis_cluster_node_isolation.py b/litellm/caching/redis_cluster_node_isolation.py index ae8c78709d9..0035801018b 100644 --- a/litellm/caching/redis_cluster_node_isolation.py +++ b/litellm/caching/redis_cluster_node_isolation.py @@ -19,13 +19,13 @@ connections untouched. Every other branch (MOVED, ASK, CLUSTERDOWN, slot-not-cov retry-exhaustion) is unchanged from upstream, since those already carry real evidence the topology changed. -redis-py 8.x fixed this upstream with gentler machinery than this override's -``node.disconnect()`` (which also kills connections other coroutines are mid-operation -on, so one timeout cascades into a reconnect storm and, with TLS, a fresh handshake per -killed connection): it marks in-use connections for reconnect only after their current -operation completes, disconnects only the idle pooled ones, and defers reinitialization -to the outer retry loop. When the installed ``ClusterNode`` has that per-connection -recovery API, the factory returns the base ``RedisCluster`` unmodified. +redis-py 8.x recovers connections per-connection, so the copied override is not used. Upstream +still flips the shared ``_initialize`` flag on any node's timeout, funneling every concurrent +caller through the reinit lock and, if ``CLUSTER SLOTS`` lands on the slow node, into a full +teardown. For those versions the factory returns a thin wrapper around upstream's +``_execute_command`` that clears the flag again after an isolated timeout (a ConnectionError, +a third consecutive timeout on the same node, or a concurrent request from any other command +or ``aclose()`` still reinits). """ import asyncio @@ -44,6 +44,8 @@ class _ClusterNodeAttrs(Protocol): mode; typing ``target_node`` as this Protocol at the one boundary keeps the override's own logic fully typed without a banned ``typing.cast``.""" + name: str + async def execute_command( self, *args: object, @@ -78,18 +80,20 @@ class _ClusterAttrs(Protocol): #: this override can't see (Python won't error -- it'll just run our now-stale copy), so #: construction logs a loud warning rather than silently trusting an unverified copy. _VERIFIED_REDIS_VERSIONS: Final = frozenset({"5.3.1"}) +_CONSECUTIVE_TIMEOUTS_BEFORE_REINIT: Final = 3 -def get_litellm_async_redis_cluster_class( +def get_litellm_async_redis_cluster_class( # noqa: C901 # supports redis-py version-specific cluster implementations cluster_node_class: type | None = None, + base_cluster_class: type | None = None, ) -> type["_AsyncRedisClusterType"]: - """Returns the base ``RedisCluster`` when the installed redis-py already recovers a - node-level connection error per-connection (8.x+), else builds the ``RedisCluster`` - subclass with the per-node isolation fix for older versions whose upstream branch - tears down the whole cluster client. + """Returns a timeout-tolerant ``RedisCluster`` subclass when installed redis-py already + recovers node-level connections per-connection (8.x+), else builds the ``RedisCluster`` + subclass with the per-node isolation fix for older versions whose upstream branch tears + down the whole cluster client. - ``cluster_node_class`` exists for dependency injection in tests; production callers - leave it unset and the installed ``ClusterNode`` is used. + ``cluster_node_class`` and ``base_cluster_class`` exist for dependency injection in tests; + production callers leave them unset and the installed redis-py classes are used. Imported lazily because this module is reachable from a base ``import litellm`` while redis is not a base dependency. Cheap to call repeatedly: the underlying redis @@ -118,13 +122,68 @@ def get_litellm_async_redis_cluster_class( from redis.exceptions import TimeoutError as _RedisTimeoutError node_class: Final = cluster_node_class if cluster_node_class is not None else _AsyncClusterNode + base_class: Final = base_cluster_class if base_cluster_class is not None else _BaseAsyncRedisCluster if hasattr(node_class, "update_active_connections_for_reconnect"): verbose_logger.debug( - "redis-py %s recovers a node-level connection error per-connection upstream; " - "using the base RedisCluster without litellm's node-isolation override.", + "redis-py %s recovers node connections per-connection upstream; using " + "LiteLLM's timeout-tolerant RedisCluster wrapper.", redis.__version__, ) - return _BaseAsyncRedisCluster + + class LiteLLMAsyncRedisClusterTimeoutTolerant( + base_class # pyright: ignore[reportGeneralTypeIssues, reportUntypedBaseClass] # the injected base class is selected at runtime + ): + def __init__( + self, + *args: object, + **kwargs: object, # kwargs-ok: passes redis-py's constructor kwargs through untouched + ) -> None: + self._litellm_initialize = False + self._litellm_reinit_requests = 0 + self._litellm_tolerated_timeouts = 0 + super().__init__(*args, **kwargs) + self._litellm_consecutive_timeouts: dict[ # mutable-ok: per-node counter updated on the command hot path + str, int + ] = {} + + @property + def _initialize(self) -> bool: + return self._litellm_initialize + + @_initialize.setter + def _initialize(self, value: bool) -> None: + if value: + self._litellm_reinit_requests += 1 + self._litellm_initialize = value + + async def _execute_command( + self, + target_node: _ClusterNodeAttrs, + *args: object, + **kwargs: object, # kwargs-ok: matches redis-py's own command dispatch signature + ) -> object: + outstanding_before: Final = self._litellm_reinit_requests - self._litellm_tolerated_timeouts + pending_before: Final = self._litellm_initialize + try: + result: Final = await super()._execute_command(target_node, *args, **kwargs) + except _RedisTimeoutError: + timeouts: Final = self._litellm_consecutive_timeouts.get(target_node.name, 0) + 1 + if timeouts >= _CONSECUTIVE_TIMEOUTS_BEFORE_REINIT: + self._litellm_consecutive_timeouts.pop(target_node.name, None) + raise + self._litellm_consecutive_timeouts[target_node.name] = timeouts + self._litellm_tolerated_timeouts += 1 + if ( + not pending_before + and self._litellm_reinit_requests - self._litellm_tolerated_timeouts == outstanding_before + ): + self._initialize = False + raise + if self._litellm_consecutive_timeouts: + self._litellm_consecutive_timeouts.pop(target_node.name, None) + return result + + return LiteLLMAsyncRedisClusterTimeoutTolerant if redis.__version__ not in _VERIFIED_REDIS_VERSIONS: verbose_logger.warning( diff --git a/litellm/constants.py b/litellm/constants.py index f5acadc32ab..da731cb5eb2 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -151,6 +151,7 @@ DEFAULT_SEMANTIC_GUARD_SIMILARITY_THRESHOLD = float(os.getenv("DEFAULT_SEMANTIC_ MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS: Final = int(os.getenv("MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS", "60")) MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE: Final = int(os.getenv("MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE", "200")) MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL: Final = int(os.getenv("MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL", "3600")) +MCP_SSO_ASSERTION_CACHE_TTL_SECONDS: Final = int(os.getenv("MCP_SSO_ASSERTION_CACHE_TTL_SECONDS", "60")) # Default npm cache directory for STDIO MCP servers. # npm/npx needs a writable cache dir; in containers the default (~/.npm) @@ -432,6 +433,9 @@ REDIS_CONNECTION_POOL_TIMEOUT: Final = int(os.getenv("REDIS_CONNECTION_POOL_TIME REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD: Final = int(os.getenv("REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD", 5)) REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT: Final = int(os.getenv("REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT", 60)) REDIS_CIRCUIT_BREAKER_ENABLED: Final = os.getenv("REDIS_CIRCUIT_BREAKER_ENABLED", "true").lower() == "true" +# minimum seconds a timeout-only failure streak must span before it can open the breaker, +# so one event-loop stall timing out many queued calls at once does not trip it +REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION: Final = float(os.getenv("REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION", 5.0)) # Seconds of idle before a Redis cluster connection is validated with a PING and # reconnected if dead, so a connection silently dropped by a cluster restart # (e.g. ElastiCache Serverless maintenance) is not reused while broken diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index ea81e323da4..6dff3976231 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -70,7 +70,7 @@ def to_basic_auth(auth_value: str) -> str: def strip_auth_scheme(auth_value: str, scheme: str) -> str: - """Return ``auth_value`` with a leading `` `` removed, or unchanged when absent. + """Return ``auth_value`` with a leading ```` and separator removed, or unchanged when absent. Callers supply both a bare credential and a complete header value, so prefixing unconditionally yields ``Bearer Bearer ``. Scheme names are case-insensitive per @@ -78,10 +78,9 @@ def strip_auth_scheme(auth_value: str, scheme: str) -> str: with the scheme text and a scheme with nothing behind it are returned untouched. Surrounding whitespace is left to ``_strip_header_whitespace`` at header-build time. """ - scheme_name, _, remainder = auth_value.lstrip().partition(" ") - credential: Final = remainder.lstrip() - if credential and scheme_name.lower() == scheme.lower(): - return credential + parts: Final = auth_value.split(None, 1) + if len(parts) == 2 and parts[0].lower() == scheme.lower(): + return parts[1] return auth_value diff --git a/litellm/integrations/azure_storage/azure_storage.py b/litellm/integrations/azure_storage/azure_storage.py index 16ef6920114..13058bf4f22 100644 --- a/litellm/integrations/azure_storage/azure_storage.py +++ b/litellm/integrations/azure_storage/azure_storage.py @@ -36,7 +36,7 @@ AZURE_STORAGE_TOKEN_SCOPE: Final = "https://storage.azure.com/.default" def _cached_credential_chain_token_provider() -> Callable[[], str]: return get_azure_ad_token_provider( azure_scope=AZURE_STORAGE_TOKEN_SCOPE, - azure_credential=AzureCredentialType.DefaultAzureCredential, + azure_credential=AzureCredentialType.DeploymentIdentityCredential, ) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index c8e5610b4a9..9bb613654e4 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -831,10 +831,10 @@ class CustomGuardrail(CustomLogger): # should run guardrail litellm_guardrails: Final = request_data.get("guardrails") if litellm_guardrails is None or not isinstance(litellm_guardrails, list): - return response + return None if self.should_run_guardrail(data=request_data, event_type=GuardrailEventHooks.post_call) is not True: - return response + return None # CHECK IF GUARDRAIL REJECTS THE REQUEST result: Final = await self.async_post_call_success_hook( @@ -850,7 +850,7 @@ class CustomGuardrail(CustomLogger): ) if not self._is_valid_response_type(result): - return response + return None return result diff --git a/litellm/integrations/vector_store_integrations/vector_store_pre_call_hook.py b/litellm/integrations/vector_store_integrations/vector_store_pre_call_hook.py index e012d35b8f3..12ff38ce4ba 100644 --- a/litellm/integrations/vector_store_integrations/vector_store_pre_call_hook.py +++ b/litellm/integrations/vector_store_integrations/vector_store_pre_call_hook.py @@ -6,7 +6,8 @@ It searches the vector store for relevant context and appends it to the messages """ from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any, Final, cast +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final, Protocol, cast import litellm import litellm.vector_stores @@ -24,10 +25,35 @@ from litellm.types.vector_stores import ( if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.proxy.utils import PrismaClient + from litellm.router import Router else: LiteLLMLoggingObj = Any +class ProxyRuntime(Protocol): + def llm_router(self) -> "Router | None": ... + + def prisma_client(self) -> "PrismaClient | None": ... + + +@dataclass(frozen=True, slots=True) +class ProxyServerRuntime: + def llm_router(self) -> "Router | None": + try: + from litellm.proxy.proxy_server import llm_router + except ImportError: + return None + return llm_router + + def prisma_client(self) -> "PrismaClient | None": + try: + from litellm.proxy.proxy_server import prisma_client + except ImportError: + return None + return prisma_client + + class VectorStorePreCallHook(CustomLogger): CONTENT_PREFIX_STRING = "Context:\n\n" """ @@ -39,8 +65,9 @@ class VectorStorePreCallHook(CustomLogger): 3. Appends the search results as context to the messages """ - def __init__(self): + def __init__(self, proxy_runtime: ProxyRuntime | None = None): super().__init__() + self.proxy_runtime: Final[ProxyRuntime] = proxy_runtime or ProxyServerRuntime() async def async_get_chat_completion_prompt( self, @@ -79,21 +106,8 @@ class VectorStorePreCallHook(CustomLogger): if litellm.vector_store_registry is None: return model, messages, non_default_params - # Get prisma_client for database fallback - prisma_client = None - llm_router = None - try: - from litellm.proxy.proxy_server import ( - llm_router as _llm_router, - ) - from litellm.proxy.proxy_server import ( - prisma_client as _prisma_client, - ) - - prisma_client = _prisma_client - llm_router = _llm_router - except ImportError: - pass + prisma_client: Final = self.proxy_runtime.prisma_client() + llm_router: Final = self.proxy_runtime.llm_router() # Use database fallback to ensure synchronization across instances vector_stores_to_run: list[ @@ -136,15 +150,23 @@ class VectorStorePreCallHook(CustomLogger): Callable[..., Awaitable[VectorStoreSearchResponse]], litellm.vector_stores.asearch, ) - search_response = await search_function( - **{ - "vector_store_id": vector_store_id, - "query": query, - "custom_llm_provider": custom_llm_provider, - "metadata": request_metadata, - **litellm_params_for_vector_store, - }, - ) + try: + search_response = await search_function( + **{ + "vector_store_id": vector_store_id, + "query": query, + "custom_llm_provider": custom_llm_provider, + "metadata": request_metadata, + **litellm_params_for_vector_store, + }, + ) + except Exception as search_error: + verbose_logger.warning( + "Vector store search failed for vector_store_id=%s, continuing without its context: %s", + vector_store_id, + search_error, + ) + continue verbose_logger.debug("search_response: %s", search_response) @@ -153,7 +175,7 @@ class VectorStorePreCallHook(CustomLogger): # Process search results and append as context modified_messages = self._append_search_results_to_messages( - messages=messages, search_response=search_response + messages=modified_messages, search_response=search_response ) # Get the number of results for logging diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index f54eeca5178..17a19f05fa3 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -10,7 +10,7 @@ import subprocess import sys import time import traceback -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from datetime import datetime as dt_object from functools import lru_cache from types import MappingProxyType, TracebackType @@ -414,6 +414,11 @@ def _resolve_vertex_location_for_cost( return VertexBase.get_vertex_region(configured_location, model) +def _provider_response_id(source: object) -> str | None: + candidate: Final = source.get("id") if isinstance(source, dict) else getattr(source, "id", None) + return candidate if isinstance(candidate, str) and candidate else None + + class Logging(LiteLLMLoggingBaseClass): global \ supabaseClient, \ @@ -429,6 +434,7 @@ class Logging(LiteLLMLoggingBaseClass): custom_pricing: bool = False stream_options = None litellm_request_debug: bool = False + streamed_anthropic_message_id: str | None = None def __init__( self, @@ -2136,7 +2142,7 @@ class Logging(LiteLLMLoggingBaseClass): self.model_call_details["cache_hit"] = cache_hit if self.call_type == CallTypes.anthropic_messages.value: - result = self._handle_anthropic_messages_response_logging(result=result) + result = self._anthropic_messages_logged_response(result=result) elif ( self.call_type == CallTypes.generate_content.value or self.call_type == CallTypes.agenerate_content.value @@ -3806,6 +3812,23 @@ class Logging(LiteLLMLoggingBaseClass): ) return None + def record_streamed_anthropic_message_id(self, message_id: str) -> None: + self.streamed_anthropic_message_id = message_id + + def _anthropic_messages_logged_response(self, result: Any) -> ModelResponse: + """ + The ModelResponse a /v1/messages spend_logs row is built from. + + A streaming call bridged onto the Responses API is the one case where the `msg_` id the + caller was served is minted locally rather than issued upstream, so it is absent from the + response the row would otherwise be keyed on and has to be carried over here. + """ + logged: Final = self._handle_anthropic_messages_response_logging(result=result) + streamed_message_id: Final = self.streamed_anthropic_message_id + if streamed_message_id is None: + return logged + return logged.model_copy(update={"id": streamed_message_id}) + def _handle_anthropic_messages_response_logging(self, result: Any) -> ModelResponse: """ Handles logging for Anthropic messages responses. @@ -3832,11 +3855,12 @@ class Logging(LiteLLMLoggingBaseClass): if isinstance(result, ResponsesAPIResponse): return self._translate_responses_api_response_to_model_response(result) + provider_response_id: Final = _provider_response_id(result) httpx_response: Final = self.model_call_details.get("httpx_response", None) if httpx_response and isinstance(httpx_response, httpx.Response): result = litellm.AnthropicConfig().transform_response( raw_response=httpx_response, - model_response=litellm.ModelResponse(), + model_response=litellm.ModelResponse(id=provider_response_id), model=self.model, messages=[], logging_obj=self, @@ -3859,7 +3883,7 @@ class Logging(LiteLLMLoggingBaseClass): status_code=200, headers={}, ), - model_response=litellm.ModelResponse(), + model_response=litellm.ModelResponse(id=provider_response_id), json_mode=None, speed=self.optional_params.get("speed") if self.optional_params else None, ) @@ -3882,7 +3906,7 @@ class Logging(LiteLLMLoggingBaseClass): return LiteLLMResponsesTransformationHandler().transform_response( model=self.model, raw_response=result, - model_response=litellm.ModelResponse(), + model_response=litellm.ModelResponse(id=_provider_response_id(result)), logging_obj=self, request_data={}, messages=[], @@ -3897,7 +3921,7 @@ class Logging(LiteLLMLoggingBaseClass): "usage-only ModelResponse to keep the spend_logs row.", str(e), ) - model_response: Final = litellm.ModelResponse() + model_response: Final = litellm.ModelResponse(id=_provider_response_id(result)) model_response.model = self.model usage: Final = getattr(result, "usage", None) if usage is not None and ResponseAPILoggingUtils._is_response_api_usage(usage): @@ -5854,14 +5878,28 @@ def _get_status_fields( ######################################################### # Map - guardrail_information.guardrail_status to guardrail_status ######################################################### - guardrail_status: GuardrailStatus = "not_run" - if guardrail_information and isinstance(guardrail_information, list): - for information in guardrail_information: - if isinstance(information, dict): - raw_status = information.get("guardrail_status", "not_run") - if raw_status != "not_run": - guardrail_status = GUARDRAIL_STATUS_MAP.get(raw_status, "not_run") - break + # Severity order, least severe first. The status aggregates across ALL + # guardrail entries rather than taking the first non-"not_run" one: a + # pre_call guardrail that passed (e.g. a mask) records its entry before a + # later guardrail's block, and first-wins would report a blocked request + # as "success". + GUARDRAIL_STATUS_SEVERITY: Final[tuple[GuardrailStatus, ...]] = ( + "not_run", + "success", + "guardrail_failed_to_respond", + "guardrail_intervened", + ) + entries: Final[Sequence[object]] = guardrail_information if isinstance(guardrail_information, list) else () + raw_statuses: Final[Iterator[object]] = ( + entry.get("guardrail_status", "not_run") for entry in entries if isinstance(entry, dict) + ) + # A guardrail is free to write any value here, and an unhashable one would + # raise TypeError on the mapping lookup and drop the whole payload. + guardrail_status: Final[GuardrailStatus] = max( + (GUARDRAIL_STATUS_MAP.get(raw_status, "not_run") for raw_status in raw_statuses if isinstance(raw_status, str)), + key=GUARDRAIL_STATUS_SEVERITY.index, + default="not_run", + ) return StandardLoggingPayloadStatusFields(llm_api_status=llm_api_status, guardrail_status=guardrail_status) diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 21587af73aa..8e24302b440 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -415,40 +415,64 @@ def _is_off_peak(off_peak: Mapping[str, object], current_time: datetime | None = return False -def _coerce_off_peak_rate(value: object, default: float) -> float: +@dataclass(frozen=True, slots=True) +class TokenRates: + input_rate: float + output_rate: float + cache_read_rate: float + cache_creation_rate: float + reasoning_rate: float | None + + @property + def billed_reasoning_rate(self) -> float: + return self.output_rate if self.reasoning_rate is None else self.reasoning_rate + + +def _parse_off_peak_rate(value: object) -> float | None: if isinstance(value, bool): - return default + return None if isinstance(value, (int, float)): return float(value) if isinstance(value, str): try: return float(value) except ValueError: - return default - return default + return None + return None -def apply_off_peak_pricing( - model_info: ModelInfo, - current_time: datetime | None, - prompt_base_cost: float, - completion_base_cost: float, - cache_read_cost: float, -) -> tuple[float, float, float]: +def _off_peak_rate(off_peak: Mapping[str, object], key: str, standard_rate: float) -> float: + parsed: Final = _parse_off_peak_rate(off_peak.get(key)) + return standard_rate if parsed is None else parsed + + +def _open_off_peak_block(model_info: ModelInfo, current_time: datetime | None) -> Mapping[str, object] | None: + off_peak: Final = model_info.get("off_peak_pricing") + if not isinstance(off_peak, Mapping) or not _is_off_peak(off_peak, current_time): + return None + return off_peak + + +def apply_off_peak_pricing(model_info: ModelInfo, current_time: datetime | None, rates: TokenRates) -> TokenRates: """Swap in off-peak per-token rates when the current UTC time is inside one of the model's off_peak_pricing rules, the every-day hours_utc windows or a day-of-week-qualified entry in windows. An off-peak rate replaces the rate that would otherwise apply rather than discounting it, so a model that also has tiered or above-threshold pricing bills the flat off-peak rate for the whole request while the window is open. Any rate left unset in - off_peak_pricing falls back to the standard rate. + off_peak_pricing falls back to the standard rate, so a block without + output_cost_per_reasoning_token keeps the model's own reasoning rate, or its off-peak output + rate when reasoning has no dedicated rate at all. """ - off_peak: Final = model_info.get("off_peak_pricing") - if not isinstance(off_peak, Mapping) or not _is_off_peak(off_peak, current_time): - return prompt_base_cost, completion_base_cost, cache_read_cost - return ( - _coerce_off_peak_rate(off_peak.get("input_cost_per_token"), prompt_base_cost), - _coerce_off_peak_rate(off_peak.get("output_cost_per_token"), completion_base_cost), - _coerce_off_peak_rate(off_peak.get("cache_read_input_token_cost"), cache_read_cost), + off_peak: Final = _open_off_peak_block(model_info, current_time) + if off_peak is None: + return rates + off_peak_reasoning_rate: Final = _parse_off_peak_rate(off_peak.get("output_cost_per_reasoning_token")) + return TokenRates( + input_rate=_off_peak_rate(off_peak, "input_cost_per_token", rates.input_rate), + output_rate=_off_peak_rate(off_peak, "output_cost_per_token", rates.output_rate), + cache_read_rate=_off_peak_rate(off_peak, "cache_read_input_token_cost", rates.cache_read_rate), + cache_creation_rate=_off_peak_rate(off_peak, "cache_creation_input_token_cost", rates.cache_creation_rate), + reasoning_rate=rates.reasoning_rate if off_peak_reasoning_rate is None else off_peak_reasoning_rate, ) @@ -458,14 +482,28 @@ def _apply_off_peak_to_base_costs( base_costs: tuple[float, float, float, float, float], ) -> tuple[float, float, float, float, float]: """Apply off-peak rates to an already-resolved set of base costs, whichever pricing path - produced them. Cache-creation rates are passed through untouched, since off_peak_pricing - has no field for them. + produced them. The one-hour cache-creation rate passes through untouched, since + off_peak_pricing has no field for it, and reasoning is left to _resolve_billed_reasoning_rate. """ prompt, completion, cache_creation, cache_creation_above_1hr, cache_read = base_costs - off_peak_prompt, off_peak_completion, off_peak_cache_read = apply_off_peak_pricing( - model_info, current_time, prompt, completion, cache_read + rates: Final = apply_off_peak_pricing( + model_info, + current_time, + TokenRates( + input_rate=prompt, + output_rate=completion, + cache_read_rate=cache_read, + cache_creation_rate=cache_creation, + reasoning_rate=None, + ), + ) + return ( + rates.input_rate, + rates.output_rate, + rates.cache_creation_rate, + cache_creation_above_1hr, + rates.cache_read_rate, ) - return (off_peak_prompt, off_peak_completion, cache_creation, cache_creation_above_1hr, off_peak_cache_read) def _get_token_base_cost( @@ -1029,6 +1067,29 @@ def _resolve_reasoning_token_cost( return standard_reasoning_cost if standard_reasoning_cost is not None else completion_base_cost +def _resolve_billed_reasoning_rate( + model_info: ModelInfo, + usage: Usage, + service_tier: str | None, + completion_base_cost: float, + current_time: datetime | None, +) -> float: + off_peak: Final = _open_off_peak_block(model_info, current_time) + off_peak_reasoning_rate: Final = ( + None if off_peak is None else _parse_off_peak_rate(off_peak.get("output_cost_per_reasoning_token")) + ) + if off_peak_reasoning_rate is not None: + return off_peak_reasoning_rate + tiered_reasoning_rate: Final = _get_tiered_reasoning_rate(model_info=model_info, usage=usage) + if tiered_reasoning_rate is not None: + return tiered_reasoning_rate + return _resolve_reasoning_token_cost( + model_info=model_info, + service_tier=service_tier, + completion_base_cost=completion_base_cost, + ) + + def generic_cost_per_token( model: str, usage: Usage, @@ -1037,6 +1098,7 @@ def generic_cost_per_token( data_residency: str | None = None, model_info: ModelInfo | None = None, vertex_location: str | None = None, + current_time: datetime | None = None, ) -> tuple[float, float]: """ Calculates the cost per token for a given model, prompt tokens, and completion tokens. @@ -1051,6 +1113,7 @@ def generic_cost_per_token( - vertex_location: optional Vertex AI location the request was served from (e.g. "us-east5", "global"), used to apply the per-model regional-endpoint uplift multiplier when non-global. + - current_time: the moment the request is billed at, for off_peak_pricing; defaults to now, UTC Returns: Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd @@ -1117,6 +1180,7 @@ def generic_cost_per_token( usage.prompt_tokens - cache_hit - audio_tokens - cache_creation - image_tokens - video_tokens, 0 ) + billing_time: Final = current_time if current_time is not None else datetime.now(timezone.utc) ( prompt_base_cost, completion_base_cost, @@ -1127,6 +1191,7 @@ def generic_cost_per_token( model_info=model_info, usage=usage, service_tier=service_tier, + current_time=billing_time, threshold_is_inclusive=_uses_inclusive_token_thresholds(custom_llm_provider), ) @@ -1185,17 +1250,13 @@ def generic_cost_per_token( ## REASONING COST if not is_text_tokens_total and reasoning_tokens and reasoning_tokens > 0: - tiered_reasoning_rate: Final = _get_tiered_reasoning_rate(model_info=model_info, usage=usage) - _output_cost_per_reasoning_token = ( - tiered_reasoning_rate - if tiered_reasoning_rate is not None - else _resolve_reasoning_token_cost( - model_info=model_info, - service_tier=service_tier, - completion_base_cost=completion_base_cost, - ) + completion_cost += float(reasoning_tokens) * _resolve_billed_reasoning_rate( + model_info=model_info, + usage=usage, + service_tier=service_tier, + completion_base_cost=completion_base_cost, + current_time=billing_time, ) - completion_cost += float(reasoning_tokens) * _output_cost_per_reasoning_token ## IMAGE COST if not is_text_tokens_total and image_tokens and image_tokens > 0: @@ -1247,6 +1308,7 @@ def get_token_type_cost_breakdown( service_tier: str | None = None, data_residency: str | None = None, vertex_location: str | None = None, + current_time: datetime | None = None, ) -> TokenTypeCostBreakdown: """ Provider-agnostic cost of reasoning and cache tokens, derived from the usage @@ -1265,6 +1327,7 @@ def get_token_type_cost_breakdown( except Exception: return TokenTypeCostBreakdown(0.0, 0.0, 0.0) + billing_time: Final = current_time if current_time is not None else datetime.now(timezone.utc) ( _prompt_base_cost, completion_base_cost, @@ -1275,6 +1338,7 @@ def get_token_type_cost_breakdown( model_info=model_info, usage=usage, service_tier=service_tier, + current_time=billing_time, threshold_is_inclusive=_uses_inclusive_token_thresholds(custom_llm_provider), ) @@ -1284,18 +1348,12 @@ def get_token_type_cost_breakdown( if not reasoning_tokens: reasoning_tokens = _coerce_token_count(getattr(usage, "reasoning_tokens", 0)) - # Reasoning is billed at the selected tier's reasoning rate for tiered models, - # else at the service-tier-aware per-reasoning-token rate - this mirrors how the - # total completion cost is computed, so the breakdown can never diverge from it. - tiered_reasoning_rate: Final = _get_tiered_reasoning_rate(model_info=model_info, usage=usage) - reasoning_rate: Final = ( - tiered_reasoning_rate - if tiered_reasoning_rate is not None - else _resolve_reasoning_token_cost( - model_info=model_info, - service_tier=service_tier, - completion_base_cost=completion_base_cost, - ) + reasoning_rate: Final = _resolve_billed_reasoning_rate( + model_info=model_info, + usage=usage, + service_tier=service_tier, + completion_base_cost=completion_base_cost, + current_time=billing_time, ) reasoning_cost = float(reasoning_tokens) * reasoning_rate diff --git a/litellm/litellm_core_utils/secret_redaction.py b/litellm/litellm_core_utils/secret_redaction.py index b62226a6a19..390abf41955 100644 --- a/litellm/litellm_core_utils/secret_redaction.py +++ b/litellm/litellm_core_utils/secret_redaction.py @@ -11,7 +11,7 @@ from typing import Final from litellm.constants import MINIMUM_CUSTOM_KEY_LENGTH -_REDACTED: Final = "REDACTED" +REDACTED: Final = "REDACTED" def _build_secret_patterns() -> "re.Pattern[str]": @@ -89,7 +89,7 @@ _SECRET_RE: Final = _build_secret_patterns() def redact_string(value: str) -> str: """Scrub known secret/credential patterns from *value* and return the result.""" - return _SECRET_RE.sub(_REDACTED, value) + return _SECRET_RE.sub(REDACTED, value) _UNIX_SYSTEM_PATH: Final = r"/(?:etc|var|opt|usr|home|root|private|Users|tmp|mnt|srv)/[^\s'\"\)\]}>,]+" @@ -110,7 +110,7 @@ def redact_internal_details(value: str) -> str: on top of redact_string(). For client-facing messages only: server logs keep this detail.""" marker_index: Final = value.find(_TRACEBACK_MARKER) without_traceback: Final = value[:marker_index].rstrip() if marker_index != -1 else value - return _INTERNAL_DETAIL_RE.sub(_REDACTED, redact_string(without_traceback)) + return _INTERNAL_DETAIL_RE.sub(REDACTED, redact_string(without_traceback)) def redact_structured_value(key: str | None, value: str) -> str: @@ -126,4 +126,4 @@ def redact_structured_value(key: str | None, value: str) -> str: if scrubbed != value or key is None: return scrubbed rendered: Final = f"'{key}': '{value}'" - return _REDACTED if redact_string(rendered) != rendered else value + return REDACTED if redact_string(rendered) != rendered else value diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index 3d60c1bda12..15b2c879224 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -1,9 +1,10 @@ -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Any, Final from pydantic import BaseModel -from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER +from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH, DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER +from litellm.litellm_core_utils.secret_redaction import REDACTED class SensitiveDataMasker: @@ -214,6 +215,46 @@ def mask_sensitive_keys(data: dict[str, Any], sensitive_fields: set[str]) -> dic return masked +def redact_credentials_in_payload(data: Mapping[str, object]) -> Mapping[str, object]: + """Return a copy of ``data`` where every value under a credential-named key is + replaced by the shared ``REDACTED`` marker, nested mappings are recursed into, + and every other value is preserved by identity. + + Sensitive-key detection is delegated to the shared :class:`SensitiveDataMasker`, + so the credential names stay in one place. Unlike + :func:`mask_credentials_in_payload`, no prefix or suffix of the secret survives + and non-string secrets are covered too, which is what a payload rendered + straight to stdout needs. ``None`` is preserved so an unset credential still + reads as unset, and lists and tuples are rebuilt element by element so a + credential nested inside one is caught as well. The walk is bounded only to stop + runaway recursion, and a container sitting at that bound is replaced wholesale + rather than passed through, so burying a credential deeper than the walk goes + hides it instead of exposing it. + """ + return _redact_mapping(data, 0) + + +def _redact_mapping(data: Mapping[str, object], depth: int) -> Mapping[str, object]: + return {key: _redact_entry(key, value, depth) for key, value in data.items()} + + +def _redact_entry(key: str, value: object, depth: int) -> object: + if value is not None and _default_masker.is_sensitive_key(key): + return REDACTED + if not isinstance(value, (Mapping, list, tuple)): + return value + if depth >= DEFAULT_MAX_RECURSE_DEPTH: + return REDACTED + if isinstance(value, Mapping): + return _redact_mapping(value, depth + 1) + return _redact_sequence(value, depth + 1) + + +def _redact_sequence(values: Sequence[object], depth: int) -> Sequence[object]: + redacted: Final = tuple(_redact_entry("", item, depth) for item in values) + return redacted if isinstance(values, tuple) else list(redacted) + + # Usage example: """ masker = SensitiveDataMasker() diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 9d61701d26d..87a29ca50ba 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -21,6 +21,7 @@ from litellm.llms.anthropic.experimental_pass_through.context_management import ) from litellm.llms.anthropic.experimental_pass_through.utils import ( is_reasoning_auto_summary_enabled, + litellm_logging_obj_from_kwargs, local_model_name, ) from litellm.types.llms.anthropic_messages.anthropic_response import ( @@ -621,6 +622,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: tool_name_mapping=tool_name_mapping, polyfill_result=polyfill_result, is_async=True, + litellm_logging_obj=litellm_logging_obj_from_kwargs(kwargs), ) if transformed_stream is not None: return transformed_stream @@ -755,6 +757,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: tool_name_mapping=tool_name_mapping, polyfill_result=polyfill_result, is_async=False, + litellm_logging_obj=litellm_logging_obj_from_kwargs(kwargs), ) if transformed_stream is not None: return transformed_stream 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 cc5879df56d..78ff83cafbf 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -31,6 +31,7 @@ from litellm.types.llms.anthropic import ( from litellm.types.utils import AdapterCompletionStreamWrapper, Delta if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject from litellm.types.utils import ModelResponseStream @@ -287,12 +288,16 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): applied_edits: list[AppliedEdit] | None = None, compaction_block: CompactionBlock | None = None, iterations_usage: list[UsageIteration] | None = None, + litellm_logging_obj: "LiteLLMLoggingObject | None" = None, ): # Wrap the upstream stream so chunks that carry both content and a # finish_reason (fake-streamed providers) are split into two — see # _CombinedChunkSplitter. super().__init__(_CombinedChunkSplitter(completion_stream)) self.model = model + self._message_id: str = f"msg_{uuid.uuid4()}" + if litellm_logging_obj is not None: + litellm_logging_obj.record_streamed_anthropic_message_id(self._message_id) # Mapping of truncated tool names to original names (for OpenAI's 64-char limit) self.tool_name_mapping = tool_name_mapping or {} # Polyfill applied_edits on final message_delta. @@ -507,7 +512,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): { "type": "message_start", "message": { - "id": f"msg_{uuid.uuid4()}", + "id": self._message_id, "type": "message", "role": "assistant", "content": [], @@ -741,7 +746,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): { "type": "message_start", "message": { - "id": f"msg_{uuid.uuid4()}", + "id": self._message_id, "type": "message", "role": "assistant", "content": [], diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 199a8ab77e7..573a461e89e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -174,6 +174,7 @@ from litellm.types.utils import Choices, ModelResponse, StreamingChoices, Usage from .streaming_iterator import AnthropicStreamWrapper if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject from litellm.types.llms.anthropic import ContentBlockContentBlockDict ToolResultContent: TypeAlias = str | list[ToolMessageContentPart] @@ -264,6 +265,7 @@ class AnthropicAdapter: tool_name_mapping: dict[str, str] | None = None, polyfill_result: PolyfillResult | None = None, is_async: bool = True, + litellm_logging_obj: "LiteLLMLoggingObject | None" = None, ) -> AsyncIterator[bytes] | Iterator[bytes] | None: """ Translate OpenAI streaming response to Anthropic format. @@ -290,6 +292,7 @@ class AnthropicAdapter: applied_edits=applied_edits, compaction_block=compaction_block, iterations_usage=iterations_usage, + litellm_logging_obj=litellm_logging_obj, ) # Return the SSE-wrapped version for proper event formatting. if is_async: diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/utils.py b/litellm/llms/anthropic/experimental_pass_through/messages/utils.py index 9deff950724..242300c7b6d 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/utils.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/utils.py @@ -6,6 +6,7 @@ from litellm.types.llms.anthropic import AnthropicMessagesRequestOptionalParams from litellm.types.llms.anthropic_messages.anthropic_response import ( AnthropicMessagesResponse, ) +from litellm.types.llms.openai import ChatCompletionSystemMessage if TYPE_CHECKING: from litellm.exceptions import ContentPolicyViolationError @@ -36,6 +37,16 @@ def safeguard_refusal_error(model: str, stop_details: Mapping[str, object]) -> " ) +def anthropic_system_to_openai_message(system: object) -> ChatCompletionSystemMessage | None: + """ + Return the Anthropic Messages top-level ``system`` (a string or a list of text + blocks) as an OpenAI-style system message, or None when the request has none. + """ + if not isinstance(system, (str, list)) or not system: + return None + return ChatCompletionSystemMessage(role="system", content=system) + + @lru_cache(maxsize=1) def _anthropic_messages_optional_param_keys() -> frozenset[str]: """ diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py index ec0560016da..0445c23ed8c 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py @@ -20,7 +20,7 @@ from litellm.types.llms.anthropic_messages.anthropic_response import ( ) from litellm.types.llms.openai import ResponsesAPIResponse -from ..utils import local_model_name +from ..utils import litellm_logging_obj_from_kwargs, local_model_name from .streaming_iterator import AnthropicResponsesStreamWrapper from .transformation import LiteLLMAnthropicToResponsesAPIAdapter @@ -186,7 +186,9 @@ class LiteLLMMessagesToResponsesAPIHandler: if stream: wrapper: Final = AnthropicResponsesStreamWrapper( - responses_stream=result, model=local_model_name(model, kwargs.get("custom_llm_provider")) + responses_stream=result, + model=local_model_name(model, kwargs.get("custom_llm_provider")), + litellm_logging_obj=litellm_logging_obj_from_kwargs(responses_kwargs), ) return wrapper.async_anthropic_sse_wrapper() @@ -266,7 +268,9 @@ class LiteLLMMessagesToResponsesAPIHandler: if stream: wrapper: Final = AnthropicResponsesStreamWrapper( - responses_stream=result, model=local_model_name(model, kwargs.get("custom_llm_provider")) + responses_stream=result, + model=local_model_name(model, kwargs.get("custom_llm_provider")), + litellm_logging_obj=litellm_logging_obj_from_kwargs(responses_kwargs), ) return wrapper.async_anthropic_sse_wrapper() diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py index 292d2622c7f..a97ce18d179 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py @@ -4,7 +4,7 @@ import json import traceback from collections import deque from collections.abc import AsyncIterator, Mapping -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final from litellm import verbose_logger from litellm._uuid import uuid @@ -12,6 +12,9 @@ from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUs from .transformation import LiteLLMAnthropicToResponsesAPIAdapter +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject + class AnthropicResponsesStreamWrapper: """ @@ -31,10 +34,13 @@ class AnthropicResponsesStreamWrapper: self, responses_stream: Any, model: str, + litellm_logging_obj: "LiteLLMLoggingObject | None" = None, ) -> None: self.responses_stream = responses_stream self.model = model self._message_id: str = f"msg_{uuid.uuid4()}" + if litellm_logging_obj is not None: + litellm_logging_obj.record_streamed_anthropic_message_id(self._message_id) self._current_block_index: int = -1 # Map item_id -> content_block_index so we can stop the right block later self._item_id_to_block_index: dict[str, int] = {} diff --git a/litellm/llms/anthropic/experimental_pass_through/utils.py b/litellm/llms/anthropic/experimental_pass_through/utils.py index 716a4f54778..55fe9c47faf 100644 --- a/litellm/llms/anthropic/experimental_pass_through/utils.py +++ b/litellm/llms/anthropic/experimental_pass_through/utils.py @@ -1,11 +1,14 @@ import os from collections.abc import Mapping from types import MappingProxyType -from typing import Final +from typing import TYPE_CHECKING, Final import litellm from litellm.types.utils import ModelInfo +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject + OPENAI_MAX_PROMPT_CACHE_KEY_LENGTH: Final = 64 _EFFORT_DEGRADATION_CHAIN: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType( @@ -24,6 +27,14 @@ def prompt_cache_key_from_user_id(user_id: object) -> str | None: return str(user_id)[:OPENAI_MAX_PROMPT_CACHE_KEY_LENGTH] or None +def litellm_logging_obj_from_kwargs(kwargs: Mapping[str, object]) -> "LiteLLMLoggingObject | None": + """The logging object the bridged call logs through, when the caller supplied one.""" + from litellm.litellm_core_utils.litellm_logging import Logging + + candidate: Final = kwargs.get("litellm_logging_obj") + return candidate if isinstance(candidate, Logging) else None + + def local_model_name(model: str, custom_llm_provider: object) -> str: """The id the provider itself knows, for reporting back to the caller in ``message_start``.""" return model.removeprefix(f"{custom_llm_provider}/") if isinstance(custom_llm_provider, str) else model diff --git a/litellm/llms/bedrock/base_aws_llm.py b/litellm/llms/bedrock/base_aws_llm.py index 1e634ced29b..c3da992a904 100644 --- a/litellm/llms/bedrock/base_aws_llm.py +++ b/litellm/llms/bedrock/base_aws_llm.py @@ -7,7 +7,7 @@ import urllib.parse from collections.abc import Callable from datetime import datetime from threading import Lock -from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, cast, get_args +from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, cast, get_args, overload import httpx from pydantic import BaseModel, ValidationError @@ -48,12 +48,24 @@ _STS_REGION_FROM_ENDPOINT_PATTERN: Final = re.compile( SIGV4_COMPUTED_HEADERS: Final = frozenset({"authorization", "x-amz-date", "x-amz-security-token", "date"}) -class Boto3CredentialsInfo(BaseModel): - credentials: Credentials +class BedrockRequestTarget(BaseModel): aws_region_name: str aws_bedrock_runtime_endpoint: str | None +class Boto3CredentialsInfo(BedrockRequestTarget): + credentials: Credentials + + +class BearerRequestTarget(BedrockRequestTarget): + credentials: None = None + + +def bedrock_bearer_token(api_key: str | None) -> str | None: + token: Final = api_key if api_key is not None else get_secret_str("AWS_BEARER_TOKEN_BEDROCK") + return token or None + + class _WebIdentityTokenClaims(BaseModel): aud: str | list[str] | None = None iss: str | None = None @@ -1387,9 +1399,26 @@ class BaseAWSLLM: else: return f"https://bedrock-runtime.{aws_region_name}.{dns_suffix}" + @overload def _get_boto_credentials_from_optional_params( - self, optional_params: dict, model: str | None = None - ) -> Boto3CredentialsInfo: + self, + optional_params: dict, # mutable-ok: the implementation pops the aws_* keys out of the caller's dict in place + model: str | None = None, + bearer_token: None = None, + ) -> Boto3CredentialsInfo: ... + + @overload + def _get_boto_credentials_from_optional_params( + self, + optional_params: dict, # mutable-ok: the implementation pops the aws_* keys out of the caller's dict in place + model: str | None = None, + *, + bearer_token: str, + ) -> BearerRequestTarget: ... + + def _get_boto_credentials_from_optional_params( + self, optional_params: dict, model: str | None = None, bearer_token: str | None = None + ) -> Boto3CredentialsInfo | BearerRequestTarget: """ Get boto3 credentials from optional params @@ -1420,6 +1449,12 @@ class BaseAWSLLM: ) # https://bedrock-runtime.{region_name}.amazonaws.com aws_external_id: Final = optional_params.pop("aws_external_id", None) + if bearer_token is not None: + return BearerRequestTarget( + aws_region_name=aws_region_name, + aws_bedrock_runtime_endpoint=aws_bedrock_runtime_endpoint, + ) + credentials: Final[Credentials] = self.get_credentials( aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, @@ -1432,7 +1467,6 @@ class BaseAWSLLM: aws_sts_endpoint=aws_sts_endpoint, aws_external_id=aws_external_id, ) - return Boto3CredentialsInfo( credentials=credentials, aws_region_name=aws_region_name, @@ -1451,14 +1485,9 @@ class BaseAWSLLM: api_key: str | None = None, supports_bearer_token: bool = True, ) -> AWSPreparedRequest: - if not supports_bearer_token: - aws_bearer_token: str | None = None - elif api_key is not None: - aws_bearer_token = api_key - else: - aws_bearer_token = get_secret_str("AWS_BEARER_TOKEN_BEDROCK") + aws_bearer_token: Final = bedrock_bearer_token(api_key) if supports_bearer_token else None - if aws_bearer_token: + if aws_bearer_token is not None: try: from botocore.awsrequest import AWSRequest except ImportError: @@ -1555,13 +1584,9 @@ class BaseAWSLLM: Returns: Tuple[dict, Optional[str]]: A tuple containing the headers and the json str body of the request """ - if api_key is not None: - aws_bearer_token: str | None = api_key - else: - aws_bearer_token = get_secret_str("AWS_BEARER_TOKEN_BEDROCK") + aws_bearer_token: Final = bedrock_bearer_token(api_key) - # If aws bearer token is set, use it directly in the header - if aws_bearer_token: + if aws_bearer_token is not None: headers = headers or {} headers["Content-Type"] = "application/json" headers["Authorization"] = f"Bearer {aws_bearer_token}" diff --git a/litellm/llms/bedrock/chat/converse_handler.py b/litellm/llms/bedrock/chat/converse_handler.py index 7d5f99ca893..a75124325ae 100644 --- a/litellm/llms/bedrock/chat/converse_handler.py +++ b/litellm/llms/bedrock/chat/converse_handler.py @@ -21,7 +21,7 @@ from litellm.rust_bridge.chat_completions import rust_chat_completions_accepts from litellm.types.utils import ModelResponse from litellm.utils import CustomStreamWrapper -from ..base_aws_llm import BaseAWSLLM, Credentials +from ..base_aws_llm import BaseAWSLLM, Credentials, bedrock_bearer_token from ..common_utils import BedrockError, _get_all_bedrock_regions from .invoke_handler import AWSEventStreamDecoder, MockResponseIterator, make_call @@ -349,17 +349,21 @@ class BedrockConverseLLM(BaseAWSLLM): litellm_params["aws_region_name"] = aws_region_name # [DO NOT DELETE] important for async calls - credentials: Final[Credentials | None] = self.get_credentials( - aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key, - aws_session_token=aws_session_token, - aws_region_name=aws_region_name, - aws_session_name=aws_session_name, - aws_profile_name=aws_profile_name, - aws_role_name=aws_role_name, - aws_web_identity_token=aws_web_identity_token, - aws_sts_endpoint=aws_sts_endpoint, - aws_external_id=aws_external_id, + credentials: Final[Credentials | None] = ( + None + if bedrock_bearer_token(api_key) is not None + else self.get_credentials( + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + aws_region_name=aws_region_name, + aws_session_name=aws_session_name, + aws_profile_name=aws_profile_name, + aws_role_name=aws_role_name, + aws_web_identity_token=aws_web_identity_token, + aws_sts_endpoint=aws_sts_endpoint, + aws_external_id=aws_external_id, + ) ) ### SET RUNTIME ENDPOINT ### diff --git a/litellm/llms/bedrock/chat/invoke_transformations/amazon_moonshot_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/amazon_moonshot_transformation.py index 91c3a363c31..04c6ec86a13 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/amazon_moonshot_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/amazon_moonshot_transformation.py @@ -149,19 +149,15 @@ class AmazonMoonshotConfig(AmazonInvokeConfig, MoonshotChatConfig): - Temperature and parameter validation """ - # Filter out AWS credentials using the existing method from BaseAWSLLM - self._get_boto_credentials_from_optional_params(optional_params, model) + inference_params: Final = {k: v for k, v in optional_params.items() if k not in self.aws_authentication_params} - # Strip routing prefixes to get the actual model ID clean_model_id: Final = self._get_model_id(model) - # Use Moonshot's transform_request which handles message transformation - # and tool_choice="required" workaround return MoonshotChatConfig.transform_request( self, model=clean_model_id, messages=messages, - optional_params=optional_params, + optional_params=inference_params, litellm_params=litellm_params, headers=headers, ) diff --git a/litellm/llms/bedrock/embed/embedding.py b/litellm/llms/bedrock/embed/embedding.py index c34ca7750e2..5fb86d476f4 100644 --- a/litellm/llms/bedrock/embed/embedding.py +++ b/litellm/llms/bedrock/embed/embedding.py @@ -6,7 +6,7 @@ import copy import json import urllib.parse from collections.abc import Callable -from typing import TYPE_CHECKING, Any, Final, get_args +from typing import TYPE_CHECKING, Final, get_args, overload import httpx @@ -26,7 +26,7 @@ from litellm.types.llms.bedrock import ( ) from litellm.types.utils import EmbeddingResponse, LlmProviders -from ..base_aws_llm import BaseAWSLLM +from ..base_aws_llm import BaseAWSLLM, Credentials, bedrock_bearer_token from ..common_utils import BedrockError from .amazon_nova_transformation import AmazonNovaEmbeddingConfig from .amazon_titan_g1_transformation import AmazonTitanG1Config @@ -42,14 +42,25 @@ if TYPE_CHECKING: class BedrockEmbedding(BaseAWSLLM): + @overload + def _load_credentials( + self, + optional_params: dict, # mutable-ok: the implementation pops the aws_* keys out of the caller's dict in place + bearer_token: None = None, + ) -> tuple[Credentials, str]: ... + + @overload + def _load_credentials( + self, + optional_params: dict, # mutable-ok: the implementation pops the aws_* keys out of the caller's dict in place + bearer_token: str, + ) -> tuple[None, str]: ... + def _load_credentials( self, optional_params: dict, - ) -> tuple[Any, str]: - try: - from botocore.credentials import Credentials - except ImportError: - raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") + bearer_token: str | None = None, + ) -> tuple[Credentials | None, str]: ## CREDENTIALS ## # pop aws_secret_access_key, aws_access_key_id, aws_session_token, aws_region_name from kwargs, since completion calls fail with them aws_secret_access_key: Final = optional_params.pop("aws_secret_access_key", None) @@ -78,17 +89,21 @@ class BedrockEmbedding(BaseAWSLLM): if aws_region_name is None: aws_region_name = "us-west-2" - credentials: Final[Credentials] = self.get_credentials( - aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key, - aws_session_token=aws_session_token, - aws_region_name=aws_region_name, - aws_session_name=aws_session_name, - aws_profile_name=aws_profile_name, - aws_role_name=aws_role_name, - aws_web_identity_token=aws_web_identity_token, - aws_sts_endpoint=aws_sts_endpoint, - aws_external_id=aws_external_id, + credentials: Final[Credentials | None] = ( + None + if bearer_token is not None + else self.get_credentials( + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + aws_region_name=aws_region_name, + aws_session_name=aws_session_name, + aws_profile_name=aws_profile_name, + aws_role_name=aws_role_name, + aws_web_identity_token=aws_web_identity_token, + aws_sts_endpoint=aws_sts_endpoint, + aws_external_id=aws_external_id, + ) ) return credentials, aws_region_name @@ -233,7 +248,7 @@ class BedrockEmbedding(BaseAWSLLM): client: HTTPHandler | None, timeout: float | httpx.Timeout | None, batch_data: list[dict], - credentials: Any, + credentials: Credentials | None, extra_headers: dict | None, endpoint_url: str, aws_region_name: str, @@ -301,7 +316,7 @@ class BedrockEmbedding(BaseAWSLLM): client: AsyncHTTPHandler | None, timeout: float | httpx.Timeout | None, batch_data: list[dict], - credentials: Any, + credentials: Credentials | None, extra_headers: dict | None, endpoint_url: str, aws_region_name: str, @@ -383,7 +398,9 @@ class BedrockEmbedding(BaseAWSLLM): litellm_params: dict, api_key: str | None = None, ) -> EmbeddingResponse: - credentials, aws_region_name = self._load_credentials(optional_params) + credentials, aws_region_name = self._load_credentials( + optional_params, bearer_token=bedrock_bearer_token(api_key) + ) ### TRANSFORMATION ### unencoded_model_id: Final = optional_params.pop("model_id", None) or model # default to model if not passed diff --git a/litellm/llms/bedrock/image_edit/handler.py b/litellm/llms/bedrock/image_edit/handler.py index 9d8631c7c26..5c517f2049c 100644 --- a/litellm/llms/bedrock/image_edit/handler.py +++ b/litellm/llms/bedrock/image_edit/handler.py @@ -29,7 +29,7 @@ from litellm.llms.custom_httpx.http_handler import ( ) from litellm.types.utils import ImageResponse -from ..base_aws_llm import BaseAWSLLM +from ..base_aws_llm import BaseAWSLLM, bedrock_bearer_token from ..common_utils import BedrockError if TYPE_CHECKING: @@ -198,7 +198,9 @@ class BedrockImageEdit(BaseAWSLLM): Returns: BedrockImageEditPreparedRequest: The prepared request object """ - boto3_credentials_info: Final = self._get_boto_credentials_from_optional_params(optional_params, model) + boto3_credentials_info: Final = self._get_boto_credentials_from_optional_params( + optional_params, model, bearer_token=bedrock_bearer_token(api_key) + ) # Use the existing ARN-aware provider detection method bedrock_provider: Final = self.get_bedrock_invoke_provider(model) diff --git a/litellm/llms/bedrock/image_generation/image_handler.py b/litellm/llms/bedrock/image_generation/image_handler.py index 6fac14a0dc3..c78e3c147cb 100644 --- a/litellm/llms/bedrock/image_generation/image_handler.py +++ b/litellm/llms/bedrock/image_generation/image_handler.py @@ -29,7 +29,7 @@ from litellm.llms.custom_httpx.http_handler import ( ) from litellm.types.utils import ImageResponse -from ..base_aws_llm import BaseAWSLLM +from ..base_aws_llm import BaseAWSLLM, bedrock_bearer_token from ..common_utils import BedrockError if TYPE_CHECKING: @@ -220,7 +220,9 @@ class BedrockImageGeneration(BaseAWSLLM): prepped (httpx.Request): The prepared request object body (bytes): The request body """ - boto3_credentials_info: Final = self._get_boto_credentials_from_optional_params(optional_params, model) + boto3_credentials_info: Final = self._get_boto_credentials_from_optional_params( + optional_params, model, bearer_token=bedrock_bearer_token(api_key) + ) # Use the existing ARN-aware provider detection method bedrock_provider: Final = self.get_bedrock_invoke_provider(model) diff --git a/litellm/llms/custom_httpx/container_handler.py b/litellm/llms/custom_httpx/container_handler.py index dd20a8c2ed4..de72735e4ec 100644 --- a/litellm/llms/custom_httpx/container_handler.py +++ b/litellm/llms/custom_httpx/container_handler.py @@ -139,7 +139,7 @@ def _build_query_params( return {name: value if isinstance(value, str) else str(value) for name, value in supplied if value is not None} -def _error_message_from_response(response: httpx.Response) -> str: +def error_message_from_response(response: httpx.Response) -> str: try: body: Final = response.json() except ValueError: @@ -153,6 +153,16 @@ def _error_message_from_response(response: httpx.Response) -> str: return response.text +def raise_for_error_status(response: httpx.Response, container_provider_config: "BaseContainerConfig") -> None: + if not httpx.codes.is_error(response.status_code): + return + raise container_provider_config.get_error_class( + error_message=error_message_from_response(response), + status_code=response.status_code, + headers=response.headers, + ) + + def _transform_response( response: httpx.Response, returns_binary: bool, @@ -163,7 +173,7 @@ def _transform_response( if httpx.codes.is_error(response.status_code): raise BaseLLMException( status_code=response.status_code, - message=_error_message_from_response(response), + message=error_message_from_response(response), headers=dict(response.headers), ) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 2896d889f96..f281c249c72 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -77,6 +77,7 @@ from litellm.llms.base_llm.vector_store_files.transformation import ( BaseVectorStoreFilesConfig, ) from litellm.llms.base_llm.videos.transformation import BaseVideoConfig +from litellm.llms.custom_httpx.container_handler import raise_for_error_status from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -8763,17 +8764,19 @@ class BaseLLMHTTPHandler: json=data, timeout=timeout, ) - - return container_provider_config.transform_container_create_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_create_response( + raw_response=response, + logging_obj=logging_obj, + ) async def async_container_create_handler( self, @@ -8839,17 +8842,19 @@ class BaseLLMHTTPHandler: json=data, timeout=timeout, ) - - return container_provider_config.transform_container_create_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_create_response( + raw_response=response, + logging_obj=logging_obj, + ) def container_list_handler( self, @@ -8929,17 +8934,19 @@ class BaseLLMHTTPHandler: headers=headers, params=params or None, ) - - return container_provider_config.transform_container_list_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_list_response( + raw_response=response, + logging_obj=logging_obj, + ) async def async_container_list_handler( self, @@ -9006,17 +9013,19 @@ class BaseLLMHTTPHandler: headers=headers, params=params or None, ) - - return container_provider_config.transform_container_list_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_list_response( + raw_response=response, + logging_obj=logging_obj, + ) def container_retrieve_handler( self, @@ -9094,17 +9103,19 @@ class BaseLLMHTTPHandler: headers=headers, params=params or None, ) - - return container_provider_config.transform_container_retrieve_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_retrieve_response( + raw_response=response, + logging_obj=logging_obj, + ) async def async_container_retrieve_handler( self, @@ -9171,17 +9182,19 @@ class BaseLLMHTTPHandler: headers=headers, params=params or None, ) - - return container_provider_config.transform_container_retrieve_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_retrieve_response( + raw_response=response, + logging_obj=logging_obj, + ) def container_delete_handler( self, @@ -9259,17 +9272,19 @@ class BaseLLMHTTPHandler: headers=headers, params=params or None, ) - - return container_provider_config.transform_container_delete_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_delete_response( + raw_response=response, + logging_obj=logging_obj, + ) async def async_container_delete_handler( self, @@ -9336,17 +9351,19 @@ class BaseLLMHTTPHandler: headers=headers, params=params or None, ) - - return container_provider_config.transform_container_delete_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_delete_response( + raw_response=response, + logging_obj=logging_obj, + ) def container_file_list_handler( self, @@ -9428,17 +9445,19 @@ class BaseLLMHTTPHandler: headers=headers, params=params or None, ) - - return container_provider_config.transform_container_file_list_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_file_list_response( + raw_response=response, + logging_obj=logging_obj, + ) async def async_container_file_list_handler( self, @@ -9507,17 +9526,19 @@ class BaseLLMHTTPHandler: headers=headers, params=params or None, ) - - return container_provider_config.transform_container_file_list_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_file_list_response( + raw_response=response, + logging_obj=logging_obj, + ) def container_file_content_handler( self, @@ -9593,17 +9614,19 @@ class BaseLLMHTTPHandler: headers=headers, params=params or None, ) - - return container_provider_config.transform_container_file_content_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_file_content_response( + raw_response=response, + logging_obj=logging_obj, + ) async def async_container_file_content_handler( self, @@ -9669,17 +9692,19 @@ class BaseLLMHTTPHandler: headers=headers, params=params or None, ) - - return container_provider_config.transform_container_file_content_response( - raw_response=response, - logging_obj=logging_obj, - ) - except Exception as e: raise self._handle_error( e=e, provider_config=container_provider_config, ) + raise_for_error_status( + response=response, + container_provider_config=container_provider_config, + ) + return container_provider_config.transform_container_file_content_response( + raw_response=response, + logging_obj=logging_obj, + ) ###### VECTOR STORE HANDLER ###### @staticmethod diff --git a/litellm/llms/dashscope/cost_calculator.py b/litellm/llms/dashscope/cost_calculator.py index d8eb1f9f8d7..17f70ec5db7 100644 --- a/litellm/llms/dashscope/cost_calculator.py +++ b/litellm/llms/dashscope/cost_calculator.py @@ -7,12 +7,13 @@ cached, cache-creation, output, reasoning) is billed at that one tier's rate. See https://help.aliyun.com/zh/model-studio/billing-for-model-studio """ -from dataclasses import dataclass, replace +from dataclasses import dataclass from datetime import datetime from typing import Final from litellm.litellm_core_utils.llm_cost_calc.tiered_pricing import select_tier_for_input, tier_rate from litellm.litellm_core_utils.llm_cost_calc.utils import ( + TokenRates, apply_off_peak_pricing, parse_completion_tokens_details, parse_prompt_tokens_details, @@ -34,19 +35,6 @@ class TokenBreakdown: return self.text_tokens + self.cached_tokens + self.cache_creation_tokens -@dataclass(frozen=True, slots=True) -class TokenRates: - input_rate: float - cache_read_rate: float - cache_creation_rate: float - output_rate: float - reasoning_rate: float | None - - @property - def billed_reasoning_rate(self) -> float: - return self.output_rate if self.reasoning_rate is None else self.reasoning_rate - - def _extract_token_breakdown(usage: Usage) -> TokenBreakdown: prompt_details: Final = parse_prompt_tokens_details(usage) cached_tokens: Final = prompt_details["cache_hit_tokens"] @@ -105,13 +93,6 @@ def _tier_rates(model_info: ModelInfo, tier: dict) -> TokenRates: ) -def _off_peak_rates(model_info: ModelInfo, current_time: datetime | None, rates: TokenRates) -> TokenRates: - input_rate, output_rate, cache_read_rate = apply_off_peak_pricing( - model_info, current_time, rates.input_rate, rates.output_rate, rates.cache_read_rate - ) - return replace(rates, input_rate=input_rate, output_rate=output_rate, cache_read_rate=cache_read_rate) - - def _bill(breakdown: TokenBreakdown, rates: TokenRates) -> tuple[float, float]: prompt_cost: Final = ( (breakdown.text_tokens * rates.input_rate) @@ -155,6 +136,6 @@ def cost_per_token( else None ) standard_rates: Final = _flat_rates(model_info) if tier is None else _tier_rates(model_info, tier) - rates: Final = _off_peak_rates(model_info, current_time, standard_rates) + rates: Final = apply_off_peak_pricing(model_info, current_time, standard_rates) return _bill(breakdown, rates) diff --git a/litellm/llms/openai/workload_identity.py b/litellm/llms/openai/workload_identity.py index ecec161ed46..283fdfb92c2 100644 --- a/litellm/llms/openai/workload_identity.py +++ b/litellm/llms/openai/workload_identity.py @@ -8,7 +8,7 @@ from urllib.parse import urlparse import litellm from litellm.secret_managers.main import get_secret_str, normalize_nonempty_secret_str -from .common_utils import OpenAIError +from .common_utils import OpenAIError, is_openai_backed_api_base if TYPE_CHECKING: from collections.abc import Callable @@ -16,7 +16,6 @@ if TYPE_CHECKING: from openai.auth import SubjectTokenProvider, WorkloadIdentity, WorkloadIdentityAuth OPENAI_WIF_CLIENT_ID: Final = "litellm" -_OPENAI_API_HOST: Final = "api.openai.com" _SDK_UPGRADE_MESSAGE: Final = ( "OpenAI workload identity federation requires openai>=2.32.0. " "Upgrade the installed openai package to use OPENAI_IDENTITY_PROVIDER_ID / " @@ -75,7 +74,7 @@ def _targets_openai_api(api_base: str | None) -> bool: if api_base is None: return True parsed: Final = urlparse(api_base) - return parsed.scheme == "https" and parsed.hostname == _OPENAI_API_HOST + return parsed.scheme == "https" and is_openai_backed_api_base(api_base) @lru_cache(maxsize=16) diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index 0968185b084..c64fc583edc 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -8,23 +8,31 @@ Routes to native Cortex REST API endpoints based on model: Ref: https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-rest-api """ +import copy import json +import re from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict import httpx from typing_extensions import ReadOnly -from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolCallChunk +from litellm.litellm_core_utils.prompt_templates.factory import ( + anthropic_process_openai_file_message, + convert_to_anthropic_tool_result, + create_anthropic_image_param, + select_anthropic_content_block_type_for_file, +) +from litellm.llms.anthropic.chat.handler import ModelResponseIterator as AnthropicStreamParser +from litellm.llms.anthropic.chat.transformation import AnthropicConfig +from litellm.llms.anthropic.common_utils import normalize_cache_control_in_anthropic_payload +from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolCallChunk, ChatCompletionToolMessage from litellm.types.utils import ( - ChatCompletionMessageToolCall, - ChatCompletionUsageBlock, Choices, - Function, GenericStreamingChunk, Message, ModelResponse, - Usage, + ModelResponseStream, ) from ...base_llm.base_model_iterator import BaseModelResponseIterator @@ -93,6 +101,103 @@ def _is_claude_model(model: str) -> bool: return any(name.startswith(p) for p in _CLAUDE_MODEL_PREFIXES) +def _convert_image_url_to_anthropic(block: Mapping[str, object]) -> object: + """One OpenAI ``image_url`` block in the native shape Cortex accepts. + + Cortex documents base64 sources only, so remote URLs are inlined the way every + other base64-only Anthropic dialect (Bedrock invoke, Vertex) inlines them, and + pdf/text data URIs become document blocks rather than malformed image blocks. + """ + image_url: Final = block.get("image_url") + url: Final = image_url if isinstance(image_url, str) else _image_url_field(image_url, "url") + if not url: + return block + + converted: Final = ( + anthropic_process_openai_file_message({"type": "file", "file": {"file_data": url}}) + if select_anthropic_content_block_type_for_file(_data_uri_media_type(url)) == "document" + else create_anthropic_image_param( + image_url if isinstance(image_url, dict) else url, # mutable-ok: caller's JSON block + format=_image_url_field(image_url, "format"), + is_bedrock_invoke=True, + ) + ) + cache_control: Final = block.get("cache_control") + if cache_control is None: + return converted + return {**converted, "cache_control": cache_control} # mutable-ok: JSON wire block + + +def _image_url_field(image_url: object, key: str) -> str | None: + value: Final = image_url.get(key) if isinstance(image_url, dict) else None + return value if isinstance(value, str) else None + + +def _data_uri_media_type(url: str) -> str: + match: Final = re.match(r"data:([^;,]+)", url) + return match.group(1) if match else "" + + +def _convert_image_url_blocks_to_anthropic(content: object) -> object: + if not isinstance(content, list): + return content + return [ # mutable-ok: JSON wire blocks + _convert_image_url_to_anthropic(block) + if isinstance(block, Mapping) and block.get("type") == "image_url" + else block + for block in content + ] + + +def _convert_tool_result_to_anthropic( + content: object, tool_call_id: str, cache_control: object +) -> Mapping[str, object]: + """The Anthropic ``tool_result`` block for one OpenAI tool message. + + Delegating to the shared converter keeps image, document and per-block cache + breakpoints identical to every other Anthropic dialect; only the plain-string + and non-list shapes it does not model are handled here. + """ + if not isinstance(content, list): + plain: Final[dict[str, object]] = { # mutable-ok: JSON wire block + "type": "tool_result", + "tool_use_id": tool_call_id, + "content": content if isinstance(content, str) else json.dumps(content), + } + return {**plain, "cache_control": cache_control} if cache_control is not None else plain + converted: Final = convert_to_anthropic_tool_result( + ChatCompletionToolMessage(role="tool", tool_call_id=tool_call_id, content=content), + force_base64=True, + ) + if cache_control is None: + return converted + return {**converted, "cache_control": cache_control} # mutable-ok: JSON wire block + + +def _signed_thinking_blocks(msg: object) -> list[dict[str, object]]: # mutable-ok: JSON wire blocks + """The assistant turn's thinking blocks that can legally be echoed back. + + Only signed blocks round-trip: Cortex rejects a thinking block whose signature is + missing, which is what an unsigned block from a non-thinking turn would produce. + """ + blocks: Final = msg.get("thinking_blocks") if isinstance(msg, dict) else getattr(msg, "thinking_blocks", None) + if not isinstance(blocks, list): + return [] # mutable-ok: JSON wire blocks + return [ # mutable-ok: JSON wire blocks + dict(block) + for block in blocks + if isinstance(block, Mapping) and (block.get("signature") or block.get("type") == "redacted_thinking") + ] + + +def _clean_input_schema(schema: object) -> object: # mutable-ok: JSON schema copy + return ( + {key: value for key, value in schema.items() if key != "$schema"} + if isinstance(schema, Mapping) + else schema # mutable-ok: JSON schema copy + ) # mutable-ok: JSON schema copy + + class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): """ Snowflake Cortex REST API — unified provider. @@ -178,7 +283,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): if "description" in func: anthropic_tool["description"] = func["description"] if "parameters" in func: - anthropic_tool["input_schema"] = func["parameters"] + anthropic_tool["input_schema"] = _clean_input_schema(func["parameters"]) else: anthropic_tool["input_schema"] = { "type": "object", @@ -186,10 +291,16 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): } anthropic_tools.append(anthropic_tool) else: - anthropic_tools.append(tool) + anthropic_tools.append( + {**tool, "input_schema": _clean_input_schema(tool["input_schema"])} # mutable-ok: JSON wire tool + if "input_schema" in tool + else tool + ) return anthropic_tools - def _extract_system_and_messages(self, messages: list[AllMessageValues]) -> tuple[str | None, list[dict]]: + def _extract_system_and_messages( # mutable-ok: JSON wire messages + self, messages: list[AllMessageValues] + ) -> tuple[list[dict] | None, list[dict]]: """ Split messages into system prompt and conversation turns for Anthropic format. @@ -197,26 +308,39 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): - assistant messages with tool_calls → tool_use content blocks - tool role messages → user role with tool_result content blocks """ - system_parts: Final[list[str]] = [] - conversation: Final[list[dict]] = [] + system_parts: Final[list[dict]] = [] # mutable-ok: JSON wire messages + conversation: Final[list[dict]] = [] # mutable-ok: JSON wire messages for msg in messages: if isinstance(msg, dict): role = msg.get("role", "") content: Any = msg.get("content", "") + msg_cache_control: object = msg.get("cache_control") else: role = getattr(msg, "role", "") content = getattr(msg, "content", "") + msg_cache_control = getattr(msg, "cache_control", None) if role == "system": if isinstance(content, str) and content: - system_parts.append(content) + system_parts.append({"type": "text", "text": content}) # mutable-ok: JSON wire system block elif isinstance(content, list): - system_parts.append("\n".join(b.get("text", "") for b in content if b.get("type") == "text")) + system_parts.extend( + { # mutable-ok: JSON wire system block + "type": "text", + "text": block.get("text", ""), + **( + {"cache_control": block["cache_control"]} if "cache_control" in block else {} + ), # mutable-ok: JSON wire block + } + for block in content + if isinstance(block, Mapping) and block.get("type") == "text" + ) elif role == "assistant": tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else getattr(msg, "tool_calls", None) + thinking_blocks = _signed_thinking_blocks(msg) if tool_calls: - content_blocks: list[dict[str, object]] = [] + content_blocks: list[dict[str, object]] = list(thinking_blocks) # mutable-ok: JSON wire blocks if content: content_blocks.append({"type": "text", "text": content}) for tc in tool_calls: @@ -239,18 +363,26 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): } ) conversation.append({"role": "assistant", "content": content_blocks}) + elif thinking_blocks: + thinking_content = ( + [ + *thinking_blocks, + *copy.deepcopy(content), + ] + if isinstance(content, list) + else [*thinking_blocks, *([{"type": "text", "text": content}] if content else [])] + ) # rebind-ok: loop-local normalized content + conversation.append({"role": "assistant", "content": thinking_content}) else: conversation.append({"role": "assistant", "content": content}) elif role == "tool": - tool_call_id = ( + tool_call_id_value = ( msg.get("tool_call_id", "") if isinstance(msg, dict) else getattr(msg, "tool_call_id", "") ) - tool_content = content if isinstance(content, str) else json.dumps(content) - tool_result_block = { - "type": "tool_result", - "tool_use_id": tool_call_id, - "content": tool_content, - } + tool_call_id = ( + tool_call_id_value if isinstance(tool_call_id_value, str) else "" + ) # rebind-ok: normalized loop value + tool_result_block = _convert_tool_result_to_anthropic(content, tool_call_id, msg_cache_control) if ( conversation and conversation[-1]["role"] == "user" @@ -260,11 +392,18 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): ): conversation[-1]["content"].append(tool_result_block) else: - conversation.append({"role": "user", "content": [tool_result_block]}) + conversation.append( + {"role": "user", "content": [tool_result_block]} # mutable-ok: JSON wire message + ) # mutable-ok: JSON wire message else: - conversation.append({"role": role, "content": content}) + conversation.append( # mutable-ok: JSON wire message + { # mutable-ok: JSON wire message + "role": role, + "content": _convert_image_url_blocks_to_anthropic(content), + } # mutable-ok: JSON wire message + ) - system: Final[str | None] = "\n\n".join(system_parts) if system_parts else None + system: Final[list[dict] | None] = system_parts if system_parts else None # mutable-ok: JSON wire messages return system, conversation def transform_request( @@ -339,7 +478,9 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): extra_body: dict, ) -> dict: """Anthropic Messages format for /messages endpoint.""" - system, conversation = self._extract_system_and_messages(messages) + passthrough_system: Final = optional_params.pop("system", None) + extracted_system, conversation = self._extract_system_and_messages(messages) + system: Final = passthrough_system if passthrough_system is not None else extracted_system if "tools" in optional_params: optional_params["tools"] = self._transform_tools_to_anthropic(optional_params["tools"]) @@ -353,16 +494,19 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): model_name: Final = model.removeprefix("snowflake/") - body: Final[dict[str, object]] = { - "model": model_name, - "messages": conversation, - "stream": stream, - **optional_params, - **extra_body, - } - + body: Final[dict[str, object]] = normalize_cache_control_in_anthropic_payload( # mutable-ok: JSON wire body + { # mutable-ok: JSON wire body + "model": model_name, + "messages": conversation, + "stream": stream, + **optional_params, + **extra_body, # mutable-ok: JSON wire body + } + ) if system is not None: - body["system"] = system + body["system"] = normalize_cache_control_in_anthropic_payload( # mutable-ok: JSON wire payload + {"system": system} # mutable-ok: JSON wire payload + )["system"] if "max_tokens" not in body: body["max_tokens"] = 4096 # reasonable default; Anthropic API max varies by model @@ -435,23 +579,10 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): additional_args={"complete_input_dict": request_data}, ) - text_content = "" - tool_calls: Final = [] - - for block in response_json.get("content", []): - if block.get("type") == "text": - text_content += block.get("text", "") - elif block.get("type") == "tool_use": - tool_calls.append( - ChatCompletionMessageToolCall( - id=block.get("id", ""), - type="function", - function=Function( - name=block.get("name", ""), - arguments=json.dumps(block.get("input", {})), - ), - ) - ) + anthropic_config: Final = AnthropicConfig() + text_content, _, thinking_blocks, reasoning_content, tool_calls, _, _, _ = ( + anthropic_config.extract_response_content(completion_response=dict(response_json)) + ) _stop_reason_map: Final = { "end_turn": "stop", @@ -461,9 +592,13 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): } finish_reason: Final = _stop_reason_map.get(response_json.get("stop_reason", "end_turn"), "stop") - message: Final = Message(content=text_content or None, role="assistant") - if tool_calls: - message.tool_calls = tool_calls + message: Final = Message( + content=text_content or None, + role="assistant", + tool_calls=tool_calls or None, + thinking_blocks=thinking_blocks, + reasoning_content=reasoning_content, + ) choice: Final = Choices( finish_reason=finish_reason, @@ -471,11 +606,13 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): message=message, ) - usage_data: Final = response_json.get("usage", {}) - usage: Final = Usage( - prompt_tokens=usage_data.get("input_tokens", 0), - completion_tokens=usage_data.get("output_tokens", 0), - total_tokens=usage_data.get("input_tokens", 0) + usage_data.get("output_tokens", 0), + # Cortex reports prompt-cache creation/read counts alongside input_tokens; the + # shared calculator folds them into prompt_tokens_details so cached input is + # visible and billed at its own rate. + usage: Final = anthropic_config.calculate_usage( + usage_object=response_json.get("usage", {}), + reasoning_content=reasoning_content, + completion_response=dict(response_json), ) model_response.choices = [choice] @@ -516,15 +653,19 @@ class SnowflakeStreamingHandler(BaseModelResponseIterator): json_mode: bool | None = False, ): super().__init__(streaming_response=streaming_response, sync_stream=sync_stream) - self._tool_index = 0 - self._tool_id = "" - self._tool_name = "" - self._input_tokens = 0 + # Cortex streams the Anthropic SSE dialect on /messages, so its events are parsed + # by Anthropic's own parser: thinking deltas, signatures and prompt-cache usage + # all arrive the way they do on every other Anthropic-dialect provider. + self._anthropic_parser: Final = AnthropicStreamParser( + streaming_response=streaming_response, + sync_stream=sync_stream, + json_mode=json_mode, + ) - def chunk_parser(self, chunk: dict) -> GenericStreamingChunk: + def chunk_parser(self, chunk: dict) -> GenericStreamingChunk | ModelResponseStream: if "choices" in chunk: return self._parse_openai_chunk(chunk) - return self._parse_anthropic_chunk(chunk) + return self._anthropic_parser.chunk_parser(chunk) def _parse_openai_chunk(self, chunk: dict) -> GenericStreamingChunk: choices: Final = chunk.get("choices", []) @@ -566,117 +707,3 @@ class SnowflakeStreamingHandler(BaseModelResponseIterator): index=choice.get("index", 0), tool_use=tool_use, ) - - def _parse_anthropic_chunk(self, chunk: dict) -> GenericStreamingChunk: - event_type: Final = chunk.get("type", "") - - if event_type == "message_start": - message: Final = chunk.get("message", {}) - usage_data = message.get("usage", {}) - self._input_tokens = usage_data.get("input_tokens", 0) - return GenericStreamingChunk( - text="", - is_finished=False, - finish_reason="", - usage=None, - index=0, - tool_use=None, - ) - - elif event_type == "content_block_delta": - delta = chunk.get("delta", {}) - delta_type: Final = delta.get("type", "") - - if delta_type == "text_delta": - return GenericStreamingChunk( - text=delta.get("text", ""), - is_finished=False, - finish_reason="", - usage=None, - index=chunk.get("index", 0), - tool_use=None, - ) - elif delta_type == "input_json_delta": - return GenericStreamingChunk( - text="", - is_finished=False, - finish_reason="", - usage=None, - index=chunk.get("index", 0), - tool_use=ChatCompletionToolCallChunk( - id=self._tool_id, - type="function", - function={ - "name": self._tool_name, - "arguments": delta.get("partial_json", ""), - }, - index=self._tool_index, - ), - ) - - elif event_type == "content_block_start": - content_block: Final = chunk.get("content_block", {}) - if content_block.get("type") == "tool_use": - self._tool_id = content_block.get("id", "") - self._tool_name = content_block.get("name", "") - self._tool_index = chunk.get("index", 0) - return GenericStreamingChunk( - text="", - is_finished=False, - finish_reason="", - usage=None, - index=chunk.get("index", 0), - tool_use=ChatCompletionToolCallChunk( - id=self._tool_id, - type="function", - function={"name": self._tool_name, "arguments": ""}, - index=self._tool_index, - ), - ) - - elif event_type == "message_delta": - delta = chunk.get("delta", {}) - stop_reason: Final = delta.get("stop_reason", "") - usage_data = chunk.get("usage", {}) - _stop_map: Final = { - "end_turn": "stop", - "max_tokens": "length", - "tool_use": "tool_calls", - "stop_sequence": "stop", - } - usage = None - if usage_data or self._input_tokens: - output_t: Final = usage_data.get("output_tokens", 0) - input_t: Final = self._input_tokens or usage_data.get("input_tokens", 0) - usage = ChatCompletionUsageBlock( - prompt_tokens=input_t, - completion_tokens=output_t, - total_tokens=input_t + output_t, - ) - return GenericStreamingChunk( - text="", - is_finished=True, - finish_reason=_stop_map.get(stop_reason, "stop"), - usage=usage, - index=0, - tool_use=None, - ) - - elif event_type == "message_stop": - return GenericStreamingChunk( - text="", - is_finished=True, - finish_reason="stop", - usage=None, - index=0, - tool_use=None, - ) - - return GenericStreamingChunk( - text="", - is_finished=False, - finish_reason="", - usage=None, - index=0, - tool_use=None, - ) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index a36c920dda0..970759479fe 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -998,6 +998,16 @@ def replace_project_and_location_in_route(requested_route: str, vertex_project: return modified_route +def _api_version_for_route(requested_route: str) -> Literal["v1", "v1beta1"]: + return "v1beta1" if "cachedContent" in requested_route else "v1" + + +def _with_api_version(requested_route: str) -> str: + if not requested_route.startswith("/projects/"): + return requested_route + return f"/{_api_version_for_route(requested_route)}{requested_route}" + + def construct_target_url( base_url: str, requested_route: str, @@ -1017,18 +1027,19 @@ def construct_target_url( new_base_url: Final = httpx.URL(base_url) if "locations" in requested_route: # contains the target project id + location - if vertex_project and vertex_location: - requested_route = replace_project_and_location_in_route(requested_route, vertex_project, vertex_location) - return new_base_url.copy_with(path=requested_route) + targeted_route: Final = ( + replace_project_and_location_in_route(requested_route, vertex_project, vertex_location) + if vertex_project and vertex_location + else requested_route + ) + return new_base_url.copy_with(path=_with_api_version(targeted_route)) """ - Add endpoint version (e.g. v1beta for cachedContent, v1 for rest) - Add default project id - Add default location """ - vertex_version: Literal["v1", "v1beta1"] = "v1" - if "cachedContent" in requested_route: - vertex_version = "v1beta1" + vertex_version: Literal["v1", "v1beta1"] = _api_version_for_route(requested_route) # Check if the requested route starts with a version # e.g. /v1beta1/publishers/google/models/gemini-3-pro-preview:streamGenerateContent diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 7f01d2322ff..616d58970cf 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -10380,6 +10380,24 @@ "supports_vision": true, "supports_web_search": true }, + "azure_ai/grok-4.6": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/grok-4-6-comes-to-microsoft-foundry-models-built-for-long-horizon-reasoning-and-/4547578", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "azure_ai/grok-4-fast-non-reasoning": { "deprecation_date": "2026-05-01", "input_cost_per_token": 2e-07, diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 94bca9460dd..81dd9057c9b 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -448,6 +448,17 @@ def _append_query_params(url: str, params: dict[str, str]) -> str: return urlunparse(parsed._replace(query=urlencode(query_params))) +def _resolve_mcp_server_by_name_or_id(lookup: str, client_ip: str | None) -> MCPServer | None: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + by_name: Final = global_mcp_server_manager.get_mcp_server_by_name(lookup, client_ip=client_ip) + if by_name is not None: + return by_name + return global_mcp_server_manager.get_mcp_server_by_id(lookup, client_ip=client_ip) + + def _resolve_oauth2_server_for_root_endpoints( client_ip: str | None = None, ) -> MCPServer | None: @@ -1766,10 +1777,6 @@ async def authorize( resource: str | None = None, ): # Redirect to real OAuth provider with PKCE support - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - global_mcp_server_manager, - ) - if mcp_server_name is None and client_id and is_gateway_dcr_client_id(client_id): if is_proxy_api_resource(request, resource): return await native_client_authorize( @@ -1797,9 +1804,7 @@ async def authorize( lookup_name: Final[str | None] = mcp_server_name or client_id client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) - mcp_server = ( - global_mcp_server_manager.get_mcp_server_by_name(lookup_name, client_ip=client_ip) if lookup_name else None - ) + mcp_server = _resolve_mcp_server_by_name_or_id(lookup_name, client_ip) if lookup_name else None if mcp_server is None and mcp_server_name is None: mcp_server = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) if mcp_server is None: @@ -1855,10 +1860,6 @@ async def token_endpoint( 3. Return the token 4. Return a virtual key in this response """ - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - global_mcp_server_manager, - ) - if mcp_server_name is None and is_gateway_dcr_client_id(client_id): from litellm.proxy.proxy_server import ( # noqa: PLC0415 # circular import at module load master_key, @@ -1882,7 +1883,7 @@ async def token_endpoint( lookup_name: Final = mcp_server_name or client_id client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) - mcp_server = global_mcp_server_manager.get_mcp_server_by_name(lookup_name, client_ip=client_ip) + mcp_server = _resolve_mcp_server_by_name_or_id(lookup_name, client_ip) if mcp_server is None and mcp_server_name is None: mcp_server = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip) if mcp_server is None: @@ -2288,10 +2289,6 @@ async def _build_oauth_protected_resource_response( Returns: OAuth protected resource metadata dict """ - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - global_mcp_server_manager, - ) - request_base_url: Final = get_request_base_url(request) client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) explicitly_named: Final = mcp_server_name is not None @@ -2304,7 +2301,7 @@ async def _build_oauth_protected_resource_response( mcp_server: MCPServer | None = None if mcp_server_name: - mcp_server = global_mcp_server_manager.get_mcp_server_by_name(mcp_server_name, client_ip=client_ip) + mcp_server = _resolve_mcp_server_by_name_or_id(mcp_server_name, client_ip) # Build resource URL based on the pattern if mcp_server_name: @@ -2562,10 +2559,6 @@ def _build_oauth_authorization_server_response( registry lookups; unlike :func:`_build_oauth_protected_resource_response` it does not need to await any upstream IO. """ - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - global_mcp_server_manager, - ) - request_base_url: Final = get_request_base_url(request) client_ip: Final = IPAddressUtils.get_mcp_client_ip(request) explicitly_named: Final = mcp_server_name is not None @@ -2583,7 +2576,7 @@ def _build_oauth_authorization_server_response( mcp_server: MCPServer | None = None if mcp_server_name: - mcp_server = global_mcp_server_manager.get_mcp_server_by_name(mcp_server_name, client_ip=client_ip) + mcp_server = _resolve_mcp_server_by_name_or_id(mcp_server_name, client_ip) _raise_unless_oauth2_discovery_server(mcp_server, mcp_server_name, "not an OAuth authorization server") @@ -2709,10 +2702,6 @@ async def oauth_authorization_server_legacy(request: Request, mcp_server_name: s @router.post("/{mcp_server_name}/register") @router.post("/register") async def register_client(request: Request, mcp_server_name: str | None = None): - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( - global_mcp_server_manager, - ) - # Get the correct base URL considering X-Forwarded-* headers request_base_url: Final = get_request_base_url(request) @@ -2748,7 +2737,7 @@ async def register_client(request: Request, mcp_server_name: str | None = None): ) return dummy_return - mcp_server: Final = global_mcp_server_manager.get_mcp_server_by_name(mcp_server_name, client_ip=client_ip) + mcp_server: Final = _resolve_mcp_server_by_name_or_id(mcp_server_name, client_ip) if mcp_server is None: return dummy_return return await register_client_with_server( diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index a772c569bfa..ce4928ff83d 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -47,7 +47,7 @@ from litellm.constants import ( MCP_TOOL_LISTING_TIMEOUT, ) from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException -from litellm.experimental_mcp_client.client import MCPClient, MCPSigV4Auth, strip_auth_scheme +from litellm.experimental_mcp_client.client import MCPClient, MCPSigV4Auth, strip_auth_scheme, to_basic_credentials from litellm.integrations.custom_guardrail import ( _sync_guardrail_info_to_logging_obj, # pyright: ignore[reportPrivateUsage] - the same bridge @log_guardrail_information uses; reimplementing it here would fork the metadata-key logic ) @@ -2299,13 +2299,13 @@ class MCPServerManager: from litellm.types.mcp import MCPAuth if server.auth_type == MCPAuth.bearer_token: - headers["Authorization"] = f"Bearer {server.authentication_token}" + headers["Authorization"] = f"Bearer {strip_auth_scheme(server.authentication_token, 'Bearer')}" elif server.auth_type == MCPAuth.api_key: - headers["Authorization"] = f"ApiKey {server.authentication_token}" + headers["Authorization"] = f"ApiKey {strip_auth_scheme(server.authentication_token, 'ApiKey')}" elif server.auth_type == MCPAuth.basic: - headers["Authorization"] = f"Basic {server.authentication_token}" + headers["Authorization"] = f"Basic {to_basic_credentials(server.authentication_token)}" elif server.auth_type == MCPAuth.token: - headers["Authorization"] = f"token {server.authentication_token}" + headers["Authorization"] = f"token {strip_auth_scheme(server.authentication_token, 'token')}" # Add any static headers from server config. # @@ -3346,9 +3346,7 @@ class MCPServerManager: normalized: Final = {k.lower(): v for k, v in raw_headers.items()} auth_value = normalized.get("authorization") if auth_value: - if auth_value.startswith("Bearer "): - return auth_value[len("Bearer ") :] - return auth_value + return strip_auth_scheme(auth_value, "Bearer") return None @staticmethod @@ -3640,30 +3638,48 @@ class MCPServerManager: user_api_key_auth: UserAPIKeyAuth | None, raw_headers: Mapping[str, str] | None = None, ) -> None: - """Run the OBO exchange for a caller-supplied subject at the transport edge. + """Mint an exchange-backed server's upstream credential at the transport edge. Single-server routes call this before the MCP session opens, where an HTTP status and ``WWW-Authenticate`` still reach the client. A rejected subject raises the RFC 9728 challenge and any other ``CredError`` maps onto its public HTTP status, so an exchange failure surfaces as a failure instead of the session continuing into an empty tool list. A successful exchange is cached by the exchanger, so the session's list/call reuses it. + + Each mode pre-flights only where it would resolve the subject the session goes on to use, + which is what keeps the pre-flight from reaching a verdict the session would contradict. + ``oauth2_token_exchange`` mints from the caller's inbound bearer, so without one there is + nothing to exchange and the missing-subject case stays the preemptive challenge's job. + ``oauth2_id_jag`` is the mirror image: tool listing resolves it from the identity assertion + captured for this user at SSO login and never from the inbound bearer, so the pre-flight is + faithful exactly when no identity bearer was sent (a LiteLLM key in ``Authorization`` is not one), + and a caller that did send one is passed through + untouched rather than judged against a subject the listing will not use. That store-sourced + case is the one whose missing-assertion 412 and store-outage 503 the session cannot report. + Only OBO has a discovery challenge to raise; ID-JAG's failures are plain statuses whose body + already names what the user has to do, so they map through ``raise_public`` as at egress. """ - if server.auth_type != MCPAuth.oauth2_token_exchange: - return - if not self._extract_bearer_token(oauth2_headers, None): - return - resolved_server: Final = await self.ensure_oauth_metadata_discovered(server) - spec: Final = to_server_spec(resolved_server) - if spec is None or not isinstance(spec.config, TokenExchangeConfig): - return subject_token: Final = self._extract_subject_token(oauth2_headers, raw_headers, user_api_key_auth) - if subject_token is None: + match server.auth_type: + case MCPAuth.oauth2_token_exchange: + if not self._extract_bearer_token(oauth2_headers, None): + return + case MCPAuth.oauth2_id_jag: + if subject_token is not None: + return + case _: + return + resolved_server: Final = await self.ensure_oauth_metadata_discovered(server) + spec: Final = _to_server_spec_fail_closed(resolved_server) + if spec is None or not isinstance(spec.config, (TokenExchangeConfig, IdJagConfig)): + return + if subject_token is None and isinstance(spec.config, TokenExchangeConfig): raise_token_exchange_challenge(resolved_server, root_path=get_server_root_path()) match await self._cred_provider.resolve_credentials(to_subject(user_api_key_auth, subject_token), spec): case Ok(_): return case Error(err): - if err.tag == "unauthorized": + if err.tag == "unauthorized" and isinstance(spec.config, TokenExchangeConfig): raise_token_exchange_challenge( resolved_server, root_path=get_server_root_path(), @@ -6147,13 +6163,13 @@ class MCPServerManager: internal_networks = IPAddressUtils.parse_internal_networks(general_settings.get("mcp_internal_ip_ranges")) return IPAddressUtils.is_internal_ip(client_ip, internal_networks) - def get_mcp_server_by_id(self, server_id: str) -> MCPServer | None: - """ - Get the MCP Server from the server id - """ + def get_mcp_server_by_id(self, server_id: str, client_ip: str | None = None) -> MCPServer | None: + """Get the MCP Server from the server id.""" registry: Final = self.get_registry() for server in registry.values(): if server.server_id == server_id: + if not self._is_server_accessible_from_ip(server, client_ip): + return None return server return None diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index 4458ac7f190..6a95a93a2a8 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -18,6 +18,7 @@ from fastapi import HTTPException from pydantic import SecretStr from typing_extensions import assert_never +from litellm.experimental_mcp_client.client import strip_auth_scheme, to_basic_credentials from litellm.proxy._experimental.mcp_server.oauth_utils import resolve_upstream_resource from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( DEFAULT_CREDENTIAL_HEADER, @@ -213,7 +214,9 @@ def _shared_key_spec( token: Final = server.authentication_token if not token: return None # no key configured -> defer to v1 (parity-safe) - value: Final = base64.b64encode(token.encode("utf-8")).decode() if encode else token + value: Final = ( + to_basic_credentials(token) if encode else strip_auth_scheme(token, value_prefix) if value_prefix else token + ) return ServerSpec( server_id=server.server_id, resource=resource, diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py index 8a0d41584f9..6552008ca54 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py @@ -11,7 +11,8 @@ being registered, so a gateway with no EMA upstream never stores bearer material The row is one encrypted payload per user, latest login wins. ``expires_at`` mirrors the id_token ``exp`` claim and is judged by the reader, never enforced by deletion here: an expired assertion with a refresh token is still renewable, and the DB row is the source of -truth, the same contract as the per-user OAuth credential store. +truth, the same contract as the per-user OAuth credential store. Reads use a per-process cache with +TTL ``MCP_SSO_ASSERTION_CACHE_TTL_SECONDS``; invalidation also guards against stale in-flight reads. """ from __future__ import annotations @@ -24,6 +25,8 @@ import jwt from pydantic import BaseModel, ConfigDict, SecretStr, TypeAdapter, ValidationError from litellm._logging import verbose_proxy_logger +from litellm.caching.in_memory_cache import InMemoryCache +from litellm.constants import MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE, MCP_SSO_ASSERTION_CACHE_TTL_SECONDS if TYPE_CHECKING: from litellm.proxy.utils import PrismaClient @@ -45,6 +48,46 @@ class SSOIdentityAssertion(BaseModel): expires_at: datetime | None = None +class SSOAssertionCache: + """Process-local read cache. ``invalidate`` bumps a process-wide epoch so a fetch that started + before a login cannot repopulate the old assertion after it.""" + + def __init__(self, ttl_seconds: int = MCP_SSO_ASSERTION_CACHE_TTL_SECONDS) -> None: + self._entries = InMemoryCache( + max_size_in_memory=MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE, + default_ttl=ttl_seconds, + ) + self._epoch: int = 0 + + def epoch(self) -> int: + return self._epoch + + def get(self, user_id: str) -> SSOIdentityAssertion | None: + cached: Final = self._entries.get_cache( # pyright: ignore[reportUnknownMemberType] # InMemoryCache is untyped + user_id + ) + return cached if isinstance(cached, SSOIdentityAssertion) else None + + def set_if_unchanged(self, user_id: str, assertion: SSOIdentityAssertion, seen_epoch: int) -> None: + if self._epoch != seen_epoch: + return + self._entries.set_cache( # pyright: ignore[reportUnknownMemberType] # InMemoryCache is untyped + user_id, assertion + ) + + def invalidate(self, user_id: str) -> None: + self._epoch += 1 + self._entries.delete_cache( # pyright: ignore[reportUnknownMemberType] # InMemoryCache is untyped + user_id + ) + + def flush(self) -> None: + self._entries.flush_cache() # pyright: ignore[reportUnknownMemberType] # InMemoryCache is untyped + + +_ASSERTION_CACHE: Final = SSOAssertionCache() + + class _IdTokenClaims(BaseModel): exp: float | None = None iss: str | None = None @@ -107,7 +150,9 @@ async def ema_assertion_retention_enabled() -> bool: return row is not None -async def persist_sso_identity_assertion(user_id: str, assertion: SSOIdentityAssertion) -> None: +async def persist_sso_identity_assertion( + user_id: str, assertion: SSOIdentityAssertion, cache: SSOAssertionCache = _ASSERTION_CACHE +) -> None: from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper # noqa: PLC0415 # runtime global from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 # runtime global @@ -127,11 +172,10 @@ async def persist_sso_identity_assertion(user_id: str, assertion: SSOIdentityAss "update": {"assertion_b64": encoded}, }, ) + cache.invalidate(user_id) -async def fetch_sso_identity_assertion(user_id: str) -> SSOIdentityAssertion | None: - """The stored assertion for ``user_id``, or ``None`` when absent, undecryptable (salt-key - rotation), or unparseable. Expiry is not judged here; the reader owns that policy.""" +async def _read_assertion_from_db(user_id: str) -> SSOIdentityAssertion | None: from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper # noqa: PLC0415 # runtime global from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 # runtime global @@ -160,6 +204,21 @@ async def fetch_sso_identity_assertion(user_id: str) -> SSOIdentityAssertion | N ) +async def fetch_sso_identity_assertion( + user_id: str, cache: SSOAssertionCache = _ASSERTION_CACHE +) -> SSOIdentityAssertion | None: + """The stored assertion for ``user_id``, or ``None`` when absent, undecryptable (salt-key + rotation), or unparseable. Expiry is not judged here; the reader owns that policy.""" + cached: Final = cache.get(user_id) + if cached is not None: + return cached + seen_epoch: Final = cache.epoch() + assertion: Final = await _read_assertion_from_db(user_id) + if assertion is not None: + cache.set_if_unchanged(user_id, assertion, seen_epoch) + return assertion + + class AssertionStoreUnavailable(Exception): """Raised by ``fetch`` when the backing store is unreachable (e.g. the DB is down). @@ -189,9 +248,12 @@ class DbSSOAssertionStore: from credential resolution and from the upstream-401 retry. """ + def __init__(self, cache: SSOAssertionCache = _ASSERTION_CACHE) -> None: + self._cache = cache + async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: try: - return await fetch_sso_identity_assertion(user_id) + return await fetch_sso_identity_assertion(user_id, cache=self._cache) except Exception as exc: # noqa: BLE001 # any driver/storage failure is an outage, not an absence raise AssertionStoreUnavailable(str(exc)) from exc diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index bb075220530..3d7c947a913 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -3851,15 +3851,15 @@ if MCP_AVAILABLE: raise_token_exchange_challenge(server, root_path=get_server_root_path()) - # token_exchange (OBO) with a subject present: run the exchange here at the transport - # edge, so a rejected subject raises the RFC 9728 challenge (and a gateway fault its - # public status) instead of the session opening and list_tools masking the failure as - # an empty tool list. Gated to single-server routes; the multi-server aggregate keeps - # absorbing per-server auth failures so one bad server cannot 401 the whole connect. + # Exchange-backed modes (token_exchange's OBO mint, id_jag's stored-assertion mint): run + # the exchange here at the transport edge, so a rejected subject raises the RFC 9728 + # challenge and any other failure its public status, instead of the session opening and + # list_tools masking it as an empty tool list. The manager owns which modes pre-flight + # and what each mints from. Gated to single-server routes the key may reach; the + # multi-server aggregate keeps absorbing per-server auth failures so one bad server + # cannot 401 the whole connect. if ( server - and server.auth_type == MCPAuth.oauth2_token_exchange - and oauth2_headers and len(mcp_servers or []) == 1 and server.server_id in frozenset( diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 5d5a25e7cd6..1a807fd39bb 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2404,6 +2404,15 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): """ completion_model: str | None = Field(None, description="proxy level default model for all chat completion calls") + max_in_flight_requests_per_worker: int | None = Field( + None, gt=0, description="maximum concurrent requests handled by each worker" + ) + max_queued_requests_per_worker: int | None = Field( + None, ge=0, description="maximum requests waiting for a worker slot" + ) + admission_queue_timeout_seconds: float = Field( + 1.0, gt=0, description="maximum time a request waits for a worker slot" + ) plugins: list[PluginConfig] | None = Field( None, description="external services registered as embeddable UI plugins" ) diff --git a/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py b/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py index d0ac94d3710..e4dd77e2f82 100644 --- a/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py +++ b/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py @@ -5,10 +5,13 @@ Handles agent permission checking for keys and teams using object_permission_id. Follows the same pattern as MCP permission handling. """ +import asyncio +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from typing import Final, TypeAlias from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.ui_session_utils import build_effective_auth_contexts from litellm.proxy._types import ( UI_TEAM_ID, LiteLLM_ObjectPermissionTable, @@ -443,15 +446,47 @@ class AgentRequestHandler: return [] -async def accessible_agents(user_api_key_auth: UserAPIKeyAuth) -> tuple[AgentResponse, ...]: - """Every registry agent for proxy admins, else the agents the key's and team's grants reach.""" +def _granted_ids(access: AgentAccess) -> frozenset[str]: + match access: + case UnrestrictedAgentAccess(): + return frozenset() + case RestrictedAgentAccess(agent_ids): + return agent_ids + + +ResolveAgentAccess: TypeAlias = Callable[[UserAPIKeyAuth], Awaitable[AgentAccess]] +EffectiveAuthContexts: TypeAlias = Callable[[UserAPIKeyAuth], Awaitable[Sequence[UserAPIKeyAuth]]] + + +async def _granted_agent_ids( + user_api_key_auth: UserAPIKeyAuth, + resolve_access: ResolveAgentAccess, + effective_contexts: EffectiveAuthContexts, +) -> frozenset[str]: + """Union of the explicit grants reachable from the key, its team, or (for a dashboard session) + the user's real teams and user row. No grant anywhere yields the empty set, unlike the + open-by-default ``resolve_agent_access`` that guards direct access.""" + accesses: Final = await asyncio.gather( + *(resolve_access(auth_context) for auth_context in await effective_contexts(user_api_key_auth)) + ) + return frozenset().union(*(_granted_ids(access) for access in accesses)) + + +async def accessible_agents( + user_api_key_auth: UserAPIKeyAuth, + all_agents: tuple[AgentResponse, ...] | None = None, + resolve_access: ResolveAgentAccess | None = None, + effective_contexts: EffectiveAuthContexts = build_effective_auth_contexts, +) -> tuple[AgentResponse, ...]: + """Every registry agent for proxy admins, else only the agents the caller was granted.""" from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry - all_agents: Final = global_agent_registry.get_agent_list() + agents: Final = global_agent_registry.get_agent_list() if all_agents is None else all_agents if user_api_key_auth.user_role in (LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN.value): - return all_agents - match await AgentRequestHandler.resolve_agent_access(user_api_key_auth=user_api_key_auth): - case UnrestrictedAgentAccess(): - return all_agents - case RestrictedAgentAccess(allowed_agent_ids): - return tuple(agent for agent in all_agents if agent.agent_id in allowed_agent_ids) + return agents + allowed_agent_ids: Final = await _granted_agent_ids( + user_api_key_auth, + AgentRequestHandler.resolve_agent_access if resolve_access is None else resolve_access, + effective_contexts, + ) + return tuple(agent for agent in agents if agent.agent_id in allowed_agent_ids) diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index 7f0045c1d93..b243b737b0a 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse import litellm from litellm._logging import verbose_proxy_logger -from litellm.anthropic_interface.exceptions import AnthropicExceptionMapping +from litellm.anthropic_interface.exceptions import AnthropicErrorResponse, AnthropicExceptionMapping from litellm.integrations.custom_guardrail import ModifyResponseException from litellm.llms.anthropic.experimental_pass_through.context_management import ( AnthropicContextManagementError, @@ -30,6 +30,27 @@ from litellm.types.utils import TokenCountResponse router: Final = APIRouter() +def _anthropic_error_json_response(exc: ProxyException, request: Request) -> JSONResponse: + from litellm.proxy.proxy_server import ( + _close_dangling_otel_server_span, # pyright: ignore[reportPrivateUsage] # proxy_server keeps the span-close helper private; error JSONResponses returned by the route must stamp the OTel server span like the global ProxyException handler does + ) + + status_code: Final = int(exc.code) if exc.code is not None and exc.code.isdigit() else 500 + _close_dangling_otel_server_span(request, status_code, exc=exc) + envelope: Final = AnthropicExceptionMapping.transform_to_anthropic_error( + status_code=status_code, + raw_message=exc.message, + request_id=request.headers.get("x-request-id"), + ) + if not exc.provider_specific_fields: + return JSONResponse(status_code=status_code, content=envelope, headers=exc.headers) + content: Final[AnthropicErrorResponse] = { + **envelope, + "error": {**envelope["error"], "provider_specific_fields": exc.provider_specific_fields}, + } + return JSONResponse(status_code=status_code, content=content, headers=exc.headers) + + def _strip_total_tokens_from_anthropic_response(response: Any) -> None: """Remove the OpenAI-flavored `usage.total_tokens` field that LiteLLM injects into Anthropic /v1/messages responses. @@ -195,7 +216,7 @@ async def anthropic_response( verbose_proxy_logger.exception("litellm.proxy.proxy_server.anthropic_response(): Exception occured - %s", e) if isinstance(e, ProxyException): - raise + return _anthropic_error_json_response(e, request) # Extract model_id from request metadata (same as success path) litellm_metadata: Final = data.get("litellm_metadata", {}) or {} @@ -216,15 +237,18 @@ async def anthropic_response( ) if isinstance(e, HTTPException): - raise proxy_exception_from_http_exception(e, headers) + return _anthropic_error_json_response(proxy_exception_from_http_exception(e, headers), request) error_msg: Final = f"{e}" - raise ProxyException( - message=getattr(e, "message", error_msg), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), - code=getattr(e, "status_code", 500), - headers=headers, + return _anthropic_error_json_response( + ProxyException( + message=getattr(e, "message", error_msg), + type=getattr(e, "type", "None"), + param=getattr(e, "param", "None"), + code=getattr(e, "status_code", 500), + headers=headers, + ), + request, ) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 0db4cbc3bf2..2b328b455dc 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -5788,7 +5788,7 @@ async def vector_store_access_check( vector_store_ids_to_run: Final = litellm.vector_store_registry.get_vector_store_ids_to_run( non_default_params=request_body, tools=request_body.get("tools", None) ) - if vector_store_ids_to_run is None: + if not vector_store_ids_to_run: verbose_proxy_logger.debug("Vector store to run not found, skipping vector store access check") return True diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py index 1625198892f..de2ca4762f1 100644 --- a/litellm/proxy/auth/model_checks.py +++ b/litellm/proxy/auth/model_checks.py @@ -387,33 +387,22 @@ def _get_wildcard_models( all_wildcard_models: Final = [] for model in unique_models: if _check_wildcard_routing(model=model): - if return_wildcard_routes: # will add the wildcard route to the list eg: anthropic/*. + if return_wildcard_routes: all_wildcard_models.append(model) - ## get litellm params from model - if llm_router is not None: - model_list = llm_router.get_model_list(model_name=model, team_id=team_id) - if model_list: - for router_model in model_list: - wildcard_models = get_known_models_from_wildcard( + models_to_remove.add(model) + + model_list = llm_router.get_model_list(model_name=model, team_id=team_id) if llm_router else None + if model_list: + for router_model in model_list: + all_wildcard_models.extend( + get_known_models_from_wildcard( wildcard_model=model, litellm_params=LiteLLM_Params(**router_model["litellm_params"]), ) - all_wildcard_models.extend(wildcard_models) - else: - # Router has no deployment for this wildcard (e.g., BYOK team models) - # Fall back to expanding from known provider models - wildcard_models = get_known_models_from_wildcard(wildcard_model=model, litellm_params=None) - if wildcard_models: - models_to_remove.add(model) - all_wildcard_models.extend(wildcard_models) + ) else: - # get all known provider models - wildcard_models = get_known_models_from_wildcard(wildcard_model=model, litellm_params=None) - - if wildcard_models: - models_to_remove.add(model) - all_wildcard_models.extend(wildcard_models) + all_wildcard_models.extend(get_known_models_from_wildcard(wildcard_model=model, litellm_params=None)) for model in models_to_remove: unique_models.remove(model) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index fc83c1ddeed..6542842f5e4 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -467,6 +467,42 @@ def _getattr_object(value: object, name: str, default: object = None) -> object: return getattr(value, name, default) +_OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType( + { + status.HTTP_401_UNAUTHORIZED: "authentication_error", + status.HTTP_403_FORBIDDEN: "permission_error", + status.HTTP_429_TOO_MANY_REQUESTS: "rate_limit_error", + } +) + + +def _error_status_code(exc: object, default: int) -> int: + """The HTTP status an exception carries, or ``default`` when it carries none.""" + carried: Final = _getattr_object(exc, "status_code") + return carried if isinstance(carried, int) and not isinstance(carried, bool) else default + + +def _openai_error_type(exc: object, status_code: int) -> str: + """OpenAI types ``error.type`` as a required string, so an exception carrying none + falls back to the type its status code stands for.""" + carried: Final = _getattr_object(exc, "type") + if isinstance(carried, str): + return carried + mapped: Final = _OPENAI_ERROR_TYPE_BY_STATUS.get(status_code) + if mapped is not None: + return mapped + if status_code < status.HTTP_500_INTERNAL_SERVER_ERROR: + return "invalid_request_error" + return "internal_server_error" + + +def _openai_error_param(exc: object) -> str | None: + """OpenAI types ``error.param`` as nullable, so an exception carrying none + serializes as JSON ``null``.""" + carried: Final = _getattr_object(exc, "param") + return carried if isinstance(carried, str) else None + + class _UpstreamHttpResponse(Protocol): @property def status_code(self) -> int: ... @@ -540,11 +576,12 @@ def proxy_exception_from_http_exception(exc: HTTPException, headers: dict[str, s message, structured_fields = serialize_http_exception_detail(raw_detail) existing_fields: Final = getattr(exc, "provider_specific_fields", None) or {} merged_fields: Final = {**existing_fields, **structured_fields} if structured_fields else (existing_fields or None) + error_status: Final = _error_status_code(exc, status.HTTP_400_BAD_REQUEST) return ProxyException( message=message, - type=getattr(exc, "type", "None"), - param=getattr(exc, "param", "None"), - code=getattr(exc, "status_code", status.HTTP_400_BAD_REQUEST), + type=_openai_error_type(exc, error_status), + param=_openai_error_param(exc), + code=error_status, provider_specific_fields=merged_fields, headers=headers, ) @@ -827,25 +864,22 @@ def sse_error_payload(exc: BaseException) -> tuple[int, Mapping[str, object]]: are byte-identical. """ # Preserve status code from HTTPException (e.g. guardrail blocks) - error_status: Final = getattr(exc, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR) + error_status: Final = _error_status_code(exc, status.HTTP_500_INTERNAL_SERVER_ERROR) raw_detail: Final = _getattr_object(exc, "detail", "Error processing stream start") message, structured_fields = serialize_http_exception_detail(raw_detail) existing_fields: Final = getattr(exc, "provider_specific_fields", None) or {} merged_fields: Final = {**existing_fields, **structured_fields} if structured_fields else (existing_fields or None) - # Built in one statement then given its one optional key, rather than spread - # conditionally: the spread form costs two extra dict constructions, which - # type-discipline-budget.json's LIT002 ceiling has no room for. error_obj: Final = { "message": message, - "type": getattr(exc, "type", "None"), - "param": getattr(exc, "param", "None"), + "type": _openai_error_type(exc, error_status), + "param": _openai_error_param(exc), "code": str(error_status), } - if merged_fields: - error_obj["provider_specific_fields"] = merged_fields - return error_status, error_obj + if not merged_fields: + return error_status, error_obj + return error_status, {**error_obj, "provider_specific_fields": merged_fields} def _sse_error_frames(error_obj: Mapping[str, object]) -> tuple[str, str]: @@ -922,7 +956,7 @@ async def create_response( "error": { "message": _CLIENT_DISCONNECT_DETAIL, "type": "client_disconnect", - "param": "None", + "param": None, "code": str(LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED), } }, @@ -3417,8 +3451,8 @@ class ProxyBaseLLMRequestProcessing: _code = status.HTTP_500_INTERNAL_SERVER_ERROR raise ProxyException( message=redact_internal_details_from_client_message(getattr(e, "message", error_msg)), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), + type=_openai_error_type(e, _code), + param=_openai_error_param(e), openai_code=getattr(e, "code", None), code=_code, provider_specific_fields=getattr(e, "provider_specific_fields", None), @@ -3628,11 +3662,12 @@ class ProxyBaseLLMRequestProcessing: if isinstance(e, HTTPException): raise e + stream_error_status: Final = _error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR) proxy_exception: Final = ProxyException( message=redact_internal_details_from_client_message(getattr(e, "message", str(e))), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), - code=getattr(e, "status_code", 500), + type=_openai_error_type(e, stream_error_status), + param=_openai_error_param(e), + code=stream_error_status, ) stream_completed = True yield serialize_error(proxy_exception) diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 96621b08ba1..552d1ea434f 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -1,10 +1,12 @@ import json import re -from collections.abc import Collection -from typing import Any, Final +from collections.abc import Collection, Mapping +from types import MappingProxyType, UnionType +from typing import Any, Final, Union, get_args, get_origin import orjson from fastapi import Request, UploadFile, status +from typing_extensions import ReadOnly from litellm._logging import verbose_proxy_logger from litellm.constants import MAX_REQUEST_BODY_SIZE_TO_REPAIR_MB @@ -40,6 +42,65 @@ def _is_json_content_type(content_type: str) -> bool: return _normalize_media_type(content_type) == "application/json" +def _numeric_form_type(annotation: object) -> type[int] | type[float] | None: + """The scalar to parse an ``int``/``float``-typed field as, else ``None``.""" + unwrapped: Final = get_args(annotation)[0] if get_origin(annotation) is ReadOnly else annotation + candidates: Final = ( + tuple(arg for arg in get_args(unwrapped) if arg is not type(None)) + if get_origin(unwrapped) in (Union, UnionType) + else (unwrapped,) + ) + if len(candidates) != 1: + return None + if candidates[0] is int: + return int + if candidates[0] is float: + return float + return None + + +def numeric_form_fields(annotations: Mapping[str, object]) -> Mapping[str, type[int] | type[float]]: + """ + The numeric fields of a request schema, mapped to the scalar to parse them as. + + Only a bare ``int``/``float`` or an optional one qualifies, so container and + literal fields are left alone and ``bool`` is excluded on purpose. + """ + return MappingProxyType( + { + name: scalar + for name, annotation in annotations.items() + if (scalar := _numeric_form_type(annotation)) is not None + } + ) + + +def _numeric_form_value(value: object, scalar: type[int] | type[float]) -> object: + if not isinstance(value, str): + return value + try: + return scalar(value) + except ValueError: + return value + + +def coerce_numeric_form_fields( + parsed_body: Mapping[str, object], + numeric_fields: Mapping[str, type[int] | type[float]], +) -> Mapping[str, object]: + """ + Parse the numeric fields of a form-encoded body back into numbers. + + ``request.form()`` yields every field as a string, so a provider that puts the + value in a JSON body would send a string where its API requires a number. A + value that will not parse is left as-is for the provider to reject as before. + """ + return { + name: _numeric_form_value(value, numeric_fields[name]) if name in numeric_fields else value + for name, value in parsed_body.items() + } + + async def _read_request_body(request: Request | None) -> dict: """ Safely read the request body and parse it as JSON. diff --git a/litellm/proxy/container_endpoints/endpoints.py b/litellm/proxy/container_endpoints/endpoints.py index eaa3db336a9..f3a2abc1225 100644 --- a/litellm/proxy/container_endpoints/endpoints.py +++ b/litellm/proxy/container_endpoints/endpoints.py @@ -15,10 +15,11 @@ from litellm.proxy.common_utils.openai_endpoint_utils import ( get_custom_llm_provider_from_request_headers, get_custom_llm_provider_from_request_query, ) +from litellm.proxy.common_utils.resource_ownership import is_proxy_admin from litellm.proxy.container_endpoints.ownership import ( assert_user_can_access_container, - filter_container_list_response, get_container_forwarding_params, + list_owned_containers, record_container_owner, ) @@ -173,6 +174,9 @@ async def list_containers( request: Request, fastapi_response: Response, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + after: str | None = None, + limit: int | None = None, + order: str | None = None, ): """ Container list endpoint for retrieving a list of containers. @@ -206,55 +210,54 @@ async def list_containers( version, ) - # Read query parameters query_params: Final = dict(request.query_params) - data: Final[dict[str, Any]] = {"query_params": query_params, "model": query_params.get("model")} - - # Extract custom_llm_provider using priority chain custom_llm_provider: Final = ( get_custom_llm_provider_from_request_headers(request=request) or get_custom_llm_provider_from_request_query(request=request) or "openai" ) + data: Final[dict[str, Any]] = { + "query_params": query_params, + "model": query_params.get("model"), + "order": order, + "custom_llm_provider": custom_llm_provider, + } - # Add custom_llm_provider to data - data["custom_llm_provider"] = custom_llm_provider + async def fetch_page(page_after: str | None, page_limit: int | None) -> object: + processor: Final = ProxyBaseLLMRequestProcessing(data={**data, "after": page_after, "limit": page_limit}) + try: + return await processor.base_process_llm_request( + request=request, + fastapi_response=fastapi_response, + user_api_key_dict=user_api_key_dict, + route_type="alist_containers", + proxy_logging_obj=proxy_logging_obj, + llm_router=llm_router, + general_settings=general_settings, + proxy_config=proxy_config, + select_data_generator=select_data_generator, + model=None, + user_model=user_model, + user_temperature=user_temperature, + user_request_timeout=user_request_timeout, + user_max_tokens=user_max_tokens, + user_api_base=user_api_base, + version=version, + ) + except Exception as e: + raise await processor._handle_llm_api_exception( + e=e, + user_api_key_dict=user_api_key_dict, + proxy_logging_obj=proxy_logging_obj, + version=version, + ) - # Process request using ProxyBaseLLMRequestProcessing - processor: Final = ProxyBaseLLMRequestProcessing(data=data) - try: - response: Final = await processor.base_process_llm_request( - request=request, - fastapi_response=fastapi_response, - user_api_key_dict=user_api_key_dict, - route_type="alist_containers", - proxy_logging_obj=proxy_logging_obj, - llm_router=llm_router, - general_settings=general_settings, - proxy_config=proxy_config, - select_data_generator=select_data_generator, - model=None, - user_model=user_model, - user_temperature=user_temperature, - user_request_timeout=user_request_timeout, - user_max_tokens=user_max_tokens, - user_api_base=user_api_base, - version=version, - ) - except Exception as e: - raise await processor._handle_llm_api_exception( - e=e, - user_api_key_dict=user_api_key_dict, - proxy_logging_obj=proxy_logging_obj, - version=version, - ) - - # Ownership filtering runs OUTSIDE the LLM-exception scope: a DB error - # in the ownership lookup is not an LLM-API error and shouldn't be - # translated to a provider-shaped failure (which would also fire the - # post_call_failure_hook for what is in fact a successful upstream call). - return await filter_container_list_response( - response=response, + if is_proxy_admin(user_api_key_dict): + return await fetch_page(after, limit) + return await list_owned_containers( + fetch_page=fetch_page, + after=after, + limit=limit, user_api_key_dict=user_api_key_dict, custom_llm_provider=custom_llm_provider, ) diff --git a/litellm/proxy/container_endpoints/handler_factory.py b/litellm/proxy/container_endpoints/handler_factory.py index aaee1d3e264..cc742f0520b 100644 --- a/litellm/proxy/container_endpoints/handler_factory.py +++ b/litellm/proxy/container_endpoints/handler_factory.py @@ -6,7 +6,9 @@ FastAPI route handlers for ALL container file endpoints. """ import json +from collections.abc import Mapping, Sequence from pathlib import Path +from types import MappingProxyType from typing import Any, Final from fastapi import APIRouter, Depends, Request, Response @@ -56,6 +58,7 @@ def _create_handler_for_path_params( route_type: str, returns_binary: bool = False, is_multipart: bool = False, + query_param_names: Sequence[str] = (), ): """ Dynamically create a handler with the correct path parameter signature. @@ -114,6 +117,7 @@ def _create_handler_for_path_params( user_api_key_dict=user_api_key_dict, route_type=route_type, path_params={"container_id": container_id}, + query_param_names=query_param_names, ) return handler_container_id @@ -133,6 +137,7 @@ def _create_handler_for_path_params( user_api_key_dict=user_api_key_dict, route_type=route_type, path_params={"container_id": container_id, "file_id": file_id}, + query_param_names=query_param_names, ) return handler_container_file @@ -150,6 +155,7 @@ def _create_handler_for_path_params( user_api_key_dict=user_api_key_dict, route_type=route_type, path_params={}, + query_param_names=query_param_names, ) return handler_no_params @@ -351,12 +357,17 @@ async def _process_multipart_upload_request( ) +def _declared_query_params(query_params: Mapping[str, str], query_param_names: Sequence[str]) -> Mapping[str, str]: + return MappingProxyType({name: query_params[name] for name in query_param_names if name in query_params}) + + async def _process_request( request: Request, fastapi_response: Response, user_api_key_dict: UserAPIKeyAuth, route_type: str, path_params: dict[str, str], + query_param_names: Sequence[str] = (), ): """Common request processing logic.""" from litellm.proxy.proxy_server import ( @@ -376,6 +387,7 @@ async def _process_request( query_params: Final = dict(request.query_params) data: Final[dict[str, Any]] = { "query_params": query_params, + **_declared_query_params(query_params, query_param_names), **path_params, } @@ -452,7 +464,13 @@ def register_container_file_endpoints(router: APIRouter) -> None: is_multipart = endpoint_config.get("is_multipart", False) # Create handler with correct signature for path params - handler = _create_handler_for_path_params(path_params, route_type, returns_binary, is_multipart) + handler = _create_handler_for_path_params( + path_params, + route_type, + returns_binary, + is_multipart, + query_param_names=endpoint_config.get("query_params", ()), + ) # Register routes route_method = getattr(router, method) diff --git a/litellm/proxy/container_endpoints/ownership.py b/litellm/proxy/container_endpoints/ownership.py index e3088771c82..14480232a4a 100644 --- a/litellm/proxy/container_endpoints/ownership.py +++ b/litellm/proxy/container_endpoints/ownership.py @@ -1,9 +1,10 @@ import json -from collections.abc import Mapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from collections.abc import Set as AbstractSet -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, TypeAlias from fastapi import HTTPException +from pydantic import BaseModel from litellm._logging import verbose_proxy_logger from litellm.caching.in_memory_cache import InMemoryCache @@ -46,6 +47,12 @@ _CONTAINER_STORED_ID_CACHE: Final = InMemoryCache(max_size_in_memory=10000, defa # different users with different scopes get disjoint cache entries. _ALLOWED_CONTAINER_IDS_CACHE: Final = InMemoryCache(max_size_in_memory=2048, default_ttl=60) +DEFAULT_CONTAINER_LIST_LIMIT: Final = 20 +OWNED_CONTAINER_LIST_PAGE_SIZE: Final = 100 +OWNED_CONTAINER_LIST_MAX_PAGES: Final = 5 + +FetchContainerListPage: TypeAlias = Callable[[str | None, int | None], Awaitable[object]] + def _allowed_container_ids_cache_key(owner_scopes: Sequence[str]) -> str: """JSON-encode the sorted scope list — using a separator like ``|`` @@ -337,27 +344,23 @@ def _get_container_list_data(response: object) -> Sequence[object] | None: return data if isinstance(data, list) else None -def _set_container_list_data(response: Any, data: list[object], removed_filtered_items: bool = False) -> object: +def _get_has_more(response: object) -> bool: if isinstance(response, dict): - response["data"] = data - if data: - response["first_id"] = _get_response_id(data[0]) - response["last_id"] = _get_response_id(data[-1]) - else: - response["first_id"] = None - response["last_id"] = None - response["has_more"] = False - if removed_filtered_items: - response["has_more"] = False - return response + return response.get("has_more") is True + return getattr(response, "has_more", None) is True - response.data = data - response.first_id = _get_response_id(data[0]) if data else None - response.last_id = _get_response_id(data[-1]) if data else None - if not data and hasattr(response, "has_more"): - response.has_more = False - if removed_filtered_items and hasattr(response, "has_more"): - response.has_more = False + +def _with_container_list_page(response: object, data: Sequence[object], has_more: bool) -> object: + page: Final = { + "data": list(data), + "first_id": _get_response_id(data[0]) if data else None, + "last_id": _get_response_id(data[-1]) if data else None, + "has_more": has_more, + } + if isinstance(response, dict): + return {**response, **page} + if isinstance(response, BaseModel): + return response.model_copy(update=page) return response @@ -366,16 +369,16 @@ async def _get_allowed_container_ids( ) -> AbstractSet[str]: owner_scopes: Final = get_resource_owner_scopes(user_api_key_dict) if not owner_scopes: - return set() + return frozenset() cache_key: Final = _allowed_container_ids_cache_key(owner_scopes) cached: Final = _ALLOWED_CONTAINER_IDS_CACHE.get_cache(cache_key) if cached is not None: - return set(cached) + return frozenset(cached) prisma_client: Final = await _get_prisma_client() if prisma_client is None: - return set() + return frozenset() table: Final = ManagedObjectRepository(prisma_client).table rows: Final[Sequence[prisma_models.LiteLLM_ManagedObjectTable]] = await table.find_many( @@ -384,34 +387,69 @@ async def _get_allowed_container_ids( "created_by": {"in": owner_scopes}, } ) - allowed_ids: Final = {row.model_object_id for row in rows if getattr(row, "model_object_id", None) is not None} - # ``InMemoryCache.get_cache`` attempts ``json.loads`` on the stored - # value; passing a set would round-trip through that path - # unnecessarily. Store as a list and rehydrate above. - _ALLOWED_CONTAINER_IDS_CACHE.set_cache(cache_key, list(allowed_ids)) + allowed_ids: Final = frozenset( + row.model_object_id for row in rows if getattr(row, "model_object_id", None) is not None + ) + _ALLOWED_CONTAINER_IDS_CACHE.set_cache(cache_key, tuple(allowed_ids)) return allowed_ids -async def filter_container_list_response( - response: object, +def _is_owned_container(item: object, allowed_container_ids: AbstractSet[str], custom_llm_provider: str) -> bool: + container_id: Final = _get_response_id(item) + if container_id is None: + return False + original_container_id, resolved_provider = decode_container_id_for_ownership(container_id, custom_llm_provider) + return _container_model_object_id(original_container_id, resolved_provider) in allowed_container_ids + + +async def _collect_owned_containers( + fetch_page: FetchContainerListPage, + after: str | None, + needed: int, + allowed_container_ids: AbstractSet[str], + custom_llm_provider: str, + pages_left: int, + collected: tuple[object, ...], +) -> tuple[object, tuple[object, ...]]: + page: Final = await fetch_page(after, OWNED_CONTAINER_LIST_PAGE_SIZE) + page_data: Final = _get_container_list_data(page) or () + owned: Final = collected + tuple( + item for item in page_data if _is_owned_container(item, allowed_container_ids, custom_llm_provider) + ) + upstream_last_id: Final = _get_response_id(page_data[-1]) if page_data else None + if len(owned) >= needed or upstream_last_id is None or pages_left <= 1 or not _get_has_more(page): + return page, owned + return await _collect_owned_containers( + fetch_page=fetch_page, + after=upstream_last_id, + needed=needed, + allowed_container_ids=allowed_container_ids, + custom_llm_provider=custom_llm_provider, + pages_left=pages_left - 1, + collected=owned, + ) + + +async def list_owned_containers( + fetch_page: FetchContainerListPage, + after: str | None, + limit: int | None, user_api_key_dict: UserAPIKeyAuth, custom_llm_provider: str, ) -> object: - if is_proxy_admin(user_api_key_dict): - return response - - data: Final = _get_container_list_data(response) - if data is None: - return response - allowed_container_ids: Final = await _get_allowed_container_ids(user_api_key_dict) - filtered: Final[list[object]] = [] - for item in data: - container_id = _get_response_id(item) - if container_id is None: - continue - original_container_id, resolved_provider = decode_container_id_for_ownership(container_id, custom_llm_provider) - if _container_model_object_id(original_container_id, resolved_provider) in allowed_container_ids: - filtered.append(item) - - return _set_container_list_data(response, filtered, removed_filtered_items=len(filtered) != len(data)) + page_limit: Final = limit if limit is not None else DEFAULT_CONTAINER_LIST_LIMIT + last_page, owned = await _collect_owned_containers( + fetch_page=fetch_page, + after=after, + needed=page_limit + 1, + allowed_container_ids=allowed_container_ids, + custom_llm_provider=custom_llm_provider, + pages_left=OWNED_CONTAINER_LIST_MAX_PAGES, + collected=(), + ) + return _with_container_list_page( + last_page, + owned[:page_limit], + has_more=len(owned) > page_limit or _get_has_more(last_page), + ) diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 30526d30dc5..7f2616c2fb8 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -41,7 +41,7 @@ from litellm.llms.anthropic.chat.guardrail_translation.handler import AnthropicM from litellm.llms.base_llm.guardrail_translation.utils import ( effective_scan_only_tool_results_for_guardrail, ) -from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM +from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, bedrock_bearer_token from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -56,7 +56,6 @@ from litellm.proxy.guardrails.anthropic_sse import ( is_raw_sse_stream, model_response_text, ) -from litellm.secret_managers.main import get_secret_str from litellm.types.guardrails import ( BedrockChecksConfigModel, BedrockGuardrailStreamingParams, @@ -713,9 +712,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): # logic becomes shared across providers. #### CALL HOOKS - proxy only #### - def _load_credentials( - self, - ): + def _load_credentials(self, bearer_token: str | None = None): try: from botocore.credentials import Credentials except ImportError: @@ -737,17 +734,21 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): aws_region_name=aws_region_name, ) - credentials: Final[Credentials] = self.get_credentials( - aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key, - aws_session_token=aws_session_token, - aws_region_name=aws_region_name, - aws_session_name=aws_session_name, - aws_profile_name=aws_profile_name, - aws_role_name=aws_role_name, - aws_web_identity_token=aws_web_identity_token, - aws_sts_endpoint=aws_sts_endpoint, - aws_external_id=aws_external_id, + credentials: Final[Credentials | None] = ( + None + if bearer_token is not None + else self.get_credentials( + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + aws_region_name=aws_region_name, + aws_session_name=aws_session_name, + aws_profile_name=aws_profile_name, + aws_role_name=aws_role_name, + aws_web_identity_token=aws_web_identity_token, + aws_sts_endpoint=aws_sts_endpoint, + aws_external_id=aws_external_id, + ) ) return credentials, aws_region_name @@ -779,13 +780,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): proxy_endpoint_url = f"{proxy_endpoint_url}{request_path}" encoded_data: Final = json.dumps(data).encode("utf-8") - # first check api-key, if none, fall back to sigV4 - if api_key is not None: - aws_bearer_token: str | None = api_key - else: - aws_bearer_token = get_secret_str("AWS_BEARER_TOKEN_BEDROCK") + aws_bearer_token: Final = bedrock_bearer_token(api_key) - if aws_bearer_token: + if aws_bearer_token is not None: try: from botocore.awsrequest import AWSRequest except ImportError: @@ -916,7 +913,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): source, ) return BedrockGuardrailResponse() - credentials, aws_region_name = self._load_credentials() + credentials, aws_region_name = self._load_credentials(bearer_token=bedrock_bearer_token(api_key)) allow_chunking: Final = not self._content_uses_contextual_grounding(content) completed_chunk_usages: Final[list[BedrockGuardrailUsage]] = [] # mutable-ok: billed-chunk usage accumulator @@ -958,7 +955,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): self, content: Sequence[BedrockContentItem], base_request_data: Mapping[str, object], - credentials: "Credentials", + credentials: "Credentials | None", aws_region_name: str, api_key: str | None, request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper @@ -1096,7 +1093,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): self, content: Sequence[BedrockContentItem], base_request_data: Mapping[str, object], - credentials: "Credentials", + credentials: "Credentials | None", aws_region_name: str, api_key: str | None, request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper @@ -1146,7 +1143,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): self, content: Sequence[BedrockContentItem], base_request_data: Mapping[str, object], - credentials: "Credentials", + credentials: "Credentials | None", aws_region_name: str, api_key: str | None, request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper @@ -1873,9 +1870,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): # Nothing to scan (e.g. tool-only turn) -> allow, like ApplyGuardrail does. return BedrockGuardrailResponse() - credentials, aws_region_name = self._load_credentials() - body: Final[dict[str, object]] = {"messages": checks_messages, "checks": self.checks} api_key: Final[str | None] = request_data.get("api_key") if request_data else None + credentials, aws_region_name = self._load_credentials(bearer_token=bedrock_bearer_token(api_key)) + body: Final[dict[str, object]] = {"messages": checks_messages, "checks": self.checks} prepared_request: Final = self._prepare_request( credentials=credentials, 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 c6b8df1b493..9029e926b35 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py +++ b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py @@ -121,9 +121,6 @@ def _a2a_jsonrpc_error_chunk(exc: HTTPException, request_id: str | None) -> Mapp } -endpoint_guardrail_translation_mappings = None - - def _ensure_litellm_metadata(data: dict, user_api_key_dict: UserAPIKeyAuth) -> None: """Populate data['litellm_metadata'] from user_api_key_dict if absent.""" if "litellm_metadata" not in data: @@ -164,7 +161,6 @@ class UnifiedLLMGuardrails(CustomLogger): Use this if you want to MODIFY the input """ - global endpoint_guardrail_translation_mappings from litellm.proxy.common_utils.callback_utils import ( add_guardrail_to_applied_guardrails_header, ) @@ -186,18 +182,15 @@ class UnifiedLLMGuardrails(CustomLogger): ) return data - if endpoint_guardrail_translation_mappings is None: - endpoint_guardrail_translation_mappings = load_guardrail_translation_mappings() + mappings: Final = load_guardrail_translation_mappings() try: - if CallTypes(call_type) not in endpoint_guardrail_translation_mappings: + if CallTypes(call_type) not in mappings: return data except ValueError: return data # handle unmapped call types - endpoint_translation: Final = _as_endpoint_translation( - endpoint_guardrail_translation_mappings[CallTypes(call_type)]() - ) + endpoint_translation: Final = _as_endpoint_translation(mappings[CallTypes(call_type)]()) _ensure_litellm_metadata(data, user_api_key_dict) @@ -222,8 +215,6 @@ class UnifiedLLMGuardrails(CustomLogger): This can NOT modify the input, only used to reject or accept a call before going to LLM API """ - global endpoint_guardrail_translation_mappings - verbose_proxy_logger.debug("Running UnifiedLLMGuardrails moderation hook") guardrail_to_apply: Final[CustomGuardrail] = data.pop("guardrail_to_apply", None) @@ -241,14 +232,11 @@ class UnifiedLLMGuardrails(CustomLogger): ) return data - if endpoint_guardrail_translation_mappings is None: - endpoint_guardrail_translation_mappings = load_guardrail_translation_mappings() - if call_type is not None and CallTypes(call_type) not in endpoint_guardrail_translation_mappings: + mappings: Final = load_guardrail_translation_mappings() + if call_type is not None and CallTypes(call_type) not in mappings: return data - endpoint_translation: Final = _as_endpoint_translation( - endpoint_guardrail_translation_mappings[CallTypes(call_type)]() - ) + endpoint_translation: Final = _as_endpoint_translation(mappings[CallTypes(call_type)]()) _ensure_litellm_metadata(data, user_api_key_dict) @@ -271,7 +259,6 @@ class UnifiedLLMGuardrails(CustomLogger): Uses Enkrypt AI guardrails to check the response for policy violations, PII, and injection attacks """ - global endpoint_guardrail_translation_mappings # Local import avoids a module-level cyclic import with # litellm.integrations.custom_guardrail. from litellm.integrations.custom_guardrail import ModifyResponseException @@ -319,10 +306,9 @@ class UnifiedLLMGuardrails(CustomLogger): ) return response - if endpoint_guardrail_translation_mappings is None: - endpoint_guardrail_translation_mappings = load_guardrail_translation_mappings() + mappings: Final = load_guardrail_translation_mappings() - if CallTypes(call_type) not in endpoint_guardrail_translation_mappings: + if CallTypes(call_type) not in mappings: verbose_proxy_logger.warning( "Guardrail '%s' selected for route '%s' but call type '%s' has no guardrail translation handler; " "skipping post-call scanning.", @@ -332,9 +318,7 @@ class UnifiedLLMGuardrails(CustomLogger): ) return response - endpoint_translation: Final = _as_endpoint_translation( - endpoint_guardrail_translation_mappings[CallTypes(call_type)]() - ) + endpoint_translation: Final = _as_endpoint_translation(mappings[CallTypes(call_type)]()) try: response = await endpoint_translation.process_output_response( @@ -906,8 +890,6 @@ class UnifiedLLMGuardrails(CustomLogger): sampling_rate=1 means every chunk, sampling_rate=5 means every 5th chunk, etc. """ - global endpoint_guardrail_translation_mappings - # Local import avoids a module-level cyclic import with # litellm.integrations.custom_guardrail. from litellm.integrations.custom_guardrail import ModifyResponseException @@ -978,9 +960,7 @@ class UnifiedLLMGuardrails(CustomLogger): yield item return - # Initialize translation mappings if needed - if endpoint_guardrail_translation_mappings is None: - endpoint_guardrail_translation_mappings = load_guardrail_translation_mappings() + mappings: Final = 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 @@ -989,7 +969,7 @@ class UnifiedLLMGuardrails(CustomLogger): if streaming_transform_mode == "incremental_diff": transform_call_type: Final = self._resolve_transform_call_type( user_api_key_dict=user_api_key_dict, - mappings=endpoint_guardrail_translation_mappings, + mappings=mappings, ) if transform_call_type is not None: async for transformed_item in self._run_incremental_transform_stream( @@ -1000,7 +980,7 @@ class UnifiedLLMGuardrails(CustomLogger): call_type=transform_call_type, sampling_rate=sampling_rate, end_of_stream_only=end_of_stream_only, - mappings=endpoint_guardrail_translation_mappings, + mappings=mappings, ): yield transformed_item return @@ -1037,7 +1017,7 @@ class UnifiedLLMGuardrails(CustomLogger): call_type = _infer_call_type(call_type=None, completion_response=item) # If call type not supported, just pass through all chunks - if call_type is None or CallTypes(call_type) not in endpoint_guardrail_translation_mappings: + if call_type is None or CallTypes(call_type) not in mappings: yield item async for remaining_item in response: yield remaining_item @@ -1049,7 +1029,7 @@ class UnifiedLLMGuardrails(CustomLogger): # moderation runs below. if end_of_stream_only: if not buffer_until_moderated: - endpoint_translation = endpoint_guardrail_translation_mappings[CallTypes(call_type)]() + endpoint_translation = mappings[CallTypes(call_type)]() stream_has_ended = hasattr( endpoint_translation, "_check_streaming_has_ended" ) and endpoint_translation._check_streaming_has_ended(responses_so_far) @@ -1063,7 +1043,7 @@ class UnifiedLLMGuardrails(CustomLogger): # Process chunk based on sampling rate if chunk_counter % sampling_rate == 0: - endpoint_translation = endpoint_guardrail_translation_mappings[CallTypes(call_type)]() + endpoint_translation = mappings[CallTypes(call_type)]() scan_key = endpoint_translation.get_streaming_scan_key(responses_so_far) if _is_redundant_scan(scan_key, last_scan_key): verbose_proxy_logger.debug( @@ -1143,14 +1123,14 @@ class UnifiedLLMGuardrails(CustomLogger): yield item # Stream has ended - do final processing with all collected chunks - if call_type is not None and CallTypes(call_type) in endpoint_guardrail_translation_mappings: + if call_type is not None and CallTypes(call_type) in mappings: verbose_proxy_logger.debug( "Processing final streaming response with all %s chunks for guardrail %s", len(responses_so_far), guardrail_to_apply.guardrail_name, ) - endpoint_translation = endpoint_guardrail_translation_mappings[CallTypes(call_type)]() + endpoint_translation = mappings[CallTypes(call_type)]() # When buffering, snapshot the original chunks before moderation. # A shallow copy suffices: end-of-stream diff --git a/litellm/proxy/guardrails/guardrail_registry.py b/litellm/proxy/guardrails/guardrail_registry.py index 003576ba555..0dc50cd6196 100644 --- a/litellm/proxy/guardrails/guardrail_registry.py +++ b/litellm/proxy/guardrails/guardrail_registry.py @@ -654,23 +654,16 @@ class InMemoryGuardrailHandler: source: Literal["db", "config"] = "db", ) -> None: """ - Update a guardrail in memory - - - updates the guardrail in memory - - updates the guardrail params in litellm.callback_manager + Update a guardrail in memory: a changed name or litellm_params rebuilds the + live callback from the new row (fail-closed: an invalid row keeps the + previous instance and raises), anything else only refreshes the stored row """ - self.IN_MEMORY_GUARDRAILS[guardrail_id] = guardrail - self._sources[guardrail_id] = source - - tracked_callbacks: Final = self._tracked_callbacks(guardrail_id) - if not tracked_callbacks: + updated_guardrail: Final = cast(Guardrail, {**guardrail, "guardrail_id": guardrail_id}) + if self._has_guardrail_params_changed(guardrail_id, updated_guardrail): + self.reinitialize_guardrail(guardrail=updated_guardrail, source=source) return - updated_litellm_params: Final = cast(LitellmParams, guardrail.get("litellm_params", {})) - tracked_callbacks[0].update_in_memory_litellm_params(litellm_params=updated_litellm_params) - for sibling_callback in tracked_callbacks[1:]: - sibling_stage = sibling_callback.event_hook - sibling_callback.update_in_memory_litellm_params(litellm_params=updated_litellm_params) - sibling_callback.event_hook = sibling_stage + self.IN_MEMORY_GUARDRAILS[guardrail_id] = updated_guardrail + self._sources[guardrail_id] = source def delete_in_memory_guardrail(self, guardrail_id: str) -> None: """ diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 8b57bdca2fe..65d0ec8c0dc 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -50,6 +50,9 @@ from litellm.proxy.health_check import ( perform_health_check, run_with_timeout, ) +from litellm.proxy.middleware.admission_control_middleware import ( + get_admission_control_stats, +) from litellm.proxy.middleware.in_flight_requests_middleware import ( get_in_flight_requests, ) @@ -63,6 +66,13 @@ from litellm.secret_managers.main import get_secret_bool #### Health ENDPOINTS #### +class _HealthBacklogResponse(TypedDict): + in_flight_requests: ReadOnly[int] + admitted_requests: ReadOnly[int] + queued_requests: ReadOnly[int] + rejected_requests: ReadOnly[int] + + def _reject_os_environ_references(params: dict) -> None: """ Validate that the provided params do not contain any ``os.environ/`` @@ -1759,7 +1769,14 @@ async def health_backlog(): for the event loop to get to them, adding latency before LiteLLM even starts its own timer. """ - return {"in_flight_requests": get_in_flight_requests()} + stats: Final = get_admission_control_stats() + response: Final[_HealthBacklogResponse] = { + "in_flight_requests": get_in_flight_requests(), + "admitted_requests": stats.admitted, + "queued_requests": stats.queued, + "rejected_requests": stats.rejected_total, + } + return response @router.get( diff --git a/litellm/proxy/hooks/responses_id_security.py b/litellm/proxy/hooks/responses_id_security.py index 21d12c8f720..c4c15c40d1e 100644 --- a/litellm/proxy/hooks/responses_id_security.py +++ b/litellm/proxy/hooks/responses_id_security.py @@ -5,10 +5,11 @@ This hook uses the DBSpendUpdateWriter to batch-write response IDs to the databa instead of writing immediately on each request. """ -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable, Mapping from typing import TYPE_CHECKING, Any, Final, cast from fastapi import HTTPException +from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_logger import CustomLogger @@ -32,6 +33,44 @@ _RESPONSES_API_PROVIDER_PREFIX: Final = "/openai" _RESPONSES_API_CREATE_ROUTES: Final = frozenset({"/v1/responses", "/responses"}) +_RESPONSE_PAYLOAD_ADAPTER: Final = TypeAdapter(Mapping[str, object]) + + +def _response_payload(response_obj: object) -> Mapping[str, object] | None: + try: + return _RESPONSE_PAYLOAD_ADAPTER.validate_python(response_obj) + except ValidationError: + return None + + +def _rewrite_advertised_id( + event: BaseLiteLLMOpenAIResponseObject, + rewrite: Callable[[str], str], +) -> BaseLiteLLMOpenAIResponseObject: + event_id: Final = getattr(event, "id", None) + if isinstance(event_id, str) and event_id.startswith("resp_"): + setattr(event, "id", rewrite(event_id)) + return event + + nested: Final = getattr(event, "response", None) + if isinstance(nested, ResponsesAPIResponse): + setattr(nested, "id", rewrite(nested.id)) + setattr(event, "response", nested) + return event + + payload: Final = _response_payload(nested) + if payload is None: + return event + + payload_id: Final = payload.get("id") + if not isinstance(payload_id, str): + return event + + rewritten: Final = {**payload, "id": rewrite(payload_id)} # mutable-ok: pydantic cannot serialize a frozen map + setattr(event, "response", rewritten) + return event + + def _is_responses_api_create_route(request_route: str | None) -> bool: if request_route is None: return False @@ -196,10 +235,6 @@ class ResponsesIDSecurity(CustomLogger): user_api_key_dict: "UserAPIKeyAuth", request_cache: dict[str, str] | None = None, ) -> BaseLiteLLMOpenAIResponseObject: - # encrypt the response id using the symmetric key - # encrypt the response id, and encode the user id and response id in base64 - - # Check if signing key is available signing_key: Final = self._get_signing_key() if signing_key is None: verbose_proxy_logger.debug( @@ -210,43 +245,22 @@ class ResponsesIDSecurity(CustomLogger): ) return response - response_id: Final = getattr(response, "id", None) - response_obj: Final = getattr(response, "response", None) + def encrypt(original_id: str) -> str: + cached: Final = request_cache.get(original_id) if request_cache is not None else None + if cached is not None: + return cached - if response_id and isinstance(response_id, str) and response_id.startswith("resp_"): - # Check request-scoped cache first (for streaming consistency) - if request_cache is not None and response_id in request_cache: - setattr(response, "id", request_cache[response_id]) - else: - encrypted_response_id = SpecialEnums.LITELLM_MANAGED_RESPONSE_API_RESPONSE_ID_COMPLETE_STR.value.format( - response_id, - user_api_key_dict.user_id or "", - user_api_key_dict.team_id or "", - ) + managed_id: Final = SpecialEnums.LITELLM_MANAGED_RESPONSE_API_RESPONSE_ID_COMPLETE_STR.value.format( + original_id, + user_api_key_dict.user_id or "", + user_api_key_dict.team_id or "", + ) + encrypted_id: Final = f"resp_{encrypt_value_helper(value=managed_id)}" + if request_cache is not None: + request_cache[original_id] = encrypted_id + return encrypted_id - encoded_user_id_and_response_id = encrypt_value_helper(value=encrypted_response_id) - encrypted_id = f"resp_{encoded_user_id_and_response_id}" - if request_cache is not None: - request_cache[response_id] = encrypted_id - setattr(response, "id", encrypted_id) - - elif response_obj and isinstance(response_obj, ResponsesAPIResponse): - # Check request-scoped cache first (for streaming consistency) - if request_cache is not None and response_obj.id in request_cache: - setattr(response_obj, "id", request_cache[response_obj.id]) - else: - encrypted_response_id = SpecialEnums.LITELLM_MANAGED_RESPONSE_API_RESPONSE_ID_COMPLETE_STR.value.format( - response_obj.id, - user_api_key_dict.user_id or "", - user_api_key_dict.team_id or "", - ) - encoded_user_id_and_response_id = encrypt_value_helper(value=encrypted_response_id) - encrypted_id = f"resp_{encoded_user_id_and_response_id}" - if request_cache is not None: - request_cache[response_obj.id] = encrypted_id - setattr(response_obj, "id", encrypted_id) - setattr(response, "response", response_obj) - return response + return _rewrite_advertised_id(response, encrypt) async def async_post_call_success_hook( self, diff --git a/litellm/proxy/image_endpoints/endpoints.py b/litellm/proxy/image_endpoints/endpoints.py index 83caa92ede5..06f99e4ae9c 100644 --- a/litellm/proxy/image_endpoints/endpoints.py +++ b/litellm/proxy/image_endpoints/endpoints.py @@ -2,7 +2,7 @@ import asyncio import io import traceback from collections.abc import Sequence -from typing import Final +from typing import Final, get_type_hints import orjson from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status @@ -16,11 +16,18 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing +from litellm.proxy.common_utils.http_parsing_utils import ( + coerce_numeric_form_fields, + numeric_form_fields, +) from litellm.proxy.route_llm_request import route_request +from litellm.types.images.main import ImageEditRequestParams from litellm.types.llms.openai import ChatCompletionUserMessage router: Final = APIRouter() +IMAGE_EDIT_NUMERIC_FORM_FIELDS: Final = numeric_form_fields(get_type_hints(ImageEditRequestParams)) + async def uploadfile_to_bytesio(upload: UploadFile) -> io.BytesIO: """ @@ -279,7 +286,12 @@ async def image_edit_api( ######################################################### # Read request body and convert UploadFiles to BytesIO ######################################################### - data: Final = await _read_request_body(request=request) + data: Final = dict( + coerce_numeric_form_fields( + parsed_body=await _read_request_body(request=request), + numeric_fields=IMAGE_EDIT_NUMERIC_FORM_FIELDS, + ) + ) image_files: Final = await batch_to_bytesio(image) mask_files: Final = await batch_to_bytesio(mask) if image_files: diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py index 0357bc7dbc6..1f91eeedf64 100644 --- a/litellm/proxy/management_endpoints/access_group_endpoints.py +++ b/litellm/proxy/management_endpoints/access_group_endpoints.py @@ -1,4 +1,5 @@ from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import Final, Protocol from fastapi import APIRouter, Depends, HTTPException, status @@ -20,7 +21,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.proxy.management_helpers.access_group_team_sync import invalidate_access_group_cache from litellm.proxy.utils import get_prisma_client_or_throw -from litellm.repositories.table_repositories import AccessGroupRepository +from litellm.repositories.table_repositories import AccessGroupRepository, TeamRepository from litellm.types.access_group import ( AccessGroupCreateRequest, AccessGroupResponse, @@ -74,11 +75,11 @@ class _AccessGroupTable(Protocol): class _TeamTable(Protocol): - async def find_unique(self, where: Mapping[str, object]) -> _TeamRecord | None: ... + async def find_unique(self, *, where: Mapping[str, object]) -> _TeamRecord | None: ... - async def find_many(self, where: Mapping[str, object]) -> Sequence[_TeamRecord]: ... + async def find_many(self, *, where: Mapping[str, object]) -> Sequence[_TeamRecord]: ... - async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> object: ... + async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> object: ... class _KeyTable(Protocol): @@ -119,8 +120,56 @@ def _require_admin_view(user_api_key_dict: UserAPIKeyAuth) -> None: ) -def _record_to_response(record: _AccessGroupRecord) -> AccessGroupResponse: - return AccessGroupResponse.model_validate(record.dict()) +def _record_to_response( + record: _AccessGroupRecord, *, assigned_team_ids: Sequence[str] | None = None +) -> AccessGroupResponse: + stored: Final = record.dict() + payload: Final = ( + stored if assigned_team_ids is None else MappingProxyType({**stored, "assigned_team_ids": assigned_team_ids}) + ) + return AccessGroupResponse.model_validate(payload) + + +def _attached_team_ids_by_group( + records: Sequence[_AccessGroupRecord], teams: Sequence[_TeamRecord] +) -> Mapping[str, tuple[str, ...]]: + """Teams really attached to each group: the stored column minus ghosts, plus teams the mirror missed.""" + real_team_ids: Final = frozenset(team.team_id for team in teams) + + def attached(record: _AccessGroupRecord) -> tuple[str, ...]: + stored: Final = (team_id for team_id in (record.assigned_team_ids or ()) if team_id in real_team_ids) + carrying: Final = (team.team_id for team in teams if record.access_group_id in (team.access_group_ids or ())) + return tuple(dict.fromkeys((*stored, *carrying))) + + return MappingProxyType({record.access_group_id: attached(record) for record in records}) + + +async def _attached_team_ids_for( + team_table: _TeamTable, records: Sequence[_AccessGroupRecord] +) -> Mapping[str, tuple[str, ...]]: + if not records: + return MappingProxyType({}) + group_ids: Final = tuple(record.access_group_id for record in records) + stored_team_ids: Final = tuple( + dict.fromkeys(team_id for record in records for team_id in (record.assigned_team_ids or ())) + ) + carrying: Final = {"access_group_ids": {"hasSome": group_ids}} # mutable-ok: prisma where is a dict + listed: Final = {"team_id": {"in": stored_team_ids}} # mutable-ok: prisma where is a dict + teams: Final = await team_table.find_many(where={"OR": (carrying, listed)}) # mutable-ok: prisma where is a dict + return _attached_team_ids_by_group(records, teams) + + +async def _require_teams_exist(tx: _AccessGroupTx, team_ids: Sequence[str]) -> None: + if not team_ids: + return + where: Final = {"team_id": {"in": team_ids}} # mutable-ok: prisma where is a dict + found: Final = await tx.litellm_teamtable.find_many(where=where) + missing: Final = frozenset(team_ids) - frozenset(team.team_id for team in found) + if missing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown team ids: {', '.join(sorted(missing))}", + ) def _record_to_access_group_table(record: _AccessGroupRecord) -> LiteLLM_AccessGroupTable: @@ -330,6 +379,7 @@ async def create_access_group( status_code=status.HTTP_409_CONFLICT, detail=f"Access group '{data.access_group_name}' already exists", ) + await _require_teams_exist(tx, data.assigned_team_ids or ()) record: Final = await tx.litellm_accessgrouptable.create( data={ @@ -390,7 +440,8 @@ async def list_access_groups( table: Final = AccessGroupRepository(prisma_client).table records: Final = await table.find_many(order={"created_at": "desc"}) - return [_record_to_response(r) for r in records] + attached: Final = await _attached_team_ids_for(TeamRepository(prisma_client).table, records) + return [_record_to_response(r, assigned_team_ids=attached[r.access_group_id]) for r in records] @router.get( @@ -411,7 +462,8 @@ async def get_access_group( status_code=status.HTTP_404_NOT_FOUND, detail=f"Access group '{access_group_id}' not found", ) - return _record_to_response(record) + attached: Final = await _attached_team_ids_for(TeamRepository(prisma_client).table, (record,)) + return _record_to_response(record, assigned_team_ids=attached[record.access_group_id]) @router.put( @@ -461,8 +513,10 @@ async def update_access_group( status_code=status.HTTP_404_NOT_FOUND, detail=f"Access group '{access_group_id}' not found", ) + await _require_teams_exist(tx, data.assigned_team_ids or ()) - old_team_ids: Final[set[str]] = set(existing.assigned_team_ids or []) + attached: Final = await _attached_team_ids_for(tx.litellm_teamtable, (existing,)) + old_team_ids: Final[set[str]] = set(attached[access_group_id]) old_key_ids: Final[set[str]] = set(existing.assigned_key_ids or []) new_team_ids: Final[set[str]] = ( set(update_fields["assigned_team_ids"] or []) if "assigned_team_ids" in update_fields else old_team_ids diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 93423ca5a1a..d066f1e9138 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -17,6 +17,7 @@ import json import traceback from collections.abc import Awaitable, Mapping, Sequence from datetime import datetime, timezone +from types import MappingProxyType from typing import Any, Final, Literal, Protocol, cast, overload import fastapi @@ -77,6 +78,7 @@ from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( BulkUpdateUserRequest, BulkUpdateUserResponse, UserListResponse, + UserSearchWhere, UserUpdateResult, ) from litellm.types.proxy.management_endpoints.scim_v2 import ( @@ -2080,6 +2082,22 @@ async def _authorize_user_list_request( return ",".join(allowed_org_ids) +_NO_SEARCH_WHERE: Final[Mapping[str, object]] = MappingProxyType({}) + + +def _user_search_where(search: str | None) -> Mapping[str, object]: + """Prisma predicate for `/user/list?search=`: user_id or user_email contains it, case-insensitive.""" + if not search: + return _NO_SEARCH_WHERE + search_where: Final[UserSearchWhere] = { + "OR": ( + {"user_id": {"contains": search, "mode": "insensitive"}}, + {"user_email": {"contains": search, "mode": "insensitive"}}, + ) + } + return search_where + + @router.get( "/user/list", tags=["Internal User management"], @@ -2091,6 +2109,10 @@ async def get_users( user_ids: str | None = fastapi.Query(default=None, description="Get list of users by user_ids"), sso_user_ids: str | None = fastapi.Query(default=None, description="Get list of users by sso_user_id"), user_email: str | None = fastapi.Query(default=None, description="Filter users by partial email match"), + search: str | None = fastapi.Query( + default=None, + description="Combined search: matches users whose 'user_id' or 'user_email' contains the value (case-insensitive).", + ), team: str | None = fastapi.Query(default=None, description="Filter users by team id"), page: int = fastapi.Query(default=1, ge=1, description="Page number"), page_size: int = fastapi.Query(default=25, ge=1, le=100, description="Number of items per page"), @@ -2121,6 +2143,8 @@ async def get_users( Get list of users by sso_ids. Comma separated list of sso_ids. user_email: Optional[str] Filter users by partial email match + search: Optional[str] + Combined search: matches users whose user_id or user_email contains the value (case-insensitive) team: Optional[str] Filter users by team id. Will match if user has this team in their teams array. page: int @@ -2197,7 +2221,11 @@ async def get_users( where_conditions["organization_memberships"] = {"some": {"organization_id": {"in": org_id_list}}} ## Filter any none fastapi.Query params - e.g. where_conditions: {'user_email': {'contains': Query(None), 'mode': 'insensitive'}, 'teams': {'has': Query(None)}} - where_conditions = {k: v for k, v in where_conditions.items() if v is not None} + where: Final[Mapping[str, object]] = { + key: value + for key, value in (*where_conditions.items(), *_user_search_where(search).items()) + if value is not None + } # Build order_by conditions @@ -2206,14 +2234,14 @@ async def get_users( ) users: Final[Sequence[prisma_models.LiteLLM_UserTable]] = await UserRepository(prisma_client).table.find_many( - where=where_conditions, + where=where, skip=skip, take=page_size, order=(order_by if order_by else {"created_at": "desc"}), # Default to created_at desc if no sort specified ) # Get total count of user rows - total_count: Final[int] = await UserRepository(prisma_client).table.count(where=where_conditions) + total_count: Final[int] = await UserRepository(prisma_client).table.count(where=where) # Get key count for each user user_key_counts: Final = await get_user_key_counts(prisma_client, [user.user_id for user in users]) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index d7d20d168b5..324d380b85b 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -147,6 +147,7 @@ from litellm.types.proxy.management_endpoints.key_management_endpoints import ( BulkUpdateKeyResponse, BulkUpdateTeamKeysRequest, FailedKeyUpdate, + KeySearchWhere, SuccessfulKeyUpdate, ) from litellm.types.router import Deployment @@ -5800,6 +5801,10 @@ async def list_keys( None, description="Filter keys by key alias. Exact match by default; set substring_matching=true (admin only) for case-insensitive substring matching.", ), + search: str | None = Query( + None, + description="Combined search: matches keys whose token (key hash) equals the value OR whose key_alias contains it (case-insensitive).", + ), return_full_object: bool = Query(False, description="Return full key object"), include_team_keys: bool = Query(False, description="Include all keys for teams that user is an admin of."), include_created_by_keys: bool = Query(False, description="Include keys created by the user"), @@ -5943,6 +5948,7 @@ async def list_keys( agent_id=agent_id, use_substring_matching=use_substring_matching, expires_filter=expires if isinstance(expires, str) else None, + search=search, ) verbose_proxy_logger.debug("Successfully prepared response") @@ -6162,6 +6168,16 @@ def _build_expires_where_clause(expires_filter: str, now: datetime) -> dict[str, return {"OR": [{"expires": None}, {"expires": {"gte": now}}]} +def _build_key_search_where(search: str) -> KeySearchWhere: + search_where: Final[KeySearchWhere] = { + "OR": ( + {"token": search}, + {"key_alias": {"contains": search, "mode": "insensitive"}}, + ) + } + return search_where + + def _build_key_filter_conditions( user_id: str | None, team_id: str | None, @@ -6177,6 +6193,7 @@ def _build_key_filter_conditions( agent_id: str | None = None, use_substring_matching: bool = False, expires_filter: str | None = None, + search: str | None = None, ) -> Mapping[str, object]: """Build filter conditions for key listing. @@ -6266,7 +6283,7 @@ def _build_key_filter_conditions( # Apply team_id, project_id and access_group_id as global AND filters so they # narrow results across all visibility conditions (own keys, team keys, etc.) - global_filters: Final[tuple[dict[str, object], ...]] = ( + global_filters: Final[tuple[Mapping[str, object], ...]] = ( *( ( {"key_alias": {"contains": key_alias, "mode": "insensitive"}} @@ -6277,6 +6294,7 @@ def _build_key_filter_conditions( else () ), *(({"token": key_hash},) if key_hash and isinstance(key_hash, str) else ()), + *((_build_key_search_where(search),) if isinstance(search, str) and search else ()), *(({"team_id": team_id},) if team_id and isinstance(team_id, str) else ()), *(({"project_id": project_id},) if project_id else ()), *(({"access_group_ids": {"hasSome": [access_group_id]}},) if access_group_id else ()), @@ -6316,6 +6334,7 @@ async def _list_key_helper( agent_id: str | None = None, use_substring_matching: bool = False, expires_filter: str | None = None, + search: str | None = None, ) -> KeyListResponseObject: """ Helper function to list keys @@ -6354,6 +6373,7 @@ async def _list_key_helper( agent_id=agent_id, use_substring_matching=use_substring_matching, expires_filter=expires_filter, + search=search, ) # Calculate skip for pagination diff --git a/litellm/proxy/management_endpoints/policy_endpoints/endpoints.py b/litellm/proxy/management_endpoints/policy_endpoints/endpoints.py index f58f3722741..69356922ea1 100644 --- a/litellm/proxy/management_endpoints/policy_endpoints/endpoints.py +++ b/litellm/proxy/management_endpoints/policy_endpoints/endpoints.py @@ -12,7 +12,7 @@ All /policy management endpoints import copy import json import os -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator, AsyncIterator from typing import TYPE_CHECKING, Final, Literal, cast from fastapi import APIRouter, Depends, HTTPException, Request @@ -20,6 +20,7 @@ from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel, Field from typing_extensions import TypedDict +import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import ( COMPETITOR_LLM_TEMPERATURE, @@ -32,6 +33,10 @@ from litellm.llms.openai.chat.guardrail_translation.handler import ( ) from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_utils.sse_keepalive import ( + SSE_COMMENT_PING, + wrap_sse_stream_with_keepalive_pings, +) from litellm.proxy.guardrails.guardrail_hooks.custom_code import ( RESPONSE_REJECTION_GUARDRAIL_CODE, CustomCodeGuardrail, @@ -811,7 +816,7 @@ async def _stream_competitor_events( llm_enrichment: dict, brand_name: str, model: str, -) -> AsyncIterator[str]: +) -> AsyncGenerator[str, None]: """Stream competitor names as SSE events, then emit a final 'done' event.""" competitors: Final[list[str]] = list(data.competitors or []) @@ -883,7 +888,11 @@ async def enrich_policy_template_stream( model: Final = data.model or DEFAULT_COMPETITOR_DISCOVERY_MODEL return StreamingResponse( - _stream_competitor_events(data, template, llm_enrichment, brand_name, model), + wrap_sse_stream_with_keepalive_pings( + _stream_competitor_events(data, template, llm_enrichment, brand_name, model), + ping_interval_seconds=litellm.sse_keepalive_ping_interval_seconds, + ping_chunk=SSE_COMMENT_PING, + ), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index 069f86c852c..ceb67e3eee8 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -1123,13 +1123,15 @@ async def _create_user_if_not_exists(user_id: str, created_via: str = "scim_grou user_id=user_id, user_email=user_id, # We don't have email from group membership user_alias=None, - teams=[], # Teams will be added separately metadata={"created_via": created_via}, auto_create_key=False, user_role=default_role, ) - created_user: Final = await new_user(data=new_user_request) + created_user: Final = await new_user( + data=new_user_request, + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) verbose_proxy_logger.info("Created user %s via %s", user_id, created_via) return created_user @@ -1699,7 +1701,7 @@ async def create_user( user_id=user_id, user_email=user_data["user_email"], user_alias=user_data["user_alias"], - teams=user_data["teams"], + teams=user_data["teams"] or None, metadata=metadata, auto_create_key=False, user_role=resolved_role if admin_group is not None else default_role, @@ -1717,6 +1719,7 @@ async def create_user( created_user: Final = await new_user( data=new_user_request, + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), ) scim_user: Final = await ScimTransformations.transform_litellm_user_to_scim_user(user=created_user) @@ -1771,22 +1774,25 @@ async def update_user( roles=user_data["roles"], ) + # SCIM User.groups is readOnly (RFC 7643 4.1.2): IdPs sync membership via /Groups and send + # no groups or `groups: []` on profile PUTs, so empty means unspecified, not "remove from every team" + target_teams: Final = user_data["teams"] or existing_user.teams await _handle_team_membership_changes( user_id=user_id, - existing_teams=existing_user.teams or [], - new_teams=user_data["teams"], + existing_teams=existing_user.teams, + new_teams=target_teams, ) update_data: Final = { "user_email": user_data["user_email"], "user_alias": user_data["user_alias"], "sso_user_id": user_data["sso_user_id"], - "teams": user_data["teams"], + "teams": target_teams, "metadata": safe_dumps(metadata), } admin_group: Final = await _get_scim_admin_group() - if admin_group is not None: + if admin_group is not None and user_data["teams"]: update_data["user_role"] = _resolve_scim_user_role( user.groups or [], admin_group, _default_scim_user_role() ) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 72cdc75c29e..90d7539b38d 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -10,6 +10,7 @@ All /team management endpoints """ import asyncio +import copy import json import math import traceback @@ -2189,6 +2190,26 @@ async def update_team( if field in updated_kv } + _writes_metadata_backed_field: Final = any( + field in updated_kv + for field in ( + *LiteLLM_ManagementEndpoint_MetadataFields, + *LiteLLM_ManagementEndpoint_MetadataFields_Premium, + ) + ) + if isinstance(existing_team_row.metadata, dict): + if "metadata" not in updated_kv and (_team_member_fields_in_request or _writes_metadata_backed_field): + updated_kv["metadata"] = copy.deepcopy(existing_team_row.metadata) + elif isinstance(updated_kv.get("metadata"), dict): + updated_kv["metadata"] = { + **updated_kv["metadata"], + **{ + key: existing_team_row.metadata[key] + for key in TeamMemberBudgetHandler.SYSTEM_MANAGED_METADATA_KEYS + if key in existing_team_row.metadata + }, + } + if _team_member_fields_in_request and TeamMemberBudgetHandler.should_create_budget( team_member_budget=data.team_member_budget, team_member_rpm_limit=data.team_member_rpm_limit, diff --git a/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py b/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py index 9d5ddda017a..7ef496f94e3 100644 --- a/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py +++ b/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py @@ -4,7 +4,7 @@ usage/spend data by querying the aggregated daily activity endpoints. """ import json -from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, Sequence from datetime import date from typing import Any, Final, Literal, Protocol, cast, overload @@ -543,7 +543,7 @@ async def stream_usage_ai_chat( model: str | None = None, user_id: str | None = None, is_admin: bool = False, -) -> AsyncIterator[str]: +) -> AsyncGenerator[str, None]: """Stream SSE events: status → tool_call → chunk → done.""" resolved_model: Final = (model or "").strip() or DEFAULT_COMPETITOR_DISCOVERY_MODEL truncated: Final = messages[-MAX_CHAT_MESSAGES:] if len(messages) > MAX_CHAT_MESSAGES else messages diff --git a/litellm/proxy/management_endpoints/usage_endpoints/endpoints.py b/litellm/proxy/management_endpoints/usage_endpoints/endpoints.py index b7b0ae2d8e5..d1e92d0c7e4 100644 --- a/litellm/proxy/management_endpoints/usage_endpoints/endpoints.py +++ b/litellm/proxy/management_endpoints/usage_endpoints/endpoints.py @@ -10,8 +10,13 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field +import litellm from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_utils.sse_keepalive import ( + SSE_COMMENT_PING, + wrap_sse_stream_with_keepalive_pings, +) router: Final = APIRouter() @@ -56,11 +61,15 @@ async def usage_ai_chat( messages: Final = [{"role": m.role, "content": m.content} for m in data.messages] return StreamingResponse( - stream_usage_ai_chat( - messages=messages, - model=data.model, - user_id=user_id, - is_admin=is_admin, + wrap_sse_stream_with_keepalive_pings( + stream_usage_ai_chat( + messages=messages, + model=data.model, + user_id=user_id, + is_admin=is_admin, + ), + ping_interval_seconds=litellm.sse_keepalive_ping_interval_seconds, + ping_chunk=SSE_COMMENT_PING, ), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, diff --git a/litellm/proxy/memory/memory_endpoints.py b/litellm/proxy/memory/memory_endpoints.py index 98c5fdd198c..d8f72d200c7 100644 --- a/litellm/proxy/memory/memory_endpoints.py +++ b/litellm/proxy/memory/memory_endpoints.py @@ -22,6 +22,7 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Final from fastapi import APIRouter, Depends, HTTPException, Query +from typing_extensions import ReadOnly, TypedDict from litellm._logging import verbose_proxy_logger from litellm.proxy._types import ( @@ -91,6 +92,36 @@ def _visibility_filter(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, object return {"OR": ors} +class _StartsWith(TypedDict): + startsWith: ReadOnly[str] + + +class _MemoryKeyWhere(TypedDict): + key: ReadOnly[str | _StartsWith] + + +class _MemoryIdWhere(TypedDict): + memory_id: ReadOnly[str] + + +class _MemorySearchWhere(TypedDict): + OR: ReadOnly[tuple[_MemoryKeyWhere, _MemoryIdWhere]] + + +def _key_filter(search: str | None, key_prefix: str | None, key: str | None) -> Mapping[str, object] | None: + """`search` matches a key prefix or an exact memory_id; otherwise `key_prefix` wins over `key`.""" + if search is not None: + search_where: Final[_MemorySearchWhere] = {"OR": ({"key": {"startsWith": search}}, {"memory_id": search})} + return search_where + if key_prefix is not None: + prefix_where: Final[_MemoryKeyWhere] = {"key": {"startsWith": key_prefix}} + return prefix_where + if key is not None: + exact_where: Final[_MemoryKeyWhere] = {"key": key} + return exact_where + return None + + def _row_to_model(row: "prisma_models.LiteLLM_MemoryTable") -> LiteLLM_MemoryRow: return LiteLLM_MemoryRow( memory_id=row.memory_id, @@ -326,6 +357,13 @@ async def list_memory( "Mutually exclusive with `key`; if both are provided, `key_prefix` wins." ), ), + search: str | None = Query( + None, + description=( + "Match entries whose key starts with this value or whose memory_id equals it. " + "Takes precedence over `key_prefix` and `key` when provided." + ), + ), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=500), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), @@ -333,22 +371,16 @@ async def list_memory( """List memory entries visible to the caller.""" prisma_client: Final = _require_prisma() - # Build the key filter first (prefix wins if both `key` and `key_prefix` - # are passed). Then AND it with the visibility filter via an explicit - # top-level "AND" — safer than `dict.update` since future visibility - # filters could grow an "OR" key that would clobber this one if merged - # by key. - key_filter: Final[dict[str, object]] = {} - if key_prefix is not None: - key_filter["key"] = {"startsWith": key_prefix} - elif key is not None: - key_filter["key"] = key + # AND the key filter with the visibility filter via an explicit top-level + # "AND": both sides can carry an "OR" key (`search`, non-admin visibility), + # so merging them by key would let one clobber the other and leak rows. + key_filter: Final = _key_filter(search=search, key_prefix=key_prefix, key=key) vis: Final = _visibility_filter(user_api_key_dict) - where: Mapping[str, object] + where: Mapping[str, object] | None if vis is None: where = key_filter - elif not key_filter: + elif key_filter is None: where = vis else: where = {"AND": [key_filter, vis]} diff --git a/litellm/proxy/middleware/admission_control_middleware.py b/litellm/proxy/middleware/admission_control_middleware.py new file mode 100644 index 00000000000..aa62ef9e3bf --- /dev/null +++ b/litellm/proxy/middleware/admission_control_middleware.py @@ -0,0 +1,315 @@ +import asyncio +import os +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from functools import lru_cache +from typing import Annotated, Final, Protocol, TypeAlias, runtime_checkable + +from pydantic import Field, TypeAdapter, ValidationError +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Receive, Scope, Send + +from litellm._logging import verbose_proxy_logger + +_EXEMPT_PATHS: Final[frozenset[str]] = frozenset( + { + "/health/liveliness", + "/health/liveness", + "/health/readiness", + "/health/readiness/details", + "/health/backlog", + "/health/drain", + "/metrics", + "/metrics/", + } +) + + +@dataclass(frozen=True, slots=True) +class AdmissionControlSettings: + max_in_flight_requests: int + max_queued_requests: int + queue_timeout_seconds: float + + +AdmissionControlSettingsGetter: TypeAlias = Callable[[], AdmissionControlSettings | None] # mutable-ok: Callable params + + +@dataclass(frozen=True, slots=True) +class AdmissionControlStats: + admitted: int + queued: int + rejected_total: int + + +@runtime_checkable +class _Gauge(Protocol): + def inc(self, amount: float = 1) -> None: ... + + def dec(self, amount: float = 1) -> None: ... + + +@runtime_checkable +class _CounterChild(Protocol): + def inc(self, amount: float = 1) -> None: ... + + +@runtime_checkable +class _Counter(Protocol): + def labels(self, reason: str) -> _CounterChild: ... + + +@dataclass(frozen=True, slots=True) +class AdmissionControlMetrics: + admitted_gauge: _Gauge + queued_gauge: _Gauge + rejected_counter: _Counter + + +AdmissionControlMetricsFactory: TypeAlias = Callable[[], AdmissionControlMetrics | None] # mutable-ok: Callable params + + +class AdmissionControlState: + """Per-process admission counters and the in-flight semaphore shared by one worker's requests.""" + + def __init__(self, metrics_factory: AdmissionControlMetricsFactory) -> None: + self._metrics_factory = metrics_factory + self._metrics: AdmissionControlMetrics | None = None + self._metrics_init_attempted = False + self._admitted = 0 + self._queued = 0 + self._rejected_total = 0 + self._semaphore: asyncio.Semaphore | None = None + self._semaphore_loop: asyncio.AbstractEventLoop | None = None + + def get_stats(self) -> AdmissionControlStats: + return AdmissionControlStats( + admitted=self._admitted, + queued=self._queued, + rejected_total=self._rejected_total, + ) + + def get_semaphore(self, max_in_flight_requests: int) -> asyncio.Semaphore: + loop: Final = asyncio.get_running_loop() + if self._semaphore_loop is not loop: + self._semaphore = asyncio.Semaphore(max_in_flight_requests) + self._semaphore_loop = loop + semaphore: Final = self._semaphore + if semaphore is None: + raise RuntimeError("Admission control semaphore was not initialized") + return semaphore + + def record_admission(self) -> None: + self._admitted += 1 + metrics: Final = self._get_metrics() + if metrics is not None: + metrics.admitted_gauge.inc() + + def record_release(self) -> None: + self._admitted -= 1 + metrics: Final = self._get_metrics() + if metrics is not None: + metrics.admitted_gauge.dec() + + def record_queue(self) -> None: + self._queued += 1 + metrics: Final = self._get_metrics() + if metrics is not None: + metrics.queued_gauge.inc() + + def record_dequeue(self) -> None: + self._queued -= 1 + metrics: Final = self._get_metrics() + if metrics is not None: + metrics.queued_gauge.dec() + + def record_rejection(self, reason: str) -> None: + self._rejected_total += 1 + metrics: Final = self._get_metrics() + if metrics is not None: + metrics.rejected_counter.labels(reason=reason).inc() + + def _get_metrics(self) -> AdmissionControlMetrics | None: + if not self._metrics_init_attempted: + self._metrics_init_attempted = True + self._metrics = self._metrics_factory() + return self._metrics + + +class AdmissionControlMiddleware: + def __init__( + self, + app: ASGIApp, + get_settings: AdmissionControlSettingsGetter, + state: AdmissionControlState, + ) -> None: + self.app = app + self.get_settings = get_settings + self.state = state + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + settings: Final = self.get_settings() + if settings is None or _get_route_path(scope) in _EXEMPT_PATHS: + await self.app(scope, receive, send) + return + + state: Final = self.state + semaphore: Final = state.get_semaphore(settings.max_in_flight_requests) + if not semaphore.locked(): + await semaphore.acquire() + state.record_admission() + elif state.get_stats().queued >= settings.max_queued_requests: + state.record_rejection("queue_full") + await _overloaded_response(state)(scope, receive, send) + return + else: + state.record_queue() + try: + await asyncio.wait_for( + semaphore.acquire(), + timeout=settings.queue_timeout_seconds, + ) + except asyncio.TimeoutError: + state.record_dequeue() + state.record_rejection("queue_timeout") + await _overloaded_response(state)(scope, receive, send) + return + except asyncio.CancelledError: + state.record_dequeue() + raise + state.record_dequeue() + state.record_admission() + + try: + await self.app(scope, receive, send) + finally: + semaphore.release() + state.record_release() + + +def _get_route_path(scope: Scope) -> str: + """Strip the ASGI root_path (SERVER_ROOT_PATH) the same way Starlette does before route matching.""" + path: Final[str] = scope["path"] + root_path: Final[str] = scope.get("root_path", "") + if not root_path or not path.startswith(root_path): + return path + if path == root_path: + return "" + if path[len(root_path)] == "/": + return path[len(root_path) :] + return path + + +def _create_gauge(gauge_type: Callable[..., object], name: str, description: str) -> _Gauge: + metric: Final = ( + gauge_type(name, description, multiprocess_mode="livesum") + if "PROMETHEUS_MULTIPROC_DIR" in os.environ + else gauge_type(name, description) + ) + if not isinstance(metric, _Gauge): + raise TypeError("Admission gauge has an unexpected type") + return metric + + +def create_prometheus_admission_metrics() -> AdmissionControlMetrics | None: + try: + from prometheus_client import Counter, Gauge + + return AdmissionControlMetrics( + admitted_gauge=_create_gauge( + Gauge, + "litellm_admission_admitted_requests", + "Number of requests admitted by this worker", + ), + queued_gauge=_create_gauge( + Gauge, + "litellm_admission_queued_requests", + "Number of requests queued by this worker", + ), + rejected_counter=Counter( # mutable-ok: Prometheus requires runtime Counter construction + "litellm_admission_rejected_requests_total", + "Number of requests rejected by this worker", + labelnames=("reason",), + ), + ) + except (ImportError, ValueError): + return None + + +admission_control_state: Final = AdmissionControlState(create_prometheus_admission_metrics) + + +def get_admission_control_stats() -> AdmissionControlStats: + return admission_control_state.get_stats() + + +_PositiveInt: TypeAlias = Annotated[int, Field(gt=0)] +_NonNegativeInt: TypeAlias = Annotated[int, Field(ge=0)] +_PositiveFloat: TypeAlias = Annotated[float, Field(gt=0)] +_AdmissionControlRaw: TypeAlias = int | float | str | None + + +def _hashable(value: object) -> _AdmissionControlRaw: + return value if value is None or isinstance(value, (int, float, str)) else repr(value) + + +_POSITIVE_INT_ADAPTER: Final[TypeAdapter[int]] = TypeAdapter(_PositiveInt) +_NON_NEGATIVE_INT_ADAPTER: Final[TypeAdapter[int]] = TypeAdapter(_NonNegativeInt) +_POSITIVE_FLOAT_ADAPTER: Final[TypeAdapter[float]] = TypeAdapter(_PositiveFloat) + + +@lru_cache(maxsize=16) +def _parse_admission_control_settings( + max_in_flight_raw: _AdmissionControlRaw, + max_queued_raw: _AdmissionControlRaw, + queue_timeout_raw: _AdmissionControlRaw, +) -> AdmissionControlSettings | None: + try: + max_in_flight: Final = _POSITIVE_INT_ADAPTER.validate_python(max_in_flight_raw) + max_queued: Final = ( + max_in_flight if max_queued_raw is None else _NON_NEGATIVE_INT_ADAPTER.validate_python(max_queued_raw) + ) + queue_timeout: Final = _POSITIVE_FLOAT_ADAPTER.validate_python(queue_timeout_raw) + except ValidationError as exc: + verbose_proxy_logger.error( + "Ignoring invalid admission control settings, per-worker admission control is disabled: %s", + exc, + ) + return None + return AdmissionControlSettings( + max_in_flight_requests=max_in_flight, + max_queued_requests=max_queued, + queue_timeout_seconds=queue_timeout, + ) + + +def get_admission_control_settings(settings: Mapping[str, object]) -> AdmissionControlSettings | None: + max_in_flight_raw: Final = settings.get("max_in_flight_requests_per_worker") + if max_in_flight_raw is None: + return None + return _parse_admission_control_settings( + _hashable(max_in_flight_raw), + _hashable(settings.get("max_queued_requests_per_worker")), + _hashable(settings.get("admission_queue_timeout_seconds", 1.0)), + ) + + +def _overloaded_response(state: AdmissionControlState) -> JSONResponse: + stats: Final = state.get_stats() + return JSONResponse( + status_code=503, + headers={"retry-after": "1"}, # mutable-ok: Starlette expects a plain headers mapping + content={ # mutable-ok: Starlette serializes a plain response mapping + "error": { # mutable-ok: nested response mapping + "message": ( + f"Worker at capacity: {stats.admitted} in-flight, {stats.queued} queued requests. Retry later." + ), + "type": "overloaded_error", + "code": "503", + } + }, + ) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 29f216fd450..6b1d6405a6a 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -9,10 +9,11 @@ Use litellm with Anthropic SDK, Vertex AI SDK, Cohere SDK, etc. from __future__ import annotations import hmac +import inspect import json import os import re -from collections.abc import Callable, Mapping +from collections.abc import AsyncGenerator, Callable, Mapping from types import MappingProxyType from typing import TYPE_CHECKING, Annotated, Final, cast @@ -32,6 +33,7 @@ from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix from litellm.llms.anthropic.common_utils import AnthropicModelInfo from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.llms.vertex_ai.vertex_llm_base import VertexBase +from litellm.passthrough.main import AsyncPassthroughStreamingResponse from litellm.proxy._types import * from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.auth.route_checks import RouteChecks @@ -40,6 +42,7 @@ from litellm.proxy.auth.user_api_key_auth import ( user_api_key_auth, user_api_key_auth_websocket, ) +from litellm.proxy.common_request_processing import open_sse_before_first_byte from litellm.proxy.common_utils.http_parsing_utils import ( _read_request_body, _safe_get_request_headers, @@ -47,6 +50,9 @@ from litellm.proxy.common_utils.http_parsing_utils import ( get_form_data, get_request_body, ) +from litellm.proxy.common_utils.sse_keepalive import ( + wrap_passthrough_sse_bytes_with_keepalive_pings, +) from litellm.proxy.pass_through_endpoints.common_utils import get_litellm_virtual_key from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( HttpPassThroughEndpointHelpers, @@ -1478,6 +1484,74 @@ def is_azure_ai_search_service_level_index_create(method: str, endpoint: str) -> return path == "indexes" or path.endswith("/indexes") +async def _relay_upstream_bytes(upstream: AsyncGenerator[bytes, bytes]) -> AsyncGenerator[bytes, None]: + try: + async for chunk in upstream: + yield chunk + finally: + await upstream.aclose() + + +async def _relay_azure_router_model( + llm_router: litellm.Router, + model: str, + endpoint: str, + request: Request, + request_body: Mapping[str, object], + is_streaming_request: bool, + user_api_key_dict: UserAPIKeyAuth, +) -> Response: + result: Final = await llm_router.allm_passthrough_route( + model=model, + method=request.method, + endpoint=endpoint, + request_query_params=request.query_params, + request_headers=_safe_get_request_headers(request), + stream=is_streaming_request, + content=None, + data=None, + files=None, + json=(request_body if request.headers.get("content-type") == "application/json" else None), + params=None, + headers=None, + cookies=None, + litellm_metadata=get_passthrough_router_request_metadata(user_api_key_dict), + ) + + if not is_streaming_request: + upstream: Final = cast(httpx.Response, result) + return Response( + content=await upstream.aread(), + status_code=upstream.status_code, + headers=HttpPassThroughEndpointHelpers.get_response_headers(headers=upstream.headers, custom_headers=None), + ) + + if inspect.isasyncgen(result): + sse_headers: Final = {"content-type": "text/event-stream"} + return StreamingResponse( + content=wrap_passthrough_sse_bytes_with_keepalive_pings( + stream=_relay_upstream_bytes(result), + ping_interval_seconds=litellm.sse_keepalive_ping_interval_seconds, + upstream_headers=sse_headers, + ), + status_code=200, + headers=sse_headers, + ) + + upstream_stream: Final = cast(AsyncPassthroughStreamingResponse, result) + return StreamingResponse( + content=wrap_passthrough_sse_bytes_with_keepalive_pings( + stream=_relay_upstream_bytes(upstream_stream), + ping_interval_seconds=litellm.sse_keepalive_ping_interval_seconds, + upstream_headers=upstream_stream.headers, + ), + status_code=upstream_stream.status_code, + headers=HttpPassThroughEndpointHelpers.get_response_headers( + headers=upstream_stream.headers, custom_headers=None + ), + ) + + @router.api_route( "/azure_ai/{endpoint:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"], @@ -1528,55 +1602,18 @@ async def azure_proxy_route( if is_router_model: request_body = await get_request_body(request) is_streaming_request = is_passthrough_request_streaming(request_body) - result = await llm_router.allm_passthrough_route( - model=part, - method=request.method, - endpoint=endpoint, - request_query_params=request.query_params, - request_headers=_safe_get_request_headers(request), - stream=is_streaming_request, - content=None, - data=None, - files=None, - json=(request_body if request.headers.get("content-type") == "application/json" else None), - params=None, - headers=None, - cookies=None, - litellm_metadata=get_passthrough_router_request_metadata(user_api_key_dict), - ) - - if is_streaming_request: - # Check if result is an async generator (from _async_streaming) - import inspect - - if inspect.isasyncgen(result): - # Result is already an async generator, use it directly - return StreamingResponse( - content=result, - status_code=200, - headers={"content-type": "text/event-stream"}, - ) - else: - # Result is an httpx.Response, use aiter_bytes() - result = cast(httpx.Response, result) - return StreamingResponse( - content=result.aiter_bytes(), - status_code=result.status_code, - headers=HttpPassThroughEndpointHelpers.get_response_headers( - headers=result.headers, - custom_headers=None, - ), - ) - - # Non-streaming response - result = cast(httpx.Response, result) - content = await result.aread() - return Response( - content=content, - status_code=result.status_code, - headers=HttpPassThroughEndpointHelpers.get_response_headers( - headers=result.headers, - custom_headers=None, + return await open_sse_before_first_byte( + _relay_azure_router_model( + llm_router=llm_router, + model=part, + endpoint=endpoint, + request=request, + request_body=request_body, + is_streaming_request=is_streaming_request, + user_api_key_dict=user_api_key_dict, + ), + ping_interval_seconds=( + litellm.sse_keepalive_ping_interval_seconds if is_streaming_request else None ), ) elif is_vector_store_index: @@ -1659,6 +1696,12 @@ async def azure_proxy_route( from abc import ABC, abstractmethod +_VERTEX_LOCATION_REQUIRED_DETAIL: Final = ( + "No Vertex AI location for this request. Include /projects//locations// in the " + "route, set vertex_location in default_vertex_config (or DEFAULT_VERTEXAI_LOCATION), or add the " + "model to model_list with use_in_pass_through: true." +) + class BaseVertexAIPassThroughHandler(ABC): @staticmethod @@ -1666,29 +1709,18 @@ class BaseVertexAIPassThroughHandler(ABC): def get_default_base_target_url(vertex_location: str | None) -> str: pass - @staticmethod - @abstractmethod - def update_base_target_url_with_credential_location(base_target_url: str, vertex_location: str | None) -> str: - pass - class VertexAIDiscoveryPassThroughHandler(BaseVertexAIPassThroughHandler): @staticmethod def get_default_base_target_url(vertex_location: str | None) -> str: return "https://discoveryengine.googleapis.com/" - @staticmethod - def update_base_target_url_with_credential_location(base_target_url: str, vertex_location: str | None) -> str: - return base_target_url - class VertexAIPassThroughHandler(BaseVertexAIPassThroughHandler): @staticmethod def get_default_base_target_url(vertex_location: str | None) -> str: - return get_vertex_base_url(vertex_location) - - @staticmethod - def update_base_target_url_with_credential_location(base_target_url: str, vertex_location: str | None) -> str: + if vertex_location is None: + raise HTTPException(status_code=400, detail=_VERTEX_LOCATION_REQUIRED_DETAIL) return get_vertex_base_url(vertex_location) @@ -1911,10 +1943,8 @@ async def _prepare_vertex_auth_headers( router_credentials: LiteLLM_ManagedVectorStore | None, vertex_project: str | None, vertex_location: str | None, - base_target_url: str | None, - get_vertex_pass_through_handler: BaseVertexAIPassThroughHandler, user_api_key_dict: UserAPIKeyAuth, -) -> tuple[Mapping[str, str], str | None, bool, str | None, str | None]: +) -> tuple[Mapping[str, str], bool, str | None, str | None]: """ Prepare authentication headers for Vertex AI pass-through requests. @@ -1924,15 +1954,12 @@ async def _prepare_vertex_auth_headers( router_credentials: Optional vector store credentials from registry vertex_project: Vertex project ID vertex_location: Vertex location - base_target_url: Base URL for the Vertex AI service - get_vertex_pass_through_handler: Handler for the specific Vertex AI service user_api_key_dict: The caller's resolved authentication, so only the secret that authenticated them is stripped on the credential-less branch Returns: tuple containing: - headers: dict - Authentication headers to use - - base_target_url: str | None - Updated base target URL - headers_passed_through: bool - Whether headers were passed through from request - vertex_project: str | None - Updated vertex project ID - vertex_location: str | None - Updated vertex location @@ -1985,14 +2012,8 @@ async def _prepare_vertex_auth_headers( # Add the Authorization header with vendor credentials headers["Authorization"] = f"Bearer {auth_header}" - if base_target_url is not None: - base_target_url = get_vertex_pass_through_handler.update_base_target_url_with_credential_location( - base_target_url, vertex_location - ) - return ( headers, - base_target_url, headers_passed_through, vertex_project, vertex_location, @@ -2085,12 +2106,9 @@ async def _base_vertex_proxy_route( location=vertex_location, ) - base_target_url = get_vertex_pass_through_handler.get_default_base_target_url(vertex_location) - # Prepare authentication headers ( headers, - base_target_url, headers_passed_through, vertex_project, vertex_location, @@ -2100,13 +2118,10 @@ async def _base_vertex_proxy_route( router_credentials=router_credentials, vertex_project=vertex_project, vertex_location=vertex_location, - base_target_url=base_target_url, - get_vertex_pass_through_handler=get_vertex_pass_through_handler, user_api_key_dict=user_api_key_dict, ) - if base_target_url is None: - base_target_url = get_vertex_base_url(vertex_location) + base_target_url: Final = get_vertex_pass_through_handler.get_default_base_target_url(vertex_location) request_route: Final = encoded_endpoint verbose_proxy_logger.debug("request_route %s", request_route) diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py index a36a365f39a..0acc7b1b584 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py @@ -107,6 +107,7 @@ class AnthropicPassthroughLoggingHandler: start_time=start_time, end_time=end_time, logging_obj=logging_obj, + response_id=optional_str(response_body.get("id")), ) return { @@ -148,8 +149,9 @@ class AnthropicPassthroughLoggingHandler: return model @staticmethod - def _extract_model_from_anthropic_chunks( + def _extract_message_start_field( all_chunks: Sequence[str | bytes], + field: str, ) -> str | None: for raw in all_chunks: text = raw.decode("utf-8") if isinstance(raw, bytes) else raw @@ -163,11 +165,23 @@ class AnthropicPassthroughLoggingHandler: if not isinstance(data, dict): continue if data.get("type") == "message_start": - model = (data.get("message") or {}).get("model") - if model: - return model + value = (data.get("message") or {}).get(field) + if isinstance(value, str) and value: + return value return None + @staticmethod + def _extract_model_from_anthropic_chunks( + all_chunks: Sequence[str | bytes], + ) -> str | None: + return AnthropicPassthroughLoggingHandler._extract_message_start_field(all_chunks, "model") + + @staticmethod + def _extract_response_id_from_anthropic_chunks( + all_chunks: Sequence[str | bytes], + ) -> str | None: + return AnthropicPassthroughLoggingHandler._extract_message_start_field(all_chunks, "id") + @staticmethod def _stream_was_interrupted( all_chunks: Sequence[str | bytes], @@ -251,6 +265,7 @@ class AnthropicPassthroughLoggingHandler: start_time: datetime, end_time: datetime, logging_obj: LiteLLMLoggingObj, + response_id: str | None = None, ): """ Create the standard logging object for Anthropic passthrough @@ -312,8 +327,7 @@ class AnthropicPassthroughLoggingHandler: json.dumps(kwargs, indent=4, default=str), ) - # set litellm_call_id to logging response object - litellm_model_response.id = logging_obj.litellm_call_id + litellm_model_response.id = response_id or logging_obj.litellm_call_id litellm_model_response.model = model logging_obj.model_call_details["model"] = model if not logging_obj.model_call_details.get("custom_llm_provider"): @@ -413,6 +427,7 @@ class AnthropicPassthroughLoggingHandler: start_time=start_time, end_time=end_time, logging_obj=litellm_logging_obj, + response_id=AnthropicPassthroughLoggingHandler._extract_response_id_from_anthropic_chunks(all_chunks), ) return { diff --git a/litellm/proxy/prompts/prompt_endpoints.py b/litellm/proxy/prompts/prompt_endpoints.py index b6cbd2d7889..06957fd1c0f 100644 --- a/litellm/proxy/prompts/prompt_endpoints.py +++ b/litellm/proxy/prompts/prompt_endpoints.py @@ -29,6 +29,12 @@ from litellm.proxy._types import ( from litellm.proxy.auth.auth_utils import is_request_body_safe from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.path_utils import safe_filename +from litellm.proxy.prompts.prompt_registry import ( + DEFAULT_PROMPT_ENVIRONMENT, + get_base_prompt_id, + get_version_number, + prompt_environment_or_default, +) from litellm.repositories.table_repositories import PromptRepository from litellm.types.prompts.init_prompts import ( ListPromptsResponse, @@ -102,165 +108,20 @@ def _prompt_table(prisma_client: "PrismaClient") -> _PromptTableActions: return PromptRepository(prisma_client).table -def get_base_prompt_id(prompt_id: str) -> str: - """ - Extract the base prompt ID by stripping the version suffix if present. - - Args: - prompt_id: Prompt ID that may include version suffix (e.g., "jack_success.v1" or "jack_success_v1") - - Returns: - Base prompt ID without version suffix (e.g., "jack_success") - - Examples: - >>> get_base_prompt_id("jack_success.v1") - "jack_success" - >>> get_base_prompt_id("jack_success_v1") - "jack_success" - >>> get_base_prompt_id("jack_success") - "jack_success" - """ - # Try dot separator first (.v) - if ".v" in prompt_id: - return prompt_id.split(".v")[0] - # Try underscore separator (_v) - if "_v" in prompt_id: - return prompt_id.split("_v")[0] - return prompt_id - - -def get_version_number(prompt_id: str) -> int: - """ - Extract the version number from a versioned prompt ID. - - Args: - prompt_id: Prompt ID that may include version suffix (e.g., "jack_success.v2" or "jack_success_v2") - - Returns: - Version number (defaults to 1 if no version suffix or invalid format) - - Examples: - >>> get_version_number("jack_success.v2") - 2 - >>> get_version_number("jack_success_v2") - 2 - >>> get_version_number("jack_success") - 1 - """ - # Try dot separator first (.v) - if ".v" in prompt_id: - version_str = prompt_id.split(".v")[1] - try: - return int(version_str) - except ValueError: - pass - - # Try underscore separator (_v) - if "_v" in prompt_id: - version_str = prompt_id.split("_v")[1] - try: - return int(version_str) - except ValueError: - pass - - return 1 - - -def construct_versioned_prompt_id(prompt_id: str, version: int | None = None) -> str: - """ - Construct a versioned prompt ID from a base prompt_id and version number. - - Args: - prompt_id: Base prompt ID (e.g., "jack_success") - version: Version number (if None, returns the base prompt_id unchanged) - - Returns: - Versioned prompt ID (e.g., "jack_success.v4") - - Examples: - >>> construct_versioned_prompt_id("jack_success", 4) - "jack_success.v4" - >>> construct_versioned_prompt_id("jack_success", None) - "jack_success" - >>> construct_versioned_prompt_id("jack_success.v2", 4) - "jack_success.v4" - """ - if version is None: - return prompt_id - - # Strip any existing version suffix first - base_id: Final = get_base_prompt_id(prompt_id) - return f"{base_id}.v{version}" - - -def get_latest_version_prompt_id(prompt_id: str, all_prompt_ids: Mapping[str, object]) -> str: - """ - Find the latest version of a prompt from available prompt IDs. - - Args: - prompt_id: Base prompt ID or versioned prompt ID (e.g., "jack_success" or "jack_success.v2") - all_prompt_ids: Dictionary of all available prompt IDs (keys are prompt IDs) - - Returns: - The prompt ID with the highest version number, or the original prompt_id if no versions exist - - Examples: - >>> all_ids = {"jack.v1": {}, "jack.v2": {}, "jack.v3": {}} - >>> get_latest_version_prompt_id("jack", all_ids) - "jack.v3" - >>> get_latest_version_prompt_id("jack.v1", all_ids) - "jack.v3" - >>> all_ids = {"simple": {}} - >>> get_latest_version_prompt_id("simple", all_ids) - "simple" - """ - base_id: Final = get_base_prompt_id(prompt_id=prompt_id) - - # Find all versions of this prompt - matching_versions: Final = [] - for stored_prompt_id in all_prompt_ids: - if get_base_prompt_id(prompt_id=stored_prompt_id) == base_id: - version_num = get_version_number(prompt_id=stored_prompt_id) - matching_versions.append((version_num, stored_prompt_id)) - - # Use the highest version number - if matching_versions: - matching_versions.sort(reverse=True) - return matching_versions[0][1] - else: - # No versioned prompts found, use the base ID as-is - return prompt_id - - def get_latest_prompt_versions(prompts: list[PromptSpec]) -> list[PromptSpec]: """ - Filter a list of prompts to return only the latest version of each unique prompt. - - Args: - prompts: List of PromptSpec objects - - Returns: - List of PromptSpec objects with only the latest version of each prompt + Filter prompts down to the latest version per (base prompt id, environment). """ - latest_prompts: Final[dict[str, PromptSpec]] = {} - - for prompt in prompts: - base_id = get_base_prompt_id(prompt_id=prompt.prompt_id) - version = get_version_number(prompt_id=prompt.prompt_id) - - # Keep the prompt with the highest version number - if base_id not in latest_prompts: - latest_prompts[base_id] = prompt - else: - existing_version = get_version_number(prompt_id=latest_prompts[base_id].prompt_id) - if version > existing_version: - latest_prompts[base_id] = prompt - + sorted_prompts: Final = sorted(prompts, key=lambda prompt: get_version_number(prompt_id=prompt.prompt_id)) + latest_prompts: Final = { + (get_base_prompt_id(prompt_id=prompt.prompt_id), prompt_environment_or_default(prompt.environment)): prompt + for prompt in sorted_prompts + } return list(latest_prompts.values()) async def get_next_version_for_prompt( - prisma_client: "PrismaClient", prompt_id: str, environment: str = "development" + prisma_client: "PrismaClient", prompt_id: str, environment: str = DEFAULT_PROMPT_ENVIRONMENT ) -> int: """ Get the next version number for a prompt in a specific environment. @@ -403,11 +264,14 @@ async def list_prompts( if key_metadata is not None: prompts: Final = cast(list[str] | None, key_metadata.get("prompts", None)) if prompts is not None: - all_prompts = [ - IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS[prompt_id] - for prompt_id in prompts - if prompt_id in IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS + allowed_prompt_ids: Final = frozenset(prompts) + allowed_prompts: Final = [ + spec + for spec in IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.values() + if spec.prompt_id in allowed_prompt_ids + or get_base_prompt_id(prompt_id=spec.prompt_id) in allowed_prompt_ids ] + all_prompts = get_latest_prompt_versions(prompts=allowed_prompts) if environment: all_prompts = [p for p in all_prompts if p.environment == environment] prompt_list: Final = [] @@ -576,7 +440,7 @@ def _get_prompt_template(prompt_spec: PromptSpec, base_prompt_id: str) -> Prompt metadata=parsed.get("metadata"), ) else: - prompt_callback: Final = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id(prompt_spec.prompt_id) + prompt_callback: Final = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_for_prompt(prompt=prompt_spec) if prompt_callback is not None: integration_name: Final = prompt_callback.integration_name if integration_name == "dotprompt": @@ -690,15 +554,10 @@ async def get_prompt_info( if env_prompts: prompt_spec = create_versioned_prompt_spec(db_prompt=env_prompts[0]) - # Fallback: use in-memory registry (no environment filter) - if prompt_spec is None and environment is None: - prompt_spec = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(prompt_id) - if prompt_spec is None: - latest_prompt_id: Final = get_latest_version_prompt_id( - prompt_id=prompt_id, - all_prompt_ids=IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS, - ) - prompt_spec = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(latest_prompt_id) + if prompt_spec is None: + prompt_spec = IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec( + prompt_id, version=requested_version, environment=environment + ) if prompt_spec is None: raise HTTPException( @@ -785,7 +644,7 @@ async def create_prompt( environment: Final = ( request.prompt_info.environment if request.prompt_info and request.prompt_info.environment - else "development" + else DEFAULT_PROMPT_ENVIRONMENT ) # Get next version number @@ -885,7 +744,7 @@ async def update_prompt( environment: Final = ( request.prompt_info.environment if request.prompt_info and request.prompt_info.environment - else "development" + else DEFAULT_PROMPT_ENVIRONMENT ) # Check if any version of this prompt exists (in any environment) @@ -897,9 +756,7 @@ async def update_prompt( detail=f"Prompt with ID {base_prompt_id} not found", ) - # Check if it's a config prompt - existing_in_memory: Final = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(prompt_id) - if existing_in_memory and existing_in_memory.prompt_info.prompt_type == "config": + if IN_MEMORY_PROMPT_REGISTRY.has_config_prompt(base_prompt_id=base_prompt_id): raise HTTPException( status_code=400, detail="Cannot update config prompts.", @@ -988,40 +845,26 @@ async def delete_prompt( raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) try: - # Try to get prompt directly first - existing_prompt = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(prompt_id) - - # If not found, try to find the latest version - if existing_prompt is None: - latest_prompt_id: Final = get_latest_version_prompt_id( - prompt_id=prompt_id, - all_prompt_ids=IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS, - ) - existing_prompt = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(latest_prompt_id) - # Use the resolved prompt_id for deletion - prompt_id = latest_prompt_id + base_prompt_id: Final = get_base_prompt_id(prompt_id=prompt_id) + existing_prompt: Final = IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec(prompt_id, environment=environment) if existing_prompt is None: raise HTTPException(status_code=404, detail=f"Prompt with ID {prompt_id} not found") - if existing_prompt.prompt_info.prompt_type == "config": + if IN_MEMORY_PROMPT_REGISTRY.has_config_prompt(base_prompt_id=base_prompt_id): raise HTTPException( status_code=400, detail="Cannot delete config prompts.", ) - # Get the base prompt ID (without version suffix) for database deletion - base_prompt_id: Final = get_base_prompt_id(prompt_id=prompt_id) - - # Build delete filter; scope to environment if provided - delete_where: Final[dict[str, str]] = {"prompt_id": base_prompt_id} - if environment: - delete_where["environment"] = environment - - # Delete versions from the database (scoped to environment if provided) + delete_where: Final[dict[str, str]] = { + "prompt_id": base_prompt_id, + **({"environment": environment} if environment else {}), + } await _prompt_table(prisma_client).delete_many(where=delete_where) - - IN_MEMORY_PROMPT_REGISTRY.delete_prompts_by_base_id(base_prompt_id, environment=environment or None) + IN_MEMORY_PROMPT_REGISTRY.delete_prompts_by_base_id( + base_prompt_id=base_prompt_id, environment=environment or None + ) env_msg: Final = f" from {environment}" if environment else "" return {"message": f"Prompt {base_prompt_id} deleted successfully{env_msg}"} @@ -1093,7 +936,7 @@ async def patch_prompt( try: # Resolve the target row: find the latest version in the given environment base_prompt_id: Final = get_base_prompt_id(prompt_id=prompt_id) - env: Final = environment or "development" + env: Final = prompt_environment_or_default(environment) requested_version: Final = get_version_number(prompt_id=prompt_id) if prompt_id != base_prompt_id else None # Build query to find the exact row by composite unique key @@ -1117,11 +960,7 @@ async def patch_prompt( target_row: Final = db_rows[0] - # Check if prompt exists in memory - versioned_id: Final = f"{base_prompt_id}.v{target_row.version}" - existing_prompt: Final = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(versioned_id) - - if existing_prompt and existing_prompt.prompt_info.prompt_type == "config": + if IN_MEMORY_PROMPT_REGISTRY.has_config_prompt(base_prompt_id=base_prompt_id): raise HTTPException( status_code=400, detail="Cannot update config prompts.", diff --git a/litellm/proxy/prompts/prompt_registry.py b/litellm/proxy/prompts/prompt_registry.py index addfb3f80d5..7803352e9e7 100644 --- a/litellm/proxy/prompts/prompt_registry.py +++ b/litellm/proxy/prompts/prompt_registry.py @@ -1,6 +1,6 @@ import importlib import os -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import Path from typing import Final @@ -14,6 +14,87 @@ from litellm.types.prompts.init_prompts import ( prompt_initializer_registry = {} +DEFAULT_PROMPT_ENVIRONMENT: Final = "development" +PROMPT_ENVIRONMENT_SERVE_PRECEDENCE: Final = ("production", "staging", "development") + + +def get_base_prompt_id(prompt_id: str) -> str: + """ + Extract the base prompt ID by stripping the version suffix if present. + + Examples: + >>> get_base_prompt_id("jack_success.v1") + "jack_success" + >>> get_base_prompt_id("jack_success_v1") + "jack_success" + >>> get_base_prompt_id("jack_success") + "jack_success" + """ + if ".v" in prompt_id: + return prompt_id.split(".v")[0] + if "_v" in prompt_id: + return prompt_id.split("_v")[0] + return prompt_id + + +def get_version_number(prompt_id: str) -> int: + """ + Extract the version number from a versioned prompt ID (defaults to 1). + + Examples: + >>> get_version_number("jack_success.v2") + 2 + >>> get_version_number("jack_success_v2") + 2 + >>> get_version_number("jack_success") + 1 + """ + if ".v" in prompt_id: + version_str = prompt_id.split(".v")[1] + try: + return int(version_str) + except ValueError: + pass + + if "_v" in prompt_id: + version_str = prompt_id.split("_v")[1] + try: + return int(version_str) + except ValueError: + pass + + return 1 + + +def prompt_environment_or_default(environment: str | None) -> str: + return environment or DEFAULT_PROMPT_ENVIRONMENT + + +def registry_key_for_prompt(prompt: PromptSpec) -> str: + return f"{prompt.prompt_id}::{prompt_environment_or_default(prompt.environment)}" + + +def parse_prompt_version(raw_version: object) -> int | None: + if isinstance(raw_version, bool): + return None + if isinstance(raw_version, int): + return raw_version + if isinstance(raw_version, str) and raw_version.isdigit(): + return int(raw_version) + return None + + +def _spec_version(prompt: PromptSpec) -> int: + return prompt.version if prompt.version is not None else get_version_number(prompt_id=prompt.prompt_id) + + +def _default_serve_environment(prompts: Sequence[PromptSpec]) -> str: + present: Final = frozenset(prompt_environment_or_default(prompt.environment) for prompt in prompts) + ladder_pick: Final = next((env for env in PROMPT_ENVIRONMENT_SERVE_PRECEDENCE if env in present), None) + if ladder_pick is not None: + return ladder_pick + return min(present) if present else DEFAULT_PROMPT_ENVIRONMENT + def get_prompt_initializer_from_integrations(): """ @@ -113,17 +194,16 @@ class InMemoryPromptRegistry: """ import litellm - prompt_id: Final = prompt.prompt_id - if prompt_id in self.IN_MEMORY_PROMPTS: - verbose_proxy_logger.debug("prompt_id already exists in IN_MEMORY_PROMPTS") - return self.IN_MEMORY_PROMPTS[prompt_id] + registry_key: Final = registry_key_for_prompt(prompt) + if registry_key in self.IN_MEMORY_PROMPTS: + verbose_proxy_logger.debug("prompt already exists in IN_MEMORY_PROMPTS") + return self.IN_MEMORY_PROMPTS[registry_key] parsed_prompt, custom_prompt_callback = self._build_prompt_callback(prompt=prompt) litellm.logging_callback_manager.add_litellm_callback(custom_prompt_callback) - # store references to the prompt in memory - self.IN_MEMORY_PROMPTS[prompt_id] = parsed_prompt - self.prompt_id_to_custom_prompt[prompt_id] = custom_prompt_callback + self.IN_MEMORY_PROMPTS[registry_key] = parsed_prompt + self.prompt_id_to_custom_prompt[registry_key] = custom_prompt_callback return parsed_prompt @@ -166,68 +246,93 @@ class InMemoryPromptRegistry: import litellm parsed_prompt, new_callback = self._build_prompt_callback(prompt=prompt) - stale_callback: Final = self.prompt_id_to_custom_prompt.pop(prompt.prompt_id, None) - self.IN_MEMORY_PROMPTS.pop(prompt.prompt_id, None) + registry_key: Final = registry_key_for_prompt(parsed_prompt) + stale_callback: Final = self.prompt_id_to_custom_prompt.pop(registry_key, None) + self.IN_MEMORY_PROMPTS.pop(registry_key, None) if stale_callback is not None: litellm.logging_callback_manager.remove_callback_from_all_lists(stale_callback) litellm.logging_callback_manager.add_litellm_callback(new_callback) - self.IN_MEMORY_PROMPTS[prompt.prompt_id] = parsed_prompt - self.prompt_id_to_custom_prompt[prompt.prompt_id] = new_callback + self.IN_MEMORY_PROMPTS[registry_key] = parsed_prompt + self.prompt_id_to_custom_prompt[registry_key] = new_callback return parsed_prompt def sync_prompt_from_db(self, prompt: PromptSpec) -> PromptSpec | None: - existing: Final = self.IN_MEMORY_PROMPTS.get(prompt.prompt_id) + existing: Final = self.IN_MEMORY_PROMPTS.get(registry_key_for_prompt(prompt)) if existing is None: return self.initialize_prompt(prompt=prompt) if existing.litellm_params == prompt.litellm_params and existing.prompt_info == prompt.prompt_info: return existing return self.reload_prompt(prompt=prompt) - def get_prompt_by_id(self, prompt_id: str) -> PromptSpec | None: + def resolve_prompt_spec( + self, + prompt_id: str, + version: int | None = None, + environment: str | None = None, + ) -> PromptSpec | None: """ - Get a prompt by its ID from memory - """ - return self.IN_MEMORY_PROMPTS.get(prompt_id) + Resolve a prompt spec by base prompt id, optional version, and optional environment. - def get_prompt_callback_by_id(self, prompt_id: str) -> CustomPromptManagement | None: + With no environment, resolves within the default serve environment + (production > staging > development > alphabetical first present). + With no version, resolves to the highest version in the chosen environment. """ - Get a prompt callback by its ID from memory - """ - return self.prompt_id_to_custom_prompt.get(prompt_id) + base_prompt_id: Final = get_base_prompt_id(prompt_id=prompt_id) + base_matches: Final = tuple( + spec + for spec in self.IN_MEMORY_PROMPTS.values() + if get_base_prompt_id(prompt_id=spec.prompt_id) == base_prompt_id + ) + if not base_matches: + return None + resolved_environment: Final = ( + environment if environment is not None else _default_serve_environment(base_matches) + ) + env_matches: Final = tuple( + spec for spec in base_matches if prompt_environment_or_default(spec.environment) == resolved_environment + ) + if not env_matches: + return None + if version is not None: + return next((spec for spec in env_matches if _spec_version(spec) == version), None) + return max(env_matches, key=_spec_version) - def remove_prompt(self, prompt_id: str) -> None: + def get_prompt_callback_for_prompt(self, prompt: PromptSpec) -> CustomPromptManagement | None: + return self.prompt_id_to_custom_prompt.get(registry_key_for_prompt(prompt)) + + def has_config_prompt(self, base_prompt_id: str) -> bool: + return any( + spec.prompt_info.prompt_type == "config" + for spec in self.IN_MEMORY_PROMPTS.values() + if get_base_prompt_id(prompt_id=spec.prompt_id) == base_prompt_id + ) + + def remove_prompt(self, registry_key: str) -> None: import litellm - self.IN_MEMORY_PROMPTS.pop(prompt_id, None) - stale_callback: Final = self.prompt_id_to_custom_prompt.pop(prompt_id, None) + self.IN_MEMORY_PROMPTS.pop(registry_key, None) + stale_callback: Final = self.prompt_id_to_custom_prompt.pop(registry_key, None) if stale_callback is not None: litellm.logging_callback_manager.remove_callback_from_all_lists(stale_callback) def delete_prompts_by_base_id(self, base_prompt_id: str, environment: str | None = None) -> list[str]: """ - Delete all prompts matching the given base prompt ID from memory, along with their - registered callbacks; scoped to one environment when given. + Delete matching prompts from memory, along with their registered callbacks, + scoped to one environment when given. - Args: - base_prompt_id: The base prompt ID (without version suffix) - environment: When set, only delete prompts deployed to this environment - - Returns: - List of prompt IDs that were deleted + Returns the registry keys that were deleted. """ - from litellm.proxy.prompts.prompt_endpoints import get_base_prompt_id - - prompts_to_delete: Final = [ - pid - for pid, prompt in self.IN_MEMORY_PROMPTS.items() - if get_base_prompt_id(prompt_id=pid) == base_prompt_id - and (environment is None or prompt.environment == environment) + keys_to_delete: Final = [ + key + for key, spec in self.IN_MEMORY_PROMPTS.items() + if get_base_prompt_id(prompt_id=spec.prompt_id) == base_prompt_id + and (environment is None or prompt_environment_or_default(spec.environment) == environment) ] - for pid in prompts_to_delete: - self.remove_prompt(prompt_id=pid) + for key in keys_to_delete: + self.remove_prompt(registry_key=key) - return prompts_to_delete + return keys_to_delete IN_MEMORY_PROMPT_REGISTRY: Final = InMemoryPromptRegistry() diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 96b425f2a24..1f39a78e12a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -583,6 +583,11 @@ try: except ImportError: build_billing_metrics_recorder = None shutdown_billing_metrics_recorder = None +from litellm.proxy.middleware.admission_control_middleware import ( + AdmissionControlMiddleware, + admission_control_state, + get_admission_control_settings, +) from litellm.proxy.middleware.in_flight_requests_middleware import ( InFlightRequestsMiddleware, ) @@ -7589,7 +7594,7 @@ class ProxyConfig: return create_versioned_prompt_spec(db_prompt=db_prompt) async def _init_prompts_in_db(self, prisma_client: PrismaClient): - from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY + from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY, registry_key_for_prompt from litellm.types.prompts.init_prompts import PromptSpec def parse_row(db_prompt: object) -> PromptSpec | None: @@ -7604,21 +7609,12 @@ class ProxyConfig: return None try: - prompt_ids_loaded_before_db_read: Final = frozenset(IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS) + registry_keys_loaded_before_db_read: Final = frozenset(IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS) prompts_in_db: Final[Sequence[object]] = await PromptRepository(prisma_client).table.find_many() parsed_specs: Final[tuple[PromptSpec, ...]] = tuple( spec for row in prompts_in_db if (spec := parse_row(row)) is not None ) - newest_spec_per_id: Final[Mapping[str, PromptSpec]] = MappingProxyType( - { - spec.prompt_id: spec - for spec in sorted( - parsed_specs, - key=lambda s: s.updated_at.timestamp() if s.updated_at else float("-inf"), - ) - } - ) - for prompt_spec in newest_spec_per_id.values(): + for prompt_spec in parsed_specs: try: IN_MEMORY_PROMPT_REGISTRY.sync_prompt_from_db(prompt=prompt_spec) except Exception as prompt_sync_error: # noqa: BLE001 # one poisoned row must not block syncing the remaining prompts @@ -7630,15 +7626,16 @@ class ProxyConfig: # An unparsable row still exists in the DB, so skip the sweep rather than unload its in-memory copy every_row_parsed: Final = len(parsed_specs) == len(prompts_in_db) if every_row_parsed: - deleted_db_prompt_ids: Final = tuple( - prompt_id - for prompt_id in prompt_ids_loaded_before_db_read - if (loaded_spec := IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.get(prompt_id)) is not None + db_registry_keys: Final = frozenset(registry_key_for_prompt(spec) for spec in parsed_specs) + deleted_db_registry_keys: Final = tuple( + registry_key + for registry_key in registry_keys_loaded_before_db_read + if (loaded_spec := IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.get(registry_key)) is not None and loaded_spec.prompt_info.prompt_type == "db" - and prompt_id not in newest_spec_per_id + and registry_key not in db_registry_keys ) - for deleted_prompt_id in deleted_db_prompt_ids: - IN_MEMORY_PROMPT_REGISTRY.remove_prompt(prompt_id=deleted_prompt_id) + for deleted_registry_key in deleted_db_registry_keys: + IN_MEMORY_PROMPT_REGISTRY.remove_prompt(registry_key=deleted_registry_key) except Exception as e: verbose_proxy_logger.debug("litellm.proxy.proxy_server.py::ProxyConfig:_init_prompts_in_db - %s", e) @@ -15241,20 +15238,33 @@ async def async_queue_request( if llm_router is None: raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.no_llm_router.value}) - - response: Final = await llm_router.schedule_acompletion(**data) + router: Final = llm_router if "stream" in data and data["stream"] is True: # use generate_responses to stream responses - return StreamingResponse( - async_data_generator( - user_api_key_dict=user_api_key_dict, - response=response, - request_data=data, - request=request, - ), - media_type="text/event-stream", + + async def produce_queue_stream() -> StreamingResponse: + return StreamingResponse( + async_data_generator( + user_api_key_dict=user_api_key_dict, + response=await router.schedule_acompletion(**data), + request_data=data, + request=request, + ), + media_type="text/event-stream", + ) + + async def audit_late_failure(exc: Exception) -> HTTPException | None: + return await proxy_logging_obj.post_call_failure_hook( + user_api_key_dict=user_api_key_dict, original_exception=exc, request_data=data + ) + + return await open_sse_before_first_byte( + produce_queue_stream(), + ping_interval_seconds=ttft_keepalive_interval(data, router), + on_late_failure=audit_late_failure, ) + response: Final = await router.schedule_acompletion(**data) fastapi_response.headers.update({"x-litellm-priority": str(data["priority"])}) return response except Exception as e: @@ -16510,6 +16520,9 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro { "max_parallel_requests": "Integer", "global_max_parallel_requests": "Integer", + "max_in_flight_requests_per_worker": "Integer", + "max_queued_requests_per_worker": "Integer", + "admission_queue_timeout_seconds": "Float", "max_request_size_mb": "Integer", "max_batch_file_size_mb": "Integer", "max_file_size_mb": "Integer", @@ -18185,6 +18198,11 @@ app.add_middleware( get_max_request_size_mb=lambda: general_settings.get("max_request_size_mb"), is_request_size_limit_enabled=lambda: premium_user is True, ) +app.add_middleware( + AdmissionControlMiddleware, + get_settings=lambda: get_admission_control_settings(general_settings), + state=admission_control_state, +) async def _stream_mcp_asgi_response(handle_fn, scope: dict, receive) -> "StreamingResponse": diff --git a/litellm/proxy/rag_endpoints/endpoints.py b/litellm/proxy/rag_endpoints/endpoints.py index e144ff965ae..d6a402e1860 100644 --- a/litellm/proxy/rag_endpoints/endpoints.py +++ b/litellm/proxy/rag_endpoints/endpoints.py @@ -23,11 +23,14 @@ from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import ( LiteLLM_ManagedVectorStore, ) -from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper from litellm.proxy._types import * from litellm.proxy.auth.auth_utils import is_request_body_safe from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth -from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing +from litellm.proxy.common_request_processing import ( + ProxyBaseLLMRequestProcessing, + open_sse_before_first_byte, + ttft_keepalive_interval, +) from litellm.proxy.common_utils.http_parsing_utils import ( _read_request_body, _safe_get_request_headers, @@ -48,6 +51,7 @@ from litellm.proxy.vector_store_endpoints.utils import ( assert_user_can_access_vector_store_id, ) from litellm.repositories.table_repositories import ManagedVectorStoresRepository +from litellm.types.utils import ModelResponse if TYPE_CHECKING: from litellm.proxy.utils import PrismaClient @@ -756,42 +760,53 @@ async def rag_query( merged_retrieval_config.get("custom_llm_provider"), ) - # Call query - response: Final = await litellm.aquery( - model=model, - messages=messages, - retrieval_config=merged_retrieval_config, - rerank=rerank, - stream=stream, - router=llm_router, - **request_data, - ) - - hidden_params: Final = getattr(response, "_hidden_params", {}) or {} - custom_headers: Final = ProxyBaseLLMRequestProcessing.get_custom_headers( - user_api_key_dict=user_api_key_dict, - call_id=hidden_params.get("litellm_call_id", None) or "", - model_id=hidden_params.get("model_id", None) or "", - cache_key=hidden_params.get("cache_key", None) or "", - api_base=hidden_params.get("api_base", None) or "", - version=version, - response_cost=hidden_params.get("response_cost", None), - request_data=request_data, - ) - - if isinstance(response, CustomStreamWrapper): - return StreamingResponse( - select_data_generator( - response=response, - user_api_key_dict=user_api_key_dict, - request_data=request_data, - request=request, - ), - media_type="text/event-stream", - headers=custom_headers, + async def query() -> ModelResponse: + return await litellm.aquery( + model=model, + messages=messages, + retrieval_config=merged_retrieval_config, + vector_store_params=store_data, + rerank=rerank, + stream=stream, + router=llm_router, + **request_data, ) - fastapi_response.headers.update(custom_headers) + def custom_headers_for(response: ModelResponse) -> Mapping[str, str]: + hidden_params: Final = getattr(response, "_hidden_params", {}) or {} + return ProxyBaseLLMRequestProcessing.get_custom_headers( + user_api_key_dict=user_api_key_dict, + call_id=hidden_params.get("litellm_call_id", None) or "", + model_id=hidden_params.get("model_id", None) or "", + cache_key=hidden_params.get("cache_key", None) or "", + api_base=hidden_params.get("api_base", None) or "", + version=version, + response_cost=hidden_params.get("response_cost", None), + request_data=request_data, + ) + + if stream: + + async def produce_stream() -> StreamingResponse: + response: Final = await query() + return StreamingResponse( + select_data_generator( + response=response, + user_api_key_dict=user_api_key_dict, + request_data=request_data, + request=request, + ), + media_type="text/event-stream", + headers=custom_headers_for(response), + ) + + return await open_sse_before_first_byte( + produce_stream(), + ping_interval_seconds=ttft_keepalive_interval(data, llm_router), + ) + + response: Final = await query() + fastapi_response.headers.update(custom_headers_for(response)) return response except HTTPException: diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 2a50d5170f0..dff100bdea7 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -3,7 +3,8 @@ import collections import json import os from collections.abc import Mapping, Sequence -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone +from itertools import groupby from types import MappingProxyType from typing import ( TYPE_CHECKING, @@ -16,7 +17,6 @@ from typing import ( TypeAlias, TypedDict, TypeVar, - cast, # noqa: TID251 # prisma group_by returns untyped aggregate mappings ) import fastapi @@ -201,16 +201,12 @@ class _SessionSpendStats(NamedTuple): _SessionSpendMap: TypeAlias = Mapping[tuple[str, str], _SessionSpendStats] -class _SpendSumAggregate(TypedDict, total=False): - spend: ReadOnly[float] - - -class _SpendGroupByRow(TypedDict): +class _SpendDailySummaryRow(TypedDict): + day: ReadOnly[str] api_key: ReadOnly[str] user: ReadOnly[str | None] model: ReadOnly[str] - startTime: ReadOnly[object] - _sum: ReadOnly[_SpendSumAggregate] + spend: ReadOnly[float] async def _query_raw(prisma_client: PrismaClient, sql_query: str, *args: object) -> Sequence[_RowT]: @@ -251,6 +247,66 @@ def _verification_token_table(prisma_client: PrismaClient) -> _VerificationToken return VerificationTokenRepository(prisma_client).table +def _spend_logs_daily_summary_sql( + *, + start_date_iso: str, + end_date_iso: str, + api_key: str | None, + request_id: str | None, + user_id: str | None, +) -> tuple[str, tuple[object, ...]]: + filter_params: Final[tuple[tuple[str, object], ...]] = tuple( + (column, value) + for column, value in ( + ("api_key", api_key), + ("request_id", request_id), + ('"user"', user_id), + ) + if value is not None + ) + filter_clauses: Final[tuple[str, ...]] = tuple( + f"AND {column} = ${index}" for index, (column, _) in enumerate(filter_params, start=3) + ) + filter_sql: Final = "\n".join(filter_clauses) + sql_query: Final = f""" +SELECT + to_char(date_trunc('day', "startTime"), 'YYYY-MM-DD') AS day, + api_key, + "user", + model, + SUM(spend) AS spend +FROM "LiteLLM_SpendLogs" +WHERE "startTime" >= ($1::timestamptz AT TIME ZONE 'UTC') AND "startTime" <= ($2::timestamptz AT TIME ZONE 'UTC') +{filter_sql} +GROUP BY 1, 2, 3, 4 +ORDER BY 1 +""" + params: Final[tuple[object, ...]] = ( + start_date_iso, + end_date_iso, + *(value for _, value in filter_params), + ) + return sql_query, params + + +def _sum_spend_by( + rows: Sequence[_SpendDailySummaryRow], column: Literal["api_key", "user", "model"] +) -> Mapping[str | None, float]: + keys: Final = frozenset(row[column] for row in rows) + return {key: sum(float(row["spend"]) for row in rows if row[column] == key) for key in keys} + + +def _daily_summary_item(summary_date: date, rows: Sequence[_SpendDailySummaryRow]) -> Mapping[str, object]: + api_key_spend: Final = {key: value for key, value in _sum_spend_by(rows, "api_key").items() if key is not None} + return { + **api_key_spend, + "startTime": summary_date, + "spend": sum(float(row["spend"]) for row in rows), + "users": _sum_spend_by(rows, "user"), + "models": _sum_spend_by(rows, "model"), + } + + async def _find_spend_logs( prisma_client: PrismaClient, where: Mapping[str, object], @@ -2229,6 +2285,31 @@ async def calculate_spend(request: SpendCalculateRequest): ) +class _SpendLogSearchCondition(NamedTuple): + sql: str + params: tuple[object, ...] + + +def _build_spend_log_search_condition( + search: str, + start_date: datetime, + end_date: datetime, + next_param_index: int, +) -> _SpendLogSearchCondition: + """request_id (indexed) matches across all time; the unindexed id columns only inside the window.""" + raw: Final = f"${next_param_index}" + window_start: Final = f"${next_param_index + 1}" + window_end: Final = f"${next_param_index + 2}" + sql: Final = ( + f"(request_id = {raw} OR (" + f"\"startTime\" >= ({window_start}::timestamptz AT TIME ZONE 'UTC') " + f"AND \"startTime\" <= ({window_end}::timestamptz AT TIME ZONE 'UTC') " + f'AND (api_key = {raw} OR team_id = {raw} OR "user" = {raw} OR end_user = {raw} ' + f"OR session_id = {raw} OR model_id = {raw})))" + ) + return _SpendLogSearchCondition(sql=sql, params=(search, start_date, end_date)) + + @router.get( "/spend/logs/v2", tags=["Budget & Spend Tracking"], @@ -2329,6 +2410,14 @@ async def ui_view_spend_logs( "UI route only, honored when sorting by startTime" ), ), + search: str | None = fastapi.Query( + default=None, + description=( + "Match a log whose request_id, api_key (hash), team_id, user, end_user, " + "session_id, or model_id equals this value. request_id matches across all time; the other columns " + "match inside start_date/end_date, which stay required" + ), + ), ): """ View spend logs with pagination support. @@ -2392,8 +2481,10 @@ async def ui_view_spend_logs( try: is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict) is_request_id_lookup: Final = request_id is not None and not is_v2 + is_search_lookup: Final = search is not None + search_owns_window: Final = is_search_lookup and not is_v2 - if is_request_id_lookup: + if is_request_id_lookup and not is_search_lookup: # request_id is the @id primary key: it identifies a single row, so a # time window is meaningless. The dashboard always sends a default 24h # window, which hid ids copied from an older page (LIT-3981). Drop the @@ -2576,7 +2667,7 @@ async def ui_view_spend_logs( # Date range. Wrap the param side with `AT TIME ZONE 'UTC'` so comparison # against the plain `timestamp` column does not depend on the DB session # timezone (see #22529). Absent for a request_id-only lookup (see above). - if start_date_obj is not None and end_date_obj is not None: + if start_date_obj is not None and end_date_obj is not None and not search_owns_window: sql_conditions.append(f"\"startTime\" >= (${p}::timestamptz AT TIME ZONE 'UTC')") sql_params.append(start_date_obj) p += 1 @@ -2584,6 +2675,17 @@ async def ui_view_spend_logs( sql_params.append(end_date_obj) p += 1 + if search is not None and start_date_obj is not None and end_date_obj is not None: + search_condition: Final = _build_spend_log_search_condition( + search=search, + start_date=start_date_obj, + end_date=end_date_obj, + next_param_index=p, + ) + sql_conditions.append(search_condition.sql) + sql_params.extend(search_condition.params) + p += len(search_condition.params) # rebind-ok: advances the file's shared $N placeholder counter + # Equality filters - read effective values from where_conditions (post-authorization) for sql_col, wc_key in [ ("team_id", "team_id"), @@ -2662,7 +2764,13 @@ async def ui_view_spend_logs( sql_params.append(f"%{error_message}%") p += 1 - if group_by_session is True and not is_v2 and not is_request_id_lookup and sort_by == "startTime": + if ( + group_by_session is True + and not is_v2 + and not is_request_id_lookup + and not is_search_lookup + and sort_by == "startTime" + ): return await _ui_session_grouped_spend_logs( prisma_client=prisma_client, sql_conditions=sql_conditions, @@ -2696,7 +2804,7 @@ async def ui_view_spend_logs( _order_expr = order_column joined_conditions: Final = " AND ".join(sql_conditions) - session_grouping: Final = group_by_session is True + session_grouping: Final = group_by_session is True and not is_search_lookup count_group_clause: Final = f"GROUP BY {_SESSION_GROUP_KEY_SQL}" if session_grouping else "" count_query: Final = f""" SELECT COUNT(*) AS total_count @@ -3214,18 +3322,22 @@ async def view_spend_logs( start_date_iso: Final = start_date_obj.isoformat() end_date_iso: Final = end_date_obj.isoformat() - filter_query: Final = { + filter_query: Final[ + dict[str, object] + ] = { # mutable-ok: legacy filters are extended for optional parameters "startTime": { "gte": start_date_iso, # Greater than or equal to Start Date "lte": end_date_iso, # Less than or equal to End Date } } + summary_api_key: Final[str | None] = ( + prisma_client.hash_token(token=api_key) + if api_key is not None and api_key.startswith("sk-") + else api_key + ) if api_key is not None and isinstance(api_key, str): - if api_key.startswith("sk-"): - filter_query["api_key"] = prisma_client.hash_token(token=api_key) - else: - filter_query["api_key"] = api_key + filter_query["api_key"] = summary_api_key if request_id is not None and isinstance(request_id, str): filter_query["request_id"] = request_id if user_id is not None and isinstance(user_id, str): @@ -3244,58 +3356,34 @@ async def view_spend_logs( return data # Legacy behavior: return summarized data (when summarize=true) - # SQL query - response: Final = await SpendLogsRepository(prisma_client).table.group_by( - by=["api_key", "user", "model", "startTime"], - where=filter_query, - sum={ - "spend": True, - }, + summary_sql_and_params: Final = _spend_logs_daily_summary_sql( + start_date_iso=start_date_iso, + end_date_iso=end_date_iso, + api_key=summary_api_key, + request_id=request_id, + user_id=user_id, ) + sql_query, params = summary_sql_and_params + rows: Final[Sequence[_SpendDailySummaryRow]] = await _query_raw(prisma_client, sql_query, *params) + if len(rows) == 0: + return [] # pyright: ignore[reportUnknownVariableType] # empty summary has no element type - if isinstance(response, list) and len(response) > 0 and isinstance(response[0], dict): - spend_rows: Final = cast(Sequence[_SpendGroupByRow], response) # cast-ok: by/sum fix the shape - result: Final[dict] = {} - for record in spend_rows: - dt_object = datetime.strptime(str(record["startTime"]), "%Y-%m-%dT%H:%M:%S.%fZ") - date = dt_object.date() - if date not in result: - result[date] = {"users": {}, "models": {}} - api_key = record["api_key"] - user_id = record["user"] - model = record["model"] - result[date]["spend"] = result[date].get("spend", 0) + record.get("_sum", {}).get("spend", 0) - result[date][api_key] = result[date].get(api_key, 0) + record.get("_sum", {}).get("spend", 0) - result[date]["users"][user_id] = result[date]["users"].get(user_id, 0) + record.get("_sum", {}).get( - "spend", 0 - ) - result[date]["models"][model] = result[date]["models"].get(model, 0) + record.get("_sum", {}).get( - "spend", 0 - ) - return_list: Final = [] - final_date = None - for k, v in sorted(result.items()): - return_list.append({**v, "startTime": k}) - final_date = k - - end_date_date: Final = end_date_obj.date() - if final_date is not None and final_date < end_date_date: - current_date = final_date + timedelta(days=1) - while current_date <= end_date_date: - # Represent current_date as string because original response has it this way - return_list.append( - { - "startTime": current_date, - "spend": 0, - "users": {}, - "models": {}, - } - ) # If no data, will stay as zero - current_date += timedelta(days=1) # Move on to the next day - - return return_list - - return response + summary_items: Final = tuple( + _daily_summary_item(date.fromisoformat(day), tuple(day_rows)) + for day, day_rows in groupby(rows, key=lambda row: row["day"]) + ) + final_date: Final = date.fromisoformat(rows[-1]["day"]) + end_date_date: Final = end_date_obj.date() + padding: Final[tuple[Mapping[str, object], ...]] = tuple( + { + "startTime": final_date + timedelta(days=offset), + "spend": 0, + "users": {}, + "models": {}, + } + for offset in range(1, (end_date_date - final_date).days + 1) + ) + return [*summary_items, *padding] else: scoped_filter: Final[dict[str, str]] = {} diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 24a65457928..1f453d3b1ba 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -1478,28 +1478,27 @@ class ProxyLogging: ) -> None: """Process prompt template if applicable.""" - from litellm.proxy.prompts.prompt_endpoints import ( - construct_versioned_prompt_id, - get_latest_version_prompt_id, - ) from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY from litellm.responses.utils import ResponsesAPIRequestUtils from litellm.utils import get_non_default_completion_params - if prompt_version is None: - lookup_prompt_id = get_latest_version_prompt_id( - prompt_id=prompt_id, - all_prompt_ids=IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS, - ) - else: - lookup_prompt_id = construct_versioned_prompt_id(prompt_id=prompt_id, version=prompt_version) - - custom_logger: Final = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id(lookup_prompt_id) - prompt_spec: Final = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(lookup_prompt_id) + raw_prompt_environment: Final = data.get("prompt_environment", None) + prompt_environment: Final = raw_prompt_environment if isinstance(raw_prompt_environment, str) else None + prompt_spec: Final = IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec( + prompt_id, + version=prompt_version, + environment=prompt_environment, + ) + custom_logger: Final = ( + IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_for_prompt(prompt=prompt_spec) + if prompt_spec is not None + else None + ) litellm_prompt_id: str | None = None if prompt_spec is not None: litellm_prompt_id = prompt_spec.litellm_params.prompt_id data.pop("prompt_id", None) + data.pop("prompt_environment", None) if custom_logger and prompt_spec is not None: is_responses_call: Final = call_type == "aresponses" @@ -1542,6 +1541,7 @@ class ProxyLogging: data.pop("prompt_variables", None) data.pop("prompt_label", None) data.pop("prompt_version", None) + data.pop("prompt_environment", None) def _process_guardrail_metadata(self, data: dict) -> None: """Process guardrails from metadata and add to applied_guardrails.""" @@ -1750,7 +1750,6 @@ class ProxyLogging: litellm_logging_obj: Final = cast(Optional["LiteLLMLoggingObj"], data.get("litellm_logging_obj", None)) prompt_id: Final[str | None] = data.get("prompt_id", None) - prompt_version: Final[int | None] = data.get("prompt_version", None) ## PROMPT TEMPLATE CHECK ## @@ -1760,11 +1759,13 @@ class ProxyLogging: and prompt_id is not None and (call_type == "completion" or call_type == "acompletion" or call_type == "aresponses") ): + from litellm.proxy.prompts.prompt_registry import parse_prompt_version + await self._process_prompt_template( data=data, litellm_logging_obj=litellm_logging_obj, prompt_id=prompt_id, - prompt_version=prompt_version, + prompt_version=parse_prompt_version(data.get("prompt_version", None)), call_type=call_type, ) diff --git a/litellm/rag/main.py b/litellm/rag/main.py index 94bfc305a6a..1f63152632e 100644 --- a/litellm/rag/main.py +++ b/litellm/rag/main.py @@ -11,7 +11,7 @@ __all__ = ["aingest", "aquery", "ingest", "query"] import asyncio import contextvars -from collections.abc import Coroutine, Iterator +from collections.abc import Coroutine, Iterator, Mapping from contextlib import contextmanager from functools import partial from types import MappingProxyType @@ -66,6 +66,10 @@ _FORWARDABLE_RETRIEVAL_CONFIG_KEYS: Final = frozenset( } ) +_SEARCH_ARGS_SET_BY_PIPELINE: Final = frozenset( + {"vector_store_id", "query", "max_num_results", "custom_llm_provider", "router"} +) + def get_ingestion_class(provider: str) -> type[BaseRAGIngestion]: """ @@ -225,6 +229,7 @@ async def _execute_query_pipeline( retrieval_config: dict[str, Any], rerank: dict[str, Any] | None = None, stream: bool = False, + vector_store_params: Mapping[str, object] | None = None, **kwargs, ) -> ModelResponse: """ @@ -241,11 +246,19 @@ async def _execute_query_pipeline( # 2. Search vector store # Forward allowlisted provider retrieval_config extras (region, embedding - # model, bucket, credential refs) to the search call; kwargs win on conflict. + # model, bucket, credential refs) to the search call; the managed store's + # params win on conflict. provider_search_params: Final = MappingProxyType( {k: v for k, v in retrieval_config.items() if k in _FORWARDABLE_RETRIEVAL_CONFIG_KEYS} ) - forwarded_search_params: Final = MappingProxyType({**provider_search_params, **kwargs}) + store_search_params: Final = MappingProxyType( + { + k: v + for k, v in (vector_store_params.items() if vector_store_params else ()) + if k not in _SEARCH_ARGS_SET_BY_PIPELINE + } + ) + forwarded_search_params: Final = MappingProxyType({**provider_search_params, **kwargs, **store_search_params}) with _suppressed_sub_call_billing(): search_response: Final = await litellm.vector_stores.asearch( vector_store_id=retrieval_config["vector_store_id"], @@ -339,6 +352,7 @@ async def aquery( retrieval_config: dict[str, Any], rerank: dict[str, Any] | None = None, stream: bool = False, + vector_store_params: Mapping[str, object] | None = None, **kwargs, ) -> ModelResponse: """ @@ -356,6 +370,7 @@ async def aquery( retrieval_config=retrieval_config, rerank=rerank, stream=stream, + vector_store_params=vector_store_params, **kwargs, ) @@ -386,6 +401,7 @@ def query( retrieval_config: dict[str, Any], rerank: dict[str, Any] | None = None, stream: bool = False, + vector_store_params: Mapping[str, object] | None = None, **kwargs, ) -> ModelResponse | Coroutine[None, None, ModelResponse]: """ @@ -402,6 +418,7 @@ def query( retrieval_config=retrieval_config, rerank=rerank, stream=stream, + vector_store_params=vector_store_params, **kwargs, ) else: @@ -412,6 +429,7 @@ def query( retrieval_config=retrieval_config, rerank=rerank, stream=stream, + vector_store_params=vector_store_params, **kwargs, ) ) diff --git a/litellm/repositories/table_repositories.py b/litellm/repositories/table_repositories.py index 18cf884f267..739d6ada71c 100644 --- a/litellm/repositories/table_repositories.py +++ b/litellm/repositories/table_repositories.py @@ -176,6 +176,10 @@ class PolicyAttachmentRepository(PrismaTableRepository["prisma_models.LiteLLM_Po table_name = "litellm_policyattachmenttable" +class TeamRepository(PrismaTableRepository["prisma_models.LiteLLM_TeamTable"]): + table_name = "litellm_teamtable" + + class DeletedTeamRepository(PrismaTableRepository["prisma_models.LiteLLM_DeletedTeamTable"]): table_name = "litellm_deletedteamtable" diff --git a/litellm/router.py b/litellm/router.py index dc750941559..6da201725b6 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -197,6 +197,7 @@ from litellm.router_utils.router_callbacks.track_deployment_metrics import ( from litellm.scheduler import FlowItem, Scheduler from litellm.types.llms.openai import ( AllMessageValues, + ChatCompletionToolParam, FileTypes, OpenAIFileObject, OpenAIFilesPurpose, @@ -594,16 +595,20 @@ set_live_deployment_replay(_replay_live_router_model_cost) # Kwargs that carry no signal about the failed attempt, so log_retry drops them from a -# breadcrumb entirely: the request payload and the router-internal walk state. Credentials are -# handled separately by mask_credentials_in_payload, which scrubs credential-named values from -# whatever kwargs remain rather than trying to enumerate every credential-bearing key here. +# breadcrumb entirely: the request payload, the proxy's snapshot of the inbound request (its body +# aliases the live request metadata, earlier breadcrumbs included, so copying it would nest every +# breadcrumb inside the next one), and the router-internal walk state. Credentials are handled +# separately by mask_credentials_in_payload, which scrubs credential-named values from whatever +# kwargs remain rather than trying to enumerate every credential-bearing key here. RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset( ( "messages", "original_function", "attempted_targets", + "proxy_server_request", ) ) +RETRY_BREADCRUMB_LIMIT: Final = 4 class Router: @@ -964,7 +969,6 @@ class Router: self.total_calls: defaultdict = defaultdict(int) # dict to store total calls made to each model self.fail_calls: defaultdict = defaultdict(int) # dict to store fail_calls made to each model self.success_calls: defaultdict = defaultdict(int) # dict to store success_calls made to each model - self.previous_models: list = [] # list to store failed calls (passed in as metadata to next call) # make Router.chat.completions.create compatible for openai.chat.completions.create default_litellm_params = default_litellm_params or {} @@ -8143,35 +8147,31 @@ class Router: """ When a retry or fallback happens, log the details of the just failed model call - similar to Sentry breadcrumbing """ - try: - _metadata_var: Final = "litellm_metadata" if "litellm_metadata" in kwargs else "metadata" - # Log failed model as the previous model - previous_model: Final = { + _metadata_var: Final = "litellm_metadata" if "litellm_metadata" in kwargs else "metadata" + request_metadata: Final[Mapping[str, object]] = kwargs[_metadata_var] + attempt_kwargs: Final = MappingProxyType( + {k: v for k, v in kwargs.items() if k != _metadata_var and k not in RETRY_BREADCRUMB_EXCLUDED_KWARGS} + ) + attempt_metadata: Final = MappingProxyType( + {k: v for k, v in request_metadata.items() if k != "previous_models"} + ) + previous_model: Final = MappingProxyType( + { "exception_type": type(e).__name__, "exception_string": str(e), + **attempt_kwargs, + _metadata_var: attempt_metadata, } - for ( - k, - v, - ) in kwargs.items(): # log everything in kwargs except the old previous_models value - prevent nesting - if k != _metadata_var and k not in RETRY_BREADCRUMB_EXCLUDED_KWARGS: - previous_model[k] = v - elif k == _metadata_var and isinstance(v, dict): - previous_model[_metadata_var] = {} - for metadata_k, metadata_v in kwargs[_metadata_var].items(): - if metadata_k != "previous_models": - previous_model[k][metadata_k] = metadata_v - - # check current size of self.previous_models, if it's larger than 3, remove the first element - if len(self.previous_models) > 3: - self.previous_models.pop(0) - - scrubbed_previous_model: Final = mask_credentials_in_payload(previous_model) - self.previous_models.append(scrubbed_previous_model) - kwargs[_metadata_var]["previous_models"] = self.previous_models - return kwargs - except Exception as e: - raise e + ) + earlier_breadcrumbs: Final = request_metadata.get("previous_models") + kept_breadcrumbs: Final[tuple[object, ...]] = ( + tuple(earlier_breadcrumbs)[-(RETRY_BREADCRUMB_LIMIT - 1) :] + if isinstance(earlier_breadcrumbs, (list, tuple)) + else () + ) + breadcrumbs: Final = (*kept_breadcrumbs, mask_credentials_in_payload(previous_model)) + kwargs[_metadata_var]["previous_models"] = breadcrumbs # rebind-ok: the logging object already holds this dict + return kwargs def _update_usage(self, deployment_id: str, parent_otel_span: Span | None) -> int: """ @@ -9546,6 +9546,7 @@ class Router: public_model_name for _, public_model_name in self.team_model_to_deployment_indices ) + self.pattern_router.remove_deployment(model_id) for team_id in list(self.team_pattern_routers.keys()): team_pattern_router = self.team_pattern_routers[team_id] team_pattern_router.remove_deployment(model_id) @@ -11763,7 +11764,7 @@ class Router: self, messages: list[dict[str, str]] | None, input: str | list | None, - instructions: str | None = None, + request_kwargs: Mapping[str, object] | None = None, ) -> int: """ Count input tokens for context-window pre-call checks. @@ -11773,9 +11774,28 @@ class Router: The Responses payload is normalized to chat messages via the shared LiteLLMCompletionResponsesConfig transform so the same token_counter path covers both API surfaces and `instructions` tokens are included in the count. + + Prompt content the message list never carries is read from `request_kwargs`: + `tools` (Chat Completions, Responses and Anthropic Messages shapes) and the + Anthropic Messages top-level `system` block. """ + from litellm.llms.anthropic.experimental_pass_through.messages.utils import ( + anthropic_system_to_openai_message, + ) + + extras: Final = request_kwargs if request_kwargs is not None else MappingProxyType({}) + raw_instructions: Final = extras.get("instructions") + instructions: Final = raw_instructions if isinstance(raw_instructions, str) else None + raw_tools: Final = extras.get("tools") + tools: Final = ( + cast(list[ChatCompletionToolParam], raw_tools) # cast-ok: token_counter formats any tool dict shape + if isinstance(raw_tools, list) and raw_tools + else None + ) + system_message: Final = anthropic_system_to_openai_message(extras.get("system")) if messages is not None: - return litellm.token_counter(messages=messages) + counted_messages: Final = (system_message, *messages) if system_message is not None else messages + return litellm.token_counter(messages=counted_messages, tools=tools) if input is not None: from openai.types.responses.response_create_params import ResponseInputParam @@ -11788,7 +11808,10 @@ class Router: input=typed_input, responses_api_request={"instructions": instructions} if instructions is not None else {}, ) - return litellm.token_counter(messages=cast(list, input_messages)) # cast-ok: transformed chat messages + return litellm.token_counter( + messages=cast(list, input_messages), # cast-ok: transformed chat messages + tools=tools, + ) raise ValueError("Either messages or input must be provided to count tokens") def _deployment_max_input_tokens(self, model: str, deployment: Mapping[str, object]) -> int | None: @@ -11834,14 +11857,13 @@ class Router: """ if messages is None and input is None: return None - raw_instructions: Final = request_kwargs.get("instructions") if request_kwargs else None try: if not self._pre_call_checks_need_token_count(model, healthy_deployments): return None return await asyncify(self._count_pre_call_check_tokens)( messages=cast(list[dict[str, str]] | None, messages), # cast-ok: forwarded to the sync counter input=cast(str | list | None, input), # cast-ok: forwarded to the sync counter - instructions=raw_instructions if isinstance(raw_instructions, str) else None, + request_kwargs=request_kwargs, ) except Exception as e: # noqa: BLE001 # best-effort: an uncountable prompt must not fail the request verbose_router_logger.error( @@ -11888,8 +11910,6 @@ class Router: _rate_limit_error = False parent_otel_span: Final = _get_parent_otel_span_from_kwargs(request_kwargs) - raw_instructions: Final = request_kwargs.get("instructions") if request_kwargs else None - instructions: Final = raw_instructions if isinstance(raw_instructions, str) else None has_countable_input: Final = messages is not None or input is not None ## get model group RPM ## @@ -11920,7 +11940,7 @@ class Router: return _returned_deployments try: input_tokens = self._count_pre_call_check_tokens( - messages=messages, input=input, instructions=instructions + messages=messages, input=input, request_kwargs=request_kwargs ) except Exception as e: verbose_router_logger.error( diff --git a/litellm/router_strategy/complexity_router/README.md b/litellm/router_strategy/complexity_router/README.md index e3da70f50fe..afa27719064 100644 --- a/litellm/router_strategy/complexity_router/README.md +++ b/litellm/router_strategy/complexity_router/README.md @@ -190,6 +190,9 @@ model_list: # Let that replacement also override a kept session pin, for image turns only (default: false) modality_pin_override: true + + # Refreshes on every pin reuse, so this is idle time rather than total session length (default: 3600) + session_affinity_ttl_seconds: 300 ``` ## Usage @@ -240,6 +243,10 @@ affinity write happens upstream of the gate and stores the session's own model, turn replays the original pin and the override is never pinned in its place. It does nothing unless `modality_routing` is also on. +### Session pin retention + +`session_affinity_ttl_seconds` is the idle window for both the model pin selected by session affinity and the deployment pin. Every request that reuses a pin refreshes its TTL, so a session actively sending requests stays pinned. After the window passes with no pin reuse, the next request classifies again and creates a fresh pin. Omit the setting to track the default of 3600 seconds. + ### Heuristic-first chaining `classifier_type: heuristic_first` runs the local scorer on every request and only calls the LLM diff --git a/litellm/router_strategy/lowest_tpm_rpm_v2.py b/litellm/router_strategy/lowest_tpm_rpm_v2.py index 6deba5aa1cf..665ff69ab47 100644 --- a/litellm/router_strategy/lowest_tpm_rpm_v2.py +++ b/litellm/router_strategy/lowest_tpm_rpm_v2.py @@ -1,6 +1,7 @@ #### What this does #### # identifies lowest tpm deployment import random +from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Final import httpx @@ -350,9 +351,9 @@ class LowestTPMLoggingHandler_v2(BaseRoutingStrategy, CustomLogger): model_group: str, healthy_deployments: list, tpm_keys: list, - tpm_values: list | None, + tpm_values: Sequence | None, rpm_keys: list, - rpm_values: list | None, + rpm_values: Sequence | None, messages: list[dict[str, str]] | None = None, input: str | list | None = None, ) -> dict | None: diff --git a/litellm/rust_bridge/__init__.py b/litellm/rust_bridge/__init__.py index e6d8ffef48c..9e8558bbf7d 100644 --- a/litellm/rust_bridge/__init__.py +++ b/litellm/rust_bridge/__init__.py @@ -4,6 +4,7 @@ from litellm.rust_bridge.configuration import use_litellm_rust from litellm.rust_bridge.loader import ( get_native_bridge, native_bridge_available, + reset_native_bridge_cache, ) -__all__ = ["get_native_bridge", "native_bridge_available", "use_litellm_rust"] +__all__ = ["get_native_bridge", "native_bridge_available", "reset_native_bridge_cache", "use_litellm_rust"] diff --git a/litellm/rust_bridge/loader.py b/litellm/rust_bridge/loader.py index 1c11d6435d8..022c38f5a85 100644 --- a/litellm/rust_bridge/loader.py +++ b/litellm/rust_bridge/loader.py @@ -24,6 +24,12 @@ def get_native_bridge() -> ModuleType | None: return _native +def reset_native_bridge_cache() -> None: + """Forget the cached extension so the next lookup reimports it from disk.""" + global _cached_bridge + _cached_bridge = _BRIDGE_SENTINEL + + def native_bridge_available() -> bool: """Whether the packaged Rust extension is importable.""" return get_native_bridge() is not None diff --git a/litellm/secret_managers/get_azure_ad_token_provider.py b/litellm/secret_managers/get_azure_ad_token_provider.py index c2dc09bc65d..5d056ea3fe0 100644 --- a/litellm/secret_managers/get_azure_ad_token_provider.py +++ b/litellm/secret_managers/get_azure_ad_token_provider.py @@ -57,9 +57,11 @@ def get_azure_ad_token_provider( from azure import identity from azure.identity import ( CertificateCredential, + ChainedTokenCredential, ClientSecretCredential, DefaultAzureCredential, ManagedIdentityCredential, + WorkloadIdentityCredential, get_bearer_token_provider, ) @@ -101,6 +103,28 @@ def get_azure_ad_token_provider( # DefaultAzureCredential doesn't require explicit environment variables # It automatically discovers credentials from the environment (managed identity, CLI, etc.) credential = DefaultAzureCredential() + elif cred == AzureCredentialType.DeploymentIdentityCredential: + # DefaultAzureCredential cannot express this: excluding its developer credentials still + # leaves one managed identity link, which AZURE_CLIENT_ID pins to a user assigned identity, + # so a host running as a system assigned identity never gets asked + workload_client_id: Final = os.environ.get("AZURE_CLIENT_ID") + workload_tenant_id: Final = os.environ.get("AZURE_TENANT_ID") + workload_token_file: Final = os.environ.get("AZURE_FEDERATED_TOKEN_FILE") + credential = ChainedTokenCredential( + *( + ( + WorkloadIdentityCredential( + client_id=workload_client_id, + tenant_id=workload_tenant_id, + token_file_path=workload_token_file, + ), + ) + if workload_client_id and workload_tenant_id and workload_token_file + else () + ), + *((ManagedIdentityCredential(client_id=workload_client_id),) if workload_client_id else ()), + ManagedIdentityCredential(), + ) else: cred_cls: Final = getattr(identity, cred) credential = cred_cls() diff --git a/litellm/types/caching.py b/litellm/types/caching.py index 10c83376a6c..747f202d35e 100644 --- a/litellm/types/caching.py +++ b/litellm/types/caching.py @@ -18,6 +18,11 @@ class LiteLLMCacheType(str, Enum): GCS = "gcs" +class SemanticCacheScope(str, Enum): + KEY = "key" + END_USER = "end_user" + + CachingSupportedCallTypes = Literal[ "completion", "acompletion", diff --git a/litellm/types/management_endpoints/cache_settings_endpoints.py b/litellm/types/management_endpoints/cache_settings_endpoints.py index 2e6d0be9071..32b32991449 100644 --- a/litellm/types/management_endpoints/cache_settings_endpoints.py +++ b/litellm/types/management_endpoints/cache_settings_endpoints.py @@ -187,6 +187,19 @@ CACHE_SETTINGS_FIELDS: Final[list[CacheSettingsField]] = [ ui_field_name="Embedding Model", redis_type="semantic", ), + CacheSettingsField( + field_name="semantic_cache_scope", + field_type="String", + field_value=None, + field_description=( + "Isolation granularity for semantic cache hits. 'key' shares hits between all end users of a key/team/org." + " 'end_user' also isolates per end user; requests without an end user fall back to the key scope." + ), + field_default="key", + options=["key", "end_user"], + ui_field_name="Semantic Cache Scope", + redis_type="semantic", + ), # GCP IAM authentication fields CacheSettingsField( field_name="gcp_service_account", diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index df0a090cdb0..6973f1d1f12 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -1,6 +1,8 @@ -from typing import Any, Final +from collections.abc import Mapping +from typing import Any, Final, Literal from pydantic import BaseModel, field_validator +from typing_extensions import ReadOnly, TypedDict from litellm.proxy._types import ( LiteLLM_UserTableWithKeyCount, @@ -9,6 +11,17 @@ from litellm.proxy._types import ( ) +class InsensitiveContains(TypedDict): + contains: ReadOnly[str] + mode: ReadOnly[Literal["insensitive"]] + + +class UserSearchWhere(TypedDict): + """Prisma filter behind `/user/list?search=`: user_id or user_email contains the term, case-insensitive.""" + + OR: ReadOnly[tuple[Mapping[Literal["user_id", "user_email"], InsensitiveContains], ...]] + + class UserListResponse(BaseModel): """ Response model for the user list endpoint diff --git a/litellm/types/proxy/management_endpoints/key_management_endpoints.py b/litellm/types/proxy/management_endpoints/key_management_endpoints.py index 0f17f2f23ab..9fb5bea81e3 100644 --- a/litellm/types/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/key_management_endpoints.py @@ -2,6 +2,23 @@ from datetime import datetime from typing import Any, Final, Literal from pydantic import BaseModel, ConfigDict, model_validator +from typing_extensions import ReadOnly, TypedDict + +from litellm.types.proxy.management_endpoints.internal_user_endpoints import InsensitiveContains + + +class KeyTokenWhere(TypedDict): + token: ReadOnly[str] + + +class KeyAliasContainsWhere(TypedDict): + key_alias: ReadOnly[InsensitiveContains] + + +class KeySearchWhere(TypedDict): + """Prisma filter behind `/key/list?search=`: exact token or case-insensitive alias substring.""" + + OR: ReadOnly[tuple[KeyTokenWhere, KeyAliasContainsWhere]] class BulkUpdateKeyRequestItem(BaseModel): diff --git a/litellm/types/secret_managers/get_azure_ad_token_provider.py b/litellm/types/secret_managers/get_azure_ad_token_provider.py index 5d2f7409f95..6b4700d081d 100644 --- a/litellm/types/secret_managers/get_azure_ad_token_provider.py +++ b/litellm/types/secret_managers/get_azure_ad_token_provider.py @@ -6,3 +6,4 @@ class AzureCredentialType(str, Enum): ManagedIdentityCredential = "ManagedIdentityCredential" CertificateCredential = "CertificateCredential" DefaultAzureCredential = "DefaultAzureCredential" + DeploymentIdentityCredential = "DeploymentIdentityCredential" diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 0bdbb83fbb4..c1d90694f14 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -222,7 +222,9 @@ class OffPeakPricing(TypedDict, total=False): weekday_timezone: ReadOnly[str] input_cost_per_token: ReadOnly[float] output_cost_per_token: ReadOnly[float] + output_cost_per_reasoning_token: ReadOnly[float] cache_read_input_token_cost: ReadOnly[float] + cache_creation_input_token_cost: ReadOnly[float] class ModelInfoBase(ProviderSpecificModelInfo, total=False): @@ -3612,6 +3614,7 @@ all_litellm_params = ( "litellm_system_prompt", "provider_specific_header", "prompt_version", + "prompt_environment", "api_base", "force_timeout", "logger_fn", diff --git a/litellm/utils.py b/litellm/utils.py index 239673a20d9..585b5dbe1a8 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -83,6 +83,7 @@ from litellm.constants import ( from litellm.litellm_core_utils.fallback_generalizations import ( match_capability_generalizations, ) +from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload _CachingHandlerResponse = None _LLMCachingHandler = None @@ -543,6 +544,14 @@ def print_verbose( pass +def _print_verbose_is_active() -> bool: + """Whether print_verbose would reach either of its two consumers, so a call site can skip + building a payload nothing would read. _is_debugging_on() is not the same predicate: it reads + litellm._logging.set_verbose, while print_verbose's print reads litellm.set_verbose, and + assigning the documented litellm.set_verbose = True rebinds only the latter.""" + return litellm.set_verbose is True or verbose_logger.isEnabledFor(logging.DEBUG) + + ####### CLIENT ################### # make it easy to log if completion/embedding runs succeeded or failed + see what happened | Non-Blocking def custom_llm_setup(): @@ -1284,16 +1293,18 @@ async def async_post_call_success_deployment_hook( except ValueError: typed_call_type = None # unknown call type + modified_response = response + CustomLogger: Final = _get_cached_custom_logger() for callback in litellm.callbacks: if isinstance(callback, CustomLogger): result = await callback.async_post_call_success_deployment_hook( - request_data, cast(LLMResponseTypes, response), typed_call_type + request_data, cast(LLMResponseTypes, modified_response), typed_call_type ) if result is not None: - return result + modified_response = result - return response + return modified_response async def async_post_call_failure_deployment_hook( @@ -4707,7 +4718,8 @@ def get_optional_params( openai_params=list(DEFAULT_CHAT_COMPLETION_PARAM_VALUES.keys()), additional_drop_params=additional_drop_params, ) - print_verbose(f"Final returned optional params: {optional_params}") + if _print_verbose_is_active(): + print_verbose(f"Final returned optional params: {redact_credentials_in_payload(optional_params)}") optional_params = _apply_openai_param_overrides( optional_params=optional_params, non_default_params=non_default_params, @@ -7462,7 +7474,8 @@ def print_args_passed_to_litellm(original_function, args, kwargs): return args_str: Final = ", ".join(map(repr, args)) - kwargs_str: Final = ", ".join(f"{key}={value!r}" for key, value in kwargs.items()) + redacted_kwargs: Final = redact_credentials_in_payload(kwargs) + kwargs_str: Final = ", ".join(f"{key}={value!r}" for key, value in redacted_kwargs.items()) print_verbose( "\n", ) # new line before diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 7f01d2322ff..616d58970cf 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -10380,6 +10380,24 @@ "supports_vision": true, "supports_web_search": true }, + "azure_ai/grok-4.6": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/grok-4-6-comes-to-microsoft-foundry-models-built-for-long-horizon-reasoning-and-/4547578", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "azure_ai/grok-4-fast-non-reasoning": { "deprecation_date": "2026-05-01", "input_cost_per_token": 2e-07, diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index be2b30fc189..8763318b4eb 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -42,7 +42,7 @@ "limit": 52 }, "B010": { - "limit": 190 + "limit": 187 }, "B018": { "limit": 2 @@ -96,7 +96,7 @@ "limit": 10 }, "DTZ007": { - "limit": 17 + "limit": 6 }, "DTZ011": { "limit": 3 @@ -144,7 +144,7 @@ "limit": 1 }, "PLR1704": { - "limit": 3 + "limit": 1 }, "PLR1714": { "limit": 253 @@ -240,13 +240,13 @@ "limit": 96 }, "TRY201": { - "limit": 403 + "limit": 401 }, "TRY203": { - "limit": 111 + "limit": 109 }, "TRY300": { - "limit": 854 + "limit": 852 }, "UP028": { "limit": 2 diff --git a/terraform/litellm/aws/ecs.tf b/terraform/litellm/aws/ecs.tf index 01b730dac65..2fb1ca08205 100644 --- a/terraform/litellm/aws/ecs.tf +++ b/terraform/litellm/aws/ecs.tf @@ -214,8 +214,8 @@ locals { gateway_uvicorn_args = "--host 0.0.0.0 --port 4000 --workers ${var.gateway_num_workers}" backend_uvicorn_args = "--host 0.0.0.0 --port 4001" - gateway_launch_cmd = "if [ \"$USE_DDTRACE\" = \"true\" ]; then export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run uvicorn gateway.main:app ${local.gateway_uvicorn_args}; else exec uvicorn gateway.main:app ${local.gateway_uvicorn_args}; fi" - backend_launch_cmd = "if [ \"$USE_DDTRACE\" = \"true\" ]; then export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run uvicorn backend.main:app ${local.backend_uvicorn_args}; else exec uvicorn backend.main:app ${local.backend_uvicorn_args}; fi" + gateway_launch_cmd = "case \"$USE_DDTRACE\" in [Tt][Rr][Uu][Ee]) export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run uvicorn gateway.main:app ${local.gateway_uvicorn_args};; *) exec uvicorn gateway.main:app ${local.gateway_uvicorn_args};; esac" + backend_launch_cmd = "case \"$USE_DDTRACE\" in [Tt][Rr][Uu][Ee]) export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run uvicorn backend.main:app ${local.backend_uvicorn_args};; *) exec uvicorn backend.main:app ${local.backend_uvicorn_args};; esac" gateway_proxy_overrides = local.proxy_config_enabled ? { entryPoint = ["sh", "-c"] diff --git a/terraform/litellm/gcp/cloudrun.tf b/terraform/litellm/gcp/cloudrun.tf index 3340522734f..5a5c361b832 100644 --- a/terraform/litellm/gcp/cloudrun.tf +++ b/terraform/litellm/gcp/cloudrun.tf @@ -138,8 +138,8 @@ locals { gateway_uvicorn_args = "--host 0.0.0.0 --port 4000 --workers ${var.gateway_num_workers}" backend_uvicorn_args = "--host 0.0.0.0 --port 4001" - gateway_launch_cmd = "if [ \"$USE_DDTRACE\" = \"true\" ]; then export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run uvicorn gateway.main:app ${local.gateway_uvicorn_args}; else exec uvicorn gateway.main:app ${local.gateway_uvicorn_args}; fi" - backend_launch_cmd = "if [ \"$USE_DDTRACE\" = \"true\" ]; then export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run uvicorn backend.main:app ${local.backend_uvicorn_args}; else exec uvicorn backend.main:app ${local.backend_uvicorn_args}; fi" + gateway_launch_cmd = "case \"$USE_DDTRACE\" in [Tt][Rr][Uu][Ee]) export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run uvicorn gateway.main:app ${local.gateway_uvicorn_args};; *) exec uvicorn gateway.main:app ${local.gateway_uvicorn_args};; esac" + backend_launch_cmd = "case \"$USE_DDTRACE\" in [Tt][Rr][Uu][Ee]) export DD_TRACE_OPENAI_ENABLED=\"False\"; exec ddtrace-run uvicorn backend.main:app ${local.backend_uvicorn_args};; *) exec uvicorn backend.main:app ${local.backend_uvicorn_args};; esac" gateway_args = join(" && ", concat( local.redis_ca_fragment, diff --git a/tests/e2e/llm_translation/passthrough_client.py b/tests/e2e/llm_translation/passthrough_client.py index 20a8592db20..a56d3dc077e 100644 --- a/tests/e2e/llm_translation/passthrough_client.py +++ b/tests/e2e/llm_translation/passthrough_client.py @@ -168,6 +168,35 @@ def completed_responses_object(result: StreamingResponse) -> ResponsesObject | N return completed[-1] if completed else None +class AnthropicMessageObject(BaseModel): + id: str + + +class AnthropicStreamEvent(BaseModel): + """One SSE frame of a native Anthropic stream. Only `message_start` carries the + message, so it stays optional and the deltas validate as themselves.""" + + type: str + message: AnthropicMessageObject | None = None + + +def anthropic_message_id(result: StreamingResponse) -> str | None: + """The `msg_...` id the caller was served, which is what the spend row is keyed by + on this route: off the `message_start` frame when streaming, off the body when not.""" + if not result.is_streaming: + return AnthropicMessageObject.model_validate_json(result.body).id + events = ( + AnthropicStreamEvent.model_validate_json(payload) + for payload in result.stream_events + ) + started = tuple( + event.message + for event in events + if event.type == "message_start" and event.message is not None + ) + return started[0].id if started else None + + class OpenAIResponsesBody(BaseModel): model: str input: str diff --git a/tests/e2e/llm_translation/test_passthrough_e2e.py b/tests/e2e/llm_translation/test_passthrough_e2e.py index 7e6a8b25155..50ea8f4b4df 100644 --- a/tests/e2e/llm_translation/test_passthrough_e2e.py +++ b/tests/e2e/llm_translation/test_passthrough_e2e.py @@ -2,7 +2,8 @@ Each test sends a NATIVE provider request through the proxy's passthrough route and verifies the proxy still logged a costed SpendLogs row -(call_type="pass_through_endpoint"), correlated by the x-litellm-call-id header. +(call_type="pass_through_endpoint"), correlated by the id the caller was served: +the x-litellm-call-id header on gemini, the `msg_...` message id on anthropic. Covered: gemini ("gemini-2.5-flash") + anthropic ("claude-haiku-4-5"), streaming + non-streaming, plus native tool calls. See LLM_TRANSLATION_COVERAGE_MATRIX.md. @@ -14,7 +15,7 @@ A passthrough call returning non-2xx fails hard (never a skip); once it returns import pytest from e2e_config import CHEAP_OPENAI_MODEL, unique_marker -from e2e_http import StreamingResponse, require_successful_call, unwrap +from e2e_http import require_successful_call, unwrap from lifecycle import ResourceManager from models import KeyGenerateBody, SpendLogRow from passthrough_client import ( @@ -24,6 +25,7 @@ from passthrough_client import ( JsonSchema, JsonSchemaProperty, PassthroughClient, + anthropic_message_id, completed_responses_object, ) @@ -33,18 +35,18 @@ REALTIME_MODEL = "gpt-realtime-2" pytestmark = pytest.mark.e2e -def _fetch_cost_breakdown(client: PassthroughClient, result: StreamingResponse) -> SpendLogRow: +def _fetch_cost_breakdown(client: PassthroughClient, request_id: str | None) -> SpendLogRow: """The passthrough call's logged row, polled until it carries a cost. Asserts (not skips) that a 2xx passthrough call produced a costed row - the whole point of passthrough spend tracking. """ - assert result.call_id, "passthrough response had no x-litellm-call-id header" + assert request_id, "passthrough response carried no id to correlate its spend row by" rows = client.proxy.poll_logs_for_request_id( - result.call_id, + request_id, predicate=lambda rs: (rs[0].spend or 0) > 0, ) - assert rows, f"no SpendLogs row for passthrough call_id {result.call_id}" + assert rows, f"no SpendLogs row for passthrough request_id {request_id}" row = rows[0] assert row.call_type == "pass_through_endpoint" assert (row.spend or 0) > 0, f"passthrough call was not costed: {row}" @@ -64,7 +66,7 @@ def test_gemini_passthrough_nonstreaming_logs_cost( ) require_successful_call(result) - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, result.call_id) assert row.custom_llm_provider == "gemini" assert "gemini" in (row.model or "") assert tag in (row.request_tags or []), f"tags not logged: {row.request_tags}" @@ -107,7 +109,7 @@ def test_gemini_passthrough_streaming_logs_cost( require_successful_call(result) assert result.chunks > 0, "streaming passthrough produced no events" - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, result.call_id) assert row.custom_llm_provider == "gemini" @@ -137,7 +139,7 @@ def test_gemini_passthrough_tool_call_logs_cost( require_successful_call(result) assert "functionCall" in result.body, "gemini did not emit a tool call" - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, result.call_id) assert row.custom_llm_provider == "gemini" @@ -150,7 +152,7 @@ def test_anthropic_passthrough_nonstreaming_logs_cost( result = client.anthropic_message(scoped_key, "claude-haiku-4-5", "Say hello") require_successful_call(result) - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, anthropic_message_id(result)) assert row.custom_llm_provider == "anthropic" assert "claude" in (row.model or "") @@ -164,7 +166,7 @@ def test_anthropic_passthrough_streaming_logs_cost( require_successful_call(result) assert result.chunks > 0, "streaming passthrough produced no events" - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, anthropic_message_id(result)) assert row.custom_llm_provider == "anthropic" @@ -190,7 +192,7 @@ def test_anthropic_passthrough_tool_call_logs_cost( require_successful_call(result) assert "tool_use" in result.body, "anthropic did not emit a tool call" - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, anthropic_message_id(result)) assert row.custom_llm_provider == "anthropic" diff --git a/tests/e2e/ui/tests/tables/tableScrolling.spec.ts b/tests/e2e/ui/tests/tables/tableScrolling.spec.ts new file mode 100644 index 00000000000..5c7438cfd11 --- /dev/null +++ b/tests/e2e/ui/tests/tables/tableScrolling.spec.ts @@ -0,0 +1,218 @@ +import { test, expect, type APIRequestContext, type Locator, type Page as PlaywrightPage } from "@playwright/test"; +import { ADMIN_STORAGE_PATH } from "../../constants"; +import { Page } from "../../fixtures/pages"; +import { navigateToPage } from "../../helpers/navigation"; +import { CHAT_MODEL_A, masterKey, sendChatCompletion, waitForSpendLog } from "../../helpers/traffic"; + +const VIEWPORT = { width: 1280, height: 720 }; +const SEED_ROWS = 40; +const LOG_ROWS = 20; +const BODY_SCROLL_PX = 500; +const MAX_FOOTER_GAP_PX = 40; + +interface GeneratedKey { + key: string; +} + +interface CreatedTeam { + team_id: string; +} + +interface CreatedModel { + model_info: { id: string }; +} + +interface BoxMetrics { + top: number; + bottom: number; + scrollHeight: number; + clientHeight: number; + scrollWidth: number; + clientWidth: number; +} + +const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +const adminHeaders = (): Record => ({ Authorization: `Bearer ${masterKey()}` }); + +const appShellMain = (page: PlaywrightPage): Locator => page.locator("main").first(); + +const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true }); + +const visibleDataTable = (page: PlaywrightPage): Locator => visibleTestId(page, "data-table-root").first(); + +const visibleRows = (page: PlaywrightPage): Locator => visibleDataTable(page).locator("tbody tr"); + +const metrics = (locator: Locator): Promise => + locator.evaluate((el) => { + const rect = el.getBoundingClientRect(); + return { + top: rect.top, + bottom: rect.bottom, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth, + }; + }); + +async function postOk(request: APIRequestContext, path: string, data: Record): Promise { + const res = await request.post(path, { headers: adminHeaders(), data }); + expect(res.ok(), `POST ${path} failed (${res.status()}): ${await res.text()}`).toBe(true); + return (await res.json()) as T; +} + +const oneAtATime = (count: number, call: (index: number) => Promise): Promise => + Array.from({ length: count }, (_, i) => i).reduce>( + async (previous, i) => [...(await previous), await call(i)], + Promise.resolve([]), + ); + +async function expectRowsAtLeast(page: PlaywrightPage, count: number): Promise { + await expect.poll(() => visibleRows(page).count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(count); +} + +async function setRowsPerPage(page: PlaywrightPage, size: "25" | "50" | "100"): Promise { + await visibleTestId(page, "pagination-page-size").click(); + await page.getByRole("option", { name: size, exact: true }).click(); +} + +async function expectBodyIsTheOnlyScroller(page: PlaywrightPage): Promise { + const scroller = await metrics(appShellMain(page)); + const body = visibleTestId(page, "data-table-scroller"); + const bodyBefore = await metrics(body); + const headBefore = await metrics(visibleTestId(page, "data-table-head")); + const footer = await metrics(visibleDataTable(page)); + + expect(scroller.scrollHeight, "page scroller must not overflow vertically").toBe(scroller.clientHeight); + expect(scroller.scrollWidth, "page scroller must not overflow horizontally").toBe(scroller.clientWidth); + expect(bodyBefore.scrollHeight, "table body must be the element that scrolls").toBeGreaterThan( + bodyBefore.clientHeight, + ); + expect(footer.bottom, "pagination footer must be inside the page").toBeLessThanOrEqual(scroller.bottom); + expect(scroller.bottom - footer.bottom, "pagination footer must sit at the bottom of the page").toBeLessThanOrEqual( + MAX_FOOTER_GAP_PX, + ); + + await body.evaluate((el, px) => { + el.scrollTop = px; + }, BODY_SCROLL_PX); + await expect.poll(() => body.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + const headAfter = await metrics(visibleTestId(page, "data-table-head")); + expect(Math.round(headAfter.top), "header must stay put while the body scrolls").toBe(Math.round(headBefore.top)); +} + +const rowsPaintingPastAnAncestor = (page: PlaywrightPage): Promise => + visibleDataTable(page) + .locator("table") + .evaluate((table) => { + const scrollsVertically = (el: Element): boolean => + /auto|scroll/.test(getComputedStyle(el).overflowY) && el.scrollHeight > el.clientHeight + 1; + const boxesUpToTheScroller = (el: Element | null): Element[] => + el === null || el === document.body || scrollsVertically(el) + ? [] + : [el, ...boxesUpToTheScroller(el.parentElement)]; + const describe = (el: Element): string => + `<${el.tagName.toLowerCase()} class="${el.getAttribute("class") ?? ""}">`; + return Array.from(table.querySelectorAll("tbody tr")).flatMap((row, index) => { + const rowBottom = row.getBoundingClientRect().bottom; + return boxesUpToTheScroller(row.parentElement) + .filter((box) => rowBottom > box.getBoundingClientRect().bottom + 1) + .map( + (box) => + `row ${index} bottom ${Math.round(rowBottom)} past ${describe(box)} bottom ${Math.round(box.getBoundingClientRect().bottom)}`, + ); + }); + }); + +test.describe("Admin tables scroll inside the page", () => { + test.use({ storageState: ADMIN_STORAGE_PATH, viewport: VIEWPORT }); + + test("Virtual Keys: rows scroll under a sticky header and the page itself never scrolls", async ({ + page, + request, + }) => { + const suffix = uniqueSuffix(); + const keys = await oneAtATime(SEED_ROWS, (i) => + postOk(request, "/key/generate", { key_alias: `e2e-scroll-key-${suffix}-${i}` }), + ); + try { + await navigateToPage(page, Page.ApiKeys); + await expectRowsAtLeast(page, SEED_ROWS); + await expectBodyIsTheOnlyScroller(page); + } finally { + await request.post("/key/delete", { headers: adminHeaders(), data: { keys: keys.map((k) => k.key) } }); + } + }); + + test("Teams: rows scroll under a sticky header and the page itself never scrolls", async ({ page, request }) => { + const suffix = uniqueSuffix(); + const teams = await oneAtATime(SEED_ROWS, (i) => + postOk(request, "/team/new", { team_alias: `e2e-scroll-team-${suffix}-${i}` }), + ); + try { + await navigateToPage(page, Page.Teams); + await expectRowsAtLeast(page, SEED_ROWS); + await expectBodyIsTheOnlyScroller(page); + } finally { + await request.post("/team/delete", { headers: adminHeaders(), data: { team_ids: teams.map((t) => t.team_id) } }); + } + }); + + test("Request Logs: rows scroll under a sticky header and the page itself never scrolls", async ({ + page, + request, + }) => { + const suffix = uniqueSuffix(); + const ids = await oneAtATime(LOG_ROWS, (i) => + sendChatCompletion(request, { model: CHAT_MODEL_A, prompt: `scroll ${suffix} ${i}` }), + ); + await waitForSpendLog(request, ids[ids.length - 1]); + + await navigateToPage(page, Page.Logs); + await expect(visibleTestId(page, "datatable-search")).toBeVisible({ timeout: 20_000 }); + await setRowsPerPage(page, "25"); + await expectRowsAtLeast(page, LOG_ROWS); + await expectBodyIsTheOnlyScroller(page); + }); + + test("Tags: no row paints past the box it lives in", async ({ page, request }) => { + const suffix = uniqueSuffix(); + const names = Array.from({ length: SEED_ROWS }, (_, i) => `e2e-scroll-tag-${suffix}-${i}`); + await oneAtATime(SEED_ROWS, (i) => + postOk(request, "/tag/new", { name: names[i], description: "LIT-4738 scroll" }), + ); + try { + await navigateToPage(page, Page.TagManagement); + await expectRowsAtLeast(page, SEED_ROWS); + expect(await rowsPaintingPastAnAncestor(page)).toEqual([]); + } finally { + await oneAtATime(SEED_ROWS, (i) => + request.post("/tag/delete", { headers: adminHeaders(), data: { name: names[i] } }), + ); + } + }); + + test("Model Hub: no row paints past the box it lives in", async ({ page, request }) => { + const suffix = uniqueSuffix(); + const models = await oneAtATime(SEED_ROWS, (i) => + postOk(request, "/model/new", { + model_name: `e2e-scroll-model-${suffix}-${i}`, + litellm_params: { + model: "openai/fake-gpt-4", + api_base: `http://127.0.0.1:${process.env.MOCK_LLM_PORT ?? "8090"}/v1`, + api_key: "fake-key", + }, + }), + ); + try { + await navigateToPage(page, Page.ModelHubTable); + await expectRowsAtLeast(page, SEED_ROWS); + expect(await rowsPaintingPastAnAncestor(page)).toEqual([]); + } finally { + await oneAtATime(SEED_ROWS, (i) => + request.post("/model/delete", { headers: adminHeaders(), data: { id: models[i].model_info.id } }), + ); + } + }); +}); diff --git a/tests/e2e/ui/tests/users/searchUsers.spec.ts b/tests/e2e/ui/tests/users/searchUsers.spec.ts index fa8f32764e8..64fce24bf2a 100644 --- a/tests/e2e/ui/tests/users/searchUsers.spec.ts +++ b/tests/e2e/ui/tests/users/searchUsers.spec.ts @@ -17,7 +17,7 @@ test.describe("Internal Users Search", () => { test("narrows the table to the matching email, and restores it when cleared", async ({ page }) => { await goToInternalUsers(page); - const search = page.getByPlaceholder("Search by email…"); + const search = page.getByPlaceholder("Search by email or ID…"); await expect(search).toBeVisible(); await search.fill("noteam@"); diff --git a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py index e7d7fdaef81..57394f1cebe 100644 --- a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py +++ b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py @@ -11,6 +11,7 @@ from litellm.caching import DualCache from litellm.proxy._types import CallTypes from litellm.proxy.openai_files_endpoints.common_utils import ( _is_base64_encoded_unified_file_id, + encode_file_id_with_model, ) @@ -3153,6 +3154,421 @@ async def test_same_user_different_keys_can_access_batch(): assert result1["batch_id"] == result2["batch_id"] +MODEL_ENCODED_BATCH_ID = encode_file_id_with_model( + "batch_provider123", "gpt-4o-team-alias", id_type="batch" +) +MODEL_ENCODED_OUTPUT_FILE_ID = encode_file_id_with_model( + "file-output456", "gpt-4o-team-alias", id_type="file" +) +RAW_PROVIDER_BATCH_ID = "batch_provider123" +RAW_PROVIDER_FILE_ID = "file-output456" + + +def _owned_record(created_by, team_id): + record = MagicMock() + record.created_by = created_by + record.team_id = team_id + return record + + +def _batch_response(batch_id, output_file_id=None, is_create=False): + from litellm.types.utils import LiteLLMBatch + + batch = LiteLLMBatch( + id=batch_id, + completion_window="24h", + created_at=1700000000, + endpoint="/v1/chat/completions", + input_file_id="file-input789", + object="batch", + status="completed", + output_file_id=output_file_id, + ) + if is_create: + batch._hidden_params["unified_file_id"] = "unified-input-file-id" + return batch + + +@pytest.mark.asyncio +@pytest.mark.parametrize("call_type", ["aretrieve_batch", "acancel_batch"]) +@pytest.mark.parametrize( + "batch_id", [MODEL_ENCODED_BATCH_ID, RAW_PROVIDER_BATCH_ID] +) +async def test_team_b_cannot_access_team_a_provider_format_batch( + call_type, batch_id +): + """ + Cross-team retrieve/cancel of a model-encoded or raw provider batch id + must 403 when an ownership row exists for another team. + + Regression test: before this check only unified (litellm_proxy-prefixed) + batch ids were enforced, so any key could read any model-encoded or raw + provider batch. + """ + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedobjecttable.find_first.return_value = ( + _owned_record(created_by="user_a", team_id="team_a") + ) + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + DualCache(), prisma_client=prisma_client + ) + + with pytest.raises(HTTPException) as exc_info: + await proxy_managed_files.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth( + user_id="user_b", team_id="team_b", parent_otel_span=MagicMock() + ), + cache=MagicMock(), + data={"batch_id": batch_id}, + call_type=call_type, + ) + + assert exc_info.value.status_code == 403 + prisma_client.db.litellm_managedobjecttable.find_first.assert_awaited_once_with( + where={"OR": [{"unified_object_id": batch_id}, {"model_object_id": batch_id}]} + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "caller_kwargs", + [ + {"user_id": "user_a", "team_id": "team_a"}, + {"user_id": "teammate_of_a", "team_id": "team_a"}, + {"user_id": "admin_user", "user_role": "proxy_admin"}, + ], +) +async def test_authorized_callers_can_access_provider_format_batch(caller_kwargs): + """ + The creator, a same-team member, and a proxy admin can all retrieve a + model-encoded batch owned by team_a. Data must pass through unmodified so + the endpoint's own routing still applies. + """ + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedobjecttable.find_first.return_value = ( + _owned_record(created_by="user_a", team_id="team_a") + ) + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + DualCache(), prisma_client=prisma_client + ) + + result = await proxy_managed_files.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth( + parent_otel_span=MagicMock(), **caller_kwargs + ), + cache=MagicMock(), + data={"batch_id": MODEL_ENCODED_BATCH_ID}, + call_type="aretrieve_batch", + ) + + assert result["batch_id"] == MODEL_ENCODED_BATCH_ID + assert "model" not in result + + +@pytest.mark.asyncio +async def test_provider_format_batch_without_ownership_row_stays_accessible(): + """ + A provider-format batch id with no ownership row (created before ownership + tracking, or directly on the provider account) must stay retrievable. + """ + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedobjecttable.find_first.return_value = None + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + DualCache(), prisma_client=prisma_client + ) + + result = await proxy_managed_files.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth( + user_id="user_b", team_id="team_b", parent_otel_span=MagicMock() + ), + cache=MagicMock(), + data={"batch_id": RAW_PROVIDER_BATCH_ID}, + call_type="aretrieve_batch", + ) + + assert result["batch_id"] == RAW_PROVIDER_BATCH_ID + + +@pytest.mark.asyncio +async def test_fine_tuning_provider_format_id_not_enforced(): + """ + Provider-format fine-tuning job ids are deliberately out of scope for + ownership enforcement; only unified fine-tuning ids are checked. + """ + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedobjecttable.find_first.return_value = ( + _owned_record(created_by="user_a", team_id="team_a") + ) + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + DualCache(), prisma_client=prisma_client + ) + + result = await proxy_managed_files.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth( + user_id="user_b", team_id="team_b", parent_otel_span=MagicMock() + ), + cache=MagicMock(), + data={"fine_tuning_job_id": "ftjob-abc123"}, + call_type="aretrieve_fine_tuning_job", + ) + + assert result["fine_tuning_job_id"] == "ftjob-abc123" + prisma_client.db.litellm_managedobjecttable.find_first.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "call_type", ["afile_content", "afile_retrieve", "afile_delete"] +) +@pytest.mark.parametrize( + "file_id", [MODEL_ENCODED_OUTPUT_FILE_ID, RAW_PROVIDER_FILE_ID] +) +async def test_team_b_cannot_access_team_a_provider_format_file( + call_type, file_id +): + """ + Cross-team content/retrieve/delete of a model-encoded or raw provider + file id must 403 when an ownership row exists for another team. + """ + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedfiletable.find_first.return_value = ( + _owned_record(created_by="user_a", team_id="team_a") + ) + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + MagicMock(), prisma_client=prisma_client + ) + + with pytest.raises(HTTPException) as exc_info: + await proxy_managed_files.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth( + user_id="user_b", team_id="team_b", parent_otel_span=MagicMock() + ), + cache=MagicMock(), + data={"file_id": file_id}, + call_type=call_type, + ) + + assert exc_info.value.status_code == 403 + prisma_client.db.litellm_managedfiletable.find_first.assert_awaited_once_with( + where={"OR": [{"unified_file_id": file_id}, {"flat_model_file_ids": {"has": file_id}}]} + ) + + +@pytest.mark.asyncio +async def test_same_team_can_access_provider_format_file(): + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedfiletable.find_first.return_value = ( + _owned_record(created_by="user_a", team_id="team_a") + ) + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + MagicMock(), prisma_client=prisma_client + ) + + result = await proxy_managed_files.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth( + user_id="teammate_of_a", team_id="team_a", parent_otel_span=MagicMock() + ), + cache=MagicMock(), + data={"file_id": MODEL_ENCODED_OUTPUT_FILE_ID}, + call_type="afile_content", + ) + + assert result["file_id"] == MODEL_ENCODED_OUTPUT_FILE_ID + + +@pytest.mark.asyncio +async def test_provider_format_file_without_ownership_row_stays_accessible(): + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedfiletable.find_first.return_value = None + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + MagicMock(), prisma_client=prisma_client + ) + + result = await proxy_managed_files.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth( + user_id="user_b", team_id="team_b", parent_otel_span=MagicMock() + ), + cache=MagicMock(), + data={"file_id": RAW_PROVIDER_FILE_ID}, + call_type="afile_content", + ) + + assert result["file_id"] == RAW_PROVIDER_FILE_ID + + +@pytest.mark.asyncio +async def test_post_call_batch_create_stores_ownership_row(): + """ + Batch creation (response hidden params carry the unified input file id) + must write an ownership row attributed to the creating key. + """ + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + MagicMock(async_set_cache=AsyncMock()), prisma_client=prisma_client + ) + + await proxy_managed_files.async_post_call_success_hook( + data={ + "input_file_id": "file-input789", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + }, + user_api_key_dict=UserAPIKeyAuth( + user_id="user_a", team_id="team_a", parent_otel_span=MagicMock() + ), + response=_batch_response(MODEL_ENCODED_BATCH_ID, is_create=True), + ) + + upsert_call = prisma_client.db.litellm_managedobjecttable.upsert.await_args + assert upsert_call.kwargs["where"] == { + "unified_object_id": MODEL_ENCODED_BATCH_ID + } + create_data = upsert_call.kwargs["data"]["create"] + assert create_data["created_by"] == "user_a" + assert create_data["team_id"] == "team_a" + prisma_client.db.litellm_managedobjecttable.update_many.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_post_call_batch_sync_does_not_claim_ownership(): + """ + Retrieve/cancel of a batch with no ownership row must NOT create one: + otherwise the first foreign key to touch a legacy batch would become its + owner and lock out the real creator once enforcement is on. + """ + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedobjecttable.update_many.return_value = 0 + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + MagicMock(async_set_cache=AsyncMock()), prisma_client=prisma_client + ) + + await proxy_managed_files.async_post_call_success_hook( + data={"batch_id": MODEL_ENCODED_BATCH_ID}, + user_api_key_dict=UserAPIKeyAuth( + user_id="user_b", team_id="team_b", parent_otel_span=MagicMock() + ), + response=_batch_response(MODEL_ENCODED_BATCH_ID), + ) + + prisma_client.db.litellm_managedobjecttable.upsert.assert_not_awaited() + prisma_client.db.litellm_managedobjecttable.update_many.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_post_call_batch_sync_updates_existing_row(): + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedobjecttable.update_many.return_value = 1 + prisma_client.db.litellm_managedobjecttable.find_first.return_value = ( + _owned_record(created_by="user_a", team_id="team_a") + ) + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + MagicMock(async_set_cache=AsyncMock()), prisma_client=prisma_client + ) + + await proxy_managed_files.async_post_call_success_hook( + data={"batch_id": MODEL_ENCODED_BATCH_ID}, + user_api_key_dict=UserAPIKeyAuth( + user_id="user_a", team_id="team_a", parent_otel_span=MagicMock() + ), + response=_batch_response(MODEL_ENCODED_BATCH_ID), + ) + + update_call = prisma_client.db.litellm_managedobjecttable.update_many.await_args + assert update_call.kwargs["where"] == { + "unified_object_id": MODEL_ENCODED_BATCH_ID + } + assert update_call.kwargs["data"]["status"] == "completed" + prisma_client.db.litellm_managedobjecttable.upsert.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_post_call_batch_sync_stores_output_file_ownership_from_batch_row(): + """ + When a synced batch reports a provider-format output file id, an + ownership row for that file must be written with the BATCH row's + created_by/team_id, not the caller's identity. + """ + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedobjecttable.update_many.return_value = 1 + prisma_client.db.litellm_managedobjecttable.find_first.return_value = ( + _owned_record(created_by="user_a", team_id="team_a") + ) + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + MagicMock(async_set_cache=AsyncMock()), prisma_client=prisma_client + ) + + await proxy_managed_files.async_post_call_success_hook( + data={"batch_id": MODEL_ENCODED_BATCH_ID}, + user_api_key_dict=UserAPIKeyAuth( + user_id="admin_user", + user_role="proxy_admin", + parent_otel_span=MagicMock(), + ), + response=_batch_response( + MODEL_ENCODED_BATCH_ID, output_file_id=MODEL_ENCODED_OUTPUT_FILE_ID + ), + ) + + file_upsert = prisma_client.db.litellm_managedfiletable.upsert.await_args + assert file_upsert.kwargs["where"] == { + "unified_file_id": MODEL_ENCODED_OUTPUT_FILE_ID + } + create_data = file_upsert.kwargs["data"]["create"] + assert create_data["created_by"] == "user_a" + assert create_data["team_id"] == "team_a" + assert create_data["flat_model_file_ids"] == ["file-output456"] + + +@pytest.mark.asyncio +async def test_post_call_batch_create_does_not_store_output_file_ownership(): + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + MagicMock(async_set_cache=AsyncMock()), prisma_client=prisma_client + ) + + await proxy_managed_files.async_post_call_success_hook( + data={ + "input_file_id": "file-input789", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + }, + user_api_key_dict=UserAPIKeyAuth( + user_id="user_a", team_id="team_a", parent_otel_span=MagicMock() + ), + response=_batch_response( + MODEL_ENCODED_BATCH_ID, + output_file_id=MODEL_ENCODED_OUTPUT_FILE_ID, + is_create=True, + ), + ) + + prisma_client.db.litellm_managedfiletable.upsert.assert_not_awaited() + + @pytest.mark.asyncio async def test_file_list_cursors_are_scoped_to_the_caller(): """A non-owner must not learn other callers' file ids through the page cursors.""" diff --git a/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py b/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py index a0a26c089eb..fd1b05ff060 100644 --- a/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py +++ b/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta -from unittest.mock import AsyncMock, patch +from typing import Final +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI @@ -8,10 +9,12 @@ from litellm_enterprise.proxy.audit_logging_endpoints import router as audit_rou from litellm_enterprise.types.proxy.audit_logging_endpoints import AuditLogResponse from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth # Create an app with just the audit router for testing app = FastAPI() app.include_router(audit_router) +app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role="proxy_admin") client = TestClient(app) # Mock data for testing @@ -130,3 +133,45 @@ async def test_get_audit_log_by_id_not_found(mock_prisma_client): data = response.json() assert "message" in data["detail"] assert "not found" in data["detail"]["message"].lower() + + +def _list_audit_logs_where(mock_prisma_client: MagicMock, query: str) -> dict[str, object]: + mock_prisma_client.db.litellm_auditlog.find_many.return_value = [] + mock_prisma_client.db.litellm_auditlog.count.return_value = 0 + + response: Final = client.get(f"/audit?{query}") + + assert response.status_code == 200, response.text + find_many_where: Final = mock_prisma_client.db.litellm_auditlog.find_many.call_args.kwargs["where"] + assert mock_prisma_client.db.litellm_auditlog.count.call_args.kwargs["where"] == find_many_where + return find_many_where + + +def test_search_matches_any_id_column_alongside_the_other_filters(mock_prisma_client): + where: Final = _list_audit_logs_where(mock_prisma_client, "search=abc-123&action=create&object_team_id=team-1") + + assert where == { + "action": "create", + "AND": ( + { + "OR": [ + {"before_value": {"path": ["team_id"], "string_contains": "team-1"}}, + {"updated_values": {"path": ["team_id"], "string_contains": "team-1"}}, + ] + }, + { + "OR": ( + {"id": "abc-123"}, + {"changed_by": "abc-123"}, + {"object_id": "abc-123"}, + {"changed_by_api_key": "abc-123"}, + ) + }, + ), + } + + +def test_an_empty_search_leaves_the_where_clause_unchanged(mock_prisma_client): + where: Final = _list_audit_logs_where(mock_prisma_client, "action=create&search=") + + assert where == {"action": "create"} diff --git a/tests/guardrails_tests/test_tracing_guardrails.py b/tests/guardrails_tests/test_tracing_guardrails.py index bd8b7bad33f..ac85803ba39 100644 --- a/tests/guardrails_tests/test_tracing_guardrails.py +++ b/tests/guardrails_tests/test_tracing_guardrails.py @@ -806,3 +806,91 @@ def test_guardrail_status_fields_computation(): ) assert status_fields_no_guardrail.get("llm_api_status") == "success" assert status_fields_no_guardrail.get("guardrail_status") == "not_run" + + +@pytest.mark.parametrize( + "status, guardrail_information, expected_guardrail_status", + [ + pytest.param( + "failure", + [ + {"guardrail_status": "success"}, + {"guardrail_status": "guardrail_intervened"}, + ], + "guardrail_intervened", + id="pre_call_success_before_blocker", + ), + pytest.param( + "failure", + [ + {"guardrail_status": "guardrail_intervened"}, + {"guardrail_status": "success"}, + ], + "guardrail_intervened", + id="blocker_before_success", + ), + pytest.param( + "failure", + [ + {"guardrail_status": "success"}, + {"guardrail_status": "guardrail_failed_to_respond"}, + ], + "guardrail_failed_to_respond", + id="failure_outranks_success", + ), + pytest.param( + "failure", + [ + {"guardrail_status": "guardrail_failed_to_respond"}, + {"guardrail_status": "guardrail_intervened"}, + ], + "guardrail_intervened", + id="intervention_outranks_failure", + ), + pytest.param( + "success", + [ + {"guardrail_status": "success"}, + {"guardrail_status": "success"}, + ], + "success", + id="all_success_stays_success", + ), + pytest.param( + "failure", + [ + {"guardrail_status": "some_new_status"}, + {"guardrail_status": "blocked"}, + ], + "guardrail_intervened", + id="unknown_status_does_not_mask_blocker", + ), + pytest.param( + "failure", + [ + {"guardrail_status": {"unhashable": True}}, + {"guardrail_status": "guardrail_intervened"}, + ], + "guardrail_intervened", + id="unhashable_status_is_skipped", + ), + ], +) +def test_guardrail_status_fields_severity_across_entries( + status, guardrail_information, expected_guardrail_status +): + """ + A blocked request must never be reported as a guardrail success. + + With multiple guardrails on one request (e.g. a pre_call mask that passes, + then a post_call guardrail that blocks), entries are recorded in execution + order, so the earlier "success" entry must not shadow the later + "guardrail_intervened" entry: the aggregate takes the most severe status, + regardless of entry order. + """ + from litellm.litellm_core_utils.litellm_logging import _get_status_fields + + fields = _get_status_fields( + status=status, guardrail_information=guardrail_information, error_str=None + ) + assert fields.get("guardrail_status") == expected_guardrail_status diff --git a/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py b/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py index 22558faedad..3fab20a28ad 100644 --- a/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py +++ b/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py @@ -567,7 +567,7 @@ class TestResolveAllMigrationsLedger: return _FakeCompleted() return _FakeCompleted() - monkeypatch.setattr(utils_module.subprocess, "run", fake_run) + monkeypatch.setattr(utils_module.prisma_toolchain, "run_prisma", fake_run) ProxyExtrasDBManager._resolve_all_migrations(str(tmp_path), "schema.prisma") return calls @@ -604,9 +604,9 @@ class TestPartitionedSpendLogsPushGuard: import litellm_proxy_extras.utils as utils_module def fail_run(cmd, **kwargs): - raise AssertionError(f"subprocess.run should not be called, got: {cmd}") + raise AssertionError(f"run_prisma should not be called, got: {cmd}") - monkeypatch.setattr(utils_module.subprocess, "run", fail_run) + monkeypatch.setattr(utils_module.prisma_toolchain, "run_prisma", fail_run) def test_v1_db_push_fails_fast_with_guidance(self, monkeypatch): monkeypatch.setattr( @@ -763,7 +763,7 @@ class _MigrateDeployHarness: "_resolve_specific_migration", staticmethod(self.resolved.append), ) - monkeypatch.setattr(utils_module.subprocess, "run", self._fake_run) + monkeypatch.setattr(utils_module.prisma_toolchain, "run_prisma", self._fake_run) monkeypatch.setattr(utils_module.time, "sleep", lambda seconds: None) self.baseline_succeeds = True diff --git a/tests/load_tests/test_granian_admission_saturation.py b/tests/load_tests/test_granian_admission_saturation.py new file mode 100644 index 00000000000..b42c06037c2 --- /dev/null +++ b/tests/load_tests/test_granian_admission_saturation.py @@ -0,0 +1,150 @@ +import asyncio +import os +import socket +import subprocess +import sys +import time +from pathlib import Path +from typing import Final + +import httpx +import pytest + +pytestmark = pytest.mark.skipif( + os.environ.get("LITELLM_RUN_SATURATION_BENCHMARK") != "1", + reason="set LITELLM_RUN_SATURATION_BENCHMARK=1 to run the saturation benchmark", +) + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def _percentile(values: list[float], percentile: float) -> float: + return sorted(values)[min(int(len(values) * percentile), len(values) - 1)] + + +@pytest.mark.asyncio +async def test_granian_admission_control_saturation(tmp_path: Path) -> None: + fake_port: Final = _free_port() + proxy_port: Final = _free_port() + fake_script: Final = Path(__file__).parents[1] / "_fake_openai_endpoint_server.py" + config_path: Final = tmp_path / "saturation_config.yaml" + config_path.write_text( + f"""model_list: + - model_name: slow-endpoint + litellm_params: + model: openai/slow-endpoint + api_base: http://127.0.0.1:{fake_port}/v1 +general_settings: + master_key: sk-saturation + max_in_flight_requests_per_worker: 8 + max_queued_requests_per_worker: 8 + admission_queue_timeout_seconds: 0.5 +""" + ) + fake_process: Final = subprocess.Popen( + [sys.executable, str(fake_script), "--host", "127.0.0.1", "--port", str(fake_port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + proxy_process: Final = subprocess.Popen( + [ + sys.executable, + "-m", + "litellm.proxy.proxy_cli", + "--config", + str(config_path), + "--run_granian", + "--num_workers", + "1", + "--port", + str(proxy_port), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + async with httpx.AsyncClient(base_url=f"http://127.0.0.1:{proxy_port}") as client: + deadline: Final = time.monotonic() + 60 + while time.monotonic() < deadline: + try: + response: Final = await client.get("/health/liveliness", timeout=2) + if response.status_code == 200: + break + except httpx.HTTPError: + pass + await asyncio.sleep(0.25) + else: + raise AssertionError("Granian proxy did not become healthy") + + liveness_latencies: Final[list[float]] = [] + stop_sampling: Final = asyncio.Event() + + async def sample_liveness() -> None: + while not stop_sampling.is_set(): + start: Final = time.perf_counter() + try: + response = await client.get("/health/liveliness", timeout=2) + response.raise_for_status() + liveness_latencies.append(time.perf_counter() - start) + except httpx.HTTPError: + pass + await asyncio.sleep(0.05) + + async def send_completion() -> tuple[int, float, bool]: + start: Final = time.perf_counter() + response = await client.post( + "/chat/completions", + headers={"Authorization": "Bearer sk-saturation"}, + json={ + "model": "slow-endpoint", + "messages": [{"role": "user", "content": "hello"}], + }, + timeout=10, + ) + return response.status_code, time.perf_counter() - start, "retry-after" in response.headers + + sampler: Final = asyncio.create_task(sample_liveness()) + results: Final = await asyncio.gather(*(send_completion() for _ in range(200))) + stop_sampling.set() + await sampler + + statuses: Final = [result[0] for result in results] + latencies: Final = [result[1] for result in results] + rejected: Final = [result for result in results if result[0] == 503] + assert set(statuses) <= {200, 503} + assert rejected + assert all(result[2] for result in rejected) + assert _percentile(latencies, 0.99) < 5 + assert liveness_latencies + assert _percentile(liveness_latencies, 0.95) < 0.5 + + duration: Final = max(latencies) + print( + "\nmetric value\n" + f"rps {len(results) / duration:.2f}\n" + f"200 count {statuses.count(200)}\n" + f"503 count {statuses.count(503)}\n" + f"p50 {_percentile(latencies, 0.50):.3f}s\n" + f"p95 {_percentile(latencies, 0.95):.3f}s\n" + f"p99 {_percentile(latencies, 0.99):.3f}s\n" + f"liveness p95 {_percentile(liveness_latencies, 0.95):.3f}s" + ) + finally: + proxy_process.terminate() + try: + proxy_process.wait(timeout=10) + except subprocess.TimeoutExpired: + proxy_process.kill() + proxy_process.wait() + finally: + fake_process.terminate() + try: + fake_process.wait(timeout=10) + except subprocess.TimeoutExpired: + fake_process.kill() + fake_process.wait() diff --git a/tests/local_testing/test_dual_cache.py b/tests/local_testing/test_dual_cache.py index e60fa5f3746..43b10a9557a 100644 --- a/tests/local_testing/test_dual_cache.py +++ b/tests/local_testing/test_dual_cache.py @@ -240,3 +240,35 @@ async def test_dual_cache_delete(is_async): result = dual_cache.get_cache(test_key) assert result is None + + +@pytest.mark.asyncio +async def test_dual_cache_concurrent_sync_and_async_redis_reads(): + """Sync and async batch reads share one Redis backend in one process, and sync reads never open an async connection""" + redis_cache = RedisCache(host=os.getenv("REDIS_HOST"), port=os.getenv("REDIS_PORT")) + dual_cache = DualCache(redis_cache=redis_cache) + + run_id = str(uuid.uuid4()) + sync_keys = [f"sync_{run_id}_{index}" for index in range(5)] + async_keys = [f"async_{run_id}_{index}" for index in range(5)] + in_loop_keys = [f"in_loop_{run_id}_{index}" for index in range(3)] + survivor_key = f"survivor_{run_id}" + expected = {key: {"key": key} for key in [*sync_keys, *async_keys, *in_loop_keys, survivor_key]} + for key, value in expected.items(): + await redis_cache.async_set_cache(key, value, ttl=60) + + concurrent_results = await asyncio.gather( + *(asyncio.to_thread(dual_cache.batch_get_cache, keys=[key]) for key in sync_keys), + *(dual_cache.async_batch_get_cache(keys=[key]) for key in async_keys), + ) + assert list(concurrent_results) == [[expected[key]] for key in [*sync_keys, *async_keys]] + + with patch.object( + redis_cache, + "async_batch_get_cache", + side_effect=AssertionError("sync batch reads must not call async Redis"), + ): + in_loop_results = [dual_cache.batch_get_cache(keys=[key]) for key in in_loop_keys] + + assert in_loop_results == [[expected[key]] for key in in_loop_keys] + assert await dual_cache.async_batch_get_cache(keys=[survivor_key]) == [expected[survivor_key]] diff --git a/tests/openai_endpoints_tests/test_e2e_openai_responses_api.py b/tests/openai_endpoints_tests/test_e2e_openai_responses_api.py index abae26e02cd..7e338dafb86 100644 --- a/tests/openai_endpoints_tests/test_e2e_openai_responses_api.py +++ b/tests/openai_endpoints_tests/test_e2e_openai_responses_api.py @@ -1,6 +1,13 @@ +import time +from collections.abc import Iterator +from typing import Final + import httpx -from openai import OpenAI, BadRequestError, NotFoundError, APIStatusError import pytest +from openai import APIStatusError, BadRequestError, NotFoundError, OpenAI, Stream +from openai.types.responses import ResponseStreamEvent + +BACKGROUND_STREAM_ADMISSION_DEADLINE_SECONDS: Final = 90 def generate_key(): @@ -153,43 +160,48 @@ def test_cancel_response(): raise e +def admitted_response_id(chunk: ResponseStreamEvent) -> str | None: + response: Final = getattr(chunk, "response", None) + return None if response is None else response.id + + +def events_until_admission(stream: Stream[ResponseStreamEvent], started: float) -> Iterator[ResponseStreamEvent]: + for chunk in stream: + print("stream chunk=", chunk) + yield chunk + if admitted_response_id(chunk) is not None: + return + if time.monotonic() - started > BACKGROUND_STREAM_ADMISSION_DEADLINE_SECONDS: + return + + def test_cancel_streaming_response(): - try: - client = get_test_client() - from litellm.types.llms.openai import ResponsesAPIResponse + client: Final = get_test_client() + started: Final = time.monotonic() + stream: Final = client.responses.create( + model="gpt-5.5", + input="count from 1 to 500, one number per line", + stream=True, + background=True, + timeout=BACKGROUND_STREAM_ADMISSION_DEADLINE_SECONDS, + ) - stream = client.responses.create( - model="gpt-5.5", - input="just respond with the word 'ping'", - stream=True, - background=True, + with stream: + events: Final = tuple(events_until_admission(stream, started)) + + elapsed: Final = time.monotonic() - started + keepalive_events: Final = sum(1 for chunk in events if chunk.type == "keepalive") + response_id: Final = next((rid for rid in map(admitted_response_id, events) if rid is not None), None) + if response_id is None and keepalive_events: + pytest.skip( + f"OpenAI held the background stream in keepalive for {elapsed:.0f}s " + f"({keepalive_events} keepalive events) without creating the response" ) + assert response_id is not None, f"no response event within {elapsed:.0f}s of streaming a background response" - collected_chunks = [] - response_id = None - for chunk in stream: - print("stream chunk=", chunk) - collected_chunks.append(chunk) - # Extract response ID from the first chunk that has it - if ( - response_id is None - and hasattr(chunk, "response") - and hasattr(chunk.response, "id") - ): - response_id = chunk.response.id - - assert len(collected_chunks) > 0 - - # cancel the response if we got a response ID - if response_id: - cancel_response = client.responses.cancel(response_id) - print("CANCEL streaming response=", cancel_response) - assert hasattr(cancel_response, "id") - except Exception as e: - if "Cannot cancel a completed response" in str(e): - pass - else: - raise e + cancel_response: Final = client.responses.cancel(response_id) + print("CANCEL streaming response=", cancel_response) + assert cancel_response.status == "cancelled" def test_cancel_invalid_response_id(): diff --git a/tests/pass_through_tests/test_anthropic_passthrough.py b/tests/pass_through_tests/test_anthropic_passthrough.py index d42e06937dc..0452b171f9e 100644 --- a/tests/pass_through_tests/test_anthropic_passthrough.py +++ b/tests/pass_through_tests/test_anthropic_passthrough.py @@ -50,9 +50,9 @@ async def test_anthropic_basic_completion_with_headers(): anthropic_api_output_tokens = ( reported_usage.get("output_tokens", None) if reported_usage else None ) - litellm_call_id = response_headers.get("x-litellm-call-id") + anthropic_message_id = response_json.get("id") - print(f"LiteLLM Call ID: {litellm_call_id}") + print(f"Anthropic message ID: {anthropic_message_id}") # Wait for spend to be logged await asyncio.sleep(15) @@ -64,7 +64,7 @@ async def test_anthropic_basic_completion_with_headers(): print(f"Attempt {attempt + 1}/{max_retries} to check spend logs") async with session.get( - f"http://0.0.0.0:4000/spend/logs?request_id={litellm_call_id}", + f"http://0.0.0.0:4000/spend/logs?request_id={anthropic_message_id}", headers={"Authorization": "Bearer sk-1234"}, ) as spend_response: print("text spend response") @@ -84,25 +84,25 @@ async def test_anthropic_basic_completion_with_headers(): print("Waiting 10 seconds before retry...") await asyncio.sleep(10) - # Spend data might be unavailable (auth error, slow DB write, etc.) - if ( - spend_data is None - or not isinstance(spend_data, list) - or len(spend_data) == 0 - or not isinstance(spend_data[0], dict) - or "request_id" not in spend_data[0] - ): - print(f"Spend data not available or is error response: {spend_data}") - print("Skipping spend assertions (DB write may be slow in CI)") + if not isinstance(spend_data, list): + print(f"Spend endpoint answered with an error response: {spend_data}") + print("Skipping spend assertions (spend logs unreachable in CI)") return + assert spend_data, ( + f"GET /spend/logs?request_id={anthropic_message_id} found no row for the id " + "the caller received" + ) + log_entry = spend_data[0] # Basic existence checks assert isinstance(log_entry, dict), "Log entry should be a dictionary" # Request metadata assertions - assert log_entry["request_id"] == litellm_call_id, "Request ID should match" + assert ( + log_entry["request_id"] == anthropic_message_id + ), "Request ID should be the message id the caller received" assert ( log_entry["call_type"] == "pass_through_endpoint" ), "Call type should be pass_through_endpoint" @@ -182,8 +182,6 @@ async def test_anthropic_streaming_with_headers(): assert response.status == 200, "Response should be successful" response_headers = response.headers print(f"Response headers: {response_headers}") - litellm_call_id = response_headers.get("x-litellm-call-id") - print(f"LiteLLM Call ID: {litellm_call_id}") collected_output = [] async for line in response.content: @@ -194,13 +192,18 @@ async def test_anthropic_streaming_with_headers(): print("Collected output:", "".join(collected_output)) anthropic_api_usage_chunks = [] + anthropic_message_id = None for chunk in collected_output: chunk_json = json.loads(chunk) + if chunk_json.get("type") == "message_start": + anthropic_message_id = chunk_json.get("message", {}).get("id") if "usage" in chunk_json: anthropic_api_usage_chunks.append(chunk_json["usage"]) elif "message" in chunk_json and "usage" in chunk_json["message"]: anthropic_api_usage_chunks.append(chunk_json["message"]["usage"]) + print(f"Anthropic message ID: {anthropic_message_id}") + print( "anthropic_api_usage_chunks", json.dumps(anthropic_api_usage_chunks, indent=4, default=str), @@ -232,7 +235,7 @@ async def test_anthropic_streaming_with_headers(): print(f"Attempt {attempt + 1}/{max_retries} to check spend logs") async with session.get( - f"http://0.0.0.0:4000/spend/logs?request_id={litellm_call_id}", + f"http://0.0.0.0:4000/spend/logs?request_id={anthropic_message_id}", headers={"Authorization": "Bearer sk-1234"}, ) as spend_response: spend_data = await spend_response.json() @@ -250,25 +253,25 @@ async def test_anthropic_streaming_with_headers(): print("Waiting 10 seconds before retry...") await asyncio.sleep(10) - # Spend data might be unavailable (auth error, slow DB write, etc.) - if ( - spend_data is None - or not isinstance(spend_data, list) - or len(spend_data) == 0 - or not isinstance(spend_data[0], dict) - or "request_id" not in spend_data[0] - ): - print(f"Spend data not available or is error response: {spend_data}") - print("Skipping spend assertions (DB write may be slow in CI)") + if not isinstance(spend_data, list): + print(f"Spend endpoint answered with an error response: {spend_data}") + print("Skipping spend assertions (spend logs unreachable in CI)") return + assert spend_data, ( + f"GET /spend/logs?request_id={anthropic_message_id} found no row for the id " + "the caller received" + ) + log_entry = spend_data[0] # Basic existence checks assert isinstance(log_entry, dict), "Log entry should be a dictionary" # Request metadata assertions - assert log_entry["request_id"] == litellm_call_id, "Request ID should match" + assert ( + log_entry["request_id"] == anthropic_message_id + ), "Request ID should be the message id the caller received" assert ( log_entry["call_type"] == "pass_through_endpoint" ), "Call type should be pass_through_endpoint" diff --git a/tests/proxy_admin_ui_tests/test_team_delete_member_add_race.py b/tests/proxy_admin_ui_tests/test_team_delete_member_add_race.py index 7577570be48..d3ffcf2445e 100644 --- a/tests/proxy_admin_ui_tests/test_team_delete_member_add_race.py +++ b/tests/proxy_admin_ui_tests/test_team_delete_member_add_race.py @@ -9,14 +9,16 @@ be forced by a sequential script: it needs one request to be genuinely mid-fligh other commits. A mocked prisma cannot arbitrate that either, since the property under test is whether Postgres's own advisory lock actually serializes the two requests. -These tests pin the interleaving the same way test_access_group_team_sync.py does: a second -real connection holds the team's advisory lock in its own transaction, so the function under -test is provably blocked on it rather than hoping a sleep lands in the right gap. +These tests pin the interleaving without a timing assumption: a second real connection holds +the team's advisory lock in its own transaction, and the test then waits for Postgres itself +to report the endpoint queued behind that exact lock. A sleep can only guess whether the +endpoint has reached the lock yet; pg_locks answers it. """ import asyncio import json import os +import time import uuid from contextlib import asynccontextmanager from datetime import timedelta @@ -39,6 +41,47 @@ _DELETE_SEEDED = 'DELETE FROM "LiteLLM_TeamMembership" WHERE team_id = $1' _DELETE_USER = 'DELETE FROM "LiteLLM_UserTable" WHERE user_id = $1' _DELETE_TEAM = 'DELETE FROM "LiteLLM_TeamTable" WHERE team_id = $1' _LOCK_SQL = "SELECT pg_advisory_xact_lock(hashtext($1)) IS NULL AS locked" +_HELD_LOCK_KEY_SQL = ( + "SELECT classid::bigint AS classid, objid::bigint AS objid FROM pg_locks " + "WHERE locktype = 'advisory' AND granted AND pid = pg_backend_pid()" +) +_LOCK_WAITER_SQL = ( + "SELECT count(*)::int AS waiters FROM pg_locks " + "WHERE locktype = 'advisory' AND NOT granted " + "AND classid::bigint = $1 AND objid::bigint = $2" +) +_LOCK_WAIT_TIMEOUT_SECONDS = 20.0 +_LOCK_POLL_SECONDS = 0.01 + + +async def _hold_team_lock(held, team_id: str) -> tuple[int, int]: + """Take the team's advisory lock and return its pg_locks key. + + Reading the key back off our own backend avoids re-deriving hashtext()'s signed + 32-bit split here, and pins the watcher to this lock rather than to any advisory + lock another xdist worker happens to hold on the same database.""" + await held.query_raw(_LOCK_SQL, team_id) + rows = await held.query_raw(_HELD_LOCK_KEY_SQL) + assert len(rows) == 1, f"expected exactly one advisory lock on the blocking connection, got {rows}" + return rows[0]["classid"], rows[0]["objid"] + + +async def _await_lock_contention(watcher, lock_key: tuple[int, int], task, what: str) -> None: + """Block until Postgres reports `task` queued behind the held lock. + + This is the assertion that the endpoint serializes on the team's advisory lock, and it + is what a fixed sleep was standing in for: the endpoint is only provably waiting once a + non-granted advisory lock on the same key exists. `watcher` must be a connection that is + not itself blocked, so it can observe the queue.""" + classid, objid = lock_key + deadline = time.monotonic() + _LOCK_WAIT_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if task.done(): + raise AssertionError(f"{what} returned without waiting on the team's advisory lock") from task.exception() + if (await watcher.query_raw(_LOCK_WAITER_SQL, classid, objid))[0]["waiters"]: + return + await asyncio.sleep(_LOCK_POLL_SECONDS) + raise AssertionError(f"{what} never queued on the team's advisory lock within {_LOCK_WAIT_TIMEOUT_SECONDS}s") def _race_ids() -> tuple[str, str]: @@ -110,10 +153,8 @@ async def test_member_add_blocked_by_delete_writes_no_dangling_reference(): blocker = Prisma() await blocker.connect() - lock_acquired = asyncio.Event() async def add_member(): - lock_acquired.set() await _add_team_members_to_team( data=TeamMemberAddRequest( team_id=team_id, @@ -128,11 +169,9 @@ async def test_member_add_blocked_by_delete_writes_no_dangling_reference(): try: async with blocker.tx(timeout=timedelta(seconds=30)) as held: - await held.query_raw(_LOCK_SQL, team_id) + lock_key = await _hold_team_lock(held, team_id) task = asyncio.create_task(add_member()) - await lock_acquired.wait() - await asyncio.sleep(0.2) - assert not task.done(), "member_add did not wait on the team's advisory lock" + await _await_lock_contention(db, lock_key, task, "member_add") # the delete wins the race: strip the team row while the lock is held await held.execute_raw(_DELETE_TEAM, team_id) @@ -185,10 +224,8 @@ async def test_member_delete_blocked_by_member_add_removes_from_the_fresh_roster blocker = Prisma() await blocker.connect() - lock_acquired = asyncio.Event() async def run_delete(): - lock_acquired.set() return await team_member_delete( data=TeamMemberDeleteRequest(team_id=team_id, user_id=user_id), user_api_key_dict=_admin_auth(), @@ -196,11 +233,9 @@ async def test_member_delete_blocked_by_member_add_removes_from_the_fresh_roster try: async with blocker.tx(timeout=timedelta(seconds=30)) as held: - await held.query_raw(_LOCK_SQL, team_id) + lock_key = await _hold_team_lock(held, team_id) task = asyncio.create_task(run_delete()) - await lock_acquired.wait() - await asyncio.sleep(0.2) - assert not task.done(), "member_delete did not wait on the team's advisory lock" + await _await_lock_contention(db, lock_key, task, "member_delete") # member_add wins the race: it adds `other_user` while holding the lock await held.litellm_teamtable.update( @@ -265,10 +300,8 @@ async def test_delete_blocked_by_member_add_sweeps_the_fresh_reference(): blocker = Prisma() await blocker.connect() - lock_acquired = asyncio.Event() async def run_delete(): - lock_acquired.set() return await delete_team( data=DeleteTeamRequest(team_ids=[team_id]), http_request=MagicMock(), @@ -278,11 +311,9 @@ async def test_delete_blocked_by_member_add_sweeps_the_fresh_reference(): try: async with blocker.tx(timeout=timedelta(seconds=30)) as held: - await held.query_raw(_LOCK_SQL, team_id) + lock_key = await _hold_team_lock(held, team_id) task = asyncio.create_task(run_delete()) - await lock_acquired.wait() - await asyncio.sleep(0.3) - assert not task.done(), "delete_team did not wait on the team's advisory lock" + await _await_lock_contention(db, lock_key, task, "delete_team") # member_add wins the race: write the reference while holding the lock await held.litellm_usertable.upsert( diff --git a/tests/proxy_migration_tests/test_prisma_toolchain.py b/tests/proxy_migration_tests/test_prisma_toolchain.py index 733870f3239..0ed33193a9b 100644 --- a/tests/proxy_migration_tests/test_prisma_toolchain.py +++ b/tests/proxy_migration_tests/test_prisma_toolchain.py @@ -18,6 +18,7 @@ import ast import json import logging import os +import signal import sys import time from collections.abc import Callable @@ -47,6 +48,7 @@ FAKE_PRISMA = """#!{python} import json import os import pathlib +import subprocess import sys import time @@ -66,6 +68,9 @@ with log_path.open("a") as log: time.sleep(float(os.environ.get("FAKE_PRISMA_SLEEP", "0"))) if args[:2] == ["migrate", "deploy"]: if earlier_same_command == 0: + if os.environ.get("FAKE_PRISMA_GRANDCHILD_PIDFILE"): + grandchild = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(600)"]) + pathlib.Path(os.environ["FAKE_PRISMA_GRANDCHILD_PIDFILE"]).write_text(str(grandchild.pid)) time.sleep(float(os.environ.get("FAKE_PRISMA_FIRST_DEPLOY_SLEEP", "0"))) elif os.environ.get("FAKE_PRISMA_LATER_DEPLOY_STDERR"): print(os.environ["FAKE_PRISMA_LATER_DEPLOY_STDERR"], file=sys.stderr) @@ -272,6 +277,43 @@ def test_migrate_deploy_stops_at_its_own_timeout( assert elapsed < 30 +def _process_is_gone(pid: int, within_seconds: float) -> bool: + deadline = time.monotonic() + within_seconds + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + time.sleep(0.05) + return False + + +def test_a_timed_out_migrate_deploy_takes_its_process_tree_with_it( + toolchain_env: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The real CLI forks Node and a schema engine; a timeout must not leave them running.""" + _, log_path = toolchain_env + pidfile = tmp_path / "grandchild.pid" + monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x") + monkeypatch.setenv(PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR, "1") + monkeypatch.setenv("FAKE_PRISMA_FIRST_DEPLOY_SLEEP", "60") + monkeypatch.setenv("FAKE_PRISMA_LATER_DEPLOY_STDERR", "Error: P3018 permission denied for schema public") + monkeypatch.setenv("FAKE_PRISMA_GRANDCHILD_PIDFILE", str(pidfile)) + + with pytest.raises(RuntimeError, match="insufficient permissions"): + ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True) + + grandchild_pid = int(pidfile.read_text()) + try: + assert len(_deploy_calls(log_path)) == 2 + assert _process_is_gone(grandchild_pid, within_seconds=5) + finally: + try: + os.kill(grandchild_pid, signal.SIGKILL) + except ProcessLookupError: + pass + + def test_db_push_timeout_hint_names_the_per_command_budget( toolchain_env: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/rust-python-harness/AGENTS.md b/tests/rust-python-harness/AGENTS.md index e9b17027ddc..017668d4289 100644 --- a/tests/rust-python-harness/AGENTS.md +++ b/tests/rust-python-harness/AGENTS.md @@ -3,42 +3,75 @@ ```text tests/rust-python-harness/ ├── __main__.py +├── cli/ +│ ├── __init__.py +│ ├── catalog.py +│ └── commands.py │ ├── strategies/ │ ├── e2e_parity/ -│ │ ├── runner.py +│ │ ├── __init__.py +│ │ ├── reporting.py │ │ ├── sdk/ -│ │ │ ├── ocr/ -│ │ │ ├── messages/ -│ │ │ ├── chat_completions/ -│ │ │ └── responses/ -│ │ └── gateway/ +│ │ │ └── ocr/ │ │ │ ├── trace_parity/ -│ │ ├── runner.py -│ │ ├── sdk/ -│ │ └── gateway/ +│ │ ├── __init__.py +│ │ ├── models.py +│ │ ├── reporting.py +│ │ └── sdk/ +│ │ ├── chat_completions/ +│ │ ├── messages/ +│ │ ├── ocr/ +│ │ └── transcription/ │ │ -│ └── unit_tests/ -│ ├── runner.py -│ ├── mapping_validator.py -│ ├── python_runner.py -│ └── rust_runner.py +│ ├── unit_tests_mapping/ +│ │ ├── __init__.py +│ │ ├── contracts.py +│ │ ├── cases/ +│ │ │ └── ocr.py +│ │ ├── mapping_report.py +│ │ ├── mappings.py +│ │ ├── mapping_validator.py +│ │ ├── reporting.py +│ │ └── runner.py +│ │ +│ ├── unit_tests_parity/ +│ │ ├── __init__.py +│ │ ├── reporting.py +│ │ └── runner.py +│ │ +│ └── unit_tests_rust/ +│ ├── __init__.py +│ ├── reporting.py +│ └── runner.py │ └── shared/ ├── parity/ ├── tracing/ - └── reporting/ + ├── reporting/ + │ └── strategy.py + └── unit_runners/ + └── suite_runner.py ``` +- A strategy is a folder under `strategies/` with a one-line `AGENTS.md` and an `__init__.py` exporting exactly one `STRATEGY: StrategyDefinition`; its id must equal the folder name +- `shared/reporting/strategy.py` is the contract: runnable module/suite specs, not-implemented/skipped specs, the runner protocol, and `StrategyDefinition` +- Every `STRATEGY` explicitly classifies every SDK function; surface-aware strategies declare their surfaces and classify the complete surface-by-function matrix - Run locally only; no CI integration -- `__main__.py` selects strategies and combines their reports; each strategy also runs independently +- `python -m tests.rust-python-harness run |all` runs the selected strategy; `--function` is common, while each strategy exposes only its supported options +- Examples: `run e2e_parity --surface sdk --function ocr`, `run unit_tests_parity --function ocr --pytest-arg=-x`, or `run all --function ocr` +- `cli/catalog.py` discovers strategies, validates their Python definitions, and orders them; `cli/__init__.py` builds the Click command tree; `cli/commands.py` runs selected cases - `e2e_parity/` compares SDK objects, exceptions, callbacks, and streams, or gateway HTTP responses -- `trace_parity/` compares mapped operations, call counts, and required execution ordering -- E2E and trace runners share orchestration across `sdk/` and `gateway/`; surface-specific execution lives in those folders -- `unit_tests/runner.py` combines mapping validation, Python test runs, and native Rust test runs -- `mapping_validator.py` matches Python/Rust tests by agreed names or annotations and reports missing or ambiguous counterparts -- `python_runner.py` runs existing Python tests with Rust disabled and enabled in separate processes, verifies backend selection, and compares results -- `rust_runner.py` runs Cargo tests; native Rust unit tests stay beside their implementation -- `shared/` contains reusable parity, tracing, and reporting machinery +- `trace_parity/` compares mapped operations, call counts, and required execution ordering; before running it rebuilds the native bridge with the `trace-parity` feature whenever `litellm-rust` sources are newer than the installed extension (`shared/native_build.py`) +- E2E and trace strategies load their registered module cases and run surface-specific execution from their folders +- `unit_tests_mapping/contracts.py` owns typed harness-side mapping contracts, per-function contracts live below `cases/`, and `mappings.py` exports the registry; live test discovery derives unmapped Python and Rust-only tests without an exhaustive manifest +- `unit_tests_mapping/runner.py` validates confirmed mappings against the live Python and Rust inventories and attaches the derived status report +- `unit_tests_parity/runner.py` runs each contract's `unit_parity_scope` with `LITELLM_RUST=0` and `LITELLM_RUST=1` in separate processes and requires matching outcomes, including failures; exclusions require a reason in the contract +- `unit_tests_rust/runner.py` runs each contract's focused Cargo test suite; native Rust unit tests stay beside their implementation +- `shared/unit_runners/suite_runner.py` runs typed suites registered in code with nodeids of the form `suite:::` +- Every strategy declares its report sections and presentation in its own `reporting.py`; shared reporting code only provides reusable models and cell-formatting primitives +- `shared/` contains reusable parity, tracing, reporting primitives, and unit-runner machinery - Keep fixtures with their owning API and existing Python tests in their current locations +- Each strategy folder carries an `AGENTS.md` one-liner stating what it should be doing +- Run the harness's own checks with `uv run pytest -o consider_namespace_packages=true tests/rust-python-harness/shared tests/rust-python-harness/cli tests/rust-python-harness/strategies/unit_tests_mapping tests/rust-python-harness/strategies/unit_tests_parity tests/rust-python-harness/strategies/unit_tests_rust tests/test_rust_python_harness.py -q` diff --git a/tests/rust-python-harness/README.md b/tests/rust-python-harness/README.md deleted file mode 100644 index 77df4a24dd3..00000000000 --- a/tests/rust-python-harness/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# Rust/Python migration harness - -This local harness follows [the agreed structure](AGENTS.md). The root command selects strategies and combines their reports. Each strategy has an independent entry point - -```text -strategies/ - e2e_parity/runner.py - sdk/ocr/fixtures/ - sdk/messages/ - sdk/chat_completions/ - sdk/responses/ - gateway/ - existing_e2e_test_sdk/runner.py - trace_parity/runner.py - sdk/ - gateway/ - unit_tests/ - runner.py - mapping_validator.py - python_runner.py - rust_runner.py -shared/ - parity/ - tracing/ - reporting/ -``` - -## Run locally - -```bash -uv run python -m tests.rust-python-harness --list -uv run python -m tests.rust-python-harness --function ocr --plain -uv run python -m tests.rust-python-harness --strategy e2e_parity --surface sdk --function ocr --plain -uv run python -m tests.rust-python-harness.strategies.e2e_parity.runner --function ocr --plain -uv run python -m tests.rust-python-harness.strategies.trace_parity.runner --plain -uv run python -m tests.rust-python-harness.strategies.unit_tests.runner --plain -uv run python -m tests.rust-python-harness.strategies.existing_e2e_test_sdk.runner --function transcription --plain -``` - -Use `--interactive` for strategy and function selection, `--pytest-arg=-x` to stop pytest on its first failure, and `--coverage` to write Python coverage under `target/rust-python-harness/`. The harness enables pytest namespace-package discovery only for its own invocations - -This harness has no CI execution. A configured test that fails or disappears makes the command fail. An unconfigured strategy cell remains planned and contributes no passing evidence. Interruptions and collection errors stop execution; ordinary test failures remain in the combined report while later strategies run - -## Strategy responsibilities - -E2E parity compares SDK objects, exceptions, callbacks, streams, and provider requests. Gateway tests compare HTTP responses. Both surfaces use the same strategy runner and keep execution details and fixtures in their own folders. OCR has recorded sync/async SDK coverage; the existing Messages and Responses bridge checks remain partial - -Trace parity compares operation names through an explicit Python/Rust mapping, call counts, and required completion-before-start ordering with `shared/tracing/compare.py`. Surface tests supply captured operation intervals. No production trace instrumentation or trace case is configured yet - -Unit testing combines test mapping validation, separate Python processes with Rust disabled and enabled, backend verification, result comparison, and native Cargo tests. Native tests stay beside their Rust implementation. Existing Python tests stay at their original paths. No complete Python/native unit mapping is configured yet, so these cells remain planned - -The existing E2E SDK strategy retains the live provider tests configured upstream. It runs OCR, Chat Completions, and Transcription checks from their existing paths and reports them separately from parity tests. These tests require provider credentials - -## Configure cases - -Each strategy has a `strategy.json`. Its `functions` object defines SDK cases for OCR, Messages, Responses, Count Tokens, Chat Completions, and Transcription. E2E and trace manifests also accept a `gateway` object keyed by API name. A case has `coverage`, `selectors`, and an optional `note` - -```json -{ - "coverage": "partial", - "selectors": ["tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py"] -} -``` - -Selectors use pytest file or node syntax. A selector ending in `/` includes tests recursively from that directory - -Use `planned` with no selectors until an executable contract exists, `partial` for incomplete coverage, `complete` for the full contract, and `not_applicable` when a strategy does not apply. The dashboard shows passing evidence separately from coverage completeness and LOC coverage - -Unit cases use `unit_suite` instead of `selectors`, pointing to a repository-relative JSON file with this shape: - -```json -{ - "python_selectors": ["tests/test_api.py::test_decode"], - "cargo_manifest": "litellm-rust/Cargo.toml", - "cargo_package": "litellm-core", - "cargo_filter": "ocr::", - "backend": { - "environment_variable": "LITELLM_USE_RUST_OCR", - "probe": "tests.rust-python-harness.strategies.unit_tests.python_runner:ocr_backend" - }, - "mappings": [{"python": "tests/test_api.py::test_decode", "rust": "ocr::test_decode"}] -} -``` - -Names match automatically when the collected Python and Rust test names agree. Explicit `mappings` handle different names, class names, and parametrized cases. Missing or ambiguous counterparts fail validation in either direction. The Cargo filter must select the same behavior as the Python selectors - -The backend probe returns `python` or `rust` and runs at startup and before every test call, after fixtures have run. The OCR probe verifies the dispatch flag and native extension availability. Surface tests must also assert that calls reach their intended implementation to catch per-call fallback. Python outcomes must agree, and failed runs remain failures even if both backends fail identically - -## OCR fixtures - -Fixtures, provider configuration, input strategies, and recording commands live in [the OCR package](strategies/e2e_parity/sdk/ocr/fixtures/README.md). Record with provider credentials: - -```bash -uv run python -m tests.rust-python-harness.strategies.e2e_parity.sdk.ocr.fixtures.record --examples 1000 -``` - -`LITELLM_OCR_FIXTURE_DIR` and `--fixture-dir` override the default directory. Shared recording, replay, comparison, streaming, and cassette persistence live in `shared/parity/` - -Run the harness's own checks locally: - -```bash -uv run pytest -o consider_namespace_packages=true tests/rust-python-harness/shared tests/rust-python-harness/strategies/unit_tests tests/test_rust_python_harness.py -q -``` - -Existing OCR parity gaps remain visible: invalid-model provider errors differ, Reducto lacks a native contract, and the expanded Azure corpus exposes duplicate Content-Type headers. Moving the harness does not change provider responses or weaken assertions diff --git a/tests/rust-python-harness/__init__.py b/tests/rust-python-harness/__init__.py index 70362674d2b..448e24de03b 100644 --- a/tests/rust-python-harness/__init__.py +++ b/tests/rust-python-harness/__init__.py @@ -1,5 +1,4 @@ -"""Interactive Rust/Python SDK parity test harness.""" +from .cli import main +from .cli.catalog import load_catalog -from .catalog import load_catalog - -__all__ = ["load_catalog"] +__all__ = ["load_catalog", "main"] diff --git a/tests/rust-python-harness/catalog.py b/tests/rust-python-harness/catalog.py deleted file mode 100644 index f40fd5fc6b0..00000000000 --- a/tests/rust-python-harness/catalog.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -from typing import Final - -from pydantic import BaseModel, ConfigDict, ValidationError - -from .shared.reporting.models import Coverage, HarnessCase, SDK_FUNCTIONS, Strategy - -STRATEGIES_ROOT: Final = Path(__file__).parent / "strategies" - - -class CaseSpec(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - coverage: Coverage - selectors: tuple[str, ...] = () - note: str = "" - unit_suite: str | None = None - - -class StrategySpec(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - order: int - id: str - label: str - description: str - functions: dict[str, CaseSpec] - gateway: dict[str, CaseSpec] = {} - - -def _load_strategy(source: Path) -> Strategy: - data: Final = StrategySpec.model_validate_json(source.read_text(encoding="utf-8")) - if set(data.functions) != set(SDK_FUNCTIONS): - raise ValueError(f"{source}: functions must exactly match {SDK_FUNCTIONS}") - cases: Final = tuple( - HarnessCase( - strategy_id=data.id, - strategy_label=data.label, - sdk_function=name, - coverage=case.coverage, - selectors=case.selectors, - note=case.note, - surface=surface, - unit_suite=case.unit_suite, - ) - for surface, functions in (("sdk", data.functions), ("gateway", data.gateway)) - for name in (SDK_FUNCTIONS if surface == "sdk" else functions) - for case in (functions[name],) - ) - for case in cases: - if case.coverage in {Coverage.PLANNED, Coverage.NOT_APPLICABLE} and (case.selectors or case.unit_suite): - raise ValueError(f"{source}: {case.coverage.value} case {case.key} cannot configure tests") - if any(not selector.strip() for selector in case.selectors): - raise ValueError(f"{source}: empty selector in {case.key}") - if data.id == "unit_tests" and case.selectors: - raise ValueError(f"{source}: unit_tests must configure unit_suite instead of pytest selectors") - if data.id != "unit_tests" and case.unit_suite: - raise ValueError(f"{source}: unit_suite is only valid for unit_tests") - return Strategy(data.order, data.id, data.label, data.description, source.parent, cases) - - -def load_catalog(root: Path = STRATEGIES_ROOT) -> tuple[Strategy, ...]: - sources: Final = tuple(sorted(root.glob("*/strategy.json"))) - if not sources: - raise ValueError(f"No strategy manifests found below {root}") - try: - strategies: Final = tuple(sorted((_load_strategy(source) for source in sources), key=lambda item: item.order)) - except (ValidationError, json.JSONDecodeError) as error: - raise ValueError(str(error)) from error - if len({strategy.id for strategy in strategies}) != len(strategies): - raise ValueError(f"Duplicate strategy id in {root}") - return strategies diff --git a/tests/rust-python-harness/cli.py b/tests/rust-python-harness/cli.py deleted file mode 100644 index d266a12ce92..00000000000 --- a/tests/rust-python-harness/cli.py +++ /dev/null @@ -1,245 +0,0 @@ -from __future__ import annotations - -import argparse -import importlib.util -from collections.abc import Sequence -from pathlib import Path - -from .catalog import load_catalog -from .shared.reporting.models import SDK_FUNCTIONS, HarnessCase, Strategy -from .shared.reporting.orchestration import StrategyRunner, run_strategies -from .shared.reporting.ui import make_dashboard -from .strategies.e2e_parity.runner import run as run_e2e -from .strategies.existing_e2e_test_sdk.runner import run as run_existing -from .strategies.trace_parity.runner import run as run_trace -from .strategies.unit_tests.mapping_validator import FunctionReport, build_function_report -from .strategies.unit_tests.runner import run as run_units - -REPO_ROOT = Path(__file__).resolve().parents[2] -COVERAGE_ROOT = REPO_ROOT / "target" / "rust-python-harness" - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="rust-python-harness", - description="Run Rust/Python parity tests with a live strategy-by-SDK-function dashboard.", - ) - parser.add_argument( - "-i", - "--interactive", - action="store_true", - help="pick strategies and SDK functions in a guided terminal menu", - ) - parser.add_argument( - "--list", action="store_true", help="show the catalog without running tests" - ) - parser.add_argument( - "--strategy", - action="append", - default=[], - metavar="ID", - help="run only this strategy", - ) - parser.add_argument( - "--function", - action="append", - default=[], - dest="sdk_functions", - choices=SDK_FUNCTIONS, - help="run only this SDK function", - ) - parser.add_argument("--surface", choices=("sdk", "gateway"), help="run only this API surface") - parser.add_argument( - "--validate-ledger", - action="store_true", - help=( - "report Python<->Rust test-parity ledger gaps and drift instead of " - "running the dashboard; narrow with --function" - ), - ) - parser.add_argument( - "--plain", - action="store_true", - help="disable the interactive terminal dashboard", - ) - parser.add_argument( - "--coverage", - action="store_true", - help="write Python reference LOC reports (HTML, JSON, and XML)", - ) - parser.add_argument( - "--pytest-arg", - action="append", - default=[], - metavar="ARG", - help="append an argument to pytest (repeatable, for example --pytest-arg=-x)", - ) - return parser - - -def _coverage_pytest_args(output_root: Path = COVERAGE_ROOT) -> tuple[str, ...]: - output_root.mkdir(parents=True, exist_ok=True) - return ( - "--cov=litellm", - "--cov-context=test", - f"--cov-report=json:{output_root / 'python.json'}", - f"--cov-report=xml:{output_root / 'python.xml'}", - f"--cov-report=html:{output_root / 'python-html'}", - ) - - -def _pick_values( - title: str, options: Sequence[tuple[str, str]], input_fn=input -) -> set[str]: - print(f"\n{title} (Enter = all)") - for index, (value, label) in enumerate(options, start=1): - print(f" {index:>2}. {label} [{value}]") - while True: - answer = input_fn("Choose numbers, comma-separated: ").strip() - if not answer: - return set() - try: - indexes = {int(part.strip()) for part in answer.split(",")} - except ValueError: - print("Please enter numbers separated by commas.") - continue - if indexes and all(1 <= index <= len(options) for index in indexes): - return {options[index - 1][0] for index in indexes} - print(f"Choose values from 1 to {len(options)}.") - - -def _interactive_filters(strategies: Sequence[Strategy]) -> tuple[set[str], set[str]]: - strategy_ids = _pick_values( - "Testing strategies", [(strategy.id, strategy.label) for strategy in strategies] - ) - sdk_functions = _pick_values( - "SDK functions", - [(name, name) for name in SDK_FUNCTIONS], - ) - return strategy_ids, sdk_functions - - -def _select( - strategies: Sequence[Strategy], strategy_ids: set[str], sdk_functions: set[str] -) -> tuple[HarnessCase, ...]: - known_ids = {strategy.id for strategy in strategies} - unknown = strategy_ids - known_ids - if unknown: - raise ValueError(f"Unknown strategy: {', '.join(sorted(unknown))}") - return tuple( - case - for strategy in strategies - if not strategy_ids or strategy.id in strategy_ids - for case in strategy.cases - if not sdk_functions or case.sdk_function in sdk_functions - ) - - -def _print_catalog(strategies: Sequence[Strategy]) -> None: - for strategy in strategies: - print(f"{strategy.id:20} {strategy.label}") - for case in strategy.cases: - selectors = ( - ", ".join(case.selectors) if case.selectors else case.unit_suite or "no test configured" - ) - print(f" {case.surface}/{case.sdk_function:12} {case.coverage.value:14} {selectors}") - - -def _print_function_report(report: FunctionReport) -> None: - print(f"\n{report.sdk_function}") - if report.ledger is None or report.audit is None: - print(" no ledger yet") - return - ledger, audit = report.ledger, report.audit - print( - f" {ledger.mapped_count}/{ledger.total_count} python tests mapped to rust " - f"({ledger.percentage}%)" - ) - print(f" {len(ledger.rust_only_tests)} rust-only tests with no python counterpart") - if audit.is_clean: - print(" ledger is in sync with the live test files") - return - for label, items in ( - ("ledger references a python test that no longer exists", audit.missing_python_tests), - ("python test exists but is not tracked in the ledger", audit.stale_python_tests), - ("ledger references a rust test that no longer exists", audit.missing_rust_tests), - ("rust test exists but is not tracked in the ledger", audit.stale_rust_tests), - ): - for item in items: - print(f" {label}: {item}") - - -def _validate_ledger(sdk_functions: set[str]) -> int: - functions = sdk_functions or set(SDK_FUNCTIONS) - reports = tuple(build_function_report(function) for function in sorted(functions)) - for report in reports: - _print_function_report(report) - return 0 if all(report.is_clean for report in reports) else 1 - - -def _resolve_runner(strategy_id: str) -> StrategyRunner: - match strategy_id: - case "e2e_parity": - return run_e2e - case "trace_parity": - return run_trace - case "unit_tests": - return run_units - case "existing_e2e_test_sdk": - return run_existing - case _: - raise ValueError(f"Unknown strategy: {strategy_id}") - - -def main(argv: Sequence[str] | None = None, *, strategy_id: str | None = None) -> int: - args = _parser().parse_args(argv) - if args.coverage and importlib.util.find_spec("pytest_cov") is None: - _parser().error( - "--coverage requires the project's pytest-cov dependency; run with " - "`poetry run python -m tests.rust-python-harness --coverage`" - ) - if args.validate_ledger: - return _validate_ledger(set(args.sdk_functions)) - catalog = load_catalog() - strategies = tuple(strategy for strategy in catalog if strategy_id is None or strategy.id == strategy_id) - if args.list: - _print_catalog(strategies) - return 0 - - strategy_ids = set(args.strategy) - sdk_functions = set(args.sdk_functions) - if args.interactive: - picked_strategies, picked_functions = _interactive_filters(strategies) - strategy_ids = strategy_ids or picked_strategies - sdk_functions = sdk_functions or picked_functions - - try: - selected = _select(strategies, strategy_ids, sdk_functions) - cases = tuple(case for case in selected if args.surface is None or case.surface == args.surface) - except ValueError as exc: - _parser().error(str(exc)) - selected_strategy_ids = {case.strategy_id for case in cases} - visible_strategies = tuple( - strategy for strategy in strategies if strategy.id in selected_strategy_ids - ) - dashboard = make_dashboard( - visible_strategies, - plain=args.plain, - confidence_strategies=strategies, - ) - pytest_args = [*args.pytest_arg] - if args.coverage: - pytest_args.extend(_coverage_pytest_args()) - with dashboard: - exit_code, run = run_strategies( - cases=cases, - repo_root=REPO_ROOT, - on_update=dashboard.update, - pytest_args=pytest_args, - resolve_runner=_resolve_runner, - ) - dashboard.finish(run, exit_code) - if args.coverage and (COVERAGE_ROOT / "python.json").exists(): - print(f"Python LOC heatmap: {COVERAGE_ROOT / 'python-html' / 'index.html'}") - print(f"Machine-readable coverage: {COVERAGE_ROOT / 'python.json'}") - return exit_code diff --git a/tests/rust-python-harness/cli/__init__.py b/tests/rust-python-harness/cli/__init__.py new file mode 100644 index 00000000000..13b995825dd --- /dev/null +++ b/tests/rust-python-harness/cli/__init__.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import sys +from collections.abc import Sequence +from typing import Final, cast + +import click + +from ..shared.reporting.models import SDK_FUNCTIONS, SdkFunction, Strategy, Surface +from .catalog import load_catalog +from .commands import run_command, select_cases + +__all__ = ["load_catalog", "main"] + +_INTERRUPTED_EXIT_CODE: Final = 130 + + +def _function_option() -> click.Option: + return click.Option( + ("--function", "sdk_functions"), + type=click.Choice(SDK_FUNCTIONS), + multiple=True, + help="run only this SDK function; repeat to select more than one", + ) + + +def _run_all_command(strategies: Sequence[Strategy]) -> click.Command: + def run_all(sdk_functions: tuple[str, ...]) -> int: + selected_functions: Final = cast(frozenset[SdkFunction], frozenset(sdk_functions)) + cases: Final = select_cases(strategies, selected_functions) + return run_command(strategies, cases) + + return click.Command( + "all", + params=[_function_option()], + callback=run_all, + help="run every strategy", + ) + + +def _strategy_command(strategy: Strategy) -> click.Command: + params: list[click.Parameter] = [_function_option()] + if strategy.definition.surfaces: + params.append( + click.Option( + ("--surface",), + type=click.Choice(strategy.definition.surfaces), + help="run only this API surface; omit to run every surface", + ) + ) + runner_argument: Final = strategy.definition.runner_argument + if runner_argument is not None: + params.append( + click.Option( + (runner_argument.option, "runner_args"), + multiple=True, + metavar=runner_argument.metavar, + help=runner_argument.help, + ) + ) + + def run_strategy( + sdk_functions: tuple[str, ...], + surface: str | None = None, + runner_args: tuple[str, ...] = (), + ) -> int: + selected_functions: Final = cast(frozenset[SdkFunction], frozenset(sdk_functions)) + selected_surface: Final = cast(Surface | None, surface) + cases: Final = select_cases((strategy,), selected_functions, selected_surface) + return run_command((strategy,), cases, runner_args) + + return click.Command( + strategy.id, + params=params, + callback=run_strategy, + help=strategy.description, + ) + + +def _build_cli(strategies: Sequence[Strategy]) -> click.Group: + root: Final = click.Group( + "rust-python-harness", + help="Run Rust/Python parity tests with raw progress and strategy reports.", + ) + run: Final = click.Group("run", help="run one strategy or the complete harness") + run.add_command(_run_all_command(strategies)) + for strategy in strategies: + run.add_command(_strategy_command(strategy)) + root.add_command(run) + return root + + +def main(argv: Sequence[str] | None = None) -> int: + try: + strategies: Final = load_catalog() + result: Final = _build_cli(strategies).main( + args=None if argv is None else list(argv), + prog_name="rust-python-harness", + standalone_mode=False, + ) + exit_code: Final = result if isinstance(result, int) else 0 + except click.ClickException as error: + error.show() + return error.exit_code + except click.Abort: + click.echo("Aborted!", err=True) + return 1 + except KeyboardInterrupt: + sys.stderr.write("\nInterrupted\n") + return _INTERRUPTED_EXIT_CODE + if exit_code == _INTERRUPTED_EXIT_CODE: + sys.stderr.write("Interrupted\n") + return exit_code diff --git a/tests/rust-python-harness/cli/catalog.py b/tests/rust-python-harness/cli/catalog.py new file mode 100644 index 00000000000..03eb032d9c6 --- /dev/null +++ b/tests/rust-python-harness/cli/catalog.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import hashlib +import importlib +import importlib.util +import pkgutil +import sys +from pathlib import Path +from types import ModuleType +from typing import Final + +from .. import strategies as _strategies_package +from ..shared.reporting.models import SDK_FUNCTIONS, SURFACES, CaseDisposition, HarnessCase, Strategy +from ..shared.reporting.strategy import StrategyDefinition + +_STRATEGIES_PACKAGE: Final = _strategies_package +STRATEGIES_ROOT: Final = Path(_STRATEGIES_PACKAGE.__path__[0]) + + +def _load_strategy_module(name: str, folder: Path, prefix: str | None) -> ModuleType: + if prefix is not None: + return importlib.import_module(f"{prefix}.{name}") + module_name: Final = _synthetic_module_name(folder) + spec: Final = importlib.util.spec_from_file_location( + module_name, folder / "__init__.py" + ) + if spec is None or spec.loader is None: + raise ValueError(f"{folder}: cannot load strategy package") + module: Final = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception as error: + del sys.modules[module_name] + raise ValueError(f"{folder}: cannot import strategy package: {error}") from error + return module + + +def _synthetic_module_name(folder: Path) -> str: + digest: Final = hashlib.sha1(str(folder.resolve()).encode()).hexdigest()[:8] + return f"_harness_strategy_{folder.name}_{digest}" + + +def _load_strategy(name: str, folder: Path, prefix: str | None) -> Strategy: + module: Final = _load_strategy_module(name, folder, prefix) + definition: Final = getattr(module, "STRATEGY", None) + if not isinstance(definition, StrategyDefinition): + raise ValueError(f"{folder}: __init__.py must export STRATEGY: StrategyDefinition") + if definition.id != name: + raise ValueError(f"{folder}: strategy id {definition.id!r} must match folder name {name!r}") + if definition.directory.resolve() != folder.resolve(): + raise ValueError(f"{folder}: strategy directory must be {folder}") + if len(set(definition.surfaces)) != len(definition.surfaces) or any( + surface not in SURFACES for surface in definition.surfaces + ): + raise ValueError(f"{folder}: invalid strategy surfaces: {definition.surfaces}") + keys: Final = tuple((case.surface, case.sdk_function) for case in definition.cases) + duplicates: Final = tuple(sorted(key for key in set(keys) if keys.count(key) > 1)) + if duplicates: + raise ValueError(f"{folder}: duplicate strategy cases: {duplicates}") + expected: Final = frozenset( + (surface, function) + for surface in (definition.surfaces or (None,)) + for function in SDK_FUNCTIONS + ) + actual: Final = frozenset(keys) + if actual != expected: + missing: Final = tuple(sorted(expected - actual)) + extra: Final = tuple(sorted(actual - expected)) + raise ValueError( + f"{folder}: strategy cases must exactly match its declared matrix; missing={missing}, extra={extra}" + ) + incompatible: Final = tuple( + (case.surface, case.sdk_function) + for case in definition.cases + if case.spec.disposition is CaseDisposition.RUNNABLE + and not isinstance(case.spec, definition.runnable_spec) + ) + if incompatible: + raise ValueError(f"{folder}: runnable cases do not match {definition.runnable_spec.__name__}: {incompatible}") + cases: Final = tuple( + HarnessCase( + strategy_id=definition.id, + strategy_label=definition.label, + sdk_function=case.sdk_function, + spec=case.spec, + surface=case.surface, + ) + for case in definition.cases + ) + return Strategy( + definition.order, + definition.id, + definition.label, + definition.description, + definition.directory, + cases, + definition, + ) + + +def load_catalog(root: Path | None = None) -> tuple[Strategy, ...]: + resolved: Final = STRATEGIES_ROOT if root is None else root + prefix: Final = _STRATEGIES_PACKAGE.__name__ if resolved == STRATEGIES_ROOT else None + folders: Final = tuple( + info.name for info in pkgutil.iter_modules([str(resolved)]) if info.ispkg + ) + if not folders: + raise ValueError(f"No strategy packages found below {resolved}") + strategies: Final = tuple( + _load_strategy(name, resolved / name, prefix) for name in sorted(folders) + ) + ids: Final = [strategy.id for strategy in strategies] + if len(set(ids)) != len(ids): + raise ValueError(f"Duplicate strategy id in {resolved}") + return tuple(sorted(strategies, key=lambda strategy: (strategy.order, strategy.id))) diff --git a/tests/rust-python-harness/cli/commands.py b/tests/rust-python-harness/cli/commands.py new file mode 100644 index 00000000000..f94c3277dc2 --- /dev/null +++ b/tests/rust-python-harness/cli/commands.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Sequence, Set +from dataclasses import replace +from pathlib import Path +from typing import Final + +from ..shared.reporting.models import HarnessCase, SdkFunction, Strategy, Surface +from ..shared.reporting.orchestration import run_strategies +from ..shared.reporting.ui import make_dashboard + +REPO_ROOT: Final = Path(__file__).resolve().parents[3] + + +def select_cases( + strategies: Sequence[Strategy], + sdk_functions: Set[SdkFunction], + surface: Surface | None = None, +) -> tuple[HarnessCase, ...]: + return tuple( + case + for strategy in strategies + for case in strategy.cases + if (not sdk_functions or case.sdk_function in sdk_functions) + and (surface is None or case.surface == surface) + ) + + +def run_command( + strategies: Sequence[Strategy], + cases: Sequence[HarnessCase], + runner_args: Sequence[str] = (), +) -> int: + grouped: Final = { + strategy.id: tuple(case for case in cases if case.strategy_id == strategy.id) + for strategy in strategies + } + visible: Final = tuple(strategy for strategy in strategies if grouped[strategy.id]) + runners: Final = tuple(replace(strategy, cases=grouped[strategy.id]) for strategy in visible) + dashboard: Final = make_dashboard(visible) + with dashboard: + exit_code, run = run_strategies(runners, REPO_ROOT, dashboard.update, runner_args) + if exit_code != 130: + dashboard.finish(run, exit_code) + return exit_code diff --git a/tests/rust-python-harness/cli/test_cli.py b/tests/rust-python-harness/cli/test_cli.py new file mode 100644 index 00000000000..226c89843d0 --- /dev/null +++ b/tests/rust-python-harness/cli/test_cli.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +import importlib +from collections.abc import Callable, Sequence +from dataclasses import replace +from pathlib import Path +from typing import Final + +import pytest + +from ..shared.reporting.models import ( + SDK_FUNCTIONS, + SURFACES, + CaseDisposition, + HarnessCase, + HarnessRun, + RunStatus, + Strategy, +) +from ..shared.reporting.strategy import NotImplementedCaseSpec, SkippedCaseSpec, StrategyDefinition +from ..shared.reporting.ui import PlainDashboard, final_report, make_dashboard +from ..strategies.unit_tests_mapping.mappings import UNIT_TEST_CONTRACTS +from ..strategies.unit_tests_parity import UNIT_PARITY_SUITES +from ..strategies.unit_tests_rust import RUST_SUITES +from . import main +from .catalog import STRATEGIES_ROOT, load_catalog +from .commands import REPO_ROOT, select_cases + + +def _strategy_source( + *, + strategy_id: str = "example", + surfaces: tuple[str, ...] = (), + drop: tuple[str | None, str] | None = None, + duplicate: tuple[str | None, str] | None = None, + incompatible: tuple[str | None, str] | None = None, +) -> str: + cells: Final = tuple( + (surface, function) + for surface in (surfaces or (None,)) + for function in SDK_FUNCTIONS + if (surface, function) != drop + ) + definitions: Final = tuple( + ( + f"strategy.CaseDefinition({function!r}, " + "strategy.ModuleCaseSpec(coverage=models.Coverage.COMPLETE, module='tests.example'), " + f"surface={surface!r})" + if (surface, function) == incompatible + else ( + f"strategy.CaseDefinition({function!r}, " + "strategy.NotImplementedCaseSpec(reason='Not implemented yet'), " + f"surface={surface!r})" + ) + ) + for surface, function in (*cells, *((duplicate,) if duplicate is not None else ())) + ) + return ( + "import importlib\n" + "from pathlib import Path\n" + "strategy = importlib.import_module('tests.rust-python-harness.shared.reporting.strategy')\n" + "models = importlib.import_module('tests.rust-python-harness.shared.reporting.models')\n" + "runner = importlib.import_module('tests.rust-python-harness.strategies.trace_parity.runner')\n" + "rendering = importlib.import_module('tests.rust-python-harness.shared.reporting.rendering')\n" + "def render(results):\n" + " return (rendering.ReportSection('Example outcomes', " + "tuple(rendering.render_case_outcome(r) for r in results)),)\n" + f"CASES = ({','.join(definitions)},)\n" + "STRATEGY = strategy.StrategyDefinition(" + f"id={strategy_id!r}, order=1, label='Example strategy', description='Example description', " + "directory=Path(__file__).parent, runnable_spec=strategy.SuiteCaseSpec, cases=CASES, " + f"run=runner.run_trace_cases, render=render, surfaces={surfaces!r})\n" + ) + + +def _write_strategy_folder( + root: Path, + name: str = "example", + *, + init_source: str | None = None, +) -> Path: + folder: Final = root / name + folder.mkdir(parents=True) + (folder / "__init__.py").write_text(init_source or _strategy_source(), encoding="utf-8") + return folder + + +def test_should_load_surface_aware_and_function_only_strategies() -> None: + strategies: Final = load_catalog() + + assert [strategy.id for strategy in strategies] == [ + "e2e_parity", + "trace_parity", + "unit_tests_mapping", + "unit_tests_parity", + "unit_tests_rust", + ] + for strategy in strategies: + expected: Final = tuple( + (surface, function) for surface in (strategy.definition.surfaces or (None,)) for function in SDK_FUNCTIONS + ) + assert tuple((case.surface, case.sdk_function) for case in strategy.cases) == expected + + +def test_unit_strategies_use_function_only_cases() -> None: + strategies: Final = { + strategy.id: strategy + for strategy in load_catalog() + if strategy.id in {"unit_tests_mapping", "unit_tests_parity", "unit_tests_rust"} + } + + for sdk_function in SDK_FUNCTIONS: + cases: Final = tuple( + case for strategy in strategies.values() for case in strategy.cases if case.sdk_function == sdk_function + ) + assert len(cases) == 3 + assert all(case.surface is None for case in cases) + expected_mapping: Final = ( + CaseDisposition.RUNNABLE if sdk_function in UNIT_TEST_CONTRACTS else CaseDisposition.NOT_IMPLEMENTED + ) + assert cases[0].spec.disposition is expected_mapping + expected_parity: Final = ( + CaseDisposition.RUNNABLE if sdk_function in UNIT_PARITY_SUITES else CaseDisposition.NOT_IMPLEMENTED + ) + expected_rust: Final = ( + CaseDisposition.RUNNABLE if sdk_function in RUST_SUITES else CaseDisposition.NOT_IMPLEMENTED + ) + assert cases[1].spec.disposition is expected_parity + assert cases[2].spec.disposition is expected_rust + + +def test_raw_dashboard_is_always_the_default() -> None: + assert isinstance(make_dashboard(load_catalog()), PlainDashboard) + + +def test_every_strategy_folder_complies() -> None: + strategies: Final = load_catalog() + folders: Final = { + path.name for path in STRATEGIES_ROOT.iterdir() if path.is_dir() and (path / "__init__.py").exists() + } + + assert folders == {strategy.id for strategy in strategies} + for strategy in strategies: + definition: Final = strategy.definition + assert isinstance(definition, StrategyDefinition) + assert definition.directory == strategy.directory + assert not (strategy.directory / "strategy.json").exists() + assert (strategy.directory / "AGENTS.md").exists() + for case in strategy.cases: + if case.spec.disposition is CaseDisposition.RUNNABLE: + assert isinstance(case.spec, definition.runnable_spec) + + +@pytest.mark.parametrize("surfaces", ((), SURFACES)) +def test_should_reject_a_registry_missing_a_declared_matrix_cell(tmp_path: Path, surfaces: tuple[str, ...]) -> None: + surface: Final = surfaces[0] if surfaces else None + _write_strategy_folder( + tmp_path, + init_source=_strategy_source(surfaces=surfaces, drop=(surface, "count_tokens")), + ) + + with pytest.raises(ValueError, match="must exactly match its declared matrix"): + load_catalog(tmp_path) + + +def test_should_reject_a_duplicate_matrix_cell(tmp_path: Path) -> None: + _write_strategy_folder(tmp_path, init_source=_strategy_source(duplicate=(None, "ocr"))) + + with pytest.raises(ValueError, match="duplicate strategy cases"): + load_catalog(tmp_path) + + +def test_should_reject_invalid_declared_surfaces(tmp_path: Path) -> None: + _write_strategy_folder(tmp_path, init_source=_strategy_source(surfaces=("sdk", "sdk"))) + + with pytest.raises(ValueError, match="invalid strategy surfaces"): + load_catalog(tmp_path) + + +def test_should_reject_a_folder_without_a_strategy_definition(tmp_path: Path) -> None: + folder: Final = tmp_path / "example" + folder.mkdir() + (folder / "__init__.py").write_text("VALUE = 1\n", encoding="utf-8") + + with pytest.raises(ValueError, match="STRATEGY"): + load_catalog(tmp_path) + + +def test_should_reject_a_strategy_id_that_differs_from_its_folder(tmp_path: Path) -> None: + _write_strategy_folder(tmp_path, init_source=_strategy_source(strategy_id="other")) + + with pytest.raises(ValueError, match="must match folder name"): + load_catalog(tmp_path) + + +def test_should_reject_a_runnable_case_incompatible_with_the_strategy(tmp_path: Path) -> None: + _write_strategy_folder(tmp_path, init_source=_strategy_source(incompatible=(None, "ocr"))) + + with pytest.raises(ValueError, match="runnable cases do not match SuiteCaseSpec"): + load_catalog(tmp_path) + + +@pytest.mark.parametrize("case_type", (NotImplementedCaseSpec, SkippedCaseSpec)) +def test_should_reject_an_unavailable_case_with_a_blank_reason( + case_type: type[NotImplementedCaseSpec] | type[SkippedCaseSpec], +) -> None: + with pytest.raises(ValueError, match="at least 1 character"): + case_type(reason=" ") + + +def test_should_select_functions_and_surfaces() -> None: + strategy: Final = next(strategy for strategy in load_catalog() if strategy.id == "e2e_parity") + + assert tuple(case.key for case in select_cases((strategy,), {"messages"})) == ( + "e2e_parity:messages", + "e2e_parity:gateway:messages", + ) + assert tuple(case.display_name for case in select_cases((strategy,), {"ocr"}, "gateway")) == ("gateway/ocr",) + + +def _assert_unavailable_cell(strategy: Strategy, case: HarnessCase, section_title: str) -> None: + spec: Final = case.spec + assert isinstance(spec, (NotImplementedCaseSpec, SkippedCaseSpec)) + scoped: Final = replace(strategy, cases=(case,)) + exit_code, run = strategy.definition.run((case,), REPO_ROOT, lambda _: None) + result: Final = run.results[case.key] + expected: Final = ( + RunStatus.NOT_IMPLEMENTED if spec.disposition is CaseDisposition.NOT_IMPLEMENTED else RunStatus.SKIPPED + ) + report: Final = final_report(run, exit_code, (scoped,)) + + assert exit_code == 0 + assert result.status is expected + assert spec.reason in report + assert section_title in report + expected_result: Final = "NOT RUN" if expected is RunStatus.NOT_IMPLEMENTED else "SKIPPED" + expected_implemented: Final = 0 if expected is RunStatus.NOT_IMPLEMENTED else 1 + assert f"Result: {expected_result}" in report + assert f"Harness support: {expected_implemented}/1 cases implemented" in report + + +def test_every_unavailable_case_finishes_and_explains_itself() -> None: + section_titles: Final = { + "e2e_parity": "End-to-end parity outcomes", + "trace_parity": "trace comparisons", + "unit_tests_mapping": "Python/Rust unit-test mappings", + "unit_tests_parity": "Python backend parity outcomes", + "unit_tests_rust": "Native Rust unit-test outcomes", + } + unavailable: Final = tuple( + (strategy, case) + for strategy in load_catalog() + for case in strategy.cases + if case.spec.disposition is not CaseDisposition.RUNNABLE + ) + + for strategy, case in unavailable: + _assert_unavailable_cell(strategy, case, section_titles[strategy.id]) + + +@pytest.mark.parametrize( + ("strategy_id", "present", "absent"), + ( + ("e2e_parity", "--surface", "--pytest-arg"), + ("trace_parity", "--surface", "--pytest-arg"), + ("unit_tests_parity", "--pytest-arg", "--surface"), + ("unit_tests_mapping", "--detail", "--surface"), + ("unit_tests_rust", "--function", "--surface"), + ), +) +def test_strategy_help_only_lists_supported_options( + strategy_id: str, + present: str, + absent: str, + capsys: pytest.CaptureFixture[str], +) -> None: + exit_code: Final = main(["run", strategy_id, "--help"]) + captured: Final = capsys.readouterr() + + assert exit_code == 0 + assert present in captured.out + assert absent not in captured.out + + +def test_run_help_lists_all_and_every_strategy(capsys: pytest.CaptureFixture[str]) -> None: + exit_code: Final = main(["run", "--help"]) + captured: Final = capsys.readouterr() + + assert exit_code == 0 + for command in ( + "all", + "e2e_parity", + "trace_parity", + "unit_tests_mapping", + "unit_tests_parity", + "unit_tests_rust", + ): + assert command in captured.out + + +@pytest.mark.parametrize( + "argv", + ( + ("list",), + ("check",), + ("run", "--strategy", "unit_tests_parity"), + ("run", "unit_tests_parity", "--surface", "sdk"), + ("run", "unit_tests_parity", "--plain"), + ("run", "unit_tests_parity", "--runner-arg=-x"), + ("run", "all", "--pytest-arg=-x"), + ), +) +def test_removed_commands_and_options_are_rejected(argv: tuple[str, ...], capsys: pytest.CaptureFixture[str]) -> None: + exit_code: Final = main(argv) + captured: Final = capsys.readouterr() + + assert exit_code == 2 + assert captured.err + + +def test_strategy_command_forwards_repeated_filters_and_runner_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cli: Final = importlib.import_module("tests.rust-python-harness.cli") + captured: list[tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]] = [] + + def capture_run( + strategies: Sequence[Strategy], + cases: Sequence[HarnessCase], + runner_args: Sequence[str] = (), + ) -> int: + captured.append( + ( + tuple(strategy.id for strategy in strategies), + tuple(case.display_name for case in cases), + tuple(runner_args), + ) + ) + return 0 + + monkeypatch.setattr(cli, "run_command", capture_run) + + assert ( + main( + [ + "run", + "unit_tests_parity", + "--function", + "ocr", + "--function", + "messages", + "--pytest-arg=-x", + ] + ) + == 0 + ) + assert captured == [ + (("unit_tests_parity",), ("ocr", "messages"), ("-x",)), + ] + + +def test_omitted_surface_selects_every_strategy_surface(monkeypatch: pytest.MonkeyPatch) -> None: + cli: Final = importlib.import_module("tests.rust-python-harness.cli") + selected: list[str] = [] + + def capture_run( + strategies: Sequence[Strategy], + cases: Sequence[HarnessCase], + runner_args: Sequence[str] = (), + ) -> int: + del strategies, runner_args + selected.extend(case.display_name for case in cases) + return 0 + + monkeypatch.setattr(cli, "run_command", capture_run) + + assert main(["run", "e2e_parity", "--function", "ocr"]) == 0 + assert selected == ["sdk/ocr", "gateway/ocr"] + + +def test_run_all_selects_every_declared_case_once(monkeypatch: pytest.MonkeyPatch) -> None: + cli: Final = importlib.import_module("tests.rust-python-harness.cli") + selected: list[HarnessCase] = [] + + def capture_run( + strategies: Sequence[Strategy], + cases: Sequence[HarnessCase], + runner_args: Sequence[str] = (), + ) -> int: + del strategies, runner_args + selected.extend(cases) + return 0 + + monkeypatch.setattr(cli, "run_command", capture_run) + + assert main(["run", "all", "--function", "ocr"]) == 0 + assert len(selected) == 7 + assert sum(case.surface is None for case in selected) == 3 + assert sum(case.surface is not None for case in selected) == 4 + + +def test_run_reports_not_implemented_surface_as_not_run( + capsys: pytest.CaptureFixture[str], +) -> None: + exit_code: Final = main(["run", "trace_parity", "--surface", "gateway", "--function", "ocr"]) + captured: Final = capsys.readouterr() + + assert exit_code == 0 + assert "Result: NOT RUN" in captured.out + assert "Harness support: 0/1 cases implemented" in captured.out + assert "Cases: 1 selected, 1 not implemented, 0 skipped" in captured.out + assert "Not implemented" in captured.out + assert "No gateway OCR trace-parity case is registered." in captured.out + + +def test_keyboard_interrupt_exits_cleanly( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + cli: Final = importlib.import_module("tests.rust-python-harness.cli") + + def interrupt() -> tuple[object, ...]: + raise KeyboardInterrupt + + monkeypatch.setattr(cli, "load_catalog", interrupt) + + exit_code: Final = main(["run", "all"]) + captured: Final = capsys.readouterr() + + assert exit_code == 130 + assert captured.out == "" + assert captured.err == "\nInterrupted\n" + + +def test_runner_interrupt_skips_the_completion_report( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + commands: Final = importlib.import_module("tests.rust-python-harness.cli.commands") + + def interrupt_run( + strategies: Sequence[Strategy], + repo_root: Path, + on_update: Callable[[HarnessRun], None], + runner_args: Sequence[str] = (), + ) -> tuple[int, HarnessRun]: + del repo_root, on_update, runner_args + run: Final = HarnessRun.from_cases(case for strategy in strategies for case in strategy.cases) + return 130, run + + monkeypatch.setattr(commands, "run_strategies", interrupt_run) + + exit_code: Final = main(["run", "trace_parity", "--surface", "gateway"]) + captured: Final = capsys.readouterr() + + assert exit_code == 130 + assert "Rust <-> Python parity report" not in captured.out + assert captured.err == "Interrupted\n" diff --git a/tests/rust-python-harness/conftest.py b/tests/rust-python-harness/conftest.py new file mode 100644 index 00000000000..d50d0fa4204 --- /dev/null +++ b/tests/rust-python-harness/conftest.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Final + +import pytest + +HARNESS_ROOT: Final = Path(__file__).resolve().parents[2] + + +@pytest.fixture(autouse=True) +def subprocess_test_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PYTHONPATH", str(HARNESS_ROOT)) + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + + +@pytest.fixture +def cargo_project(tmp_path: Path) -> Callable[[str, str], Path]: + def create(package: str, source: str) -> Path: + manifest: Final = tmp_path / "Cargo.toml" + manifest.write_text( + f'[package]\nname = "{package}"\nversion = "0.1.0"\nedition = "2021"\n[workspace]\n' + ) + (tmp_path / "src").mkdir() + (tmp_path / "src/lib.rs").write_text(source) + return manifest + + return create diff --git a/tests/rust-python-harness/shared/native_build.py b/tests/rust-python-harness/shared/native_build.py new file mode 100644 index 00000000000..2ca7131c2c1 --- /dev/null +++ b/tests/rust-python-harness/shared/native_build.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path +from typing import Final + +from litellm.rust_bridge import get_native_bridge, reset_native_bridge_cache + +MATURIN_SPEC: Final = "maturin==1.15.0" +BRIDGE_FEATURE: Final = "trace-parity" +_RUST_ROOT: Final = "litellm-rust" +_LOCKFILE: Final = "Cargo.lock" +_SOURCE_SUFFIXES: Final = frozenset({".rs", ".toml"}) +_FAILURE_OUTPUT_LINES: Final = 15 + + +def needs_rebuild(native_mtime: float | None, newest_source_mtime: float | None) -> bool: + if native_mtime is None: + return True + if newest_source_mtime is None: + return False + return newest_source_mtime > native_mtime + + +def _source_files(rust_root: Path) -> Iterator[Path]: + for path in rust_root.rglob("*"): + relative: Final = path.relative_to(rust_root) + if "target" in relative.parts or not path.is_file(): + continue + if path.name == _LOCKFILE or path.suffix in _SOURCE_SUFFIXES: + yield path + + +def _newest_source_mtime(repo_root: Path) -> float | None: + rust_root: Final = repo_root / _RUST_ROOT + if not rust_root.is_dir(): + return None + return max((path.stat().st_mtime for path in _source_files(rust_root)), default=None) + + +def _native_module_path() -> Path | None: + try: + spec: Final = importlib.util.find_spec("litellm.rust_bridge._native") + except (ImportError, ValueError): + return None + origin: Final = getattr(spec, "origin", None) + return Path(origin) if origin else None + + +def _drop_imported_bridge() -> None: + reset_native_bridge_cache() + for name in tuple(sys.modules): + if name.startswith("litellm.rust_bridge._native"): + del sys.modules[name] + + +def _rebuild(repo_root: Path) -> tuple[bool, str]: + command: Final = ("uvx", "--from", MATURIN_SPEC, "maturin", "develop", "--features", BRIDGE_FEATURE) + completed: Final = subprocess.run( + command, + cwd=repo_root, + env={**os.environ, "VIRTUAL_ENV": sys.prefix}, + capture_output=True, + text=True, + check=False, + ) + output: Final = f"{completed.stdout}\n{completed.stderr}".strip() + lines: Final = tuple(output.splitlines()) + return completed.returncode == 0, "\n".join(lines[-_FAILURE_OUTPUT_LINES:]) + + +def ensure_trace_bridge(repo_root: Path) -> str | None: + native_path: Final = _native_module_path() + native_mtime: Final = native_path.stat().st_mtime if native_path is not None and native_path.exists() else None + if needs_rebuild(native_mtime, _newest_source_mtime(repo_root)): + print(f"Rebuilding native Rust bridge ({BRIDGE_FEATURE} feature)...", flush=True) + succeeded: Final + output: Final + succeeded, output = _rebuild(repo_root) + if not succeeded: + return f"native Rust bridge rebuild failed:\n{output}" + _drop_imported_bridge() + bridge: Final = get_native_bridge() + if bridge is None: + return "native Rust bridge is not importable" + if getattr(bridge, "_trace", None) is None: + return f"native Rust bridge does not expose _trace; it must be built with the {BRIDGE_FEATURE} feature" + return None diff --git a/tests/rust-python-harness/shared/parity/__init__.py b/tests/rust-python-harness/shared/parity/__init__.py index f18197acd2a..e69de29bb2d 100644 --- a/tests/rust-python-harness/shared/parity/__init__.py +++ b/tests/rust-python-harness/shared/parity/__init__.py @@ -1,3 +0,0 @@ -import pytest - -pytest.register_assert_rewrite("tests.rust-python-harness.shared.parity.compare") diff --git a/tests/rust-python-harness/shared/parity/fixtures/__init__.py b/tests/rust-python-harness/shared/parity/fixtures/__init__.py index 9d48db4f9f8..6d7f8b4f048 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/__init__.py +++ b/tests/rust-python-harness/shared/parity/fixtures/__init__.py @@ -1 +1,7 @@ from __future__ import annotations + +from typing import Final + +from pydantic import TypeAdapter + +JSON_OBJECT_ADAPTER: Final = TypeAdapter(dict[str, object]) diff --git a/tests/rust-python-harness/shared/parity/fixtures/cassette.py b/tests/rust-python-harness/shared/parity/fixtures/cassette.py index 03a5fc9f416..79e04d63b22 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/cassette.py +++ b/tests/rust-python-harness/shared/parity/fixtures/cassette.py @@ -5,11 +5,10 @@ from datetime import datetime from itertools import accumulate from typing import Final, Literal -from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, TypeAdapter +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field from vcr.serialize import serialize from vcr.serializers import yamlserializer -from .recording import RecordedInteraction from ..recorded_http import ( HttpHeader, RecordedHttpResponse, @@ -17,8 +16,8 @@ from ..recorded_http import ( RecordedResponse, RecordedStreamChunk, ) - -_OBJECT: Final = TypeAdapter(dict[str, object]) +from . import JSON_OBJECT_ADAPTER +from .recording import RecordedInteraction class _CassetteModel(BaseModel): @@ -118,7 +117,7 @@ def serialize_cassette( recorded_at: datetime, request_source: Literal["recorded", "python_replay"], ) -> str: - normalized: Final = _OBJECT.validate_python( + normalized: Final = JSON_OBJECT_ADAPTER.validate_python( yamlserializer.deserialize( serialize( { diff --git a/tests/rust-python-harness/shared/parity/fixtures/pipeline.py b/tests/rust-python-harness/shared/parity/fixtures/pipeline.py index ab5c3437c2a..8a6f72b4a4d 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/pipeline.py +++ b/tests/rust-python-harness/shared/parity/fixtures/pipeline.py @@ -8,11 +8,11 @@ from types import MappingProxyType from typing import Final, Generic, Literal, Protocol, TypeVar from hypothesis.strategies import SearchStrategy -from pydantic import BaseModel from .inputs import generate_case_inputs from .recording import UpstreamEndpoint, record_upstream_interactions from .store import ( + CaseT, FixtureInput, canonical_json, fixture_cache_key, @@ -25,7 +25,6 @@ from .store import ( LOGGER: Final = logging.getLogger(__name__) InputT = TypeVar("InputT", bound=FixtureInput) InputT_contra = TypeVar("InputT_contra", bound=FixtureInput, contravariant=True) -CaseT = TypeVar("CaseT", bound=BaseModel) class RecordingInvocation(Protocol[InputT_contra]): diff --git a/tests/rust-python-harness/shared/parity/fixtures/pytest_support.py b/tests/rust-python-harness/shared/parity/fixtures/pytest_support.py index 04c097f318c..844417d1c6b 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/pytest_support.py +++ b/tests/rust-python-harness/shared/parity/fixtures/pytest_support.py @@ -3,14 +3,12 @@ from __future__ import annotations import os from collections.abc import Callable from pathlib import Path -from typing import Final, TypeVar +from typing import Final import pytest -from pydantic import BaseModel, ValidationError +from pydantic import ValidationError -from .store import recorded_fixtures - -CaseT = TypeVar("CaseT", bound=BaseModel) +from .store import CaseT, fixture_directory, recorded_fixtures def parametrize_recorded_fixtures( @@ -27,9 +25,10 @@ def parametrize_recorded_fixtures( if fixture_name not in metafunc.fixturenames: return configured: Final = os.environ.get(env_var) - if configured == "": - raise pytest.UsageError(f"{env_var} is set but empty") - directory: Final = Path(configured).expanduser() if configured is not None else default_directory + try: + directory: Final = fixture_directory(None, configured, default_directory) + except ValueError as error: + raise pytest.UsageError(f"{env_var} is set but empty") from error try: fixtures: Final = recorded_fixtures(directory, case_type) except (ValidationError, ValueError) as error: diff --git a/tests/rust-python-harness/shared/parity/fixtures/recording.py b/tests/rust-python-harness/shared/parity/fixtures/recording.py index 6c23e36f20c..ee0d4b6de7b 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/recording.py +++ b/tests/rust-python-harness/shared/parity/fixtures/recording.py @@ -1,23 +1,24 @@ from __future__ import annotations import queue -import threading from collections.abc import Callable, Generator, Iterable -from contextlib import contextmanager +from contextlib import AbstractContextManager from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Final, TypeVar, cast -from urllib.parse import urlsplit, urlunsplit import httpx from vcr.filters import remove_query_parameters from vcr.request import Request from ..http import ( + PARITY_PROVIDER_HOST, dropped_request_headers, dropped_response_headers, is_streaming_response, + local_response_header, + normalized_response_header, ) +from ..local_server import LocalHttpHandler, LocalHttpServer, serve_in_thread from ..recorded_http import ( HttpHeader, RecordedHttpResponse, @@ -26,7 +27,6 @@ from ..recorded_http import ( RecordedStreamChunk, ) -_PARITY_PROVIDER_HOST: Final = "parity-provider.invalid" _SECRET_HEADERS: Final = frozenset( { "authorization", @@ -61,42 +61,18 @@ def _end_to_end_headers(headers: httpx.Headers) -> tuple[HttpHeader, ...]: decoded: Final = tuple((name.decode("ascii"), value.decode("latin-1")) for name, value in headers.raw) excluded: Final = dropped_response_headers(decoded) return tuple( - HttpHeader(name=name, value=_normalized_response_header(name, value)) + HttpHeader(name=name, value=normalized_response_header(name, value)) for name, value in decoded if name.lower() not in excluded ) -def _normalized_response_header(name: str, value: str) -> str: - if name.lower() not in {"location", "operation-location"}: - return value - parsed: Final = urlsplit(value) - if not parsed.netloc: - return value - return urlunsplit(("http", _PARITY_PROVIDER_HOST, parsed.path, parsed.query, parsed.fragment)) - - -def local_response_header(name: str, value: str, provider_url: str) -> str: - if name.lower() not in {"location", "operation-location"}: - return value - parsed: Final = urlsplit(value) - if parsed.hostname != _PARITY_PROVIDER_HOST: - return value - return f"{provider_url}{parsed.path}{'?' + parsed.query if parsed.query else ''}" - - -class _RecordingProvider(ThreadingHTTPServer): - daemon_threads = True - +class _RecordingProvider(LocalHttpServer): def __init__(self, spec: UpstreamEndpoint) -> None: super().__init__(("127.0.0.1", 0), _RecordingHandler) self.spec: Final = spec self.interactions: queue.Queue[RecordedInteraction] = queue.Queue() - @property - def url(self) -> str: - return f"http://127.0.0.1:{self.server_address[1]}" - def take_interactions(self) -> tuple[RecordedInteraction, ...]: try: first: Final = self.interactions.get(timeout=5) @@ -106,9 +82,7 @@ class _RecordingProvider(ThreadingHTTPServer): return (first, *remaining) -class _RecordingHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - +class _RecordingHandler(LocalHttpHandler): def do_POST(self) -> None: self._forward() @@ -151,7 +125,7 @@ class _RecordingHandler(BaseHTTPRequestHandler): recorded_request: Final = remove_query_parameters( Request( self.command, - f"http://{_PARITY_PROVIDER_HOST}{self.path}", + f"http://{PARITY_PROVIDER_HOST}{self.path}", request_body, {name: value for name, value in forwarded_headers if name.lower() not in _SECRET_HEADERS}, ), @@ -191,8 +165,10 @@ class _RecordingHandler(BaseHTTPRequestHandler): self.send_header("transfer-encoding", "chunked") self.end_headers() chunks: Final = tuple(self._relay_chunks(upstream.iter_bytes())) - self.wfile.write(b"0\r\n\r\n") - self.wfile.flush() + try: + self.finish_chunked() + except (BrokenPipeError, ConnectionResetError): + pass return RecordedHttpStreamResponse( kind="http_stream", status_code=upstream.status_code, @@ -202,10 +178,7 @@ class _RecordingHandler(BaseHTTPRequestHandler): def _relay_chunks(self, chunks: Iterable[bytes]) -> Generator[RecordedStreamChunk, None, None]: for chunk in chunks: - self.wfile.write(f"{len(chunk):X}\r\n".encode("ascii")) - self.wfile.write(chunk) - self.wfile.write(b"\r\n") - self.wfile.flush() + self.write_chunk(chunk) yield RecordedStreamChunk.from_bytes(chunk) def _send_response(self, status_code: int, headers: tuple[HttpHeader, ...], body: bytes) -> None: @@ -218,21 +191,8 @@ class _RecordingHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) - def log_message(self, format: str, *args: object) -> None: - return - - -@contextmanager -def _recording_provider(spec: UpstreamEndpoint) -> Generator[_RecordingProvider]: - server: Final = _RecordingProvider(spec) - thread: Final = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield server - finally: - server.shutdown() - server.server_close() - thread.join(timeout=5) +def _recording_provider(spec: UpstreamEndpoint) -> AbstractContextManager[_RecordingProvider]: + return serve_in_thread(_RecordingProvider(spec)) def _invoke_and_take_interactions( diff --git a/tests/rust-python-harness/shared/parity/fixtures/store.py b/tests/rust-python-harness/shared/parity/fixtures/store.py index 7a10c5c6c5d..270af2a7625 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/store.py +++ b/tests/rust-python-harness/shared/parity/fixtures/store.py @@ -8,15 +8,13 @@ from datetime import datetime, timezone from pathlib import Path from typing import Final, Literal, Protocol, TypeVar, cast -from pydantic import AwareDatetime, BaseModel, ConfigDict, TypeAdapter, ValidationError +from pydantic import AwareDatetime, BaseModel, ConfigDict, ValidationError +from . import JSON_OBJECT_ADAPTER from .cassette import deserialize_cassette, serialize_cassette from .recording import RecordedInteraction FIXTURE_SCHEMA_VERSION: Final = 1 -JSON_OBJECT: Final = TypeAdapter(dict[str, object]) - - class FixtureInput(Protocol): def canonical_input(self) -> dict[str, object]: ... @@ -40,10 +38,13 @@ def fixture_cache_key(case_input: FixtureInput) -> dict[str, object]: return case_input.canonical_input() -def fixture_path(directory: Path, case_input: FixtureInput) -> Path: +def _fixture_digest(case_input: FixtureInput) -> str: input_json: Final = canonical_json(fixture_cache_key(case_input)) - digest: Final = hashlib.sha256(input_json.encode("utf-8")).hexdigest() - return directory / f"{digest}.yaml" + return hashlib.sha256(input_json.encode("utf-8")).hexdigest() + + +def fixture_path(directory: Path, case_input: FixtureInput) -> Path: + return directory / f"{_fixture_digest(case_input)}.yaml" def load_fixture(directory: Path, case_input: FixtureInput, case_type: type[CaseT]) -> CaseT | None: @@ -87,7 +88,7 @@ def save_fixture( def read_fixture(path: Path, case_type: type[CaseT]) -> CaseT: contents: Final = path.read_text(encoding="utf-8") if path.suffix == ".json": - return _load_fixture(JSON_OBJECT.validate_json(contents), path, case_type) + return _load_fixture(JSON_OBJECT_ADAPTER.validate_json(contents), path, case_type) try: cassette: Final = deserialize_cassette(contents) return case_type.model_validate(cassette.case_data()) @@ -117,10 +118,12 @@ def recorded_fixtures(directory: Path, case_type: type[CaseT]) -> tuple[CaseT, . def fixture_directory(configured: Path | None, env_value: str | None, default: Path) -> Path: - return (configured or Path(env_value or default)).expanduser() + if configured is not None: + return configured.expanduser() + if env_value == "": + raise ValueError("fixture directory environment variable is set but empty") + return Path(env_value).expanduser() if env_value is not None else default.expanduser() def fixture_id(case_input: FixtureInput, prefix: str) -> str: - input_json: Final = canonical_json(case_input.canonical_input()) - digest: Final = hashlib.sha256(input_json.encode("utf-8")).hexdigest()[:8] - return f"{prefix}-{digest}" + return f"{prefix}-{_fixture_digest(case_input)[:8]}" diff --git a/tests/rust-python-harness/shared/parity/fixtures/test_cassette.py b/tests/rust-python-harness/shared/parity/fixtures/test_cassette.py index e668223782b..7850f0863f8 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/test_cassette.py +++ b/tests/rust-python-harness/shared/parity/fixtures/test_cassette.py @@ -10,9 +10,6 @@ from vcr import VCR from vcr.request import Request from ..fixture_models import ParityCase, SdkInputBase -from .cassette import deserialize_cassette -from .recording import RecordedInteraction -from .store import load_fixture, save_fixture from ..recorded_http import ( HttpHeader, RecordedHttpResponse, @@ -21,6 +18,9 @@ from ..recorded_http import ( RecordedStreamChunk, ) from ..replay import replay_server +from .cassette import deserialize_cassette +from .recording import RecordedInteraction +from .store import load_fixture, save_fixture _URI: Final = "http://parity-provider.invalid/operation?api-version=1" @@ -93,3 +93,18 @@ def test_cassette_preserves_duplicate_response_headers(tmp_path: Path) -> None: save_fixture(tmp_path, sdk_input, case, (RecordedInteraction(Request("POST", _URI, b"", {}), response),)) assert load_fixture(tmp_path, sdk_input, ParityCase[_Input]) == case + + +def test_local_replay_skips_recorded_retry_delay() -> None: + response: Final = RecordedHttpResponse.from_bytes( + 200, + (HttpHeader(name="retry-after", value="5"),), + b"{}", + ) + + with replay_server() as server: + server.enqueue_response(response) + replayed: Final = httpx.post(f"{server.url}/operation", content=b"{}") + server.take_requests(1) + + assert replayed.headers["retry-after"] == "0" diff --git a/tests/rust-python-harness/shared/parity/fixtures/test_pipeline.py b/tests/rust-python-harness/shared/parity/fixtures/test_pipeline.py index 66b6b4bffdc..4535ba05bf6 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/test_pipeline.py +++ b/tests/rust-python-harness/shared/parity/fixtures/test_pipeline.py @@ -2,10 +2,8 @@ from __future__ import annotations import logging import threading -from collections.abc import Generator -from contextlib import contextmanager +from contextlib import AbstractContextManager from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Final, Literal @@ -14,6 +12,8 @@ import pytest from hypothesis import strategies as st from pydantic import BaseModel, ConfigDict +from ..local_server import LocalHttpHandler, LocalHttpServer, serve_in_thread +from ..recorded_http import RecordedResponse from .pipeline import ( RecordingInvocation, RecordingTarget, @@ -22,7 +22,6 @@ from .pipeline import ( ) from .recording import UpstreamEndpoint from .store import fixture_path -from ..recorded_http import RecordedResponse class _FixtureInput(BaseModel): @@ -41,20 +40,12 @@ class _ParityCase(BaseModel): provider_responses: tuple[RecordedResponse, ...] -class _Upstream(ThreadingHTTPServer): - daemon_threads = True - +class _Upstream(LocalHttpServer): def __init__(self, status: int = 200) -> None: super().__init__(("127.0.0.1", 0), _UpstreamHandler) self.response_status: Final = status - @property - def url(self) -> str: - return f"http://127.0.0.1:{self.server_address[1]}" - - -class _UpstreamHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" +class _UpstreamHandler(LocalHttpHandler): def do_POST(self) -> None: length: Final = int(self.headers.get("content-length") or "0") @@ -68,21 +59,8 @@ class _UpstreamHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) - def log_message(self, format: str, *args: object) -> None: - return - - -@contextmanager -def _upstream(status: int = 200) -> Generator[_Upstream]: - server: Final = _Upstream(status) - thread: Final = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield server - finally: - server.shutdown() - server.server_close() - thread.join(timeout=5) +def _upstream(status: int = 200) -> AbstractContextManager[_Upstream]: + return serve_in_thread(_Upstream(status)) @dataclass(frozen=True, slots=True) diff --git a/tests/rust-python-harness/shared/parity/fixtures/test_recording.py b/tests/rust-python-harness/shared/parity/fixtures/test_recording.py index e3da59ac4d8..6181f18e89a 100644 --- a/tests/rust-python-harness/shared/parity/fixtures/test_recording.py +++ b/tests/rust-python-harness/shared/parity/fixtures/test_recording.py @@ -3,10 +3,9 @@ from __future__ import annotations import asyncio import queue import threading -from collections.abc import AsyncIterator, Callable, Generator, Iterator -from contextlib import contextmanager +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import AbstractContextManager from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Final, Literal @@ -17,19 +16,8 @@ from openai._streaming import SSEDecoder from pydantic import BaseModel, ConfigDict from ..compare import assert_request_parity -from .pipeline import RecordingTarget, record_fixtures -from .recording import ( - UpstreamEndpoint, - record_upstream_interactions, - record_upstream_responses, -) -from .store import ( - FIXTURE_SCHEMA_VERSION, - fixture_path, - load_fixture, - recorded_fixtures, -) from ..inprocess import InProcessExecution, run_in_process, run_in_process_async +from ..local_server import LocalHttpHandler, LocalHttpServer, serve_in_thread from ..recorded_http import ( HttpHeader, RecordedHttpStreamResponse, @@ -45,6 +33,18 @@ from ..stream import ( consume_async_stream, consume_sync_stream, ) +from .pipeline import RecordingTarget, record_fixtures +from .recording import ( + UpstreamEndpoint, + record_upstream_interactions, + record_upstream_responses, +) +from .store import ( + FIXTURE_SCHEMA_VERSION, + fixture_path, + load_fixture, + recorded_fixtures, +) _SSE_CHUNKS: Final = ( b'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n', @@ -146,9 +146,7 @@ class _Invocation: self.sdk_call(provider_url, case_input) -class _ControlledUpstream(ThreadingHTTPServer): - daemon_threads = True - +class _ControlledUpstream(LocalHttpServer): def __init__(self, stream_chunks: tuple[bytes, ...]) -> None: super().__init__(("127.0.0.1", 0), _ControlledUpstreamHandler) self.stream_chunks: Final = stream_chunks @@ -158,10 +156,6 @@ class _ControlledUpstream(ThreadingHTTPServer): self.max_active_requests: int = 0 self.request_count: int = 0 - @property - def url(self) -> str: - return f"http://127.0.0.1:{self.server_address[1]}" - def start_request(self) -> None: with self.lock: self.active_requests += 1 @@ -176,9 +170,7 @@ class _ControlledUpstream(ThreadingHTTPServer): self.active_requests -= 1 -class _ControlledUpstreamHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - +class _ControlledUpstreamHandler(LocalHttpHandler): def do_POST(self) -> None: upstream: Final = self.server assert isinstance(upstream, _ControlledUpstream) @@ -207,13 +199,7 @@ class _ControlledUpstreamHandler(BaseHTTPRequestHandler): self.send_header("content-type", "text/event-stream") self.send_header("transfer-encoding", "chunked") self.end_headers() - for chunk in upstream.stream_chunks: - self.wfile.write(f"{len(chunk):X}\r\n".encode("ascii")) - self.wfile.write(chunk) - self.wfile.write(b"\r\n") - self.wfile.flush() - self.wfile.write(b"0\r\n\r\n") - self.wfile.flush() + self.write_chunked(upstream.stream_chunks) return if self.path == "/error": self._send_json(429, b'{"error":{"message":"rate limited"}}') @@ -252,21 +238,10 @@ class _ControlledUpstreamHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) - def log_message(self, format: str, *args: object) -> None: - return - - -@contextmanager -def _controlled_upstream(stream_chunks: tuple[bytes, ...] = _SSE_CHUNKS) -> Generator[_ControlledUpstream]: - server: Final = _ControlledUpstream(stream_chunks) - thread: Final = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield server - finally: - server.shutdown() - server.server_close() - thread.join(timeout=5) +def _controlled_upstream( + stream_chunks: tuple[bytes, ...] = _SSE_CHUNKS, +) -> AbstractContextManager[_ControlledUpstream]: + return serve_in_thread(_ControlledUpstream(stream_chunks)) def _case(identifier: str) -> _FixtureInput: diff --git a/tests/rust-python-harness/shared/parity/http.py b/tests/rust-python-harness/shared/parity/http.py index c164e3c6549..46cdbeedb59 100644 --- a/tests/rust-python-harness/shared/parity/http.py +++ b/tests/rust-python-harness/shared/parity/http.py @@ -2,6 +2,9 @@ from __future__ import annotations from collections.abc import Iterable from typing import Final +from urllib.parse import urlsplit, urlunsplit + +PARITY_PROVIDER_HOST: Final = "parity-provider.invalid" HOP_BY_HOP_HEADERS: Final[frozenset[str]] = frozenset( { @@ -52,3 +55,21 @@ def dropped_response_headers(headers: Iterable[tuple[str, str]]) -> frozenset[st def is_streaming_response(content_type: str) -> bool: return "text/event-stream" in content_type.lower() + + +def normalized_response_header(name: str, value: str) -> str: + if name.lower() not in {"location", "operation-location"}: + return value + parsed: Final = urlsplit(value) + if not parsed.netloc: + return value + return urlunsplit(("http", PARITY_PROVIDER_HOST, parsed.path, parsed.query, parsed.fragment)) + + +def local_response_header(name: str, value: str, provider_url: str) -> str: + if name.lower() not in {"location", "operation-location"}: + return value + parsed: Final = urlsplit(value) + if parsed.hostname != PARITY_PROVIDER_HOST: + return value + return f"{provider_url}{parsed.path}{'?' + parsed.query if parsed.query else ''}" diff --git a/tests/rust-python-harness/shared/parity/ledger.py b/tests/rust-python-harness/shared/parity/ledger.py deleted file mode 100644 index 40dfed583ae..00000000000 --- a/tests/rust-python-harness/shared/parity/ledger.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -@dataclass(frozen=True, slots=True) -class LedgerEntry: - python_file: str - python_test: str - status: str - rust_file: str - rust_test: str - justification: str - reason: str - - -@dataclass(frozen=True, slots=True) -class RustOnlyEntry: - rust_file: str - rust_test: str - reason: str - - -@dataclass(frozen=True, slots=True) -class TestLedger: - sdk_function: str - python_scope: tuple[str, ...] - rust_scope: tuple[str, ...] - entries: tuple[LedgerEntry, ...] - rust_only_tests: tuple[RustOnlyEntry, ...] - - @property - def mapped_count(self) -> int: - return sum(1 for entry in self.entries if entry.status == "mapped") - - @property - def total_count(self) -> int: - return len(self.entries) - - @property - def percentage(self) -> float: - if self.total_count == 0: - return 0.0 - return round(100.0 * self.mapped_count / self.total_count, 1) - - -def _require_string(value: Any, field: str, source: Path) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{source}: {field} must be a non-empty string") - return value - - -def _require_string_list(value: Any, field: str, source: Path) -> tuple[str, ...]: - if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): - raise ValueError(f"{source}: {field} must be a list of non-empty strings") - return tuple(value) - - -def _load_entry(data: Any, index: int, source: Path) -> LedgerEntry: - if not isinstance(data, dict): - raise ValueError(f"{source}: entries[{index}] must be an object") - python_file = _require_string(data.get("python_file"), f"entries[{index}].python_file", source) - python_test = _require_string(data.get("python_test"), f"entries[{index}].python_test", source) - status = data.get("status") - if status not in ("mapped", "unmapped"): - raise ValueError(f"{source}: entries[{index}].status must be 'mapped' or 'unmapped'") - - if status == "mapped": - rust_file = _require_string(data.get("rust_file"), f"entries[{index}].rust_file", source) - rust_test = _require_string(data.get("rust_test"), f"entries[{index}].rust_test", source) - justification = _require_string( - data.get("justification"), f"entries[{index}].justification", source - ) - return LedgerEntry( - python_file=python_file, - python_test=python_test, - status=status, - rust_file=rust_file, - rust_test=rust_test, - justification=justification, - reason="", - ) - - reason = _require_string(data.get("reason"), f"entries[{index}].reason", source) - return LedgerEntry( - python_file=python_file, - python_test=python_test, - status=status, - rust_file="", - rust_test="", - justification="", - reason=reason, - ) - - -def _load_rust_only_entry(data: Any, index: int, source: Path) -> RustOnlyEntry: - if not isinstance(data, dict): - raise ValueError(f"{source}: rust_only_tests[{index}] must be an object") - return RustOnlyEntry( - rust_file=_require_string(data.get("rust_file"), f"rust_only_tests[{index}].rust_file", source), - rust_test=_require_string(data.get("rust_test"), f"rust_only_tests[{index}].rust_test", source), - reason=_require_string(data.get("reason"), f"rust_only_tests[{index}].reason", source), - ) - - -def load_ledger(path: Path) -> TestLedger: - with path.open(encoding="utf-8") as stream: - data = json.load(stream) - - sdk_function = _require_string(data.get("sdk_function"), "sdk_function", path) - python_scope = _require_string_list(data.get("python_scope"), "python_scope", path) - rust_scope = _require_string_list(data.get("rust_scope"), "rust_scope", path) - - entries_data = data.get("entries") - if not isinstance(entries_data, list): - raise ValueError(f"{path}: entries must be a list") - entries = tuple( - _load_entry(entry, index, path) for index, entry in enumerate(entries_data) - ) - - rust_only_data = data.get("rust_only_tests") - if not isinstance(rust_only_data, list): - raise ValueError(f"{path}: rust_only_tests must be a list") - rust_only_tests = tuple( - _load_rust_only_entry(entry, index, path) for index, entry in enumerate(rust_only_data) - ) - - return TestLedger( - sdk_function=sdk_function, - python_scope=python_scope, - rust_scope=rust_scope, - entries=entries, - rust_only_tests=rust_only_tests, - ) diff --git a/tests/rust-python-harness/shared/parity/local_server.py b/tests/rust-python-harness/shared/parity/local_server.py new file mode 100644 index 00000000000..6cf2377d123 --- /dev/null +++ b/tests/rust-python-harness/shared/parity/local_server.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import threading +from collections.abc import Generator, Iterable +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Final, TypeVar + + +class LocalHttpServer(ThreadingHTTPServer): + daemon_threads = True + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self.server_address[1]}" + + +class LocalHttpHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def write_chunk(self, chunk: bytes) -> None: + self.wfile.write(f"{len(chunk):X}\r\n".encode("ascii")) + self.wfile.write(chunk) + self.wfile.write(b"\r\n") + self.wfile.flush() + + def finish_chunked(self) -> None: + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + + def write_chunked(self, chunks: Iterable[bytes]) -> None: + for chunk in chunks: + self.write_chunk(chunk) + self.finish_chunked() + + def log_message(self, format: str, *args: object) -> None: + return + + +ServerT = TypeVar("ServerT", bound=LocalHttpServer) + + +@contextmanager +def serve_in_thread(server: ServerT, poll_interval: float = 0.5) -> Generator[ServerT]: + thread: Final = threading.Thread( + target=server.serve_forever, + kwargs={"poll_interval": poll_interval}, + daemon=True, + ) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/tests/rust-python-harness/shared/parity/replay.py b/tests/rust-python-harness/shared/parity/replay.py index bde84aba3c4..c7bf76895ad 100644 --- a/tests/rust-python-harness/shared/parity/replay.py +++ b/tests/rust-python-harness/shared/parity/replay.py @@ -2,15 +2,13 @@ from __future__ import annotations import base64 import queue -import threading -from collections.abc import Generator -from contextlib import contextmanager -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from contextlib import AbstractContextManager from typing import Final from pydantic import JsonValue, TypeAdapter -from .fixtures.recording import local_response_header +from .http import local_response_header +from .local_server import LocalHttpHandler, LocalHttpServer, serve_in_thread from .models import CapturedRequest from .recorded_http import RecordedHttpResponse, RecordedHttpStreamResponse, RecordedResponse @@ -28,18 +26,18 @@ EXCLUDED_REQUEST_HEADERS: Final = frozenset( EXCLUDED_RESPONSE_HEADERS: Final = frozenset({"content-length", "transfer-encoding", "connection"}) -class ReplayServer(ThreadingHTTPServer): - daemon_threads = True +def _replay_response_header(name: str, value: str, provider_url: str) -> str: + if name.lower() == "retry-after": + return "0" + return local_response_header(name, value, provider_url) + +class ReplayServer(LocalHttpServer): def __init__(self) -> None: super().__init__(("127.0.0.1", 0), _ReplayHandler) self.responses: queue.Queue[RecordedResponse] = queue.Queue() self.requests: queue.Queue[CapturedRequest] = queue.Queue() - @property - def url(self) -> str: - return f"http://127.0.0.1:{self.server_address[1]}" - def enqueue_response(self, response: RecordedResponse) -> None: self.responses.put(response) @@ -56,9 +54,7 @@ class ReplayServer(ThreadingHTTPServer): self.requests.get_nowait() -class _ReplayHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - +class _ReplayHandler(LocalHttpHandler): def do_POST(self) -> None: self._replay() @@ -111,7 +107,7 @@ class _ReplayHandler(BaseHTTPRequestHandler): self.send_response_only(response.status_code) for header in response.headers: if header.name.lower() not in EXCLUDED_RESPONSE_HEADERS: - self.send_header(header.name, local_response_header(header.name, header.value, provider.url)) + self.send_header(header.name, _replay_response_header(header.name, header.value, provider.url)) if isinstance(response, RecordedHttpResponse): response_body: Final = response.body_bytes() self.send_header("content-length", str(len(response_body))) @@ -121,27 +117,8 @@ class _ReplayHandler(BaseHTTPRequestHandler): assert isinstance(response, RecordedHttpStreamResponse) self.send_header("transfer-encoding", "chunked") self.end_headers() - for chunk in response.chunks: - data = chunk.data_bytes() - self.wfile.write(f"{len(data):X}\r\n".encode("ascii")) - self.wfile.write(data) - self.wfile.write(b"\r\n") - self.wfile.flush() - self.wfile.write(b"0\r\n\r\n") - self.wfile.flush() - - def log_message(self, format: str, *args: object) -> None: - return + self.write_chunked(chunk.data_bytes() for chunk in response.chunks) -@contextmanager -def replay_server() -> Generator[ReplayServer]: - server: Final = ReplayServer() - thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) - thread.start() - try: - yield server - finally: - server.shutdown() - server.server_close() - thread.join(timeout=5) +def replay_server() -> AbstractContextManager[ReplayServer]: + return serve_in_thread(ReplayServer(), poll_interval=0.01) diff --git a/tests/rust-python-harness/shared/parity/runner.py b/tests/rust-python-harness/shared/parity/runner.py index 5add5177113..43a583382cb 100644 --- a/tests/rust-python-harness/shared/parity/runner.py +++ b/tests/rust-python-harness/shared/parity/runner.py @@ -26,6 +26,7 @@ from .replay import ReplayServer, replay_server WORKER_RESULT_PREFIX: Final = "LITELLM_PARITY_RESULT " WORKER_RESULT_ADAPTER: Final[TypeAdapter[WorkerResult]] = TypeAdapter(WorkerResult) +PROJECT_ROOT: Final = Path(__file__).resolve().parents[4] @dataclass(frozen=True, slots=True) @@ -39,7 +40,7 @@ class SubprocessRunner: sys.executable, "-m", ".".join( - self.entrypoint.resolve().relative_to(Path(__file__).resolve().parents[4]).with_suffix("").parts + self.entrypoint.resolve().relative_to(PROJECT_ROOT).with_suffix("").parts ), "--parity-worker", provider_url, @@ -54,7 +55,7 @@ class ExecutionVariant: class SubprocessWorker: def __init__(self, runner: SubprocessRunner, provider: ReplayServer, variant: ExecutionVariant) -> None: - project_root: Final = str(Path(__file__).resolve().parents[4]) + project_root: Final = str(PROJECT_ROOT) existing_pythonpath: Final = os.environ.get("PYTHONPATH") env: Final = { **os.environ, @@ -165,15 +166,6 @@ def execution_worker( worker.close() -def run_execution( - worker: SubprocessWorker, - case_file: Path, - route: str, - responses: tuple[RecordedResponse, ...], -) -> Execution: - return worker.execute(case_file, route, responses) - - @contextmanager def execution_worker_pair( runner: SubprocessRunner, diff --git a/tests/rust-python-harness/shared/parity/stream.py b/tests/rust-python-harness/shared/parity/stream.py index 72e00d3b2bd..9da7bd42d93 100644 --- a/tests/rust-python-harness/shared/parity/stream.py +++ b/tests/rust-python-harness/shared/parity/stream.py @@ -81,80 +81,61 @@ def _failed(phase: Literal["creation", "iteration"], error: Exception) -> Stream ) +def _creation_failure(error: Exception) -> StreamOutcome: + return StreamOutcome( + wrapper_type=None, + supports_sync_iteration=None, + supports_async_iteration=None, + chunks=(), + chunk_types=(), + terminal=_failed("creation", error), + ) + + +def _stream_outcome( + stream: object, + chunks: Iterable[object], + terminal: StreamTerminal, +) -> StreamOutcome: + recorded: Final = tuple(chunks) + return StreamOutcome( + wrapper_type=type(stream), + supports_sync_iteration=hasattr(stream, "__iter__"), + supports_async_iteration=hasattr(stream, "__aiter__"), + chunks=recorded, + chunk_types=tuple(type(chunk) for chunk in recorded), + terminal=terminal, + ) + + def consume_sync_stream(create: Callable[[], Iterable[object]]) -> StreamOutcome: try: stream: Final = create() except Exception as error: - return StreamOutcome( - wrapper_type=None, - supports_sync_iteration=None, - supports_async_iteration=None, - chunks=(), - chunk_types=(), - terminal=_failed("creation", error), - ) + return _creation_failure(error) chunks: list[object] = [] # mutable-ok: iterator consumption builds an ordered trace try: for chunk in stream: chunks.append(chunk) # noqa: PERF402 # partial trace is required if iteration raises except Exception as error: - recorded: Final = tuple(chunks) - return StreamOutcome( - wrapper_type=type(stream), - supports_sync_iteration=hasattr(stream, "__iter__"), - supports_async_iteration=hasattr(stream, "__aiter__"), - chunks=recorded, - chunk_types=tuple(type(chunk) for chunk in recorded), - terminal=_failed("iteration", error), - ) - completed_chunks: Final = tuple(chunks) - return StreamOutcome( - wrapper_type=type(stream), - supports_sync_iteration=hasattr(stream, "__iter__"), - supports_async_iteration=hasattr(stream, "__aiter__"), - chunks=completed_chunks, - chunk_types=tuple(type(chunk) for chunk in completed_chunks), - terminal=StreamCompleted(), - ) + return _stream_outcome(stream, chunks, _failed("iteration", error)) + return _stream_outcome(stream, chunks, StreamCompleted()) async def consume_async_stream(create: Callable[[], Awaitable[AsyncIterable[object]]]) -> StreamOutcome: try: stream: Final = await create() except Exception as error: - return StreamOutcome( - wrapper_type=None, - supports_sync_iteration=None, - supports_async_iteration=None, - chunks=(), - chunk_types=(), - terminal=_failed("creation", error), - ) + return _creation_failure(error) chunks: list[object] = [] # mutable-ok: iterator consumption builds an ordered trace try: async for chunk in stream: chunks.append(chunk) except Exception as error: - recorded: Final = tuple(chunks) - return StreamOutcome( - wrapper_type=type(stream), - supports_sync_iteration=hasattr(stream, "__iter__"), - supports_async_iteration=hasattr(stream, "__aiter__"), - chunks=recorded, - chunk_types=tuple(type(chunk) for chunk in recorded), - terminal=_failed("iteration", error), - ) - completed_chunks: Final = tuple(chunks) - return StreamOutcome( - wrapper_type=type(stream), - supports_sync_iteration=hasattr(stream, "__iter__"), - supports_async_iteration=hasattr(stream, "__aiter__"), - chunks=completed_chunks, - chunk_types=tuple(type(chunk) for chunk in completed_chunks), - terminal=StreamCompleted(), - ) + return _stream_outcome(stream, chunks, _failed("iteration", error)) + return _stream_outcome(stream, chunks, StreamCompleted()) def normalize_chunk(chunk: object) -> object: diff --git a/tests/rust-python-harness/shared/reporting/models.py b/tests/rust-python-harness/shared/reporting/models.py index 4ffacdce9ab..f78141fa552 100644 --- a/tests/rust-python-harness/shared/reporting/models.py +++ b/tests/rust-python-harness/shared/reporting/models.py @@ -1,17 +1,25 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass, field from enum import Enum from pathlib import Path from time import monotonic -from typing import Iterable +from typing import TYPE_CHECKING, Final, Literal, TypeAlias, assert_never + +if TYPE_CHECKING: + from .strategy import CaseSpec, StrategyDefinition class Coverage(str, Enum): COMPLETE = "complete" PARTIAL = "partial" - PLANNED = "planned" - NOT_APPLICABLE = "not_applicable" + + +class CaseDisposition(str, Enum): + RUNNABLE = "runnable" + NOT_IMPLEMENTED = "not_implemented" + SKIPPED = "skipped" class RunStatus(str, Enum): @@ -23,33 +31,45 @@ class RunStatus(str, Enum): SKIPPED = "skipped" ERROR = "error" MISSING = "missing" - PLANNED = "planned" - NOT_APPLICABLE = "not_applicable" + NOT_IMPLEMENTED = "not_implemented" -class ConfidenceLevel(str, Enum): - HIGH = "HIGH" - MEDIUM = "MEDIUM" - LOW = "LOW" - - -SDK_FUNCTIONS = ("ocr", "messages", "responses", "count_tokens", "chat_completions", "transcription") +SdkFunction: TypeAlias = Literal["ocr", "messages", "responses", "count_tokens", "chat_completions", "transcription"] +Surface: TypeAlias = Literal["sdk", "gateway"] +SURFACES: Final[tuple[Surface, ...]] = ("sdk", "gateway") +SDK_FUNCTIONS: Final[tuple[SdkFunction, ...]] = ( + "ocr", + "messages", + "responses", + "count_tokens", + "chat_completions", + "transcription", +) @dataclass(frozen=True) class HarnessCase: strategy_id: str strategy_label: str - sdk_function: str - coverage: Coverage - selectors: tuple[str, ...] - note: str = "" - surface: str = "sdk" - unit_suite: str | None = None + sdk_function: SdkFunction + spec: CaseSpec + surface: Surface | None = None @property def key(self) -> str: - return f"{self.strategy_id}:{self.sdk_function}" if self.surface == "sdk" else f"{self.strategy_id}:gateway:{self.sdk_function}" + return ( + f"{self.strategy_id}:{self.sdk_function}" + if self.surface in {None, "sdk"} + else f"{self.strategy_id}:gateway:{self.sdk_function}" + ) + + @property + def display_name(self) -> str: + return self.sdk_function if self.surface is None else f"{self.surface}/{self.sdk_function}" + + @property + def coverage(self) -> Coverage | None: + return self.spec.coverage @dataclass(frozen=True) @@ -60,6 +80,7 @@ class Strategy: description: str directory: Path cases: tuple[HarnessCase, ...] + definition: StrategyDefinition @dataclass @@ -74,6 +95,7 @@ class CaseResult: errors: int = 0 outcomes: dict[str, RunStatus] = field(default_factory=dict) durations: dict[str, float] = field(default_factory=dict) + artifacts: dict[str, tuple[ResultArtifact, ...]] = field(default_factory=dict) @property def total(self) -> int: @@ -83,10 +105,18 @@ class CaseResult: def duration(self) -> float: return sum(self.durations.values()) - def record(self, nodeid: str, status: RunStatus, duration: float = 0.0) -> None: + def record( + self, + nodeid: str, + status: RunStatus, + duration: float = 0.0, + artifacts: tuple[ResultArtifact, ...] = (), + ) -> None: """Record a terminal outcome, allowing teardown errors to replace a pass.""" self.outcomes[nodeid] = status - self.durations[nodeid] = self.durations.get(nodeid, 0.0) + duration + self.add_duration(nodeid, duration) + if artifacts: + self.artifacts[nodeid] = artifacts self.completed = set(self.outcomes) values = tuple(self.outcomes.values()) self.passed = values.count(RunStatus.PASSED) @@ -95,16 +125,25 @@ class CaseResult: self.errors = values.count(RunStatus.ERROR) self.finalize() + def add_duration(self, nodeid: str, duration: float) -> None: + self.durations[nodeid] = self.durations.get(nodeid, 0.0) + duration + def set_initial_status(self) -> None: - if self.case.coverage is Coverage.NOT_APPLICABLE: - self.status = RunStatus.NOT_APPLICABLE - elif not self.case.selectors and not self.case.unit_suite: - self.status = RunStatus.PLANNED - else: - self.status = RunStatus.QUEUED + disposition: Final = self.case.spec.disposition + match disposition: + case CaseDisposition.RUNNABLE: + self.status = RunStatus.QUEUED + return + case CaseDisposition.NOT_IMPLEMENTED: + self.status = RunStatus.NOT_IMPLEMENTED + return + case CaseDisposition.SKIPPED: + self.status = RunStatus.SKIPPED + return + assert_never(disposition) def finalize(self) -> None: - if self.status in {RunStatus.NOT_APPLICABLE, RunStatus.PLANNED}: + if self.status in {RunStatus.NOT_IMPLEMENTED, RunStatus.SKIPPED} and not self.collected: return if not self.collected: self.status = RunStatus.MISSING @@ -118,11 +157,18 @@ class CaseResult: self.status = RunStatus.SKIPPED +@dataclass(frozen=True, slots=True) +class ResultArtifact: + kind: str + body: str + + @dataclass class HarnessRun: results: dict[str, CaseResult] current_nodeid: str | None = None failures: list[tuple[str, str]] = field(default_factory=list) + strategy_durations: dict[str, float] = field(default_factory=dict) started_at: float = field(default_factory=monotonic) finished_at: float | None = None @@ -131,92 +177,20 @@ class HarnessRun: return (self.finished_at or monotonic()) - self.started_at @property - def unique_tests(self) -> int: + def unique_checks(self) -> int: return len( {nodeid for result in self.results.values() for nodeid in result.collected} ) @property - def completed_tests(self) -> int: + def completed_checks(self) -> int: return len( {nodeid for result in self.results.values() for nodeid in result.completed} ) @classmethod - def from_cases(cls, cases: Iterable[HarnessCase]) -> "HarnessRun": + def from_cases(cls, cases: Iterable[HarnessCase]) -> HarnessRun: results = {case.key: CaseResult(case=case) for case in cases} for result in results.values(): result.set_initial_status() return cls(results=results) - - -@dataclass(frozen=True) -class SectionConfidence: - sdk_function: str - verified_strategies: int - required_strategies: int - level: ConfidenceLevel - details: tuple[str, ...] - - @property - def percentage(self) -> int: - if not self.required_strategies: - return 0 - return round(100 * self.verified_strategies / self.required_strategies) - - -def section_confidence( - run: HarnessRun, strategies: Iterable[Strategy] -) -> tuple[SectionConfidence, ...]: - strategy_list = tuple(strategies) - scores: list[SectionConfidence] = [] - sections = tuple(dict.fromkeys((case.surface, case.sdk_function) for strategy in strategy_list for case in strategy.cases)) - for surface, sdk_function in sections: - cases = tuple( - case - for strategy in strategy_list - for case in strategy.cases - if case.sdk_function == sdk_function and case.surface == surface - and case.coverage is not Coverage.NOT_APPLICABLE - ) - verified = 0 - details: list[str] = [] - for case in cases: - result = run.results.get(case.key) - status = result.status if result is not None else RunStatus.NOT_RUN - if status is RunStatus.PASSED: - verified += 1 - details.append( - f"{STATUS_LABELS[status]} {case.strategy_id} ({case.coverage.value})" - ) - required = len(cases) - if required and verified == required: - level = ConfidenceLevel.HIGH - elif verified: - level = ConfidenceLevel.MEDIUM - else: - level = ConfidenceLevel.LOW - scores.append( - SectionConfidence( - sdk_function=sdk_function if surface == "sdk" else f"gateway/{sdk_function}", - verified_strategies=verified, - required_strategies=required, - level=level, - details=tuple(details), - ) - ) - return tuple(scores) - - -STATUS_LABELS = { - RunStatus.NOT_RUN: "·", - RunStatus.QUEUED: "○", - RunStatus.RUNNING: "◉", - RunStatus.PASSED: "✓", - RunStatus.FAILED: "✗", - RunStatus.SKIPPED: "↷", - RunStatus.ERROR: "!", - RunStatus.MISSING: "?", - RunStatus.PLANNED: "—", - RunStatus.NOT_APPLICABLE: "n/a", -} diff --git a/tests/rust-python-harness/shared/reporting/orchestration.py b/tests/rust-python-harness/shared/reporting/orchestration.py index 5e1c0ff57d8..4740f25dc9c 100644 --- a/tests/rust-python-harness/shared/reporting/orchestration.py +++ b/tests/rust-python-harness/shared/reporting/orchestration.py @@ -1,29 +1,31 @@ from __future__ import annotations -from collections.abc import Callable, Sequence +from collections.abc import Sequence from pathlib import Path from time import monotonic -from typing import Final, Protocol +from typing import Final -from .models import HarnessCase, HarnessRun -from .pytest_runner import UpdateCallback +from .models import HarnessRun, Strategy +from .strategy import StrategyRunner, UpdateCallback + +__all__ = ["StrategyRunner", "run_strategies"] -class StrategyRunner(Protocol): - def __call__( - self, - cases: Sequence[HarnessCase], - repo_root: Path, - on_update: UpdateCallback, - pytest_args: Sequence[str] = (), - ) -> tuple[int, HarnessRun]: ... - - -def combine_reports(reports: Sequence[HarnessRun]) -> HarnessRun: +def combine_reports( + reports: Sequence[HarnessRun], + *, + timed_reports: Sequence[HarnessRun] | None = None, +) -> HarnessRun: + duration_sources: Final = reports if timed_reports is None else timed_reports return HarnessRun( results={key: result for report in reports for key, result in report.results.items()}, current_nodeid=next((report.current_nodeid for report in reversed(reports) if report.current_nodeid), None), failures=[failure for report in reports for failure in report.failures], + strategy_durations={ + strategy_id: report.duration + for report in duration_sources + for strategy_id in {result.case.strategy_id for result in report.results.values()} + }, started_at=min((report.started_at for report in reports), default=monotonic()), finished_at=( max((report.finished_at for report in reports if report.finished_at is not None), default=None) @@ -34,32 +36,38 @@ def combine_reports(reports: Sequence[HarnessRun]) -> HarnessRun: def run_strategies( - cases: Sequence[HarnessCase], + strategies: Sequence[Strategy], repo_root: Path, on_update: UpdateCallback, - pytest_args: Sequence[str], - resolve_runner: Callable[[str], StrategyRunner], + runner_args: Sequence[str] = (), ) -> tuple[int, HarnessRun]: - strategy_ids: Final = tuple(dict.fromkeys(case.strategy_id for case in cases)) + cases: Final = tuple(case for strategy in strategies for case in strategy.cases) def execute( - remaining: tuple[str, ...], reports: tuple[HarnessRun, ...], codes: tuple[int, ...] + remaining: tuple[Strategy, ...], reports: tuple[HarnessRun, ...], codes: tuple[int, ...] ) -> tuple[int, HarnessRun]: if not remaining: combined: Final = combine_reports(reports) on_update(combined) return next((code for code in codes if code), 0), combined - strategy_id, *tail = remaining - selected: Final = tuple(case for case in cases if case.strategy_id == strategy_id) - pending: Final = HarnessRun.from_cases(case for case in cases if case.strategy_id in tail) - code, report = resolve_runner(strategy_id)( + strategy, *tail = remaining + selected: Final = tuple(case for case in cases if case.strategy_id == strategy.id) + pending: Final = HarnessRun.from_cases( + case for case in cases if case.strategy_id in {later.id for later in tail} + ) + code, report = strategy.definition.run( selected, repo_root, - lambda current: on_update(combine_reports((*reports, current, pending))), - pytest_args, + lambda current: on_update( + combine_reports( + (*reports, current, pending), + timed_reports=(*reports, current), + ) + ), + runner_args, ) if code in {2, 3, 4}: return code, combine_reports((*reports, report, pending)) return execute(tuple(tail), (*reports, report), (*codes, code)) - return execute(strategy_ids, (), ()) + return execute(tuple(strategies), (), ()) diff --git a/tests/rust-python-harness/shared/reporting/pytest_runner.py b/tests/rust-python-harness/shared/reporting/pytest_runner.py deleted file mode 100644 index a7e73308f30..00000000000 --- a/tests/rust-python-harness/shared/reporting/pytest_runner.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import annotations - -import os -from collections.abc import Callable, Sequence -from pathlib import Path -from time import monotonic -from typing import Final - -import pytest - -from .models import CaseResult, HarnessCase, HarnessRun, RunStatus - -UpdateCallback = Callable[[HarnessRun], None] - - -def selector_matches_node(selector: str, nodeid: str) -> bool: - normalized_selector = selector.replace("\\", "/") - normalized_nodeid = nodeid.replace("\\", "/") - if normalized_selector.endswith("/"): - return normalized_nodeid.startswith(normalized_selector) - if "::" in normalized_selector: - return normalized_nodeid == normalized_selector or normalized_nodeid.startswith( - f"{normalized_selector}[" - ) - return normalized_nodeid == normalized_selector or normalized_nodeid.startswith( - f"{normalized_selector}::" - ) - - -def selector_path(selector: str) -> Path: - return Path(selector.split("::", 1)[0]) - - -def runnable_selectors( - cases: Sequence[HarnessCase], repo_root: Path -) -> tuple[str, ...]: - selectors = { - selector - for case in cases - for selector in case.selectors - if (repo_root / selector_path(selector)).exists() - } - return tuple(sorted(selectors)) - - -class HarnessPytestPlugin: - def __init__(self, run: HarnessRun, on_update: UpdateCallback) -> None: - self.run = run - self.on_update = on_update - self.node_to_results: dict[str, list[CaseResult]] = {} - - def _notify(self) -> None: - self.on_update(self.run) - - def pytest_collection_modifyitems(self, items: list[pytest.Item]) -> None: - for item in items: - matched_results: list[CaseResult] = [] - for result in self.run.results.values(): - if any( - selector_matches_node(selector, item.nodeid) - for selector in result.case.selectors - ): - result.collected.add(item.nodeid) - matched_results.append(result) - if matched_results: - self.node_to_results[item.nodeid] = matched_results - for result in self.run.results.values(): - if result.status is RunStatus.QUEUED and not result.collected: - result.status = RunStatus.MISSING - self._notify() - - def pytest_runtest_logstart( - self, nodeid: str, location: tuple[str, int | None, str] - ) -> None: - del location - self.run.current_nodeid = nodeid - for result in self.node_to_results.get(nodeid, []): - if result.status not in {RunStatus.FAILED, RunStatus.ERROR}: - result.status = RunStatus.RUNNING - self._notify() - - def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: - if report.when not in {"setup", "call", "teardown"}: - return - results = self.node_to_results.get(report.nodeid, []) - if not results: - return - - terminal = report.when == "call" or report.failed or report.skipped - if not terminal: - for result in results: - result.durations[report.nodeid] = ( - result.durations.get(report.nodeid, 0.0) + report.duration - ) - return - for result in results: - if report.when == "teardown" and not report.failed: - result.durations[report.nodeid] = ( - result.durations.get(report.nodeid, 0.0) + report.duration - ) - continue - if report.skipped: - status = RunStatus.SKIPPED - elif report.failed and report.when in {"setup", "teardown"}: - status = RunStatus.ERROR - elif report.failed: - status = RunStatus.FAILED - else: - status = RunStatus.PASSED - result.record(report.nodeid, status, report.duration) - if report.failed: - failure = (report.nodeid, str(report.longrepr)) - if failure not in self.run.failures: - self.run.failures.append(failure) - self._notify() - - def pytest_sessionfinish( - self, session: pytest.Session, exitstatus: int | pytest.ExitCode - ) -> None: - del session, exitstatus - self.run.current_nodeid = None - self.run.finished_at = monotonic() - for result in self.run.results.values(): - result.finalize() - self._notify() - - -def run_pytest( - cases: Sequence[HarnessCase], - repo_root: Path, - on_update: UpdateCallback, - pytest_args: Sequence[str] = (), -) -> tuple[int, HarnessRun]: - run = HarnessRun.from_cases(cases) - selectors = runnable_selectors(cases, repo_root) - if not selectors: - for result in run.results.values(): - result.finalize() - run.finished_at = monotonic() - on_update(run) - has_missing_test = any( - result.status is RunStatus.MISSING for result in run.results.values() - ) - exit_code = ( - int(pytest.ExitCode.TESTS_FAILED) - if has_missing_test - else int(pytest.ExitCode.OK) - ) - return exit_code, run - - plugin = HarnessPytestPlugin(run=run, on_update=on_update) - args: Final = (*selectors, "-q", "--tb=no", "--no-summary", "-o", "consider_namespace_packages=true", *pytest_args) - previous_directory = Path.cwd() - try: - os.chdir(repo_root) - exit_code = int(pytest.main(list(args), plugins=[plugin])) - finally: - os.chdir(previous_directory) - for result in run.results.values(): - missing = tuple( - selector for selector in result.case.selectors - if not any(selector_matches_node(selector, node) for node in result.collected) - ) - if missing: - result.status = RunStatus.MISSING - run.failures.extend((selector, "Configured selector collected no tests") for selector in missing) - on_update(run) - if exit_code == 0 and any( - result.status is RunStatus.MISSING for result in run.results.values() - ): - exit_code = int(pytest.ExitCode.TESTS_FAILED) - return exit_code, run diff --git a/tests/rust-python-harness/shared/reporting/rendering.py b/tests/rust-python-harness/shared/reporting/rendering.py new file mode 100644 index 00000000000..217caa48526 --- /dev/null +++ b/tests/rust-python-harness/shared/reporting/rendering.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Final, Protocol, assert_never + +from .models import CaseDisposition, CaseResult + + +@dataclass(frozen=True, slots=True) +class ReportSection: + title: str + blocks: tuple[str, ...] + + +class StrategyRenderer(Protocol): + def __call__(self, results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: ... + + +def render_case_outcome(result: CaseResult) -> str: + prefix: Final = f"- {result.case.display_name}: {result.status.value}" + spec: Final = result.case.spec + match spec.disposition: + case CaseDisposition.RUNNABLE: + progress: Final = f", {len(result.completed)}/{result.total} checks" if result.total else "" + return f"{prefix}{progress}, {spec.coverage.value} coverage" + case CaseDisposition.NOT_IMPLEMENTED | CaseDisposition.SKIPPED: + return f"{prefix}, {spec.reason}" + assert_never(spec.disposition) diff --git a/tests/rust-python-harness/shared/reporting/strategy.py b/tests/rust-python-harness/shared/reporting/strategy.py new file mode 100644 index 00000000000..7e76f035e20 --- /dev/null +++ b/tests/rust-python-harness/shared/reporting/strategy.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated, Literal, Protocol, TypeAlias + +from pydantic import BaseModel, ConfigDict, StringConstraints + +from .models import CaseDisposition, Coverage, HarnessCase, HarnessRun, SdkFunction, Surface +from .rendering import StrategyRenderer + +UpdateCallback: TypeAlias = Callable[[HarnessRun], None] +NonBlankString: TypeAlias = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] + + +class SuiteCaseSpec(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + disposition: Literal[CaseDisposition.RUNNABLE] = CaseDisposition.RUNNABLE + coverage: Coverage + suite: NonBlankString + note: str = "" + + +class ModuleCaseSpec(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + disposition: Literal[CaseDisposition.RUNNABLE] = CaseDisposition.RUNNABLE + coverage: Coverage + module: NonBlankString + note: str = "" + + +class NotImplementedCaseSpec(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + disposition: Literal[CaseDisposition.NOT_IMPLEMENTED] = CaseDisposition.NOT_IMPLEMENTED + coverage: None = None + reason: NonBlankString + + +class SkippedCaseSpec(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + disposition: Literal[CaseDisposition.SKIPPED] = CaseDisposition.SKIPPED + coverage: None = None + reason: NonBlankString + + +RunnableCaseSpec: TypeAlias = SuiteCaseSpec | ModuleCaseSpec +UnavailableCaseSpec: TypeAlias = NotImplementedCaseSpec | SkippedCaseSpec +CaseSpec: TypeAlias = RunnableCaseSpec | UnavailableCaseSpec + + +@dataclass(frozen=True, slots=True) +class CaseDefinition: + sdk_function: SdkFunction + spec: CaseSpec + surface: Surface | None = None + + +@dataclass(frozen=True, slots=True) +class RunnerArgumentDefinition: + option: str + help: str + metavar: str = "ARG" + + +class StrategyRunner(Protocol): + def __call__( + self, + cases: Sequence[HarnessCase], + repo_root: Path, + on_update: UpdateCallback, + runner_args: Sequence[str] = (), + ) -> tuple[int, HarnessRun]: ... + + +@dataclass(frozen=True, slots=True) +class StrategyDefinition: + id: str + order: int + label: str + description: str + directory: Path + runnable_spec: type[SuiteCaseSpec] | type[ModuleCaseSpec] + cases: tuple[CaseDefinition, ...] + run: StrategyRunner + render: StrategyRenderer + surfaces: tuple[Surface, ...] = () + runner_argument: RunnerArgumentDefinition | None = None diff --git a/tests/rust-python-harness/shared/reporting/test_orchestration.py b/tests/rust-python-harness/shared/reporting/test_orchestration.py index 8aea6d67e6e..7696ab781e2 100644 --- a/tests/rust-python-harness/shared/reporting/test_orchestration.py +++ b/tests/rust-python-harness/shared/reporting/test_orchestration.py @@ -1,47 +1,161 @@ from __future__ import annotations +import logging +from collections.abc import Sequence from pathlib import Path +from time import monotonic from typing import Final -from .models import Coverage, HarnessCase, RunStatus +from .models import CaseResult, Coverage, HarnessCase, HarnessRun, RunStatus, Strategy from .orchestration import run_strategies -from .pytest_runner import run_pytest +from .rendering import ReportSection, StrategyRenderer, render_case_outcome +from .strategy import ( + CaseDefinition, + ModuleCaseSpec, + NotImplementedCaseSpec, + StrategyDefinition, + UpdateCallback, +) +from .ui import HarnessOutputFilter, final_report -def test_combines_independent_strategy_reports_and_keeps_failures(tmp_path: Path) -> None: - (tmp_path / "test_first.py").write_text("def test_first():\n assert 1 == 2\n") - (tmp_path / "test_second.py").write_text("def test_second():\n assert True\n") - cases: Final = tuple( - HarnessCase( - strategy_id=name, - strategy_label=name, - sdk_function="ocr", - coverage=Coverage.COMPLETE, - selectors=(f"test_{name}.py",), - ) - for name in ("first", "second") +def _run_cases( + cases: Sequence[HarnessCase], + repo_root: Path, + on_update: UpdateCallback, + runner_args: Sequence[str] = (), +) -> tuple[int, HarnessRun]: + del repo_root, runner_args + run: Final = HarnessRun.from_cases(cases) + for case in cases: + _record_case(run, case, on_update) + run.finished_at = monotonic() + return int(bool(run.failures)), run + + +def _record_case(run: HarnessRun, case: HarnessCase, on_update: UpdateCallback) -> None: + result: Final = run.results[case.key] + nodeid: Final = f"check:{case.strategy_id}:{case.sdk_function}" + result.collected.add(nodeid) + failed: Final = isinstance(case.spec, ModuleCaseSpec) and case.spec.module == "fail" + result.record(nodeid, RunStatus.FAILED if failed else RunStatus.PASSED) + if failed: + run.failures.append((nodeid, "comparison failed")) + on_update(run) + + +def _render_test_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + return (ReportSection("Test outcomes", tuple(render_case_outcome(result) for result in results)),) + + +def _strategy(name: str, module: str, *, render: StrategyRenderer = _render_test_results) -> Strategy: + case_definition: Final = CaseDefinition("ocr", ModuleCaseSpec(coverage=Coverage.COMPLETE, module=module)) + definition: Final = StrategyDefinition( + id=name, + order=1, + label=name, + description="Example strategy", + directory=Path.cwd(), + runnable_spec=ModuleCaseSpec, + cases=(case_definition,), + run=_run_cases, + render=render, ) - code, report = run_strategies(cases, tmp_path, lambda _: None, (), lambda _: run_pytest) + case: Final = HarnessCase( + strategy_id=name, + strategy_label=name, + sdk_function="ocr", + spec=case_definition.spec, + ) + return Strategy(1, name, name, "", Path.cwd(), (case,), definition) + + +def test_combines_strategy_reports_and_delegates_rendering() -> None: + strategies: Final = (_strategy("first", "fail"), _strategy("second", "pass")) + + code, report = run_strategies(strategies, Path.cwd(), lambda _: None) + assert code == 1 assert report.results["first:ocr"].status is RunStatus.FAILED assert report.results["second:ocr"].status is RunStatus.PASSED - assert report.completed_tests == 2 - assert len(report.failures) == 1 - assert "assert 1 == 2" in report.failures[0][1] - assert "terminalreporter" not in report.failures[0][1] + assert report.completed_checks == 2 + rendered: Final = final_report(report, code, strategies) + assert "Result: FAILED" in rendered + assert rendered.count("Test outcomes") == 2 + assert "- ocr: failed, 1/1 checks, complete coverage" in rendered + assert "- ocr: passed, 1/1 checks, complete coverage" in rendered + assert "Failures (showing 1 of 1)" in rendered + assert "Port confidence" not in rendered + assert "Slowest tests" not in rendered -def test_missing_selector_cannot_hide_behind_a_passing_surface(tmp_path: Path) -> None: - (tmp_path / "test_present.py").write_text("def test_present():\n assert True\n") - case: Final = HarnessCase( - strategy_id="e2e_parity", - strategy_label="End-to-end parity", - sdk_function="ocr", - surface="gateway", - coverage=Coverage.PARTIAL, - selectors=("test_present.py", "test_missing.py"), +def test_strategy_can_replace_the_generic_result_view() -> None: + def render_custom(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + del results + return (ReportSection("Custom comparison", ("domain-owned diff",)),) + + strategy: Final = _strategy("custom", "pass", render=render_custom) + code, report = run_strategies((strategy,), Path.cwd(), lambda _: None) + + rendered: Final = final_report(report, code, (strategy,)) + assert "Custom comparison\ndomain-owned diff" in rendered + assert "sdk/ocr" not in rendered + + +def test_report_separates_successful_execution_from_incomplete_coverage() -> None: + runnable: Final = _strategy("mixed", "pass") + unavailable: Final = HarnessCase( + strategy_id="mixed", + strategy_label="mixed", + sdk_function="messages", + spec=NotImplementedCaseSpec(reason="No Messages case is registered."), ) - code, report = run_pytest((case,), tmp_path, lambda _: None) - assert code == 1 - assert report.results["e2e_parity:gateway:ocr"].status is RunStatus.MISSING - assert ("test_missing.py", "Configured selector collected no tests") in report.failures + code, executed = run_strategies((runnable,), Path.cwd(), lambda _: None) + unavailable_run: Final = HarnessRun.from_cases((unavailable,)) + combined: Final = HarnessRun( + results={**executed.results, **unavailable_run.results}, + started_at=executed.started_at, + finished_at=executed.finished_at, + ) + + rendered: Final = final_report(combined, code, (runnable,)) + + assert code == 0 + assert "Result: PASSED" in rendered + assert "Harness support: 1/2 cases implemented" in rendered + assert "Cases: 2 selected, 1 not implemented, 0 skipped" in rendered + + +def test_harness_output_filter_suppresses_expected_harness_warnings() -> None: + output_filter: Final = HarnessOutputFilter() + ocr_cost_warning: Final = logging.LogRecord( + "LiteLLM", + logging.WARNING, + "/repo/litellm/cost_calculator.py", + 1953, + "OCR cost: model=%s has no pricing", + ("example",), + None, + ) + other_warning: Final = logging.LogRecord( + "LiteLLM", + logging.WARNING, + "/repo/litellm/main.py", + 1, + "Provider warning", + (), + None, + ) + loop_warning: Final = logging.LogRecord( + "LiteLLM", + logging.WARNING, + "/repo/litellm/litellm_core_utils/logging_worker.py", + 129, + "LoggingWorker: event loop changed; carried %d pending and revived %d dequeued logging task(s) onto the new loop", + (1, 0), + None, + ) + + assert output_filter.filter(ocr_cost_warning) is False + assert output_filter.filter(loop_warning) is False + assert output_filter.filter(other_warning) is True diff --git a/tests/rust-python-harness/shared/reporting/ui.py b/tests/rust-python-harness/shared/reporting/ui.py index 3807af8c53b..e4b1b7b0442 100644 --- a/tests/rust-python-harness/shared/reporting/ui.py +++ b/tests/rust-python-harness/shared/reporting/ui.py @@ -1,45 +1,39 @@ from __future__ import annotations -import os -import shlex -import sys +import logging from collections.abc import Sequence from contextlib import AbstractContextManager -from pathlib import Path -from typing import Any +from textwrap import indent +from types import TracebackType +from typing import TYPE_CHECKING, Final -from .models import ( - Coverage, - HarnessRun, - RunStatus, - Strategy, - section_confidence, -) +from litellm._logging import handler as litellm_log_handler -STATUS_GLYPHS = { - RunStatus.NOT_RUN: "·", - RunStatus.QUEUED: "○", - RunStatus.RUNNING: "◉", - RunStatus.PASSED: "✓", - RunStatus.FAILED: "✗", - RunStatus.SKIPPED: "↷", - RunStatus.ERROR: "!", - RunStatus.MISSING: "?", - RunStatus.PLANNED: "—", - RunStatus.NOT_APPLICABLE: "n/a", -} +from .models import HarnessRun, RunStatus, Strategy +from .rendering import ReportSection -STATUS_STYLES = { - RunStatus.QUEUED: "dim", - RunStatus.RUNNING: "bold cyan", - RunStatus.PASSED: "bold green", - RunStatus.FAILED: "bold red", - RunStatus.SKIPPED: "yellow", - RunStatus.ERROR: "bold red", - RunStatus.MISSING: "magenta", - RunStatus.PLANNED: "dim", - RunStatus.NOT_APPLICABLE: "dim", -} +if TYPE_CHECKING: + from rich.live import Live + + +class HarnessOutputFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + noisy_prefixes: Final = ( + "OCR cost:", + "LoggingWorker: event loop changed;", + ) + return not (record.name == "LiteLLM" and record.getMessage().startswith(noisy_prefixes)) + + +_HARNESS_OUTPUT_FILTER: Final = HarnessOutputFilter() + + +def _start_output_filtering() -> None: + litellm_log_handler.addFilter(_HARNESS_OUTPUT_FILTER) + + +def _stop_output_filtering() -> None: + litellm_log_handler.removeFilter(_HARNESS_OUTPUT_FILTER) def _format_duration(seconds: float) -> str: @@ -50,250 +44,221 @@ def _format_duration(seconds: float) -> str: return f"{int(seconds // 60)}m {seconds % 60:.0f}s" -def _rerun_command(nodeid: str) -> str: - if nodeid.startswith("unit-suite:"): - return "uv run python -m tests.rust-python-harness.strategies.unit_tests.runner --plain" - return f"poetry run pytest {shlex.quote(nodeid)} -q -o consider_namespace_packages=true" - - def _summary(run: HarnessRun) -> tuple[int, int, int, int]: outcomes: dict[str, RunStatus] = {} for result in run.results.values(): outcomes.update(result.outcomes) + values: Final = tuple(outcomes.values()) return ( - list(outcomes.values()).count(RunStatus.PASSED), - list(outcomes.values()).count(RunStatus.FAILED), - list(outcomes.values()).count(RunStatus.ERROR), - list(outcomes.values()).count(RunStatus.SKIPPED), + values.count(RunStatus.PASSED), + values.count(RunStatus.FAILED), + values.count(RunStatus.ERROR), + values.count(RunStatus.SKIPPED), ) -def _cell_text(run: HarnessRun, strategy_id: str, sdk_function: str, surface: str = "sdk") -> tuple[str, str]: - key = f"{strategy_id}:{sdk_function}" if surface == "sdk" else f"{strategy_id}:gateway:{sdk_function}" - result = run.results.get(key) - if result is None: - return "", "" - counts = "" - if result.total: - counts = f" {len(result.completed)}/{result.total}" - coverage = " ◐" if result.case.coverage is Coverage.PARTIAL else "" - return f"{STATUS_GLYPHS[result.status]}{counts}{coverage}", STATUS_STYLES.get( - result.status, "" +def _strategy_state(statuses: tuple[RunStatus, ...], outcomes: tuple[RunStatus, ...]) -> str: + for status in ( + RunStatus.ERROR, + RunStatus.FAILED, + RunStatus.MISSING, + RunStatus.RUNNING, + RunStatus.QUEUED, + ): + if status in statuses: + return status.value + if RunStatus.NOT_IMPLEMENTED in statuses: + return RunStatus.NOT_IMPLEMENTED.value + if outcomes and all(outcome is RunStatus.SKIPPED for outcome in outcomes): + return RunStatus.SKIPPED.value + if statuses and all(status is RunStatus.SKIPPED for status in statuses): + return RunStatus.SKIPPED.value + for status in (RunStatus.PASSED, RunStatus.SKIPPED): + if status in statuses: + return status.value + return RunStatus.NOT_RUN.value + + +def _strategy_line(strategy: Strategy, run: HarnessRun) -> str: + results: Final = tuple(run.results[case.key] for case in strategy.cases if case.key in run.results) + outcomes: dict[str, RunStatus] = {} + collected: set[str] = set() + for result in results: + outcomes.update(result.outcomes) + collected.update(result.collected) + values: Final = tuple(outcomes.values()) + statuses: Final = tuple(result.status for result in results) + state: Final = _strategy_state(statuses, values) + completed: Final = len(outcomes) + total: Final = len(collected) + progress: Final = f", {completed}/{total} checks" if total else "" + counts: Final = ( + f", {values.count(RunStatus.PASSED)} passed, " + f"{values.count(RunStatus.FAILED) + values.count(RunStatus.ERROR)} failed, " + f"{values.count(RunStatus.SKIPPED)} skipped" + if total + else "" ) + duration: Final = run.strategy_durations.get(strategy.id, 0.0) + return f"- {strategy.label}: {state}{progress}{counts}, {_format_duration(duration)}" + + +def _rendered_sections(run: HarnessRun, strategies: Sequence[Strategy]) -> tuple[ReportSection, ...]: + return tuple( + section + for strategy in strategies + if any(case.key in run.results for case in strategy.cases) + for section in strategy.definition.render( + tuple(run.results[case.key] for case in strategy.cases if case.key in run.results) + ) + ) + + +def _format_section(section: ReportSection) -> str: + return f"{section.title}\n" + ("\n\n".join(section.blocks) or "- No results") + + +def _run_result(run: HarnessRun, exit_code: int) -> str: + statuses: Final = tuple(result.status for result in run.results.values()) + if exit_code: + return "FAILED" + if not statuses or all(status is RunStatus.NOT_IMPLEMENTED for status in statuses): + return "NOT RUN" + if all(status is RunStatus.SKIPPED for status in statuses): + return "SKIPPED" + return "PASSED" + + +def final_report(run: HarnessRun, exit_code: int, strategies: Sequence[Strategy]) -> str: + passed, failed, errors, skipped = _summary(run) + run_result: Final = _run_result(run, exit_code) + statuses: Final = tuple(case_result.status for case_result in run.results.values()) + not_implemented: Final = statuses.count(RunStatus.NOT_IMPLEMENTED) + implemented: Final = len(statuses) - not_implemented + skipped_cells: Final = statuses.count(RunStatus.SKIPPED) + failure_lines: Final = tuple( + f"{index}. {nodeid}\n{indent(detail.strip(), ' ')}" + for index, (nodeid, detail) in enumerate(run.failures[:5], start=1) + ) + rendered: Final = tuple(_format_section(section) for section in _rendered_sections(run, strategies)) + summary: Final = ( + "Rust <-> Python parity report\n\n" + f"Result: {run_result}\n" + f"Harness support: {implemented}/{len(statuses)} cases implemented\n" + f"Cases: {len(statuses)} selected, {not_implemented} not implemented, {skipped_cells} skipped\n" + f"Checks: {run.completed_checks}/{run.unique_checks} completed, {passed} passed, " + f"{failed} failed, {errors} errors, {skipped} skipped\n" + f"Duration: {_format_duration(run.duration)}\n" + f"Exit code: {exit_code}" + ) + failures: Final = ( + (f"Failures (showing {len(failure_lines)} of {len(run.failures)})\n" + "\n\n".join(failure_lines)) + if failure_lines + else "" + ) + return "\n\n".join((summary, *rendered, *((failures,) if failures else ()))) class RichDashboard(AbstractContextManager["RichDashboard"]): - def __init__( - self, - strategies: Sequence[Strategy], - confidence_strategies: Sequence[Strategy], - ) -> None: + def __init__(self, strategies: Sequence[Strategy]) -> None: from rich.console import Console from rich.live import Live self.strategies = strategies - self.confidence_strategies = confidence_strategies self.console = Console() - self.live: Any = Live( - console=self.console, refresh_per_second=12, transient=False - ) + self.live: Live = Live(console=self.console, refresh_per_second=12, transient=True) + self._live_active = False - def _table(self, run: HarnessRun) -> Any: - from rich import box - from rich.table import Table - from rich.text import Text - - columns = tuple(dict.fromkeys((case.surface, case.sdk_function) for strategy in self.strategies for case in strategy.cases)) - narrow = self.console.width < 96 - if narrow: - table = Table(box=box.SIMPLE_HEAVY, expand=True, show_header=False) - table.add_column("Strategy", ratio=3) - table.add_column("Results", ratio=5) - for strategy in self.strategies: - values = [] - for surface, sdk_function in columns: - value, style = _cell_text(run, strategy.id, sdk_function, surface) - if value: - values.append( - Text.assemble((f"{surface}/{sdk_function} ", "dim"), (value, style)) - ) - table.add_row(strategy.label, Text(" ").join(values)) - return table - - table = Table(box=box.ROUNDED, expand=True, title="Strategy × API") - table.add_column("Strategy", ratio=3) - for surface, label in columns: - table.add_column(label if surface == "sdk" else f"gateway/{label}", justify="center", ratio=1) - for strategy in self.strategies: - cells = [] - for surface, sdk_function in columns: - value, style = _cell_text(run, strategy.id, sdk_function, surface) - cells.append(Text(value, style=style)) - table.add_row(strategy.label, *cells) - return table - - def __enter__(self) -> "RichDashboard": + def __enter__(self) -> RichDashboard: + _start_output_filtering() self.live.__enter__() + self._live_active = True return self - def __exit__(self, *args: object) -> None: - self.live.__exit__(*args) + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + _stop_output_filtering() + if self._live_active: + self.live.__exit__(exc_type, exc_value, traceback) + self._live_active = False def update(self, run: HarnessRun) -> None: from rich.markup import escape - from rich.panel import Panel - active = run.current_nodeid or "Waiting for test events…" - if len(active) > max(40, self.console.width - 16): - active = f"…{active[-(self.console.width - 17):]}" + active: Final = run.current_nodeid or "Waiting for test events..." + available_width: Final = max(40, self.console.width - 10) + visible_active: Final = active if len(active) <= available_width else f"...{active[-(available_width - 3) :]}" passed, failed, errors, skipped = _summary(run) - progress = ( - f"[bold]{run.completed_tests}/{run.unique_tests}[/bold] tests " - f"[green]{passed} passed[/green] [red]{failed + errors} failed[/red] " - f"[yellow]{skipped} skipped[/yellow] [dim]{_format_duration(run.duration)}[/dim]" - ) - legend = "✓ pass ✗ fail ! error ↷ skip\n? configured test missing — planned ◐ partial coverage" + total: Final = run.unique_checks + percentage: Final = round(100 * run.completed_checks / total) if total else 0 + strategy_lines: Final = "\n".join(escape(_strategy_line(strategy, run)) for strategy in self.strategies) self.live.update( - Panel( - self._table(run), - title="⚡ Rust ↔ Python parity lab", - subtitle=f"{progress}\n[dim]{escape(active)}[/dim]\n{legend}", - border_style="cyan", - ) + "[bold]Running Rust <-> Python parity[/bold]\n" + f"Progress: [bold]{run.completed_checks}/{total} ({percentage}%)[/bold] | " + f"[green]{passed} passed[/green] | [red]{failed + errors} failed[/red] | " + f"[yellow]{skipped} skipped[/yellow] | [dim]{_format_duration(run.duration)}[/dim]\n" + f"Strategies:\n{strategy_lines}\n" + f"Current: [dim]{escape(visible_active)}[/dim]" ) def finish(self, run: HarnessRun, exit_code: int) -> None: - self.update(run) - if run.failures: - from rich.markup import escape - from rich.panel import Panel - - for nodeid, detail in run.failures[:5]: - rerun = _rerun_command(nodeid) - self.console.print( - Panel( - f"{escape(detail)}\n\n[bold]Rerun just this test[/bold]\n" - f"[cyan]{escape(rerun)}[/cyan]", - title=f"✗ {escape(nodeid)}", - border_style="red", - ) - ) - durations: dict[str, float] = {} - for result in run.results.values(): - for nodeid, duration in result.durations.items(): - durations[nodeid] = max(duration, durations.get(nodeid, 0.0)) - if durations: - slow = sorted(durations.items(), key=lambda item: item[1], reverse=True)[:3] - self.console.print( - "[bold]Slowest tests[/bold] " - + " • ".join( - f"{Path(nodeid).name} [dim]{_format_duration(duration)}[/dim]" - for nodeid, duration in slow - ) - ) - from rich import box - from rich.table import Table - - confidence_table = Table( - title="Port confidence by API", box=box.ROUNDED, expand=True - ) - confidence_table.add_column("SDK section") - confidence_table.add_column("Score", justify="right") - confidence_table.add_column("Confidence") - confidence_table.add_column("Strategy evidence", ratio=4) - confidence_styles = {"HIGH": "green", "MEDIUM": "yellow", "LOW": "red"} - for score in section_confidence(run, self.confidence_strategies): - confidence_table.add_row( - score.sdk_function, - f"{score.verified_strategies}/{score.required_strategies} {score.percentage}%", - f"[{confidence_styles[score.level.value]}]{score.level.value}[/]", - " ".join(score.details), - ) - self.console.print(confidence_table) - self.console.print( - "[dim]Score = required strategies with passing evidence. " - "LOC coverage remains a separate report.[/dim]" - ) - style = "green" if exit_code == 0 else "red" - self.console.print( - f"[{style}]Harness finished in {_format_duration(run.duration)} " - f"(exit {exit_code})[/{style}]" - ) + if self._live_active: + self.live.stop() + self._live_active = False + print(final_report(run, exit_code, self.strategies), flush=True) # noqa: T201 # CLI output class PlainDashboard(AbstractContextManager["PlainDashboard"]): - def __init__( - self, - strategies: Sequence[Strategy], - confidence_strategies: Sequence[Strategy], - ) -> None: + def __init__(self, strategies: Sequence[Strategy]) -> None: self.strategies = strategies - self.confidence_strategies = confidence_strategies self._seen: dict[str, tuple[RunStatus, int]] = {} - def __enter__(self) -> "PlainDashboard": - print("Rust <-> Python SDK parity harness", flush=True) + def __enter__(self) -> PlainDashboard: + _start_output_filtering() + print("Running Rust <-> Python parity", flush=True) # noqa: T201 # CLI output return self - def __exit__(self, *args: object) -> None: - return None + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + del exc_type, exc_value, traceback + _stop_output_filtering() def update(self, run: HarnessRun) -> None: for key, result in run.results.items(): - state = (result.status, len(result.completed)) - if self._seen.get(key) != state: - self._seen[key] = state - progress = ( - f" {len(result.completed)}/{result.total}" if result.total else "" - ) - print( - f"{STATUS_GLYPHS[result.status]} {key}: {result.status.value}{progress}", - flush=True, - ) + self._update_result(key, result.case.display_name, result.status, len(result.completed), result.total) + + def _update_result(self, key: str, label: str, status: RunStatus, completed: int, total: int) -> None: + state: Final = (status, completed) + previous: Final = self._seen.get(key) + self._seen[key] = state + visible: Final = status not in { + RunStatus.NOT_RUN, + RunStatus.QUEUED, + RunStatus.NOT_IMPLEMENTED, + RunStatus.SKIPPED, + } + should_print: Final = visible and ( + previous is None or previous[0] is not status or (completed > 0 and completed % 25 == 0) + ) + if should_print: + progress: Final = f" {completed}/{total}" if total else "" + print( # noqa: T201 # CLI output + f"{label}: {status.value}{progress}", flush=True + ) def finish(self, run: HarnessRun, exit_code: int) -> None: - self.update(run) - passed, failed, errors, skipped = _summary(run) - print( - f"Summary: {passed} passed, {failed} failed, {errors} errors, " - f"{skipped} skipped in {_format_duration(run.duration)}", - flush=True, + print( # noqa: T201 # CLI output + f"\n{final_report(run, exit_code, self.strategies)}", flush=True ) - for nodeid, detail in run.failures[:5]: - print(f"{nodeid}: {detail}", flush=True) - print(f"Rerun: {_rerun_command(nodeid)}", flush=True) - print("Port confidence by API", flush=True) - for score in section_confidence(run, self.confidence_strategies): - print( - f" {score.sdk_function:12} " - f"{score.verified_strategies}/{score.required_strategies} " - f"{score.percentage:3}% {score.level.value:6} " - f"{' | '.join(score.details)}", - flush=True, - ) - print( - " Score = required strategies with passing evidence; LOC is reported separately.", - flush=True, - ) - print(f"Harness finished with exit code {exit_code}", flush=True) -def make_dashboard( - strategies: Sequence[Strategy], - plain: bool = False, - confidence_strategies: Sequence[Strategy] | None = None, -) -> RichDashboard | PlainDashboard: - confidence_strategies = confidence_strategies or strategies - interactive_terminal = ( - sys.stdout.isatty() - and not os.environ.get("CI") - and os.environ.get("TERM") != "dumb" - ) - if not plain and interactive_terminal: - try: - import rich # noqa: F401 - - return RichDashboard(strategies, confidence_strategies) - except ImportError: - pass - return PlainDashboard(strategies, confidence_strategies) +def make_dashboard(strategies: Sequence[Strategy]) -> PlainDashboard: + return PlainDashboard(strategies) diff --git a/tests/rust-python-harness/shared/test_native_build.py b/tests/rust-python-harness/shared/test_native_build.py new file mode 100644 index 00000000000..f3e5aead846 --- /dev/null +++ b/tests/rust-python-harness/shared/test_native_build.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Final + +import pytest + +from . import native_build + + +def test_needs_rebuild_when_bridge_is_missing() -> None: + assert native_build.needs_rebuild(None, 1.0) + + +def test_needs_rebuild_when_sources_are_newer_than_bridge() -> None: + assert native_build.needs_rebuild(1.0, 2.0) + + +def test_fresh_bridge_with_older_sources_needs_no_rebuild() -> None: + assert not native_build.needs_rebuild(2.0, 1.0) + + +def test_bridge_without_rust_sources_needs_no_rebuild() -> None: + assert not native_build.needs_rebuild(2.0, None) + + +def test_newest_source_mtime_tracks_rust_sources_and_skips_target(tmp_path: Final) -> None: + source: Final = tmp_path / "litellm-rust" / "crates" / "bridge" / "src" + source.mkdir(parents=True) + (source / "lib.rs").write_text("fn main() {}\n") + os.utime(source / "lib.rs", (1_000, 1_000)) + manifest: Final = tmp_path / "litellm-rust" / "crates" / "bridge" / "Cargo.toml" + manifest.write_text("[package]\n") + os.utime(manifest, (2_000, 2_000)) + lockfile: Final = tmp_path / "litellm-rust" / "Cargo.lock" + lockfile.write_text("") + os.utime(lockfile, (1_500, 1_500)) + target: Final = tmp_path / "litellm-rust" / "target" / "debug" / "junk.rs" + target.parent.mkdir(parents=True) + target.write_text("fn main() {}\n") + os.utime(target, (9_999, 9_999)) + + assert native_build._newest_source_mtime(tmp_path) == 2_000.0 + + +def test_newest_source_mtime_is_none_without_rust_workspace(tmp_path: Final) -> None: + assert native_build._newest_source_mtime(tmp_path) is None + + +def test_ensure_trace_bridge_rebuilds_when_stale( + tmp_path: Final, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + native: Final = tmp_path / "_native.abi3.so" + native.write_bytes(b"") + os.utime(native, (1_000, 1_000)) + source: Final = tmp_path / "litellm-rust" / "crates" / "bridge" / "src" / "lib.rs" + source.parent.mkdir(parents=True) + source.write_text("fn main() {}\n") + os.utime(source, (2_000, 2_000)) + state: Final = SimpleNamespace(rebuilt=False) + + def fake_rebuild(repo_root: object) -> tuple[bool, str]: + state.rebuilt = True + return True, "" + + monkeypatch.setattr(native_build, "_native_module_path", lambda: native) + monkeypatch.setattr(native_build, "_rebuild", fake_rebuild) + monkeypatch.setattr(native_build, "_drop_imported_bridge", lambda: None) + monkeypatch.setattr(native_build, "get_native_bridge", lambda: SimpleNamespace(_trace=object())) + + assert native_build.ensure_trace_bridge(tmp_path) is None + assert state.rebuilt is True + assert "Rebuilding native Rust bridge" in capsys.readouterr().out + + +def test_ensure_trace_bridge_reports_failed_rebuild(tmp_path: Final, monkeypatch: pytest.MonkeyPatch) -> None: + source: Final = tmp_path / "litellm-rust" / "crates" / "bridge" / "src" / "lib.rs" + source.parent.mkdir(parents=True) + source.write_text("fn main() {}\n") + + monkeypatch.setattr(native_build, "_native_module_path", lambda: None) + monkeypatch.setattr(native_build, "_rebuild", lambda repo_root: (False, "boom")) + + message: Final = native_build.ensure_trace_bridge(tmp_path) + + assert message is not None + assert "rebuild failed" in message + assert "boom" in message + + +def test_ensure_trace_bridge_flags_missing_trace_feature_without_rebuild( + tmp_path: Final, monkeypatch: pytest.MonkeyPatch +) -> None: + native: Final = tmp_path / "_native.abi3.so" + native.write_bytes(b"") + os.utime(native, (9_999, 9_999)) + source: Final = tmp_path / "litellm-rust" / "crates" / "bridge" / "src" / "lib.rs" + source.parent.mkdir(parents=True) + source.write_text("fn main() {}\n") + os.utime(source, (1_000, 1_000)) + state: Final = SimpleNamespace(rebuilt=False) + + def fake_rebuild(repo_root: object) -> tuple[bool, str]: + state.rebuilt = True + return True, "" + + monkeypatch.setattr(native_build, "_native_module_path", lambda: native) + monkeypatch.setattr(native_build, "_rebuild", fake_rebuild) + monkeypatch.setattr(native_build, "get_native_bridge", lambda: SimpleNamespace(_trace=None)) + + message: Final = native_build.ensure_trace_bridge(tmp_path) + + assert message is not None + assert "_trace" in message + assert state.rebuilt is False diff --git a/tests/rust-python-harness/shared/tracing/compare.py b/tests/rust-python-harness/shared/tracing/compare.py deleted file mode 100644 index 9c43bea6c0e..00000000000 --- a/tests/rust-python-harness/shared/tracing/compare.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -from collections import Counter -from collections.abc import Mapping, Sequence -from dataclasses import dataclass -from typing import Final - - -@dataclass(frozen=True, slots=True) -class Operation: - name: str - started: int - finished: int - - -def compare_traces( - python: Sequence[Operation], - rust: Sequence[Operation], - mapping: Mapping[str, str], - required_order: Sequence[tuple[str, str]] = (), -) -> tuple[str, ...]: - python_names: Final = {operation.name for operation in python} - rust_names: Final = {operation.name for operation in rust} - problems: Final = ( - *(f"unmapped Python operation: {name}" for name in sorted(python_names - mapping.keys())), - *(f"unmapped Rust operation: {name}" for name in sorted(rust_names - set(mapping.values()))), - *(f"ambiguous Rust operation: {name}" for name, count in Counter(mapping.values()).items() if count > 1), - *( - f"invalid interval: {operation.name}" - for operation in (*python, *rust) - if operation.started > operation.finished - ), - ) - if problems: - return problems - python_counts: Final = Counter(operation.name for operation in python) - rust_counts: Final = Counter(operation.name for operation in rust) - counts: Final = tuple( - f"call count differs for {name}: Python={python_counts[name]}, Rust={rust_counts[target]}" - for name, target in mapping.items() - if python_counts[name] != rust_counts[target] - ) - ordering: Final = tuple( - f"{label}: required order {before} before {after} was not observed" - for before, after in required_order - for label, operations, first, second in ( - ("Python", python, before, after), - ("Rust", rust, mapping.get(before), mapping.get(after)), - ) - if not first - or not second - or not any(operation.name == first for operation in operations) - or not any(operation.name == second for operation in operations) - or max(operation.finished for operation in operations if operation.name == first) - > min(operation.started for operation in operations if operation.name == second) - ) - return (*counts, *ordering) diff --git a/tests/rust-python-harness/shared/tracing/native.py b/tests/rust-python-harness/shared/tracing/native.py new file mode 100644 index 00000000000..4f988f65294 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/native.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Final + +from pydantic import BaseModel, ConfigDict + +from .profiler import FunctionTraceEvent + + +class _TraceEventPayload(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid") + id: int + parent_id: int | None + function: str + module_path: str | None = None + file: str | None = None + line: int | None = None + + +class TraceResponsePayload(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid") + response: object + trace: tuple[_TraceEventPayload, ...] | list[_TraceEventPayload] + + +def native_trace_events(payload: object) -> tuple[FunctionTraceEvent, ...]: + response: Final = TraceResponsePayload.model_validate(payload) + return tuple( + FunctionTraceEvent( + event.id, + event.parent_id, + event.function, + event.module_path, + event.file, + event.line, + ) + for event in response.trace + ) diff --git a/tests/rust-python-harness/shared/tracing/profiler.py b/tests/rust-python-harness/shared/tracing/profiler.py new file mode 100644 index 00000000000..55d9818f507 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/profiler.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import sys +import threading +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from types import CodeType, FrameType +from typing import Final + + +@dataclass(frozen=True, slots=True) +class FunctionTraceEvent: + id: int + parent_id: int | None + function: str + module_path: str | None = None + file: str | None = None + line: int | None = None + + @property + def raw(self) -> str: + location: Final = f"{self.file}:{self.line}" if self.file is not None and self.line is not None else "" + qualified: Final = f"{self.module_path}::{self.function}" if self.module_path is not None else self.function + return f"{location} {qualified}" if location else qualified + + +class PythonProfiler: + def __init__(self, source_root: Path) -> None: + self._source_root: Final = str(source_root.resolve()) + "/" + self._seen_frames: Final[set[FrameType]] = set() + self._event_ids: Final[dict[FrameType, int]] = {} + self.events: Final[list[FunctionTraceEvent]] = [] + + def __call__(self, frame: FrameType, event: str, _arg: object) -> None: + if event != "call" or frame in self._seen_frames: + return + function_name: Final = self.function_name(frame.f_code) + if function_name is None: + return + event_id: Final = len(self.events) + parent_id: Final = next( + (self._event_ids[ancestor] for ancestor in _frame_ancestors(frame) if ancestor in self._event_ids), + None, + ) + self._seen_frames.add(frame) + self._event_ids[frame] = event_id + self.events.append(FunctionTraceEvent(id=event_id, parent_id=parent_id, function=function_name)) + + def function_name(self, code: CodeType) -> str | None: + if not code.co_filename.startswith(self._source_root): + return None + relative: Final = code.co_filename.removeprefix(self._source_root) + return f"{relative}:{code.co_firstlineno} {getattr(code, 'co_qualname', code.co_name)}" + + +class PythonFunctionUsageProfiler: + def __init__(self, source_root: Path, functions: frozenset[str]) -> None: + self._source_root: Final = str(source_root.resolve()) + "/" + self._functions: Final = functions + self.called: Final[set[str]] = set() + + def __call__(self, frame: FrameType, event: str, _arg: object) -> None: + if event != "call": + return + code: Final = frame.f_code + if not code.co_filename.startswith(self._source_root): + return + relative: Final = code.co_filename.removeprefix(self._source_root) + function: Final = f"{relative}:{code.co_firstlineno} {getattr(code, 'co_qualname', code.co_name)}" + if function in self._functions: + self.called.add(function) + + +def _frame_ancestors(frame: FrameType) -> Generator[FrameType]: + ancestor: Final = frame.f_back + if ancestor is not None: + yield ancestor + yield from _frame_ancestors(ancestor) + + +@contextmanager +def profile_python(source_root: Path, *, threads: bool = False) -> Generator[PythonProfiler]: + profiler: Final = PythonProfiler(source_root) + previous_thread: Final = threading.getprofile() + if threads: + threading.setprofile(profiler) + previous: Final = sys.getprofile() + sys.setprofile(profiler) + try: + yield profiler + finally: + sys.setprofile(previous) + if threads: + threading.setprofile(previous_thread) + + +@contextmanager +def profile_python_function_usage( + source_root: Path, + functions: frozenset[str], + *, + threads: bool = False, +) -> Generator[PythonFunctionUsageProfiler]: + profiler: Final = PythonFunctionUsageProfiler(source_root, functions) + previous_thread: Final = threading.getprofile() + if threads: + threading.setprofile(profiler) + previous: Final = sys.getprofile() + sys.setprofile(profiler) + try: + yield profiler + finally: + sys.setprofile(previous) + if threads: + threading.setprofile(previous_thread) diff --git a/tests/rust-python-harness/shared/tracing/pytest_usage.py b/tests/rust-python-harness/shared/tracing/pytest_usage.py new file mode 100644 index 00000000000..58af174df38 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/pytest_usage.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import argparse +import ast +import importlib +import inspect +import os +import subprocess +import sys +import tempfile +import warnings +from collections.abc import Generator, Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from pluggy import HookimplMarker +from pydantic import BaseModel, ConfigDict + +from .profiler import profile_python_function_usage + +if TYPE_CHECKING: + import pytest + +hookimpl: Final = HookimplMarker("pytest") + + +class PythonFunctionIdentity(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + file: str + line: int + qualname: str + + @property + def raw(self) -> str: + return f"{self.file}:{self.line} {self.qualname}" + + @property + def key(self) -> str: + return f"{self.file}::{self.qualname}" + + @classmethod + def from_trace(cls, raw: str) -> PythonFunctionIdentity: + location, separator, qualname = raw.partition(" ") + file, line_separator, line = location.rpartition(":") + if not separator or not line_separator or not file or not line.isdigit() or not qualname: + raise ValueError(f"Unrecognized Python trace function: {raw}") + return cls(file=file, line=int(line), qualname=qualname) + + +class PythonFunctionReference(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + module: str + qualname: str + + @property + def owner(self) -> str: + return self.qualname.partition(".")[0] + + def resolve(self, source_root: Path) -> PythonFunctionIdentity: + value: object = importlib.import_module(self.module) + for component in self.qualname.split("."): + value = getattr(value, component) + function: Final = inspect.unwrap(value) + code: Final = getattr(function, "__code__", None) + if code is None: + raise ValueError(f"Python function has no code object: {self.module}:{self.qualname}") + source: Final = Path(code.co_filename).resolve() + try: + relative: Final = source.relative_to(source_root.resolve()) + except ValueError as error: + raise ValueError(f"Python function is outside {source_root}: {source}") from error + return PythonFunctionIdentity( + file=relative.as_posix(), + line=code.co_firstlineno, + qualname=code.co_qualname, + ) + + +class RustFunctionIdentity(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + file: str + line: int + module_path: str + function: str + + @property + def test_module(self) -> str: + _, separator, module = self.module_path.partition("::") + if not separator: + raise ValueError(f"Rust function has no crate-qualified module: {self.module_path}") + return f"{module}::tests" + + @classmethod + def from_trace(cls, raw: str) -> RustFunctionIdentity: + location, separator, qualified = raw.partition(" ") + file, line_separator, line = location.rpartition(":") + module_path, function_separator, function = qualified.rpartition("::") + if ( + not separator + or not line_separator + or not function_separator + or not file + or not line.isdigit() + or not module_path + or not function + ): + raise ValueError(f"Unrecognized Rust trace function: {raw}") + return cls(file=file, line=int(line), module_path=module_path, function=function) + + +class PythonFunctionUsage(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + function: PythonFunctionIdentity + tests: tuple[str, ...] + + +class PythonUsageReport(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + usages: tuple[PythonFunctionUsage, ...] + collected_tests: tuple[str, ...] + exit_code: int + problems: tuple[str, ...] = () + + +def candidate_test_files( + functions: Sequence[PythonFunctionReference | PythonFunctionIdentity], + search_roots: Sequence[str], + repo_root: Path, + *, + exclude_roots: Sequence[str] = (), +) -> tuple[str, ...]: + owners: Final = frozenset( + function.owner if isinstance(function, PythonFunctionReference) else function.qualname.partition(".")[0] + for function in functions + if "." in function.qualname + and ( + isinstance(function, PythonFunctionReference) + or function.file.startswith("ocr/") + or "/ocr/" in function.file + ) + ) + top_level_functions: Final = frozenset( + function.qualname for function in functions if "." not in function.qualname and function.qualname.isidentifier() + ) + candidates: Final = tuple( + path.relative_to(repo_root).as_posix() + for root in search_roots + for path in sorted((repo_root / root).rglob("test*.py")) + if not any( + path == repo_root / excluded or path.is_relative_to(repo_root / excluded) for excluded in exclude_roots + ) + if _references_function(path, owners, top_level_functions) + ) + return tuple(dict.fromkeys(candidates)) + + +def _references_function(path: Path, owners: frozenset[str], top_level_functions: frozenset[str]) -> bool: + contents: Final = path.read_text(errors="ignore") + if any(owner in contents for owner in owners): + return True + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SyntaxWarning) + tree: Final = ast.parse(contents) + except SyntaxError: + return False + aliases: Final = frozenset( + alias.asname or alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + for alias in node.names + if alias.name in top_level_functions + ) + names: Final = top_level_functions | aliases + return any( + isinstance(node, ast.Call) + and ( + (isinstance(node.func, ast.Name) and node.func.id in names) + or (isinstance(node.func, ast.Attribute) and node.func.attr in top_level_functions) + ) + for node in ast.walk(tree) + ) + + +class _WorkerConfig(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + functions: tuple[PythonFunctionIdentity, ...] + source_root: Path + output: Path + pytest_args: tuple[str, ...] + + +class _FunctionUsagePlugin: + def __init__(self, functions: tuple[PythonFunctionIdentity, ...], source_root: Path) -> None: + self._functions: Final = functions + self._function_names: Final = frozenset(function.raw for function in functions) + self._source_root: Final = source_root + self._tests_by_function: Final[dict[str, set[str]]] = {function.raw: set() for function in functions} + self.collected_tests: tuple[str, ...] = () + self.problems: tuple[str, ...] = () + + def pytest_collection_finish(self, session: pytest.Session) -> None: + self.collected_tests = tuple(item.nodeid for item in session.items) + + def pytest_collectreport(self, report: pytest.CollectReport) -> None: + if report.failed: + self.problems = (*self.problems, str(report.longrepr)) + + @hookimpl(hookwrapper=True) + def pytest_runtest_protocol(self, item: pytest.Item, nextitem: pytest.Item | None) -> Generator[None, object, None]: + del nextitem + with profile_python_function_usage(self._source_root, self._function_names, threads=True) as profiler: + yield + for function in self._functions: + if function.raw in profiler.called: + self._tests_by_function[function.raw].add(item.nodeid) + + def usages(self) -> tuple[PythonFunctionUsage, ...]: + return tuple( + PythonFunctionUsage( + function=function, + tests=tuple(sorted(self._tests_by_function[function.raw])), + ) + for function in self._functions + ) + + +def collect_python_function_tests( + functions: Sequence[PythonFunctionIdentity], + selectors: Sequence[str], + repo_root: Path, + *, + source_root: Path | None = None, + exclusions: Sequence[str] = (), +) -> PythonUsageReport: + selected_functions: Final = tuple(dict.fromkeys(functions)) + if not selected_functions: + raise ValueError("Python function discovery needs at least one function") + if not selectors: + raise ValueError("Python function discovery needs at least one test selector") + with tempfile.TemporaryDirectory(prefix="litellm-function-tests-") as directory: + temporary: Final = Path(directory) + config_path: Final = temporary / "config.json" + output_path: Final = temporary / "report.json" + config: Final = _WorkerConfig( + functions=selected_functions, + source_root=source_root or repo_root / "litellm", + output=output_path, + pytest_args=tuple( + ( + "-o", + "consider_namespace_packages=true", + "-p", + "no:cacheprovider", + *selectors, + *(f"--deselect={nodeid}" for nodeid in exclusions), + ) + ), + ) + config_path.write_text(config.model_dump_json()) + import_roots: Final = tuple( + dict.fromkeys( + ( + str(repo_root), + str(source_root or repo_root / "litellm"), + *( + str(path.parent if path.suffix == ".py" else path) + for selector in selectors + if (path := repo_root / selector.partition("::")[0]).exists() + ), + os.environ.get("PYTHONPATH", ""), + ) + ) + ) + env: Final = { + **os.environ, + "PYTHONPATH": os.pathsep.join(import_roots), + } + try: + result: Final = subprocess.run( + (sys.executable, "-m", __name__, str(config_path)), + cwd=repo_root, + env=env, + capture_output=True, + text=True, + timeout=600, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + return PythonUsageReport(usages=(), collected_tests=(), exit_code=1, problems=(str(error),)) + if not output_path.exists(): + return PythonUsageReport( + usages=(), + collected_tests=(), + exit_code=result.returncode or 1, + problems=((result.stdout + result.stderr).strip(),), + ) + report: Final = PythonUsageReport.model_validate_json(output_path.read_text()) + process_output: Final = (result.stdout + result.stderr).strip() + if result.returncode and not report.problems and process_output: + return report.model_copy(update={"problems": (process_output,)}) + return report + + +def _run_worker(config: _WorkerConfig) -> int: + import pytest + + plugin: Final = _FunctionUsagePlugin(config.functions, config.source_root) + exit_code: Final = int(pytest.main(list(config.pytest_args), plugins=[plugin])) + report: Final = PythonUsageReport( + usages=plugin.usages(), + collected_tests=plugin.collected_tests, + exit_code=exit_code, + problems=plugin.problems, + ) + config.output.write_text(report.model_dump_json()) + return exit_code + + +def main(argv: Sequence[str] | None = None) -> int: + parser: Final = argparse.ArgumentParser() + parser.add_argument("config", type=Path) + namespace: Final = parser.parse_args(argv) + config: Final = _WorkerConfig.model_validate_json(namespace.config.read_text()) + return _run_worker(config) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/rust-python-harness/shared/tracing/steps.py b/tests/rust-python-harness/shared/tracing/steps.py new file mode 100644 index 00000000000..2475f0fdcc5 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/steps.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import re +from collections import Counter +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Final, Literal + +from .profiler import FunctionTraceEvent + +Engine = Literal["python", "rust"] + + +@dataclass(frozen=True, slots=True) +class TraceMapping: + span: str + python: re.Pattern[str] | None + rust: str | None + + +def mapping( + *, + python_frame: str | None = None, + rust_span: str | None = None, + span: str | None = None, +) -> TraceMapping: + if rust_span is None: + if python_frame is None: + raise ValueError("mapping needs a python_frame pattern, a rust_span name, or both") + if span is None: + raise ValueError("a python-only mapping needs an explicit span to compare under") + return TraceMapping(span, re.compile(python_frame), None) + if python_frame is None: + return TraceMapping(rust_span, None, rust_span) + if span is not None and span != rust_span: + raise ValueError(f"span {span!r} disagrees with rust_span {rust_span!r}") + return TraceMapping(rust_span, re.compile(python_frame), rust_span) + + +@dataclass(frozen=True, slots=True) +class TraceContract: + unordered_children_of: frozenset[str] = frozenset() + + +@dataclass(frozen=True, slots=True) +class PipelineStep: + id: int + parent_id: int | None + span: str + raw: str + + +@dataclass(frozen=True, slots=True) +class PipelineProjection: + steps: tuple[PipelineStep, ...] = () + unmatched: int = 0 + + +def _span_for(engine: Engine, function: str, mappings: Sequence[TraceMapping]) -> str | None: + matches: Final = tuple( + item.span + for item in mappings + if ( + engine == "python" + and item.python is not None + and item.python.search(function) + or engine == "rust" + and item.rust == function + ) + ) + if len(matches) > 1: + raise ValueError(f"{engine} event {function!r} matches multiple trace mappings: {matches}") + if matches: + return matches[0] + return function if engine == "rust" else None + + +def pipeline_projection( + engine: Engine, events: Sequence[FunctionTraceEvent], mappings: Sequence[TraceMapping] +) -> PipelineProjection: + raw_parents: dict[int, int | None] = {} + projected_ids: set[int] = set() + shown: list[PipelineStep] = [] + unmatched: int = 0 + for event in events: + if event.id in raw_parents: + raise ValueError(f"duplicate trace event id {event.id}") + if event.parent_id is not None and event.parent_id not in raw_parents: + raise ValueError(f"trace event {event.id} references unknown or later parent {event.parent_id}") + raw_parents[event.id] = event.parent_id + span = _span_for(engine, event.function, mappings) + if span is None: + unmatched += 1 + continue + parent_id: int | None = event.parent_id + while parent_id is not None and parent_id not in projected_ids: + parent_id = raw_parents[parent_id] + shown.append(PipelineStep(event.id, parent_id, span, event.raw)) + projected_ids.add(event.id) + return PipelineProjection(tuple(shown), unmatched) + + +@dataclass(frozen=True, slots=True) +class TraceNode: + id: int + span: str + children: tuple[TraceNode, ...] + + +def trace_depths(steps: Sequence[PipelineStep]) -> dict[int, int]: + depths: dict[int, int] = {} + for step in steps: + depths[step.id] = 0 if step.parent_id is None else depths[step.parent_id] + 1 + return depths + + +def _forest(steps: Sequence[PipelineStep]) -> tuple[TraceNode, ...]: + children: dict[int | None, list[PipelineStep]] = {} + known: set[int] = set() + for step in steps: + if step.id in known: + raise ValueError(f"duplicate projected event id {step.id}") + if step.parent_id is not None and step.parent_id not in known: + raise ValueError(f"projected event {step.id} references unknown or later parent {step.parent_id}") + known.add(step.id) + children.setdefault(step.parent_id, []).append(step) + + def node(step: PipelineStep) -> TraceNode: + return TraceNode(step.id, step.span, tuple(node(child) for child in children.get(step.id, ()))) + + return tuple(node(step) for step in children.get(None, ())) + + +def _exclusive_spans(engine: Engine, mappings: Sequence[TraceMapping]) -> frozenset[str]: + return frozenset( + item.span + for item in mappings + if (engine == "python" and item.rust is None) or (engine == "rust" and item.python is None) + ) + + +def _comparable_steps( + engine: Engine, steps: Sequence[PipelineStep], mappings: Sequence[TraceMapping] +) -> tuple[PipelineStep, ...]: + exclusive: Final = _exclusive_spans(engine, mappings) + raw_parents: Final = {step.id: step.parent_id for step in steps} + included: Final = {step.id for step in steps if step.span not in exclusive} + comparable: list[PipelineStep] = [] + for step in steps: + if step.id not in included: + continue + parent_id: int | None = step.parent_id + while parent_id is not None and parent_id not in included: + parent_id = raw_parents[parent_id] + comparable.append(PipelineStep(step.id, parent_id, step.span, step.raw)) + return tuple(comparable) + + +def _signature(node: TraceNode, contract: TraceContract) -> tuple[object, ...]: + children: tuple[tuple[object, ...], ...] = tuple(_signature(child, contract) for child in node.children) + normalized: Final = tuple(sorted(children, key=repr)) if node.span in contract.unordered_children_of else children + return (node.span, normalized) + + +def trace_signature( + engine: Engine, + steps: Sequence[PipelineStep], + mappings: Sequence[TraceMapping], + contract: TraceContract, +) -> tuple[tuple[object, ...], ...]: + return tuple(_signature(root, contract) for root in _forest(_comparable_steps(engine, steps, mappings))) + + +@dataclass(frozen=True, slots=True) +class TraceDiff: + python_only: tuple[str, ...] + rust_only: tuple[str, ...] + shared_order_matches: bool + missing_mappings: tuple[str, ...] = () + first_difference: str | None = None + + @property + def matches(self) -> bool: + return ( + not self.python_only + and not self.rust_only + and not self.missing_mappings + and self.shared_order_matches + ) + + +def _missing_mappings( + python: Sequence[PipelineStep], rust: Sequence[PipelineStep], mappings: Sequence[TraceMapping] +) -> tuple[str, ...]: + python_seen: Final = frozenset(step.span for step in python) + rust_seen: Final = frozenset(step.span for step in rust) + return tuple( + item.span + for item in mappings + if (item.python is not None and item.span not in python_seen) + or (item.rust is not None and item.span not in rust_seen) + ) + + +def _first_difference( + python: Sequence[PipelineStep], + rust: Sequence[PipelineStep], + mappings: Sequence[TraceMapping], + contract: TraceContract, +) -> str | None: + python_forest: Final = _forest(_comparable_steps("python", python, mappings)) + rust_forest: Final = _forest(_comparable_steps("rust", rust, mappings)) + + def compare_children( + python_nodes: Sequence[TraceNode], rust_nodes: Sequence[TraceNode], path: str, *, unordered: bool + ) -> str | None: + if unordered: + python_signatures: Final = Counter(_signature(node, contract) for node in python_nodes) + rust_signatures: Final = Counter(_signature(node, contract) for node in rust_nodes) + if python_signatures != rust_signatures: + return f"{path}: unordered child subtree multiset differs" + return None + for index in range(max(len(python_nodes), len(rust_nodes))): + child_path = f"{path}/child[{index + 1}]" + if index >= len(python_nodes): + return f"{child_path}: Rust has extra {rust_nodes[index].span!r}" + if index >= len(rust_nodes): + return f"{child_path}: Python has extra {python_nodes[index].span!r}" + python_node = python_nodes[index] + rust_node = rust_nodes[index] + if python_node.span != rust_node.span: + return f"{child_path}: Python={python_node.span!r}, Rust={rust_node.span!r}" + difference = compare_children( + python_node.children, + rust_node.children, + f"{child_path}/{python_node.span}", + unordered=python_node.span in contract.unordered_children_of, + ) + if difference is not None: + return difference + return None + + return compare_children(python_forest, rust_forest, "root", unordered=False) + + +def trace_diff( + python: Sequence[PipelineStep], + rust: Sequence[PipelineStep], + mappings: Sequence[TraceMapping] = (), + contract: TraceContract = TraceContract(), +) -> TraceDiff: + python_comparable: Final = _comparable_steps("python", python, mappings) + rust_comparable: Final = _comparable_steps("rust", rust, mappings) + python_spans: Final = tuple(step.span for step in python_comparable) + rust_spans: Final = tuple(step.span for step in rust_comparable) + python_counts: Final = Counter(python_spans) + rust_counts: Final = Counter(rust_spans) + python_only_counts: Final = python_counts - rust_counts + rust_only_counts: Final = rust_counts - python_counts + python_only: Final = tuple( + span for span, count in python_only_counts.items() for _ in range(count) + ) + rust_only: Final = tuple(span for span, count in rust_only_counts.items() for _ in range(count)) + first_difference: Final = _first_difference(python, rust, mappings, contract) + return TraceDiff( + python_only=python_only, + rust_only=rust_only, + shared_order_matches=bool(python_comparable or rust_comparable) and first_difference is None, + missing_mappings=_missing_mappings(python, rust, mappings), + first_difference=first_difference, + ) diff --git a/tests/rust-python-harness/shared/tracing/test_compare.py b/tests/rust-python-harness/shared/tracing/test_compare.py deleted file mode 100644 index 2dfad24846b..00000000000 --- a/tests/rust-python-harness/shared/tracing/test_compare.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -import pytest - -from .compare import Operation, compare_traces - - -@pytest.mark.parametrize( - ("rust", "message"), - ( - ((Operation("decode", 0, 1), Operation("send", 2, 3)), None), - ((Operation("decode", 0, 1), Operation("send", 2, 3), Operation("send", 4, 5)), "call count differs"), - ((Operation("send", 0, 1), Operation("decode", 2, 3)), "required order"), - ((Operation("decode", 0, 4), Operation("send", 2, 3)), "required order"), - ((Operation("decode", 0, 1), Operation("unknown", 2, 3)), "unmapped Rust"), - ), -) -def test_compare_mapped_calls_and_required_completion_order(rust: tuple[Operation, ...], message: str | None) -> None: - problems = compare_traces( - (Operation("parse", 0, 1), Operation("request", 2, 3)), - rust, - {"parse": "decode", "request": "send"}, - (("parse", "request"),), - ) - if message is None: - assert problems == () - else: - assert any(message in problem for problem in problems) - - -def test_missing_required_operations_and_ambiguous_mappings_fail() -> None: - assert compare_traces((), (), {"parse": "decode"}, (("parse", "request"),)) - assert compare_traces((), (), {"parse": "decode", "request": "decode"}) == ("ambiguous Rust operation: decode",) diff --git a/tests/rust-python-harness/shared/tracing/test_profiler.py b/tests/rust-python-harness/shared/tracing/test_profiler.py new file mode 100644 index 00000000000..ba85ffe63cd --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/test_profiler.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +import sys +import threading +from pathlib import Path +from typing import Final + +import pytest + +from .profiler import FunctionTraceEvent, PythonProfiler, profile_python, profile_python_function_usage + + +def _events_named(profiler: PythonProfiler, name: str) -> tuple[FunctionTraceEvent, ...]: + return tuple(event for event in profiler.events if event.function.endswith(name)) + + +def test_profiler_keeps_repeated_calls() -> None: + def called() -> None: + return None + + with profile_python(Path(__file__).parent) as profiler: + called() + called() + + assert len(_events_named(profiler, "called")) == 2 + + +def test_profiler_records_real_frame_ancestry() -> None: + def called() -> None: + return None + + def outer() -> None: + called() + + with profile_python(Path(__file__).parent) as profiler: + outer() + + outer_event, called_event = (event for event in profiler.events if event.function.endswith(("outer", "called"))) + assert called_event.parent_id == outer_event.id + + +def test_profiler_restores_previous_profiler_after_failure() -> None: + previous: Final = sys.getprofile() + + with pytest.raises(RuntimeError, match="stop"): + with profile_python(Path(__file__).parent): + raise RuntimeError("stop") + + assert sys.getprofile() is previous + + +def test_profiler_does_not_count_coroutine_resumption_as_another_call() -> None: + async def suspended() -> None: + await asyncio.sleep(0) + await asyncio.sleep(0) + + with profile_python(Path(__file__).parent) as profiler: + asyncio.run(suspended()) + + assert len(_events_named(profiler, "suspended")) == 1 + + +def test_profiler_preserves_parent_across_coroutine_suspension() -> None: + def called() -> None: + return None + + async def suspended() -> None: + await asyncio.sleep(0) + called() + + with profile_python(Path(__file__).parent) as profiler: + asyncio.run(suspended()) + + suspended_event: Final = _events_named(profiler, "suspended")[0] + called_event: Final = _events_named(profiler, "called")[0] + assert called_event.parent_id == suspended_event.id + + +def test_profiler_captures_worker_threads_when_enabled() -> None: + def called() -> None: + return None + + with profile_python(Path(__file__).parent, threads=True) as profiler: + thread: Final = threading.Thread(target=called) + thread.start() + thread.join() + + called_event: Final = _events_named(profiler, "called")[0] + assert called_event.parent_id is None + + +def test_function_usage_profiler_records_only_selected_functions() -> None: + def selected() -> None: + return None + + def ignored() -> None: + return None + + source_root: Final = Path(__file__).parent + function: Final = PythonProfiler(source_root).function_name(selected.__code__) + assert function is not None + + with profile_python_function_usage(source_root, frozenset((function,))) as profiler: + selected() + ignored() + + assert profiler.called == {function} diff --git a/tests/rust-python-harness/shared/tracing/test_pytest_usage.py b/tests/rust-python-harness/shared/tracing/test_pytest_usage.py new file mode 100644 index 00000000000..9f40da19010 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/test_pytest_usage.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Final + +import pytest + +from .pytest_usage import ( + PythonFunctionIdentity, + PythonFunctionReference, + RustFunctionIdentity, + candidate_test_files, + collect_python_function_tests, +) + + +def test_collects_parameterized_tests_that_execute_function(tmp_path: Path) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + (tmp_path / "source.py").write_text("def target():\n return 1\n\ndef other():\n return 2\n") + (tmp_path / "test_source.py").write_text( + "import pytest\n" + "from source import other, target\n" + "@pytest.mark.parametrize('value', [1, 2])\n" + "def test_target(value): assert target() + value > 0\n" + "def test_other(): assert other() == 2\n" + ) + target: Final = PythonFunctionIdentity(file="source.py", line=1, qualname="target") + + report: Final = collect_python_function_tests( + (target,), + ("test_source.py",), + tmp_path, + source_root=tmp_path, + ) + + assert report.exit_code == 0, report.problems + assert report.usages[0].tests == ( + "test_source.py::test_target[1]", + "test_source.py::test_target[2]", + ) + + +def test_collects_async_and_threaded_function_calls(tmp_path: Path) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + (tmp_path / "source.py").write_text( + "async def async_target():\n return 1\n\ndef threaded_target():\n return 2\n" + ) + (tmp_path / "test_source.py").write_text( + "import asyncio\n" + "from threading import Thread\n" + "from source import async_target, threaded_target\n" + "def test_async(): assert asyncio.run(async_target()) == 1\n" + "def test_thread():\n" + " thread = Thread(target=threaded_target)\n" + " thread.start()\n" + " thread.join()\n" + ) + functions: Final = ( + PythonFunctionIdentity(file="source.py", line=1, qualname="async_target"), + PythonFunctionIdentity(file="source.py", line=4, qualname="threaded_target"), + ) + + report: Final = collect_python_function_tests( + functions, + ("test_source.py",), + tmp_path, + source_root=tmp_path, + ) + + assert report.exit_code == 0, report.problems + assert report.usages[0].tests == ("test_source.py::test_async",) + assert report.usages[1].tests == ("test_source.py::test_thread",) + + +def test_adds_candidate_directory_to_worker_import_path(tmp_path: Path) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + source: Final = tmp_path / "source" + tests: Final = tmp_path / "tests" + source.mkdir() + tests.mkdir() + (source / "implementation.py").write_text("def target():\n return 1\n") + (tests / "helper.py").write_text("VALUE = 1\n") + (tests / "test_source.py").write_text( + "from helper import VALUE\nfrom implementation import target\ndef test_target(): assert target() == VALUE\n" + ) + target: Final = PythonFunctionIdentity(file="implementation.py", line=1, qualname="target") + + report: Final = collect_python_function_tests( + (target,), + ("tests/test_source.py",), + tmp_path, + source_root=source, + ) + + assert report.exit_code == 0, report.problems + assert report.usages[0].tests == ("tests/test_source.py::test_target",) + + +def test_parses_function_identity_from_trace() -> None: + function: Final = PythonFunctionIdentity.from_trace("llms/mistral/ocr/transformation.py:72 Config.map") + + assert function.file == "llms/mistral/ocr/transformation.py" + assert function.line == 72 + assert function.qualname == "Config.map" + + +def test_resolves_function_and_finds_candidate_test_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + package: Final = tmp_path / "package" + tests: Final = tmp_path / "tests" + package.mkdir() + tests.mkdir() + (package / "__init__.py").write_text("") + (package / "implementation.py").write_text("class Config:\n def transform(self):\n return 1\n") + (tests / "test_implementation.py").write_text("from package.implementation import Config\n") + (tests / "test_unrelated.py").write_text("def test_other(): pass\n") + monkeypatch.syspath_prepend(tmp_path) + reference: Final = PythonFunctionReference(module="package.implementation", qualname="Config.transform") + + function: Final = reference.resolve(tmp_path) + candidates: Final = candidate_test_files((reference,), ("tests",), tmp_path) + + assert function.file == "package/implementation.py" + assert function.qualname == "Config.transform" + assert candidates == ("tests/test_implementation.py",) + + +def test_candidate_test_files_excludes_harness_roots(tmp_path: Path) -> None: + tests: Final = tmp_path / "tests" + harness: Final = tests / "harness" + harness.mkdir(parents=True) + (tests / "test_implementation.py").write_text("from package.implementation import Config\n") + (harness / "test_fixture.py").write_text("from package.implementation import Config\n") + function: Final = PythonFunctionReference(module="package.implementation", qualname="Config.transform") + + candidates: Final = candidate_test_files( + (function,), + ("tests",), + tmp_path, + exclude_roots=("tests/harness",), + ) + + assert candidates == ("tests/test_implementation.py",) + + +def test_candidate_test_files_finds_top_level_calls_and_import_aliases(tmp_path: Path) -> None: + tests: Final = tmp_path / "tests" + tests.mkdir() + (tests / "test_attribute.py").write_text("import package\ndef test_call(): package.ocr()\n") + (tests / "test_alias.py").write_text("from package import ocr as run_ocr\ndef test_call(): run_ocr()\n") + (tests / "test_unrelated.py").write_text("def test_call(): return 'ocr'\n") + function: Final = PythonFunctionIdentity(file="ocr/main.py", line=1, qualname="ocr") + + candidates: Final = candidate_test_files((function,), ("tests",), tmp_path) + + assert candidates == ( + "tests/test_alias.py", + "tests/test_attribute.py", + ) + + +def test_parses_rust_function_identity_and_derives_test_module() -> None: + function: Final = RustFunctionIdentity.from_trace( + "crates/core/src/providers/mistral/ocr/transformation.rs:73 " + "litellm_core::providers::mistral::ocr::transformation::supported_ocr_params" + ) + + assert function.file == "crates/core/src/providers/mistral/ocr/transformation.rs" + assert function.test_module == "providers::mistral::ocr::transformation::tests" diff --git a/tests/rust-python-harness/shared/tracing/test_steps.py b/tests/rust-python-harness/shared/tracing/test_steps.py new file mode 100644 index 00000000000..2efc6a3c579 --- /dev/null +++ b/tests/rust-python-harness/shared/tracing/test_steps.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import Final + +import pytest + +from .profiler import FunctionTraceEvent +from .steps import Engine, TraceContract, mapping, pipeline_projection, trace_depths, trace_diff + +MAPPINGS: Final = ( + mapping(rust_span="route", python_frame=r"entry$"), + mapping(rust_span="provider", python_frame=r"provider$"), + mapping(rust_span="request", python_frame=r"request$"), + mapping(rust_span="http", python_frame=r"post$"), + mapping(rust_span="response", python_frame=r"response$"), +) + + +def event(event_id: int, function: str, parent_id: int | None = None) -> FunctionTraceEvent: + return FunctionTraceEvent(event_id, parent_id, function) + + +def test_python_projection_collapses_unmapped_parents_and_counts_noise() -> None: + events: Final = ( + event(0, "module.py:1 entry"), + event(1, "noise", 0), + event(2, "module.py:2 provider", 1), + event(3, "module.py:3 request", 0), + event(4, "client.py:4 post", 3), + event(5, "module.py:5 response", 0), + ) + projection: Final = pipeline_projection("python", events, MAPPINGS) + assert projection.unmatched == 1 + assert [(step.id, step.parent_id, step.span, step.raw) for step in projection.steps] == [ + (0, None, "route", "module.py:1 entry"), + (2, 0, "provider", "module.py:2 provider"), + (3, 0, "request", "module.py:3 request"), + (4, 3, "http", "client.py:4 post"), + (5, 0, "response", "module.py:5 response"), + ] + + +def test_rust_projection_keeps_unknown_spans() -> None: + projection: Final = pipeline_projection("rust", (event(0, "route"), event(1, "new_span", 0)), MAPPINGS) + assert [(step.span, step.parent_id) for step in projection.steps] == [("route", None), ("new_span", 0)] + + +def test_projection_preserves_repeated_occurrences() -> None: + projection: Final = pipeline_projection( + "rust", + (event(0, "route"), event(1, "http", 0), event(2, "http", 0)), + MAPPINGS, + ) + assert [step.span for step in projection.steps] == ["route", "http", "http"] + + +def test_projection_preserves_multiple_roots() -> None: + projection: Final = pipeline_projection("rust", (event(0, "route"), event(1, "request")), MAPPINGS) + assert trace_depths(projection.steps) == {0: 0, 1: 0} + + +def test_projection_rejects_duplicate_and_unknown_parent_ids() -> None: + with pytest.raises(ValueError, match="duplicate trace event id"): + pipeline_projection("rust", (event(0, "route"), event(0, "request")), MAPPINGS) + with pytest.raises(ValueError, match="unknown or later parent"): + pipeline_projection("rust", (event(1, "request", 0),), MAPPINGS) + + +@pytest.mark.parametrize("engine", ("python", "rust")) +def test_rust_only_mappings_do_not_swallow_python_frames(engine: Engine) -> None: + projection: Final = pipeline_projection( + engine, + (event(0, "anything"),), + (mapping(rust_span="rust_only_span"),), + ) + if engine == "python": + assert projection.unmatched == 1 + assert projection.steps == () + else: + assert projection.unmatched == 0 + assert projection.steps[0].span == "anything" + + +def test_mapping_builder_rejects_empty_and_ambiguous_declarations() -> None: + with pytest.raises(ValueError, match="mapping needs"): + mapping() + with pytest.raises(ValueError, match="python-only mapping needs"): + mapping(python_frame=r"frame$") + with pytest.raises(ValueError, match="disagrees with"): + mapping(rust_span="span_a", python_frame=r"frame$", span="span_b") + + +def test_projection_rejects_ambiguous_python_mapping() -> None: + mappings: Final = ( + mapping(rust_span="first", python_frame=r"same$"), + mapping(rust_span="second", python_frame=r"same$"), + ) + with pytest.raises(ValueError, match="multiple trace mappings"): + pipeline_projection("python", (event(0, "module.py:1 same"),), mappings) + + +def test_trace_diff_matches_identical_occurrence_trees() -> None: + mappings: Final = (MAPPINGS[0], MAPPINGS[2]) + steps: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "request", 0), event(2, "request", 0)), mappings + ).steps + assert trace_diff(steps, steps, mappings).matches + + +def test_trace_diff_rejects_missing_occurrence_and_parent_drift() -> None: + python: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "request", 0), event(2, "request", 0)), MAPPINGS + ).steps + missing: Final = pipeline_projection("rust", (event(0, "route"), event(1, "request", 0)), MAPPINGS).steps + reparented: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "request", 0), event(2, "request", 1)), MAPPINGS + ).steps + assert trace_diff(python, missing, MAPPINGS).python_only == ("request",) + assert not trace_diff(python, reparented, MAPPINGS).matches + + +def test_trace_diff_rejects_sequential_reorder() -> None: + first: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "request", 0), event(2, "response", 0)), MAPPINGS + ).steps + second: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "response", 0), event(2, "request", 0)), MAPPINGS + ).steps + diff: Final = trace_diff(first, second, MAPPINGS) + assert not diff.matches + assert diff.first_difference == "root/child[1]/route/child[1]: Python='request', Rust='response'" + + +def test_trace_diff_allows_reordered_concurrent_children() -> None: + mappings: Final = (MAPPINGS[0], MAPPINGS[2], MAPPINGS[4]) + first: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "request", 0), event(2, "response", 0)), mappings + ).steps + second: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "response", 0), event(2, "request", 0)), mappings + ).steps + contract: Final = TraceContract(frozenset({"route"})) + assert trace_diff(first, second, mappings, contract).matches + + +def test_trace_diff_prunes_declared_engine_only_nodes_but_requires_them() -> None: + mappings: Final = (MAPPINGS[0], mapping(rust_span="rust_prepare")) + python: Final = pipeline_projection("python", (event(0, "module.py:1 entry"),), mappings).steps + rust: Final = pipeline_projection( + "rust", (event(0, "route"), event(1, "rust_prepare", 0)), mappings + ).steps + assert trace_diff(python, rust, mappings).matches + assert trace_diff(python, rust[:1], mappings).missing_mappings == ("rust_prepare",) + + +def test_trace_diff_does_not_claim_empty_traces_match() -> None: + assert not trace_diff((), ()).matches diff --git a/tests/rust-python-harness/strategies/e2e_parity/gateway/__init__.py b/tests/rust-python-harness/shared/unit_runners/__init__.py similarity index 100% rename from tests/rust-python-harness/strategies/e2e_parity/gateway/__init__.py rename to tests/rust-python-harness/shared/unit_runners/__init__.py diff --git a/tests/rust-python-harness/strategies/unit_tests/python_runner.py b/tests/rust-python-harness/shared/unit_runners/python_runner.py similarity index 63% rename from tests/rust-python-harness/strategies/unit_tests/python_runner.py rename to tests/rust-python-harness/shared/unit_runners/python_runner.py index 900b30180dc..86ea0dbf1b9 100644 --- a/tests/rust-python-harness/strategies/unit_tests/python_runner.py +++ b/tests/rust-python-harness/shared/unit_runners/python_runner.py @@ -1,7 +1,6 @@ from __future__ import annotations import argparse -import ast import importlib import os import subprocess @@ -9,11 +8,16 @@ import sys import tempfile from collections.abc import Callable, Sequence from pathlib import Path -from typing import Final, Literal, cast +from typing import TYPE_CHECKING, Final, Literal, cast -import pytest +from pluggy import HookimplMarker from pydantic import BaseModel, ConfigDict +if TYPE_CHECKING: + import pytest + +hookimpl: Final = HookimplMarker("pytest") + Backend = Literal["python", "rust"] @@ -32,22 +36,20 @@ class BackendSpec(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") environment_variable: str + probe: str = "" + + +class WorkerArgs(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + backend: Backend probe: str - - -def ocr_backend() -> Backend: - from litellm.rust_bridge import native_bridge_available - from litellm.rust_bridge.configuration import rust_ocr_enabled - - if not rust_ocr_enabled(): - return "python" - if not native_bridge_available(): - raise RuntimeError("Rust OCR was enabled but the native extension is unavailable") - return "rust" + output: Path + pytest_args: tuple[str, ...] class ResultPlugin: - def __init__(self, backend: Backend, probe: Callable[[], object]) -> None: + def __init__(self, backend: Backend, probe: Callable[[], object] | None) -> None: self.backend: Final = backend self.probe: Final = probe self.tests: tuple[str, ...] = () @@ -55,13 +57,13 @@ class ResultPlugin: self.problems: tuple[str, ...] = () def verify(self) -> None: - if self.probe() != self.backend: + if self.probe is not None and self.probe() != self.backend: raise RuntimeError(f"backend probe did not select {self.backend}") def pytest_collection_finish(self, session: pytest.Session) -> None: self.tests = tuple(item.nodeid for item in session.items) - @pytest.hookimpl(tryfirst=True) + @hookimpl(tryfirst=True) def pytest_runtest_call(self, item: pytest.Item) -> None: del item self.verify() @@ -91,8 +93,7 @@ def run_python_tests( __name__, "--backend", backend, - "--probe", - spec.probe, + *(("--probe", spec.probe) if spec.probe else ()), "--output", str(output), "--", @@ -118,6 +119,9 @@ def run_python_tests( problems=(result.stdout + result.stderr,), ) report: Final = PythonReport.model_validate_json(output.read_text()) + process_output: Final = (result.stdout + result.stderr).strip() + if result.returncode and not report.problems and process_output: + return report.model_copy(update={"problems": (process_output,)}) if report.exit_code != result.returncode: return report.model_copy( update={ @@ -129,43 +133,42 @@ def run_python_tests( def compare_python_runs(python: PythonReport, rust: PythonReport) -> tuple[str, ...]: + python_only: Final = tuple(sorted(set(python.outcomes) - set(rust.outcomes))) + rust_only: Final = tuple(sorted(set(rust.outcomes) - set(python.outcomes))) return ( *(("backend selection was not verified",) if not python.verified or not rust.verified else ()), *(("Python run used the wrong backend",) if python.backend != "python" else ()), *(("Rust run used the wrong backend",) if rust.backend != "rust" else ()), *(("Python/Rust test inventories differ",) if python.tests != rust.tests else ()), - *(("Python/Rust test outcomes differ",) if sorted(python.outcomes) != sorted(rust.outcomes) else ()), + *(("Python/Rust test outcomes differ",) if python_only or rust_only else ()), + *(f"Python only: {nodeid} [{stage}] {outcome}" for nodeid, stage, outcome in python_only), + *(f"Rust only: {nodeid} [{stage}] {outcome}" for nodeid, stage, outcome in rust_only), + *(f"Python run: {problem}" for problem in python.problems if not python.verified or not python.tests), + *(f"Rust run: {problem}" for problem in rust.problems if not rust.verified or not rust.tests), *(("no Python tests collected",) if not python.tests else ()), - *( - ("Python tests did not all pass",) - if set(python.tests) - != {node for node, phase, status in python.outcomes if phase == "call" and status == "passed"} - else () - ), - *( - ("Rust-enabled Python tests did not all pass",) - if set(rust.tests) - != {node for node, phase, status in rust.outcomes if phase == "call" and status == "passed"} - else () - ), - *(("Python test run failed",) if python.exit_code else ()), - *(("Rust-enabled Python test run failed",) if rust.exit_code else ()), - *python.problems, - *rust.problems, + *(("Python/Rust exit codes differ",) if python.exit_code != rust.exit_code else ()), ) +def _load_probe(reference: str) -> Callable[[], object] | None: + if not reference: + return None + module, name = reference.rsplit(":", 1) + return cast(Callable[[], object], getattr(importlib.import_module(module), name)) + + def main(argv: Sequence[str] | None = None) -> int: + import pytest + parser: Final = argparse.ArgumentParser() parser.add_argument("--backend", required=True, choices=("python", "rust")) - parser.add_argument("--probe", required=True) + parser.add_argument("--probe", default="") parser.add_argument("--output", required=True, type=Path) parser.add_argument("pytest_args", nargs=argparse.REMAINDER) - args: Final = parser.parse_args(argv) + namespace: Final = parser.parse_args(argv) + args: Final = WorkerArgs.model_validate(vars(namespace)) try: - module, name = args.probe.rsplit(":", 1) - probe: Final = cast(Callable[[], object], getattr(importlib.import_module(module), name)) - plugin: Final = ResultPlugin(args.backend, probe) + plugin: Final = ResultPlugin(args.backend, _load_probe(args.probe)) plugin.verify() code: Final = int( pytest.main(["-o", "consider_namespace_packages=true", *args.pytest_args[1:]], plugins=[plugin]) @@ -186,22 +189,29 @@ def main(argv: Sequence[str] | None = None) -> int: return report.exit_code -def enumerate_python_tests(repo_root: Path, relative_path: str) -> frozenset[str]: - source = (repo_root / relative_path).read_text(encoding="utf-8") - tree = ast.parse(source, filename=relative_path) +def contract_nodeid(nodeid: str) -> str: + owner, separator, test = nodeid.rpartition("::") + function: Final = test.partition("[")[0] + if not separator or not function.startswith("test_"): + raise ValueError(f"Unrecognized pytest node id: {nodeid}") + return f"{owner}::{function}" - module_level: list[str] = [] - for node in ast.iter_child_nodes(tree): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"): - module_level.append(node.name) - elif isinstance(node, ast.ClassDef): - for child in ast.iter_child_nodes(node): - if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith( - "test_" - ): - module_level.append(f"{node.name}::{child.name}") - return frozenset(module_level) +def collect_python_tests(selectors: Sequence[str], repo_root: Path) -> frozenset[str]: + report: Final = run_python_tests( + selectors, + repo_root, + "python", + BackendSpec(environment_variable="LITELLM_RUST"), + ("--collect-only", "-p", "no:cacheprovider"), + ) + if report.exit_code or report.problems: + details: Final = "\n".join(report.problems) or f"pytest exited with code {report.exit_code}" + raise ValueError(f"Python test collection failed:\n{details}") + tests: Final = frozenset(contract_nodeid(nodeid) for nodeid in report.tests) + if not tests: + raise ValueError(f"pytest collected no tests for: {', '.join(selectors)}") + return tests if __name__ == "__main__": diff --git a/tests/rust-python-harness/shared/unit_runners/rust_runner.py b/tests/rust-python-harness/shared/unit_runners/rust_runner.py new file mode 100644 index 00000000000..9df77895519 --- /dev/null +++ b/tests/rust-python-harness/shared/unit_runners/rust_runner.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import subprocess +from collections.abc import Callable +from dataclasses import dataclass +from itertools import groupby +from pathlib import Path +from typing import Annotated, Final, Literal, TypeAlias + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from typing_extensions import Self + +CommandRunner: TypeAlias = Callable[[tuple[str, ...], Path], str] +_MODEL_CONFIG: Final = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class RustTarget(BaseModel): + model_config = _MODEL_CONFIG + + package: str + name: str + kind: Literal["lib", "bin", "test"] + + @property + def key(self) -> str: + return f"{self.package}/{self.kind}/{self.name}" + + +class RustTestIdentity(BaseModel): + model_config = _MODEL_CONFIG + + target: RustTarget + name: str + + @property + def key(self) -> str: + return f"{self.target.key}::{self.name}" + + +class RustTestScope(BaseModel): + model_config = _MODEL_CONFIG + + target: RustTarget + modules: Annotated[tuple[str, ...], Field(min_length=1)] + features: tuple[str, ...] = () + default_features: bool = True + + @model_validator(mode="after") + def validate_scope(self) -> Self: + duplicate_features: Final = tuple( + feature for feature, values in groupby(sorted(self.features)) if sum(1 for _ in values) > 1 + ) + duplicate_modules: Final = tuple( + module for module, values in groupby(sorted(self.modules)) if sum(1 for _ in values) > 1 + ) + if duplicate_features: + raise ValueError(f"Rust features contain duplicates: {', '.join(duplicate_features)}") + if duplicate_modules: + raise ValueError(f"Rust modules contain duplicates: {', '.join(duplicate_modules)}") + if any(not module or module.endswith("::") for module in self.modules): + raise ValueError("Rust modules must be non-empty and omit the trailing :: separator") + overlaps: Final = tuple( + f"{outer} includes {inner}" + for outer in self.modules + for inner in self.modules + if inner.startswith(f"{outer}::") + ) + if overlaps: + raise ValueError(f"Rust modules overlap: {', '.join(overlaps)}") + return self + + def contains(self, identity: RustTestIdentity) -> bool: + return identity.target == self.target and any( + identity.name.startswith(f"{module}::") for module in self.modules + ) + + +class _CargoPackage(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + id: str + name: str + + +class _CargoMetadata(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + packages: tuple[_CargoPackage, ...] + + +class _CargoMessage(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + reason: str + + +class _CargoTarget(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + name: str + kind: tuple[str, ...] + + +class _CargoProfile(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + test: bool + + +class _CargoArtifact(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + reason: Literal["compiler-artifact"] + package_id: str + target: _CargoTarget + profile: _CargoProfile + executable: str | None + + +@dataclass(frozen=True, slots=True) +class RustReport: + tests: tuple[str, ...] + exit_code: int + output: str + + +def run_command(command: tuple[str, ...], cwd: Path) -> str: + try: + result: Final = subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=False, timeout=600) + except (OSError, subprocess.TimeoutExpired) as error: + raise ValueError(f"Rust inventory command failed: {error}") from error + if result.returncode != 0: + raise ValueError( + f"Rust inventory command failed ({result.returncode}): {' '.join(command)}\n" + f"{result.stderr}\n{result.stdout}" + ) + return result.stdout + + +def run_rust_tests(manifest: Path, package: str | None, test_filter: str, *, collect_only: bool = False) -> RustReport: + command: Final = ( + "cargo", + "test", + "--manifest-path", + str(manifest), + *(("--package", package) if package else ()), + "--lib", + test_filter, + "--", + *(("--list",) if collect_only else ("--format=pretty",)), + ) + try: + result: Final = subprocess.run(command, capture_output=True, text=True, check=False, timeout=600) + except (OSError, subprocess.TimeoutExpired) as error: + return RustReport((), 1, str(error)) + tests: Final = ( + tuple(line.removesuffix(": test") for line in result.stdout.splitlines() if line.endswith(": test")) + if collect_only + else tuple( + line.removeprefix("test ").removesuffix(" ... ok") + for line in result.stdout.splitlines() + if line.startswith("test ") and line.endswith(" ... ok") + ) + ) + return RustReport(tests, result.returncode, result.stdout + result.stderr) + + +def _build_command(scope: RustTestScope) -> tuple[str, ...]: + selector: Final = ("--lib",) if scope.target.kind == "lib" else (f"--{scope.target.kind}", scope.target.name) + features: Final = ("--features", ",".join(scope.features)) if scope.features else () + defaults: Final = () if scope.default_features else ("--no-default-features",) + return ( + "cargo", + "test", + "--package", + scope.target.package, + *selector, + *features, + *defaults, + "--locked", + "--no-run", + "--message-format=json", + "--color", + "never", + ) + + +def _test_names(output: str) -> frozenset[str]: + lines: Final = tuple(line for line in output.splitlines() if line) + invalid: Final = tuple(line for line in lines if not line.endswith((": test", ": benchmark"))) + if invalid: + raise ValueError(f"Unrecognized libtest inventory output: {invalid!r}") + names: Final = tuple(line.removesuffix(": test") for line in lines if line.endswith(": test")) + if len(names) != len(frozenset(names)): + raise ValueError("Duplicate test names in libtest inventory") + return frozenset(names) + + +def _scope_tests( + scope: RustTestScope, + metadata: _CargoMetadata, + cwd: Path, + command_runner: CommandRunner, +) -> frozenset[RustTestIdentity]: + package_ids: Final = tuple(package.id for package in metadata.packages if package.name == scope.target.package) + if len(package_ids) != 1: + raise ValueError(f"Expected one Cargo package for {scope.target.package}, found {len(package_ids)}") + output: Final = command_runner(_build_command(scope), cwd) + artifacts: Final = tuple( + _CargoArtifact.model_validate_json(line) + for line in output.splitlines() + if _CargoMessage.model_validate_json(line).reason == "compiler-artifact" + ) + executables: Final = frozenset( + artifact.executable + for artifact in artifacts + if artifact.package_id == package_ids[0] + and artifact.target.name == scope.target.name + and scope.target.kind in artifact.target.kind + and artifact.profile.test + and artifact.executable is not None + ) + if len(executables) != 1: + raise ValueError(f"Expected one test executable for {scope.target.key}, found {len(executables)}") + executable: Final = next(iter(executables)) + names: Final = _test_names(command_runner((executable, "--list", "--format", "terse"), cwd)) + ignored: Final = _test_names(command_runner((executable, "--list", "--ignored", "--format", "terse"), cwd)) + identities: Final = frozenset(RustTestIdentity(target=scope.target, name=name) for name in names) + scoped: Final = frozenset(identity for identity in identities if scope.contains(identity)) + ignored_scoped: Final = tuple(sorted(identity.key for identity in scoped if identity.name in ignored)) + if ignored_scoped: + raise ValueError(f"Ignored Rust tests cannot satisfy the mapping: {', '.join(ignored_scoped)}") + empty_modules: Final = tuple( + module for module in scope.modules if not any(identity.name.startswith(f"{module}::") for identity in scoped) + ) + if empty_modules: + raise ValueError(f"No compiled tests in {scope.target.key} modules: {', '.join(empty_modules)}") + return scoped + + +def enumerate_rust_tests( + repo_root: Path, + scopes: tuple[RustTestScope, ...], + *, + command_runner: CommandRunner = run_command, +) -> frozenset[RustTestIdentity]: + if not scopes: + return frozenset() + cwd: Final = repo_root / "litellm-rust" + metadata: Final = _CargoMetadata.model_validate_json( + command_runner(("cargo", "metadata", "--format-version", "1", "--no-deps", "--locked"), cwd) + ) + return frozenset(identity for scope in scopes for identity in _scope_tests(scope, metadata, cwd, command_runner)) diff --git a/tests/rust-python-harness/shared/unit_runners/suite_runner.py b/tests/rust-python-harness/shared/unit_runners/suite_runner.py new file mode 100644 index 00000000000..b0374bdf833 --- /dev/null +++ b/tests/rust-python-harness/shared/unit_runners/suite_runner.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from time import monotonic +from typing import Final, TypeVar + +from pydantic import BaseModel + +from ..reporting.models import HarnessCase, HarnessRun, ResultArtifact, RunStatus, SdkFunction +from ..reporting.strategy import SuiteCaseSpec, UpdateCallback + +S = TypeVar("S", bound=BaseModel) + + +@dataclass(frozen=True, slots=True) +class SuiteExecution: + problems: tuple[str, ...] = () + artifacts: tuple[ResultArtifact, ...] = () + + +SuiteExecutor = Callable[[S, Path, Sequence[str]], SuiteExecution] + + +def suite_nodeid(case: HarnessCase) -> str: + spec = case.spec + suite = spec.suite if isinstance(spec, SuiteCaseSpec) else "invalid" + return f"suite:{case.strategy_id}:{case.sdk_function}:{suite}" + + +def run_suites( + cases: Sequence[HarnessCase], + repo_root: Path, + on_update: UpdateCallback, + runner_args: Sequence[str] = (), + *, + suites: Mapping[SdkFunction, S], + execute: SuiteExecutor[S], +) -> tuple[int, HarnessRun]: + report = HarnessRun.from_cases(cases) + for case in cases: + result = report.results[case.key] + spec = case.spec + if not isinstance(spec, SuiteCaseSpec): + continue + nodeid = suite_nodeid(case) + result.collected.add(nodeid) + result.status = RunStatus.RUNNING + on_update(report) + suite = suites.get(case.sdk_function) + if suite is None: + result.record(nodeid, RunStatus.ERROR) + report.failures.append((nodeid, f"no suite registered for {case.sdk_function}")) + continue + try: + execution: Final = execute(suite, repo_root, runner_args) + except (OSError, ValueError) as error: + result.record(nodeid, RunStatus.ERROR) + report.failures.append((nodeid, str(error))) + continue + result.record( + nodeid, + RunStatus.FAILED if execution.problems else RunStatus.PASSED, + artifacts=execution.artifacts, + ) + report.failures.extend((nodeid, problem) for problem in execution.problems) + on_update(report) + report.finished_at = monotonic() + on_update(report) + return int( + any( + result.status in {RunStatus.ERROR, RunStatus.FAILED, RunStatus.MISSING} + for result in report.results.values() + ) + ), report diff --git a/tests/rust-python-harness/strategies/unit_tests/test_python_runner.py b/tests/rust-python-harness/shared/unit_runners/test_python_runner.py similarity index 54% rename from tests/rust-python-harness/strategies/unit_tests/test_python_runner.py rename to tests/rust-python-harness/shared/unit_runners/test_python_runner.py index ed4920b7525..abd4316ca98 100644 --- a/tests/rust-python-harness/strategies/unit_tests/test_python_runner.py +++ b/tests/rust-python-harness/shared/unit_runners/test_python_runner.py @@ -4,9 +4,7 @@ import os from pathlib import Path from typing import Final -from .python_runner import BackendSpec, compare_python_runs, run_python_tests - -HARNESS_ROOT: Final = Path(__file__).resolve().parents[4] +from .python_runner import BackendSpec, collect_python_tests, compare_python_runs, run_python_tests def _suite(root: Path, *, mismatch: bool = False) -> BackendSpec: @@ -23,9 +21,7 @@ def _suite(root: Path, *, mismatch: bool = False) -> BackendSpec: return BackendSpec(environment_variable="TEST_USE_RUST", probe="backend_probe:selected") -def test_runs_existing_python_tests_in_separate_verified_backends(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("PYTHONPATH", str(HARNESS_ROOT)) - monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") +def test_runs_existing_python_tests_in_separate_verified_backends(tmp_path: Path) -> None: spec: Final = _suite(tmp_path) python: Final = run_python_tests(("test_backend.py",), tmp_path, "python", spec) rust: Final = run_python_tests(("test_backend.py",), tmp_path, "rust", spec) @@ -35,9 +31,7 @@ def test_runs_existing_python_tests_in_separate_verified_backends(tmp_path: Path assert (tmp_path / "python.pid").read_text() != str(os.getpid()) -def test_rejects_wrong_backend_and_different_test_results(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("PYTHONPATH", str(HARNESS_ROOT)) - monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") +def test_rejects_wrong_backend_and_different_test_results(tmp_path: Path) -> None: spec: Final = _suite(tmp_path, mismatch=True) python: Final = run_python_tests(("test_backend.py",), tmp_path, "python", spec) rust: Final = run_python_tests(("test_backend.py",), tmp_path, "rust", spec) @@ -51,3 +45,44 @@ def test_rejects_wrong_backend_and_different_test_results(tmp_path: Path, monkey assert wrong.exit_code == 1 assert not wrong.verified assert "backend probe did not select rust" in wrong.problems[0] + + +def test_matches_outcomes_without_a_probe_when_both_backends_fail_identically(tmp_path: Path) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + (tmp_path / "test_backend.py").write_text("def test_fails():\n assert False\n") + spec: Final = BackendSpec(environment_variable="TEST_USE_RUST") + + python: Final = run_python_tests(("test_backend.py",), tmp_path, "python", spec) + rust: Final = run_python_tests(("test_backend.py",), tmp_path, "rust", spec) + + assert compare_python_runs(python, rust) == () + + +def test_reports_worker_output_when_pytest_exits_before_collection(tmp_path: Path) -> None: + report: Final = run_python_tests( + ("missing.py",), + tmp_path, + "python", + BackendSpec(environment_variable="TEST_USE_RUST"), + ) + + assert report.exit_code != 0 + assert report.problems + assert "missing.py" in report.problems[0] + + +def test_collects_tests_with_pytest_semantics_and_collapses_parameters(tmp_path: Path) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + (tmp_path / "test_inventory.py").write_text( + "import pytest\n" + "class Helper:\n" + " def test_not_collected(self): pass\n" + "class TestCollected:\n" + " @pytest.mark.parametrize('value', [1, 2])\n" + " def test_parameterized(self, value): pass\n", + encoding="utf-8", + ) + + tests: Final = collect_python_tests(("test_inventory.py",), tmp_path) + + assert tests == frozenset(("test_inventory.py::TestCollected::test_parameterized",)) diff --git a/tests/rust-python-harness/shared/unit_runners/test_rust_runner.py b/tests/rust-python-harness/shared/unit_runners/test_rust_runner.py new file mode 100644 index 00000000000..a12d00a7ed6 --- /dev/null +++ b/tests/rust-python-harness/shared/unit_runners/test_rust_runner.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import shutil +from collections.abc import Callable +from pathlib import Path +from typing import Final + +import pytest + +from .rust_runner import RustTarget, RustTestScope, enumerate_rust_tests, run_command, run_rust_tests + + +@pytest.mark.skipif(shutil.which("cargo") is None, reason="Cargo is required for native runner integration") +def test_collects_and_runs_native_tests_and_propagates_failure( + tmp_path: Path, + cargo_project: Callable[[str, str], Path], +) -> None: + manifest: Final = cargo_project("harness-runner-check", "#[test] fn test_parity() { assert_eq!(2 + 2, 4); }\n") + source: Final = tmp_path / "src/lib.rs" + inventory: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity", collect_only=True) + assert inventory.exit_code == 0, inventory.output + assert inventory.tests == ("test_parity",) + passing: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity") + assert passing.exit_code == 0, passing.output + source.write_text("#[test] fn test_parity() { assert_eq!(2 + 2, 5); }\n") + failed: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity") + assert failed.exit_code != 0 + assert "test_parity" in failed.output + + +@pytest.mark.skipif(shutil.which("cargo") is None, reason="Cargo is required for compiled inventory tests") +def test_discovers_compiled_fully_qualified_tests(tmp_path: Path) -> None: + workspace: Final = tmp_path / "litellm-rust" + source: Final = workspace / "src" + external: Final = source / "ocr" / "external.rs" + external.parent.mkdir(parents=True) + (workspace / "Cargo.toml").write_text( + '[package]\nname = "inventory-fixture"\nversion = "0.1.0"\nedition = "2021"\n[workspace]\n', + encoding="utf-8", + ) + (source / "lib.rs").write_text( + "#[cfg(test)]\n" + "mod ocr {\n" + " mod external;\n" + " #[test] fn same_name() {}\n" + " #[cfg(any())] #[test] fn compiled_out() {}\n" + " macro_rules! generate_test { ($name:ident) => { #[test] fn $name() {} }; }\n" + " generate_test!(generated_case);\n" + "}\n", + encoding="utf-8", + ) + external.write_text("#[test] fn same_name() {}\n", encoding="utf-8") + run_command(("cargo", "generate-lockfile", "--offline"), workspace) + target: Final = RustTarget(package="inventory-fixture", name="inventory_fixture", kind="lib") + scope: Final = RustTestScope(target=target, modules=("ocr",)) + + inventory: Final = enumerate_rust_tests(tmp_path, (scope,)) + + assert frozenset(identity.name for identity in inventory) == frozenset( + ("ocr::same_name", "ocr::external::same_name", "ocr::generated_case") + ) diff --git a/tests/rust-python-harness/shared/unit_runners/test_suite_runner.py b/tests/rust-python-harness/shared/unit_runners/test_suite_runner.py new file mode 100644 index 00000000000..92670da666e --- /dev/null +++ b/tests/rust-python-harness/shared/unit_runners/test_suite_runner.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Final + +from pydantic import BaseModel + +from ..reporting.models import Coverage, HarnessCase, ResultArtifact, RunStatus +from ..reporting.strategy import CaseSpec, NotImplementedCaseSpec, SuiteCaseSpec +from .suite_runner import SuiteExecution, run_suites + + +class _Suite(BaseModel): + problems: tuple[str, ...] = () + + +def _execute(suite: _Suite, repo_root: Path, pytest_args: Sequence[str]) -> SuiteExecution: + del repo_root, pytest_args + return SuiteExecution(problems=suite.problems) + + +def _case(spec: CaseSpec) -> HarnessCase: + return HarnessCase( + strategy_id="example", + strategy_label="Example", + sdk_function="ocr", + spec=spec, + ) + + +def test_not_implemented_cell_finalizes_without_running(tmp_path: Path) -> None: + case = _case(NotImplementedCaseSpec(reason="No suite is registered.")) + + code, report = run_suites((case,), tmp_path, lambda _: None, (), suites={}, execute=_execute) + + assert code == 0 + assert report.results[case.key].status is RunStatus.NOT_IMPLEMENTED + assert not report.failures + + +def test_missing_registered_suite_marks_the_cell_as_error(tmp_path: Path) -> None: + case = _case(SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr")) + + code, report = run_suites((case,), tmp_path, lambda _: None, (), suites={}, execute=_execute) + + assert code == 1 + assert report.results[case.key].status is RunStatus.ERROR + assert report.failures + + +def test_suite_problems_mark_the_cell_as_failed(tmp_path: Path) -> None: + case = _case(SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr")) + + code, report = run_suites( + (case,), tmp_path, lambda _: None, (), suites={"ocr": _Suite(problems=("boom",))}, execute=_execute + ) + + assert code == 1 + assert report.results[case.key].status is RunStatus.FAILED + assert ("suite:example:ocr:ocr", "boom") in report.failures + + +def test_suite_without_problems_passes(tmp_path: Path) -> None: + case = _case(SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr")) + + code, report = run_suites((case,), tmp_path, lambda _: None, (), suites={"ocr": _Suite()}, execute=_execute) + + assert code == 0 + assert report.results[case.key].status is RunStatus.PASSED + assert not report.failures + + +def test_suite_attaches_artifacts_to_passing_and_failing_results(tmp_path: Path) -> None: + case: Final = _case(SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr")) + artifact: Final = ResultArtifact("example", "body") + + def execute(suite: _Suite, repo_root: Path, pytest_args: Sequence[str]) -> SuiteExecution: + del repo_root, pytest_args + return SuiteExecution(problems=suite.problems, artifacts=(artifact,)) + + _, passing = run_suites((case,), tmp_path, lambda _: None, suites={"ocr": _Suite()}, execute=execute) + _, failing = run_suites( + (case,), tmp_path, lambda _: None, suites={"ocr": _Suite(problems=("boom",))}, execute=execute + ) + + assert passing.results[case.key].artifacts == {"suite:example:ocr:ocr": (artifact,)} + assert failing.results[case.key].artifacts == {"suite:example:ocr:ocr": (artifact,)} diff --git a/tests/rust-python-harness/strategies/e2e_parity/AGENTS.md b/tests/rust-python-harness/strategies/e2e_parity/AGENTS.md new file mode 100644 index 00000000000..27c87d0f100 --- /dev/null +++ b/tests/rust-python-harness/strategies/e2e_parity/AGENTS.md @@ -0,0 +1 @@ +Switches between the Rust implementation and the existing Python core, then compares their observable behavior for parity across SDK objects, exceptions, callbacks, streams, and gateway HTTP responses using generated and recorded inputs. diff --git a/tests/rust-python-harness/strategies/e2e_parity/README.md b/tests/rust-python-harness/strategies/e2e_parity/README.md deleted file mode 100644 index 17643e69676..00000000000 --- a/tests/rust-python-harness/strategies/e2e_parity/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# E2E Parity - -Run independently with `uv run python -m tests.rust-python-harness.strategies.e2e_parity.runner --plain`. Configure SDK and gateway selectors in `strategy.json`; keep API-specific execution and fixtures in their owning surface folder - -See [the harness guide](../../README.md) for coverage status and shared comparison tools diff --git a/tests/rust-python-harness/strategies/e2e_parity/__init__.py b/tests/rust-python-harness/strategies/e2e_parity/__init__.py index e69de29bb2d..f668e178eef 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/__init__.py +++ b/tests/rust-python-harness/strategies/e2e_parity/__init__.py @@ -0,0 +1,103 @@ +from pathlib import Path +from typing import Final + +from ...shared.reporting.models import SURFACES, Coverage +from ...shared.reporting.strategy import ( + CaseDefinition, + ModuleCaseSpec, + NotImplementedCaseSpec, + StrategyDefinition, +) +from .reporting import render_e2e_results +from .runner import run_e2e_cases + +CASES: Final[tuple[CaseDefinition, ...]] = ( + CaseDefinition( + "ocr", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.e2e_parity.sdk.ocr.test_sdk_parity", + note=( + "Recorded sync/async SDK parity; invalid-model provider errors differ, " + "and Reducto lacks a Rust contract." + ), + ), + surface="sdk", + ), + CaseDefinition( + "messages", + NotImplementedCaseSpec( + reason="Bridge unit tests exist, but no standalone end-to-end parity case is registered." + ), + surface="sdk", + ), + CaseDefinition( + "responses", + NotImplementedCaseSpec( + reason="Bridge unit tests exist, but no standalone end-to-end parity case is registered." + ), + surface="sdk", + ), + CaseDefinition( + "count_tokens", + NotImplementedCaseSpec(reason="No Rust count_tokens parity test is present yet."), + surface="sdk", + ), + CaseDefinition( + "chat_completions", + NotImplementedCaseSpec( + reason="Bridge unit tests exist, but no standalone end-to-end parity case is registered." + ), + surface="sdk", + ), + CaseDefinition( + "transcription", + NotImplementedCaseSpec( + reason="Bridge unit tests exist, but no standalone end-to-end parity case is registered." + ), + surface="sdk", + ), + CaseDefinition( + "ocr", + NotImplementedCaseSpec(reason="No gateway end-to-end OCR parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "messages", + NotImplementedCaseSpec(reason="No gateway end-to-end Messages parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "responses", + NotImplementedCaseSpec(reason="No gateway end-to-end Responses parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "count_tokens", + NotImplementedCaseSpec(reason="No gateway end-to-end token-count parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "chat_completions", + NotImplementedCaseSpec(reason="No gateway end-to-end chat parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "transcription", + NotImplementedCaseSpec(reason="No gateway end-to-end transcription parity case is registered."), + surface="gateway", + ), +) + +STRATEGY: Final = StrategyDefinition( + id="e2e_parity", + order=10, + label="End-to-end parity", + description="Compare observable Python and Rust behavior over generated and recorded inputs.", + directory=Path(__file__).parent, + runnable_spec=ModuleCaseSpec, + cases=CASES, + run=run_e2e_cases, + render=render_e2e_results, + surfaces=SURFACES, +) diff --git a/tests/rust-python-harness/strategies/e2e_parity/reporting.py b/tests/rust-python-harness/strategies/e2e_parity/reporting.py new file mode 100644 index 00000000000..d1a5c389bdf --- /dev/null +++ b/tests/rust-python-harness/strategies/e2e_parity/reporting.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final + +from ...shared.reporting.models import CaseResult +from ...shared.reporting.rendering import ReportSection, render_case_outcome + + +def render_e2e_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + blocks: Final = tuple(render_case_outcome(result) for result in results) + return (ReportSection("End-to-end parity outcomes", blocks or ("No end-to-end cases selected",)),) diff --git a/tests/rust-python-harness/strategies/e2e_parity/runner.py b/tests/rust-python-harness/strategies/e2e_parity/runner.py index 2886c823370..109df68e2ec 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/runner.py +++ b/tests/rust-python-harness/strategies/e2e_parity/runner.py @@ -1,26 +1,125 @@ from __future__ import annotations -from collections.abc import Sequence +import importlib +from collections.abc import Callable, Sequence +from contextlib import AbstractContextManager, nullcontext +from dataclasses import dataclass from pathlib import Path +from time import monotonic +from typing import Final, cast -from ...shared.reporting.models import HarnessCase, HarnessRun -from ...shared.reporting.pytest_runner import UpdateCallback, run_pytest +from ...shared.reporting.models import CaseResult, HarnessCase, HarnessRun, RunStatus +from ...shared.reporting.strategy import ModuleCaseSpec, UpdateCallback -def run( +@dataclass(frozen=True, slots=True) +class E2ECheck: + name: str + execute: Callable[[], None] + + +@dataclass(frozen=True, slots=True) +class E2ELoadFailure: + message: str + + +def _load_checks(reference: str) -> AbstractContextManager[object] | E2ELoadFailure: + try: + module: Final = importlib.import_module(reference) + factory_value: Final[object] = getattr(module, "parity_checks", None) + if not callable(factory_value): + return E2ELoadFailure(f"{reference} must export parity_checks()") + factory: Final = cast(Callable[[], object], factory_value) + checks_value: Final = factory() + except Exception as error: + return E2ELoadFailure(f"cannot load {reference}: {type(error).__name__}: {error}") + if isinstance(checks_value, tuple): + return nullcontext(cast(object, checks_value)) + if isinstance(checks_value, AbstractContextManager): + return cast(AbstractContextManager[object], checks_value) + return E2ELoadFailure( + f"{reference}.parity_checks() must return tuple[E2ECheck, ...] or a context manager yielding one" + ) + + +def _validate_checks(reference: str, checks_value: object) -> tuple[E2ECheck, ...] | E2ELoadFailure: + if not isinstance(checks_value, tuple): + return E2ELoadFailure(f"{reference}.parity_checks() context manager must yield tuple[E2ECheck, ...]") + untyped_checks: Final = cast(tuple[object, ...], checks_value) + if not all(isinstance(check, E2ECheck) for check in untyped_checks): + return E2ELoadFailure(f"{reference}.parity_checks() must return tuple[E2ECheck, ...]") + return cast(tuple[E2ECheck, ...], untyped_checks) + + +def _run_check( + run: HarnessRun, + result: CaseResult, + check: E2ECheck, + nodeid: str, + on_update: UpdateCallback, +) -> None: + started_at: Final = monotonic() + try: + check.execute() + except Exception as error: + result.record(nodeid, RunStatus.FAILED, monotonic() - started_at) + run.failures.append((nodeid, f"{type(error).__name__}: {error}")) + else: + result.record(nodeid, RunStatus.PASSED, monotonic() - started_at) + on_update(run) + + +def _run_case(run: HarnessRun, harness_case: HarnessCase, on_update: UpdateCallback) -> None: + result: Final = run.results[harness_case.key] + spec: Final = harness_case.spec + if not isinstance(spec, ModuleCaseSpec): + return + loaded: Final = _load_checks(spec.module) + if isinstance(loaded, E2ELoadFailure): + load_nodeid: Final = f"e2e:{harness_case.surface}:{harness_case.sdk_function}:load" + result.collected.add(load_nodeid) + result.record(load_nodeid, RunStatus.ERROR) + run.failures.append((load_nodeid, loaded.message)) + on_update(run) + return + try: + with loaded as checks_value: + checks: Final = _validate_checks(spec.module, checks_value) + if isinstance(checks, E2ELoadFailure): + raise TypeError(checks.message) + nodeids: Final = tuple( + (check, f"e2e:{harness_case.surface}:{harness_case.sdk_function}:{check.name}") for check in checks + ) + result.collected.update(nodeid for _, nodeid in nodeids) + if not nodeids: + result.status = RunStatus.SKIPPED + on_update(run) + return + result.status = RunStatus.RUNNING + on_update(run) + for check, nodeid in nodeids: + _run_check(run, result, check, nodeid, on_update) + except Exception as error: + session_nodeid: Final = f"e2e:{harness_case.surface}:{harness_case.sdk_function}:session" + result.collected.add(session_nodeid) + result.record(session_nodeid, RunStatus.ERROR) + run.failures.append((session_nodeid, f"{type(error).__name__}: {error}")) + on_update(run) + + +def run_e2e_cases( cases: Sequence[HarnessCase], repo_root: Path, on_update: UpdateCallback, - pytest_args: Sequence[str] = (), + runner_args: Sequence[str] = (), ) -> tuple[int, HarnessRun]: - return run_pytest(cases, repo_root, on_update, pytest_args) - - -def main(argv: Sequence[str] | None = None) -> int: - from ...cli import main as harness_main - - return harness_main(argv, strategy_id="e2e_parity") - - -if __name__ == "__main__": - raise SystemExit(main()) + del repo_root, runner_args + run: Final = HarnessRun.from_cases(cases) + for harness_case in cases: + _run_case(run, harness_case, on_update) + run.finished_at = monotonic() + on_update(run) + failed: Final = any( + result.status in {RunStatus.ERROR, RunStatus.FAILED, RunStatus.MISSING} for result in run.results.values() + ) + return int(failed), run diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/chat_completions/__init__.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/chat_completions/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/messages/__init__.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/messages/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/__init__.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/config.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/config.py index 2d65189790a..ffd8d903551 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/config.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/config.py @@ -6,6 +6,8 @@ from collections.abc import Callable, Mapping from pathlib import Path from typing import Final +from ......shared.parity.fixtures.store import fixture_directory + FIXTURE_DIR_ENV: Final = "LITELLM_OCR_FIXTURE_DIR" DEFAULT_FIXTURE_DIRECTORY: Final = Path(__file__).with_name("data") @@ -40,5 +42,4 @@ def recording_environment( def configured_fixture_directory() -> Path: - configured: Final = os.environ.get(FIXTURE_DIR_ENV) - return Path(configured).expanduser() if configured is not None else DEFAULT_FIXTURE_DIRECTORY + return fixture_directory(None, os.environ.get(FIXTURE_DIR_ENV), DEFAULT_FIXTURE_DIRECTORY) diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/reducto.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/reducto.py index fe8fab50518..bcca8ac6d42 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/reducto.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/fixtures/reducto.py @@ -89,13 +89,19 @@ ReductoBlockType = Literal[ "Comment", "Signature", ] -_REDUCTO_FILTER_BLOCK_GROUPS: Final[tuple[tuple[ReductoBlockType, ...], ...]] = ( +REDUCTO_FORMATTING_INCLUDE_GROUPS: Final[tuple[tuple[ReductoFormattingInclude, ...], ...]] = ( + (), + ("hyperlinks",), + ("change_tracking", "highlight", "comments"), + ("signatures", "ignore_watermarks"), +) +REDUCTO_FILTER_BLOCK_GROUPS: Final[tuple[tuple[ReductoBlockType, ...], ...]] = ( (), ("Header",), ("Header", "Footer", "Page Number"), ("Figure", "Table", "Key Value"), ) -_REDUCTO_RETURN_IMAGE_GROUPS: Final[tuple[tuple[ReductoReturnImage, ...], ...]] = ( +REDUCTO_RETURN_IMAGE_GROUPS: Final[tuple[tuple[ReductoReturnImage, ...], ...]] = ( (), ("figure",), ("table",), @@ -258,14 +264,7 @@ def _formatting_strategy() -> SearchStrategy[ReductoFormatting]: ), st.sampled_from((False, True)).map(lambda value: {"add_page_markers": value}), st.sampled_from((False, True)).map(lambda value: {"merge_tables": value}), - st.sampled_from( - ( - (), - ("hyperlinks",), - ("change_tracking", "highlight", "comments"), - ("signatures", "ignore_watermarks"), - ) - ) + st.sampled_from(REDUCTO_FORMATTING_INCLUDE_GROUPS) .map(list) .map(lambda value: {"include": value}), ) @@ -288,7 +287,7 @@ def _chunking_strategy() -> SearchStrategy[ReductoChunking]: def _retrieval_strategy() -> SearchStrategy[ReductoRetrieval]: filter_blocks: Final = cast( SearchStrategy[list[ReductoBlockType]], - st.sampled_from(_REDUCTO_FILTER_BLOCK_GROUPS).map(list), + st.sampled_from(REDUCTO_FILTER_BLOCK_GROUPS).map(list), ) return st.one_of( _chunking_strategy().map(lambda chunking: ReductoRetrieval(chunking=chunking)), @@ -305,7 +304,7 @@ def _retrieval_strategy() -> SearchStrategy[ReductoRetrieval]: def _settings_strategy() -> SearchStrategy[ReductoSettings]: # force_url_result stays model-compatible but is not recorded until the # response transform follows and downloads result.url. - return_images: Final[SearchStrategy[list[ReductoReturnImage]]] = st.sampled_from(_REDUCTO_RETURN_IMAGE_GROUPS).map( + return_images: Final[SearchStrategy[list[ReductoReturnImage]]] = st.sampled_from(REDUCTO_RETURN_IMAGE_GROUPS).map( list ) page_ranges: Final = st.one_of( diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py index bdccddd9cfa..80b830369e6 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_fixture_models.py @@ -4,16 +4,16 @@ import base64 from collections.abc import Callable from datetime import date from pathlib import Path -from typing import Final, TypeVar, cast +from typing import Final, cast from unittest.mock import patch from urllib.parse import parse_qs, urlparse import httpx import pytest import respx -from hypothesis import find, given, settings +from hypothesis import given, settings from hypothesis import strategies as st -from hypothesis.strategies import DataObject, SearchStrategy +from hypothesis.strategies import DataObject from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError from litellm.llms.azure_ai.ocr.document_intelligence.transformation import AzureDocumentIntelligenceOCRConfig @@ -27,6 +27,7 @@ from litellm.llms.mistral.ocr.transformation import MistralOCRConfig from litellm.llms.reducto.ocr.transformation import ReductoParseLegacyConfig, ReductoParseV3Config from litellm.llms.vertex_ai.ocr.deepseek_transformation import VertexAIDeepSeekOCRConfig from litellm.llms.vertex_ai.ocr.transformation import VertexAIOCRConfig + from .....shared.parity.fixtures.media import structured_pdf_data_uri from .conftest import ocr_fixture_marks from .fixtures.azure import ( @@ -49,7 +50,10 @@ from .fixtures.base import ( from .fixtures.mistral import MISTRAL_MODELS, MistralOcrSdkInput, mistral_input_strategy from .fixtures.models import OcrParityCase, OcrSdkInput from .fixtures.reducto import ( + REDUCTO_FILTER_BLOCK_GROUPS, + REDUCTO_FORMATTING_INCLUDE_GROUPS, REDUCTO_LEGACY_MODELS, + REDUCTO_RETURN_IMAGE_GROUPS, REDUCTO_V3_MODELS, ReductoChunking, ReductoDocumentUrlDocument, @@ -71,6 +75,7 @@ from .fixtures.vertex import ( vertex_deepseek_input_strategy, vertex_mistral_input_strategy, ) +from .test_support import find_fixture as _find_fixture COMMON_FIELDS: Final = frozenset( {"contract", "model", "document", "custom_llm_provider", "vertex_project", "vertex_location"} @@ -150,27 +155,6 @@ _MISTRAL_2505_OPTION_GROUPS: Final = frozenset( _AZURE_MISTRAL_OPTION_GROUPS: Final = _MISTRAL_2505_OPTION_GROUPS - { frozenset({"document_annotation_format", "document_annotation_prompt"}) } -_REDUCTO_FORMATTING_INCLUDE_GROUPS: Final = ( - (), - ("hyperlinks",), - ("change_tracking", "highlight", "comments"), - ("signatures", "ignore_watermarks"), -) -_REDUCTO_FILTER_BLOCK_GROUPS: Final = ( - (), - ("Header",), - ("Header", "Footer", "Page Number"), - ("Figure", "Table", "Key Value"), -) -_REDUCTO_RETURN_IMAGE_GROUPS: Final = ( - (), - ("figure",), - ("table",), - ("page",), - ("figure", "table"), -) -_FIND_SETTINGS: Final = settings(max_examples=2_000, deadline=None, derandomize=True, database=None) -_FixtureInputT = TypeVar("_FixtureInputT") INLINE_IMAGE_DATA_URI: Final = "data:image/png;base64,dGVzdA==" _MapOcrParams = Callable[[dict[str, object], dict[str, object], str], dict[str, object]] _TransformOcrRequest = Callable[ @@ -198,13 +182,6 @@ def _transform_with_stubbed_download( return transform_request(model, document, mapped, {}) -def _find_fixture( - strategy: SearchStrategy[_FixtureInputT], - predicate: Callable[[_FixtureInputT], bool], -) -> _FixtureInputT: - return find(strategy, predicate, settings=_FIND_SETTINGS) - - def _document_transport(document: ImageUrlDocument | DocumentUrlDocument) -> tuple[str, str]: if isinstance(document, ImageUrlDocument): source: Final = document.image_url.url if isinstance(document.image_url, ImageUrlValue) else document.image_url @@ -701,7 +678,7 @@ def test_reducto_v3_strategy_only_generates_bounded_valid_sdk_inputs(sdk_input: if "merge_tables" in formatting_fields: assert sdk_input.formatting.merge_tables in {False, True} if "include" in formatting_fields: - assert tuple(sdk_input.formatting.include) in _REDUCTO_FORMATTING_INCLUDE_GROUPS + assert tuple(sdk_input.formatting.include) in REDUCTO_FORMATTING_INCLUDE_GROUPS if "retrieval" in option_groups: retrieval_fields: Final = frozenset(sdk_input.retrieval.model_fields_set) assert retrieval_fields in { @@ -719,7 +696,7 @@ def test_reducto_v3_strategy_only_generates_bounded_valid_sdk_inputs(sdk_input: if chunking.chunk_overlap: assert chunking.chunk_size == 1000 if "filter_blocks" in retrieval_fields: - assert tuple(sdk_input.retrieval.filter_blocks) in _REDUCTO_FILTER_BLOCK_GROUPS + assert tuple(sdk_input.retrieval.filter_blocks) in REDUCTO_FILTER_BLOCK_GROUPS if "embedding_optimized" in retrieval_fields: assert chunking.chunk_mode == "variable" assert chunking.chunk_size is None @@ -757,7 +734,7 @@ def test_reducto_v3_strategy_only_generates_bounded_valid_sdk_inputs(sdk_input: if "return_ocr_data" in settings_fields: assert sdk_input.settings.return_ocr_data is True if "return_images" in settings_fields: - assert tuple(sdk_input.settings.return_images) in _REDUCTO_RETURN_IMAGE_GROUPS + assert tuple(sdk_input.settings.return_images) in REDUCTO_RETURN_IMAGE_GROUPS if "embed_pdf_metadata_dpi" in settings_fields: assert sdk_input.settings.embed_pdf_metadata is True assert sdk_input.settings.embed_pdf_metadata_dpi in {50, 100, 250} @@ -859,7 +836,7 @@ def test_reducto_v3_strategy_reaches_every_formatting_boolean(field: str, value: assert getattr(sdk_input.formatting, field) is value -@pytest.mark.parametrize("include", _REDUCTO_FORMATTING_INCLUDE_GROUPS) +@pytest.mark.parametrize("include", REDUCTO_FORMATTING_INCLUDE_GROUPS) def test_reducto_v3_strategy_reaches_every_formatting_include(include: tuple[str, ...]) -> None: sdk_input: Final = _find_fixture( reducto_v3_input_strategy(INLINE_IMAGE_DATA_URI), @@ -910,7 +887,7 @@ def test_reducto_v3_strategy_reaches_every_chunk_overlap(chunk_overlap: int) -> assert sdk_input.retrieval.chunking.chunk_overlap == chunk_overlap -@pytest.mark.parametrize("filter_blocks", _REDUCTO_FILTER_BLOCK_GROUPS) +@pytest.mark.parametrize("filter_blocks", REDUCTO_FILTER_BLOCK_GROUPS) def test_reducto_v3_strategy_reaches_every_filter_block_group(filter_blocks: tuple[str, ...]) -> None: sdk_input: Final = _find_fixture( reducto_v3_input_strategy(INLINE_IMAGE_DATA_URI), @@ -992,7 +969,7 @@ def test_reducto_v3_strategy_reaches_every_scalar_setting(field: str, value: obj assert getattr(sdk_input.settings, field) == value -@pytest.mark.parametrize("return_images", _REDUCTO_RETURN_IMAGE_GROUPS) +@pytest.mark.parametrize("return_images", REDUCTO_RETURN_IMAGE_GROUPS) def test_reducto_v3_strategy_reaches_every_return_image_group(return_images: tuple[str, ...]) -> None: sdk_input: Final = _find_fixture( reducto_v3_input_strategy(INLINE_IMAGE_DATA_URI), diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_record_fixtures.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_record_fixtures.py index 6c6dcea17d1..bc770131d85 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_record_fixtures.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_record_fixtures.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import Final, cast import pytest -from hypothesis import find, settings from hypothesis.strategies import SearchStrategy from .....shared.parity.fixtures.cli import parse_recording_args @@ -41,6 +40,7 @@ from .fixtures.vertex import ( vertex_deepseek_provider_rejected_inputs, vertex_mistral_provider_rejected_inputs, ) +from .test_support import find_fixture class _UnusedOcrClient: @@ -76,7 +76,6 @@ _MISTRAL_PARAMS: Final = frozenset( _MISTRAL_2512_PARAMS: Final = _MISTRAL_PARAMS - {"include_blocks"} _MISTRAL_2505_PARAMS: Final = _MISTRAL_2512_PARAMS - {"extract_header", "extract_footer", "table_format"} _AZURE_MISTRAL_PARAMS: Final = _MISTRAL_2505_PARAMS - {"document_annotation_prompt"} -_FIND_SETTINGS: Final = settings(max_examples=2_000, deadline=None, derandomize=True, database=None) _INLINE_IMAGE_DATA_URI: Final = "data:image/png;base64,dGVzdA==" @@ -94,7 +93,7 @@ def _find_input( strategy: SearchStrategy[OcrSdkInputBase], predicate: Callable[[OcrSdkInputBase], bool], ) -> OcrSdkInputBase: - return find(strategy, predicate, settings=_FIND_SETTINGS) + return find_fixture(strategy, predicate) def _document_transport(case_input: OcrSdkInputBase) -> tuple[str, str]: diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py index bedbdeb6a13..e72980752f2 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py @@ -2,23 +2,21 @@ from __future__ import annotations import asyncio import sys +import tempfile import traceback -from collections.abc import Awaitable, Callable, Coroutine, Generator +from collections.abc import Callable, Coroutine, Generator from contextlib import contextmanager -from dataclasses import dataclass from enum import Enum +from functools import partial from pathlib import Path -from typing import Final, cast +from typing import Annotated, Final, Literal, cast -import pytest +from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter from litellm.llms.base_llm.ocr.transformation import OCRResponse -from litellm.rust_bridge import get_native_bridge -from litellm.rust_bridge import ocr as rust_ocr_bridge -from litellm.rust_bridge.ocr import RustAocr, RustOcr -from .....shared.parity.compare import assert_model_parity, assert_parity, assert_request_parity -from .....shared.parity.fixtures.store import recorded_fixtures -from .....shared.parity.inprocess import run_in_process + +from .....shared.parity.compare import assert_parity +from .....shared.parity.fixtures.store import fixture_id, recorded_fixtures from .....shared.parity.models import ( SDKCommand, SDKError, @@ -29,22 +27,21 @@ from .....shared.parity.models import ( WorkerSuccess, sdk_error_report, ) -from .....shared.parity.replay import replay_server from .....shared.parity.runner import ( ExecutionVariant, SubprocessRunner, SubprocessWorker, execution_worker_pair, parity_worker_main, - run_execution, ) +from ...runner import E2ECheck from .fixtures.config import configured_fixture_directory from .fixtures.models import OcrParityCase, OcrSdkInput API_KEY: Final = "test-key" PYTHON_HTTP_SENTINEL: Final = "python-ocr-parity-fallback" -PYTHON_VARIANT: Final = ExecutionVariant(name="Python", environment=(("LITELLM_USE_RUST_OCR", "0"),)) -RUST_VARIANT: Final = ExecutionVariant(name="Rust", environment=(("LITELLM_USE_RUST_OCR", "1"),)) +PYTHON_VARIANT: Final = ExecutionVariant(name="Python", environment=(("LITELLM_RUST", "0"),)) +RUST_VARIANT: Final = ExecutionVariant(name="Rust", environment=(("LITELLM_RUST", "1"),)) class SDKRoute(str, Enum): @@ -52,16 +49,34 @@ class SDKRoute(str, Enum): AOCR = "aocr" -@dataclass(frozen=True, slots=True) -class InvalidOcrCase: +class InvalidOcrCase(BaseModel): + model_config = ConfigDict(frozen=True) + name: str model: str - document: object + document: JsonValue expected_exception_type: str expected_status_code: int expected_message: str - extra_kwargs: tuple[tuple[str, object], ...] = () - expected_rust_calls: int = 0 + extra_kwargs: tuple[tuple[str, JsonValue], ...] = () + + +class RecordedOcrWorkerCase(BaseModel): + model_config = ConfigDict(frozen=True) + + kind: Literal["recorded"] = "recorded" + case: OcrParityCase + + +class InvalidOcrWorkerCase(BaseModel): + model_config = ConfigDict(frozen=True) + + kind: Literal["invalid"] = "invalid" + case: InvalidOcrCase + + +OcrWorkerCase = Annotated[RecordedOcrWorkerCase | InvalidOcrWorkerCase, Field(discriminator="kind")] +OCR_WORKER_CASE_ADAPTER: Final[TypeAdapter[OcrWorkerCase]] = TypeAdapter(OcrWorkerCase) INVALID_OCR_CASES: Final = ( @@ -120,7 +135,6 @@ INVALID_OCR_CASES: Final = ( expected_exception_type="litellm.exceptions.APIConnectionError", expected_status_code=500, expected_message="Document URL is required", - expected_rust_calls=1, ), InvalidOcrCase( name="missing_image_url", @@ -129,7 +143,6 @@ INVALID_OCR_CASES: Final = ( expected_exception_type="litellm.exceptions.APIConnectionError", expected_status_code=500, expected_message="Document URL is required", - expected_rust_calls=1, ), InvalidOcrCase( name="invalid_request_format", @@ -199,32 +212,13 @@ def _execute_sdk_case( return _execute_sdk_call(call_kwargs, route, event_loop) -def _execute_recorded_sdk_case( - sdk_input: OcrSdkInput, - route: SDKRoute, - mock_url: str, - event_loop: asyncio.AbstractEventLoop, -) -> OCRResponse | SDKError: - import litellm - - call_kwargs: Final = _call_kwargs(sdk_input, mock_url, route) - try: - if route is SDKRoute.OCR: - sync_route: Final = cast(Callable[..., OCRResponse], litellm.ocr) - return sync_route(**call_kwargs) - async_route: Final = cast(Callable[..., Coroutine[object, object, OCRResponse]], litellm.aocr) - return event_loop.run_until_complete(async_route(**call_kwargs)) - except Exception as error: - return sdk_error_report(error) - - def _execute_invalid_sdk_case( case: InvalidOcrCase, route: SDKRoute, mock_url: str, event_loop: asyncio.AbstractEventLoop, ) -> SDKReport: - call_kwargs: Final = { + call_kwargs: Final[dict[str, object]] = { "model": case.model, "document": case.document, "api_base": mock_url, @@ -235,204 +229,93 @@ def _execute_invalid_sdk_case( return _execute_sdk_call(call_kwargs, route, event_loop) -class _RustOcrSpy: - def __init__(self, delegate: RustOcr) -> None: - self.delegate: Final = delegate - self.calls = 0 +def _check_recorded_ocr_sdk_parity( + ocr_fixture: OcrParityCase, + route: SDKRoute, + case_file: Path, + sdk_workers: tuple[SubprocessWorker, SubprocessWorker], +) -> None: + python_worker, rust_worker = sdk_workers + python: Final = python_worker.execute(case_file, route.value, ocr_fixture.provider_responses) + rust: Final = rust_worker.execute(case_file, route.value, ocr_fixture.provider_responses) - def __call__( - self, - model: str, - document: dict[str, object], - api_key: str | None, - api_base: str | None, - custom_llm_provider: str | None, - extra_headers: dict[str, object] | None, - optional_params: dict[str, object], - timeout_seconds: float | None, - ) -> dict[str, object]: - self.calls += 1 - return self.delegate( - model=model, - document=document, - api_key=api_key, - api_base=api_base, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - optional_params=optional_params, - timeout_seconds=timeout_seconds, - ) + assert_parity(python, rust, PYTHON_HTTP_SENTINEL) + if any(response.status_code >= 400 for response in ocr_fixture.provider_responses): + assert isinstance(python.report, SDKError) -class _RustAocrSpy: - def __init__(self, delegate: RustAocr) -> None: - self.delegate: Final = delegate - self.calls = 0 +def _check_invalid_ocr_sdk_parity( + case: InvalidOcrCase, + route: SDKRoute, + case_file: Path, + sdk_workers: tuple[SubprocessWorker, SubprocessWorker], +) -> None: + python_worker, rust_worker = sdk_workers + python: Final = python_worker.execute(case_file, route.value, ()) + rust: Final = rust_worker.execute(case_file, route.value, ()) - async def __call__( - self, - model: str, - document: dict[str, object], - api_key: str | None, - api_base: str | None, - custom_llm_provider: str | None, - extra_headers: dict[str, object] | None, - optional_params: dict[str, object], - timeout_seconds: float | None, - ) -> dict[str, object]: - self.calls += 1 - result: Final[Awaitable[dict[str, object]]] = self.delegate( - model=model, - document=document, - api_key=api_key, - api_base=api_base, - custom_llm_provider=custom_llm_provider, - extra_headers=extra_headers, - optional_params=optional_params, - timeout_seconds=timeout_seconds, - ) - return await result + assert_parity(python, rust, PYTHON_HTTP_SENTINEL) + assert python.requests == () + assert rust.requests == () + assert isinstance(python.report, SDKError) + assert python.report.exception_type == case.expected_exception_type + assert python.report.status_code == case.expected_status_code + assert case.expected_message in python.report.message + + +def _recorded_check_name(fixture: OcrParityCase, route: SDKRoute) -> str: + case_input: Final = fixture.litellm_input + provider: Final = case_input.custom_llm_provider + prefix: Final = f"{provider}/{case_input.model}" if provider else case_input.model + return f"recorded:{route.value}:{fixture_id(case_input, prefix)}" + + +def _write_worker_case(directory: Path, index: int, case: OcrWorkerCase) -> Path: + case_file: Final = directory / f"case-{index}.json" + case_file.write_text(OCR_WORKER_CASE_ADAPTER.dump_json(case).decode("utf-8"), encoding="utf-8") + return case_file @contextmanager -def _restore_rust_ocr_state() -> Generator[None]: - enabled: Final = rust_ocr_bridge.rust_ocr_enabled() - ocr_impl: Final = rust_ocr_bridge._rust_ocr_impl # pyright: ignore[reportPrivateUsage] # preserve injected test binding - aocr_impl: Final = rust_ocr_bridge._rust_aocr_impl # pyright: ignore[reportPrivateUsage] # preserve injected test binding - try: - yield - finally: - rust_ocr_bridge.use_litellm_rust(enabled, ocr=ocr_impl, aocr=aocr_impl) - - -def _native_spies() -> tuple[_RustOcrSpy, _RustAocrSpy]: - native_bridge: Final = get_native_bridge() - if native_bridge is None: - pytest.fail("native Rust bridge is required for OCR parity testing") - sync_spy: Final = _RustOcrSpy(cast(RustOcr, getattr(native_bridge, "ocr"))) - async_spy: Final = _RustAocrSpy(cast(RustAocr, getattr(native_bridge, "aocr"))) - return sync_spy, async_spy - - -@pytest.fixture(scope="module") -def sdk_workers() -> Generator[tuple[SubprocessWorker, SubprocessWorker]]: +def parity_checks() -> Generator[tuple[E2ECheck, ...]]: + fixtures: Final = tuple( + fixture + for fixture in recorded_fixtures(configured_fixture_directory(), OcrParityCase) + if fixture.litellm_input.contract not in {"reducto_v3", "reducto_legacy"} + ) runner: Final = SubprocessRunner( entrypoint=Path(__file__), baseline_user_agent=PYTHON_HTTP_SENTINEL, route_label="OCR", ) - with execution_worker_pair(runner, PYTHON_VARIANT, RUST_VARIANT) as workers: - yield workers - - -@pytest.fixture(scope="module") -def startup_ocr_fixture() -> OcrParityCase: - directory: Final = configured_fixture_directory() - fixtures: Final = recorded_fixtures(directory, OcrParityCase) - if not fixtures: - pytest.skip(f"no recorded fixtures in {directory}") - return fixtures[0] - - -@pytest.mark.parametrize("route", tuple(SDKRoute), ids=tuple(route.value for route in SDKRoute)) -def test_recorded_ocr_sdk_parity( - ocr_fixture: OcrParityCase, - route: SDKRoute, -) -> None: - sync_spy, async_spy = _native_spies() - event_loop: Final = asyncio.new_event_loop() - try: - with _restore_rust_ocr_state(), replay_server() as provider: - rust_ocr_bridge.use_litellm_rust(False, ocr=sync_spy, aocr=async_spy) - rust_ocr_bridge.use_litellm_rust(False) - python: Final = run_in_process( - provider, - ocr_fixture.provider_responses, - lambda mock_url: _execute_recorded_sdk_case(ocr_fixture.litellm_input, route, mock_url, event_loop), + with tempfile.TemporaryDirectory(prefix="litellm-ocr-parity-") as raw_directory: + directory: Final = Path(raw_directory) + recorded_files: Final = tuple( + _write_worker_case(directory, index, RecordedOcrWorkerCase(case=fixture)) + for index, fixture in enumerate(fixtures) + ) + invalid_files: Final = tuple( + _write_worker_case(directory, len(recorded_files) + index, InvalidOcrWorkerCase(case=case)) + for index, case in enumerate(INVALID_OCR_CASES) + ) + with execution_worker_pair(runner, PYTHON_VARIANT, RUST_VARIANT) as workers: + recorded: Final = tuple( + E2ECheck( + _recorded_check_name(fixture, route), + partial(_check_recorded_ocr_sdk_parity, fixture, route, case_file, workers), + ) + for fixture, case_file in zip(fixtures, recorded_files, strict=True) + for route in SDKRoute ) - assert sync_spy.calls == 0 - assert async_spy.calls == 0 - - rust_ocr_bridge.use_litellm_rust(True) - rust: Final = run_in_process( - provider, - ocr_fixture.provider_responses, - lambda mock_url: _execute_recorded_sdk_case(ocr_fixture.litellm_input, route, mock_url, event_loop), + invalid: Final = tuple( + E2ECheck( + f"invalid:{route.value}:{case.name}", + partial(_check_invalid_ocr_sdk_parity, case, route, case_file, workers), + ) + for case, case_file in zip(INVALID_OCR_CASES, invalid_files, strict=True) + for route in SDKRoute ) - finally: - event_loop.close() - - assert sync_spy.calls == (1 if route is SDKRoute.OCR else 0) - assert async_spy.calls == (1 if route is SDKRoute.AOCR else 0) - assert_request_parity(python.requests, rust.requests) - if any(response.status_code >= 400 for response in ocr_fixture.provider_responses): - assert isinstance(python.response, SDKError) - if isinstance(python.response, SDKError): - assert python.response == rust.response - else: - assert isinstance(rust.response, OCRResponse) - assert_model_parity(python.response, rust.response) - - -@pytest.mark.parametrize("case", INVALID_OCR_CASES, ids=tuple(case.name for case in INVALID_OCR_CASES)) -@pytest.mark.parametrize("route", tuple(SDKRoute), ids=tuple(route.value for route in SDKRoute)) -def test_invalid_ocr_sdk_parity(case: InvalidOcrCase, route: SDKRoute) -> None: - sync_spy, async_spy = _native_spies() - event_loop: Final = asyncio.new_event_loop() - try: - with _restore_rust_ocr_state(), replay_server() as provider: - rust_ocr_bridge.use_litellm_rust(False, ocr=sync_spy, aocr=async_spy) - rust_ocr_bridge.use_litellm_rust(False) - python: Final = run_in_process( - provider, - (), - lambda mock_url: _execute_invalid_sdk_case(case, route, mock_url, event_loop), - ) - assert sync_spy.calls == 0 - assert async_spy.calls == 0 - - rust_ocr_bridge.use_litellm_rust(True) - rust: Final = run_in_process( - provider, - (), - lambda mock_url: _execute_invalid_sdk_case(case, route, mock_url, event_loop), - ) - finally: - event_loop.close() - - assert sync_spy.calls == (case.expected_rust_calls if route is SDKRoute.OCR else 0) - assert async_spy.calls == (case.expected_rust_calls if route is SDKRoute.AOCR else 0) - assert python.requests == () - assert rust.requests == () - assert python.response == rust.response - assert isinstance(python.response, SDKError) - assert python.response.exception_type == case.expected_exception_type - assert python.response.status_code == case.expected_status_code - assert case.expected_message in python.response.message - - -def test_ocr_subprocess_startup_smoke( - startup_ocr_fixture: OcrParityCase, - tmp_path: Path, - sdk_workers: tuple[SubprocessWorker, SubprocessWorker], -) -> None: - case_file: Final = tmp_path / "ocr-startup-smoke.json" - case_file.write_text(startup_ocr_fixture.model_dump_json(indent=2, exclude_unset=True), encoding="utf-8") - python_worker, rust_worker = sdk_workers - python: Final = run_execution( - python_worker, - case_file, - SDKRoute.OCR.value, - startup_ocr_fixture.provider_responses, - ) - rust: Final = run_execution( - rust_worker, - case_file, - SDKRoute.OCR.value, - startup_ocr_fixture.provider_responses, - ) - - assert_parity(python, rust, PYTHON_HTTP_SENTINEL) + yield (*recorded, *invalid) def _execute_worker_command( @@ -444,8 +327,12 @@ def _execute_worker_command( command: Final = SDKCommand.model_validate_json(command_json) case_file: Final = Path(command.case_file) route: Final = SDKRoute(command.route) - case: Final = OcrParityCase.model_validate_json(case_file.read_text(encoding="utf-8")) - return WorkerSuccess(report=_execute_sdk_case(case.litellm_input, route, mock_url, event_loop)) + worker_case: Final = OCR_WORKER_CASE_ADAPTER.validate_json(case_file.read_bytes()) + match worker_case: + case RecordedOcrWorkerCase(case=recorded): + return WorkerSuccess(report=_execute_sdk_case(recorded.litellm_input, route, mock_url, event_loop)) + case InvalidOcrWorkerCase(case=invalid): + return WorkerSuccess(report=_execute_invalid_sdk_case(invalid, route, mock_url, event_loop)) except Exception: return WorkerFailure(error=traceback.format_exc()) diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_support.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_support.py new file mode 100644 index 00000000000..b6526fb12f5 --- /dev/null +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_support.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TypeVar + +from hypothesis import find, settings +from hypothesis.strategies import SearchStrategy + +FixtureT = TypeVar("FixtureT") +FIND_SETTINGS = settings(max_examples=2_000, deadline=None, derandomize=True, database=None) + + +def find_fixture( + strategy: SearchStrategy[FixtureT], + predicate: Callable[[FixtureT], bool], +) -> FixtureT: + return find(strategy, predicate, settings=FIND_SETTINGS) diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/responses/__init__.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/responses/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/e2e_parity/strategy.json b/tests/rust-python-harness/strategies/e2e_parity/strategy.json deleted file mode 100644 index d791b9373aa..00000000000 --- a/tests/rust-python-harness/strategies/e2e_parity/strategy.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "order": 10, - "id": "e2e_parity", - "label": "End-to-end parity", - "description": "Compare observable Python and Rust SDK behavior over generated and recorded inputs.", - "functions": { - "ocr": { - "coverage": "partial", - "selectors": [ - "tests/rust-python-harness/strategies/e2e_parity/sdk/ocr/test_sdk_parity.py", - "tests/test_litellm/ocr/test_rust_bridge.py" - ], - "note": "Recorded sync/async SDK parity; invalid-model provider errors differ, and Reducto lacks a Rust contract." - }, - "messages": { - "coverage": "partial", - "selectors": [ - "tests/test_litellm/anthropic_interface/test_rust_bridge_messages.py" - ], - "note": "Bridge coverage exists; frozen-oracle fuzz parity is still being added." - }, - "responses": { - "coverage": "partial", - "selectors": [ - "tests/test_litellm/responses/test_rust_bridge_websocket.py" - ], - "note": "Covers the websocket bridge; full responses parity is still being added." - }, - "count_tokens": { - "coverage": "planned", - "selectors": [], - "note": "No Rust count_tokens parity test is present yet." - }, - "chat_completions": { - "coverage": "partial", - "selectors": [ - "tests/test_litellm/rust_bridge/test_chat_completions.py" - ], - "note": "Bridge coverage exists; frozen-oracle fuzz parity is still being added." - }, - "transcription": { - "coverage": "partial", - "selectors": [ - "tests/test_litellm/test_audio_transcription_rust_bridge.py" - ], - "note": "Bridge coverage exists; frozen-oracle fuzz parity is still being added." - } - }, - "gateway": {} -} diff --git a/tests/rust-python-harness/strategies/e2e_parity/test_runner.py b/tests/rust-python-harness/strategies/e2e_parity/test_runner.py new file mode 100644 index 00000000000..31ef907465a --- /dev/null +++ b/tests/rust-python-harness/strategies/e2e_parity/test_runner.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from functools import partial +from pathlib import Path +from types import SimpleNamespace +from typing import Final +from unittest.mock import Mock, call + +from pytest import MonkeyPatch + +from ...shared.reporting.models import Coverage, HarnessCase, RunStatus +from ...shared.reporting.strategy import ModuleCaseSpec +from . import runner as e2e_runner +from .runner import E2ECheck, run_e2e_cases + + +def test_runs_checks_inside_suite_context(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + lifecycle: Final = Mock() + + @contextmanager + def parity_checks() -> Generator[tuple[E2ECheck, ...]]: + lifecycle("entered") + try: + yield (E2ECheck("check", partial(lifecycle, "checked")),) + finally: + lifecycle("exited") + + module: Final = SimpleNamespace(parity_checks=parity_checks) + + def import_module(_name: str, _package: str | None = None) -> SimpleNamespace: + return module + + monkeypatch.setattr(e2e_runner.importlib, "import_module", import_module) + case: Final = HarnessCase( + strategy_id="e2e_parity", + strategy_label="End-to-end parity", + sdk_function="ocr", + spec=ModuleCaseSpec(coverage=Coverage.PARTIAL, module="example"), + surface="sdk", + ) + + code, run = run_e2e_cases((case,), tmp_path, lambda _: None) + + assert code == 0, run.failures + assert run.results[case.key].status is RunStatus.PASSED + assert lifecycle.call_args_list == [call("entered"), call("checked"), call("exited")] diff --git a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/README.md b/tests/rust-python-harness/strategies/existing_e2e_test_sdk/README.md deleted file mode 100644 index fb84f170703..00000000000 --- a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Existing e2e SDK tests - -Wires already-existing live-API SDK tests into the matrix instead of writing new parity tests. Selectors point at real test files and folders, such as `tests/ocr_tests/`, rather than individual node IDs, so future tests added to those folders are picked up automatically. diff --git a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/__init__.py b/tests/rust-python-harness/strategies/existing_e2e_test_sdk/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/runner.py b/tests/rust-python-harness/strategies/existing_e2e_test_sdk/runner.py deleted file mode 100644 index f5ea17735fc..00000000000 --- a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/runner.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from pathlib import Path - -from ...shared.reporting.models import HarnessCase, HarnessRun -from ...shared.reporting.pytest_runner import UpdateCallback, run_pytest - - -def run( - cases: Sequence[HarnessCase], - repo_root: Path, - on_update: UpdateCallback, - pytest_args: Sequence[str] = (), -) -> tuple[int, HarnessRun]: - return run_pytest(cases, repo_root, on_update, pytest_args) - - -def main(argv: Sequence[str] | None = None) -> int: - from ...cli import main as harness_main - - return harness_main(argv, strategy_id="existing_e2e_test_sdk") - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/strategy.json b/tests/rust-python-harness/strategies/existing_e2e_test_sdk/strategy.json deleted file mode 100644 index eefceea1a75..00000000000 --- a/tests/rust-python-harness/strategies/existing_e2e_test_sdk/strategy.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "order": 40, - "id": "existing_e2e_test_sdk", - "label": "Existing e2e SDK tests", - "description": "Wire already-existing live-API SDK tests into the matrix instead of writing new parity tests.", - "functions": { - "ocr": {"coverage": "partial", "selectors": ["tests/ocr_tests/"], "note": "Existing live OCR provider tests; not yet a frozen Rust/Python oracle comparison."}, - "messages": {"coverage": "planned", "selectors": []}, - "responses": {"coverage": "planned", "selectors": []}, - "count_tokens": {"coverage": "planned", "selectors": []}, - "chat_completions": {"coverage": "partial", "selectors": ["tests/llm_translation/test_anthropic_completion.py", "tests/llm_translation/test_bedrock_completion.py"], "note": "Existing live chat completion tests for providers with confirmed Rust bridge regressions."}, - "transcription": {"coverage": "partial", "selectors": ["tests/audio_tests/test_whisper.py"], "note": "Existing live Whisper transcription test."} - } -} diff --git a/tests/rust-python-harness/strategies/trace_parity/AGENTS.md b/tests/rust-python-harness/strategies/trace_parity/AGENTS.md new file mode 100644 index 00000000000..bb7cb8c91d8 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/AGENTS.md @@ -0,0 +1 @@ +Maps Python profiler frames onto feature-gated Rust span names via an explicit per-case mapping (Rust span name is the identity) and compares steps, order, and nesting of both live traces against a replayed provider response. diff --git a/tests/rust-python-harness/strategies/trace_parity/README.md b/tests/rust-python-harness/strategies/trace_parity/README.md deleted file mode 100644 index 6520a510112..00000000000 --- a/tests/rust-python-harness/strategies/trace_parity/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Trace Parity - -Run independently with `uv run python -m tests.rust-python-harness.strategies.trace_parity.runner --plain`. Configure SDK and gateway selectors in `strategy.json`; keep API-specific execution and fixtures in their owning surface folder - -See [the harness guide](../../README.md) for coverage status and shared comparison tools diff --git a/tests/rust-python-harness/strategies/trace_parity/__init__.py b/tests/rust-python-harness/strategies/trace_parity/__init__.py index e69de29bb2d..ec88b0169fa 100644 --- a/tests/rust-python-harness/strategies/trace_parity/__init__.py +++ b/tests/rust-python-harness/strategies/trace_parity/__init__.py @@ -0,0 +1,115 @@ +from pathlib import Path +from typing import Final + +from ...shared.reporting.models import SURFACES, Coverage +from ...shared.reporting.strategy import ( + CaseDefinition, + ModuleCaseSpec, + NotImplementedCaseSpec, + RunnerArgumentDefinition, + StrategyDefinition, +) +from .reporting import render_trace_results +from .runner import run_trace_cases + +CASES: Final[tuple[CaseDefinition, ...]] = ( + CaseDefinition( + "ocr", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.trace_parity.sdk.ocr.case", + ), + surface="sdk", + ), + CaseDefinition( + "messages", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.trace_parity.sdk.messages.case", + note="Async only until anthropic_messages_handler supports sync calls.", + ), + surface="sdk", + ), + CaseDefinition( + "responses", + NotImplementedCaseSpec(reason="No Responses trace-parity case is registered."), + surface="sdk", + ), + CaseDefinition( + "count_tokens", + NotImplementedCaseSpec(reason="No token-count trace-parity case is registered."), + surface="sdk", + ), + CaseDefinition( + "chat_completions", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.trace_parity.sdk.chat_completions.case", + ), + surface="sdk", + ), + CaseDefinition( + "transcription", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.trace_parity.sdk.transcription.case", + note=( + "The Python SDK delegates this provider to the Rust pipeline, so only dispatch is visible " + "to the Python profiler." + ), + ), + surface="sdk", + ), + CaseDefinition( + "ocr", + NotImplementedCaseSpec(reason="No gateway OCR trace-parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "messages", + ModuleCaseSpec( + coverage=Coverage.PARTIAL, + module="tests.rust-python-harness.strategies.trace_parity.gateway.messages.case", + note="Non-streaming success paths only.", + ), + surface="gateway", + ), + CaseDefinition( + "responses", + NotImplementedCaseSpec(reason="No gateway Responses trace-parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "count_tokens", + NotImplementedCaseSpec(reason="No gateway token-count trace-parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "chat_completions", + NotImplementedCaseSpec(reason="No gateway chat trace-parity case is registered."), + surface="gateway", + ), + CaseDefinition( + "transcription", + NotImplementedCaseSpec(reason="No gateway transcription trace-parity case is registered."), + surface="gateway", + ), +) + +STRATEGY: Final = StrategyDefinition( + id="trace_parity", + order=20, + label="Trace parity", + description="Compare pipeline steps, order, and nesting between Python profiler frames and Rust spans via an explicit mapping.", + directory=Path(__file__).parent, + runnable_spec=ModuleCaseSpec, + cases=CASES, + run=run_trace_cases, + render=render_trace_results, + surfaces=SURFACES, + runner_argument=RunnerArgumentDefinition( + option="--scenario", + metavar="NAME", + help="run only this named trace scenario; repeat to select more than one", + ), +) diff --git a/tests/rust-python-harness/strategies/trace_parity/gateway/__init__.py b/tests/rust-python-harness/strategies/trace_parity/gateway/__init__.py index e69de29bb2d..f999dfecfc6 100644 --- a/tests/rust-python-harness/strategies/trace_parity/gateway/__init__.py +++ b/tests/rust-python-harness/strategies/trace_parity/gateway/__init__.py @@ -0,0 +1 @@ +"""In-process gateway trace adapters.""" diff --git a/tests/rust-python-harness/strategies/trace_parity/gateway/execution.py b/tests/rust-python-harness/strategies/trace_parity/gateway/execution.py new file mode 100644 index 00000000000..2bd3a50f39f --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/gateway/execution.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Final, Protocol, cast + +import httpx +from pydantic import BaseModel, ConfigDict + +from ....shared.parity.replay import replay_server +from ....shared.tracing.native import TraceResponsePayload, native_trace_events +from ....shared.tracing.profiler import FunctionTraceEvent, profile_python +from ....shared.tracing.steps import Engine, PipelineProjection, pipeline_projection +from ..models import GatewayRouteSpec, RouteFixture, TraceExecutionFailure, TraceMode, TraceScenario +from ..reporting import TraceComparisonArtifact + + +class _GatewayResponsePayload(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid") + + status: int + body: object + + +class _GatewayClient(Protocol): + def post(self, url: str, *, json: object, headers: dict[str, str]) -> httpx.Response: ... + + +def _collect_python(fixture: RouteFixture) -> tuple[FunctionTraceEvent, ...]: + import litellm + from fastapi.testclient import TestClient + + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.anthropic_endpoints.endpoints import user_api_key_auth + from litellm.proxy import proxy_server + + provider_model: Final = cast(str, fixture.kwargs["provider_model"]) + model_alias: Final = cast(str, fixture.kwargs["model_alias"]) + old_router: Final = proxy_server.llm_router + old_override: Final = proxy_server.app.dependency_overrides.get(user_api_key_auth) + + async def authorize() -> UserAPIKeyAuth: + return UserAPIKeyAuth(api_key="trace-key") + + proxy_server.llm_router = litellm.Router( + model_list=[ + { + "model_name": model_alias, + "litellm_params": { + "model": provider_model, + "api_key": "trace-provider-key", + "api_base": fixture.kwargs["api_base"], + }, + } + ] + ) + proxy_server.app.dependency_overrides[user_api_key_auth] = authorize + try: + with profile_python(Path(litellm.__file__).parent, threads=True) as profiler: + client: Final = cast(_GatewayClient, TestClient(proxy_server.app)) + response: Final = client.post( + "/v1/messages", + json=fixture.kwargs["body"], + headers={"authorization": "Bearer trace-key"}, + ) + if response.status_code != 200: + raise RuntimeError(f"Python gateway returned {response.status_code}: {response.text}") + return tuple(profiler.events) + finally: + proxy_server.llm_router = old_router + if old_override is None: + proxy_server.app.dependency_overrides.pop(user_api_key_auth, None) + else: + proxy_server.app.dependency_overrides[user_api_key_auth] = old_override + + +def _collect_rust(fixture: RouteFixture) -> tuple[FunctionTraceEvent, ...]: + from litellm.rust_bridge import get_native_bridge + + bridge: Final[object | None] = get_native_bridge() + trace: Final[object | None] = getattr(bridge, "_trace", None) if bridge is not None else None + gateway_messages: Final[object | None] = getattr(trace, "gateway_messages", None) + if gateway_messages is None or not callable(gateway_messages): + raise RuntimeError("native Rust trace bridge does not expose gateway_messages") + invoke_gateway: Final = cast(Callable[[str, str, str, object], Awaitable[object]], gateway_messages) + + async def invoke() -> object: + return await invoke_gateway( + cast(str, fixture.kwargs["model_alias"]), + cast(str, fixture.kwargs["provider_model"]), + cast(str, fixture.kwargs["api_base"]), + fixture.kwargs["body"], + ) + + result: Final = asyncio.run(invoke()) + payload: Final = TraceResponsePayload.model_validate(result) + response: Final = _GatewayResponsePayload.model_validate(payload.response) + if response.status != 200: + raise RuntimeError(f"Rust gateway returned {response.status}: {response.body}") + return native_trace_events(payload) + + +def _collect(scenario: TraceScenario, engine: Engine) -> tuple[FunctionTraceEvent, ...] | TraceExecutionFailure: + try: + with replay_server() as provider: + base_fixture: Final = scenario.fixture(engine, provider.url) + fixture: Final = RouteFixture( + kwargs={**base_fixture.kwargs, "api_base": provider.url}, + provider_responses=base_fixture.provider_responses, + ) + for response in fixture.provider_responses: + provider.enqueue_response(response) + events: Final = _collect_python(fixture) if engine == "python" else _collect_rust(fixture) + provider.take_requests(len(fixture.provider_responses)) + return events + except Exception as error: + return TraceExecutionFailure(engine, f"{type(error).__name__}: {error}") + + +def _projections( + python_events: tuple[FunctionTraceEvent, ...], + rust_events: tuple[FunctionTraceEvent, ...], + scenario: TraceScenario, + mode: TraceMode, +) -> tuple[PipelineProjection, PipelineProjection, str | None]: + mappings: Final = scenario.mappings_for(mode) + try: + return ( + pipeline_projection("python", python_events, mappings), + pipeline_projection("rust", rust_events, mappings), + None, + ) + except ValueError as error: + return PipelineProjection(), PipelineProjection(), f"harness: {error}" + + +def execute_gateway_trace(route: GatewayRouteSpec, scenario: TraceScenario, mode: TraceMode) -> TraceComparisonArtifact: + mappings: Final = scenario.mappings_for(mode) + python_trace: Final = _collect(scenario, "python") + rust_trace: Final = _collect(scenario, "rust") + collection_python_error: Final = None if isinstance(python_trace, tuple) else f"python: {python_trace.message}" + rust_error: Final = None if isinstance(rust_trace, tuple) else f"rust: {rust_trace.message}" + python_events: Final = python_trace if isinstance(python_trace, tuple) else () + rust_events: Final = rust_trace if isinstance(rust_trace, tuple) else () + python, rust, projection_error = _projections(python_events, rust_events, scenario, mode) + python_error: Final = projection_error or collection_python_error + return TraceComparisonArtifact.from_traces( + surface="gateway", + sdk_function=route.route, + scenario=scenario.name, + mode=mode, + mappings=mappings, + contract=scenario.contract, + python=python.steps, + rust=rust.steps, + python_unmatched=python.unmatched, + python_error=python_error, + rust_error=rust_error, + ) diff --git a/tests/rust-python-harness/strategies/trace_parity/gateway/messages/__init__.py b/tests/rust-python-harness/strategies/trace_parity/gateway/messages/__init__.py new file mode 100644 index 00000000000..bd9195b7c22 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/gateway/messages/__init__.py @@ -0,0 +1 @@ +"""Messages gateway trace cases.""" diff --git a/tests/rust-python-harness/strategies/trace_parity/gateway/messages/case.py b/tests/rust-python-harness/strategies/trace_parity/gateway/messages/case.py new file mode 100644 index 00000000000..30f51cee353 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/gateway/messages/case.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +from typing import Final + +from .....shared.parity.recorded_http import HttpHeader, RecordedHttpResponse +from .....shared.tracing.steps import Engine, mapping +from ...models import GatewayRouteSpec, RouteFixture, TraceScenario, TraceSuite + + +GATEWAY_MAPPINGS: Final = ( + mapping( + span="python_messages_gateway_route", + python_frame=r"anthropic_endpoints/endpoints\.py:\d+ anthropic_response$", + ), + mapping(rust_span="messages_gateway_route"), + mapping( + span="python_messages_gateway_service", + python_frame=r"ProxyBaseLLMRequestProcessing\.base_process_llm_request$", + ), + mapping(rust_span="messages_gateway_service"), + mapping(rust_span="messages"), + mapping( + span="python_messages_provider_config", + python_frame=r"ProviderConfigManager\.get_provider_anthropic_messages_config$", + ), + mapping(rust_span="messages_provider_config"), + mapping(rust_span="validate_environment", python_frame=r"validate_anthropic_messages_environment$"), + mapping(rust_span="complete_url", python_frame=r"get_complete_url$"), + mapping(span="python_messages_entry_handler", python_frame=r"messages/handler\.py:\d+ anthropic_messages_handler$"), + mapping(span="python_messages_handler_wrapper", python_frame=r"BaseLLMHTTPHandler\.anthropic_messages_handler$"), + mapping( + rust_span="execute_messages_provider_call", + python_frame=r"BaseLLMHTTPHandler\.async_anthropic_messages_handler$", + ), + mapping(rust_span="http_request", python_frame=r"AsyncHTTPHandler\.post$|HTTPHandler\.post$"), + mapping(rust_span="transform_response", python_frame=r"(? RouteFixture: + return RouteFixture( + kwargs={ + "model_alias": "trace-model", + "provider_model": f"{provider}/claude-sonnet-5", + "body": { + "model": "trace-model", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 16, + }, + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, + (HttpHeader(name="content-type", value="application/json"),), + json.dumps( + { + "id": "msg_trace", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-5", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 2, "output_tokens": 3}, + } + ).encode(), + ), + ), + ) + + +def _anthropic_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture(engine, "anthropic") + + +def _azure_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture(engine, "azure_ai") + + +ANTHROPIC_MAPPINGS: Final = ( + *GATEWAY_MAPPINGS, + mapping( + rust_span="transform_request", + python_frame=r"(? tuple[TraceMapping, ...]: + selected: Final = self.async_mappings if mode == "async" else self.sync_mappings + return self.mappings if selected is None else selected + + +@dataclass(frozen=True, slots=True) +class TraceSuite: + route: TraceRouteSpec + scenarios: tuple[TraceScenario, ...] + + +@dataclass(frozen=True, slots=True) +class TraceExecutionFailure: + engine: TraceFailureSource + message: str diff --git a/tests/rust-python-harness/strategies/trace_parity/reporting.py b/tests/rust-python-harness/strategies/trace_parity/reporting.py new file mode 100644 index 00000000000..9c5bf9e88cd --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/reporting.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +import os +import re +import sys +from collections.abc import Sequence +from typing import Final, Literal + +from pydantic import BaseModel, ConfigDict, ValidationError + +from ...shared.reporting.models import SURFACES, CaseResult, RunStatus, SdkFunction, Surface +from ...shared.reporting.rendering import ReportSection +from ...shared.reporting.strategy import NotImplementedCaseSpec, SkippedCaseSpec +from ...shared.tracing.steps import ( + PipelineStep, + TraceContract, + TraceDiff, + TraceMapping, + trace_depths, + trace_diff, +) + +TRACE_COMPARISON_ARTIFACT: Final = "trace_comparison" +TRACE_PARITY_HINT: Final = ( + "rebuild the native bridge with the trace-parity feature, e.g. `uvx maturin develop --features trace-parity`" +) + +_COLORS: Final[dict[str, str]] = {"green": "32", "yellow": "33", "red": "31", "cyan": "36"} +_RESET: Final = "\033[0m" + + +def _paint(text: str, color: str) -> str: + if not sys.stdout.isatty() or os.environ.get("NO_COLOR"): + return text + return f"\033[{_COLORS[color]}m{text}{_RESET}" + + +class TraceEventArtifact(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + id: int + parent_id: int | None + span: str + raw: str + + def step(self) -> PipelineStep: + return PipelineStep(self.id, self.parent_id, self.span, self.raw) + + +class TraceMappingArtifact(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + span: str + python: str | None + rust: str | None + + +class TraceComparisonArtifact(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + surface: Surface + sdk_function: SdkFunction + scenario: str + mode: Literal["sync", "async"] + mappings: tuple[TraceMappingArtifact, ...] + python: tuple[TraceEventArtifact, ...] + rust: tuple[TraceEventArtifact, ...] + python_unmatched: int + unordered_children_of: frozenset[str] + python_error: str | None = None + rust_error: str | None = None + + @classmethod + def from_traces( + cls, + *, + surface: Surface, + sdk_function: SdkFunction, + scenario: str, + mode: Literal["sync", "async"], + mappings: Sequence[TraceMapping], + contract: TraceContract, + python: Sequence[PipelineStep], + rust: Sequence[PipelineStep], + python_unmatched: int, + python_error: str | None = None, + rust_error: str | None = None, + ) -> TraceComparisonArtifact: + return cls( + surface=surface, + sdk_function=sdk_function, + scenario=scenario, + mode=mode, + mappings=tuple( + TraceMappingArtifact( + span=item.span, + python=item.python.pattern if item.python else None, + rust=item.rust, + ) + for item in mappings + ), + python=tuple( + TraceEventArtifact(id=step.id, parent_id=step.parent_id, span=step.span, raw=step.raw) + for step in python + ), + rust=tuple( + TraceEventArtifact(id=step.id, parent_id=step.parent_id, span=step.span, raw=step.raw) + for step in rust + ), + python_unmatched=python_unmatched, + unordered_children_of=contract.unordered_children_of, + python_error=python_error, + rust_error=rust_error, + ) + + def python_steps(self) -> tuple[PipelineStep, ...]: + return tuple(event.step() for event in self.python) + + def rust_steps(self) -> tuple[PipelineStep, ...]: + return tuple(event.step() for event in self.rust) + + def diff(self) -> TraceDiff: + return trace_diff( + self.python_steps(), + self.rust_steps(), + tuple( + TraceMapping( + item.span, + re.compile(item.python) if item.python is not None else None, + item.rust, + ) + for item in self.mappings + ), + TraceContract(self.unordered_children_of), + ) + + def exact_match(self) -> bool: + return self.diff().matches + + def has_errors(self) -> bool: + return self.python_error is not None or self.rust_error is not None + + def contract_matches(self) -> bool: + if self.has_errors(): + return False + return self.diff().matches + + +def _split_raw(raw: str) -> tuple[str, str]: + location, separator, name = raw.partition(" ") + if separator: + return name, location + return raw, "" + + +def _python_line(index: int, step: PipelineStep, depth: int, exclusive: frozenset[str]) -> str: + name: Final = _split_raw(step.raw)[0] + location: Final = _split_raw(step.raw)[1] + suffix: Final = f" ({location})" if location else "" + marker: Final = " [python only]" if step.span in exclusive else "" + return _paint(f"{index} {' ' * depth}{name}{suffix}{marker}", "cyan") + + +def _python_lines(steps: tuple[PipelineStep, ...], exclusive: frozenset[str]) -> str: + depths: Final = trace_depths(steps) + lines: Final = tuple( + _python_line(index, step, depths[step.id], exclusive) for index, step in enumerate(steps, start=1) + ) + return f"{_paint('PYTHON', 'cyan')} ({len(steps)} steps)\n" + ("\n".join(lines) if lines else "(empty)") + + +def _python_references(steps: tuple[PipelineStep, ...]) -> dict[tuple[str, int], str]: + references: dict[tuple[str, int], str] = {} + occurrences: dict[str, int] = {} + for index, step in enumerate(steps, start=1): + name = _split_raw(step.raw)[0] + occurrence = occurrences.get(step.span, 0) + 1 + occurrences[step.span] = occurrence + references[(step.span, occurrence)] = f"{index} {name}" + return references + + +def _rust_line( + step: PipelineStep, + depth: int, + occurrence: int, + references: dict[tuple[str, int], str], +) -> str: + span: Final = _paint(step.span, "yellow") + key: Final = (step.span, occurrence) + reference: Final = ( + _paint(references[key], "cyan") if key in references else _paint("[rust only]", "yellow") + ) + suffix: Final = f"#{occurrence}" if occurrence > 1 else "" + return f"{' ' * depth}{span}{suffix} -> {reference}" + + +def _rust_lines(steps: tuple[PipelineStep, ...], references: dict[tuple[str, int], str]) -> str: + depths: Final = trace_depths(steps) + occurrences: dict[str, int] = {} + lines: list[str] = [] + for step in steps: + occurrence = occurrences.get(step.span, 0) + 1 + occurrences[step.span] = occurrence + lines.append(_rust_line(step, depths[step.id], occurrence, references)) + return f"{_paint('RUST', 'yellow')} ({len(steps)} steps)\n" + ("\n".join(lines) if lines else "(empty)") + + +def _state_text(state: str, *, good: bool) -> str: + return _paint(state, "green" if good else "red") + + +def _contract_line(artifact: TraceComparisonArtifact) -> str: + matches: Final = artifact.contract_matches() + status: Final = _state_text("PASS" if matches else "FAIL", good=matches) + if artifact.python_error or artifact.rust_error: + return f"Contract: {status}" + return f"Contract: {status}" + + +def _error_lines(artifact: TraceComparisonArtifact) -> tuple[str, ...]: + lines: list[str] = [] + for engine, error in (("Python", artifact.python_error), ("Rust", artifact.rust_error)): + if error is None: + continue + lines.append(_paint(f"{engine} error: {error}", "red")) + if "trace-parity feature" in error: + lines.append(f"hint: {TRACE_PARITY_HINT}") + return tuple(lines) + + +def _unseen_mappings( + artifact: TraceComparisonArtifact, + python: tuple[PipelineStep, ...], + rust: tuple[PipelineStep, ...], +) -> tuple[str, ...]: + return artifact.diff().missing_mappings + + +def _comparison_status_lines( + artifact: TraceComparisonArtifact, + python: tuple[PipelineStep, ...], + rust: tuple[PipelineStep, ...], +) -> tuple[str, ...]: + diff: Final = artifact.diff() + exact_match: Final = artifact.exact_match() + if artifact.has_errors(): + return (*_error_lines(artifact), _contract_line(artifact)) + unseen: Final = _unseen_mappings(artifact, python, rust) + unseen_line: Final[tuple[str, ...]] = (f"Unseen mappings: {', '.join(unseen)}",) if unseen else () + drift_lines: Final[tuple[str, ...]] = ( + (_state_text("Same steps, order, and nesting", good=True),) + if exact_match + else ( + _paint(f"Python only: {', '.join(diff.python_only) or 'none'}", "cyan"), + _paint(f"Rust only: {', '.join(diff.rust_only) or 'none'}", "yellow"), + f"First difference: {diff.first_difference or 'none'}", + f"Python frames outside mapping: {artifact.python_unmatched}", + ) + ) + return ( + f"Trace: {_state_text('MATCH' if exact_match else 'DRIFT', good=exact_match)}", + *drift_lines, + *unseen_line, + _contract_line(artifact), + ) + + +def _render_comparison(artifact: TraceComparisonArtifact) -> str: + python: Final = artifact.python_steps() + rust: Final = artifact.rust_steps() + diff: Final = artifact.diff() + python_exclusive: Final = frozenset(item.span for item in artifact.mappings if item.rust is None) + status_lines: Final = _comparison_status_lines(artifact, python, rust) + return "\n\n".join( + ( + _python_lines(python, python_exclusive | frozenset(diff.python_only)), + _rust_lines(rust, _python_references(python)), + "\n".join(status_lines), + ) + ) + + +def _mode(nodeid: str) -> str: + if "[" in nodeid: + return nodeid.rsplit("[", 1)[-1].removesuffix("]") + head, _, tail = nodeid.rpartition(":") + return tail if head else "unknown mode" + + +def _scenario(nodeid: str) -> str: + parts: Final = nodeid.split(":") + return parts[-2] if len(parts) >= 5 else "default" + + +def _unavailable(status: RunStatus) -> str: + return f"Trace: NOT AVAILABLE\nTest outcome: {status.value}" + + +def _render_artifact(body: str) -> str: + try: + artifact: Final = TraceComparisonArtifact.model_validate_json(body) + except ValidationError as error: + return f"Trace comparison artifact is invalid: {error}" + return _render_comparison(artifact) + + +def _mode_section(result: CaseResult, nodeid: str, status: RunStatus) -> str: + artifacts: Final = tuple( + artifact for artifact in result.artifacts.get(nodeid, ()) if artifact.kind == TRACE_COMPARISON_ARTIFACT + ) + body: Final = ( + "\n\n".join(_render_artifact(artifact.body) for artifact in artifacts) if artifacts else _unavailable(status) + ) + label: Final = f"Scenario: {_scenario(nodeid)} / Mode: {_mode(nodeid)}" + return f"{label}\n{'-' * len(label)}\n\n{body}" + + +def _case_block(result: CaseResult) -> str: + header: Final = f"Case: {result.case.sdk_function}" + outcomes: Final = tuple(result.outcomes.items()) or ( + (nodeid, RunStatus.NOT_RUN) for nodeid in sorted(result.collected) + ) + sections: Final = tuple(_mode_section(result, nodeid, status) for nodeid, status in outcomes) + return "\n\n".join((f"{header}\n{'=' * len(header)}", *sections)) + + +def _unavailable_block(title: str, lines: tuple[str, ...]) -> str | None: + if not lines: + return None + return f"{title}\n{'-' * len(title)}\n" + "\n".join(lines) + + +def _surface_section(surface: Surface, results: Sequence[CaseResult]) -> ReportSection | None: + selected: Final = tuple(result for result in results if result.case.surface == surface) + if not selected: + return None + outcome_blocks: Final = tuple(_case_block(result) for result in selected if result.outcomes) + not_implemented: Final = _unavailable_block( + "Not implemented", + tuple( + f"- {result.case.sdk_function}: {spec.reason}" + for result in selected + if isinstance((spec := result.case.spec), NotImplementedCaseSpec) + ), + ) + skipped: Final = _unavailable_block( + "Skipped", + tuple( + f"- {result.case.sdk_function}: {spec.reason}" + for result in selected + if isinstance((spec := result.case.spec), SkippedCaseSpec) + ), + ) + blocks: Final = ( + *outcome_blocks, + *((not_implemented,) if not_implemented else ()), + *((skipped,) if skipped else ()), + ) + return ReportSection(f"{surface.upper()} trace comparisons", blocks or ("No runnable trace comparisons",)) + + +def render_trace_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + sections: Final = tuple( + section for surface in SURFACES if (section := _surface_section(surface, results)) is not None + ) + return sections or (ReportSection("Trace comparisons", ("No trace comparisons selected",)),) diff --git a/tests/rust-python-harness/strategies/trace_parity/runner.py b/tests/rust-python-harness/strategies/trace_parity/runner.py index 127bf5dce40..ef373a6f2f6 100644 --- a/tests/rust-python-harness/strategies/trace_parity/runner.py +++ b/tests/rust-python-harness/strategies/trace_parity/runner.py @@ -1,26 +1,182 @@ from __future__ import annotations +import importlib from collections.abc import Sequence from pathlib import Path +from time import monotonic +from typing import Final -from ...shared.reporting.models import HarnessCase, HarnessRun -from ...shared.reporting.pytest_runner import UpdateCallback, run_pytest +from ...shared.reporting.models import CaseResult, HarnessCase, HarnessRun, ResultArtifact, RunStatus, Surface +from ...shared.reporting.strategy import ModuleCaseSpec, UpdateCallback +from ...shared.native_build import ensure_trace_bridge +from .models import GatewayRouteSpec, RouteSpec, TraceExecutionFailure, TraceMode, TraceScenario, TraceSuite +from .reporting import TRACE_COMPARISON_ARTIFACT, TraceComparisonArtifact +from .sdk.execution import execute_trace -def run( +def _load_case(reference: str, harness_case: HarnessCase) -> TraceSuite | TraceExecutionFailure: + try: + module: Final = importlib.import_module(reference) + except Exception as error: + return TraceExecutionFailure("harness", f"cannot import {reference}: {type(error).__name__}: {error}") + suite: Final = getattr(module, "TRACE_SUITE", None) + if not isinstance(suite, TraceSuite): + return TraceExecutionFailure("harness", f"{reference} must export TRACE_SUITE: TraceSuite") + validation_error: Final = validate_trace_suite(suite, harness_case) + if validation_error is not None: + return TraceExecutionFailure("harness", f"{reference} {validation_error}") + return suite + + +def validate_trace_suite(suite: TraceSuite, harness_case: HarnessCase) -> str | None: + names: Final = tuple(scenario.name for scenario in suite.scenarios) + if not names or len(names) != len(set(names)) or any(not name or ":" in name for name in names): + return "scenario names must be non-empty, unique, and colon-free" + invalid_modes: Final = tuple( + scenario.name + for scenario in suite.scenarios + if not scenario.modes + or len(scenario.modes) != len(set(scenario.modes)) + or any(mode not in {"sync", "async"} for mode in scenario.modes) + ) + if invalid_modes: + return f"scenarios must use non-empty, unique sync/async modes: {', '.join(invalid_modes)}" + surface: Final = harness_case.surface + if surface == "sdk" and not isinstance(suite.route, RouteSpec): + return "must use RouteSpec for the sdk surface" + if surface == "gateway" and not isinstance(suite.route, GatewayRouteSpec): + return "must use GatewayRouteSpec for the gateway surface" + if surface is None: + return "requires an sdk or gateway surface" + if suite.route.route != harness_case.sdk_function: + return f"route {suite.route.route} does not match case function {harness_case.sdk_function}" + return None + + +def scenario_nodeids( + trace_suite: TraceSuite, + harness_case: HarnessCase, + selected_scenarios: frozenset[str] = frozenset(), +) -> tuple[tuple[TraceScenario, TraceMode, str], ...]: + surface: Final = harness_case.surface + if surface is None: + return () + return tuple( + (scenario, mode, f"trace:{surface}:{harness_case.sdk_function}:{scenario.name}:{mode}") + for scenario in trace_suite.scenarios + if not selected_scenarios or scenario.name in selected_scenarios + for mode in scenario.modes + ) + + +def _record_setup_failure(run: HarnessRun, case: HarnessCase, message: str, stage: str) -> None: + result: Final = run.results[case.key] + nodeid: Final = f"trace:{case.surface}:{case.sdk_function}:{stage}" + result.collected.add(nodeid) + result.record(nodeid, RunStatus.ERROR) + run.failures.append((nodeid, message)) + + +def run_trace_mode( + run: HarnessRun, + result: CaseResult, + trace_suite: TraceSuite, + scenario: TraceScenario, + mode: TraceMode, + surface: Surface, + nodeid: str, + on_update: UpdateCallback, +) -> None: + started_at: Final = monotonic() + comparison: Final = _execute_mode(trace_suite, scenario, mode, surface) + duration: Final = monotonic() - started_at + if isinstance(comparison, TraceExecutionFailure): + result.record(nodeid, RunStatus.ERROR, duration) + run.failures.append((nodeid, comparison.message)) + on_update(run) + return + artifact: Final = ResultArtifact(TRACE_COMPARISON_ARTIFACT, comparison.model_dump_json()) + if comparison.has_errors(): + result.record(nodeid, RunStatus.ERROR, duration, (artifact,)) + run.failures.append( + (nodeid, "\n".join(error for error in (comparison.python_error, comparison.rust_error) if error)) + ) + else: + status: Final = RunStatus.PASSED if comparison.contract_matches() else RunStatus.FAILED + result.record(nodeid, status, duration, (artifact,)) + if status is RunStatus.FAILED: + run.failures.append((nodeid, "trace contract mismatch; see the rendered comparison")) + on_update(run) + + +def _execute_mode( + trace_suite: TraceSuite, + scenario: TraceScenario, + mode: TraceMode, + surface: Surface, +) -> TraceComparisonArtifact | TraceExecutionFailure: + route: Final = trace_suite.route + if isinstance(route, GatewayRouteSpec): + if surface != "gateway": + return TraceExecutionFailure("harness", "gateway route cannot run on the sdk surface") + from .gateway.execution import execute_gateway_trace + + return execute_gateway_trace(route, scenario, mode) + if surface != "sdk": + return TraceExecutionFailure("harness", "sdk route cannot run on the gateway surface") + return execute_trace(route, scenario, mode, surface) + + +def _run_case( + run: HarnessRun, + harness_case: HarnessCase, + selected_scenarios: frozenset[str], + on_update: UpdateCallback, +) -> None: + result: Final = run.results[harness_case.key] + spec: Final = harness_case.spec + if not isinstance(spec, ModuleCaseSpec): + return + surface: Final = harness_case.surface + if surface is None: + return + trace_suite: Final = _load_case(spec.module, harness_case) + if isinstance(trace_suite, TraceExecutionFailure): + _record_setup_failure(run, harness_case, trace_suite.message, "load") + on_update(run) + return + nodeids: Final = scenario_nodeids(trace_suite, harness_case, selected_scenarios) + result.collected.update(nodeid for _, _, nodeid in nodeids) + if not nodeids: + result.status = RunStatus.SKIPPED + on_update(run) + return + result.status = RunStatus.RUNNING + on_update(run) + for scenario, mode, nodeid in nodeids: + run_trace_mode(run, result, trace_suite, scenario, mode, surface, nodeid, on_update) + + +def run_trace_cases( cases: Sequence[HarnessCase], repo_root: Path, on_update: UpdateCallback, - pytest_args: Sequence[str] = (), + runner_args: Sequence[str] = (), ) -> tuple[int, HarnessRun]: - return run_pytest(cases, repo_root, on_update, pytest_args) - - -def main(argv: Sequence[str] | None = None) -> int: - from ...cli import main as harness_main - - return harness_main(argv, strategy_id="trace_parity") - - -if __name__ == "__main__": - raise SystemExit(main()) + selected_scenarios: Final = frozenset(runner_args) + run: Final = HarnessRun.from_cases(cases) + bridge_error: Final = ensure_trace_bridge(repo_root) + if bridge_error is not None: + for harness_case in cases: + _record_setup_failure(run, harness_case, bridge_error, "bridge") + run.finished_at = monotonic() + on_update(run) + return 1, run + for harness_case in cases: + _run_case(run, harness_case, selected_scenarios, on_update) + run.finished_at = monotonic() + on_update(run) + failed: Final = any( + result.status in {RunStatus.ERROR, RunStatus.FAILED, RunStatus.MISSING} for result in run.results.values() + ) + return int(failed), run diff --git a/tests/rust-python-harness/strategies/trace_parity/sdk/__init__.py b/tests/rust-python-harness/strategies/trace_parity/sdk/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/trace_parity/sdk/chat_completions/case.py b/tests/rust-python-harness/strategies/trace_parity/sdk/chat_completions/case.py new file mode 100644 index 00000000000..6be5afd60d6 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/sdk/chat_completions/case.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +from typing import Final + +from .....shared.parity.recorded_http import HttpHeader, RecordedHttpResponse +from .....shared.tracing.steps import Engine, mapping +from ...models import RouteFixture, RouteSpec, TraceScenario, TraceSuite + +COMMON_MAPPINGS: Final = ( + mapping(span="python_provider_config", python_frame=r"ProviderConfigManager\.get_provider_chat_config$"), + mapping(rust_span="chat_completions_provider_config"), + mapping( + span="python_supported_openai_params", + python_frame=r"litellm_core_utils/get_supported_openai_params\.py:\d+ get_supported_openai_params$", + ), + mapping( + span="python_provider_supported_openai_params", + python_frame=r"AnthropicConfig\.get_supported_openai_params$", + ), + mapping(rust_span="supported_openai_params"), + mapping(rust_span="validate_environment", python_frame=r"(? RouteFixture: + response: Final = json.dumps( + { + "id": "msg_trace", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-5", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 2, "output_tokens": 3}, + } + ).encode() + return RouteFixture( + kwargs={ + "model": "anthropic/claude-sonnet-5", + "messages": [{"role": "user", "content": "hello"}], + **({"optional_params": {"max_tokens": 16}} if engine == "rust" else {"max_tokens": 16}), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, (HttpHeader(name="content-type", value="application/json"),), response + ), + ), + ) + + +def _bedrock_fixture(engine: Engine, _base_url: str) -> RouteFixture: + response: Final = json.dumps( + { + "output": {"message": {"role": "assistant", "content": [{"text": "hello"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 2, "outputTokens": 3, "totalTokens": 5}, + "metrics": {"latencyMs": 1}, + } + ).encode() + credentials: Final = { + "aws_access_key_id": "test-access", + "aws_secret_access_key": "test-secret", + "aws_region_name": "us-east-1", + } + return RouteFixture( + kwargs={ + "model": "bedrock/us-east-1/anthropic.claude-v2", + "messages": [{"role": "user", "content": "hello"}], + **( + {"optional_params": {**credentials, "maxTokens": 16}} + if engine == "rust" + else {**credentials, "max_tokens": 16} + ), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, (HttpHeader(name="content-type", value="application/json"),), response + ), + ), + ) + + +SPEC: Final = RouteSpec( + "chat_completions", + ("completion", "acompletion"), + ("chat_completions", "achat_completions"), + _anthropic_fixture, +) +BEDROCK_COMMON_MAPPINGS: Final = ( + mapping(rust_span="chat_completions_provider_config"), + mapping(rust_span="supported_openai_params"), + mapping(rust_span="execute_chat_completions_provider_call"), + mapping(rust_span="validate_environment"), + mapping(rust_span="http_request", python_frame=r"AsyncHTTPHandler\.post$|HTTPHandler\.post$"), + mapping(span="python_transform_response", python_frame=r"AmazonConverseConfig\._transform_response$"), +) +BEDROCK_SYNC_MAPPINGS: Final = ( + mapping(span="python_chat_completions", python_frame=r"main\.py:\d+ completion$"), + mapping(rust_span="chat_completions"), + mapping(span="python_transform_request", python_frame=r"AmazonConverseConfig\._transform_request$"), + *BEDROCK_COMMON_MAPPINGS, +) +BEDROCK_ASYNC_MAPPINGS: Final = ( + mapping(span="python_chat_completions", python_frame=r"main\.py:\d+ acompletion$"), + mapping(span="python_completion_wrapper", python_frame=r"main\.py:\d+ completion$"), + mapping(rust_span="chat_completions"), + *BEDROCK_COMMON_MAPPINGS, +) +TRACE_SUITE: Final = TraceSuite( + route=SPEC, + scenarios=( + TraceScenario( + name="anthropic", + fixture=_anthropic_fixture, + mappings=COMMON_MAPPINGS, + sync_mappings=SYNC_MAPPINGS, + async_mappings=ASYNC_MAPPINGS, + ), + TraceScenario( + name="bedrock", + fixture=_bedrock_fixture, + mappings=BEDROCK_COMMON_MAPPINGS, + sync_mappings=BEDROCK_SYNC_MAPPINGS, + async_mappings=BEDROCK_ASYNC_MAPPINGS, + ), + ), +) diff --git a/tests/rust-python-harness/strategies/trace_parity/sdk/execution.py b/tests/rust-python-harness/strategies/trace_parity/sdk/execution.py new file mode 100644 index 00000000000..f8d7c55d4e2 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/sdk/execution.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable +from pathlib import Path +from typing import Final, Protocol, cast + +from ....shared.parity.replay import replay_server +from ....shared.reporting.models import Surface +from ....shared.tracing.native import native_trace_events +from ....shared.tracing.profiler import FunctionTraceEvent, profile_python +from ....shared.tracing.steps import Engine, pipeline_projection +from ..models import RouteSpec, TraceExecutionFailure, TraceMode, TraceScenario +from ..reporting import TraceComparisonArtifact + + +class SdkCall(Protocol): + def __call__(self, **kwargs: object) -> object: ... + + +def _invoke(function: SdkCall, kwargs: dict[str, object], *, asynchronous: bool) -> object: + async def invoke_async() -> object: + return await cast(Awaitable[object], function(**kwargs)) + + if asynchronous: + return asyncio.run(invoke_async()) + return function(**kwargs) + + +def _entrypoint(spec: RouteSpec, engine: Engine, *, asynchronous: bool) -> SdkCall | TraceExecutionFailure: + import litellm + from litellm.anthropic_interface import messages as sdk_messages + from litellm.rust_bridge import get_native_bridge + + if engine == "rust": + bridge: Final = cast(object | None, get_native_bridge()) + if bridge is None: + return TraceExecutionFailure("rust", "native Rust bridge is required for trace parity") + trace_bridge: Final[object | None] = getattr(bridge, "_trace", None) + if trace_bridge is None: + return TraceExecutionFailure("rust", "native Rust bridge must include the trace-parity feature") + entrypoint: Final = spec.rust_entrypoints[int(asynchronous)] + function: Final[object | None] = getattr(trace_bridge, entrypoint, None) + if function is None: + return TraceExecutionFailure("rust", f"native Rust trace bridge does not expose {entrypoint}") + return cast(SdkCall, function) + owner: Final = sdk_messages if spec.route == "messages" else litellm + return cast(SdkCall, getattr(owner, spec.python_entrypoints[int(asynchronous)])) + + +def _collect( + function: SdkCall, kwargs: dict[str, object], engine: Engine, *, asynchronous: bool +) -> tuple[FunctionTraceEvent, ...]: + if engine == "rust": + return native_trace_events(_invoke(function, kwargs, asynchronous=asynchronous)) + import litellm + + with profile_python(Path(litellm.__file__).parent, threads=True) as profiler: + _invoke(function, kwargs, asynchronous=asynchronous) + return tuple(profiler.events) + + +def collect_trace( + spec: RouteSpec, engine: Engine, *, asynchronous: bool +) -> tuple[FunctionTraceEvent, ...] | TraceExecutionFailure: + function: Final = _entrypoint(spec, engine, asynchronous=asynchronous) + if isinstance(function, TraceExecutionFailure): + return function + try: + with replay_server() as provider: + fixture: Final = spec.fixture(engine, provider.url) + for response in fixture.provider_responses: + provider.enqueue_response(response) + kwargs: Final = { + **fixture.kwargs, + "api_key": "test-key", + "api_base": provider.url, + **({"timeout_seconds": 5} if engine == "rust" else {"timeout": 5}), + } + events: Final = _collect(function, kwargs, engine, asynchronous=asynchronous) + provider.take_requests(len(fixture.provider_responses)) + except Exception as error: + return TraceExecutionFailure(engine, f"{type(error).__name__}: {error}") + if not events: + return TraceExecutionFailure(engine, "trace is empty") + return events + + +def _failure_message(result: tuple[FunctionTraceEvent, ...] | TraceExecutionFailure) -> str | None: + if isinstance(result, tuple): + return None + return f"{result.engine}: {result.message}" + + +def execute_trace( + route: RouteSpec, scenario: TraceScenario, mode: TraceMode, surface: Surface +) -> TraceComparisonArtifact: + asynchronous: Final = mode == "async" + mappings: Final = scenario.mappings_for(mode) + scenario_route: Final = RouteSpec( + route=route.route, + python_entrypoints=route.python_entrypoints, + rust_entrypoints=route.rust_entrypoints, + fixture=scenario.fixture, + ) + python_trace: Final = collect_trace(scenario_route, "python", asynchronous=asynchronous) + rust_trace: Final = collect_trace(scenario_route, "rust", asynchronous=asynchronous) + python_error: Final = _failure_message(python_trace) + rust_error: Final = _failure_message(rust_trace) + python_events: Final = python_trace if isinstance(python_trace, tuple) else () + rust_events: Final = rust_trace if isinstance(rust_trace, tuple) else () + try: + python: Final = pipeline_projection("python", python_events, mappings) + rust: Final = pipeline_projection("rust", rust_events, mappings) + except ValueError as error: + return TraceComparisonArtifact.from_traces( + surface=surface, + sdk_function=route.route, + scenario=scenario.name, + mode=mode, + mappings=mappings, + contract=scenario.contract, + python=(), + rust=(), + python_unmatched=0, + python_error=f"harness: {error}", + ) + return TraceComparisonArtifact.from_traces( + surface=surface, + sdk_function=route.route, + scenario=scenario.name, + mode=mode, + mappings=mappings, + contract=scenario.contract, + python=python.steps, + rust=rust.steps, + python_unmatched=python.unmatched, + python_error=python_error, + rust_error=rust_error, + ) diff --git a/tests/rust-python-harness/strategies/trace_parity/sdk/messages/case.py b/tests/rust-python-harness/strategies/trace_parity/sdk/messages/case.py new file mode 100644 index 00000000000..27079c28cd8 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/sdk/messages/case.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from typing import Final + +from .....shared.parity.recorded_http import HttpHeader, RecordedHttpResponse +from .....shared.tracing.steps import Engine, mapping +from ...models import RouteFixture, RouteSpec, TraceScenario, TraceSuite + +COMMON_MAPPINGS: Final = ( + mapping(rust_span="messages", python_frame=r"anthropic_interface/messages/__init__\.py:\d+ a?create$"), + mapping( + span="python_messages_provider_config", + python_frame=r"ProviderConfigManager\.get_provider_anthropic_messages_config$", + ), + mapping(rust_span="messages_provider_config"), + mapping(rust_span="validate_environment", python_frame=r"validate_anthropic_messages_environment$"), + mapping(rust_span="complete_url", python_frame=r"get_complete_url$"), + mapping( + span="python_messages_entry_handler", + python_frame=r"messages/handler\.py:\d+ anthropic_messages_handler$", + ), + mapping( + span="python_messages_handler_wrapper", + python_frame=r"BaseLLMHTTPHandler\.anthropic_messages_handler$", + ), + mapping( + rust_span="execute_messages_provider_call", + python_frame=r"BaseLLMHTTPHandler\.async_anthropic_messages_handler$", + ), + mapping(rust_span="http_request", python_frame=r"AsyncHTTPHandler\.post$|HTTPHandler\.post$"), + mapping(rust_span="transform_response", python_frame=r"(? RouteFixture: + conversation: Final = {"messages": [{"role": "user", "content": "hello"}], "max_tokens": 16} + response: Final = json.dumps( + { + "id": "msg_trace", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-5", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 2, "output_tokens": 3}, + } + ).encode() + return RouteFixture( + kwargs={ + "model": f"{provider}/claude-sonnet-5", + **({"body": {**conversation, "model": "claude-sonnet-5"}} if engine == "rust" else conversation), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, (HttpHeader(name="content-type", value="application/json"),), response + ), + ), + ) + + +def _anthropic_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture(engine, "anthropic") + + +def _azure_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture(engine, "azure_ai") + + +SPEC: Final = RouteSpec("messages", ("create", "acreate"), ("messages", "amessages"), _anthropic_fixture) +TRACE_SUITE: Final = TraceSuite( + route=SPEC, + scenarios=( + TraceScenario(name="anthropic", fixture=_anthropic_fixture, mappings=ANTHROPIC_MAPPINGS, modes=("async",)), + TraceScenario(name="azure-ai", fixture=_azure_fixture, mappings=AZURE_MAPPINGS, modes=("async",)), + ), +) diff --git a/tests/rust-python-harness/strategies/trace_parity/sdk/ocr/case.py b/tests/rust-python-harness/strategies/trace_parity/sdk/ocr/case.py new file mode 100644 index 00000000000..fe214f45339 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/sdk/ocr/case.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +import json +from typing import Final, cast + +from .....shared.parity.recorded_http import HttpHeader, RecordedHttpResponse +from .....shared.tracing.steps import Engine, mapping +from ...models import RouteFixture, RouteSpec, TraceScenario, TraceSuite + +COMMON_MAPPINGS: Final = ( + mapping(rust_span="ocr", python_frame=r"ocr/main\.py:\d+ a?ocr$"), + mapping(rust_span="prepare_ocr_call", python_frame=r"ocr/main\.py:\d+ _prepare_ocr_request$"), + mapping(rust_span="ocr_provider_config", python_frame=r"ProviderConfigManager\.get_provider_ocr_config$"), + mapping(rust_span="supported_ocr_params", python_frame=r"get_supported_ocr_params$"), + mapping(rust_span="map_ocr_params", python_frame=r"(? RouteFixture: + response: Final = json.dumps( + { + "pages": [{"index": 0, "markdown": "hello"}], + "model": "mistral-ocr-latest", + "usage_info": {"pages_processed": 1}, + } + ).encode() + return RouteFixture( + kwargs={ + "model": model, + "document": document or {"type": "document_url", "document_url": "https://example.com/document.pdf"}, + **({"optional_params": {"pages": [0]}} if engine == "rust" else {"pages": [0]}), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, (HttpHeader(name="content-type", value="application/json"),), response + ), + ), + ) + + +def _mistral_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture(engine, "mistral/mistral-ocr-latest") + + +def _azure_fixture(engine: Engine, _base_url: str) -> RouteFixture: + return _fixture( + engine, + "azure_ai/pixtral-12b-2409", + {"type": "image_url", "image_url": "data:image/png;base64,aGVsbG8="}, + ) + + +def _vertex_fixture(engine: Engine, _base_url: str) -> RouteFixture: + fixture: Final = _fixture( + engine, + "vertex_ai/mistral-ocr-maas", + {"type": "image_url", "image_url": "data:image/png;base64,aGVsbG8="}, + ) + vertex: Final = {"vertex_project": "trace-project", "vertex_location": "us-central1"} + optional_params: Final = cast(dict[str, object], fixture.kwargs.get("optional_params", {})) + return RouteFixture( + kwargs={ + **fixture.kwargs, + **({"optional_params": {**optional_params, **vertex}} if engine == "rust" else vertex), + }, + provider_responses=fixture.provider_responses, + ) + + +def _vertex_deepseek_fixture(engine: Engine, _base_url: str) -> RouteFixture: + vertex: Final = {"vertex_project": "trace-project", "vertex_location": "us-central1"} + return RouteFixture( + kwargs={ + "model": "vertex_ai/deepseek-ocr-maas", + "document": {"type": "image_url", "image_url": "data:image/png;base64,aGVsbG8="}, + **({"optional_params": vertex} if engine == "rust" else vertex), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, + (HttpHeader(name="content-type", value="application/json"),), + json.dumps( + { + "choices": [{"message": {"role": "assistant", "content": "hello"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + } + ).encode(), + ), + ), + ) + + +def _azure_document_intelligence_fixture(engine: Engine, base_url: str) -> RouteFixture: + completed: Final = json.dumps( + { + "status": "succeeded", + "analyzeResult": { + "content": "hello", + "pages": [ + { + "pageNumber": 1, + "width": 8.5, + "height": 11, + "unit": "inch", + "lines": [{"content": "hello"}], + } + ], + }, + } + ).encode() + return RouteFixture( + kwargs={ + "model": "azure_ai/doc-intelligence/prebuilt-read", + "document": { + "type": "document_url", + "document_url": "data:application/pdf;base64,aGVsbG8=", + }, + **({"optional_params": {"pages": [0]}} if engine == "rust" else {"pages": [0]}), + }, + provider_responses=( + RecordedHttpResponse.from_bytes( + 202, + ( + HttpHeader(name="content-type", value="application/json"), + HttpHeader(name="operation-location", value=f"{base_url}/operations/trace"), + ), + b"{}", + ), + RecordedHttpResponse.from_bytes( + 200, + (HttpHeader(name="content-type", value="application/json"),), + completed, + ), + ), + ) + + +VERTEX_COMMON_MAPPINGS: Final = ( + *COMMON_MAPPINGS[:7], + mapping( + rust_span="transform_ocr_request", + python_frame=( + r"VertexAIOCRConfig\.(?:async_)?transform_ocr_request$" + r"|MistralOCRConfig\.transform_ocr_request$" + ), + ), + COMMON_MAPPINGS[-1], +) +VERTEX_SYNC_MAPPINGS: Final = ( + *VERTEX_COMMON_MAPPINGS, + mapping(rust_span="execute_ocr_provider_call", python_frame=r"BaseLLMHTTPHandler\.ocr$"), + mapping(span="python_transform_ocr_response_wrapper", python_frame=r"BaseLLMHTTPHandler\._transform_ocr_response$"), + mapping(rust_span="transform_ocr_response", python_frame=r"MistralOCRConfig\.transform_ocr_response$"), +) +VERTEX_ASYNC_MAPPINGS: Final = ( + *VERTEX_COMMON_MAPPINGS, + mapping(span="python_ocr_wrapper", python_frame=r"BaseLLMHTTPHandler\.ocr$"), + mapping(rust_span="execute_ocr_provider_call", python_frame=r"BaseLLMHTTPHandler\.async_ocr$"), + mapping(rust_span="transform_ocr_response", python_frame=r"MistralOCRConfig\.transform_ocr_response$"), +) + +DEEPSEEK_COMMON_MAPPINGS: Final = ( + mapping(rust_span="ocr", python_frame=r"ocr/main\.py:\d+ a?ocr$"), + mapping(rust_span="prepare_ocr_call", python_frame=r"ocr/main\.py:\d+ _prepare_ocr_request$"), + mapping(rust_span="ocr_provider_config", python_frame=r"ProviderConfigManager\.get_provider_ocr_config$"), + mapping(rust_span="supported_ocr_params", python_frame=r"get_supported_ocr_params$"), + mapping(rust_span="map_ocr_params", python_frame=r"(? bytes: + with io.BytesIO() as buffer: + with wave.open(buffer, "wb") as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(16000) + audio.writeframes(b"\x00\x00" * 1600) + return buffer.getvalue() + + +def _fixture(engine: Engine, _base_url: str) -> RouteFixture: + credentials: Final = { + "aws_access_key_id": "test-access", + "aws_secret_access_key": "test-secret", + "aws_region_name": "us-east-1", + } + audio: Final = _audio_bytes() + payload: Final = ( + {"audio": {"data": base64.b64encode(audio).decode(), "format": "wav"}, "optional_params": credentials} + if engine == "rust" + else {"file": ("sample.wav", audio, "audio/wav"), **credentials} + ) + response: Final = json.dumps( + { + "output": {"message": {"role": "assistant", "content": [{"text": "hello"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 2, "outputTokens": 3, "totalTokens": 5}, + } + ).encode() + return RouteFixture( + kwargs={"model": "bedrock/mistral.voxtral-mini-3b-2507", **payload}, + provider_responses=( + RecordedHttpResponse.from_bytes( + 200, (HttpHeader(name="content-type", value="application/json"),), response + ), + ), + ) + + +SPEC: Final = RouteSpec( + "transcription", + ("transcription", "atranscription"), + ("transcription", "atranscription"), + _fixture, +) +TRACE_SUITE: Final = TraceSuite( + route=SPEC, + scenarios=( + TraceScenario( + name="bedrock", + fixture=_fixture, + mappings=MAPPINGS, + sync_mappings=SYNC_MAPPINGS, + async_mappings=ASYNC_MAPPINGS, + ), + ), +) diff --git a/tests/rust-python-harness/strategies/trace_parity/strategy.json b/tests/rust-python-harness/strategies/trace_parity/strategy.json deleted file mode 100644 index 9b67d8570cc..00000000000 --- a/tests/rust-python-harness/strategies/trace_parity/strategy.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "order": 20, - "id": "trace_parity", - "label": "Trace parity", - "description": "Compare mapped operations, call counts, and required execution ordering.", - "functions": { - "ocr": { - "coverage": "planned", - "selectors": [] - }, - "messages": { - "coverage": "planned", - "selectors": [] - }, - "chat_completions": { - "coverage": "planned", - "selectors": [] - }, - "responses": { - "coverage": "planned", - "selectors": [] - }, - "count_tokens": { - "coverage": "planned", - "selectors": [] - }, - "transcription": { - "coverage": "planned", - "selectors": [] - } - }, - "gateway": {} -} diff --git a/tests/rust-python-harness/strategies/trace_parity/test_reporting.py b/tests/rust-python-harness/strategies/trace_parity/test_reporting.py new file mode 100644 index 00000000000..68264064c92 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/test_reporting.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final, Literal + +import pytest + +from ...shared.reporting.models import CaseResult, Coverage, HarnessCase, ResultArtifact, RunStatus +from ...shared.reporting.strategy import ModuleCaseSpec, NotImplementedCaseSpec +from ...shared.tracing.steps import PipelineStep, TraceContract, TraceMapping, mapping +from . import reporting +from .reporting import TRACE_COMPARISON_ARTIFACT, TraceComparisonArtifact, render_trace_results + +MAPPINGS: Final = ( + mapping(rust_span="ocr", python_frame=r"ocr/main\.py:\d+ a?ocr$"), + mapping(rust_span="http_request", python_frame=r"AsyncHTTPHandler\.post$"), +) + + +def _result(comparison: TraceComparisonArtifact) -> CaseResult: + case: Final = HarnessCase( + strategy_id="trace_parity", + strategy_label="Trace parity", + sdk_function=comparison.sdk_function, + spec=ModuleCaseSpec(coverage=Coverage.PARTIAL, module="example"), + surface=comparison.surface, + ) + result: Final = CaseResult(case=case) + nodeid: Final = f"trace:sdk:{comparison.sdk_function}:{comparison.scenario}:{comparison.mode}" + result.collected.add(nodeid) + result.record( + nodeid, + RunStatus.PASSED, + artifacts=(ResultArtifact(TRACE_COMPARISON_ARTIFACT, comparison.model_dump_json()),), + ) + return result + + +def _comparison( + python: tuple[PipelineStep, ...], + rust: tuple[PipelineStep, ...], + *, + mappings: Sequence[TraceMapping] = MAPPINGS, + rust_error: str | None = None, +) -> TraceComparisonArtifact: + return TraceComparisonArtifact.from_traces( + surface="sdk", + sdk_function="ocr", + scenario="default", + mode="sync", + mappings=mappings, + contract=TraceContract(), + python=python, + rust=rust, + python_unmatched=796, + rust_error=rust_error, + ) + + +def _events(*items: tuple[str, int, str | None]) -> tuple[PipelineStep, ...]: + parents: dict[int, int] = {} + steps: list[PipelineStep] = [] + for event_id, (span, depth, raw) in enumerate(items): + parent_id = parents.get(depth - 1) if depth else None + steps.append(PipelineStep(event_id, parent_id, span, raw if raw is not None else span)) + parents[depth] = event_id + return tuple(steps) + + +def test_renderer_shows_matching_python_and_rust_paths() -> None: + rust: Final = _events(("ocr", 0, None), ("http_request", 1, None)) + python: Final = _events( + ("ocr", 0, "ocr/main.py:88 aocr"), + ("http_request", 1, "http_handler.py:673 AsyncHTTPHandler.post"), + ) + + section: Final = render_trace_results((_result(_comparison(python, rust)),))[0] + report: Final = "\n\n".join(section.blocks) + + assert section.title == "SDK trace comparisons" + assert "Case: ocr" in report + assert "PYTHON (2 steps)\n1 aocr (ocr/main.py:88)\n2 AsyncHTTPHandler.post (http_handler.py:673)" in report + assert "RUST (2 steps)\nocr -> 1 aocr\n http_request -> 2 AsyncHTTPHandler.post" in report + assert "Mapping (identifier -> span)" not in report + assert "Trace: MATCH" in report + assert "Same steps, order, and nesting" in report + assert "Unseen mappings:" not in report + + +def test_renderer_reports_mappings_that_matched_nothing() -> None: + events: Final = _events(("ocr", 0, None)) + + section: Final = render_trace_results((_result(_comparison(events, events)),))[0] + report: Final = "\n\n".join(section.blocks) + + assert "Unseen mappings: http_request" in report + assert "Contract: FAIL" in report + + +def test_renderer_numbers_repeated_span_occurrences() -> None: + mappings: Final = (MAPPINGS[0], MAPPINGS[1]) + rust: Final = _events(("ocr", 0, None), ("http_request", 1, None), ("http_request", 1, None)) + python: Final = _events( + ("ocr", 0, "ocr/main.py:88 aocr"), + ("http_request", 1, "http_handler.py:673 AsyncHTTPHandler.post"), + ("http_request", 1, "http_handler.py:673 AsyncHTTPHandler.post"), + ) + + report: Final = "\n\n".join(render_trace_results((_result(_comparison(python, rust, mappings=mappings)),))[0].blocks) + + assert "http_request#2" in report + + +def test_renderer_accepts_declared_engine_specific_steps() -> None: + mappings: Final = ( + *MAPPINGS[:1], + mapping(span="python_prepare", python_frame=r"python_prepare$"), + mapping(rust_span="rust_prepare"), + ) + python: Final = _events(("ocr", 0, None), ("python_prepare", 1, "prep.py:1 python_prepare")) + rust: Final = _events(("ocr", 0, None), ("rust_prepare", 1, None)) + + section: Final = render_trace_results((_result(_comparison(python, rust, mappings=mappings)),))[0] + report: Final = "\n\n".join(section.blocks) + + assert "2 python_prepare (prep.py:1) [python only]" in report + assert "rust_prepare -> [rust only]" in report + assert "Trace: MATCH" in report + assert "Contract: PASS" in report + + +def test_unavailable_check_reports_mode_from_nodeid() -> None: + case: Final = HarnessCase( + strategy_id="trace_parity", + strategy_label="Trace parity", + sdk_function="ocr", + spec=ModuleCaseSpec(coverage=Coverage.PARTIAL, module="example"), + surface="sdk", + ) + result: Final = CaseResult(case=case) + result.collected.add("trace:sdk:ocr:default:sync") + result.record("trace:sdk:ocr:default:sync", RunStatus.ERROR) + + section: Final = render_trace_results((result,))[0] + report: Final = "\n\n".join(section.blocks) + + assert "Case: ocr" in report + assert "Scenario: default / Mode: sync" in report + assert "Trace: NOT AVAILABLE\nTest outcome: error" in report + assert "unknown mode" not in report + + +def test_renderer_keeps_collected_trace_when_one_engine_errors() -> None: + python: Final = _events( + ("ocr", 0, "ocr/main.py:88 aocr"), + ("http_request", 1, "http_handler.py:673 AsyncHTTPHandler.post"), + ) + + section: Final = render_trace_results( + (_result(_comparison(python, (), rust_error="rust: native Rust bridge must include the trace-parity feature")),) + )[0] + report: Final = "\n\n".join(section.blocks) + + assert "PYTHON (2 steps)\n1 aocr (ocr/main.py:88) [python only]" in report + assert "Rust error: rust: native Rust bridge must include the trace-parity feature" in report + assert "hint: rebuild the native bridge with the trace-parity feature" in report + assert "Contract: FAIL" in report + + +def test_renderer_groups_all_modes_under_one_case_header() -> None: + case: Final = HarnessCase( + strategy_id="trace_parity", + strategy_label="Trace parity", + sdk_function="ocr", + spec=ModuleCaseSpec(coverage=Coverage.PARTIAL, module="example"), + surface="sdk", + ) + result: Final = CaseResult(case=case) + events: Final = _events(("ocr", 0, None)) + modes: Final[tuple[Literal["sync", "async"], ...]] = ("sync", "async") + for mode in modes: + nodeid = f"trace:sdk:ocr:default:{mode}" + result.collected.add(nodeid) + comparison = TraceComparisonArtifact.from_traces( + surface="sdk", + sdk_function="ocr", + scenario="default", + mode=mode, + mappings=MAPPINGS, + contract=TraceContract(), + python=events, + rust=events, + python_unmatched=0, + ) + result.record( + nodeid, + RunStatus.PASSED, + artifacts=(ResultArtifact(TRACE_COMPARISON_ARTIFACT, comparison.model_dump_json()),), + ) + + section: Final = render_trace_results((result,))[0] + + assert len(section.blocks) == 1 + report: Final = section.blocks[0] + assert report.count("Case: ocr") == 1 + assert "Scenario: default / Mode: sync" in report + assert "Scenario: default / Mode: async" in report + + +def test_renderer_colors_every_trace_line_in_a_terminal(monkeypatch: pytest.MonkeyPatch) -> None: + rust: Final = _events(("ocr", 0, None), ("http_request", 1, None)) + python: Final = _events( + ("ocr", 0, "ocr/main.py:88 aocr"), + ("http_request", 1, "http_handler.py:673 AsyncHTTPHandler.post"), + ) + monkeypatch.setattr(reporting.sys.stdout, "isatty", lambda: True) + monkeypatch.delenv("NO_COLOR", raising=False) + + section: Final = render_trace_results((_result(_comparison(python, rust)),))[0] + report: Final = "\n\n".join(section.blocks) + + assert "\033[36mPYTHON\033[0m (2 steps)" in report + assert "\033[36m1 aocr (ocr/main.py:88)\033[0m" in report + assert "\033[33mRUST\033[0m (2 steps)" in report + assert "\033[33mocr\033[0m -> \033[36m1 aocr\033[0m" in report + assert "\033[33mhttp_request\033[0m -> \033[36m2 AsyncHTTPHandler.post\033[0m" in report + + +def test_renderer_groups_cases_and_unavailable_entries_by_surface() -> None: + events: Final = _events(("ocr", 0, None)) + gateway_results: Final = tuple( + CaseResult( + case=HarnessCase( + strategy_id="trace_parity", + strategy_label="Trace parity", + sdk_function=sdk_function, + spec=NotImplementedCaseSpec(reason=f"No {sdk_function} case is registered."), + surface="gateway", + ), + status=RunStatus.NOT_IMPLEMENTED, + ) + for sdk_function in ("ocr", "messages") + ) + + sections: Final = render_trace_results((_result(_comparison(events, events)), *gateway_results)) + + assert tuple(section.title for section in sections) == ("SDK trace comparisons", "GATEWAY trace comparisons") + gateway_report: Final = "\n\n".join(sections[1].blocks) + assert gateway_report.count("Not implemented") == 1 + assert "- ocr: No ocr case is registered." in gateway_report + assert "- messages: No messages case is registered." in gateway_report diff --git a/tests/rust-python-harness/strategies/trace_parity/test_runner.py b/tests/rust-python-harness/strategies/trace_parity/test_runner.py new file mode 100644 index 00000000000..0fcc5860ff2 --- /dev/null +++ b/tests/rust-python-harness/strategies/trace_parity/test_runner.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Final + +from ...shared.reporting.models import Coverage, HarnessCase, HarnessRun, RunStatus, SdkFunction, Surface +from ...shared.reporting.strategy import ModuleCaseSpec +from ...shared.tracing.steps import Engine +from .models import GatewayRouteSpec, RouteFixture, RouteSpec, TraceScenario, TraceSuite +from .runner import run_trace_mode, scenario_nodeids, validate_trace_suite + + +def _fixture(_engine: Engine, _base_url: str) -> RouteFixture: + return RouteFixture(kwargs={}, provider_responses=()) + + +def _case(*, surface: Surface = "sdk", function: SdkFunction = "ocr") -> HarnessCase: + return HarnessCase( + strategy_id="trace_parity", + strategy_label="Trace parity", + sdk_function=function, + spec=ModuleCaseSpec(coverage=Coverage.PARTIAL, module="example"), + surface=surface, + ) + + +def test_scenario_filtering_and_occurrence_node_ids() -> None: + suite: Final = TraceSuite( + route=RouteSpec("ocr", ("ocr", "aocr"), ("ocr", "aocr"), _fixture), + scenarios=( + TraceScenario("one", _fixture, (), modes=("sync", "async")), + TraceScenario("two", _fixture, (), modes=("async",)), + ), + ) + case: Final = _case() + + nodes: Final = scenario_nodeids(suite, case, frozenset({"two"})) + + assert tuple(nodeid for _, _, nodeid in nodes) == ("trace:sdk:ocr:two:async",) + + +def test_scenario_validation_rejects_duplicate_and_unsafe_names() -> None: + route: Final = RouteSpec("ocr", ("ocr", "aocr"), ("ocr", "aocr"), _fixture) + duplicate: Final = TraceSuite( + route=route, + scenarios=(TraceScenario("same", _fixture, ()), TraceScenario("same", _fixture, ())), + ) + unsafe: Final = TraceSuite(route=route, scenarios=(TraceScenario("bad:name", _fixture, ()),)) + case: Final = _case() + + assert validate_trace_suite(duplicate, case) is not None + assert validate_trace_suite(unsafe, case) is not None + + +def test_scenario_validation_rejects_invalid_modes_and_route_registration() -> None: + invalid_modes: Final = TraceSuite( + route=RouteSpec("ocr", ("ocr", "aocr"), ("ocr", "aocr"), _fixture), + scenarios=(TraceScenario("invalid", _fixture, (), modes=("sync", "sync")),), + ) + wrong_function: Final = TraceSuite( + route=RouteSpec("messages", ("create", "acreate"), ("messages", "amessages"), _fixture), + scenarios=(TraceScenario("one", _fixture, ()),), + ) + wrong_surface: Final = TraceSuite( + route=GatewayRouteSpec("ocr"), + scenarios=(TraceScenario("one", _fixture, ()),), + ) + case: Final = _case() + + assert "unique sync/async modes" in (validate_trace_suite(invalid_modes, case) or "") + assert "does not match case function" in (validate_trace_suite(wrong_function, case) or "") + assert "must use RouteSpec" in (validate_trace_suite(wrong_surface, case) or "") + + +def test_invalid_route_dispatch_records_harness_error() -> None: + case: Final = _case() + run: Final = HarnessRun.from_cases((case,)) + result: Final = run.results[case.key] + suite: Final = TraceSuite( + route=GatewayRouteSpec("ocr"), + scenarios=(TraceScenario("one", _fixture, (), modes=("sync",)),), + ) + nodeid: Final = "trace:sdk:ocr:one:sync" + + run_trace_mode(run, result, suite, suite.scenarios[0], "sync", "sdk", nodeid, lambda _: None) + + assert result.outcomes[nodeid] is RunStatus.ERROR + assert run.failures == [(nodeid, "gateway route cannot run on the sdk surface")] diff --git a/tests/rust-python-harness/strategies/unit_tests/README.md b/tests/rust-python-harness/strategies/unit_tests/README.md deleted file mode 100644 index bd37072ae4b..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Unit tests - -Run independently with `uv run python -m tests.rust-python-harness.strategies.unit_tests.runner --plain`. Configure a `unit_suite` for each mapped API in `strategy.json` - -The runner combines mapping validation, Python tests in separate verified backend processes, and Cargo tests. It reports missing and ambiguous counterparts. Native Rust tests and existing Python tests stay in their original locations - -See [the suite format](../../README.md#configure-cases) for configuration. No complete API mapping is configured yet diff --git a/tests/rust-python-harness/strategies/unit_tests/__init__.py b/tests/rust-python-harness/strategies/unit_tests/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/rust-python-harness/strategies/unit_tests/ledgers/ocr/ocr_test_ledger.json b/tests/rust-python-harness/strategies/unit_tests/ledgers/ocr/ocr_test_ledger.json deleted file mode 100644 index 41381456993..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/ledgers/ocr/ocr_test_ledger.json +++ /dev/null @@ -1,212 +0,0 @@ -{ - "sdk_function": "ocr", - "python_scope": [ - "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", - "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", - "tests/test_litellm/ocr/test_rust_bridge.py", - "tests/test_litellm/ocr/test_ocr_file_input.py", - "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", - "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", - "tests/test_litellm/ocr/test_ocr_native_format.py", - "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", - "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py" - ], - "rust_scope": [ - "litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs", - "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", - "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", - "litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs", - "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", - "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs" - ], - "entries": [ - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_should_encode_azure_document_intelligence_model_id", "status": "unmapped", "reason": "model-id URL percent-encoding has no Rust test; Rust only tests pages/features query building"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_should_reject_dot_segment_azure_document_intelligence_model_id", "status": "unmapped", "reason": "model-id dot-segment validation has no Rust test"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_transform_ocr_response_preserves_azure_native_fields", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_response_normalizes_pages", "justification": "both assert page markdown, dimension (inch-to-pixel) normalization, and usage_info.pages_processed from the same Azure succeeded response shape"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_async_transform_ocr_response_preserves_azure_native_fields", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_response_normalizes_pages", "justification": "async twin of the sync case above, same underlying transform is exercised on the Rust side"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_transform_ocr_response_tolerates_missing_native_fields", "status": "unmapped", "reason": "tables/keyValuePairs absence tolerance is not asserted by the Rust response test"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_transform_ocr_response_non_succeeded_status_raises", "status": "unmapped", "reason": "no Rust test asserts on a non-succeeded Azure DI status"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_get_supported_ocr_params_includes_features", "status": "unmapped", "reason": "supported-params list content has no Rust equivalent for Azure"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_transform_ocr_response_native_format_carries_raw_operation", "status": "unmapped", "reason": "native req_format raw-operation passthrough is not tested in Rust"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_async_transform_ocr_response_native_format_carries_raw_operation", "status": "unmapped", "reason": "native req_format raw-operation passthrough is not tested in Rust"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_transform_ocr_response_default_format_omits_raw_operation", "status": "unmapped", "reason": "req_format gating of raw-operation output has no Rust test"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_map_ocr_params_passes_through_req_format", "status": "unmapped", "reason": "req_format passthrough in map_ocr_params has no Rust test"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_map_ocr_params_rejects_unknown_req_format_as_bad_request", "status": "unmapped", "reason": "req_format validation error path has no Rust test"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_get_complete_url_omits_req_format_query_param", "status": "unmapped", "reason": "no Rust test asserts req_format is excluded from the built URL"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_map_ocr_params_features", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_normalizes_features", "justification": "both normalize comma-separated feature names and whitespace; Python does this during parameter mapping and Rust during URL construction"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_map_ocr_params_empty_features_list_omitted", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_omits_empty_feature_list", "justification": "both omit empty feature lists from the outgoing request; Python removes the parameter and Rust omits the query field"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_map_ocr_params_invalid_features_raises", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_rejects_invalid_features", "justification": "both reject malformed feature values, including query injection, empty strings, and objects before sending the request"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_get_complete_url_appends_features_query", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_normalizes_features", "justification": "both assert the selected feature names appear in the outgoing features query parameter"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_get_complete_url_combines_pages_and_features", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_combines_pages_and_feature_list", "justification": "both combine zero-based pages [0, 1, 2] with keyValuePairs and languages into pages=1,2,3 and features=keyValuePairs,languages"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_validate_environment_uses_subscription_key", "status": "unmapped", "reason": "Python-side header derivation from litellm_params; Rust's poll test only checks the header is present, not how it was resolved"}, - {"python_file": "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", "python_test": "test_validate_environment_falls_back_to_entra_token", "status": "unmapped", "reason": "Entra bearer-token fallback logic has no Rust test"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestIsAzureDocumentIntelligenceModel::test_matches_doc_intelligence_route", "status": "unmapped", "reason": "model-route string matching is Python-only dispatch logic"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestIsAzureDocumentIntelligenceModel::test_matches_documentintelligence_and_is_case_insensitive", "status": "unmapped", "reason": "model-route string matching is Python-only dispatch logic"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestIsAzureDocumentIntelligenceModel::test_does_not_match_mistral_route", "status": "unmapped", "reason": "model-route string matching is Python-only dispatch logic"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestDocIntelligenceApiBaseResolution::test_generic_azure_ai_base_does_not_hijack_doc_intelligence", "status": "unmapped", "reason": "api_base resolution from the secret manager runs before the Rust bridge is called, no Rust test exists for it"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestDocIntelligenceApiBaseResolution::test_explicit_api_base_is_honoured_for_doc_intelligence", "status": "unmapped", "reason": "api_base precedence resolution is Python-only"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py", "python_test": "TestDocIntelligenceApiBaseResolution::test_generic_azure_ai_base_still_applies_to_mistral_ocr", "status": "unmapped", "reason": "api_base precedence resolution is Python-only"}, - - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_use_litellm_rust_toggles_flag", "status": "unmapped", "reason": "bridge-plumbing: Python-side feature-flag toggle, no Rust equivalent"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_env_var_enables_rust_ocr", "status": "unmapped", "reason": "bridge-plumbing: Python-side env-var flag gating"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_explicit_false_overrides_process_enable", "status": "unmapped", "reason": "Python request-level Rust opt-out overrides the process flag before any Rust implementation runs"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_load_rust_ocr_returns_injected_impl", "status": "unmapped", "reason": "bridge-plumbing: dependency-injection test hook, not provider behavior"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_native_bridge_loader_returns_none_when_extension_absent", "status": "unmapped", "reason": "bridge-plumbing: native-extension import/loader fallback"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_native_bridge_loader_caches_absent_extension", "status": "unmapped", "reason": "bridge-plumbing: loader caching behavior"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_native_bridge_available_reflects_loader", "status": "unmapped", "reason": "bridge-plumbing: loader availability check"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_load_rust_aocr_returns_injected_impl", "status": "unmapped", "reason": "bridge-plumbing: dependency-injection test hook"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_toggle_without_ocr_arg_preserves_injected_impl", "status": "unmapped", "reason": "bridge-plumbing: injected-impl state retention regression"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_explicit_ocr_none_clears_injected_impl", "status": "unmapped", "reason": "bridge-plumbing: injected-impl clearing behavior"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_load_rust_ocr_none_when_extension_absent", "status": "unmapped", "reason": "bridge-plumbing: degrade path when the native extension is missing"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_load_rust_ocr_uses_compiled_extension", "status": "unmapped", "reason": "bridge-plumbing: native module resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_timeout_to_seconds_handles_float_timeout_and_none", "status": "unmapped", "reason": "bridge-plumbing: Python-side timeout normalization helper"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_bridge_wrapper_forwards_prepared_args_and_wraps_response", "status": "unmapped", "reason": "bridge-plumbing: wrapper argument forwarding, asserted against a fake bridge not the real Rust code"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_bridge_wrapper_forwards_prepared_async_args_and_wraps_response", "status": "unmapped", "reason": "bridge-plumbing: async wrapper argument forwarding"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_run_rust_ocr_prepares_request_and_wraps_response", "status": "unmapped", "reason": "bridge-plumbing: request preparation and response wrapping in Python"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_run_rust_ocr_resolves_key_via_secret_manager_when_missing", "status": "unmapped", "reason": "secret-manager: API key resolution happens in Python before the bridge is invoked"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_run_rust_ocr_prefers_explicit_key_over_resolver", "status": "unmapped", "reason": "secret-manager: key precedence resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_run_rust_ocr_uses_provider_api_key_env_var", "status": "unmapped", "reason": "secret-manager: provider-specific env var name resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_prepare_rust_ocr_call_forwards_vertex_routing_metadata", "status": "unmapped", "reason": "secret-manager: vertex routing metadata merge happens in Python"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_prepare_rust_ocr_call_resolves_vertex_routing_metadata_from_secret_manager", "status": "unmapped", "reason": "secret-manager: vertex project/location resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_prepare_rust_ocr_call_resolves_azure_ai_api_base_from_secret_manager", "status": "unmapped", "reason": "secret-manager: azure_ai api_base resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_prepare_rust_ocr_call_resolves_document_intelligence_endpoint", "status": "unmapped", "reason": "secret-manager: doc-intelligence endpoint resolution"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_run_rust_ocr_runs_pre_call_logging", "status": "unmapped", "reason": "bridge-plumbing: Python logging-object pre_call invocation"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_routes_to_rust_when_enabled", "status": "unmapped", "reason": "bridge-plumbing: routing to a fake bridge, not the real Rust transform"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_routes_azure_ai_to_rust_when_enabled", "status": "unmapped", "reason": "bridge-plumbing: provider-prefix stripping before routing"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_rust_path_converts_file_document_before_bridge", "status": "unmapped", "reason": "file-normalization: raw-bytes-to-data-URI conversion happens in Python before the bridge call"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_exception_type_uses_resolved_provider_context", "status": "unmapped", "reason": "bridge-plumbing: Python exception-type mapping on bridge failure"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_aocr_routes_to_async_rust_when_enabled", "status": "unmapped", "reason": "bridge-plumbing: async routing to a fake bridge"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_aocr_exception_type_uses_resolved_provider_context", "status": "unmapped", "reason": "bridge-plumbing: async exception-type mapping on bridge failure"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_does_not_route_to_rust_when_disabled", "status": "unmapped", "reason": "bridge-plumbing: Python control flow for the toggle-disabled branch, no Rust-owned behavior runs"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_falls_back_to_python_when_bridge_unavailable", "status": "unmapped", "reason": "bridge-plumbing: Python-only fallback when the compiled Rust extension is absent, Rust cannot test its own absence"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_forwards_timeout_to_rust", "status": "unmapped", "reason": "bridge-plumbing: asserts the Python call site forwards a timeout kwarg, Rust receives an already-constructed request"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_passes_default_request_timeout_to_rust", "status": "unmapped", "reason": "bridge-plumbing: asserts the Python call site supplies a default timeout kwarg, no Rust equivalent"}, - {"python_file": "tests/test_litellm/ocr/test_rust_bridge.py", "python_test": "test_ocr_provider_configs_expose_api_key_env_vars", "status": "unmapped", "reason": "asserts per-provider get_api_key_env_var() strings; the closest Rust test (ocr_dispatch_supports_migrated_providers) asserts provider dispatch/param resolution instead, not API key env var names"}, - - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_pdf_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection is Python-only preprocessing before the bridge call"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_png_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_jpg_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_jpeg_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_gif_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_webp_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_tiff_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_tif_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_bmp_mime_type", "status": "unmapped", "reason": "file-normalization: MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_be_case_insensitive", "status": "unmapped", "reason": "file-normalization: MIME detection case handling"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_fallback_for_unknown_extension", "status": "unmapped", "reason": "file-normalization: MIME detection fallback"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_pdf_pathlib_path_to_document_url", "status": "unmapped", "reason": "file-normalization: local-path-to-data-URI conversion happens in Python"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_image_pathlib_path_to_image_url", "status": "unmapped", "reason": "file-normalization: local-path-to-data-URI conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_reject_bare_str_path", "status": "unmapped", "reason": "file-normalization: arbitrary-file-read guard on bare str paths"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_pathlib_path", "status": "unmapped", "reason": "file-normalization: local-path-to-data-URI conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_raw_bytes", "status": "unmapped", "reason": "file-normalization: raw-bytes-to-data-URI conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_raw_bytes_with_explicit_mime_type", "status": "unmapped", "reason": "file-normalization: explicit MIME override on raw bytes"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_raw_bytes_with_image_mime_type", "status": "unmapped", "reason": "file-normalization: explicit MIME override on raw bytes"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_file_like_object", "status": "unmapped", "reason": "file-normalization: file-like-object conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_file_like_object_with_name", "status": "unmapped", "reason": "file-normalization: file-like-object name-based MIME detection"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_missing_file_field", "status": "unmapped", "reason": "file-normalization: missing-field validation"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_nonexistent_pathlib_path", "status": "unmapped", "reason": "file-normalization: missing-file validation"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_empty_file", "status": "unmapped", "reason": "file-normalization: empty-file validation"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_unsupported_type", "status": "unmapped", "reason": "file-normalization: unsupported input type validation"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_invalid_mime_type", "status": "unmapped", "reason": "file-normalization: MIME-type injection validation"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_override_mime_type_for_pathlib_path", "status": "unmapped", "reason": "file-normalization: explicit MIME override precedence"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_build_document_url_for_pdf", "status": "unmapped", "reason": "file-normalization: multipart upload conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_build_image_url_for_png", "status": "unmapped", "reason": "file-normalization: multipart upload conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_build_image_url_for_jpeg", "status": "unmapped", "reason": "file-normalization: multipart upload conversion"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_detect_mime_from_filename_when_content_type_is_octet_stream", "status": "unmapped", "reason": "file-normalization: filename-based MIME fallback"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_detect_mime_from_filename_when_content_type_is_none", "status": "unmapped", "reason": "file-normalization: filename-based MIME fallback"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_fallback_to_octet_stream_for_unknown", "status": "unmapped", "reason": "file-normalization: default MIME fallback"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_preserve_base64_content_correctly", "status": "unmapped", "reason": "file-normalization: binary round-trip through base64"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_strip_mime_parameters_from_content_type", "status": "unmapped", "reason": "file-normalization: content-type parameter stripping"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_strip_mime_parameters_with_multiple_params", "status": "unmapped", "reason": "file-normalization: content-type parameter stripping"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_reject_file_type_document_in_json_body", "status": "unmapped", "reason": "proxy-layer JSON-body file-type guard, a different mechanism than Rust's URL-fetch SSRF guard"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_accept_document_url_type_in_json_body", "status": "unmapped", "reason": "proxy-layer JSON-body parsing"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_raise_on_invalid_json_body", "status": "unmapped", "reason": "proxy-layer JSON-body parsing error path"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_ignore_document_form_field_injection", "status": "unmapped", "reason": "proxy-layer multipart form-field injection guard, a different mechanism than Rust's URL-fetch SSRF guard"}, - - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestGetSupportedOcrParams::test_extract_header_in_supported_params", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "extract_header_is_a_supported_ocr_param", "justification": "both assert extract_header appears in the Mistral supported OCR params list"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestGetSupportedOcrParams::test_extract_footer_in_supported_params", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "extract_footer_is_a_supported_ocr_param", "justification": "both assert extract_footer appears in the Mistral supported OCR params list"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestGetSupportedOcrParams::test_existing_params_still_present", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "existing_ocr_params_remain_supported", "justification": "both assert the previously supported params are still present in the supported list"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestMapOcrParams::test_extract_header_passed_through", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "map_ocr_params_forwards_extract_header", "justification": "both assert extract_header alone survives map_ocr_params unchanged"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestMapOcrParams::test_extract_footer_passed_through", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "map_ocr_params_forwards_extract_footer", "justification": "both assert extract_footer alone survives map_ocr_params unchanged"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestMapOcrParams::test_extract_header_and_footer_together", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "map_ocr_params_forwards_extract_header_and_footer", "justification": "both assert header and footer passed together are both forwarded with their given values"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestMapOcrParams::test_unknown_param_is_dropped", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "map_ocr_params_drops_unknown_params", "justification": "both assert an unrecognized param key is dropped while a known one is kept"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestNewSupportedParams::test_new_param_in_supported_list", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "new_ocr_params_are_supported", "justification": "both assert each OCR4 param is in the supported list"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestNewParamsMapOcr::test_new_param_passed_through", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "map_ocr_params_forwards_new_ocr_params", "justification": "both assert each OCR4 param/value pair survives map_ocr_params unchanged"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestTransformOcrRequest::test_param_included_in_request_body", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_request_includes_each_optional_param", "justification": "both assert each optional param value lands in the built request body alongside model/document with no files"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestTransformOcrRequest::test_multiple_new_params_together", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_request_includes_multiple_new_params", "justification": "both assert multiple OCR4 params passed together all land in the same request body"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestTransformOcrResponseOcr4Fields::test_blocks_and_confidence_scores_preserved", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_response_preserves_blocks_and_confidence_scores", "justification": "both assert blocks and confidence_scores survive the OCR response transform on the returned page"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", "python_test": "TestTransformOcrResponseOcr4Fields::test_ocr4_fields_survive_model_dump", "status": "mapped", "rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_response_preserves_ocr4_page_fields", "justification": "both assert tables, hyperlinks, header and footer survive the OCR response transform on the returned page"}, - - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_model_info_ocr4_price", "status": "unmapped", "reason": "cost-calc: pricing/model-info lookup is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr4_cost_scales_with_pages", "status": "unmapped", "reason": "cost-calc: per-page pricing math is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_pricing_entry", "status": "unmapped", "reason": "cost-calc: cost-map JSON entry validation is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_model_info_price", "status": "unmapped", "reason": "cost-calc: pricing/model-info lookup is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_cost_scales_with_pages", "status": "unmapped", "reason": "cost-calc: per-page pricing math is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_bills_ocr_and_annotation_pages_at_their_own_rates", "status": "unmapped", "reason": "cost-calc: mixed-rate billing math is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_bills_annotation_only_response", "status": "unmapped", "reason": "cost-calc: annotation-only billing math is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_ocr3_bills_annotation_pages_when_pages_processed_missing", "status": "unmapped", "reason": "cost-calc: fallback billing math is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_azure_doc_ai_annotation_pages_fall_back_to_ocr_rate", "status": "unmapped", "reason": "cost-calc: fallback billing math is Python-only"}, - {"python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_cost.py", "python_test": "test_azure_ocr4_bills_ocr_and_annotation_pages_at_their_own_rates", "status": "unmapped", "reason": "cost-calc: mixed-rate billing math is Python-only"}, - - {"python_file": "tests/test_litellm/ocr/test_ocr_native_format.py", "python_test": "test_rust_ocr_serves_default_format", "status": "unmapped", "reason": "request-format gating decision is made in Python before the Rust bridge is ever invoked"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_native_format.py", "python_test": "test_rust_ocr_skipped_for_native_format", "status": "unmapped", "reason": "request-format gating decision is made in Python before the Rust bridge is ever invoked"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_native_format.py", "python_test": "test_native_format_rejected_for_provider_without_support_as_bad_request", "status": "unmapped", "reason": "provider-support validation for req_format happens in Python"}, - {"python_file": "tests/test_litellm/ocr/test_ocr_native_format.py", "python_test": "test_unknown_format_rejected_for_provider_without_support_as_bad_request", "status": "unmapped", "reason": "req_format validation error path is Python-only"}, - - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestHandlerDiscovery::test_handler_discovered_for_ocr", "status": "unmapped", "reason": "guardrail-translation handler discovery is a Python proxy-layer concern"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestHandlerDiscovery::test_handler_discovered_for_aocr", "status": "unmapped", "reason": "guardrail-translation handler discovery is a Python proxy-layer concern"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestInputProcessing::test_process_document_url", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestInputProcessing::test_process_image_url", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestInputProcessing::test_process_no_document", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestInputProcessing::test_process_invalid_document", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestInputProcessing::test_input_blocking_guardrail", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_process_single_page", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_process_multiple_pages", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_process_empty_pages", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_process_page_with_empty_markdown", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_process_preserves_page_metadata", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestOutputProcessing::test_output_blocking_guardrail", "status": "unmapped", "reason": "scoped to the Python translation handler, not the Rust gateway's guardrail hook lifecycle"}, - {"python_file": "tests/test_litellm/llms/ocr/guardrail_translation/test_ocr_guardrail_handler.py", "python_test": "TestPIIMaskingScenario::test_pii_masking_in_ocr_pages", "status": "unmapped", "reason": "PII redaction in the translation handler has no Rust equivalent"}, - - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_read_req_format_from_header", "status": "unmapped", "reason": "proxy-layer header parsing has no Rust equivalent"}, - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_prefer_body_req_format_over_header", "status": "unmapped", "reason": "proxy-layer body-vs-header precedence has no Rust equivalent"}, - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_omit_req_format_when_header_absent", "status": "unmapped", "reason": "proxy-layer parsing has no Rust equivalent"}, - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_reject_unknown_req_format", "status": "unmapped", "reason": "proxy-layer validation has no Rust equivalent"}, - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_return_native_payload_with_litellm_response_headers", "status": "unmapped", "reason": "proxy-layer response construction has no Rust equivalent"}, - {"python_file": "tests/test_litellm/proxy/ocr_endpoints/test_endpoints.py", "python_test": "test_should_return_normalized_response_when_no_native_payload", "status": "unmapped", "reason": "proxy-layer response construction has no Rust equivalent"} - ], - "rust_only_tests": [ - {"rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_maps_features", "reason": "Rust retains the feature list while filtering unsupported parameters; Python normalizes the list to a string during mapping"}, - {"rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_url_normalizes_zero_based_pages", "reason": "Python covers ascending page indices with features, but has no dedicated test for deduplicating and sorting page indices"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs", "rust_test": "rust_custom_logger_reads_failure_payload_for_non_ocr_call_type", "reason": "exercises the non-OCR (acompletion) call-type branch of the logger; the OCR branch is covered separately by rust_custom_logger_reads_success_payload_for_ocr"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs", "rust_test": "no_callback_fast_path_dispatches_nothing", "reason": "Rust-only fast-path optimization test for when zero callbacks are registered; Python has no equivalent no-op dispatch path"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs", "rust_test": "with_standard_logging_payload_keeps_top_level_fields_in_sync", "reason": "Rust-internal builder-method invariant, Python has no equivalent internal builder"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs", "rust_test": "blocks_private_and_metadata_ips", "reason": "SSRF IP-blocking helper has no Python unit test; Python relies on the proxy-layer JSON/form guards instead"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs", "rust_test": "convert_document_url_rejects_loopback_fetch", "reason": "URL-fetch SSRF protection is Rust-gateway-only"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/common_utils.rs", "rust_test": "convert_document_url_leaves_data_uri_untouched", "reason": "URL-fetch SSRF protection is Rust-gateway-only"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "truncate_error_body_passes_short_strings_through", "reason": "Rust-gateway error-body truncation helper has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "truncate_error_body_caps_long_payloads", "reason": "Rust-gateway error-body truncation helper has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "truncate_error_body_does_not_split_multibyte_chars", "reason": "Rust-gateway error-body truncation helper has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "ocr_dispatch_supports_migrated_providers", "reason": "Rust-internal provider-config dispatch table has no equivalent Python unit test"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "string_headers_accepts_string_values", "reason": "Rust-gateway header-coercion helper has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "auth_header_detection_is_case_insensitive", "reason": "Rust-gateway header-detection helper has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "ocr_lifecycle_runs_pre_during_and_success_hooks", "reason": "full gateway-level guardrail-hook-plus-HTTP-lifecycle test with no Python equivalent at this integration scope"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "ocr_lifecycle_runs_failure_hook_on_provider_error", "reason": "full gateway-level failure-hook-plus-HTTP-lifecycle test with no Python equivalent at this integration scope"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "ocr_lifecycle_pre_call_block_skips_provider_socket", "reason": "full gateway-level pre-call-block-plus-socket-skip test with no Python equivalent at this integration scope"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "ocr_does_not_duplicate_authorization_header_when_header_is_supplied", "reason": "outgoing HTTP header dedup at the Rust gateway has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "document_intelligence_poll_uses_resolved_subscription_key", "reason": "full Azure DI poll-loop integration test with no Python equivalent at this scope"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/ocr/tests.rs", "rust_test": "string_headers_rejects_non_string_values", "reason": "Rust-gateway header-coercion error path has no Python counterpart"}, - {"rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "azure_ai_reuses_mistral_body_transform", "reason": "Rust-internal delegation-to-Mistral-transform implementation detail, no Python test asserts this delegation"}, - {"rust_file": "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "rust_test": "document_intelligence_request_uses_base64_source_for_data_uri", "reason": "no Python test asserts on the base64Source request body shape"}, - {"rust_file": "litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs", "rust_test": "vertex_mistral_url_uses_project_location_and_model", "reason": "vertex OCR support has no Python unit test coverage yet"}, - {"rust_file": "litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs", "rust_test": "vertex_mistral_reuses_mistral_body_transform", "reason": "vertex OCR support has no Python unit test coverage yet"}, - {"rust_file": "litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs", "rust_test": "vertex_deepseek_request_uses_ocr_endpoint_shape", "reason": "vertex OCR support has no Python unit test coverage yet"}, - {"rust_file": "litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs", "rust_test": "vertex_deepseek_response_wraps_markdown_content", "reason": "vertex OCR support has no Python unit test coverage yet"}, - {"rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_request_rejects_non_object_document", "reason": "non-object document rejection has no dedicated Python unit test"}, - {"rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "transform_ocr_response_normalizes_mistral_json", "reason": "Python's response tests target OCR4-specific fields only, none asserts the same base normalization this Rust test checks"}, - {"rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "complete_url_defaults_and_dedupes_v1", "reason": "URL-building/defaulting for Mistral has no Python unit test"}, - {"rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "resolve_api_key_prefers_param_then_env", "reason": "API key resolution precedence at the Rust provider-config layer has no Python unit test"}, - {"rust_file": "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", "rust_test": "resolve_api_key_errors_when_absent", "reason": "API key resolution error path at the Rust provider-config layer has no Python unit test"}, - {"rust_file": "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs", "rust_test": "rust_custom_logger_reads_success_payload_for_ocr", "reason": "Rust-internal custom-logger dispatch for OCR payloads has no Python unit test at this layer"} - ] -} diff --git a/tests/rust-python-harness/strategies/unit_tests/mapping_validator.py b/tests/rust-python-harness/strategies/unit_tests/mapping_validator.py deleted file mode 100644 index d805311e488..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/mapping_validator.py +++ /dev/null @@ -1,173 +0,0 @@ -from __future__ import annotations - -from collections import Counter -from collections.abc import Sequence -from dataclasses import dataclass -from pathlib import Path -from typing import Final - -from pydantic import BaseModel, ConfigDict - -from ...shared.parity.ledger import TestLedger, load_ledger -from .python_runner import enumerate_python_tests -from .rust_runner import enumerate_rust_tests - - -class TestMapping(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - python: str - rust: str - - -@dataclass(frozen=True, slots=True) -class MappingReport: - pairs: tuple[TestMapping, ...] - problems: tuple[str, ...] - - -def _name(node: str) -> str: - return node.rsplit("::", 1)[-1].split("[", 1)[0] - - -def validate_mapping( - python_tests: Sequence[str], - rust_tests: Sequence[str], - annotations: Sequence[TestMapping] = (), -) -> MappingReport: - explicit_problems: Final = ( - *(f"missing Python counterpart: {pair.python}" for pair in annotations if pair.python not in python_tests), - *(f"missing Rust counterpart: {pair.rust}" for pair in annotations if pair.rust not in rust_tests), - *( - f"ambiguous Python annotation: {name}" - for name, count in Counter(p.python for p in annotations).items() - if count > 1 - ), - *( - f"ambiguous Rust annotation: {name}" - for name, count in Counter(p.rust for p in annotations).items() - if count > 1 - ), - ) - explicit_python: Final = {pair.python for pair in annotations} - candidates: Final = { - python: tuple(rust for rust in rust_tests if _name(python) == _name(rust)) - for python in python_tests - if python not in explicit_python - } - pairs: Final = ( - *annotations, - *(TestMapping(python=python, rust=matches[0]) for python, matches in candidates.items() if len(matches) == 1), - ) - problems: Final = ( - *explicit_problems, - *(f"missing Rust counterpart: {python}" for python, matches in candidates.items() if not matches), - *( - f"ambiguous Rust counterparts: {python}: {matches}" - for python, matches in candidates.items() - if len(matches) > 1 - ), - *( - f"ambiguous Python counterparts: {rust}" - for rust, count in Counter(pair.rust for pair in pairs).items() - if count > 1 - ), - *(f"missing Python counterpart: {rust}" for rust in rust_tests if rust not in {pair.rust for pair in pairs}), - *(("no Python tests collected",) if not python_tests else ()), - *(("no Rust tests collected",) if not rust_tests else ()), - ) - return MappingReport(pairs, problems) - - -REPO_ROOT = Path(__file__).resolve().parents[4] -LEDGER_ROOT = Path(__file__).parent / "ledgers" - - -def ledger_path_for(sdk_function: str) -> Path: - return LEDGER_ROOT / sdk_function / f"{sdk_function}_test_ledger.json" - - -@dataclass(frozen=True, slots=True) -class AuditReport: - missing_python_tests: tuple[str, ...] - stale_python_tests: tuple[str, ...] - missing_rust_tests: tuple[str, ...] - stale_rust_tests: tuple[str, ...] - - @property - def is_clean(self) -> bool: - return not ( - self.missing_python_tests - or self.stale_python_tests - or self.missing_rust_tests - or self.stale_rust_tests - ) - - -def _ledger_python_tests_by_file(ledger: TestLedger) -> dict[str, set[str]]: - grouping: dict[str, set[str]] = {path: set() for path in ledger.python_scope} - for entry in ledger.entries: - grouping.setdefault(entry.python_file, set()).add(entry.python_test) - return grouping - - -def _ledger_rust_tests_by_file(ledger: TestLedger) -> dict[str, set[str]]: - grouping: dict[str, set[str]] = {path: set() for path in ledger.rust_scope} - for entry in ledger.entries: - if entry.status == "mapped": - grouping.setdefault(entry.rust_file, set()).add(entry.rust_test) - for rust_only in ledger.rust_only_tests: - grouping.setdefault(rust_only.rust_file, set()).add(rust_only.rust_test) - return grouping - - -def audit_ledger(ledger: TestLedger, repo_root: Path = REPO_ROOT) -> AuditReport: - missing_python: list[str] = [] - stale_python: list[str] = [] - for python_file, ledger_tests in _ledger_python_tests_by_file(ledger).items(): - actual_tests = enumerate_python_tests(repo_root, python_file) - for missing in sorted(ledger_tests - actual_tests): - missing_python.append(f"{python_file}:{missing}") - for stale in sorted(actual_tests - ledger_tests): - stale_python.append(f"{python_file}:{stale}") - - missing_rust: list[str] = [] - stale_rust: list[str] = [] - for rust_file, ledger_tests in _ledger_rust_tests_by_file(ledger).items(): - actual_tests = enumerate_rust_tests(repo_root, rust_file) - for missing in sorted(ledger_tests - actual_tests): - missing_rust.append(f"{rust_file}:{missing}") - for stale in sorted(actual_tests - ledger_tests): - stale_rust.append(f"{rust_file}:{stale}") - - return AuditReport( - missing_python_tests=tuple(missing_python), - stale_python_tests=tuple(stale_python), - missing_rust_tests=tuple(missing_rust), - stale_rust_tests=tuple(stale_rust), - ) - - -@dataclass(frozen=True, slots=True) -class FunctionReport: - sdk_function: str - ledger: TestLedger | None - audit: AuditReport | None - - @property - def has_ledger(self) -> bool: - return self.ledger is not None - - @property - def is_clean(self) -> bool: - return self.audit is None or self.audit.is_clean - - -def build_function_report(sdk_function: str, repo_root: Path = REPO_ROOT) -> FunctionReport: - path = ledger_path_for(sdk_function) - if not path.exists(): - return FunctionReport(sdk_function=sdk_function, ledger=None, audit=None) - ledger = load_ledger(path) - return FunctionReport( - sdk_function=sdk_function, ledger=ledger, audit=audit_ledger(ledger, repo_root) - ) diff --git a/tests/rust-python-harness/strategies/unit_tests/runner.py b/tests/rust-python-harness/strategies/unit_tests/runner.py deleted file mode 100644 index d10fa364d1c..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/runner.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import sys -from collections.abc import Sequence -from pathlib import Path -from time import monotonic -from typing import Final - -from pydantic import BaseModel, ConfigDict - -from ...shared.reporting.models import HarnessCase, HarnessRun, RunStatus -from ...shared.reporting.pytest_runner import UpdateCallback -from .mapping_validator import TestMapping, validate_mapping -from .python_runner import BackendSpec, compare_python_runs, run_python_tests -from .rust_runner import run_rust_tests - - -class UnitSuite(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - python_selectors: tuple[str, ...] - cargo_manifest: str - cargo_package: str - cargo_filter: str - backend: BackendSpec - mappings: tuple[TestMapping, ...] = () - - -def run_suite(suite: UnitSuite, repo_root: Path, pytest_args: Sequence[str] = ()) -> tuple[str, ...]: - if not suite.python_selectors or not suite.cargo_filter: - return ("unit suites must select Python tests and a focused Cargo filter",) - python: Final = run_python_tests(suite.python_selectors, repo_root, "python", suite.backend, pytest_args) - rust_python: Final = run_python_tests(suite.python_selectors, repo_root, "rust", suite.backend, pytest_args) - inventory: Final = run_rust_tests( - repo_root / suite.cargo_manifest, suite.cargo_package, suite.cargo_filter, collect_only=True - ) - mapping: Final = validate_mapping(python.tests, inventory.tests, suite.mappings) - rust: Final = run_rust_tests(repo_root / suite.cargo_manifest, suite.cargo_package, suite.cargo_filter) - return ( - *compare_python_runs(python, rust_python), - *mapping.problems, - *(("native Rust tests did not all pass",) if set(inventory.tests) != set(rust.tests) else ()), - *((inventory.output,) if inventory.exit_code else ()), - *((rust.output,) if rust.exit_code else ()), - ) - - -def run( - cases: Sequence[HarnessCase], - repo_root: Path, - on_update: UpdateCallback, - pytest_args: Sequence[str] = (), -) -> tuple[int, HarnessRun]: - report: Final = HarnessRun.from_cases(cases) - for case in cases: - result: Final = report.results[case.key] - if case.unit_suite is None: - result.finalize() - continue - nodeid: Final = f"unit-suite:{case.unit_suite}" - result.collected.add(nodeid) - result.status = RunStatus.RUNNING - on_update(report) - try: - suite: Final = UnitSuite.model_validate_json((repo_root / case.unit_suite).read_text()) - problems: Final = run_suite(suite, repo_root, pytest_args) - except (OSError, ValueError) as error: - result.record(nodeid, RunStatus.ERROR) - report.failures.append((nodeid, str(error))) - continue - result.record(nodeid, RunStatus.FAILED if problems else RunStatus.PASSED) - report.failures.extend((nodeid, problem) for problem in problems) - on_update(report) - report.finished_at = monotonic() - on_update(report) - return int( - any( - result.status in {RunStatus.ERROR, RunStatus.FAILED, RunStatus.MISSING} - for result in report.results.values() - ) - ), report - - -def main(argv: Sequence[str] | None = None) -> int: - from ...cli import main as harness_main - - return harness_main(argv, strategy_id="unit_tests") - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/tests/rust-python-harness/strategies/unit_tests/rust_runner.py b/tests/rust-python-harness/strategies/unit_tests/rust_runner.py deleted file mode 100644 index b24034199b5..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/rust_runner.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -import re -import subprocess -from dataclasses import dataclass -from pathlib import Path -from typing import Final - - -@dataclass(frozen=True, slots=True) -class RustReport: - tests: tuple[str, ...] - exit_code: int - output: str - - -def run_rust_tests(manifest: Path, package: str, test_filter: str, *, collect_only: bool = False) -> RustReport: - command: Final = ( - "cargo", - "test", - "--manifest-path", - str(manifest), - "--package", - package, - "--lib", - test_filter, - "--", - *(("--list",) if collect_only else ("--format=pretty",)), - ) - try: - result: Final = subprocess.run(command, capture_output=True, text=True, check=False, timeout=600) - except (OSError, subprocess.TimeoutExpired) as error: - return RustReport((), 1, str(error)) - tests: Final = ( - tuple(line.removesuffix(": test") for line in result.stdout.splitlines() if line.endswith(": test")) - if collect_only - else tuple( - line.removeprefix("test ").removesuffix(" ... ok") - for line in result.stdout.splitlines() - if line.startswith("test ") and line.endswith(" ... ok") - ) - ) - return RustReport(tests, result.returncode, result.stdout + result.stderr) - - -_RUST_TEST_PATTERN = re.compile( - r"#\[(?:test|tokio::test)\][^\n]*\n(?:[^\n]*\n)*?\s*(?:async\s+)?fn\s+(\w+)\s*\(" -) - - -def enumerate_rust_tests(repo_root: Path, relative_path: str) -> frozenset[str]: - source = (repo_root / relative_path).read_text(encoding="utf-8") - return frozenset(match.group(1) for match in _RUST_TEST_PATTERN.finditer(source)) diff --git a/tests/rust-python-harness/strategies/unit_tests/strategy.json b/tests/rust-python-harness/strategies/unit_tests/strategy.json deleted file mode 100644 index 7ae5d9c22c6..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/strategy.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "order": 30, - "id": "unit_tests", - "label": "Unit tests", - "description": "Validate Python/Rust test mappings and compare isolated Python runs alongside native Cargo tests.", - "functions": { - "ocr": { - "coverage": "planned", - "selectors": [] - }, - "messages": { - "coverage": "planned", - "selectors": [] - }, - "chat_completions": { - "coverage": "planned", - "selectors": [] - }, - "responses": { - "coverage": "planned", - "selectors": [] - }, - "count_tokens": { - "coverage": "planned", - "selectors": [] - }, - "transcription": { - "coverage": "planned", - "selectors": [] - } - } -} diff --git a/tests/rust-python-harness/strategies/unit_tests/test_mapping_validator.py b/tests/rust-python-harness/strategies/unit_tests/test_mapping_validator.py deleted file mode 100644 index 25c63faf3a7..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/test_mapping_validator.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -import pytest - -from .mapping_validator import TestMapping as Mapping, validate_mapping - - -def test_matches_names_and_explicit_annotations() -> None: - report = validate_mapping( - ("tests/test_api.py::test_decode", "tests/test_api.py::test_error"), - ("api::test_decode", "api::preserves_error"), - (Mapping(python="tests/test_api.py::test_error", rust="api::preserves_error"),), - ) - assert report.problems == () - assert {(pair.python, pair.rust) for pair in report.pairs} == { - ("tests/test_api.py::test_decode", "api::test_decode"), - ("tests/test_api.py::test_error", "api::preserves_error"), - } - - -@pytest.mark.parametrize( - ("python", "rust", "message"), - ( - (("test_decode",), (), "missing Rust counterpart"), - ((), ("test_decode",), "missing Python counterpart"), - (("test_decode",), ("one::test_decode", "two::test_decode"), "ambiguous Rust counterparts"), - (("one::test_decode", "two::test_decode"), ("test_decode",), "ambiguous Python counterparts"), - ), -) -def test_reports_missing_and_ambiguous_counterparts( - python: tuple[str, ...], rust: tuple[str, ...], message: str -) -> None: - assert any(message in problem for problem in validate_mapping(python, rust).problems) - - -def test_rejects_stale_annotations_even_when_names_match() -> None: - report = validate_mapping(("test_decode",), ("test_decode",), (Mapping(python="test_decode", rust="removed"),)) - assert "missing Rust counterpart: removed" in report.problems diff --git a/tests/rust-python-harness/strategies/unit_tests/test_runner.py b/tests/rust-python-harness/strategies/unit_tests/test_runner.py deleted file mode 100644 index c652d6e12b1..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/test_runner.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -import json -import shutil -from pathlib import Path -from typing import Final - -import pytest - -from ...shared.reporting.models import Coverage, HarnessCase, RunStatus -from .runner import run - - -@pytest.mark.skipif(shutil.which("cargo") is None, reason="Cargo is required for the combined unit strategy") -def test_combines_mapping_backend_comparison_and_cargo_results(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("PYTHONPATH", str(Path(__file__).resolve().parents[4])) - monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") - (tmp_path / "pytest.ini").write_text("[pytest]\n") - (tmp_path / "backend_probe.py").write_text( - "import os\ndef selected():\n return 'rust' if os.environ['TEST_USE_RUST'] == '1' else 'python'\n" - ) - (tmp_path / "test_api.py").write_text("def test_decode():\n assert int('42') == 42\n") - (tmp_path / "Cargo.toml").write_text( - '[package]\nname = "combined-check"\nversion = "0.1.0"\nedition = "2021"\n[workspace]\n' - ) - (tmp_path / "src").mkdir() - (tmp_path / "src/lib.rs").write_text('#[test] fn test_decode() { assert_eq!("42".parse::().unwrap(), 42); }\n') - suite: Final = { - "python_selectors": ("test_api.py",), - "cargo_manifest": "Cargo.toml", - "cargo_package": "combined-check", - "cargo_filter": "test_decode", - "backend": {"environment_variable": "TEST_USE_RUST", "probe": "backend_probe:selected"}, - } - (tmp_path / "suite.json").write_text(json.dumps(suite)) - case: Final = HarnessCase( - strategy_id="unit_tests", - strategy_label="Unit tests", - sdk_function="ocr", - coverage=Coverage.COMPLETE, - selectors=(), - unit_suite="suite.json", - ) - code, report = run((case,), tmp_path, lambda _: None) - assert code == 0, report.failures - assert report.results[case.key].status is RunStatus.PASSED - (tmp_path / "suite.json").write_text( - json.dumps({**suite, "mappings": [{"python": "test_api.py::test_decode", "rust": "removed"}]}) - ) - failed_code, failed_report = run((case,), tmp_path, lambda _: None) - assert failed_code == 1 - assert failed_report.results[case.key].status is RunStatus.FAILED - assert any("missing Rust counterpart: removed" in detail for _, detail in failed_report.failures) - - (tmp_path / "suite.json").write_text(json.dumps(suite)) - (tmp_path / "src/lib.rs").write_text("#[test] #[ignore] fn test_decode() {}\n") - skipped_code, skipped_report = run((case,), tmp_path, lambda _: None) - assert skipped_code == 1 - assert any("native Rust tests did not all pass" in detail for _, detail in skipped_report.failures) diff --git a/tests/rust-python-harness/strategies/unit_tests/test_rust_runner.py b/tests/rust-python-harness/strategies/unit_tests/test_rust_runner.py deleted file mode 100644 index aeeb7f602f5..00000000000 --- a/tests/rust-python-harness/strategies/unit_tests/test_rust_runner.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -import shutil -from pathlib import Path -from typing import Final - -import pytest - -from .rust_runner import run_rust_tests - - -@pytest.mark.skipif(shutil.which("cargo") is None, reason="Cargo is required for native runner integration") -def test_collects_and_runs_native_tests_and_propagates_failure(tmp_path: Path) -> None: - manifest: Final = tmp_path / "Cargo.toml" - manifest.write_text('[package]\nname = "harness-runner-check"\nversion = "0.1.0"\nedition = "2021"\n[workspace]\n') - (tmp_path / "src").mkdir() - source: Final = tmp_path / "src/lib.rs" - source.write_text("#[test] fn test_parity() { assert_eq!(2 + 2, 4); }\n") - inventory: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity", collect_only=True) - assert inventory.exit_code == 0, inventory.output - assert inventory.tests == ("test_parity",) - passing: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity") - assert passing.exit_code == 0, passing.output - source.write_text("#[test] fn test_parity() { assert_eq!(2 + 2, 5); }\n") - failed: Final = run_rust_tests(manifest, "harness-runner-check", "test_parity") - assert failed.exit_code != 0 - assert "test_parity" in failed.output diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/AGENTS.md b/tests/rust-python-harness/strategies/unit_tests_mapping/AGENTS.md new file mode 100644 index 00000000000..379d1443f33 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/AGENTS.md @@ -0,0 +1,13 @@ +# What this is + +Validates that unit tests covering traced Python behavior have semantic counterparts among colocated Rust unit tests + +# How it works + +Trace parity runs representative public API scenarios and records the Python and Rust functions reached, including their source files and lines. The OCR contract selects the behavior-level trace spans that require parity and excludes shared infrastructure such as generic HTTP transport + +For Python, those traced functions define the denominator. Static references and explicit includes create a safe pytest discovery universe, then a pytest profiler keeps only tests that actually execute at least one selected function. Static matches do not count by themselves. Parametrized pytest cases are collapsed to one logical test function in the mapping report. Explicit includes and exclusions cover dynamic callers or intentional harness behavior that static discovery cannot express reliably + +For Rust, each traced function identifies its source file and module. If that source file has a colocated `#[cfg(test)] mod tests`, the harness inventories that module for the configured Rust target. Rust test names are therefore derived from traced implementation files, not from a hand-maintained list of OCR test modules + +The Python-to-Rust mappings remain explicit because equivalent behavior often has different test boundaries and names in each SDK. Host-only exclusions require a reason. The report validates both against the live inventories, then shows mapped, excluded, and unmapped Python tests plus Rust-only tests diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/__init__.py b/tests/rust-python-harness/strategies/unit_tests_mapping/__init__.py new file mode 100644 index 00000000000..4d857c01ed0 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/__init__.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from functools import partial +from pathlib import Path +from typing import Final + +from ...shared.reporting.models import SDK_FUNCTIONS, Coverage +from ...shared.reporting.strategy import ( + CaseDefinition, + NotImplementedCaseSpec, + RunnerArgumentDefinition, + StrategyDefinition, + SuiteCaseSpec, +) +from ...shared.unit_runners.suite_runner import run_suites +from .mappings import UNIT_TEST_CONTRACTS +from .reporting import render_mapping_results +from .runner import run_suite + + +CASES: Final[tuple[CaseDefinition, ...]] = ( + *( + CaseDefinition( + sdk_function, + SuiteCaseSpec(coverage=Coverage.COMPLETE, suite=sdk_function) + if sdk_function in UNIT_TEST_CONTRACTS + else NotImplementedCaseSpec(reason=f"No {sdk_function} unit-test mapping is registered."), + ) + for sdk_function in SDK_FUNCTIONS + ), +) + +STRATEGY: Final = StrategyDefinition( + id="unit_tests_mapping", + order=30, + label="Unit test mapping", + description="Validate Python/Rust unit-test mappings against collected test inventories.", + directory=Path(__file__).parent, + runnable_spec=SuiteCaseSpec, + cases=CASES, + run=partial(run_suites, suites=UNIT_TEST_CONTRACTS, execute=run_suite), + render=render_mapping_results, + runner_argument=RunnerArgumentDefinition( + option="--detail", + metavar="MODE", + help="show individual test names; any value enables full detail", + ), +) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/cases/__init__.py b/tests/rust-python-harness/strategies/unit_tests_mapping/cases/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/cases/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/cases/ocr.py b/tests/rust-python-harness/strategies/unit_tests_mapping/cases/ocr.py new file mode 100644 index 00000000000..dc3167017d9 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/cases/ocr.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +from typing import Final + +from ....shared.unit_runners.rust_runner import RustTarget, RustTestIdentity +from ..contracts import ( + MappingExclusionSpec, + MappingSpec, + PythonFunctionDiscoverySpec, + RustTestFamily, + RustUnitSpec, + TestMapping, + UnitParityExclusionSpec, + UnitParitySpec, + UnitTestContract, +) + +_CORE_TARGET: Final = RustTarget(package="litellm-core", name="litellm_core", kind="lib") +_GATEWAY_TARGET: Final = RustTarget( + package="litellm-ai-gateway", + name="litellm_ai_gateway", + kind="lib", +) +_AZURE_OCR_TESTS: Final = "providers::azure_ai::ocr::transformation::tests" +_MISTRAL_OCR_TESTS: Final = "providers::mistral::ocr::transformation::tests" +_VERTEX_OCR_TESTS: Final = "providers::vertex_ai::ocr::transformation::tests" +_REDUCTO_OCR_TESTS: Final = "providers::reducto::ocr::tests" +_GATEWAY_OCR_TESTS: Final = "ocr::tests" +_GATEWAY_PREPARE_OCR_TESTS: Final = "ocr::prepare::tests" + + +def _rust_test(target: RustTarget, module: str, test: str) -> RustTestIdentity: + return RustTestIdentity(target=target, name=f"{module}::{test}") + + +def _rust_family(target: RustTarget, module: str, test: str) -> RustTestFamily: + return RustTestFamily(target=target, name=f"{module}::{test}") + + +def _test_mappings(target: RustTarget, module: str, pairs: tuple[tuple[str, str], ...]) -> tuple[TestMapping, ...]: + return tuple(TestMapping(python=python, rust=_rust_test(target, module, test)) for python, test in pairs) + + +_AZURE_TRANSFORM_FILE: Final = "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py" +_AZURE_PAGES_FILE: Final = "tests/ocr_tests/test_ocr_azure_document_intelligence.py" +_AZURE_BASE_FILE: Final = "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py" +_RUST_BRIDGE_FILE: Final = "tests/test_litellm/ocr/test_rust_bridge.py" + +_AZURE_PORT_MAPPINGS: Final = _test_mappings( + _CORE_TARGET, + _AZURE_OCR_TESTS, + ( + ( + f"{_AZURE_TRANSFORM_FILE}::test_should_encode_azure_document_intelligence_model_id", + "azure_document_intelligence_model_id_is_encoded", + ), + ( + f"{_AZURE_TRANSFORM_FILE}::test_should_reject_dot_segment_azure_document_intelligence_model_id", + "azure_document_intelligence_dot_segment_model_id_is_rejected", + ), + ( + f"{_AZURE_TRANSFORM_FILE}::test_async_transform_ocr_response_preserves_azure_native_fields", + "document_intelligence_async_response_preserves_normalized_fields", + ), + ( + f"{_AZURE_TRANSFORM_FILE}::test_transform_ocr_response_tolerates_missing_native_fields", + "document_intelligence_response_tolerates_missing_native_fields", + ), + ( + f"{_AZURE_TRANSFORM_FILE}::test_transform_ocr_response_non_succeeded_status_raises", + "document_intelligence_non_succeeded_status_is_rejected", + ), + ( + f"{_AZURE_TRANSFORM_FILE}::test_get_supported_ocr_params_includes_features", + "document_intelligence_supported_params_include_features", + ), + ( + f"{_AZURE_TRANSFORM_FILE}::test_transform_ocr_response_native_format_carries_raw_operation", + "document_intelligence_native_format_carries_raw_operation", + ), + ( + f"{_AZURE_TRANSFORM_FILE}::test_async_transform_ocr_response_native_format_carries_raw_operation", + "document_intelligence_async_native_format_carries_raw_operation", + ), + ( + f"{_AZURE_TRANSFORM_FILE}::test_map_ocr_params_rejects_unknown_req_format_as_bad_request", + "document_intelligence_rejects_unknown_req_format", + ), + ( + f"{_AZURE_TRANSFORM_FILE}::test_get_complete_url_omits_req_format_query_param", + "document_intelligence_url_omits_req_format", + ), + ( + f"{_AZURE_TRANSFORM_FILE}::test_validate_environment_uses_subscription_key", + "document_intelligence_validate_environment_uses_subscription_key", + ), + ( + f"{_AZURE_TRANSFORM_FILE}::test_validate_environment_falls_back_to_entra_token", + "document_intelligence_validate_environment_falls_back_to_entra_token", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_get_supported_ocr_params_includes_pages_and_features", + "document_intelligence_supported_params_include_pages_features_and_req_format", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_mistral_zero_based_int_list", + "document_intelligence_maps_zero_based_page_list", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_dedupes_and_sorts", + "document_intelligence_page_mapping_dedupes_and_sorts", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_empty_list_omits_pages", + "document_intelligence_page_mapping_omits_empty_list", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_azure_native_string_range", + "document_intelligence_page_mapping_accepts_native_range", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_azure_native_string_with_spaces_stripped", + "document_intelligence_page_mapping_strips_spaces", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_list_of_string_tokens", + "document_intelligence_page_mapping_accepts_string_tokens", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_invalid_string_raises", + "document_intelligence_page_mapping_rejects_invalid_string", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_negative_index_raises", + "document_intelligence_page_mapping_rejects_negative_index", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_bool_list_raises", + "document_intelligence_page_mapping_rejects_bool_list", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_unsupported_type_raises", + "document_intelligence_page_mapping_rejects_unsupported_type", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_get_complete_url_appends_pages_query", + "document_intelligence_url_appends_pages_query", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_get_complete_url_no_pages_when_optional_params_empty", + "document_intelligence_url_has_no_pages_when_params_are_empty", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_transform_ocr_request_does_not_put_pages_in_body", + "document_intelligence_request_keeps_pages_out_of_body", + ), + ( + f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_end_to_end_mistral_shape_to_azure_query", + "document_intelligence_mistral_pages_flow_to_query_only", + ), + ( + "tests/test_litellm/llms/azure_ai/test_azure_ai_entra_auth.py::test_ocr_authenticates_with_entra_token", + "azure_ai_ocr_authenticates_with_entra_token", + ), + ( + f"{_AZURE_BASE_FILE}::TestDocIntelligenceApiBaseResolution::test_generic_azure_ai_base_does_not_hijack_doc_intelligence", + "document_intelligence_endpoint_ignores_generic_azure_ai_base", + ), + ( + f"{_AZURE_BASE_FILE}::TestDocIntelligenceApiBaseResolution::test_explicit_api_base_is_honoured_for_doc_intelligence", + "document_intelligence_endpoint_honors_explicit_api_base", + ), + ( + f"{_AZURE_BASE_FILE}::TestDocIntelligenceApiBaseResolution::test_generic_azure_ai_base_still_applies_to_mistral_ocr", + "azure_ai_mistral_ocr_uses_generic_api_base", + ), + ), +) + +_REDUCTO_PORT_MAPPINGS: Final = _test_mappings( + _CORE_TARGET, + _REDUCTO_OCR_TESTS, + ( + ( + "tests/test_litellm/llms/reducto/test_parse_v3.py::test_parse_v3_reducto_id_passthrough_skips_upload", + "test_parse_v3_reducto_id_passthrough_skips_upload", + ), + ( + "tests/test_litellm/llms/reducto/test_parse_legacy.py::test_parse_legacy_wraps_enhance_under_options", + "test_parse_legacy_wraps_enhance_under_options", + ), + ( + "tests/test_litellm/llms/reducto/test_upload.py::test_parse_v3_image_data_uri_upload_uses_image_mime", + "test_parse_v3_image_data_uri_upload_uses_image_mime", + ), + ( + "tests/test_litellm/llms/reducto/test_upload.py::test_parse_v3_uses_programmatic_api_key_over_env", + "test_parse_v3_uses_programmatic_api_key_over_env", + ), + ), +) + +_REDUCTO_GATEWAY_MAPPING: Final = TestMapping( + python="tests/test_litellm/llms/reducto/test_parse_v3.py::test_parse_v3_file_upload_and_response_mapping", + rust=_rust_test(_GATEWAY_TARGET, _GATEWAY_OCR_TESTS, "reducto_file_upload_then_parse_maps_response"), +) + +_GATEWAY_PORT_MAPPINGS: Final = _test_mappings( + _GATEWAY_TARGET, + _GATEWAY_PREPARE_OCR_TESTS, + ( + ( + "tests/test_litellm/ocr/test_ocr_native_format.py::test_native_format_rejected_for_provider_without_support_as_bad_request", + "native_format_rejected_for_provider_without_support_as_bad_request", + ), + ( + "tests/test_litellm/ocr/test_ocr_native_format.py::test_unknown_format_rejected_for_provider_without_support_as_bad_request", + "unknown_format_rejected_for_provider_without_support_as_bad_request", + ), + ), +) + +_HOST_ONLY_BRIDGE_EXCLUSIONS: Final = tuple( + MappingExclusionSpec(nodeid=f"{_RUST_BRIDGE_FILE}::{test}", reason=reason) + for test, reason in ( + ("test_ocr_routes_to_rust_when_enabled", "Python selects and invokes the native bridge."), + ("test_ocr_routes_azure_ai_to_rust_when_enabled", "Python resolves provider arguments before the bridge."), + ("test_ocr_rust_path_converts_file_document_before_bridge", "Python converts file inputs before the bridge."), + ( + "test_ocr_exception_type_uses_resolved_provider_context", + "Python wraps bridge exceptions into public errors.", + ), + ("test_aocr_routes_to_async_rust_when_enabled", "Python selects and invokes the async native bridge."), + ("test_aocr_exception_type_uses_resolved_provider_context", "Python wraps async bridge exceptions."), + ("test_ocr_forwards_timeout_to_rust", "Python converts and forwards explicit timeouts."), + ("test_ocr_passes_default_request_timeout_to_rust", "Python supplies its process-level default timeout."), + ("test_ocr_falls_back_to_python_when_bridge_unavailable", "Python owns fallback when the extension is absent."), + ) +) + +_FAMILY_PORT_MAPPINGS: Final = ( + TestMapping( + python=f"{_AZURE_TRANSFORM_FILE}::test_transform_ocr_response_default_format_omits_raw_operation", + rust=_rust_family( + _CORE_TARGET, + _AZURE_OCR_TESTS, + "document_intelligence_default_format_omits_raw_operation", + ), + ), + TestMapping( + python=f"{_AZURE_TRANSFORM_FILE}::test_map_ocr_params_passes_through_req_format", + rust=_rust_family(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_maps_req_format"), + ), + TestMapping( + python="tests/ocr_tests/test_ocr_vertex_ai.py::test_deepseek_request_uses_single_provider_namespace", + rust=_rust_family( + _CORE_TARGET, + _VERTEX_OCR_TESTS, + "vertex_deepseek_request_uses_single_provider_namespace", + ), + ), + TestMapping( + python="tests/test_litellm/llms/reducto/test_upload.py::test_parse_v3_rejects_plain_http_urls", + rust=_rust_family(_CORE_TARGET, _REDUCTO_OCR_TESTS, "test_parse_v3_rejects_plain_http_urls"), + ), +) + + +OCR_CONTRACT: Final = UnitTestContract( + mapping=MappingSpec( + python_functions=PythonFunctionDiscoverySpec( + trace_module="tests.rust-python-harness.strategies.trace_parity.sdk.ocr.case", + trace_spans=( + "ocr", + "prepare_ocr_call", + "ocr_provider_config", + "supported_ocr_params", + "map_ocr_params", + "validate_environment", + "complete_url", + "transform_ocr_request", + "execute_ocr_provider_call", + "transform_ocr_response", + "poll_document_intelligence", + ), + search_roots=("tests",), + exclude_roots=( + "tests/e2e", + "tests/ocr_tests/test_ocr_mistral.py", + "tests/rust-python-harness", + ), + includes=( + "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", + "tests/test_litellm/llms/mistral/ocr", + "tests/test_litellm/llms/ocr", + "tests/test_litellm/ocr", + "tests/test_litellm/proxy/ocr_endpoints", + ), + exclusions=( + "tests/ocr_tests/test_ocr_azure_document_intelligence.py::TestAzureDocumentIntelligenceOCR", + "tests/ocr_tests/test_ocr_vertex_ai.py::TestVertexAIMistralOCR", + "tests/ocr_tests/test_ocr_vertex_ai.py::TestVertexAIDeepSeekOCR", + ), + ), + rust_targets=(_CORE_TARGET, _GATEWAY_TARGET), + mappings=( + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_transform_ocr_response_preserves_azure_native_fields", + rust=_rust_test(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_response_normalizes_pages"), + ), + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_map_ocr_params_features", + rust=_rust_family(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_maps_features"), + ), + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_map_ocr_params_empty_features_list_omitted", + rust=_rust_test(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_url_omits_empty_feature_list"), + ), + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_map_ocr_params_invalid_features_raises", + rust=_rust_family( + _CORE_TARGET, + _AZURE_OCR_TESTS, + "document_intelligence_mapping_rejects_invalid_features", + ), + ), + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_get_complete_url_appends_features_query", + rust=_rust_test(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_url_normalizes_features"), + ), + TestMapping( + python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_get_complete_url_combines_pages_and_features", + rust=_rust_test( + _CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_url_combines_pages_and_feature_list" + ), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestGetSupportedOcrParams::test_extract_header_in_supported_params", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "extract_header_is_a_supported_ocr_param"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestGetSupportedOcrParams::test_extract_footer_in_supported_params", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "extract_footer_is_a_supported_ocr_param"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestGetSupportedOcrParams::test_existing_params_still_present", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "existing_ocr_params_remain_supported"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestMapOcrParams::test_extract_header_passed_through", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "map_ocr_params_forwards_extract_header"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestMapOcrParams::test_extract_footer_passed_through", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "map_ocr_params_forwards_extract_footer"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestMapOcrParams::test_extract_header_and_footer_together", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "map_ocr_params_forwards_extract_header_and_footer"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestMapOcrParams::test_unknown_param_is_dropped", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "map_ocr_params_drops_unknown_params"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestNewSupportedParams::test_new_param_in_supported_list", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "new_ocr_params_are_supported"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestNewParamsMapOcr::test_new_param_passed_through", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "map_ocr_params_forwards_new_ocr_params"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestTransformOcrRequest::test_param_included_in_request_body", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "transform_ocr_request_includes_each_optional_param"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestTransformOcrRequest::test_multiple_new_params_together", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "transform_ocr_request_includes_multiple_new_params"), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestTransformOcrResponseOcr4Fields::test_blocks_and_confidence_scores_preserved", + rust=_rust_test( + _CORE_TARGET, _MISTRAL_OCR_TESTS, "transform_ocr_response_preserves_blocks_and_confidence_scores" + ), + ), + TestMapping( + python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestTransformOcrResponseOcr4Fields::test_ocr4_fields_survive_model_dump", + rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "transform_ocr_response_preserves_ocr4_page_fields"), + ), + *_AZURE_PORT_MAPPINGS, + *_REDUCTO_PORT_MAPPINGS, + _REDUCTO_GATEWAY_MAPPING, + *_GATEWAY_PORT_MAPPINGS, + *_FAMILY_PORT_MAPPINGS, + ), + exclusions=_HOST_ONLY_BRIDGE_EXCLUSIONS, + require_complete=True, + ), + unit_parity=UnitParitySpec( + python_selectors=( + "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py", + "tests/test_litellm/llms/mistral/ocr", + "tests/test_litellm/llms/ocr", + "tests/test_litellm/ocr", + ), + exclusions=( + UnitParityExclusionSpec( + nodeid="tests/test_litellm/ocr/test_rust_bridge.py::test_use_litellm_rust_toggles_flag", + reason="This test asserts the process-level backend flag selected by the parity runner.", + ), + ), + ), + rust=RustUnitSpec( + cargo_manifest="litellm-rust/Cargo.toml", + cargo_filter="ocr", + ), +) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/contracts.py b/tests/rust-python-harness/strategies/unit_tests_mapping/contracts.py new file mode 100644 index 00000000000..a8f309cc8f3 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/contracts.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from collections import Counter +from typing import Final, Literal + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from typing_extensions import Self + +from ...shared.tracing.pytest_usage import PythonFunctionReference +from ...shared.unit_runners.rust_runner import RustTarget, RustTestIdentity, RustTestScope + + +class _ContractModel(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + +def _clean_unique(values: tuple[str, ...], field: str) -> tuple[str, ...]: + cleaned: Final = tuple(value.strip().rstrip("/") for value in values) + if not cleaned or any(not value for value in cleaned): + raise ValueError(f"{field} must contain non-empty paths") + duplicates: Final = tuple(value for value, count in Counter(cleaned).items() if count > 1) + if duplicates: + raise ValueError(f"{field} contains duplicates: {sorted(duplicates)}") + return cleaned + + +def _selector_contains(parent: str, child: str) -> bool: + return child == parent or child.startswith(f"{parent}/") + + +class RustTestFamily(_ContractModel): + kind: Literal["family"] = "family" + target: RustTarget + name: str + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + stripped: Final = value.strip() + if not stripped or stripped.endswith("::"): + raise ValueError("must be a non-empty Rust test base name") + return stripped + + @property + def key(self) -> str: + return f"{self.target.key}::{self.name}::case_*" + + def contains(self, identity: RustTestIdentity) -> bool: + return identity.target == self.target and identity.name.startswith(f"{self.name}::case_") + + +class TestMapping(_ContractModel): + python: str + rust: RustTestIdentity | RustTestFamily + + @field_validator("python") + @classmethod + def validate_python_nodeid(cls, value: str) -> str: + stripped: Final = value.strip() + if "::" not in stripped: + raise ValueError("must be a source path and test name separated by '::'") + return stripped + + +class PythonFunctionDiscoverySpec(_ContractModel): + functions: tuple[PythonFunctionReference, ...] = () + trace_module: str | None = None + trace_spans: tuple[str, ...] = () + search_roots: tuple[str, ...] + exclude_roots: tuple[str, ...] = () + includes: tuple[str, ...] = () + exclusions: tuple[str, ...] = () + + @field_validator("search_roots") + @classmethod + def validate_search_roots(cls, value: tuple[str, ...]) -> tuple[str, ...]: + return _clean_unique(value, "python function search_roots") + + @field_validator("exclude_roots") + @classmethod + def validate_exclude_roots(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if not value: + return () + return _clean_unique(value, "python function exclude_roots") + + @model_validator(mode="after") + def validate_functions(self) -> Self: + if bool(self.functions) == bool(self.trace_module): + raise ValueError("python function discovery needs exactly one function list or trace module") + if self.trace_module is not None and not self.trace_spans: + raise ValueError("trace-derived Python function discovery needs trace_spans") + if not self.functions: + return self + keys: Final = tuple(f"{function.module}:{function.qualname}" for function in self.functions) + duplicates: Final = tuple(key for key, count in Counter(keys).items() if count > 1) + if duplicates: + raise ValueError(f"python function discovery contains duplicates: {sorted(duplicates)}") + return self + + +class UnitParityExclusionSpec(_ContractModel): + nodeid: str + reason: str + + @field_validator("nodeid", "reason") + @classmethod + def validate_fields(cls, value: str) -> str: + stripped: Final = value.strip() + if not stripped: + raise ValueError("must be a non-empty string") + return stripped + + +class MappingExclusionSpec(_ContractModel): + nodeid: str + reason: str + + @field_validator("nodeid", "reason") + @classmethod + def validate_fields(cls, value: str) -> str: + stripped: Final = value.strip() + if not stripped: + raise ValueError("must be a non-empty string") + return stripped + + +class MappingSpec(_ContractModel): + python_selectors: tuple[str, ...] = () + python_functions: PythonFunctionDiscoverySpec | None = None + rust_scope: tuple[RustTestScope, ...] = () + rust_targets: tuple[RustTarget, ...] = () + mappings: tuple[TestMapping, ...] + exclusions: tuple[MappingExclusionSpec, ...] = () + require_complete: bool = False + + @field_validator("python_selectors") + @classmethod + def validate_python_selectors(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if not value: + return () + return _clean_unique(value, "python_selectors") + + @model_validator(mode="after") + def validate_rust_scope(self) -> Self: + if bool(self.python_selectors) == bool(self.python_functions): + raise ValueError("mapping needs exactly one Python selector or function-discovery scope") + targets: Final = tuple(scope.target.key for scope in self.rust_scope) + duplicates: Final = tuple(target for target, count in Counter(targets).items() if count > 1) + if duplicates: + raise ValueError(f"rust_scope contains duplicate targets: {sorted(duplicates)}") + target_names: Final = tuple(target.name for target in self.rust_targets) + duplicate_names: Final = tuple(name for name, count in Counter(target_names).items() if count > 1) + if duplicate_names: + raise ValueError(f"rust_targets contains duplicate names: {sorted(duplicate_names)}") + exclusion_nodeids: Final = tuple(exclusion.nodeid for exclusion in self.exclusions) + duplicate_exclusions: Final = tuple(nodeid for nodeid, count in Counter(exclusion_nodeids).items() if count > 1) + if duplicate_exclusions: + raise ValueError(f"mapping exclusions contain duplicate nodeids: {sorted(duplicate_exclusions)}") + return self + + +class UnitParitySpec(_ContractModel): + python_selectors: tuple[str, ...] + exclusions: tuple[UnitParityExclusionSpec, ...] = () + + @field_validator("python_selectors") + @classmethod + def validate_python_selectors(cls, value: tuple[str, ...]) -> tuple[str, ...]: + return _clean_unique(value, "unit parity python_selectors") + + @model_validator(mode="after") + def validate_exclusions(self) -> Self: + nodeids: Final = tuple(exclusion.nodeid for exclusion in self.exclusions) + duplicates: Final = tuple(nodeid for nodeid, count in Counter(nodeids).items() if count > 1) + if duplicates: + raise ValueError(f"unit parity exclusions contain duplicate nodeids: {sorted(duplicates)}") + return self + + +class RustUnitSpec(_ContractModel): + cargo_manifest: str + cargo_filter: str + cargo_package: str | None = None + + @field_validator("cargo_manifest", "cargo_filter") + @classmethod + def validate_required_fields(cls, value: str) -> str: + stripped: Final = value.strip() + if not stripped: + raise ValueError("must be a non-empty string") + return stripped + + @field_validator("cargo_package") + @classmethod + def validate_package(cls, value: str | None) -> str | None: + if value is None: + return None + stripped: Final = value.strip() + if not stripped: + raise ValueError("must be a non-empty string when provided") + return stripped + + +class UnitTestContract(_ContractModel): + mapping: MappingSpec + unit_parity: UnitParitySpec + rust: RustUnitSpec + + @model_validator(mode="after") + def validate_unit_parity_scope(self) -> Self: + if not self.mapping.python_selectors: + return self + unknown: Final = tuple( + selector + for selector in self.unit_parity.python_selectors + if not any(_selector_contains(parent, selector) for parent in self.mapping.python_selectors) + ) + if unknown: + raise ValueError(f"unit parity selectors must be contained in mapping selectors: {sorted(unknown)}") + return self diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_report.py b/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_report.py new file mode 100644 index 00000000000..a5fd92e449d --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_report.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from collections import Counter +from collections.abc import Callable, Sequence +from typing import Final + +from pydantic import BaseModel, ConfigDict + +from .mapping_validator import MappingReport + + +class MappingReportArtifact(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + report: MappingReport + detailed: bool = False + + +def _group_counts(nodeids: Sequence[str], owner: Callable[[str], str]) -> tuple[str, ...]: + counts: Final = Counter(owner(nodeid) for nodeid in nodeids) + width: Final = max((len(str(count)) for count in counts.values()), default=1) + return tuple( + f" {count:>{width}} {name}" for name, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])) + ) + + +def _python_file(nodeid: str) -> str: + return nodeid.partition("::")[0] + + +def _rust_module(nodeid: str) -> str: + return nodeid.rpartition("::")[0] + + +def _details(nodeids: Sequence[str], owner: Callable[[str], str]) -> tuple[str, ...]: + owners: Final = tuple(sorted(frozenset(owner(nodeid) for nodeid in nodeids))) + return tuple( + line + for name in owners + for line in ( + f" {name}", + *(f" {nodeid.removeprefix(f'{name}::')}" for nodeid in nodeids if owner(nodeid) == name), + ) + ) + + +def _contract_errors(report: MappingReport) -> tuple[str, ...]: + return ( + *(f" Missing Python test: {nodeid}" for nodeid in report.missing_python_tests), + *(f" Missing Rust test: {nodeid}" for nodeid in report.missing_rust_tests), + *(f" Python test mapped more than once: {nodeid}" for nodeid in report.duplicate_python_mappings), + *(f" Rust test mapped more than once: {nodeid}" for nodeid in report.duplicate_rust_mappings), + *(f" Missing mapping exclusion: {nodeid}" for nodeid in report.invalid_mapping_exclusions), + *(f" Python test is both mapped and excluded: {nodeid}" for nodeid in report.mapped_and_excluded_python_tests), + *(f" Missing unit-parity exclusion: {nodeid}" for nodeid in report.invalid_unit_parity_exclusions), + ) + + +def mapping_report_lines(report: MappingReport, *, detailed: bool = False) -> tuple[str, ...]: + unmapped_count: Final = len(report.unmapped_python_tests) + excluded_count: Final = len(report.excluded_python_tests) + excluded_percentage: Final = ( + 0.0 if not report.total_count else round(100.0 * excluded_count / report.total_count, 1) + ) + unmapped_percentage: Final = ( + 0.0 if not report.total_count else round(100.0 * unmapped_count / report.total_count, 1) + ) + rust_total: Final = len(report.rust_tests) + rust_only_count: Final = len(report.rust_only_tests) + rust_mapped_count: Final = rust_total - rust_only_count + contract_errors: Final = _contract_errors(report) + detail_lines: Final = ( + ( + "", + "Unmapped Python test details", + *_details(report.unmapped_python_tests, _python_file), + "", + "Excluded Python test details", + *_details(report.excluded_python_tests, _python_file), + "", + "Rust-only test details", + *_details(report.rust_only_tests, _rust_module), + ) + if detailed + else () + ) + return ( + f"Contract: {'PASS' if report.is_valid else 'FAIL'}", + "", + "Python coverage", + f" Mapped {report.mapped_count:>3} / {report.total_count} ({report.percentage}%)", + f" Excluded {excluded_count:>3} / {report.total_count} ({excluded_percentage}%)", + f" Unmapped {unmapped_count:>3} / {report.total_count} ({unmapped_percentage}%)", + "", + "Rust inventory", + f" Mapped {rust_mapped_count:>3} / {rust_total}", + f" Rust-only {rust_only_count:>3} / {rust_total}", + "", + f"Unmapped Python tests by file ({unmapped_count})", + *_group_counts(report.unmapped_python_tests, _python_file), + "", + f"Excluded Python tests by file ({excluded_count})", + *_group_counts(report.excluded_python_tests, _python_file), + "", + f"Rust-only tests by module ({rust_only_count})", + *_group_counts(report.rust_only_tests, _rust_module), + *(("", "Contract errors", *contract_errors) if contract_errors else ()), + *detail_lines, + ) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_validator.py b/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_validator.py new file mode 100644 index 00000000000..98ea0b02e68 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/mapping_validator.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import importlib +from collections import Counter, defaultdict +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Final, TypeAlias + +from pydantic import BaseModel, ConfigDict + +from ...shared.tracing.pytest_usage import ( + PythonFunctionIdentity, + RustFunctionIdentity, + candidate_test_files, + collect_python_function_tests, +) +from ...shared.tracing.steps import pipeline_projection +from ...shared.unit_runners.python_runner import collect_python_tests, contract_nodeid +from ...shared.unit_runners.rust_runner import RustTarget, RustTestIdentity, RustTestScope, enumerate_rust_tests +from .contracts import PythonFunctionDiscoverySpec, RustTestFamily, TestMapping, UnitTestContract + +PythonInventory: TypeAlias = Callable[[Sequence[str], Path], frozenset[str]] +RustInventory: TypeAlias = Callable[[Path, tuple[RustTestScope, ...]], frozenset[RustTestIdentity]] + + +def _trace_functions( + spec: PythonFunctionDiscoverySpec, +) -> tuple[tuple[PythonFunctionIdentity, ...], tuple[RustFunctionIdentity, ...]]: + from ..trace_parity.models import RouteSpec, TraceExecutionFailure, TraceSuite + from ..trace_parity.sdk.execution import collect_trace + + if spec.trace_module is None: + return () + module: Final = importlib.import_module(spec.trace_module) + suite: Final = getattr(module, "TRACE_SUITE", None) + if not isinstance(suite, TraceSuite) or not isinstance(suite.route, RouteSpec): + raise ValueError(f"{spec.trace_module} must export an SDK TRACE_SUITE") + python_functions: Final[dict[str, PythonFunctionIdentity]] = {} + rust_functions: Final[dict[str, RustFunctionIdentity]] = {} + for scenario in suite.scenarios: + for mode in scenario.modes: + route: Final = RouteSpec( + route=suite.route.route, + python_entrypoints=suite.route.python_entrypoints, + rust_entrypoints=suite.route.rust_entrypoints, + fixture=scenario.fixture, + ) + python_trace: Final = collect_trace(route, "python", asynchronous=mode == "async") + rust_trace: Final = collect_trace(route, "rust", asynchronous=mode == "async") + if isinstance(python_trace, TraceExecutionFailure): + raise ValueError(f"Python trace discovery failed for {scenario.name}/{mode}: {python_trace.message}") + if isinstance(rust_trace, TraceExecutionFailure): + raise ValueError(f"Rust trace discovery failed for {scenario.name}/{mode}: {rust_trace.message}") + mappings: Final = scenario.mappings_for(mode) + python_projection: Final = pipeline_projection("python", python_trace, mappings) + rust_projection: Final = pipeline_projection("rust", rust_trace, mappings) + for step in python_projection.steps: + if step.span in spec.trace_spans: + function: Final = PythonFunctionIdentity.from_trace(step.raw) + python_functions[function.raw] = function + for step in rust_projection.steps: + if step.span in spec.trace_spans: + function: Final = RustFunctionIdentity.from_trace(step.raw) + rust_functions[step.raw] = function + if not python_functions or not rust_functions: + raise ValueError(f"Python trace discovery found no functions for spans: {', '.join(spec.trace_spans)}") + return ( + tuple(python_functions[key] for key in sorted(python_functions)), + tuple(rust_functions[key] for key in sorted(rust_functions)), + ) + + +def collect_python_function_inventory( + spec: PythonFunctionDiscoverySpec, + repo_root: Path, + traced_functions: Sequence[PythonFunctionIdentity] = (), +) -> frozenset[str]: + source_root: Final = repo_root / "litellm" + functions: Final = ( + tuple(reference.resolve(source_root) for reference in spec.functions) + if spec.functions + else tuple(traced_functions) + ) + discovered: Final = candidate_test_files( + functions, + spec.search_roots, + repo_root, + exclude_roots=spec.exclude_roots, + ) + selectors: Final = tuple(dict.fromkeys((*discovered, *spec.includes))) + if not selectors: + raise ValueError("Python function discovery found no candidate test files") + report: Final = collect_python_function_tests( + functions, + selectors, + repo_root, + source_root=source_root, + exclusions=spec.exclusions, + ) + if report.exit_code or report.problems: + details: Final = "\n".join(report.problems) or f"pytest exited with code {report.exit_code}" + raise ValueError(f"Python function test discovery failed:\n{details}") + return frozenset(contract_nodeid(nodeid) for usage in report.usages for nodeid in usage.tests) + + +def _colocated_rust_scope(mappings: Sequence[TestMapping]) -> tuple[RustTestScope, ...]: + modules_by_target: Final[dict[str, set[str]]] = defaultdict(set) + targets: Final[dict[str, RustTarget]] = {} + for item in mappings: + module, separator, _ = item.rust.name.partition("::tests::") + if not separator: + raise ValueError(f"Rust unit test is not colocated in a tests module: {item.rust.key}") + target_key: Final = item.rust.target.key + targets[target_key] = item.rust.target + modules_by_target[target_key].add(f"{module}::tests") + return tuple( + RustTestScope( + target=targets[target_key], + modules=tuple(sorted(modules_by_target[target_key])), + ) + for target_key in sorted(targets) + ) + + +def _traced_rust_scope( + functions: Sequence[RustFunctionIdentity], + targets: Sequence[RustTarget], + repo_root: Path, +) -> tuple[RustTestScope, ...]: + targets_by_name: Final = {target.name: target for target in targets} + modules_by_target: Final[dict[str, set[str]]] = defaultdict(set) + for function in functions: + crate: Final = function.module_path.partition("::")[0] + target: Final = targets_by_name.get(crate) + if target is None: + continue + source_candidates: Final = ( + repo_root / "litellm-rust" / function.file, + repo_root / function.file, + ) + source: Final = next((path for path in source_candidates if path.is_file()), None) + if source is None: + raise ValueError(f"Traced Rust source does not exist: {function.file}") + contents: Final = source.read_text() + if "mod tests" in contents and "#[cfg(test)]" in contents: + modules_by_target[target.key].add(function.test_module) + selected_targets: Final = {target.key: target for target in targets} + scopes: Final = tuple( + RustTestScope(target=selected_targets[key], modules=tuple(sorted(modules))) + for key, modules in sorted(modules_by_target.items()) + if modules + ) + if not scopes: + raise ValueError("Traced Rust functions have no colocated test modules") + return scopes + + +def _merge_rust_scopes(scopes: Sequence[RustTestScope]) -> tuple[RustTestScope, ...]: + targets: Final = {scope.target.key: scope.target for scope in scopes} + modules: Final[dict[str, set[str]]] = defaultdict(set) + features: Final[dict[str, set[str]]] = defaultdict(set) + default_features: Final[dict[str, bool]] = {} + for scope in scopes: + modules[scope.target.key].update(scope.modules) + features[scope.target.key].update(scope.features) + default_features[scope.target.key] = default_features.get(scope.target.key, True) and scope.default_features + return tuple( + RustTestScope( + target=targets[key], + modules=tuple( + sorted( + module + for module in modules[key] + if not any(module.startswith(f"{parent}::") for parent in modules[key]) + ) + ), + features=tuple(sorted(features[key])), + default_features=default_features[key], + ) + for key in sorted(targets) + ) + + +def _owned_rust_tests( + rust: RustTestIdentity | RustTestFamily, + inventory: frozenset[RustTestIdentity], +) -> frozenset[RustTestIdentity]: + if isinstance(rust, RustTestFamily): + return frozenset(identity for identity in inventory if rust.contains(identity)) + return frozenset((rust,)) if rust in inventory else frozenset() + + +class MappingReport(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + python_tests: tuple[str, ...] + rust_tests: tuple[str, ...] + mapped_python_tests: tuple[str, ...] + excluded_python_tests: tuple[str, ...] + unmapped_python_tests: tuple[str, ...] + rust_only_tests: tuple[str, ...] + missing_python_tests: tuple[str, ...] + missing_rust_tests: tuple[str, ...] + duplicate_python_mappings: tuple[str, ...] + duplicate_rust_mappings: tuple[str, ...] + invalid_mapping_exclusions: tuple[str, ...] + mapped_and_excluded_python_tests: tuple[str, ...] + invalid_unit_parity_exclusions: tuple[str, ...] + + @property + def mapped_count(self) -> int: + return len(self.mapped_python_tests) + + @property + def total_count(self) -> int: + return len(self.python_tests) + + @property + def percentage(self) -> float: + return 0.0 if not self.total_count else round(100.0 * self.mapped_count / self.total_count, 1) + + @property + def is_valid(self) -> bool: + return not ( + self.missing_python_tests + or self.missing_rust_tests + or self.duplicate_python_mappings + or self.duplicate_rust_mappings + or self.invalid_mapping_exclusions + or self.mapped_and_excluded_python_tests + or self.invalid_unit_parity_exclusions + ) + + +def audit_mapping( + contract: UnitTestContract, + repo_root: Path, + *, + python_inventory: PythonInventory = collect_python_tests, + rust_inventory: RustInventory = enumerate_rust_tests, +) -> MappingReport: + mapping: Final = contract.mapping + traced_python: tuple[PythonFunctionIdentity, ...] = () + traced_rust: tuple[RustFunctionIdentity, ...] = () + if mapping.python_functions is not None and mapping.python_functions.trace_module is not None: + traced_python, traced_rust = _trace_functions(mapping.python_functions) + python_tests: Final = ( + collect_python_function_inventory(mapping.python_functions, repo_root, traced_python) + if mapping.python_functions is not None + else python_inventory(mapping.python_selectors, repo_root) + ) + unit_parity_tests: Final = python_inventory(contract.unit_parity.python_selectors, repo_root) + traced_scope: Final = _traced_rust_scope(traced_rust, mapping.rust_targets, repo_root) if traced_rust else () + rust_scope: Final = _merge_rust_scopes( + (*mapping.rust_scope, *traced_scope, *_colocated_rust_scope(mapping.mappings)) + ) + rust_tests: Final = rust_inventory(repo_root, rust_scope) + mapped_python: Final = frozenset(item.python for item in mapping.mappings) + excluded_python: Final = frozenset(exclusion.nodeid for exclusion in mapping.exclusions) + rust_ownership: Final = tuple((item.rust, _owned_rust_tests(item.rust, rust_tests)) for item in mapping.mappings) + mapped_rust: Final = frozenset(identity for _, identities in rust_ownership for identity in identities) + duplicate_python: Final = tuple( + sorted(nodeid for nodeid, count in Counter(item.python for item in mapping.mappings).items() if count > 1) + ) + duplicate_exact_rust: Final = frozenset( + identity.key + for identity, count in Counter( + item.rust for item in mapping.mappings if isinstance(item.rust, RustTestIdentity) + ).items() + if count > 1 + ) + duplicate_owned_rust: Final = frozenset( + identity.key + for identity, count in Counter(identity for _, identities in rust_ownership for identity in identities).items() + if count > 1 + ) + duplicate_rust: Final = tuple(sorted(duplicate_exact_rust | duplicate_owned_rust)) + return MappingReport( + python_tests=tuple(sorted(python_tests)), + rust_tests=tuple(sorted(identity.key for identity in rust_tests)), + mapped_python_tests=tuple(sorted(python_tests & mapped_python)), + excluded_python_tests=tuple(sorted((python_tests & excluded_python) - mapped_python)), + unmapped_python_tests=tuple(sorted(python_tests - mapped_python - excluded_python)), + rust_only_tests=tuple(sorted(identity.key for identity in rust_tests - mapped_rust)), + missing_python_tests=tuple(sorted(mapped_python - python_tests)), + missing_rust_tests=tuple(sorted(rust.key for rust, identities in rust_ownership if not identities)), + duplicate_python_mappings=duplicate_python, + duplicate_rust_mappings=duplicate_rust, + invalid_mapping_exclusions=tuple(sorted(excluded_python - python_tests)), + mapped_and_excluded_python_tests=tuple(sorted(mapped_python & excluded_python)), + invalid_unit_parity_exclusions=tuple( + sorted( + exclusion.nodeid + for exclusion in contract.unit_parity.exclusions + if exclusion.nodeid not in unit_parity_tests + ) + ), + ) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/mappings.py b/tests/rust-python-harness/strategies/unit_tests_mapping/mappings.py new file mode 100644 index 00000000000..efb5b2a644a --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/mappings.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final + +from ...shared.reporting.models import SdkFunction +from .cases.ocr import OCR_CONTRACT +from .contracts import UnitTestContract + +UNIT_TEST_CONTRACTS: Final[Mapping[SdkFunction, UnitTestContract]] = MappingProxyType({"ocr": OCR_CONTRACT}) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/reporting.py b/tests/rust-python-harness/strategies/unit_tests_mapping/reporting.py new file mode 100644 index 00000000000..d4bce7bc768 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/reporting.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final + +from pydantic import ValidationError + +from ...shared.reporting.models import CaseResult +from ...shared.reporting.rendering import ReportSection, render_case_outcome +from .mapping_report import MappingReportArtifact, mapping_report_lines +from .runner import MAPPING_REPORT_ARTIFACT + + +def _render_artifact(body: str) -> str: + try: + artifact: Final = MappingReportArtifact.model_validate_json(body) + except ValidationError as error: + return f"Mapping report artifact is invalid: {error}" + return "\n".join(mapping_report_lines(artifact.report, detailed=artifact.detailed)) + + +def _render_result(result: CaseResult) -> str: + reports: Final = tuple( + _render_artifact(artifact.body) + for artifacts in result.artifacts.values() + for artifact in artifacts + if artifact.kind == MAPPING_REPORT_ARTIFACT + ) + if reports: + return "\n".join((f"Case: {result.case.display_name}", *reports)) + return render_case_outcome(result) + + +def render_mapping_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + blocks: Final = tuple(_render_result(result) for result in results) + return (ReportSection("Python/Rust unit-test mappings", blocks or ("No mapping cases selected",)),) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/runner.py b/tests/rust-python-harness/strategies/unit_tests_mapping/runner.py new file mode 100644 index 00000000000..540edca9385 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/runner.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Final + +from ...shared.native_build import ensure_trace_bridge +from ...shared.reporting.models import ResultArtifact +from ...shared.unit_runners.python_runner import collect_python_tests +from ...shared.unit_runners.rust_runner import enumerate_rust_tests +from ...shared.unit_runners.suite_runner import SuiteExecution +from .contracts import UnitTestContract +from .mapping_report import MappingReportArtifact +from .mapping_validator import PythonInventory, RustInventory, audit_mapping + +MAPPING_REPORT_ARTIFACT: Final = "mapping_report" + + +def _audit_problems(artifact: MappingReportArtifact) -> tuple[str, ...]: + report: Final = artifact.report + return ( + *(f"mapped Python test does not exist: {nodeid}" for nodeid in report.missing_python_tests), + *(f"mapped Rust test does not exist: {nodeid}" for nodeid in report.missing_rust_tests), + *(f"Python test has multiple mappings: {nodeid}" for nodeid in report.duplicate_python_mappings), + *(f"Rust test has multiple mappings: {nodeid}" for nodeid in report.duplicate_rust_mappings), + *(f"mapping exclusion does not exist: {nodeid}" for nodeid in report.invalid_mapping_exclusions), + *(f"Python test is both mapped and excluded: {nodeid}" for nodeid in report.mapped_and_excluded_python_tests), + *(f"unit parity exclusion does not exist: {nodeid}" for nodeid in report.invalid_unit_parity_exclusions), + ) + + +def run_suite( + contract: UnitTestContract, + repo_root: Path, + runner_args: Sequence[str] = (), + *, + python_inventory: PythonInventory = collect_python_tests, + rust_inventory: RustInventory = enumerate_rust_tests, +) -> SuiteExecution: + if contract.mapping.python_functions is not None and contract.mapping.python_functions.trace_module is not None: + bridge_error: Final = ensure_trace_bridge(repo_root) + if bridge_error is not None: + return SuiteExecution(problems=(bridge_error,)) + artifact: Final = MappingReportArtifact( + report=audit_mapping( + contract, + repo_root, + python_inventory=python_inventory, + rust_inventory=rust_inventory, + ), + detailed=bool(runner_args), + ) + completeness_problems: Final = ( + tuple(f"Python test has no Rust mapping: {nodeid}" for nodeid in artifact.report.unmapped_python_tests) + if contract.mapping.require_complete + else () + ) + return SuiteExecution( + problems=(*_audit_problems(artifact), *completeness_problems), + artifacts=(ResultArtifact(MAPPING_REPORT_ARTIFACT, artifact.model_dump_json()),), + ) diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/test_mapping_validator.py b/tests/rust-python-harness/strategies/unit_tests_mapping/test_mapping_validator.py new file mode 100644 index 00000000000..6635a0eb522 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/test_mapping_validator.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Final + +import pytest +from pydantic import ValidationError + +from ...shared.unit_runners.rust_runner import RustTarget, RustTestIdentity, RustTestScope +from .contracts import ( + MappingExclusionSpec, + MappingSpec, + RustTestFamily, + RustUnitSpec, + UnitParityExclusionSpec, + UnitParitySpec, + UnitTestContract, +) +from .contracts import TestMapping as MappingPair +from .mapping_validator import audit_mapping + +_TARGET: Final = RustTarget(package="example", name="example", kind="lib") +_SCOPE: Final = RustTestScope(target=_TARGET, modules=("api::tests",)) +_PYTHON_TESTS: Final = frozenset(("test_api.py::test_decode", "test_api.py::test_unmapped")) +_RUST_TEST: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes") +_RUST_ONLY: Final = RustTestIdentity(target=_TARGET, name="api::tests::rust_only") +_RUST_TESTS: Final = frozenset((_RUST_TEST, _RUST_ONLY)) + + +def _python_inventory(*_: object) -> frozenset[str]: + return _PYTHON_TESTS + + +def _rust_inventory(*_: object) -> frozenset[RustTestIdentity]: + return _RUST_TESTS + + +def _contract(*mappings: MappingPair, exclusions: tuple[UnitParityExclusionSpec, ...] = ()) -> UnitTestContract: + return UnitTestContract( + mapping=MappingSpec( + python_selectors=("test_api.py",), + rust_scope=(_SCOPE,), + mappings=mappings, + ), + unit_parity=UnitParitySpec(python_selectors=("test_api.py",), exclusions=exclusions), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + +def _mapping_exclusion(nodeid: str) -> MappingExclusionSpec: + return MappingExclusionSpec(nodeid=nodeid, reason="Python bridge availability is host-only") + + +def test_derives_mapping_status_from_live_inventories(tmp_path: Path) -> None: + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert report.is_valid + assert report.mapped_python_tests == ("test_api.py::test_decode",) + assert report.unmapped_python_tests == ("test_api.py::test_unmapped",) + assert report.rust_only_tests == (_RUST_ONLY.key,) + assert report.percentage == 50.0 + + +def test_reports_stale_and_duplicate_mappings(tmp_path: Path) -> None: + removed: Final = RustTestIdentity(target=_TARGET, name="api::tests::removed") + contract: Final = _contract( + MappingPair(python="test_api.py::removed", rust=removed), + MappingPair(python="test_api.py::removed", rust=_RUST_TEST), + ) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert not report.is_valid + assert report.missing_python_tests == ("test_api.py::removed",) + assert report.missing_rust_tests == (removed.key,) + assert report.duplicate_python_mappings == ("test_api.py::removed",) + + +def test_reports_duplicate_rust_mapping_and_invalid_exclusion(tmp_path: Path) -> None: + contract: Final = _contract( + MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST), + MappingPair(python="test_api.py::test_unmapped", rust=_RUST_TEST), + exclusions=(UnitParityExclusionSpec(nodeid="test_api.py::removed", reason="Removed test"),), + ) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert not report.is_valid + assert report.duplicate_rust_mappings == (_RUST_TEST.key,) + assert report.invalid_unit_parity_exclusions == ("test_api.py::removed",) + + +def test_excludes_host_only_python_test_from_unmapped_inventory(tmp_path: Path) -> None: + partial: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + contract: Final = partial.model_copy( + update={ + "mapping": partial.mapping.model_copy( + update={"exclusions": (_mapping_exclusion("test_api.py::test_unmapped"),)} + ) + } + ) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert report.is_valid + assert report.excluded_python_tests == ("test_api.py::test_unmapped",) + assert report.unmapped_python_tests == () + + +def test_reports_missing_and_mapped_mapping_exclusions(tmp_path: Path) -> None: + partial: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + contract: Final = partial.model_copy( + update={ + "mapping": partial.mapping.model_copy( + update={ + "exclusions": ( + _mapping_exclusion("test_api.py::test_decode"), + _mapping_exclusion("test_api.py::removed"), + ) + } + ) + } + ) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert not report.is_valid + assert report.invalid_mapping_exclusions == ("test_api.py::removed",) + assert report.mapped_and_excluded_python_tests == ("test_api.py::test_decode",) + + +def test_resolves_rstest_family_to_generated_cases(tmp_path: Path) -> None: + first_case: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes::case_1_png") + second_case: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes::case_2_pdf") + family: Final = RustTestFamily(target=_TARGET, name="api::tests::decodes") + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=family)) + + report: Final = audit_mapping( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=lambda *_: frozenset((first_case, second_case)), + ) + + assert report.is_valid + assert report.mapped_python_tests == ("test_api.py::test_decode",) + assert report.missing_rust_tests == () + + +def test_reports_missing_rstest_family(tmp_path: Path) -> None: + family: Final = RustTestFamily(target=_TARGET, name="api::tests::decodes") + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=family)) + + report: Final = audit_mapping( + contract, tmp_path, python_inventory=_python_inventory, rust_inventory=_rust_inventory + ) + + assert not report.is_valid + assert report.missing_rust_tests == (family.key,) + + +def test_reports_concrete_test_owned_by_exact_and_family_mappings(tmp_path: Path) -> None: + generated: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes::case_1_png") + family: Final = RustTestFamily(target=_TARGET, name="api::tests::decodes") + contract: Final = _contract( + MappingPair(python="test_api.py::test_decode", rust=family), + MappingPair(python="test_api.py::test_unmapped", rust=generated), + ) + + report: Final = audit_mapping( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=lambda *_: frozenset((generated,)), + ) + + assert not report.is_valid + assert report.duplicate_rust_mappings == (generated.key,) + + +def test_rstest_family_cases_are_not_rust_only(tmp_path: Path) -> None: + generated: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes::case_1_png") + unrelated: Final = RustTestIdentity(target=_TARGET, name="api::tests::rust_only") + family: Final = RustTestFamily(target=_TARGET, name="api::tests::decodes") + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=family)) + + report: Final = audit_mapping( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=lambda *_: frozenset((generated, unrelated)), + ) + + assert report.rust_only_tests == (unrelated.key,) + + +def test_merges_configured_and_colocated_rust_scopes(tmp_path: Path) -> None: + support_test: Final = RustTestIdentity(target=_TARGET, name="support::tests::rust_only") + configured_scope: Final = RustTestScope( + target=_TARGET, + modules=("support::tests",), + features=("mock",), + default_features=False, + ) + expected_scope: Final = RustTestScope( + target=_TARGET, + modules=("api::tests", "support::tests"), + features=("mock",), + default_features=False, + ) + contract: Final = UnitTestContract( + mapping=MappingSpec( + python_selectors=("test_api.py",), + rust_scope=(configured_scope,), + mappings=(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST),), + ), + unit_parity=UnitParitySpec(python_selectors=("test_api.py",)), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + def assert_merged_scope(_: Path, scopes: tuple[RustTestScope, ...]) -> frozenset[RustTestIdentity]: + assert scopes == (expected_scope,) + return frozenset((_RUST_TEST, support_test)) + + report: Final = audit_mapping( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=assert_merged_scope, + ) + + assert report.is_valid + assert report.rust_only_tests == (support_test.key,) + + +def test_merged_rust_scope_removes_modules_contained_by_parent(tmp_path: Path) -> None: + expected_scope: Final = RustTestScope(target=_TARGET, modules=("api",)) + contract: Final = UnitTestContract( + mapping=MappingSpec( + python_selectors=("test_api.py",), + rust_scope=(expected_scope,), + mappings=(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST),), + ), + unit_parity=UnitParitySpec(python_selectors=("test_api.py",)), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + def assert_parent_scope(_: Path, scopes: tuple[RustTestScope, ...]) -> frozenset[RustTestIdentity]: + assert scopes == (expected_scope,) + return frozenset((_RUST_TEST,)) + + report: Final = audit_mapping( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=assert_parent_scope, + ) + + assert report.is_valid + + +def test_accepts_descendant_unit_parity_selector() -> None: + contract: Final = UnitTestContract( + mapping=MappingSpec(python_selectors=("tests/api",), rust_scope=(_SCOPE,), mappings=()), + unit_parity=UnitParitySpec(python_selectors=("tests/api/test_ocr.py",)), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + assert contract.unit_parity.python_selectors == ("tests/api/test_ocr.py",) + + +@pytest.mark.parametrize( + "mapping_selectors,parity_selectors", + (((), ("tests/api",)), (("tests/api", "tests/api"), ("tests/api",)), (("tests/api",), ("tests/chat",))), +) +def test_rejects_invalid_selector_contracts( + mapping_selectors: tuple[str, ...], parity_selectors: tuple[str, ...] +) -> None: + with pytest.raises(ValidationError): + UnitTestContract( + mapping=MappingSpec(python_selectors=mapping_selectors, rust_scope=(_SCOPE,), mappings=()), + unit_parity=UnitParitySpec(python_selectors=parity_selectors), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + +def test_rejects_duplicate_scopes_and_exclusions() -> None: + exclusion: Final = UnitParityExclusionSpec(nodeid="test_api.py::test_skip", reason="Backend assertion") + with pytest.raises(ValidationError, match="duplicate targets"): + MappingSpec(python_selectors=("test_api.py",), rust_scope=(_SCOPE, _SCOPE), mappings=()) + with pytest.raises(ValidationError, match="duplicate nodeids"): + UnitParitySpec(python_selectors=("test_api.py",), exclusions=(exclusion, exclusion)) + mapping_exclusion: Final = _mapping_exclusion("test_api.py::test_skip") + with pytest.raises(ValidationError, match="mapping exclusions contain duplicate nodeids"): + MappingSpec( + python_selectors=("test_api.py",), + rust_scope=(_SCOPE,), + mappings=(), + exclusions=(mapping_exclusion, mapping_exclusion), + ) + with pytest.raises(ValidationError, match="must be a non-empty string"): + MappingExclusionSpec(nodeid="test_api.py::test_skip", reason=" ") diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/test_reporting.py b/tests/rust-python-harness/strategies/unit_tests_mapping/test_reporting.py new file mode 100644 index 00000000000..36e18a9d109 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/test_reporting.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import Final + +from ...shared.reporting.models import CaseResult, Coverage, HarnessCase, ResultArtifact, RunStatus +from ...shared.reporting.strategy import SuiteCaseSpec +from .mapping_report import MappingReportArtifact +from .mapping_validator import MappingReport +from .reporting import render_mapping_results +from .runner import MAPPING_REPORT_ARTIFACT + + +def _report(*, invalid: bool = False, excluded: bool = False) -> MappingReport: + return MappingReport( + python_tests=("test_api.py::test_decode", "test_api.py::test_unmapped"), + rust_tests=("example/lib/example::api::tests::decodes", "example/lib/example::api::tests::rust_only"), + mapped_python_tests=("test_api.py::test_decode",), + excluded_python_tests=(("test_api.py::test_unmapped",) if excluded else ()), + unmapped_python_tests=(() if excluded else ("test_api.py::test_unmapped",)), + rust_only_tests=("example/lib/example::api::tests::rust_only",), + missing_python_tests=("test_api.py::removed",) if invalid else (), + missing_rust_tests=(), + duplicate_python_mappings=(), + duplicate_rust_mappings=(), + invalid_mapping_exclusions=(), + mapped_and_excluded_python_tests=(), + invalid_unit_parity_exclusions=(), + ) + + +def _result(body: str) -> CaseResult: + case: Final = HarnessCase( + strategy_id="unit_tests_mapping", + strategy_label="Unit test mapping", + sdk_function="ocr", + spec=SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr"), + ) + result: Final = CaseResult(case=case) + result.record( + "suite:unit_tests_mapping:ocr:ocr", + RunStatus.PASSED, + artifacts=(ResultArtifact(MAPPING_REPORT_ARTIFACT, body),), + ) + return result + + +def test_renderer_preserves_summary_and_detailed_output() -> None: + summary: Final = MappingReportArtifact(report=_report()).model_dump_json() + detailed: Final = MappingReportArtifact(report=_report(), detailed=True).model_dump_json() + + summary_text: Final = "\n".join(render_mapping_results((_result(summary),))[0].blocks) + detailed_text: Final = "\n".join(render_mapping_results((_result(detailed),))[0].blocks) + + assert "Mapped 1 / 2 (50.0%)" in summary_text + assert "Unmapped Python test details" not in summary_text + assert "Unmapped Python test details\n test_api.py\n test_unmapped" in detailed_text + assert "Rust-only test details" in detailed_text + + +def test_renderer_shows_contract_errors() -> None: + body: Final = MappingReportArtifact(report=_report(invalid=True)).model_dump_json() + rendered: Final = "\n".join(render_mapping_results((_result(body),))[0].blocks) + + assert "Contract: FAIL" in rendered + assert "Missing Python test: test_api.py::removed" in rendered + + +def test_renderer_distinguishes_excluded_python_tests() -> None: + body: Final = MappingReportArtifact(report=_report(excluded=True), detailed=True).model_dump_json() + rendered: Final = "\n".join(render_mapping_results((_result(body),))[0].blocks) + + assert "Excluded 1 / 2 (50.0%)" in rendered + assert "Unmapped 0 / 2 (0.0%)" in rendered + assert "Excluded Python test details\n test_api.py\n test_unmapped" in rendered + + +def test_renderer_handles_empty_inventory_and_malformed_artifact() -> None: + empty: Final = MappingReport( + python_tests=(), + rust_tests=(), + mapped_python_tests=(), + excluded_python_tests=(), + unmapped_python_tests=(), + rust_only_tests=(), + missing_python_tests=(), + missing_rust_tests=(), + duplicate_python_mappings=(), + duplicate_rust_mappings=(), + invalid_mapping_exclusions=(), + mapped_and_excluded_python_tests=(), + invalid_unit_parity_exclusions=(), + ) + empty_text: Final = "\n".join( + render_mapping_results((_result(MappingReportArtifact(report=empty).model_dump_json()),))[0].blocks + ) + invalid_text: Final = "\n".join(render_mapping_results((_result("not-json"),))[0].blocks) + + assert "Mapped 0 / 0 (0.0%)" in empty_text + assert "Mapping report artifact is invalid:" in invalid_text diff --git a/tests/rust-python-harness/strategies/unit_tests_mapping/test_runner.py b/tests/rust-python-harness/strategies/unit_tests_mapping/test_runner.py new file mode 100644 index 00000000000..2b14c716e1d --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_mapping/test_runner.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from functools import partial +from pathlib import Path +from typing import Final + +from ...shared.reporting.models import Coverage, HarnessCase, RunStatus +from ...shared.reporting.strategy import SuiteCaseSpec +from ...shared.unit_runners.rust_runner import RustTarget, RustTestIdentity, RustTestScope +from ...shared.unit_runners.suite_runner import run_suites +from .contracts import ( + MappingExclusionSpec, + MappingSpec, + RustUnitSpec, + TestMapping as MappingPair, + UnitParitySpec, + UnitTestContract, +) +from .mapping_report import MappingReportArtifact +from .runner import MAPPING_REPORT_ARTIFACT, run_suite + +_TARGET: Final = RustTarget(package="example", name="example", kind="lib") +_RUST_TEST: Final = RustTestIdentity(target=_TARGET, name="api::tests::decodes") +_RUST_ONLY: Final = RustTestIdentity(target=_TARGET, name="api::tests::rust_only") + + +def _python_inventory(*_: object) -> frozenset[str]: + return frozenset(("test_api.py::test_decode", "test_api.py::test_unmapped")) + + +def _rust_inventory(*_: object) -> frozenset[RustTestIdentity]: + return frozenset((_RUST_TEST, _RUST_ONLY)) + + +def _contract(mapping: MappingPair) -> UnitTestContract: + return UnitTestContract( + mapping=MappingSpec( + python_selectors=("test_api.py",), + rust_scope=(RustTestScope(target=_TARGET, modules=("api::tests",)),), + mappings=(mapping,), + ), + unit_parity=UnitParitySpec(python_selectors=("test_api.py",)), + rust=RustUnitSpec(cargo_manifest="Cargo.toml", cargo_filter="api"), + ) + + +def _case() -> HarnessCase: + return HarnessCase( + strategy_id="unit_tests_mapping", + strategy_label="Unit test mapping", + sdk_function="ocr", + spec=SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr"), + ) + + +def test_reports_structured_mapping_status_without_running_tests(tmp_path: Path) -> None: + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + case: Final = _case() + + code, report = run_suites( + (case,), + tmp_path, + lambda _: None, + suites={"ocr": contract}, + execute=partial( + run_suite, + python_inventory=_python_inventory, + rust_inventory=_rust_inventory, + ), + ) + + result: Final = report.results[case.key] + artifacts: Final = tuple( + artifact + for values in result.artifacts.values() + for artifact in values + if artifact.kind == MAPPING_REPORT_ARTIFACT + ) + parsed: Final = MappingReportArtifact.model_validate_json(artifacts[0].body) + assert code == 0, report.failures + assert result.status is RunStatus.PASSED + assert parsed.report.mapped_count == 1 + assert parsed.report.total_count == 2 + assert not parsed.detailed + + +def test_fails_when_a_mapping_target_is_missing(tmp_path: Path) -> None: + missing: Final = RustTestIdentity(target=_TARGET, name="api::tests::missing") + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=missing)) + case: Final = _case() + + code, report = run_suites( + (case,), + tmp_path, + lambda _: None, + suites={"ocr": contract}, + execute=partial( + run_suite, + python_inventory=_python_inventory, + rust_inventory=_rust_inventory, + ), + ) + + assert code == 1 + assert report.results[case.key].status is RunStatus.FAILED + assert any("mapped Rust test does not exist" in detail for _, detail in report.failures) + + +def test_required_complete_mapping_fails_for_unmapped_python_test(tmp_path: Path) -> None: + partial: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + contract: Final = partial.model_copy( + update={"mapping": partial.mapping.model_copy(update={"require_complete": True})} + ) + + execution: Final = run_suite( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=_rust_inventory, + ) + + assert execution.problems == ("Python test has no Rust mapping: test_api.py::test_unmapped",) + + +def test_required_complete_mapping_accepts_host_only_exclusion(tmp_path: Path) -> None: + partial: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + contract: Final = partial.model_copy( + update={ + "mapping": partial.mapping.model_copy( + update={ + "require_complete": True, + "exclusions": ( + MappingExclusionSpec( + nodeid="test_api.py::test_unmapped", + reason="Python bridge availability is host-only", + ), + ), + } + ) + } + ) + + execution: Final = run_suite( + contract, + tmp_path, + python_inventory=_python_inventory, + rust_inventory=_rust_inventory, + ) + artifact: Final = MappingReportArtifact.model_validate_json(execution.artifacts[0].body) + + assert execution.problems == () + assert artifact.report.excluded_python_tests == ("test_api.py::test_unmapped",) + + +def test_detail_argument_is_stored_in_artifact(tmp_path: Path) -> None: + contract: Final = _contract(MappingPair(python="test_api.py::test_decode", rust=_RUST_TEST)) + execution: Final = run_suite( + contract, + tmp_path, + ("full",), + python_inventory=_python_inventory, + rust_inventory=_rust_inventory, + ) + artifact: Final = MappingReportArtifact.model_validate_json(execution.artifacts[0].body) + + assert artifact.detailed diff --git a/tests/rust-python-harness/strategies/unit_tests_parity/AGENTS.md b/tests/rust-python-harness/strategies/unit_tests_parity/AGENTS.md new file mode 100644 index 00000000000..ccab6b1ff12 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_parity/AGENTS.md @@ -0,0 +1 @@ +Runs the existing litellm Python unit tests with LITELLM_RUST=0 and LITELLM_RUST=1 in separate processes and requires the two runs to match, including on failures. diff --git a/tests/rust-python-harness/strategies/unit_tests_parity/__init__.py b/tests/rust-python-harness/strategies/unit_tests_parity/__init__.py new file mode 100644 index 00000000000..0067bf6dfe5 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_parity/__init__.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from collections.abc import Mapping +from functools import partial +from pathlib import Path +from types import MappingProxyType +from typing import Final + +from ...shared.reporting.models import SDK_FUNCTIONS, Coverage, SdkFunction +from ...shared.reporting.strategy import ( + CaseDefinition, + NotImplementedCaseSpec, + RunnerArgumentDefinition, + StrategyDefinition, + SuiteCaseSpec, +) +from ...shared.unit_runners.suite_runner import run_suites +from ..unit_tests_mapping.mappings import UNIT_TEST_CONTRACTS +from .reporting import render_unit_parity_results +from .runner import UnitParityExclusion, UnitParitySuite, run_suite + + +UNIT_PARITY_SUITES: Final[Mapping[SdkFunction, UnitParitySuite]] = MappingProxyType( + { + sdk_function: UnitParitySuite( + python_selectors=contract.unit_parity.python_selectors, + exclusions=tuple( + UnitParityExclusion( + nodeid=exclusion.nodeid, + reason=exclusion.reason, + ) + for exclusion in contract.unit_parity.exclusions + ), + ) + for sdk_function, contract in UNIT_TEST_CONTRACTS.items() + } +) + + +CASES: Final[tuple[CaseDefinition, ...]] = ( + *( + CaseDefinition( + sdk_function, + SuiteCaseSpec(coverage=Coverage.COMPLETE, suite=sdk_function) + if sdk_function in UNIT_PARITY_SUITES + else NotImplementedCaseSpec(reason=f"No {sdk_function} unit-test parity suite is registered."), + ) + for sdk_function in SDK_FUNCTIONS + ), +) + +STRATEGY: Final = StrategyDefinition( + id="unit_tests_parity", + order=31, + label="Unit test parity", + description=( + "Run existing Python unit tests with LITELLM_RUST disabled and enabled and require matching outcomes." + ), + directory=Path(__file__).parent, + runnable_spec=SuiteCaseSpec, + cases=CASES, + run=partial(run_suites, suites=UNIT_PARITY_SUITES, execute=run_suite), + render=render_unit_parity_results, + runner_argument=RunnerArgumentDefinition( + option="--pytest-arg", + help="append an argument to both Python and Rust-backed pytest runs", + ), +) diff --git a/tests/rust-python-harness/strategies/unit_tests_parity/reporting.py b/tests/rust-python-harness/strategies/unit_tests_parity/reporting.py new file mode 100644 index 00000000000..339e893c60d --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_parity/reporting.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final + +from ...shared.reporting.models import CaseResult +from ...shared.reporting.rendering import ReportSection, render_case_outcome + + +def render_unit_parity_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + blocks: Final = tuple(render_case_outcome(result) for result in results) + return (ReportSection("Python backend parity outcomes", blocks or ("No unit-parity cases selected",)),) diff --git a/tests/rust-python-harness/strategies/unit_tests_parity/runner.py b/tests/rust-python-harness/strategies/unit_tests_parity/runner.py new file mode 100644 index 00000000000..5a3a70ea03c --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_parity/runner.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Final + +from pydantic import BaseModel, ConfigDict + +from ...shared.unit_runners.python_runner import BackendSpec, compare_python_runs, run_python_tests +from ...shared.unit_runners.suite_runner import SuiteExecution + +BACKEND: Final = BackendSpec(environment_variable="LITELLM_RUST") + + +class UnitParityExclusion(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + nodeid: str + reason: str + + +class UnitParitySuite(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + python_selectors: tuple[str, ...] + exclusions: tuple[UnitParityExclusion, ...] = () + + +def run_suite(suite: UnitParitySuite, repo_root: Path, pytest_args: Sequence[str] = ()) -> SuiteExecution: + if not suite.python_selectors: + return SuiteExecution(problems=("unit parity suites must select Python tests",)) + deselections: Final = tuple(f"--deselect={exclusion.nodeid}" for exclusion in suite.exclusions) + args: Final = (*pytest_args, *deselections) + python: Final = run_python_tests(suite.python_selectors, repo_root, "python", BACKEND, args) + rust: Final = run_python_tests(suite.python_selectors, repo_root, "rust", BACKEND, args) + return SuiteExecution(problems=compare_python_runs(python, rust)) diff --git a/tests/rust-python-harness/strategies/unit_tests_parity/test_runner.py b/tests/rust-python-harness/strategies/unit_tests_parity/test_runner.py new file mode 100644 index 00000000000..a4a9524c85f --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_parity/test_runner.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Final + +from ...shared.reporting.models import Coverage, HarnessCase, HarnessRun, RunStatus +from ...shared.reporting.strategy import SuiteCaseSpec +from ...shared.unit_runners.suite_runner import run_suites +from .runner import UnitParityExclusion, UnitParitySuite, run_suite + + +def _write_tests(tmp_path: Path, *, mismatch: bool = False, failing: bool = False) -> None: + (tmp_path / "pytest.ini").write_text("[pytest]\n") + (tmp_path / "test_api.py").write_text( + "import os\n" + "def test_decode():\n assert int('42') == 42\n" + + ("def test_backend():\n assert os.environ['LITELLM_RUST'] == '0'\n" if mismatch else "") + + ("def test_fails():\n assert False\n" if failing else "") + ) + + +def _case() -> HarnessCase: + return HarnessCase( + strategy_id="unit_tests_parity", + strategy_label="Unit test parity", + sdk_function="ocr", + spec=SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr"), + ) + + +def _run(case: HarnessCase, tmp_path: Path, suite: UnitParitySuite) -> tuple[int, HarnessRun]: + return run_suites((case,), tmp_path, lambda _: None, suites={"ocr": suite}, execute=run_suite) + + +def test_passes_when_both_backends_agree(tmp_path: Path) -> None: + _write_tests(tmp_path) + case: Final = _case() + + code, report = _run(case, tmp_path, UnitParitySuite(python_selectors=("test_api.py",))) + + assert code == 0, report.failures + assert report.results[case.key].status is RunStatus.PASSED + + +def test_passes_when_both_backends_fail_identically(tmp_path: Path) -> None: + _write_tests(tmp_path, failing=True) + case: Final = _case() + + code, report = _run(case, tmp_path, UnitParitySuite(python_selectors=("test_api.py",))) + + assert code == 0, report.failures + assert report.results[case.key].status is RunStatus.PASSED + + +def test_fails_when_backend_outcomes_differ(tmp_path: Path) -> None: + _write_tests(tmp_path, mismatch=True) + case: Final = _case() + + code, report = _run(case, tmp_path, UnitParitySuite(python_selectors=("test_api.py",))) + + assert code == 1 + assert report.results[case.key].status is RunStatus.FAILED + assert any("Python/Rust test outcomes differ" in detail for _, detail in report.failures) + assert any("Python only: test_api.py::test_backend [call] passed" in detail for _, detail in report.failures) + assert any("Rust only: test_api.py::test_backend [call] failed" in detail for _, detail in report.failures) + + +def test_excludes_tests_whose_contract_is_the_backend_flag(tmp_path: Path) -> None: + _write_tests(tmp_path, mismatch=True) + suite: Final = UnitParitySuite( + python_selectors=("test_api.py",), + exclusions=( + UnitParityExclusion( + nodeid="test_api.py::test_backend", + reason="The test intentionally asserts which backend is selected.", + ), + ), + ) + case: Final = _case() + + code, report = _run(case, tmp_path, suite) + + assert code == 0, report.failures + assert report.results[case.key].status is RunStatus.PASSED diff --git a/tests/rust-python-harness/strategies/unit_tests_rust/AGENTS.md b/tests/rust-python-harness/strategies/unit_tests_rust/AGENTS.md new file mode 100644 index 00000000000..250da763530 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_rust/AGENTS.md @@ -0,0 +1 @@ +Runs the focused native Cargo test suite for each mapped API. diff --git a/tests/rust-python-harness/strategies/unit_tests_rust/__init__.py b/tests/rust-python-harness/strategies/unit_tests_rust/__init__.py new file mode 100644 index 00000000000..8114e12ab96 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_rust/__init__.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from collections.abc import Mapping +from functools import partial +from pathlib import Path +from types import MappingProxyType +from typing import Final + +from ...shared.reporting.models import SDK_FUNCTIONS, Coverage, SdkFunction +from ...shared.reporting.strategy import ( + CaseDefinition, + NotImplementedCaseSpec, + StrategyDefinition, + SuiteCaseSpec, +) +from ...shared.unit_runners.suite_runner import run_suites +from ..unit_tests_mapping.mappings import UNIT_TEST_CONTRACTS +from .reporting import render_rust_unit_results +from .runner import RustSuite, run_suite + + +RUST_SUITES: Final[Mapping[SdkFunction, RustSuite]] = MappingProxyType( + { + sdk_function: RustSuite( + cargo_manifest=contract.rust.cargo_manifest, + cargo_filter=contract.rust.cargo_filter, + cargo_package=contract.rust.cargo_package, + ) + for sdk_function, contract in UNIT_TEST_CONTRACTS.items() + } +) + + +CASES: Final[tuple[CaseDefinition, ...]] = ( + *( + CaseDefinition( + sdk_function, + SuiteCaseSpec(coverage=Coverage.COMPLETE, suite=sdk_function) + if sdk_function in RUST_SUITES + else NotImplementedCaseSpec(reason=f"No focused {sdk_function} Rust unit suite is registered."), + ) + for sdk_function in SDK_FUNCTIONS + ), +) + +STRATEGY: Final = StrategyDefinition( + id="unit_tests_rust", + order=32, + label="Unit test Rust", + description="Run the focused native Cargo test suite for each mapped API.", + directory=Path(__file__).parent, + runnable_spec=SuiteCaseSpec, + cases=CASES, + run=partial(run_suites, suites=RUST_SUITES, execute=run_suite), + render=render_rust_unit_results, +) diff --git a/tests/rust-python-harness/strategies/unit_tests_rust/reporting.py b/tests/rust-python-harness/strategies/unit_tests_rust/reporting.py new file mode 100644 index 00000000000..575fa5e8cd1 --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_rust/reporting.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Final + +from ...shared.reporting.models import CaseResult +from ...shared.reporting.rendering import ReportSection, render_case_outcome + + +def render_rust_unit_results(results: Sequence[CaseResult]) -> tuple[ReportSection, ...]: + blocks: Final = tuple(render_case_outcome(result) for result in results) + return (ReportSection("Native Rust unit-test outcomes", blocks or ("No Rust unit-test cases selected",)),) diff --git a/tests/rust-python-harness/strategies/unit_tests_rust/runner.py b/tests/rust-python-harness/strategies/unit_tests_rust/runner.py new file mode 100644 index 00000000000..601b5a3c96b --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_rust/runner.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path + +from pydantic import BaseModel, ConfigDict + +from ...shared.unit_runners.rust_runner import run_rust_tests +from ...shared.unit_runners.suite_runner import SuiteExecution + + +class RustSuite(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + cargo_manifest: str + cargo_package: str | None = None + cargo_filter: str + + +def run_suite(suite: RustSuite, repo_root: Path, pytest_args: Sequence[str] = ()) -> SuiteExecution: + del pytest_args + if not suite.cargo_filter: + return SuiteExecution(problems=("rust suites must configure a focused Cargo filter",)) + inventory = run_rust_tests( + repo_root / suite.cargo_manifest, suite.cargo_package, suite.cargo_filter, collect_only=True + ) + rust = run_rust_tests(repo_root / suite.cargo_manifest, suite.cargo_package, suite.cargo_filter) + return SuiteExecution( + problems=( + *(("native Rust tests did not all pass",) if set(inventory.tests) != set(rust.tests) else ()), + *((inventory.output,) if inventory.exit_code else ()), + *((rust.output,) if rust.exit_code else ()), + ) + ) diff --git a/tests/rust-python-harness/strategies/unit_tests_rust/test_runner.py b/tests/rust-python-harness/strategies/unit_tests_rust/test_runner.py new file mode 100644 index 00000000000..e151308699a --- /dev/null +++ b/tests/rust-python-harness/strategies/unit_tests_rust/test_runner.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import shutil +from collections.abc import Callable +from pathlib import Path +from typing import Final + +import pytest + +from ...shared.reporting.models import Coverage, HarnessCase, RunStatus +from ...shared.reporting.strategy import SuiteCaseSpec +from ...shared.unit_runners.suite_runner import run_suites +from .runner import RustSuite, run_suite + + +@pytest.mark.skipif(shutil.which("cargo") is None, reason="Cargo is required for the native unit strategy") +def test_runs_cargo_tests_and_propagates_ignored_or_failing_tests( + tmp_path: Path, + cargo_project: Callable[[str, str], Path], +) -> None: + cargo_project("rust-unit-check", '#[test] fn test_decode() { assert_eq!("42".parse::().unwrap(), 42); }\n') + rust_root: Final = tmp_path / "litellm-rust" + rust_root.mkdir() + (tmp_path / "Cargo.toml").rename(rust_root / "Cargo.toml") + (tmp_path / "src").rename(rust_root / "src") + suite: Final = RustSuite( + cargo_manifest="litellm-rust/Cargo.toml", + cargo_filter="test_decode", + ) + case: Final = HarnessCase( + strategy_id="unit_tests_rust", + strategy_label="Unit test Rust", + sdk_function="ocr", + spec=SuiteCaseSpec(coverage=Coverage.COMPLETE, suite="ocr"), + ) + + code, report = run_suites((case,), tmp_path, lambda _: None, suites={"ocr": suite}, execute=run_suite) + + assert code == 0, report.failures + assert report.results[case.key].status is RunStatus.PASSED + + (rust_root / "src/lib.rs").write_text("#[test] #[ignore] fn test_decode() {}\n") + ignored_code, ignored_report = run_suites( + (case,), tmp_path, lambda _: None, suites={"ocr": suite}, execute=run_suite + ) + + assert ignored_code == 1 + assert any("native Rust tests did not all pass" in detail for _, detail in ignored_report.failures) + + (rust_root / "src/lib.rs").write_text("#[test] fn test_decode() { assert_eq!(2 + 2, 5); }\n") + failed_code, failed_report = run_suites((case,), tmp_path, lambda _: None, suites={"ocr": suite}, execute=run_suite) + + assert failed_code == 1 + assert failed_report.results[case.key].status is RunStatus.FAILED + assert any("test_decode" in detail for _, detail in failed_report.failures) diff --git a/tests/sdk_function_trace/README.md b/tests/sdk_function_trace/README.md deleted file mode 100644 index d3a3b654aea..00000000000 --- a/tests/sdk_function_trace/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# SDK function tracing - -The compare runner executes the same SDK calls through the Python engine and the Rust native bridge against a local HTTP provider fixture, then prints their pipeline trees side by side. Matching calls align on the same row in green; Python-only calls are blue, Rust-only calls yellow, and reordered calls red. Gaps preserve execution order and each column retains its own nesting. A comparison column labels every row even without color. Colors are enabled in terminals unless `NO_COLOR` is set. A difference summary follows (shared step order, python-only steps, rust-only steps). Each invocation must issue exactly one HTTP request. It requires the LiteLLM Python dependencies and the native extension built with tracing support - -From the repository root, using the project's Python environment: - -```bash -uv run python -m tests.sdk_function_trace.compare -uv run python -m tests.sdk_function_trace.compare --route ocr -uv run python -m tests.sdk_function_trace.compare --route ocr --sync -uv run python -m tests.sdk_function_trace.compare --route all --both --check -``` - -Calls default to async; use `--sync` for synchronous calls or `--both` for the complete matrix. Python sync Messages raises `not implemented for sync calls`; only that exact failure is marked `SKIP`, and the runner still executes Rust sync Messages and subsequent routes. Bedrock transcription has no independent Python provider implementation: its Python trace covers SDK dispatch into Rust - -Both engines are projected onto a shared per-route step table (`steps.py`): canonical names such as `transform_ocr_request` map Python functions (`MistralOCRConfig.transform_ocr_request`) and Rust spans (`transform_ocr_request`) to the same label. Only the first occurrence of each step is kept. Python indentation uses each event's actual frame ancestors and the nearest already displayed ancestor, so returned helpers and coroutine resumptions do not create false parents. Rust indentation uses instrumented span ancestry. Unmatched Rust span names pass through unchanged. `--full` prints every captured runtime event; validation still uses projected steps - -Every report checks required stage presence and dependency order. Provider lookup must precede request transformation, which must precede HTTP, followed by response transformation. The handler must precede HTTP; parameter mapping and supported-parameter checks must precede request transformation. Environment validation and URL construction, where mapped, must precede HTTP. Python transcription is checked only through native dispatch. `--check` also requires identical canonical step sequences for comparable routes and exits nonzero for missing, extra, or reordered steps, or an unexpected call failure, after finishing all selected cases - -Individual stage checks are separate from cross-language `step parity`. Passing stage checks cannot override a failing step comparison. Bedrock transcription and Python sync Messages report `UNAVAILABLE` for cross-language parity because they lack an independent Python execution to compare. Absolute nesting depth is not a cross-language gate: async Python Messages dispatches its handler onto another thread. See `route-comparison.md` for the audited matrix and remaining contract limitations - -The Python runner uses the existing `profile_python` / `sys.setprofile` collector, selecting executed code under the installed `litellm` source directory instead of maintaining a function-name allowlist. It prints source locations and qualified function names, including repeated calls. Coroutine resumptions are counted once per invocation. It profiles the current thread and threads created during the call, including the fresh async executor. Existing worker threads are not retroactively profiled; background Python calls may appear, and indentation follows selected Python stack ancestors within each thread - -The Rust runner calls the compiled PyO3 SDK entrypoints with `trace=True`. The existing `FunctionTrace` subscriber collects `#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]` spans for the route entrypoint, preparation, provider lookup, HTTP handler, and selected provider transformations. The shared `http_request` helper instruments the existing Rust send operation without changing clients, timeouts, signing, or error mapping. Function names come from the actual functions. `WithSubscriber` attaches the collector to each future across async polls. Arguments and provider payloads are not recorded in trace events. Uninstrumented functions do not appear; this is scoped instrumentation, not an exhaustive native call graph - -Tracing is opt-in: native calls without `trace=True` keep their original response shape. Traced calls return `{"response": ..., "trace": [{"function": ..., "depth": ...}]}`. The runners print only trace events. Missing native support or empty traces fail instead of falling back to source searching. The old `--repo`, `--signatures`, and `--calls` options are removed - -`profile_python(functions)` still supports direct function references for focused parity checks. `assert_function_trace_parity` compares selected Python events with Rust events supplied by an executable scenario. Successful stage checks prove the declared pipeline ran in a valid dependency order for this fixture; they do not assert identical function contracts, request bodies, responses, streaming behavior, or live-provider correctness - -Build the extension with `maturin develop` in the project's virtual environment. Then run either command above to get the executed function order diff --git a/tests/sdk_function_trace/__init__.py b/tests/sdk_function_trace/__init__.py deleted file mode 100644 index da62b8041f6..00000000000 --- a/tests/sdk_function_trace/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from tests.sdk_function_trace.harness import ( - TraceScenario, - TraceStep, - assert_function_trace_parity, -) -from tests.sdk_function_trace.profiler import FunctionTraceEvent - -__all__ = [ - "FunctionTraceEvent", - "TraceScenario", - "TraceStep", - "assert_function_trace_parity", -] diff --git a/tests/sdk_function_trace/compare.py b/tests/sdk_function_trace/compare.py deleted file mode 100644 index 941c1b6e067..00000000000 --- a/tests/sdk_function_trace/compare.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import sys -from typing import Final - -from tests.sdk_function_trace.fixtures import ROUTES -from tests.sdk_function_trace.report import compare, render - - -def _run(route: str, asynchronous: bool, *, full: bool, colorize: bool) -> bool: - comparison: Final = compare(route, asynchronous=asynchronous) - sys.stdout.write(render(comparison, full=full, colorize=colorize)) - return comparison.passed - - -def main() -> None: - parser: Final = argparse.ArgumentParser(description="Compare Python and Rust SDK pipeline steps per route") - parser.add_argument("--route", choices=("all", *ROUTES), default="all") - mode: Final = parser.add_mutually_exclusive_group() - mode.add_argument("--async", dest="asynchronous", action="store_true", default=True) - mode.add_argument("--sync", dest="asynchronous", action="store_false") - mode.add_argument("--both", action="store_true", help="run async and sync for every selected route") - parser.add_argument( - "--check", action="store_true", help="exit nonzero for missing, extra, or reordered comparable steps" - ) - parser.add_argument( - "--full", action="store_true", help="print every captured runtime event instead of pipeline steps" - ) - args: Final = parser.parse_args() - os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True") - colorize: Final = sys.stdout.isatty() and "NO_COLOR" not in os.environ - results: Final = tuple( - _run(selected, selected_mode, full=args.full, colorize=colorize) - for selected in ROUTES - if args.route in ("all", selected) - for selected_mode in ((True, False) if args.both else (args.asynchronous,)) - ) - if args.check and not all(results): - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/tests/sdk_function_trace/fixtures.py b/tests/sdk_function_trace/fixtures.py deleted file mode 100644 index 47bbe839627..00000000000 --- a/tests/sdk_function_trace/fixtures.py +++ /dev/null @@ -1,200 +0,0 @@ -from __future__ import annotations - -import base64 -import io -import json -import wave -from collections.abc import Callable -from dataclasses import dataclass -from typing import Final, Protocol, cast - -from tests.sdk_function_trace.mock_provider import MockProviderResponse -from tests.sdk_function_trace.steps import Engine - -ANTHROPIC_MODEL: Final = "claude-sonnet-5" -OCR_MODEL: Final = "mistral-ocr-latest" -AUDIO_MODEL: Final = "mistral.voxtral-mini-3b-2507" - - -class SdkCall(Protocol): - def __call__(self, **kwargs: object) -> object: ... - - -@dataclass(frozen=True, slots=True) -class Fixture: - kwargs: dict[str, object] - provider_response: MockProviderResponse - - -@dataclass(frozen=True, slots=True) -class RouteSpec: - label: str - python_entrypoints: tuple[str, str] - rust_entrypoints: tuple[str, str] - fixture: Callable[[Engine], Fixture] - - -@dataclass(frozen=True, slots=True) -class Invocation: - function: SdkCall - kwargs: dict[str, object] - provider_response: MockProviderResponse - label: str - - -def audio_bytes() -> bytes: - with io.BytesIO() as buffer: - with wave.open(buffer, "wb") as audio: - audio.setnchannels(1) - audio.setsampwidth(2) - audio.setframerate(16000) - audio.writeframes(b"\x00\x00" * 1600) - return buffer.getvalue() - - -def _anthropic_message_response() -> MockProviderResponse: - body: Final = { - "id": "msg_trace", - "type": "message", - "role": "assistant", - "model": ANTHROPIC_MODEL, - "content": [{"type": "text", "text": "hello"}], - "stop_reason": "end_turn", - "stop_sequence": None, - "usage": {"input_tokens": 2, "output_tokens": 3}, - } - return MockProviderResponse(200, (("content-type", "application/json"),), json.dumps(body).encode()) - - -def _conversation() -> dict[str, object]: - return {"messages": [{"role": "user", "content": "hello"}], "max_tokens": 16} - - -def _ocr_fixture(engine: Engine) -> Fixture: - return Fixture( - kwargs={ - "model": f"mistral/{OCR_MODEL}", - "document": {"type": "document_url", "document_url": "https://example.com/document.pdf"}, - **({"optional_params": {"pages": [0]}} if engine == "rust" else {"pages": [0]}), - }, - provider_response=MockProviderResponse( - 200, - (("content-type", "application/json"),), - json.dumps( - { - "pages": [{"index": 0, "markdown": "hello"}], - "model": OCR_MODEL, - "usage_info": {"pages_processed": 1}, - } - ).encode(), - ), - ) - - -def _chat_completions_fixture(engine: Engine) -> Fixture: - conversation: Final = _conversation() - payload: Final = ( - {"messages": conversation["messages"], "optional_params": {"max_tokens": 16}} - if engine == "rust" - else conversation - ) - return Fixture( - kwargs={"model": f"anthropic/{ANTHROPIC_MODEL}", **payload}, - provider_response=_anthropic_message_response(), - ) - - -def _messages_fixture(engine: Engine) -> Fixture: - conversation: Final = _conversation() - payload: Final = {"body": {**conversation, "model": ANTHROPIC_MODEL}} if engine == "rust" else conversation - return Fixture( - kwargs={"model": f"anthropic/{ANTHROPIC_MODEL}", **payload}, - provider_response=_anthropic_message_response(), - ) - - -def _transcription_fixture(engine: Engine) -> Fixture: - credentials: Final = { - "aws_access_key_id": "test-access", - "aws_secret_access_key": "test-secret", - "aws_region_name": "us-east-1", - } - payload: Final = ( - { - "audio": {"data": base64.b64encode(audio_bytes()).decode(), "format": "wav"}, - "optional_params": credentials, - } - if engine == "rust" - else {"file": ("sample.wav", audio_bytes(), "audio/wav"), **credentials} - ) - return Fixture( - kwargs={"model": f"bedrock/{AUDIO_MODEL}", **payload}, - provider_response=MockProviderResponse( - 200, - (("content-type", "application/json"),), - json.dumps( - { - "output": {"message": {"role": "assistant", "content": [{"text": "hello"}]}}, - "stopReason": "end_turn", - "usage": {"inputTokens": 2, "outputTokens": 3, "totalTokens": 5}, - } - ).encode(), - ), - ) - - -ROUTE_SPECS: Final[dict[str, RouteSpec]] = { - "chat_completions": RouteSpec( - label="anthropic", - python_entrypoints=("completion", "acompletion"), - rust_entrypoints=("chat_completions", "achat_completions"), - fixture=_chat_completions_fixture, - ), - "audio_transcription": RouteSpec( - label="bedrock (Rust-only provider; Python trace covers SDK dispatch)", - python_entrypoints=("transcription", "atranscription"), - rust_entrypoints=("transcription", "atranscription"), - fixture=_transcription_fixture, - ), - "messages": RouteSpec( - label="anthropic", - python_entrypoints=("create", "acreate"), - rust_entrypoints=("messages", "amessages"), - fixture=_messages_fixture, - ), - "ocr": RouteSpec( - label="mistral", - python_entrypoints=("ocr", "aocr"), - rust_entrypoints=("ocr", "aocr"), - fixture=_ocr_fixture, - ), -} - -ROUTES: Final = tuple(ROUTE_SPECS) - - -def sdk_invocation(route: str, *, engine: Engine, asynchronous: bool) -> Invocation: - import litellm - from litellm.anthropic_interface import messages as sdk_messages - from litellm.rust_bridge import get_native_bridge - - rust: Final = engine == "rust" - bridge: Final = get_native_bridge() if rust else None - if rust and bridge is None: - raise RuntimeError("Build the native extension first: maturin develop") - spec: Final = ROUTE_SPECS.get(route) - if spec is None: - raise ValueError(f"Unknown route: {route}") - fixture: Final = spec.fixture(engine) - owner: Final = bridge if rust else (sdk_messages if route == "messages" else litellm) - entrypoint: Final = (spec.rust_entrypoints if rust else spec.python_entrypoints)[int(asynchronous)] - return Invocation( - function=cast(SdkCall, getattr(owner, entrypoint)), - kwargs={ - **fixture.kwargs, - "api_key": "test-key", - **({"trace": True, "timeout_seconds": 5} if rust else {"timeout": 5}), - }, - provider_response=fixture.provider_response, - label=spec.label, - ) diff --git a/tests/sdk_function_trace/harness.py b/tests/sdk_function_trace/harness.py deleted file mode 100644 index 8f707402449..00000000000 --- a/tests/sdk_function_trace/harness.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Sequence -from dataclasses import dataclass -from types import FunctionType -from typing import Final, cast - -from tests.sdk_function_trace.profiler import FunctionTraceEvent, profile_python - - -@dataclass(frozen=True, slots=True) -class TraceStep: - function: FunctionType - depth: int - - -@dataclass(frozen=True, slots=True) -class TraceScenario: - steps: tuple[TraceStep, ...] - invoke_python: Callable[[], object] - invoke_rust: Callable[[], Sequence[FunctionTraceEvent]] - - -def assert_function_trace_parity(scenario: TraceScenario) -> None: - expected: Final = tuple( - FunctionTraceEvent(function=step.function.__name__, depth=step.depth) for step in scenario.steps - ) - functions: Final = cast(tuple[FunctionType, ...], tuple(step.function for step in scenario.steps)) - with profile_python(functions) as profiler: - scenario.invoke_python() - python_trace: Final = tuple(profiler.events) - rust_trace: Final = tuple(scenario.invoke_rust()) - - if python_trace != expected: - raise AssertionError(f"Python function trace differs: {python_trace!r} != {expected!r}") - if rust_trace != expected: - raise AssertionError(f"Rust function trace differs: {rust_trace!r} != {expected!r}") - if python_trace != rust_trace: - raise AssertionError(f"Python and Rust function traces differ: {python_trace!r} != {rust_trace!r}") diff --git a/tests/sdk_function_trace/mock_provider.py b/tests/sdk_function_trace/mock_provider.py deleted file mode 100644 index 37eca665586..00000000000 --- a/tests/sdk_function_trace/mock_provider.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from collections.abc import Generator -from contextlib import contextmanager -from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from threading import Lock, Thread -from typing import Final, cast - - -@dataclass(frozen=True, slots=True) -class MockProviderResponse: - status_code: int - headers: tuple[tuple[str, str], ...] - body: bytes - - -class _MockProviderServer(ThreadingHTTPServer): - def __init__(self, response: MockProviderResponse) -> None: - super().__init__(("127.0.0.1", 0), _MockProviderHandler) - self.response: Final = response - self._request_count = 0 - self._request_count_lock: Final = Lock() - - def record_request(self) -> None: - with self._request_count_lock: - self._request_count += 1 - - @property - def request_count(self) -> int: - with self._request_count_lock: - return self._request_count - - -class _MockProviderHandler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - - def do_POST(self) -> None: - content_length: Final = int(self.headers.get("content-length", "0")) - self.rfile.read(content_length) - server: Final = cast(_MockProviderServer, self.server) - server.record_request() - self.send_response(server.response.status_code) - for name, value in server.response.headers: - self.send_header(name, value) - self.send_header("content-length", str(len(server.response.body))) - self.end_headers() - self.wfile.write(server.response.body) - - def log_message(self, format: str, *args: object) -> None: # noqa: A002 # matches BaseHTTPRequestHandler - pass - - -@contextmanager -def mock_provider(response: MockProviderResponse) -> Generator[str]: - server: Final = _MockProviderServer(response) - thread: Final = Thread(target=server.serve_forever, daemon=True) - thread.start() - host, port = cast(tuple[str, int], server.server_address) - try: - yield f"http://{host}:{port}" - finally: - server.shutdown() - server.server_close() - thread.join() - if server.request_count != 1: - raise AssertionError(f"expected one provider request, received {server.request_count}") diff --git a/tests/sdk_function_trace/ocr-comparison.md b/tests/sdk_function_trace/ocr-comparison.md deleted file mode 100644 index d252480e218..00000000000 --- a/tests/sdk_function_trace/ocr-comparison.md +++ /dev/null @@ -1,59 +0,0 @@ -# OCR Python and Rust comparison - -Audited implementation revision: `edcba483b2`. The implementations do not match in function contracts, call structure, or all tested response behavior. This audit changes the source listing coverage, not OCR runtime behavior - -Run both source listings from the repository root: - -```bash -python3 tests/sdk_function_trace/list_python_steps.py --route ocr --signatures --calls -uv run tests/sdk_function_trace/list_rust_steps.py --route ocr --signatures --calls -``` - -Both cover Mistral, Azure AI Mistral, Azure Document Intelligence, Vertex Mistral, and Vertex DeepSeek. Listings show declarations and source call sites, not executed traces - -## Function contracts - -Comparing Python `BaseOCRConfig` with Rust `OcrProviderConfig`, omitting `self` and language-specific ownership details: - -| Python | Rust | Difference | -| --- | --- | --- | -| `get_supported_ocr_params(model)` | `supported_ocr_params()` | Name and model argument | -| `get_api_key_env_var()` | No corresponding method | Missing contract | -| `map_ocr_params(non_default_params, optional_params, model)` | `map_ocr_params(non_default_params)` | Missing accumulator and model | -| `validate_environment(headers, model, api_key, api_base, litellm_params, **kwargs)` | Separate auth/key/header helpers | Different contract | -| `get_complete_url(api_base, model, optional_params, litellm_params, **kwargs)` | `complete_url(api_base, model, optional_params, env_lookup)` | Name and context | -| `transform_ocr_request(model, document, optional_params, headers, **kwargs)` | `transform_ocr_request(model, document, optional_params)` | Missing headers and extra context | -| `async_transform_ocr_request(...)` | No corresponding method | Missing async override | -| `transform_ocr_response(model, raw_response, logging_obj, **kwargs)` | `transform_ocr_response(model, response_json)` | Missing HTTP metadata, logging and extra context | -| `async_transform_ocr_response(...)` | No corresponding method | Missing async override | -| `get_error_class(error_message, status_code, headers)` | Central Rust error mapping | Different contract | - -Python's default mapper returns the supplied `optional_params`; Rust's filters `non_default_params`. Provider overrides must also be compared - -Python maps parameters during SDK preparation, before HTTP-handler environment validation and URL construction. Rust resolves auth and URL before mapping parameters in `prepare_provider_request`. Python has async provider transforms; both native entrypoints execute the same Rust async route using synchronous transform hooks, with polling and document downloading in gateway helpers - -The native bindings also accept `optional_params` and `timeout_seconds`, while the Python SDK accepts `**kwargs` and `timeout`. Public SDK calls with Rust enabled still execute Python preparation before entering Rust, so matching SDK responses would not prove matching standalone Rust steps - -## Runtime results - -Built the native extension from the audited source using `cargo build -p litellm-python-bridge --features extension-module --offline`. Supplied that build's functions through `use_litellm_rust` dependency injection. Ran public `litellm.ocr` and `litellm.aocr` with Rust disabled and enabled against identical local HTTP response fixtures, requiring one request per invocation - -Successful `model_dump()` results and failure exception classes were compared. These checks cover Mistral response outcomes only, not request equality, error messages, live providers, or every execution branch - -| Mistral response fixture | Sync | Async | Observation | -| --- | --- | --- | --- | -| Valid page/model/usage | Match | Match | Same normalized response | -| Model omitted | Match | Match | Both use the requested model | -| `model: null` | Different | Different | Python rejects; Rust uses the requested model | -| `pages: null` | Different | Different | Python rejects; Rust returns an empty array | -| Invalid page element | Match | Match | Both reject during response validation | - -Six of ten fixture/mode comparisons match, four differ. Rust's Mistral response transform conflates missing values with explicit nulls through `as_array`/`as_str` fallbacks. Python preserves explicit nulls into response validation, which rejects them - -## Other provider gaps found in source - -Azure Document Intelligence's Python configuration supports `pages`, `features`, and `req_format`; Rust lists only `pages`. Python normalizes parameters before URL construction; Rust normalizes pages during URL construction - -Python preserves Azure `content`, `tables`, and `keyValuePairs`, and supports retaining the native operation payload. Rust's `OcrResponseData` has no corresponding fields, and its Azure transform does not preserve those values - -Azure and Vertex async document transforms and Azure polling also use different helper contracts. Their runtime equivalence was not tested in this audit diff --git a/tests/sdk_function_trace/profiler.py b/tests/sdk_function_trace/profiler.py deleted file mode 100644 index c71c74ab0d3..00000000000 --- a/tests/sdk_function_trace/profiler.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -import sys -import threading -from collections.abc import Generator, Sequence -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path -from types import CodeType, FrameType, FunctionType -from typing import Final - - -@dataclass(frozen=True, slots=True) -class FunctionTraceEvent: - function: str - depth: int - ancestors: tuple[str, ...] | None = None - - -class PythonProfiler: - def __init__(self, functions: Sequence[FunctionType], source_root: Path | None = None) -> None: - self._source_root: Final = str(source_root.resolve()) + "/" if source_root is not None else None - self._names_by_code: Final = {function.__code__: function.__name__ for function in functions} - self._seen_frames: Final[set[FrameType]] = set() - self.events: Final[list[FunctionTraceEvent]] = [] - - def __call__(self, frame: FrameType, event: str, _arg: object) -> None: - if event != "call" or frame in self._seen_frames: - return - function_name: Final = self.function_name(frame.f_code) - if function_name is None: - return - ancestors: Final = tuple( - name for ancestor in _frame_ancestors(frame) if (name := self.function_name(ancestor.f_code)) is not None - ) - self._seen_frames.add(frame) - self.events.append( - FunctionTraceEvent( - function=function_name, - depth=len(ancestors), - ancestors=ancestors if self._source_root is not None else None, - ) - ) - - def function_name(self, code: CodeType) -> str | None: - if self._source_root is None: - return self._names_by_code.get(code) - if not code.co_filename.startswith(self._source_root): - return None - relative: Final = code.co_filename.removeprefix(self._source_root) - return f"{relative}:{code.co_firstlineno} {getattr(code, 'co_qualname', code.co_name)}" - - -def _frame_ancestors(frame: FrameType) -> Generator[FrameType]: - ancestor: Final = frame.f_back - if ancestor is not None: - yield ancestor - yield from _frame_ancestors(ancestor) - - -@contextmanager -def profile_python( - functions: Sequence[FunctionType] = (), *, source_root: Path | None = None, threads: bool = False -) -> Generator[PythonProfiler]: - profiler: Final = PythonProfiler(functions, source_root) - previous_thread: Final = threading.getprofile() - if threads: - threading.setprofile(profiler) - previous: Final = sys.getprofile() - sys.setprofile(profiler) - try: - yield profiler - finally: - sys.setprofile(previous) - if threads: - threading.setprofile(previous_thread) diff --git a/tests/sdk_function_trace/report.py b/tests/sdk_function_trace/report.py deleted file mode 100644 index 9b654e571f8..00000000000 --- a/tests/sdk_function_trace/report.py +++ /dev/null @@ -1,175 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Final - -from tests.sdk_function_trace.fixtures import ROUTE_SPECS -from tests.sdk_function_trace.profiler import FunctionTraceEvent -from tests.sdk_function_trace.runtime import ( - TraceDiff, - TraceFailed, - TraceOk, - TraceRun, - TraceSkipped, - attempt_trace, - trace_diff, -) -from tests.sdk_function_trace.steps import Engine, pipeline_issues, pipeline_steps -from tests.sdk_function_trace.table import format_trace_table - -_PYTHON_ONLY_COLOR: Final = "\033[34m" -_RUST_ONLY_COLOR: Final = "\033[33m" -_RESET: Final = "\033[0m" - -_ENGINE_COLOR: Final[dict[Engine, str]] = {"python": _PYTHON_ONLY_COLOR, "rust": _RUST_ONLY_COLOR} - - -@dataclass(frozen=True, slots=True) -class EngineReport: - engine: Engine - run: TraceRun - events: tuple[FunctionTraceEvent, ...] - steps: tuple[FunctionTraceEvent, ...] - issues: tuple[str, ...] - - -@dataclass(frozen=True, slots=True) -class Comparison: - route: str - label: str - asynchronous: bool - engines: tuple[EngineReport, ...] - diff: TraceDiff - - @property - def comparable(self) -> bool: - return self.route != "audio_transcription" and all(isinstance(report.run, TraceOk) for report in self.engines) - - @property - def passed(self) -> bool: - return ( - (not self.comparable or self.diff.matches) - and not any(report.issues for report in self.engines) - and all(not isinstance(report.run, TraceFailed) for report in self.engines) - ) - - -def _events(run: TraceRun) -> tuple[FunctionTraceEvent, ...]: - match run: - case TraceOk(events=events): - return events - case TraceSkipped() | TraceFailed(): - return () - - -def _engine_report(route: str, engine: Engine, run: TraceRun) -> EngineReport: - events: Final = _events(run) - steps: Final = pipeline_steps(route, engine, events) - issues: Final = pipeline_issues(route, engine, steps) if isinstance(run, TraceOk) else () - return EngineReport(engine=engine, run=run, events=events, steps=steps, issues=issues) - - -def compare(route: str, *, asynchronous: bool) -> Comparison: - runs: Final = { - engine: attempt_trace(route, engine=engine, asynchronous=asynchronous) for engine in ("python", "rust") - } - engines: Final = tuple(_engine_report(route, engine, run) for engine, run in runs.items()) - return Comparison( - route=route, - label=ROUTE_SPECS[route].label, - asynchronous=asynchronous, - engines=engines, - diff=trace_diff(engines[0].steps, engines[1].steps), - ) - - -def _tree_line(event: FunctionTraceEvent, only: frozenset[str], marker: str, color: str, *, colorize: bool) -> str: - line: Final = f"{' ' * event.depth}{event.function}" + (f" {marker}" if event.function in only else "") - return f"{color}{line}{_RESET}\n" if colorize and event.function in only else f"{line}\n" - - -def _tree_lines( - events: tuple[FunctionTraceEvent, ...], - only: frozenset[str], - marker: str, - color: str, - *, - colorize: bool, -) -> tuple[str, ...]: - return tuple(_tree_line(event, only, marker, color, colorize=colorize) for event in events) - - -def _engine_lines( - report: EngineReport, diff: TraceDiff, *, comparable: bool, full: bool, colorize: bool -) -> tuple[str, ...]: - match report.run: - case TraceSkipped(reason=reason): - return (f"{report.engine}: SKIP ({reason})\n\n",) - case TraceFailed(reason=reason): - return (f"{report.engine}: FAIL ({reason})\n\n",) - case TraceOk(): - shown: Final = report.events if full else report.steps - only: Final = ( - () if full or not comparable else (diff.python_only if report.engine == "python" else diff.rust_only) - ) - return ( - f"{report.engine} ({len(shown)} steps)\n\n", - *_tree_lines( - shown, - frozenset(only), - f"<- {report.engine} only", - _ENGINE_COLOR[report.engine], - colorize=colorize, - ), - "\n", - ) - - -def _parity_lines(comparison: Comparison) -> tuple[str, ...]: - if not comparison.comparable: - if comparison.route == "audio_transcription": - return ("step parity: UNAVAILABLE (Bedrock transcription has no independent Python implementation)\n",) - return ("step parity: UNAVAILABLE (both engines must complete)\n",) - diff: Final = comparison.diff - order: Final = "the same" if diff.shared_order_matches else "a different" - return ( - "diff\n\n", - f"shared steps appear in {order} order\n", - f"python-only: {', '.join(diff.python_only) or 'none'}\n", - f"rust-only: {', '.join(diff.rust_only) or 'none'}\n\n", - f"step parity: {'PASS' if diff.matches else 'FAIL'}\n", - ) - - -def _stage_lines(comparison: Comparison) -> tuple[str, ...]: - return tuple( - f"{report.engine} " - f"{'SDK dispatch only' if comparison.route == 'audio_transcription' and report.engine == 'python' else 'pipeline'}: " - f"{'FAIL: ' + '; '.join(report.issues) if report.issues else 'PASS'}\n" - for report in comparison.engines - if isinstance(report.run, TraceOk) - ) - - -def render(comparison: Comparison, *, full: bool, colorize: bool) -> str: - mode: Final = "async" if comparison.asynchronous else "sync" - traces: Final = ( - (format_trace_table(comparison.engines[0].steps, comparison.engines[1].steps, colorize=colorize) + "\n\n",) - if not full and all(isinstance(report.run, TraceOk) for report in comparison.engines) - else tuple( - line - for report in comparison.engines - for line in _engine_lines( - report, comparison.diff, comparable=comparison.comparable, full=full, colorize=colorize - ) - ) - ) - return "".join( - ( - f"route: {comparison.route} provider: {comparison.label} mode: {mode}\n\n", - *traces, - *_parity_lines(comparison), - *_stage_lines(comparison), - "Each successful invocation issued exactly one local provider request\n\n", - ) - ) diff --git a/tests/sdk_function_trace/route-comparison.md b/tests/sdk_function_trace/route-comparison.md deleted file mode 100644 index 009d3544d05..00000000000 --- a/tests/sdk_function_trace/route-comparison.md +++ /dev/null @@ -1,26 +0,0 @@ -# SDK route trace audit - -Run the four native HTTP route families in both modes from the repository root: - -```bash -uv run python -m tests.sdk_function_trace.compare --route all --both --check -``` - -The local fixture matrix on 2026-09-02 completed 15 successful engine invocations and one expected skip. Every successful invocation issued exactly one local HTTP request. All five comparable route/mode pairs have identical canonical steps in the same order, with no Python-only or Rust-only steps - -| Route | Python async | Python sync | Rust async | Rust sync | -| --- | --- | --- | --- | --- | -| Chat completions, Anthropic | Pass | Pass | Pass | Pass | -| Messages, Anthropic | Pass | Unsupported, skipped | Pass | Pass | -| OCR, Mistral | Pass | Pass | Pass | Pass | -| Audio transcription, Bedrock | Dispatch only | Dispatch only | Pass | Pass | - -The same canonical step sequence ran in sync and async for each engine with both modes available. Bedrock transcription's Python SDK delegates to Rust, so its two successful calls do not establish independent provider parity. Realtime and Responses WebSockets are outside this HTTP fixture runner - -Chat and OCR also have identical projected nesting in both modes. Async Messages has the same helper nesting beneath its handler, but Python starts that handler on a worker thread, so it appears as a second root. The comparison preserves this physical thread boundary and checks step order independently of absolute depth - -Rust now resolves chat providers and supported parameters before entering its handler. Chat and Messages validate the environment and transform requests inside their handlers. Messages builds the final URL after transformation. OCR resolves its config and maps supported parameters during preparation, then validates credentials, builds the URL, and transforms the request inside its handler. Its during-call guardrails still run before HTTP, within the provider-call lifecycle phase - -The environment hooks execute credential and header validation. Chat's supported-parameter hooks return OpenAI names paired with provider names and feed the existing request acceptance checks. The direct Rust API still accepts provider-mapped parameters, and its supported subset is smaller than Python's. Matching the pipeline does not establish identical parameter contracts - -`--check` now fails if either comparable engine has missing, extra, or reordered canonical steps, even if its individual stage checks pass. Bedrock transcription and sync Messages report `UNAVAILABLE` for cross-language parity; native execution is still checked. Passing establishes step coverage and order for one non-streaming fixture per route, not complete request, response, error, or provider parity. The previously recorded OCR response gaps remain in `ocr-comparison.md` diff --git a/tests/sdk_function_trace/runtime.py b/tests/sdk_function_trace/runtime.py deleted file mode 100644 index d5bf15694bc..00000000000 --- a/tests/sdk_function_trace/runtime.py +++ /dev/null @@ -1,128 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from collections.abc import Awaitable, Generator -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path -from typing import Final, cast -from unittest.mock import patch - -from pydantic import BaseModel, ConfigDict - -from tests.sdk_function_trace.fixtures import Invocation, sdk_invocation -from tests.sdk_function_trace.mock_provider import mock_provider -from tests.sdk_function_trace.profiler import FunctionTraceEvent, profile_python -from tests.sdk_function_trace.steps import Engine - - -class TraceEventPayload(BaseModel): - model_config = ConfigDict(strict=True, extra="forbid") - function: str - depth: int - - -class TraceResponsePayload(BaseModel): - model_config = ConfigDict(strict=True, extra="forbid") - response: object - trace: tuple[TraceEventPayload, ...] | list[TraceEventPayload] - - -@contextmanager -def _python_engine() -> Generator[None]: - from litellm.rust_bridge import ocr as ocr_bridge - - previous_ocr: Final = ocr_bridge.rust_ocr_enabled() - with patch.dict(os.environ, {"LITELLM_RUST": "false"}): - ocr_bridge.use_litellm_rust(False) - try: - yield - finally: - ocr_bridge.use_litellm_rust(previous_ocr) - - -def _invoke(case: Invocation, api_base: str, *, asynchronous: bool) -> object: - async def invoke_async() -> object: - return await cast("Awaitable[object]", case.function(**case.kwargs, api_base=api_base)) - - if asynchronous: - return asyncio.run(invoke_async()) - return case.function(**case.kwargs, api_base=api_base) - - -def collect(case: Invocation, api_base: str, *, engine: Engine, asynchronous: bool) -> tuple[FunctionTraceEvent, ...]: - import litellm - - if engine == "rust": - payload: Final = TraceResponsePayload.model_validate(_invoke(case, api_base, asynchronous=asynchronous)) - return tuple(FunctionTraceEvent(event.function, event.depth) for event in payload.trace) - with profile_python(source_root=Path(litellm.__file__).parent, threads=True) as profiler: - _invoke(case, api_base, asynchronous=asynchronous) - return tuple(profiler.events) - - -def run_trace(route: str, *, engine: Engine, asynchronous: bool = False) -> tuple[FunctionTraceEvent, ...]: - case: Final = sdk_invocation(route, engine=engine, asynchronous=asynchronous) - with _python_engine(), mock_provider(case.provider_response) as api_base: - events: Final = collect(case, api_base, engine=engine, asynchronous=asynchronous) - if not events: - raise RuntimeError(f"No runtime events for {route}; rebuild the native extension with tracing support") - return events - - -@dataclass(frozen=True, slots=True) -class TraceOk: - events: tuple[FunctionTraceEvent, ...] - - -@dataclass(frozen=True, slots=True) -class TraceSkipped: - reason: str - - -@dataclass(frozen=True, slots=True) -class TraceFailed: - reason: str - - -TraceRun = TraceOk | TraceSkipped | TraceFailed - - -def attempt_trace(route: str, *, engine: Engine, asynchronous: bool) -> TraceRun: - try: - return TraceOk(run_trace(route, engine=engine, asynchronous=asynchronous)) - except Exception as error: - reason: Final = f"{type(error).__name__}: {error}" - if ( - route == "messages" - and engine == "python" - and not asynchronous - and isinstance(error, ValueError) - and str(error) == "anthropic_messages_handler is not implemented for sync calls" - ): - return TraceSkipped(reason) - return TraceFailed(reason) - - -@dataclass(frozen=True, slots=True) -class TraceDiff: - python_only: tuple[str, ...] - rust_only: tuple[str, ...] - shared_order_matches: bool - - @property - def matches(self) -> bool: - return not self.python_only and not self.rust_only and self.shared_order_matches - - -def trace_diff(python: tuple[FunctionTraceEvent, ...], rust: tuple[FunctionTraceEvent, ...]) -> TraceDiff: - python_names: Final = {event.function for event in python} - rust_names: Final = {event.function for event in rust} - shared_python: Final = tuple(event.function for event in python if event.function in rust_names) - shared_rust: Final = tuple(event.function for event in rust if event.function in python_names) - return TraceDiff( - python_only=tuple(event.function for event in python if event.function not in rust_names), - rust_only=tuple(event.function for event in rust if event.function not in python_names), - shared_order_matches=bool(shared_python) and shared_python == shared_rust, - ) diff --git a/tests/sdk_function_trace/steps.py b/tests/sdk_function_trace/steps.py deleted file mode 100644 index bb50d4ebe57..00000000000 --- a/tests/sdk_function_trace/steps.py +++ /dev/null @@ -1,181 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Sequence -from dataclasses import dataclass -from functools import reduce -from typing import Final, Literal - -from tests.sdk_function_trace.profiler import FunctionTraceEvent - -Engine = Literal["python", "rust"] - - -@dataclass(frozen=True, slots=True) -class Step: - name: str - python: re.Pattern[str] | None - rust: str | None - - -def _step(name: str, python: str | None = None, rust: str | None = None) -> Step: - return Step(name, re.compile(python) if python is not None else None, rust) - - -_POST: Final = r"AsyncHTTPHandler\.post$|HTTPHandler\.post$" - -STEPS: Final[dict[str, tuple[Step, ...]]] = { - "ocr": ( - _step("ocr", r"ocr/main\.py:\d+ a?ocr$", "ocr"), - _step("prepare_ocr_call", r"ocr/main\.py:\d+ _prepare_ocr_request$", "prepare_ocr_call"), - _step("get_provider_ocr_config", r"ProviderConfigManager\.get_provider_ocr_config$", "ocr_provider_config"), - _step("supported_ocr_params", r"get_supported_ocr_params$", "supported_ocr_params"), - _step("map_ocr_params", r"(? tuple[str, ...]: - names: Final = tuple(event.function for event in events) - required: Final = tuple(step.name for step in STEPS[route] if getattr(step, engine) is not None) - missing: Final = tuple(f"missing {name}" for name in required if name not in names) - provider: Final = next(name for name in required if name.startswith("get_provider_")) - handler: Final = next(name for name in required if name.startswith("execute_")) - dispatch_only: Final = route == "audio_transcription" and engine == "python" - request: Final = next( - (name for name in required if name.startswith("transform_") and name.endswith("request")), handler - ) - response: Final = next( - (name for name in required if name.startswith("transform_") and name.endswith("response")), handler - ) - phases: Final = ( - (route, "map_transcription_params", provider, handler) - if dispatch_only - else (route, provider, request, "http_request", response) - ) - extra_edges: Final = ( - () - if dispatch_only - else ( - (handler, "http_request"), - *((name, request) for name in required if name.startswith(("map_", "supported_"))), - *((name, "http_request") for name in ("validate_environment", "complete_url") if name in required), - ) - ) - edges: Final = (*zip(phases, phases[1:]), *extra_edges) - return missing + tuple( - f"{before} must precede {after}" - for before, after in edges - if before in names and after in names and names.index(before) >= names.index(after) - ) - - -def _canonical_name(route: str, engine: Engine, function: str) -> str | None: - for step in STEPS[route]: - if engine == "python": - if step.python is not None and step.python.search(function): - return step.name - elif step.rust is not None and function == step.rust: - return step.name - return function if engine == "rust" else None - - -@dataclass(frozen=True, slots=True) -class _Projection: - shown: tuple[FunctionTraceEvent, ...] = () - stack: tuple[tuple[int, int], ...] = () - seen: frozenset[str] = frozenset() - - -def _project(route: str, engine: Engine, state: _Projection, event: FunctionTraceEvent) -> _Projection: - stack: Final = tuple(pair for pair in state.stack if event.depth > pair[0]) - name: Final = _canonical_name(route, engine, event.function) - if name is None or name in state.seen: - return _Projection(state.shown, stack, state.seen) - depth: Final = ( - next( - ( - kept.depth + 1 - for ancestor in event.ancestors - for kept in state.shown - if kept.function == _canonical_name(route, engine, ancestor) - ), - 0, - ) - if event.ancestors is not None - else stack[-1][1] + 1 - if stack - else 0 - ) - return _Projection( - state.shown + (FunctionTraceEvent(function=name, depth=depth),), - stack + ((event.depth, depth),), - state.seen | {name}, - ) - - -def pipeline_steps(route: str, engine: Engine, events: Sequence[FunctionTraceEvent]) -> tuple[FunctionTraceEvent, ...]: - projection: Final = reduce(lambda state, event: _project(route, engine, state, event), events, _Projection()) - return projection.shown diff --git a/tests/sdk_function_trace/table.py b/tests/sdk_function_trace/table.py deleted file mode 100644 index 2124d7e3faf..00000000000 --- a/tests/sdk_function_trace/table.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from difflib import SequenceMatcher -from typing import Final - -from tests.sdk_function_trace.profiler import FunctionTraceEvent - - -def _aligned_rows( - python: tuple[FunctionTraceEvent, ...], rust: tuple[FunctionTraceEvent, ...] -) -> Iterator[tuple[FunctionTraceEvent | None, FunctionTraceEvent | None]]: - matcher: Final = SequenceMatcher( - a=tuple(event.function for event in python), - b=tuple(event.function for event in rust), - autojunk=False, - ) - for tag, python_start, python_end, rust_start, rust_end in matcher.get_opcodes(): - if tag == "equal": - yield from zip(python[python_start:python_end], rust[rust_start:rust_end]) - else: - yield from ((event, None) for event in python[python_start:python_end]) - yield from ((None, event) for event in rust[rust_start:rust_end]) - - -def _label(event: FunctionTraceEvent | None) -> str: - return f"{' ' * event.depth}{event.function}" if event is not None else "" - - -def _status( - python: FunctionTraceEvent | None, - rust: FunctionTraceEvent | None, - python_names: frozenset[str], - rust_names: frozenset[str], -) -> tuple[str, str]: - if python is not None and rust is not None: - return "match", "\033[32m" - if python is not None: - return ("reordered", "\033[31m") if python.function in rust_names else ("python only", "\033[34m") - if rust is not None: - return ("reordered", "\033[31m") if rust.function in python_names else ("rust only", "\033[33m") - return "", "" - - -def format_trace_table( - python: tuple[FunctionTraceEvent, ...], - rust: tuple[FunctionTraceEvent, ...], - *, - colorize: bool, -) -> str: - python_header: Final = f"python ({len(python)} steps)" - rust_header: Final = f"rust ({len(rust)} steps)" - python_width: Final = max(len(python_header), *(len(_label(event)) for event in python), 0) - rust_width: Final = max(len(rust_header), *(len(_label(event)) for event in rust), 0) - python_names: Final = frozenset(event.function for event in python) - rust_names: Final = frozenset(event.function for event in rust) - border: Final = f"+-{'-' * python_width}-+-{'-' * rust_width}-+-------------+" - rows: Final = tuple( - f"{color}{line}\033[0m" if colorize else line - for left, right in _aligned_rows(python, rust) - for status, color in (_status(left, right, python_names, rust_names),) - for line in (f"| {_label(left):<{python_width}} | {_label(right):<{rust_width}} | {status:<11} |",) - ) - return "\n".join( - ( - border, - f"| {python_header:<{python_width}} | {rust_header:<{rust_width}} | {'comparison':<11} |", - border, - *rows, - border, - ) - ) diff --git a/tests/sdk_function_trace/test_mock_provider.py b/tests/sdk_function_trace/test_mock_provider.py deleted file mode 100644 index 88d7d5392d0..00000000000 --- a/tests/sdk_function_trace/test_mock_provider.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from contextlib import ExitStack -from typing import Final -from urllib.error import HTTPError -from urllib.request import Request, urlopen - -import pytest - -from tests.sdk_function_trace.mock_provider import MockProviderResponse, mock_provider - - -def test_mock_provider_preserves_error_response() -> None: - response: Final = MockProviderResponse(429, (("retry-after", "2"),), b'{"error":"rate limited"}') - with mock_provider(response) as api_base: - with pytest.raises(HTTPError) as error: - urlopen(Request(api_base, data=b"{}"), timeout=5) - with error.value as received: - assert received.code == 429 - assert received.headers["retry-after"] == "2" - assert received.read() == response.body - - -@pytest.mark.parametrize("request_count", [0, 2]) -def test_mock_provider_rejects_missing_or_duplicate_requests(request_count: int) -> None: - response: Final = MockProviderResponse(200, (), b"{}") - with ExitStack() as stack: - api_base: Final = stack.enter_context(mock_provider(response)) - for _ in range(request_count): - with urlopen(Request(api_base, data=b"{}"), timeout=5) as received: - assert received.read() == response.body - with pytest.raises(AssertionError, match=f"expected one provider request, received {request_count}"): - stack.close() diff --git a/tests/sdk_function_trace/test_profiler.py b/tests/sdk_function_trace/test_profiler.py deleted file mode 100644 index 10a266fb1e8..00000000000 --- a/tests/sdk_function_trace/test_profiler.py +++ /dev/null @@ -1,167 +0,0 @@ -from __future__ import annotations - -import asyncio -import sys -from pathlib import Path -from types import FunctionType -from typing import Final, cast - -import pytest - -from tests.sdk_function_trace import ( - FunctionTraceEvent, - TraceScenario, - TraceStep, - assert_function_trace_parity, -) -from tests.sdk_function_trace.profiler import profile_python - - -class First: - @staticmethod - def run() -> None: - return None - - -class Second: - @staticmethod - def run() -> None: - return None - - -def test_profiler_matches_code_objects_and_keeps_repeated_calls() -> None: - with profile_python((First.run,)) as profiler: - Second.run() - First.run() - First.run() - - assert profiler.events == [ - FunctionTraceEvent(function="run", depth=0), - FunctionTraceEvent(function="run", depth=0), - ] - - -def test_profiler_records_selected_function_nesting_depth() -> None: - class Nested: - @staticmethod - def run() -> None: - First.run() - - with profile_python((Nested.run, First.run)) as profiler: - Nested.run() - - assert profiler.events == [ - FunctionTraceEvent(function="run", depth=0), - FunctionTraceEvent(function="run", depth=1), - ] - - -def test_profiler_restores_previous_profiler_after_failure() -> None: - previous: Final = sys.getprofile() - - with profile_python((First.run,)) as outer: - with pytest.raises(RuntimeError, match="stop"): - with profile_python((Second.run,)): - raise RuntimeError("stop") - assert sys.getprofile() is outer - First.run() - - assert sys.getprofile() is previous - assert outer.events == [FunctionTraceEvent(function="run", depth=0)] - - -def test_profiler_does_not_count_coroutine_resumption_as_another_call() -> None: - async def suspended() -> None: - await asyncio.sleep(0) - First.run() - await asyncio.sleep(0) - - with profile_python((suspended, First.run)) as profiler: - asyncio.run(suspended()) - - assert profiler.events == [ - FunctionTraceEvent(function="suspended", depth=0), - FunctionTraceEvent(function="run", depth=1), - ] - - -def test_source_profiler_records_real_frame_ancestry() -> None: - def outer() -> None: - First.run() - - with profile_python(source_root=Path(__file__).parent) as profiler: - outer() - Second.run() - - outer_event, first_event, second_event = ( - event for event in profiler.events if event.function.startswith("test_profiler.py:") - ) - assert first_event.ancestors is not None - assert outer_event.function in first_event.ancestors - assert second_event.ancestors is not None - assert outer_event.function not in second_event.ancestors - - -@pytest.mark.parametrize( - "rust_trace", - [ - (), - (FunctionTraceEvent(function="renamed", depth=0),), - (FunctionTraceEvent(function="run", depth=1),), - (FunctionTraceEvent(function="run", depth=0),) * 2, - ], - ids=["missing", "renamed", "wrong-depth", "extra-call"], -) -def test_harness_rejects_rust_function_trace_drift(rust_trace: tuple[FunctionTraceEvent, ...]) -> None: - with pytest.raises(AssertionError, match="Rust function trace differs"): - assert_function_trace_parity( - TraceScenario( - steps=(TraceStep(cast(FunctionType, First.run), depth=0),), - invoke_python=First.run, - invoke_rust=lambda: rust_trace, - ) - ) - - -def test_harness_rejects_python_function_trace_drift() -> None: - with pytest.raises(AssertionError, match="Python function trace differs"): - assert_function_trace_parity( - TraceScenario( - steps=(TraceStep(cast(FunctionType, First.run), depth=0),), - invoke_python=Second.run, - invoke_rust=lambda: (FunctionTraceEvent(function="run", depth=0),), - ) - ) - - -def test_harness_accepts_matching_traces() -> None: - assert_function_trace_parity( - TraceScenario( - steps=(TraceStep(cast(FunctionType, First.run), depth=0),), - invoke_python=First.run, - invoke_rust=lambda: (FunctionTraceEvent(function="run", depth=0),), - ) - ) - - -def test_harness_rejects_reordered_calls() -> None: - def begin() -> None: - return None - - def finish() -> None: - return None - - with pytest.raises(AssertionError, match="Rust function trace differs"): - assert_function_trace_parity( - TraceScenario( - steps=( - TraceStep(cast(FunctionType, begin), depth=0), - TraceStep(cast(FunctionType, finish), depth=0), - ), - invoke_python=lambda: (begin(), finish()), - invoke_rust=lambda: ( - FunctionTraceEvent(function="finish", depth=0), - FunctionTraceEvent(function="begin", depth=0), - ), - ) - ) diff --git a/tests/sdk_function_trace/test_runtime.py b/tests/sdk_function_trace/test_runtime.py deleted file mode 100644 index 015cba55083..00000000000 --- a/tests/sdk_function_trace/test_runtime.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from typing import Final - -import pytest - -from tests.sdk_function_trace.runtime import ( - TraceFailed, - TraceSkipped, - attempt_trace, - run_trace, - trace_diff, -) -from tests.sdk_function_trace.steps import pipeline_issues, pipeline_steps - - -def test_sync_messages_records_the_known_python_limitation() -> None: - result: Final = attempt_trace("messages", engine="python", asynchronous=False) - - assert isinstance(result, TraceSkipped) - assert result.reason == "ValueError: anthropic_messages_handler is not implemented for sync calls" - - -def test_unexpected_call_failure_is_not_skipped() -> None: - result: Final = attempt_trace("unknown", engine="python", asynchronous=False) - - assert isinstance(result, TraceFailed) - assert result.reason == "ValueError: Unknown route: unknown" - - -@pytest.mark.parametrize( - ("route", "asynchronous"), - (("chat_completions", False), ("chat_completions", True), ("messages", True), ("ocr", False), ("ocr", True)), -) -def test_compiled_routes_match_python_steps(route: str, asynchronous: bool) -> None: - from litellm.rust_bridge import get_native_bridge - - if get_native_bridge() is None: - pytest.skip("build the native bridge to run executed route parity") - python: Final = pipeline_steps(route, "python", run_trace(route, engine="python", asynchronous=asynchronous)) - rust: Final = pipeline_steps(route, "rust", run_trace(route, engine="rust", asynchronous=asynchronous)) - - assert pipeline_issues(route, "python", python) == () - assert pipeline_issues(route, "rust", rust) == () - assert trace_diff(python, rust).matches - if route != "messages": - assert python == rust diff --git a/tests/sdk_function_trace/test_steps.py b/tests/sdk_function_trace/test_steps.py deleted file mode 100644 index b5432951187..00000000000 --- a/tests/sdk_function_trace/test_steps.py +++ /dev/null @@ -1,244 +0,0 @@ -from __future__ import annotations - -from typing import Final - -import pytest - -from tests.sdk_function_trace.profiler import FunctionTraceEvent -from tests.sdk_function_trace.runtime import trace_diff -from tests.sdk_function_trace.steps import pipeline_issues, pipeline_steps - - -def test_python_ocr_projection_keeps_pipeline_and_drops_noise() -> None: - events: Final = ( - FunctionTraceEvent("utils.py:1747 client..wrapper_async", 0), - FunctionTraceEvent("ocr/main.py:331 aocr", 1), - FunctionTraceEvent("ocr/main.py:70 _prepare_ocr_request", 2), - FunctionTraceEvent("litellm_core_utils/get_llm_provider_logic.py:142 get_llm_provider", 3), - FunctionTraceEvent("utils.py:9303 ProviderConfigManager.get_provider_ocr_config", 3), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:34 MistralOCRConfig.get_supported_ocr_params", 4), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:72 MistralOCRConfig.map_ocr_params", 4), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:34 MistralOCRConfig.get_supported_ocr_params", 5), - FunctionTraceEvent("llms/custom_httpx/llm_http_handler.py:1705 BaseLLMHTTPHandler.async_ocr", 2), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:94 MistralOCRConfig.validate_environment", 4), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:124 MistralOCRConfig.get_complete_url", 4), - FunctionTraceEvent("llms/base_llm/ocr/transformation.py:209 BaseOCRConfig.async_transform_ocr_request", 5), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:149 MistralOCRConfig.transform_ocr_request", 6), - FunctionTraceEvent("llms/custom_httpx/http_handler.py:654 AsyncHTTPHandler.post", 6), - FunctionTraceEvent("llms/base_llm/ocr/transformation.py:255 BaseOCRConfig.async_transform_ocr_response", 4), - FunctionTraceEvent("llms/mistral/ocr/transformation.py:200 MistralOCRConfig.transform_ocr_response", 5), - FunctionTraceEvent("cost_calculator.py:1874 ocr_cost", 6), - ) - - assert pipeline_steps("ocr", "python", events) == ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("prepare_ocr_call", 1), - FunctionTraceEvent("get_provider_ocr_config", 2), - FunctionTraceEvent("supported_ocr_params", 3), - FunctionTraceEvent("map_ocr_params", 3), - FunctionTraceEvent("execute_ocr_provider_call", 1), - FunctionTraceEvent("validate_environment", 2), - FunctionTraceEvent("complete_url", 2), - FunctionTraceEvent("transform_ocr_request", 3), - FunctionTraceEvent("http_request", 3), - FunctionTraceEvent("transform_ocr_response", 2), - ) - - -def test_rust_ocr_projection_reuses_step_names_and_keeps_unknown_spans() -> None: - events: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("prepare_ocr_call", 1), - FunctionTraceEvent("map_ocr_params", 2), - FunctionTraceEvent("supported_ocr_params", 3), - FunctionTraceEvent("map_ocr_params", 2), - FunctionTraceEvent("transform_ocr_request", 2), - FunctionTraceEvent("execute_ocr_provider_call", 2), - FunctionTraceEvent("transform_ocr_response", 3), - FunctionTraceEvent("new_uninstrumented_span", 3), - ) - - assert pipeline_steps("ocr", "rust", events) == ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("prepare_ocr_call", 1), - FunctionTraceEvent("map_ocr_params", 2), - FunctionTraceEvent("supported_ocr_params", 3), - FunctionTraceEvent("transform_ocr_request", 2), - FunctionTraceEvent("execute_ocr_provider_call", 2), - FunctionTraceEvent("transform_ocr_response", 3), - FunctionTraceEvent("new_uninstrumented_span", 3), - ) - - -def test_projection_resets_depth_on_thread_root() -> None: - events: Final = ( - FunctionTraceEvent("main.py:387 acompletion", 1), - FunctionTraceEvent("llms/anthropic/chat/handler.py:255 AnthropicChatCompletion.acompletion_function", 2), - FunctionTraceEvent( - "llms/anthropic/experimental_pass_through/messages/handler.py:416 anthropic_messages_handler", 0 - ), - FunctionTraceEvent( - "llms/anthropic/experimental_pass_through/messages/transformation.py:575" - " AnthropicMessagesConfig.transform_anthropic_messages_request", - 4, - ), - ) - - assert pipeline_steps("chat_completions", "python", events) == ( - FunctionTraceEvent("chat_completions", 0), - FunctionTraceEvent("execute_chat_completions_provider_call", 1), - ) - assert pipeline_steps("messages", "python", events) == ( - FunctionTraceEvent("execute_messages_provider_call", 0), - FunctionTraceEvent("transform_request", 1), - ) - - -@pytest.mark.parametrize("function", ("completion", "completion_function", "acompletion_function")) -def test_chat_projection_includes_sync_and_async_handlers(function: str) -> None: - events: Final = (FunctionTraceEvent(f"llms/anthropic/chat/handler.py:100 AnthropicChatCompletion.{function}", 0),) - - assert pipeline_steps("chat_completions", "python", events) == ( - FunctionTraceEvent("execute_chat_completions_provider_call", 0), - ) - - -def test_trace_diff_reports_no_difference_for_identical_steps() -> None: - steps: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("transform_ocr_request", 1), - ) - - diff: Final = trace_diff(steps, steps) - - assert diff.python_only == () - assert diff.rust_only == () - assert diff.shared_order_matches - assert diff.matches - - -def test_trace_diff_reports_exclusive_steps_and_reordered_shared_steps() -> None: - python: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("supported_ocr_params", 1), - FunctionTraceEvent("map_ocr_params", 1), - FunctionTraceEvent("http_request", 2), - ) - rust: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("map_ocr_params", 1), - FunctionTraceEvent("supported_ocr_params", 2), - FunctionTraceEvent("transform_ocr_response", 2), - ) - - diff: Final = trace_diff(python, rust) - - assert diff.python_only == ("http_request",) - assert diff.rust_only == ("transform_ocr_response",) - assert not diff.shared_order_matches - assert not diff.matches - - -def test_trace_diff_does_not_claim_empty_or_disjoint_traces_match() -> None: - assert not trace_diff((), ()).shared_order_matches - assert not trace_diff((FunctionTraceEvent("ocr", 0),), (FunctionTraceEvent("messages", 0),)).shared_order_matches - - -def test_projection_uses_actual_ancestors_after_coroutine_resumption() -> None: - entrypoint: Final = "main.py:387 acompletion" - handler: Final = "llms/anthropic/chat/handler.py:255 AnthropicChatCompletion.acompletion_function" - events: Final = ( - FunctionTraceEvent(entrypoint, 0, ()), - FunctionTraceEvent(handler, 1, (entrypoint,)), - FunctionTraceEvent("utils.py:100 unrelated_worker", 0, ()), - FunctionTraceEvent("llms/anthropic/chat/transformation.py:100 transform_response", 1, (handler,)), - ) - - assert pipeline_steps("chat_completions", "python", events) == ( - FunctionTraceEvent("chat_completions", 0), - FunctionTraceEvent("execute_chat_completions_provider_call", 1), - FunctionTraceEvent("transform_response", 2), - ) - - -def test_projection_does_not_nest_siblings_under_a_returned_config_lookup() -> None: - events: Final = ( - FunctionTraceEvent("main.py:387 completion", 0), - FunctionTraceEvent("utils.py:100 ProviderConfigManager.get_provider_chat_config", 1), - FunctionTraceEvent("utils.py:200 unrelated_helper", 1), - FunctionTraceEvent("llms/anthropic/chat/transformation.py:100 transform_request", 2), - ) - - assert pipeline_steps("chat_completions", "python", events) == ( - FunctionTraceEvent("chat_completions", 0), - FunctionTraceEvent("get_provider_chat_config", 1), - FunctionTraceEvent("transform_request", 1), - ) - - -CHAT_RUST_STEPS: Final = ( - "chat_completions", - "get_provider_chat_config", - "supported_openai_params", - "execute_chat_completions_provider_call", - "validate_environment", - "transform_request", - "http_request", - "transform_response", -) - - -@pytest.mark.parametrize("missing", CHAT_RUST_STEPS) -def test_pipeline_check_rejects_missing_stages(missing: str) -> None: - steps: Final = tuple(FunctionTraceEvent(name, 0) for name in CHAT_RUST_STEPS if name != missing) - - assert f"missing {missing}" in pipeline_issues("chat_completions", "rust", steps) - - -def test_pipeline_check_rejects_http_before_request_transformation() -> None: - steps: Final = tuple( - FunctionTraceEvent(name, 0) - for name in ( - "chat_completions", - "get_provider_chat_config", - "supported_openai_params", - "execute_chat_completions_provider_call", - "validate_environment", - "http_request", - "transform_request", - "transform_response", - ) - ) - - assert "transform_request must precede http_request" in pipeline_issues("chat_completions", "rust", steps) - - -def test_step_parity_rejects_different_handler_boundaries_even_with_valid_stages() -> None: - rust: Final = tuple(FunctionTraceEvent(name, 0) for name in CHAT_RUST_STEPS) - python: Final = tuple( - FunctionTraceEvent(name, 0) - for name in ( - "chat_completions", - "get_provider_chat_config", - "supported_openai_params", - "validate_environment", - "transform_request", - "execute_chat_completions_provider_call", - "http_request", - "transform_response", - ) - ) - - assert not trace_diff(python, rust).shared_order_matches - assert not trace_diff(python, rust).matches - assert pipeline_issues("chat_completions", "python", python) == () - assert pipeline_issues("chat_completions", "rust", rust) == () - - -def test_step_parity_rejects_an_exclusive_helper_with_matching_shared_order() -> None: - rust: Final = tuple(FunctionTraceEvent(name, 0) for name in CHAT_RUST_STEPS) - python: Final = (*rust, FunctionTraceEvent("unmatched_helper", 0)) - diff: Final = trace_diff(python, rust) - - assert diff.shared_order_matches - assert not diff.matches diff --git a/tests/sdk_function_trace/test_table.py b/tests/sdk_function_trace/test_table.py deleted file mode 100644 index c2341a391a9..00000000000 --- a/tests/sdk_function_trace/test_table.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import re -from typing import Final - -from tests.sdk_function_trace.profiler import FunctionTraceEvent -from tests.sdk_function_trace.table import format_trace_table - - -def test_table_aligns_matches_after_missing_steps_and_preserves_indentation() -> None: - python: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("python_helper", 1), - FunctionTraceEvent("http_request", 2), - ) - rust: Final = ( - FunctionTraceEvent("ocr", 0), - FunctionTraceEvent("rust_helper", 1), - FunctionTraceEvent("http_request", 1), - ) - output: Final = format_trace_table(python, rust, colorize=False) - rows: Final = tuple(line.split("|")[1:-1] for line in output.splitlines() if line.startswith("|")) - - assert tuple(tuple(cell.strip() for cell in row) for row in rows) == ( - ("python (3 steps)", "rust (3 steps)", "comparison"), - ("ocr", "ocr", "match"), - ("python_helper", "", "python only"), - ("", "rust_helper", "rust only"), - ("http_request", "http_request", "match"), - ) - assert rows[-1][0].startswith(" http_request") - assert rows[-1][1].startswith(" http_request") - assert len({len(line) for line in output.splitlines()}) == 1 - assert "\033[" not in output - - -def test_table_marks_reordered_calls_and_keeps_both_execution_orders() -> None: - python: Final = tuple(FunctionTraceEvent(name, 0) for name in ("ocr", "map", "validate", "http")) - rust: Final = tuple(FunctionTraceEvent(name, 0) for name in ("ocr", "validate", "map", "http")) - output: Final = format_trace_table(python, rust, colorize=True) - plain: Final = re.sub(r"\033\[[0-9;]*m", "", output) - rows: Final = tuple(line.split("|")[1:-1] for line in plain.splitlines() if line.startswith("|"))[1:] - - assert tuple(row[0].strip() for row in rows if row[0].strip()) == tuple(event.function for event in python) - assert tuple(row[1].strip() for row in rows if row[1].strip()) == tuple(event.function for event in rust) - assert plain.count("reordered") == 2 - assert output.count("\033[31m") == 2 - assert "only" not in output - - -def test_table_colors_match_and_exclusive_rows_without_changing_alignment() -> None: - python: Final = (FunctionTraceEvent("ocr", 0), FunctionTraceEvent("python_helper", 1)) - rust: Final = (FunctionTraceEvent("ocr", 0), FunctionTraceEvent("rust_helper", 1)) - colored: Final = format_trace_table(python, rust, colorize=True) - - assert re.sub(r"\033\[[0-9;]*m", "", colored) == format_trace_table(python, rust, colorize=False) - assert next(line for line in colored.splitlines() if "match" in line).startswith("\033[32m") - assert next(line for line in colored.splitlines() if "python only" in line).startswith("\033[34m") - assert next(line for line in colored.splitlines() if "rust only" in line).startswith("\033[33m") - - -def test_table_handles_empty_traces() -> None: - output: Final = format_trace_table((), (), colorize=False) - - assert "python (0 steps)" in output - assert "rust (0 steps)" in output - assert "match" not in output diff --git a/tests/test_litellm/caching/test_caching.py b/tests/test_litellm/caching/test_caching.py index 955b0e531bc..4d0ec0fb677 100644 --- a/tests/test_litellm/caching/test_caching.py +++ b/tests/test_litellm/caching/test_caching.py @@ -4,7 +4,7 @@ import re import pytest from litellm.caching.caching import Cache -from litellm.types.caching import LiteLLMCacheType +from litellm.types.caching import LiteLLMCacheType, SemanticCacheScope from litellm.types.utils import Embedding, EmbeddingResponse, Usage @@ -80,12 +80,13 @@ def test_get_per_item_prompt_tokens_distributes_with_remainder(): assert per_item == [4, 3, 3] -def _semantic_cache(): +def _semantic_cache(**cache_kwargs): return Cache( type=LiteLLMCacheType.VALKEY_SEMANTIC, host="localhost", port="6379", similarity_threshold=0.8, + **cache_kwargs, ) @@ -139,6 +140,76 @@ def test_semantic_cache_key_isolates_tenants(): assert key_a != key_team +_SEMANTICALLY_IDENTICAL_PROMPTS = ( + [{"role": "user", "content": "What color is the sky?"}], + [{"role": "user", "content": "Tell me the colour of the daytime sky."}], +) + + +def _end_user_keys(cache, metadata_field, *end_user_ids): + return [ + cache.get_cache_key( + model="gpt-4o-mini", + messages=messages, + **{metadata_field: {"user_api_key": "hash-A", "user_api_key_end_user_id": end_user_id}}, + ) + for messages, end_user_id in zip(_SEMANTICALLY_IDENTICAL_PROMPTS, end_user_ids) + ] + + +@pytest.mark.parametrize("metadata_field", ["metadata", "litellm_metadata"]) +def test_semantic_cache_key_shares_bucket_across_end_users_by_default(metadata_field): + key_alice, key_bob = _end_user_keys(_semantic_cache(), metadata_field, "alice", "bob") + assert key_alice == key_bob + + +@pytest.mark.parametrize("metadata_field", ["metadata", "litellm_metadata"]) +def test_semantic_cache_key_isolates_end_users_under_end_user_scope(metadata_field): + cache = _semantic_cache(semantic_cache_scope="end_user") + key_alice, key_bob = _end_user_keys(cache, metadata_field, "alice", "bob") + key_alice_again, _ = _end_user_keys(cache, metadata_field, "alice", "alice") + assert key_alice != key_bob + assert key_alice == key_alice_again + + +def test_semantic_cache_key_end_user_scope_without_end_user_falls_back_to_key_scope(): + cache = _semantic_cache(semantic_cache_scope=SemanticCacheScope.END_USER) + messages = [{"role": "user", "content": "What color is the sky?"}] + key_scope_only = cache.get_cache_key(model="gpt-4o-mini", messages=messages, metadata={"user_api_key": "hash-A"}) + end_user_absent = cache.get_cache_key( + model="gpt-4o-mini", + messages=messages, + metadata={"user_api_key": "hash-A", "user_api_key_end_user_id": None}, + ) + other_key = cache.get_cache_key(model="gpt-4o-mini", messages=messages, metadata={"user_api_key": "hash-B"}) + key_alice, _ = _end_user_keys(cache, "metadata", "alice", "alice") + default_scope_key = _semantic_cache().get_cache_key( + model="gpt-4o-mini", messages=messages, metadata={"user_api_key": "hash-A"} + ) + assert key_scope_only == end_user_absent == default_scope_key + assert key_scope_only != other_key + assert key_scope_only != key_alice + + +def test_semantic_cache_key_reads_tenant_identity_from_litellm_metadata(): + cache = _semantic_cache() + messages = [{"role": "user", "content": "What color is the sky?"}] + key_a = cache.get_cache_key(model="gpt-4o-mini", messages=messages, litellm_metadata={"user_api_key": "hash-A"}) + key_b = cache.get_cache_key(model="gpt-4o-mini", messages=messages, litellm_metadata={"user_api_key": "hash-B"}) + key_a_in_litellm_params = cache.get_cache_key( + model="gpt-4o-mini", + messages=messages, + litellm_params={"litellm_metadata": {"user_api_key": "hash-A"}}, + ) + assert key_a != key_b + assert key_a == key_a_in_litellm_params + + +def test_semantic_cache_scope_rejects_unknown_value(): + with pytest.raises(ValueError, match="'team' is not a valid SemanticCacheScope"): + _semantic_cache(semantic_cache_scope="team") + + def test_semantic_cache_key_still_separates_models_and_params(): cache = _semantic_cache() messages = [{"role": "user", "content": "hi"}] diff --git a/tests/test_litellm/caching/test_dual_cache.py b/tests/test_litellm/caching/test_dual_cache.py index 47be139eb5e..ded3be26630 100644 --- a/tests/test_litellm/caching/test_dual_cache.py +++ b/tests/test_litellm/caching/test_dual_cache.py @@ -61,6 +61,137 @@ async def test_dual_cache_async_batch_get_cache_rolls_back_redis_reservation_on_ assert "shared_b" not in dual_cache.last_redis_batch_access_time +def _redis_mock_for_sync_batch(redis_result: dict) -> MagicMock: + mock_redis = MagicMock(spec=RedisCache) + mock_redis.batch_get_cache.return_value = redis_result + return mock_redis + + +def _assert_sync_batch_used_blocking_client(dual_cache: DualCache, mock_redis: MagicMock) -> None: + with patch("asyncio.new_event_loop", side_effect=AssertionError("sync path must not create an event loop")): + result = dual_cache.batch_get_cache(keys=["lit6729_key"]) + + assert result == ["redis_value"] + mock_redis.batch_get_cache.assert_called_once_with(key_list=["lit6729_key"], parent_otel_span=None) + mock_redis.async_batch_get_cache.assert_not_called() + mock_redis.init_async_client.assert_not_called() + assert dual_cache.in_memory_cache.get_cache("lit6729_key") == "redis_value" + + +@pytest.mark.asyncio +async def test_dual_cache_batch_get_cache_uses_sync_redis_client_inside_running_loop(): + """ + Regression test for LIT-6729: sync batch_get_cache ran async_batch_get_cache on a + throwaway event loop, reusing an async Redis client created on another loop and + corrupting its connection pool. The sync path must use the blocking client, never + the async one, and never create an event loop, even when called from a coroutine + (e.g. async_raise_no_deployment_exception -> get_min_cooldown). + """ + mock_redis = _redis_mock_for_sync_batch({"lit6729_key": "redis_value"}) + dual_cache = DualCache(in_memory_cache=InMemoryCache(), redis_cache=mock_redis) + + _assert_sync_batch_used_blocking_client(dual_cache, mock_redis) + + +def test_dual_cache_batch_get_cache_uses_sync_redis_client_without_running_loop(): + mock_redis = _redis_mock_for_sync_batch({"lit6729_key": "redis_value"}) + dual_cache = DualCache(in_memory_cache=InMemoryCache(), redis_cache=mock_redis) + + _assert_sync_batch_used_blocking_client(dual_cache, mock_redis) + + +def test_dual_cache_batch_get_cache_only_reads_missing_keys_from_redis(): + mock_redis = _redis_mock_for_sync_batch({"miss_key": "from_redis"}) + dual_cache = DualCache(in_memory_cache=InMemoryCache(), redis_cache=mock_redis) + dual_cache.in_memory_cache.set_cache("hit_key", "from_memory") + + result = dual_cache.batch_get_cache(keys=["hit_key", "miss_key"]) + + assert result == ["from_memory", "from_redis"] + mock_redis.batch_get_cache.assert_called_once_with(key_list=["miss_key"], parent_otel_span=None) + + +def test_dual_cache_batch_get_cache_throttles_repeat_redis_reads(): + mock_redis = _redis_mock_for_sync_batch({"absent_key": None}) + dual_cache = DualCache( + in_memory_cache=InMemoryCache(), redis_cache=mock_redis, default_redis_batch_cache_expiry=10 + ) + + first = dual_cache.batch_get_cache(keys=["absent_key"]) + second = dual_cache.batch_get_cache(keys=["absent_key"]) + + assert first == [None] + assert second == [None] + mock_redis.batch_get_cache.assert_called_once() + + +def test_dual_cache_batch_get_cache_rolls_back_redis_reservation_on_error(): + mock_redis = MagicMock(spec=RedisCache) + mock_redis.batch_get_cache.side_effect = RuntimeError("redis unavailable") + dual_cache = DualCache( + in_memory_cache=InMemoryCache(), redis_cache=mock_redis, default_redis_batch_cache_expiry=10 + ) + + first_result = dual_cache.batch_get_cache(keys=["shared_a"]) + second_result = dual_cache.batch_get_cache(keys=["shared_a"]) + + assert first_result is None + assert second_result is None + assert mock_redis.batch_get_cache.call_count == 2 + assert "shared_a" not in dual_cache.last_redis_batch_access_time + + +def test_dual_cache_batch_get_cache_returns_memory_only_when_redis_read_is_throttled(): + mock_redis = _redis_mock_for_sync_batch({"throttled_key": "redis_value"}) + dual_cache = DualCache( + in_memory_cache=InMemoryCache(), redis_cache=mock_redis, default_redis_batch_cache_expiry=10 + ) + dual_cache.last_redis_batch_access_time["throttled_key"] = time.time() + + result = dual_cache.batch_get_cache(keys=["throttled_key"]) + + assert result == [None] + mock_redis.batch_get_cache.assert_not_called() + + +def test_dual_cache_sync_batch_redis_backfill_injects_default_in_memory_ttl(): + """Sync batch_get_cache's Redis-to-memory backfill must honor + default_in_memory_ttl, same as the async path.""" + in_memory_cache = InMemoryCache(default_ttl=600) + mock_redis = _redis_mock_for_sync_batch({"batch_backfill_key": "redis_value"}) + dual_cache = DualCache( + in_memory_cache=in_memory_cache, + redis_cache=mock_redis, + default_in_memory_ttl=60, + ) + + before = time.time() + result = dual_cache.batch_get_cache(keys=["batch_backfill_key"]) + after = time.time() + + assert result == ["redis_value"] + expiry = in_memory_cache.ttl_dict["batch_backfill_key"] + assert expiry >= before + 60 + assert expiry <= after + 60 + + +def test_dual_cache_batch_get_cache_forwards_explicit_ttl_to_backfill(): + """An explicit ttl kwarg must reach the in-memory backfill flat, not nested + under a 'kwargs' key the way the old locals()-forwarding path sent it.""" + in_memory_cache = InMemoryCache(default_ttl=600) + mock_redis = _redis_mock_for_sync_batch({"explicit_ttl_key": "redis_value"}) + dual_cache = DualCache(in_memory_cache=in_memory_cache, redis_cache=mock_redis) + + before = time.time() + result = dual_cache.batch_get_cache(keys=["explicit_ttl_key"], ttl=5) + after = time.time() + + assert result == ["redis_value"] + expiry = in_memory_cache.ttl_dict["explicit_ttl_key"] + assert expiry >= before + 5 + assert expiry <= after + 5 + + @pytest.mark.asyncio async def test_dual_cache_async_set_cache_injects_default_in_memory_ttl(): """ diff --git a/tests/test_litellm/caching/test_redis_cache.py b/tests/test_litellm/caching/test_redis_cache.py index 487a64797d1..e4724ff8705 100644 --- a/tests/test_litellm/caching/test_redis_cache.py +++ b/tests/test_litellm/caching/test_redis_cache.py @@ -1,10 +1,10 @@ import asyncio -from unittest.mock import MagicMock, patch +from collections.abc import Iterator +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from unittest.mock import AsyncMock - +from litellm._service_logger import ServiceLogging from litellm.caching.redis_cache import RedisCache @@ -17,6 +17,17 @@ def redis_no_ping(): yield +@pytest.fixture +def sync_batch_redis_cache(redis_no_ping): + with patch( # test-quality-ok: RedisCache.__init__ builds its client eagerly, with no injection point + "litellm._redis.get_redis_client", return_value=MagicMock() + ) as get_client: + cache = RedisCache(host="127.0.0.1", port=6379) + cache.redis_client.mget.side_effect = OSError("redis unavailable") + get_client.assert_called_once() + yield cache + + @pytest.mark.parametrize( ("namespace", "key", "expected"), [ @@ -504,6 +515,173 @@ async def test_circuit_breaker_opens_when_method_swallows_redis_failure(redis_no await call_method(cache) +def test_circuit_breaker_open_keeps_sync_batch_get_cache_as_a_miss(sync_batch_redis_cache): + """An open breaker must preserve the sync batch read's dictionary fallback.""" + from litellm.constants import REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD + + for _ in range(REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD): + assert sync_batch_redis_cache.batch_get_cache(key_list=["lit6729"]) == {} + + assert sync_batch_redis_cache.batch_get_cache(key_list=["lit6729"]) == {} + + +@pytest.fixture +def sync_batch_cache_with_service_logger(redis_no_ping: None) -> Iterator[tuple[RedisCache, ServiceLogging]]: + service_logger = ServiceLogging(mock_testing=True) + failing_client = MagicMock() + failing_client.mget.side_effect = OSError("redis unavailable") + with patch( # test-quality-ok: RedisCache.__init__ builds its client eagerly, with no injection point + "litellm._redis.get_redis_client", return_value=failing_client + ): + cache = RedisCache(host="127.0.0.1", port=6379, service_logger_obj=service_logger) + yield cache, service_logger + + +@pytest.mark.asyncio +async def test_sync_batch_get_cache_reports_a_failed_read_from_a_running_loop( + sync_batch_cache_with_service_logger: tuple[RedisCache, ServiceLogging], +): + """A swallowed Redis failure must still be reported as a service failure event. + + The routing strategies call this blocking read from inside the request's event loop, + and the read hides the Redis error by returning an empty dict. Without an emitted + failure event, litellm_redis_failed_requests_total stops moving during a Redis + outage while the success path keeps reporting, so the dashboards read healthy. + """ + cache, service_logger = sync_batch_cache_with_service_logger + + assert cache.batch_get_cache(key_list=["lit6729"]) == {} + await asyncio.sleep(0.05) + + assert service_logger.mock_testing_sync_failure_hook == 1 + assert service_logger.mock_testing_async_failure_hook == 1 + + +def test_sync_batch_get_cache_reports_a_failed_read_from_a_worker_thread( + sync_batch_cache_with_service_logger: tuple[RedisCache, ServiceLogging], +): + """The same report must reach the async hook when the caller has no event loop at all.""" + from concurrent.futures import ThreadPoolExecutor + + cache, service_logger = sync_batch_cache_with_service_logger + + with ThreadPoolExecutor(max_workers=1) as pool: + assert pool.submit(cache.batch_get_cache, key_list=["lit6729"]).result() == {} + + assert service_logger.mock_testing_async_failure_hook == 1 + + +def test_sync_batch_get_cache_reports_a_failed_read_on_an_idle_event_loop( + sync_batch_cache_with_service_logger: tuple[RedisCache, ServiceLogging], +): + """The report must also go out when the caller holds an open loop that is not running.""" + cache, service_logger = sync_batch_cache_with_service_logger + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + assert cache.batch_get_cache(key_list=["lit6729"]) == {} + finally: + asyncio.set_event_loop(None) + loop.close() + + assert service_logger.mock_testing_async_failure_hook == 1 + + +def test_sync_batch_get_cache_survives_a_service_callback_that_raises( + sync_batch_cache_with_service_logger: tuple[RedisCache, ServiceLogging], + monkeypatch: pytest.MonkeyPatch, +): + """A failing service callback must not replace the swallowed Redis failure. + + A misconfigured callback raises while emitting (a datadog callback with no + DD_API_KEY raises at construction), and the failure event is emitted from inside + the except block that swallows the Redis error. If that exception escapes, a Redis + outage surfaces to routing as a callback error and the circuit breaker never + records the failed read. + """ + from concurrent.futures import ThreadPoolExecutor + + import litellm + + from litellm.constants import REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD + + cache, service_logger = sync_batch_cache_with_service_logger + monkeypatch.setattr(litellm, "service_callback", ["prometheus_system"]) + monkeypatch.setattr( + service_logger, + "init_prometheus_services_logger_if_none", + AsyncMock(side_effect=Exception("callback is misconfigured")), + ) + + for _ in range(REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD): + with ThreadPoolExecutor(max_workers=1) as pool: + assert pool.submit(cache.batch_get_cache, key_list=["lit6729"]).result() == {} + + assert cache.batch_get_cache(key_list=["lit6729"]) == {} + + +def test_call_stack_info_skips_breaker_guard_frames(): + """Guarded methods must still report their real callers in service-log call_type. + + The breaker guards put their own frames between a method body and its caller, so + without skipping them every guarded method logged the guard machinery instead of + who actually issued the Redis call. + """ + from litellm.caching.redis_cache import ( + RedisCircuitBreaker, + _get_call_stack_info, + _redis_circuit_breaker_guard_sync, + ) + + class Guarded: + _circuit_breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60) + + @_redis_circuit_breaker_guard_sync + def probe(self): + return _get_call_stack_info() + + def caller_one(): + return Guarded().probe() + + def caller_two(): + return caller_one() + + assert caller_two() == "caller_one <- caller_two" + + +def test_call_stack_info_skips_guard_frames_when_deployed_without_sources(monkeypatch): + """Guard-frame skipping must survive a bytecode-only deployment. + + Shipping `.pyc` files without their `.py` sources leaves the module's `__file__` pointing + at the compiled file while every frame still carries the compile-time source path, so a + check comparing those two paths stops skipping and the service log then names the guard + machinery instead of the real caller. + """ + from litellm.caching import redis_cache as redis_cache_module + from litellm.caching.redis_cache import ( + RedisCircuitBreaker, + _get_call_stack_info, + _redis_circuit_breaker_guard_sync, + ) + + monkeypatch.setattr(redis_cache_module, "__file__", redis_cache_module.__file__ + "c") + + class Guarded: + _circuit_breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60) + + @_redis_circuit_breaker_guard_sync + def probe(self): + return _get_call_stack_info() + + def caller_one(): + return Guarded().probe() + + def caller_two(): + return caller_one() + + assert caller_two() == "caller_one <- caller_two" + + @pytest.mark.asyncio async def test_circuit_breaker_success_still_resets_the_failure_streak(redis_no_ping): """A reachable Redis must keep the breaker closed, however many earlier calls failed. @@ -580,7 +758,6 @@ async def test_concurrent_success_is_not_cancelled_by_another_calls_failure(): async def swallows_a_failure(): await asyncio.sleep(0.02) _record_swallowed_redis_failure(breaker, RedisConnectionError("redis unreachable")) - return None async def succeeds_while_the_other_fails(): await asyncio.sleep(0.05) @@ -602,7 +779,7 @@ async def test_concurrent_success_is_not_cancelled_by_another_calls_failure(): "error, opens_breaker", [ pytest.param("ConnectionError", True, id="connection_refused_is_unhealthy"), - pytest.param("TimeoutError", True, id="timeout_is_unhealthy"), + pytest.param("TimeoutError", False, id="timeout_burst_is_ambiguous"), pytest.param("BusyLoadingError", True, id="loading_is_unhealthy"), pytest.param("ResponseError", False, id="wrong_type_command_is_not"), pytest.param("DataError", False, id="bad_data_is_not"), @@ -614,6 +791,10 @@ async def test_only_connectivity_failures_open_the_breaker(error, opens_breaker) They say nothing about connectivity, and a caller able to provoke them (an INCR against a non-numeric value, say) could otherwise trip the shared breaker on demand and drop rate limiting to per-process counters, which spreading traffic across replicas outruns. + + A rapid burst of timeouts is ambiguous too: the async timeout includes event-loop + scheduling delay, so a loop stall times out every queued call at once against a + healthy Redis. It must not open the breaker until the streak spans a minimum duration. """ import redis.exceptions @@ -633,3 +814,147 @@ async def test_only_connectivity_failures_open_the_breaker(error, opens_breaker) await _run_under_circuit_breaker(breaker, "op", failing_call) assert breaker.is_open() is opens_breaker + + +@pytest.mark.asyncio +async def test_event_loop_stall_timeout_burst_keeps_breaker_closed(): + """One blocking stall of the worker event loop must not trip the breaker. + + Every operation already waiting on the loop times out together when the loop resumes, + so a purely consecutive threshold is satisfied instantly even though the Redis on the + other end (here an in-process fake that answers immediately) is healthy. + """ + import time as time_mod + + from litellm.caching.redis_cache import RedisCircuitBreaker, _run_under_circuit_breaker + + breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, timeout_min_duration=5.0) + + async def healthy_redis_call_with_client_timeout(): + return await asyncio.wait_for(asyncio.sleep(0.001, result="ok"), timeout=0.05) + + async def stall_the_loop(): + await asyncio.sleep(0) + time_mod.sleep(0.2) + + results = await asyncio.gather( + *(_run_under_circuit_breaker(breaker, "op", healthy_redis_call_with_client_timeout) for _ in range(8)), + stall_the_loop(), + return_exceptions=True, + ) + timeouts = [r for r in results if isinstance(r, asyncio.TimeoutError)] + assert len(timeouts) >= breaker.failure_threshold, "the stall must time out a full burst" + + assert breaker.is_open() is False, "a healthy Redis behind one loop stall must stay in the pool" + assert await _run_under_circuit_breaker(breaker, "op", healthy_redis_call_with_client_timeout) == "ok" + + +@pytest.mark.asyncio +async def test_persistent_timeouts_still_open_the_breaker(): + """A real outage that surfaces only as timeouts must still open the breaker. + + Once the timeout-only streak spans the minimum duration with no success in between, + Redis is genuinely unusable from this worker and protection has to kick in. + """ + from redis.exceptions import TimeoutError as RedisTimeoutError + + from litellm.caching.redis_cache import RedisCircuitBreaker, _run_under_circuit_breaker + + breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, timeout_min_duration=0.1) + + async def timing_out_call(): + raise RedisTimeoutError("read timed out") + + for _ in range(breaker.failure_threshold): + with pytest.raises(RedisTimeoutError): + await _run_under_circuit_breaker(breaker, "op", timing_out_call) + assert breaker.is_open() is False, "the burst has not spanned the minimum duration yet" + + await asyncio.sleep(0.12) + with pytest.raises(RedisTimeoutError): + await _run_under_circuit_breaker(breaker, "op", timing_out_call) + + assert breaker.is_open() is True + + +@pytest.mark.asyncio +async def test_stale_timeout_does_not_let_sub_threshold_hard_failures_open_the_breaker(): + """Hard connectivity failures below the threshold must not open the breaker just + because an old timeout already started the streak and the duration has elapsed. + + Each class has to earn the open on its own terms: hard failures by reaching the + threshold, timeouts by reaching the threshold and spanning the minimum duration. + """ + from redis.exceptions import ConnectionError as RedisConnectionError + from redis.exceptions import TimeoutError as RedisTimeoutError + + from litellm.caching.redis_cache import RedisCircuitBreaker, _is_redis_timeout_failure + + breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, timeout_min_duration=0.05) + + breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out"))) + await asyncio.sleep(0.06) + for _ in range(breaker.failure_threshold - 1): + breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused"))) + assert breaker.is_open() is False, "2 hard failures and 1 stale timeout are below both thresholds" + + breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused"))) + assert breaker.is_open() is True, "the threshold-th hard failure must still open it" + + +@pytest.mark.asyncio +async def test_hard_failure_resets_timeout_streak_so_a_later_burst_must_earn_its_own_duration(): + """A stale timeout followed by hard failures must not pre-age the duration gate: + a later short timeout burst has to span timeout_min_duration on its own. + """ + from redis.exceptions import ConnectionError as RedisConnectionError + from redis.exceptions import TimeoutError as RedisTimeoutError + + from litellm.caching.redis_cache import RedisCircuitBreaker, _is_redis_timeout_failure + + breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, timeout_min_duration=0.05) + + breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out"))) + breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused"))) + await asyncio.sleep(0.06) + for _ in range(breaker.failure_threshold): + breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out"))) + assert breaker.is_open() is False, "the burst is instantaneous, so the duration gate must hold it closed" + + await asyncio.sleep(0.06) + breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out"))) + assert breaker.is_open() is True, "the same run of timeouts persisting past the duration must open it" + + +@pytest.mark.asyncio +async def test_breaker_metrics_track_state_and_failure_class(): + """Breaker accounting must be observable: failure class, transitions, and state.""" + from prometheus_client import REGISTRY + from redis.exceptions import ConnectionError as RedisConnectionError + from redis.exceptions import TimeoutError as RedisTimeoutError + + from litellm.caching.redis_cache import RedisCircuitBreaker, _is_redis_timeout_failure + + def sample(name, labels=None): + return REGISTRY.get_sample_value(name, labels) or 0.0 + + timeout_before = sample("litellm_redis_circuit_breaker_failures_total", {"failure_class": "timeout"}) + hard_before = sample("litellm_redis_circuit_breaker_failures_total", {"failure_class": "connectivity"}) + opened_before = sample("litellm_redis_circuit_breaker_transitions_total", {"state": "open"}) + open_gauge_before = sample("litellm_redis_circuit_breaker_state", {"state": "open"}) + closed_gauge_before = sample("litellm_redis_circuit_breaker_state", {"state": "closed"}) + + breaker = RedisCircuitBreaker(failure_threshold=2, recovery_timeout=60, timeout_min_duration=5.0) + breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("t"))) + breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused"))) + breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused"))) + + assert sample("litellm_redis_circuit_breaker_failures_total", {"failure_class": "timeout"}) == timeout_before + 1 + assert sample("litellm_redis_circuit_breaker_failures_total", {"failure_class": "connectivity"}) == hard_before + 2 + assert sample("litellm_redis_circuit_breaker_transitions_total", {"state": "open"}) == opened_before + 1 + assert sample("litellm_redis_circuit_breaker_state", {"state": "open"}) == open_gauge_before + 1 + assert sample("litellm_redis_circuit_breaker_state", {"state": "closed"}) == closed_gauge_before + + breaker.record_success() + assert sample("litellm_redis_circuit_breaker_state", {"state": "open"}) == open_gauge_before + assert sample("litellm_redis_circuit_breaker_state", {"state": "closed"}) == closed_gauge_before + 1 diff --git a/tests/test_litellm/caching/test_redis_cluster_node_isolation.py b/tests/test_litellm/caching/test_redis_cluster_node_isolation.py index c16ceec8c31..9ebc3673ab6 100644 --- a/tests/test_litellm/caching/test_redis_cluster_node_isolation.py +++ b/tests/test_litellm/caching/test_redis_cluster_node_isolation.py @@ -5,20 +5,26 @@ CLIENT PAUSE) showed 100% of concurrent commands to the other two, untouched nod stalling for the full pause duration before this fix, and zero after -- these tests pin the same behavior at the unit level so it can run without a live Redis Cluster.""" +import asyncio from typing import TYPE_CHECKING -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, patch import pytest from redis.exceptions import ( + AskError, BusyLoadingError, ClusterDownError, + ClusterError, MaxConnectionsError, MovedError, + TryAgainError, ) from redis.exceptions import ( ConnectionError as RedisConnectionError, ) -from redis.exceptions import TimeoutError as RedisTimeoutError +from redis.exceptions import ( + TimeoutError as RedisTimeoutError, +) from litellm.caching.redis_cluster_node_isolation import ( get_litellm_async_redis_cluster_class, @@ -39,10 +45,35 @@ class _NodeClassWithoutPerConnectionRecovery: class _FakeClusterNode: def __init__(self, name: str, raises: Exception | None = None, response: object = None) -> None: self.name = name - self.execute_command = AsyncMock(side_effect=raises, return_value=response) + + async def execute_command(*args: object, **kwargs: object) -> object: + await asyncio.sleep(0) + if raises is not None: + raise raises + return response + + self.execute_command = AsyncMock(side_effect=execute_command) self.disconnect = AsyncMock() +class _Fake8xRedisCluster: + def __init__(self) -> None: + self._initialize = False + + async def _execute_command( + self, target_node: _FakeClusterNode, *args: object, **kwargs: object + ) -> object: + try: + return await target_node.execute_command(*args, **kwargs) + except (RedisConnectionError, RedisTimeoutError): + self._initialize = True + await asyncio.sleep(0) + raise + + async def aclose(self) -> None: + self._initialize = True + + class _FakeNodesManager: def __init__(self, node_to_return: _FakeClusterNode) -> None: self._moved_exception: object = None @@ -68,31 +99,198 @@ def _build_cluster_instance() -> "_AsyncRedisClusterType": return instance -def test_per_connection_recovery_redis_py_gets_the_unmodified_upstream_class() -> None: - """Regression (redis-py 8.x): when upstream ClusterNode already recovers a node-level - connection error per-connection, the factory must NOT install the copied override, - whose node.disconnect() also kills connections other coroutines are mid-operation on.""" - from redis.asyncio.cluster import RedisCluster - +def _build_8x_cluster_instance() -> _Fake8xRedisCluster: cluster_cls = get_litellm_async_redis_cluster_class( - cluster_node_class=_NodeClassWithPerConnectionRecovery + cluster_node_class=_NodeClassWithPerConnectionRecovery, + base_cluster_class=_Fake8xRedisCluster, + ) + return cluster_cls() + + +def test_unverified_redis_version_logs_warning(caplog: pytest.LogCaptureFixture) -> None: + import redis + + with patch.object(redis, "__version__", "8.0.1"): + get_litellm_async_redis_cluster_class(cluster_node_class=_NodeClassWithoutPerConnectionRecovery) + + assert "not in the set this cluster-teardown-storm fix was verified against" in caplog.text + + +@pytest.mark.asyncio +async def test_single_timeout_does_not_request_topology_reinit() -> None: + error = RedisTimeoutError("timeout") + target_node = _FakeClusterNode("node-a") + target_node.execute_command.side_effect = error + instance = _build_8x_cluster_instance() + + with pytest.raises(RedisTimeoutError) as exc_info: + await instance._execute_command(target_node, "GET", "k") + + assert exc_info.value is error + assert instance._initialize is False + + +@pytest.mark.asyncio +async def test_connection_error_preserves_upstream_topology_reinit() -> None: + error = RedisConnectionError("connection error") + target_node = _FakeClusterNode("node-a") + target_node.execute_command.side_effect = error + instance = _build_8x_cluster_instance() + + with pytest.raises(RedisConnectionError) as exc_info: + await instance._execute_command(target_node, "GET", "k") + + assert exc_info.value is error + assert instance._initialize is True + + +@pytest.mark.asyncio +async def test_three_consecutive_timeouts_request_topology_reinit_and_reset_counter() -> None: + errors = [ + RedisTimeoutError("timeout-1"), + RedisTimeoutError("timeout-2"), + RedisTimeoutError("timeout-3"), + ] + fourth_error = RedisTimeoutError("timeout-4") + target_node = _FakeClusterNode("node-a") + target_node.execute_command.side_effect = [*errors, fourth_error] + instance = _build_8x_cluster_instance() + + for error in errors: + with pytest.raises(RedisTimeoutError) as exc_info: + await instance._execute_command(target_node, "GET", "k") + assert exc_info.value is error + + assert instance._initialize is True + instance._initialize = False + + with pytest.raises(RedisTimeoutError) as exc_info: + await instance._execute_command(target_node, "GET", "k") + + assert exc_info.value is fourth_error + assert instance._initialize is False + + +@pytest.mark.asyncio +async def test_success_resets_consecutive_timeout_counter() -> None: + errors = [RedisTimeoutError("timeout-1"), RedisTimeoutError("timeout-2")] + final_error = RedisTimeoutError("timeout-3") + target_node = _FakeClusterNode("node-a") + target_node.execute_command.side_effect = [*errors, b"value", final_error] + instance = _build_8x_cluster_instance() + + for error in errors: + with pytest.raises(RedisTimeoutError) as exc_info: + await instance._execute_command(target_node, "GET", "k") + assert exc_info.value is error + assert instance._initialize is False + + result = await instance._execute_command(target_node, "GET", "k") + assert result == b"value" + assert instance._initialize is False + + with pytest.raises(RedisTimeoutError) as exc_info: + await instance._execute_command(target_node, "GET", "k") + + assert exc_info.value is final_error + assert instance._initialize is False + + +@pytest.mark.asyncio +async def test_timeout_counters_are_per_node() -> None: + node_a_errors = [RedisTimeoutError("node-a-1"), RedisTimeoutError("node-a-2")] + node_b_error = RedisTimeoutError("node-b-1") + node_a = _FakeClusterNode("node-a") + node_b = _FakeClusterNode("node-b") + node_a.execute_command.side_effect = node_a_errors + node_b.execute_command.side_effect = node_b_error + instance = _build_8x_cluster_instance() + + for target_node, error in ( + (node_a, node_a_errors[0]), + (node_b, node_b_error), + (node_a, node_a_errors[1]), + ): + with pytest.raises(RedisTimeoutError) as exc_info: + await instance._execute_command(target_node, "GET", "k") + assert exc_info.value is error + + assert instance._initialize is False + + +@pytest.mark.asyncio +async def test_timeout_does_not_clear_concurrent_topology_reinit_request() -> None: + error = RedisTimeoutError("timeout") + instance = _build_8x_cluster_instance() + + async def request_reinit(*args: object, **kwargs: object) -> object: + await instance.aclose() + raise error + + target_node = _FakeClusterNode("node-a") + target_node.execute_command.side_effect = request_reinit + + with pytest.raises(RedisTimeoutError) as exc_info: + await instance._execute_command(target_node, "GET", "k") + + assert exc_info.value is error + assert instance._initialize is True + + +@pytest.mark.asyncio +async def test_tolerated_timeout_does_not_erase_concurrent_connection_error_reinit() -> None: + instance = _build_8x_cluster_instance() + failing_node = _FakeClusterNode("node-a", raises=RedisConnectionError("gone")) + slow_node = _FakeClusterNode("node-b", raises=RedisTimeoutError("slow")) + + results = await asyncio.gather( + instance._execute_command(failing_node, "GET", "a"), + instance._execute_command(slow_node, "GET", "b"), + return_exceptions=True, ) - assert cluster_cls is RedisCluster + assert isinstance(results[0], RedisConnectionError) + assert isinstance(results[1], RedisTimeoutError) + assert instance._initialize is True -def test_pre_recovery_redis_py_still_gets_the_node_isolation_override() -> None: - """Old redis-py (5.x) responds to a node-level error with a full-cluster aclose(), - so those versions must keep litellm's per-node isolation override.""" - from redis.asyncio.cluster import RedisCluster +@pytest.mark.asyncio +async def test_overlapping_tolerated_timeouts_do_not_request_topology_reinit() -> None: + instance = _build_8x_cluster_instance() + node_a = _FakeClusterNode("node-a", raises=RedisTimeoutError("slow-a")) + node_b = _FakeClusterNode("node-b", raises=RedisTimeoutError("slow-b")) - cluster_cls = get_litellm_async_redis_cluster_class( - cluster_node_class=_NodeClassWithoutPerConnectionRecovery + results = await asyncio.gather( + instance._execute_command(node_a, "GET", "a"), + instance._execute_command(node_b, "GET", "b"), + return_exceptions=True, ) - assert cluster_cls is not RedisCluster - assert issubclass(cluster_cls, RedisCluster) - assert "_execute_command" in cluster_cls.__dict__ + assert all(isinstance(result, RedisTimeoutError) for result in results) + assert instance._initialize is False + + +@pytest.mark.asyncio +async def test_tolerated_timeout_does_not_clear_pending_reinit() -> None: + instance = _build_8x_cluster_instance() + instance._initialize = True + target_node = _FakeClusterNode("node-a", raises=RedisTimeoutError("slow")) + + with pytest.raises(RedisTimeoutError): + await instance._execute_command(target_node, "GET", "k") + + assert instance._initialize is True + + +@pytest.mark.asyncio +async def test_success_returns_value_without_topology_reinit() -> None: + target_node = _FakeClusterNode("node-a", response=b"value") + instance = _build_8x_cluster_instance() + + result = await instance._execute_command(target_node, "GET", "k") + + assert result == b"value" + assert instance._initialize is False @pytest.mark.asyncio @@ -110,6 +308,48 @@ async def test_node_level_error_resets_only_that_node_not_the_whole_client(error instance.aclose.assert_not_awaited() +@pytest.mark.asyncio +async def test_moved_error_retries_without_full_reinit_before_threshold() -> None: + moved_error = MovedError("1 127.0.0.1:7001") + target_node = _FakeClusterNode("node-a") + target_node.execute_command = AsyncMock(side_effect=[moved_error, b"value"]) + instance = _build_cluster_instance() + instance.RedisClusterRequestTTL = 2 + instance.nodes_manager = _FakeNodesManager(node_to_return=target_node) + instance._determine_slot = AsyncMock(return_value=0) + + result = await instance._execute_command(target_node, "GET", "k") + + assert result == b"value" + assert instance.nodes_manager._moved_exception is moved_error + instance.aclose.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_ask_error_sends_asking_and_retries_on_redirected_node() -> None: + ask_error = AskError("0 127.0.0.1:7001") + target_node = _FakeClusterNode("node-a") + target_node.execute_command = AsyncMock(side_effect=[ask_error, None, b"value"]) + instance = _build_cluster_instance() + instance.RedisClusterRequestTTL = 2 + instance.get_node = Mock(return_value=target_node) + + result = await instance._execute_command(target_node, "GET", "k") + + assert result == b"value" + instance.get_node.assert_called_once_with(node_name="127.0.0.1:7001") + + +@pytest.mark.asyncio +async def test_try_again_error_exhausts_ttl() -> None: + target_node = _FakeClusterNode("node-a", raises=TryAgainError("try again")) + instance = _build_cluster_instance() + instance.RedisClusterRequestTTL = 2 + + with pytest.raises(ClusterError): + await instance._execute_command(target_node, "GET", "k") + + @pytest.mark.asyncio async def test_successful_command_touches_neither_disconnect_nor_aclose() -> None: target_node = _FakeClusterNode("node-a", response=b"v") diff --git a/tests/test_litellm/containers/test_container_proxy_ownership.py b/tests/test_litellm/containers/test_container_proxy_ownership.py index 176405bb9ca..38988d65c04 100644 --- a/tests/test_litellm/containers/test_container_proxy_ownership.py +++ b/tests/test_litellm/containers/test_container_proxy_ownership.py @@ -1,7 +1,7 @@ import json import sys from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import HTTPException @@ -242,110 +242,156 @@ async def test_should_not_reassign_existing_container_to_different_owner(monkeyp table.update.assert_not_awaited() -@pytest.mark.asyncio -async def test_should_filter_container_list_to_owned_records(monkeypatch): +def _owned_containers_in_db(monkeypatch, *model_object_ids: str) -> AsyncMock: table = AsyncMock() - table.find_many.return_value = [ - SimpleNamespace(model_object_id="container:openai:cntr_owned"), - ] - prisma_client = SimpleNamespace( - db=SimpleNamespace(litellm_managedobjecttable=table) - ) + table.find_many.return_value = [SimpleNamespace(model_object_id=object_id) for object_id in model_object_ids] monkeypatch.setattr( ownership, "_get_prisma_client", - AsyncMock(return_value=prisma_client), + AsyncMock(return_value=SimpleNamespace(db=SimpleNamespace(litellm_managedobjecttable=table))), ) - auth = UserAPIKeyAuth(user_id="user-1") - response = ContainerListResponse( + return table + + +def _upstream(pages_by_after): + calls = [] + + async def fetch_page(after, limit): + calls.append((after, limit)) + return pages_by_after[after] + + return fetch_page, calls + + +def _page(*container_ids: str, has_more: bool) -> ContainerListResponse: + return ContainerListResponse( object="list", - data=[_container("cntr_owned"), _container("cntr_other")], - has_more=True, + data=[_container(container_id) for container_id in container_ids], + has_more=has_more, ) - filtered = await ownership.filter_container_list_response( - response=response, - user_api_key_dict=auth, + +async def _list_owned(fetch_page, after=None, limit=None): + return await ownership.list_owned_containers( + fetch_page=fetch_page, + after=after, + limit=limit, + user_api_key_dict=UserAPIKeyAuth(user_id="user-1"), custom_llm_provider="openai", ) - assert [item.id for item in filtered.data] == ["cntr_owned"] - assert filtered.first_id == "cntr_owned" - assert filtered.last_id == "cntr_owned" - assert filtered.has_more is False + +@pytest.mark.asyncio +async def test_should_page_upstream_until_owned_containers_fill_the_limit(monkeypatch): + table = _owned_containers_in_db(monkeypatch, "container:openai:cntr_owned") + fetch_page, calls = _upstream( + { + None: _page("cntr_other_1", "cntr_other_2", has_more=True), + "cntr_other_2": _page("cntr_owned", has_more=False), + } + ) + + listed = await _list_owned(fetch_page, limit=1) + + assert [item.id for item in listed.data] == ["cntr_owned"] + assert listed.first_id == "cntr_owned" + assert listed.last_id == "cntr_owned" + assert listed.has_more is False + assert calls == [(None, 100), ("cntr_other_2", 100)] where = table.find_many.await_args.kwargs["where"] assert where["file_purpose"] == ownership.CONTAINER_OBJECT_PURPOSE assert where["created_by"]["in"] == ["user-1", "user:user-1"] @pytest.mark.asyncio -async def test_should_clear_has_more_when_filtered_container_list_is_empty( - monkeypatch, -): - table = AsyncMock() - table.find_many.return_value = [ - SimpleNamespace(model_object_id="container:openai:cntr_owned"), - ] - prisma_client = SimpleNamespace( - db=SimpleNamespace(litellm_managedobjecttable=table) - ) - monkeypatch.setattr( - ownership, - "_get_prisma_client", - AsyncMock(return_value=prisma_client), - ) - auth = UserAPIKeyAuth(user_id="user-1") - response = ContainerListResponse( - object="list", - data=[_container("cntr_other")], - has_more=True, - ) +async def test_should_trim_owned_containers_to_the_limit_without_mutating_the_upstream_page(monkeypatch): + _owned_containers_in_db(monkeypatch, "container:openai:cntr_owned_1", "container:openai:cntr_owned_2") + upstream_page = _page("cntr_owned_1", "cntr_other", "cntr_owned_2", has_more=False) + fetch_page, calls = _upstream({None: upstream_page}) - filtered = await ownership.filter_container_list_response( - response=response, - user_api_key_dict=auth, - custom_llm_provider="openai", - ) + listed = await _list_owned(fetch_page, limit=1) - assert filtered.data == [] - assert filtered.first_id is None - assert filtered.last_id is None - assert filtered.has_more is False + assert [item.id for item in listed.data] == ["cntr_owned_1"] + assert listed.first_id == "cntr_owned_1" + assert listed.last_id == "cntr_owned_1" + assert listed.has_more is True + assert calls == [(None, 100)] + assert [item.id for item in upstream_page.data] == ["cntr_owned_1", "cntr_other", "cntr_owned_2"] + assert upstream_page.has_more is False @pytest.mark.asyncio -async def test_should_clear_dict_has_more_when_filtered_container_list_is_empty( - monkeypatch, -): - table = AsyncMock() - table.find_many.return_value = [ - SimpleNamespace(model_object_id="container:openai:cntr_owned"), - ] - prisma_client = SimpleNamespace( - db=SimpleNamespace(litellm_managedobjecttable=table) +async def test_should_start_paging_from_the_requested_cursor(monkeypatch): + _owned_containers_in_db(monkeypatch, "container:openai:cntr_owned_2") + fetch_page, calls = _upstream({"cntr_owned_1": _page("cntr_other", "cntr_owned_2", has_more=False)}) + + listed = await _list_owned(fetch_page, after="cntr_owned_1", limit=1) + + assert [item.id for item in listed.data] == ["cntr_owned_2"] + assert listed.has_more is False + assert calls == [("cntr_owned_1", 100)] + + +@pytest.mark.asyncio +async def test_should_default_to_twenty_owned_containers_per_page(monkeypatch): + owned_ids = tuple(f"cntr_owned_{index}" for index in range(21)) + _owned_containers_in_db(monkeypatch, *(f"container:openai:{container_id}" for container_id in owned_ids)) + fetch_page, _ = _upstream({None: _page(*owned_ids, has_more=False)}) + + listed = await _list_owned(fetch_page) + + assert [item.id for item in listed.data] == list(owned_ids[:20]) + assert listed.last_id == "cntr_owned_19" + assert listed.has_more is True + + +@pytest.mark.asyncio +async def test_should_stop_after_five_upstream_pages_and_keep_has_more(monkeypatch): + _owned_containers_in_db(monkeypatch, "container:openai:cntr_owned") + fetch_page, calls = _upstream( + { + None: _page("cntr_other_0", has_more=True), + **{f"cntr_other_{index}": _page(f"cntr_other_{index + 1}", has_more=True) for index in range(6)}, + } ) - monkeypatch.setattr( - ownership, - "_get_prisma_client", - AsyncMock(return_value=prisma_client), - ) - auth = UserAPIKeyAuth(user_id="user-1") - response = { + + listed = await _list_owned(fetch_page, limit=1) + + assert listed.data == [] + assert listed.first_id is None + assert listed.last_id is None + assert listed.has_more is True + assert len(calls) == 5 + + +@pytest.mark.asyncio +async def test_should_stop_when_upstream_has_no_more_pages(monkeypatch): + _owned_containers_in_db(monkeypatch, "container:openai:cntr_owned") + fetch_page, calls = _upstream({None: _page("cntr_other", has_more=False)}) + + listed = await _list_owned(fetch_page, limit=1) + + assert listed.data == [] + assert listed.has_more is False + assert calls == [(None, 100)] + + +@pytest.mark.asyncio +async def test_should_build_dict_pages_without_mutating_the_upstream_page(monkeypatch): + _owned_containers_in_db(monkeypatch, "container:openai:cntr_owned") + upstream_page = {"object": "list", "data": [{"id": "cntr_other"}, {"id": "cntr_owned"}], "has_more": False} + fetch_page, _ = _upstream({None: upstream_page}) + + listed = await _list_owned(fetch_page, limit=1) + + assert listed == { "object": "list", - "data": [{"id": "cntr_other"}], - "has_more": True, + "data": [{"id": "cntr_owned"}], + "first_id": "cntr_owned", + "last_id": "cntr_owned", + "has_more": False, } - - filtered = await ownership.filter_container_list_response( - response=response, - user_api_key_dict=auth, - custom_llm_provider="openai", - ) - - assert filtered["data"] == [] - assert filtered["first_id"] is None - assert filtered["last_id"] is None - assert filtered["has_more"] is False + assert [item["id"] for item in upstream_page["data"]] == ["cntr_other", "cntr_owned"] @pytest.mark.asyncio @@ -647,7 +693,7 @@ async def test_should_return_response_when_owner_recording_raises_unexpected( @pytest.mark.asyncio -async def test_should_filter_container_list_inside_list_endpoint(monkeypatch): +async def test_should_list_owned_containers_inside_list_endpoint(monkeypatch): from litellm.proxy.container_endpoints import endpoints proxy_server_stub = SimpleNamespace( @@ -665,42 +711,37 @@ async def test_should_filter_container_list_inside_list_endpoint(monkeypatch): ) monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", proxy_server_stub) - response = ContainerListResponse( - object="list", - data=[_container("cntr_provider")], - has_more=False, - ) - - class FakeProcessor: - def __init__(self, data): - pass - - async def base_process_llm_request(self, **kwargs): - return response - - async def _handle_llm_api_exception(self, **kwargs): - raise kwargs["e"] - - filter_response = AsyncMock(return_value=response) - monkeypatch.setattr(endpoints, "ProxyBaseLLMRequestProcessing", FakeProcessor) - monkeypatch.setattr( - endpoints, - "filter_container_list_response", - filter_response, + upstream_page = _page("cntr_provider", has_more=False) + processor_cls = MagicMock( + side_effect=lambda data: SimpleNamespace(base_process_llm_request=AsyncMock(return_value=upstream_page)) ) + monkeypatch.setattr(endpoints, "ProxyBaseLLMRequestProcessing", processor_cls) + list_owned = AsyncMock(return_value=upstream_page) + monkeypatch.setattr(endpoints, "list_owned_containers", list_owned) result = await endpoints.list_containers( request=SimpleNamespace(query_params={}, headers={}), fastapi_response=SimpleNamespace(), user_api_key_dict=UserAPIKeyAuth(user_id="user-1"), + after="cntr_prev", + limit=2, + order="desc", ) - assert result == response - filter_response.assert_awaited_once_with( - response=response, - user_api_key_dict=UserAPIKeyAuth(user_id="user-1"), - custom_llm_provider="openai", - ) + assert result == upstream_page + kwargs = list_owned.await_args.kwargs + assert kwargs["after"] == "cntr_prev" + assert kwargs["limit"] == 2 + assert kwargs["user_api_key_dict"] == UserAPIKeyAuth(user_id="user-1") + assert kwargs["custom_llm_provider"] == "openai" + processor_cls.assert_not_called() + + assert await kwargs["fetch_page"]("cntr_page_cursor", 100) == upstream_page + forwarded = processor_cls.call_args.kwargs["data"] + assert forwarded["after"] == "cntr_page_cursor" + assert forwarded["limit"] == 100 + assert forwarded["order"] == "desc" + assert forwarded["custom_llm_provider"] == "openai" @pytest.mark.asyncio diff --git a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py index fd7ab3afdab..4db131da62c 100644 --- a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py +++ b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py @@ -1014,6 +1014,8 @@ class TestAuthSchemeNormalization: [ ("Bearer abc", "Bearer", "abc"), ("bearer abc", "Bearer", "abc"), + ("Bearer\tabc", "Bearer", "abc"), + ("bearer\t\tabc", "Bearer", "abc"), (" Bearer abc ", "Bearer", "abc "), ("abc", "Bearer", "abc"), ("Bearerabc", "Bearer", "Bearerabc"), diff --git a/tests/test_litellm/integrations/azure_storage/test_azure_storage.py b/tests/test_litellm/integrations/azure_storage/test_azure_storage.py index a96eae0f9c3..6e1dab4a71a 100644 --- a/tests/test_litellm/integrations/azure_storage/test_azure_storage.py +++ b/tests/test_litellm/integrations/azure_storage/test_azure_storage.py @@ -42,6 +42,7 @@ def workload_identity_env_vars(monkeypatch): "AZURE_STORAGE_ENDPOINT_SUFFIX", "AZURE_CLIENT_SECRET", "AZURE_CREDENTIAL", + "AZURE_TOKEN_CREDENTIALS", "AZURE_SCOPE", ): monkeypatch.delenv(unset, raising=False) @@ -206,10 +207,28 @@ def test_default_chain_provider_is_storage_scoped_and_built_once_per_process(): assert first() == "chain-token" mock_builder.assert_called_once_with( azure_scope="https://storage.azure.com/.default", - azure_credential=AzureCredentialType.DefaultAzureCredential, + azure_credential=AzureCredentialType.DeploymentIdentityCredential, ) +def test_storage_chain_reaches_only_the_identities_a_deployment_carries(workload_identity_env_vars): + """ + The chain runs on a server, where a developer sign-in is a person and not the deployment, so + the storage token must come from workload identity or managed identity or from nothing + """ + _cached_credential_chain_token_provider.cache_clear() + with patch("azure.identity.get_bearer_token_provider", return_value=lambda: "chain-token") as bearer: + _cached_credential_chain_token_provider() + _cached_credential_chain_token_provider.cache_clear() + + bearer.assert_called_once() + with bearer.call_args.args[0] as chain: + assert {type(link).__name__ for link in chain.credentials} == { + "WorkloadIdentityCredential", + "ManagedIdentityCredential", + } + + @pytest.mark.asyncio async def test_chain_tokens_are_read_from_the_provider_on_every_refresh( workload_identity_env_vars, diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 7d70b9a8862..6edc2c9bf77 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -1091,8 +1091,8 @@ class TestCustomGuardrailPassthroughSupport: call_type=CallTypes.allm_passthrough_route, ) - # When result is None, should return the original response - assert result == mock_response + # None means the guardrail did not modify the response (LIT-5863 contract) + assert result is None @pytest.mark.asyncio async def test_async_post_call_success_deployment_hook_with_none_call_type(self): @@ -1120,8 +1120,8 @@ class TestCustomGuardrailPassthroughSupport: call_type=None, ) - # Should return the original response when result is None - assert result == mock_response + # None means the guardrail did not modify the response (LIT-5863 contract) + assert result is None def test_is_valid_response_type_with_none(self): """ @@ -2436,3 +2436,73 @@ class TestLoggingOnlyApplyGuardrail: assert guardrail.calls == [("request", ["hello there"]), ("response", ["general kenobi"])] entries = logging_obj.model_call_details["standard_logging_object"]["guardrail_information"] assert [e["guardrail_status"] for e in entries] == ["success", "success"] + + +class TestCustomGuardrailPostCallSuccessDeploymentHook: + """Regression tests for LIT-5863: this hook answering the unmodified response instead of + None made the utils.py dispatcher treat the guardrail as having modified the response, + which starved every later callback in litellm.callbacks (notably the lazily-appended + VectorStorePreCallHook that attaches provider_specific_fields["search_results"]).""" + + @pytest.mark.asyncio + async def test_returns_none_when_request_has_no_guardrails(self): + from litellm.types.utils import ModelResponse + + guardrail = CustomGuardrail(guardrail_name="test-guardrail") + response = ModelResponse() + + assert ( + await guardrail.async_post_call_success_deployment_hook( + request_data={}, response=response, call_type=CallTypes.acompletion + ) + is None + ) + assert ( + await guardrail.async_post_call_success_deployment_hook( + request_data={"guardrails": "not-a-list"}, response=response, call_type=CallTypes.acompletion + ) + is None + ) + + @pytest.mark.asyncio + async def test_returns_none_when_guardrail_should_not_run(self): + from litellm.types.guardrails import GuardrailEventHooks + from litellm.types.utils import ModelResponse + + guardrail = CustomGuardrail( + guardrail_name="test-guardrail", + event_hook=GuardrailEventHooks.pre_call, + ) + response = ModelResponse() + + result = await guardrail.async_post_call_success_deployment_hook( + request_data={"guardrails": ["test-guardrail"]}, + response=response, + call_type=CallTypes.acompletion, + ) + + assert result is None + + @pytest.mark.asyncio + async def test_returns_modified_response_when_guardrail_runs(self): + from litellm.types.guardrails import GuardrailEventHooks + from litellm.types.utils import ModelResponse + + replacement = ModelResponse() + + class ReplacingGuardrail(CustomGuardrail): + async def async_post_call_success_hook(self, data, user_api_key_dict, response): + return replacement + + guardrail = ReplacingGuardrail( + guardrail_name="test-guardrail", + event_hook=GuardrailEventHooks.post_call, + ) + + result = await guardrail.async_post_call_success_deployment_hook( + request_data={"guardrails": ["test-guardrail"]}, + response=ModelResponse(), + call_type=CallTypes.acompletion, + ) + + assert result is replacement diff --git a/tests/test_litellm/integrations/vector_store_integrations/test_vector_store_pre_call_hook.py b/tests/test_litellm/integrations/vector_store_integrations/test_vector_store_pre_call_hook.py new file mode 100644 index 00000000000..ae5cffd8ab0 --- /dev/null +++ b/tests/test_litellm/integrations/vector_store_integrations/test_vector_store_pre_call_hook.py @@ -0,0 +1,287 @@ +import logging +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Protocol + +import pytest + +import litellm +from litellm._logging import verbose_logger +from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import ( + ProxyServerRuntime, + VectorStorePreCallHook, +) +from litellm.types.llms.openai import AllMessageValues +from litellm.types.vector_stores import ( + VectorStoreResultContent, + VectorStoreSearchResponse, + VectorStoreSearchResult, +) +from litellm.vector_stores.vector_store_registry import ( + LiteLLM_ManagedVectorStore, + VectorStoreRegistry, +) + + +def _search_response(text: str) -> VectorStoreSearchResponse: + return VectorStoreSearchResponse( + object="vector_store.search_results.page", + search_query="what is litellm?", + data=[ + VectorStoreSearchResult( + score=1.0, + content=[VectorStoreResultContent(text=text, type="text")], + ) + ], + ) + + +@dataclass +class RecordingRouter: + failing_vector_store_ids: frozenset[str] = frozenset() + calls: list[dict[str, object]] = field(default_factory=list) + + async def avector_store_search(self, **kwargs: object) -> VectorStoreSearchResponse: + self.calls.append(kwargs) + vector_store_id = str(kwargs["vector_store_id"]) + if vector_store_id in self.failing_vector_store_ids: + raise litellm.BadRequestError( + message=f"no healthy deployments for {vector_store_id}", + model="text-embedding-3-small", + llm_provider="openai", + ) + return _search_response(f"context from {vector_store_id}") + + +@dataclass(frozen=True) +class FakeProxyRuntime: + router: RecordingRouter | None + + def llm_router(self) -> RecordingRouter | None: + return self.router + + def prisma_client(self) -> None: + return None + + +class RecordingHandler(logging.Handler): + def __init__(self) -> None: + super().__init__(level=logging.WARNING) + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + +class RegisterStores(Protocol): + def __call__(self, *vector_store_ids: str, custom_llm_provider: str = "bedrock") -> None: ... + + +@pytest.fixture +def registry_with(monkeypatch: pytest.MonkeyPatch) -> RegisterStores: + def _register(*vector_store_ids: str, custom_llm_provider: str = "bedrock") -> None: + monkeypatch.setattr( + litellm, + "vector_store_registry", + VectorStoreRegistry( + vector_stores=[ + LiteLLM_ManagedVectorStore(vector_store_id=vector_store_id, custom_llm_provider=custom_llm_provider) + for vector_store_id in vector_store_ids + ], + ), + ) + + return _register + + +@pytest.fixture +def warnings() -> Iterator[list[logging.LogRecord]]: + handler = RecordingHandler() + verbose_logger.addHandler(handler) + yield handler.records + verbose_logger.removeHandler(handler) + + +class FakeLoggingObj: + def __init__(self, metadata: dict[str, str]) -> None: + self.model_call_details: dict[str, object] = {"litellm_params": {"metadata": metadata}} + + +async def _run_hook( + hook: VectorStorePreCallHook, + vector_store_ids: list[str], + logging_obj: FakeLoggingObj, +) -> tuple[str, list[AllMessageValues], dict[str, object]]: + return await hook.async_get_chat_completion_prompt( + model="chat-model", + messages=[{"role": "user", "content": "what is litellm?"}], + non_default_params={"vector_store_ids": vector_store_ids}, + prompt_id=None, + prompt_variables=None, + dynamic_callback_params={}, + litellm_logging_obj=logging_obj, + ) + + +@pytest.mark.asyncio +async def test_hook_searches_through_the_injected_router_with_the_request_metadata( + registry_with: RegisterStores, +) -> None: + """Regression (LIT-6752): the hook must reach the Router through its injected runtime, not a proxy_server import.""" + registry_with("vs-router") + router = RecordingRouter() + logging_obj = FakeLoggingObj({"user_api_key_team_id": "team-a"}) + + _, messages, _ = await _run_hook( + VectorStorePreCallHook(proxy_runtime=FakeProxyRuntime(router=router)), + ["vs-router"], + logging_obj, + ) + + assert router.calls == [ + { + "vector_store_id": "vs-router", + "query": "what is litellm?", + "custom_llm_provider": "bedrock", + "metadata": {"user_api_key_team_id": "team-a"}, + } + ] + assert messages[0]["content"] == "Context:\n\ncontext from vs-router\n\n" + + +@pytest.mark.asyncio +async def test_hook_falls_back_to_the_sdk_when_the_runtime_has_no_router( + registry_with: RegisterStores, + warnings: list[logging.LogRecord], +) -> None: + registry_with("vs-sdk", custom_llm_provider="lit6752-not-a-provider") + + _, messages, _ = await _run_hook( + VectorStorePreCallHook(proxy_runtime=FakeProxyRuntime(router=None)), + ["vs-sdk"], + FakeLoggingObj({"user_api_key_team_id": "team-a"}), + ) + + assert messages == [{"role": "user", "content": "what is litellm?"}] + assert len(warnings) == 1 + assert ( + warnings[0] + .getMessage() + .startswith("Vector store search failed for vector_store_id=vs-sdk, continuing without its context: ") + ) + assert "is not a valid LlmProviders" in warnings[0].getMessage() + + +@pytest.mark.asyncio +async def test_every_healthy_vector_store_contributes_its_own_context(registry_with: RegisterStores) -> None: + """Regression (LIT-6752): each store appended its context to the original messages, so only the last one survived.""" + registry_with("vs-one", "vs-two") + router = RecordingRouter() + + _, messages, _ = await _run_hook( + VectorStorePreCallHook(proxy_runtime=FakeProxyRuntime(router=router)), + ["vs-one", "vs-two"], + FakeLoggingObj({}), + ) + + assert [message["content"] for message in messages] == [ + "Context:\n\ncontext from vs-one\n\n", + "Context:\n\ncontext from vs-two\n\n", + "what is litellm?", + ] + + +@pytest.mark.asyncio +async def test_a_failing_vector_store_warns_with_its_id_and_the_other_stores_still_answer( + registry_with: RegisterStores, + warnings: list[logging.LogRecord], +) -> None: + """Regression (LIT-6752): one unreachable store must not silently drop every other store's context.""" + registry_with("vs-broken", "vs-healthy") + router = RecordingRouter(failing_vector_store_ids=frozenset({"vs-broken"})) + logging_obj = FakeLoggingObj({"user_api_key_team_id": "team-a"}) + + _, messages, _ = await _run_hook( + VectorStorePreCallHook(proxy_runtime=FakeProxyRuntime(router=router)), + ["vs-broken", "vs-healthy"], + logging_obj, + ) + + search_results = logging_obj.model_call_details["search_results"] + + assert [call["vector_store_id"] for call in router.calls] == ["vs-broken", "vs-healthy"] + assert messages[0]["content"] == "Context:\n\ncontext from vs-healthy\n\n" + assert isinstance(search_results, list) + assert len(search_results) == 1 + assert [record.getMessage() for record in warnings] == [ + "Vector store search failed for vector_store_id=vs-broken, continuing without its context: " + "litellm.BadRequestError: no healthy deployments for vs-broken" + ] + + +@pytest.mark.asyncio +async def test_the_only_vector_store_failing_leaves_the_messages_untouched( + registry_with: RegisterStores, + warnings: list[logging.LogRecord], +) -> None: + registry_with("vs-broken") + original_messages = [{"role": "user", "content": "what is litellm?"}] + + _, messages, _ = await _run_hook( + VectorStorePreCallHook( + proxy_runtime=FakeProxyRuntime(router=RecordingRouter(failing_vector_store_ids=frozenset({"vs-broken"}))) + ), + ["vs-broken"], + FakeLoggingObj({}), + ) + + assert messages == original_messages + assert [(record.levelname, record.getMessage()) for record in warnings] == [ + ( + "WARNING", + "Vector store search failed for vector_store_id=vs-broken, continuing without its context: " + "litellm.BadRequestError: no healthy deployments for vs-broken", + ) + ] + + +@pytest.mark.asyncio +async def test_the_default_hook_reaches_the_proxy_router_through_its_runtime( + registry_with: RegisterStores, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression (LIT-6752): a hook built with no arguments must still search through the proxy's own Router.""" + from litellm.proxy import proxy_server + + registry_with("vs-default") + router = RecordingRouter() + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(proxy_server, "prisma_client", None) + + _, messages, _ = await _run_hook( + VectorStorePreCallHook(), + ["vs-default"], + FakeLoggingObj({"user_api_key_team_id": "team-a"}), + ) + + assert [call["vector_store_id"] for call in router.calls] == ["vs-default"] + assert messages[0]["content"] == "Context:\n\ncontext from vs-default\n\n" + + +def test_the_default_runtime_follows_the_proxy_globals(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.proxy import proxy_server + + runtime = ProxyServerRuntime() + monkeypatch.setattr(proxy_server, "llm_router", None) + monkeypatch.setattr(proxy_server, "prisma_client", None) + + assert runtime.llm_router() is None + assert runtime.prisma_client() is None + + router = RecordingRouter() + prisma = object() + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + + assert runtime.llm_router() is router + assert runtime.prisma_client() is prisma 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 0b6832d4bef..311ba7aebc0 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 @@ -29,11 +29,13 @@ from litellm.types.utils import ( from litellm.litellm_core_utils.llm_cost_calc.utils import ( CostCalculatorUtils, PromptTokensDetailsResult, + TokenRates, TokenTypeCostBreakdown, _calculate_input_cost, _get_token_base_cost, _is_off_peak, _is_within_off_peak_window, + apply_off_peak_pricing, calculate_cache_writing_cost, generic_cost_per_token, get_token_type_cost_breakdown, @@ -782,6 +784,258 @@ def test_get_token_base_cost_off_peak_wins_over_tiered_pricing(): assert outside[:2] == (3e-6, 6e-6) +def _register_off_peak_reasoning_model( + model_name: str, off_peak_pricing: dict, reasoning_rate: float | None = 4e-6, **service_tier_rates: float +) -> None: + reasoning_entry = {} if reasoning_rate is None else {"output_cost_per_reasoning_token": reasoning_rate} + litellm.register_model( + { + model_name: { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + "cache_read_input_token_cost": 1e-7, + "cache_creation_input_token_cost": 1.25e-6, + "off_peak_pricing": off_peak_pricing, + **reasoning_entry, + **service_tier_rates, + } + } + ) + + +def _off_peak_reasoning_usage() -> Usage: + return Usage( + prompt_tokens=100, + completion_tokens=80, + total_tokens=180, + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=30, text_tokens=50), + ) + + +def test_generic_cost_per_token_off_peak_reasoning_rate(): + from datetime import datetime, timezone + + model_name = "litellm-test-off-peak-reasoning" + _register_off_peak_reasoning_model( + model_name, + {"hours_utc": "16:30-00:30", "output_cost_per_token": 1e-6, "output_cost_per_reasoning_token": 5e-7}, + ) + + _, inside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc), + ) + assert inside == pytest.approx(50 * 1e-6 + 30 * 5e-7) + + _, outside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), + ) + assert outside == pytest.approx(50 * 2e-6 + 30 * 4e-6) + + +def test_generic_cost_per_token_off_peak_block_without_reasoning_rate(): + from datetime import datetime, timezone + + inside_window = datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc) + block = {"hours_utc": "16:30-00:30", "output_cost_per_token": 1e-6} + + _register_off_peak_reasoning_model("litellm-test-off-peak-model-reasoning-rate", block) + _, with_model_rate = generic_cost_per_token( + model="litellm-test-off-peak-model-reasoning-rate", + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=inside_window, + ) + assert with_model_rate == pytest.approx(50 * 1e-6 + 30 * 4e-6) + + _register_off_peak_reasoning_model("litellm-test-off-peak-no-reasoning-rate", block, reasoning_rate=None) + _, without_model_rate = generic_cost_per_token( + model="litellm-test-off-peak-no-reasoning-rate", + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=inside_window, + ) + assert without_model_rate == pytest.approx(80 * 1e-6) + + +def test_generic_cost_per_token_off_peak_reasoning_rate_wins_over_the_tier(): + from datetime import datetime, timezone + + model_name = "litellm-test-off-peak-tiered-reasoning" + litellm.register_model( + { + model_name: { + "litellm_provider": "openai", + "mode": "chat", + "tiered_pricing": [ + { + "range": [0, 128000], + "input_cost_per_token": 3e-6, + "output_cost_per_token": 6e-6, + "output_cost_per_reasoning_token": 8e-6, + }, + ], + "off_peak_pricing": { + "hours_utc": "16:30-00:30", + "output_cost_per_token": 1e-6, + "output_cost_per_reasoning_token": 5e-7, + }, + } + } + ) + + _, inside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc), + ) + assert inside == pytest.approx(50 * 1e-6 + 30 * 5e-7) + + _, outside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), + ) + assert outside == pytest.approx(50 * 6e-6 + 30 * 8e-6) + + +def test_generic_cost_per_token_off_peak_reasoning_rate_wins_over_the_service_tier(): + from datetime import datetime, timezone + + model_name = "litellm-test-off-peak-reasoning-service-tier" + _register_off_peak_reasoning_model( + model_name, + {"hours_utc": "16:30-00:30", "output_cost_per_token": 1e-6, "output_cost_per_reasoning_token": 5e-7}, + output_cost_per_token_priority=3e-6, + output_cost_per_reasoning_token_priority=6e-6, + ) + + _, inside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + service_tier="priority", + current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc), + ) + assert inside == pytest.approx(50 * 1e-6 + 30 * 5e-7) + + _, outside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + service_tier="priority", + current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), + ) + assert outside == pytest.approx(50 * 3e-6 + 30 * 6e-6) + + +def test_apply_off_peak_pricing_treats_bool_as_unset_and_parses_strings(): + from datetime import datetime, timezone + + model_name = "litellm-test-off-peak-odd-values" + _register_off_peak_reasoning_model( + model_name, + { + "hours_utc": "16:30-00:30", + "cache_creation_input_token_cost": True, + "output_cost_per_reasoning_token": "5e-7", + }, + ) + standard = TokenRates( + input_rate=1e-6, output_rate=2e-6, cache_read_rate=1e-7, cache_creation_rate=1.25e-6, reasoning_rate=4e-6 + ) + + rates = apply_off_peak_pricing( + litellm.get_model_info(model_name, custom_llm_provider="openai"), + datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc), + standard, + ) + assert rates.cache_creation_rate == 1.25e-6 + assert rates.reasoning_rate == 5e-7 + + +def test_get_token_base_cost_off_peak_cache_creation_rate(): + from datetime import datetime, timezone + from typing import cast + + from litellm.types.utils import ModelInfo + + model_info = cast( + ModelInfo, + { + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + "cache_creation_input_token_cost": 1.25e-6, + "cache_creation_input_token_cost_above_1hr": 2e-6, + "off_peak_pricing": {"hours_utc": "16:30-00:30", "cache_creation_input_token_cost": 5e-7}, + }, + ) + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) + inside_window = datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc) + + inside = _get_token_base_cost(model_info, usage, current_time=inside_window) + assert inside[2] == 5e-7 + assert inside[3] == 2e-6 + + outside = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)) + assert outside[2] == 1.25e-6 + + without_key = cast( + ModelInfo, + {**model_info, "off_peak_pricing": {"hours_utc": "16:30-00:30", "input_cost_per_token": 5e-7}}, + ) + assert _get_token_base_cost(without_key, usage, current_time=inside_window)[2] == 1.25e-6 + + +def test_get_token_type_cost_breakdown_reflects_off_peak_reasoning_and_cache_creation_rates(): + from datetime import datetime, timezone + + model_name = "litellm-test-off-peak-breakdown" + _register_off_peak_reasoning_model( + model_name, + { + "hours_utc": "16:30-00:30", + "output_cost_per_reasoning_token": 5e-7, + "cache_creation_input_token_cost": 5e-7, + }, + ) + usage = Usage( + prompt_tokens=1000, + completion_tokens=80, + total_tokens=1080, + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=30, text_tokens=50), + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=100, cache_creation_tokens=400, text_tokens=500), + ) + + inside = get_token_type_cost_breakdown( + model=model_name, + custom_llm_provider="openai", + usage=usage, + current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc), + ) + assert inside.reasoning_cost == pytest.approx(30 * 5e-7) + assert inside.cache_creation_cost == pytest.approx(400 * 5e-7) + assert inside.cache_read_cost == pytest.approx(100 * 1e-7) + + outside = get_token_type_cost_breakdown( + model=model_name, + custom_llm_provider="openai", + usage=usage, + current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), + ) + assert outside.reasoning_cost == pytest.approx(30 * 4e-6) + assert outside.cache_creation_cost == pytest.approx(400 * 1.25e-6) + + def test_generic_cost_per_token_gpt54_above_272k_tokens(_local_model_cost_map): """GPT-5.4/5.4-pro: prompts >272K input tokens priced at 2x input, 1.5x output.""" model = "gpt-5.4" diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index f6b8a93c472..fadc4ca49e9 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -312,3 +312,121 @@ def test_mask_credentials_in_payload_masks_only_sensitive_string_leaves(): assert masked != plaintext assert masked.startswith(plaintext[:4]) assert masked.endswith(plaintext[-4:]) + + +def test_redact_credentials_in_payload_leaves_no_fragment_of_the_secret(): + """A payload rendered straight to stdout cannot afford the partial reveal + mask_credentials_in_payload leaves, so every credential-named value is replaced + whole, nested header dicts included, while ordinary params survive verbatim.""" + from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload + + fake_key = "sk-fake-lit6823-0000000000000000" + fake_token = "fake-azure-ad-token-0000" + result = redact_credentials_in_payload( + { + "api_key": fake_key, + "azure_ad_token": fake_token, + "aws_secret_access_key": "fake-aws-secret-0000", + "vertex_credentials": {"private_key": "fake-pem"}, + "extra_headers": {"Authorization": "Bearer fake-bearer-0000", "x-request-id": "abc123"}, + "model": "gpt-4o-mini", + "max_tokens": 17, + "temperature": 0.25, + "api_base": None, + } + ) + + assert fake_key not in str(result) + assert fake_token not in str(result) + assert "fake-aws-secret-0000" not in str(result) + assert "fake-pem" not in str(result) + assert "fake-bearer-0000" not in str(result) + assert result["api_key"] == "REDACTED" + assert result["extra_headers"]["Authorization"] == "REDACTED" + assert result["extra_headers"]["x-request-id"] == "abc123" + assert result["model"] == "gpt-4o-mini" + assert result["max_tokens"] == 17 + assert result["temperature"] == 0.25 + assert result["api_base"] is None + + +def test_redact_credentials_in_payload_reaches_credentials_nested_in_sequences(): + """Free-form kwargs like extra_body and metadata routinely carry lists of dicts, so a + credential hiding one level inside a list or tuple must be replaced too, while the + surrounding container keeps its type and every ordinary element stays verbatim.""" + from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload + + result = redact_credentials_in_payload( + { + "extra_body": {"providers": [{"name": "openai", "api_key": "sk-fake-lit6823-in-a-list"}]}, + "metadata": {"upstreams": ({"aws_secret_access_key": "fake-aws-in-a-tuple"},)}, + "messages": [{"role": "user", "content": "hello"}], + } + ) + + assert "sk-fake-lit6823-in-a-list" not in str(result) + assert "fake-aws-in-a-tuple" not in str(result) + assert result["extra_body"]["providers"][0]["api_key"] == "REDACTED" + assert result["extra_body"]["providers"][0]["name"] == "openai" + assert isinstance(result["extra_body"]["providers"], list) + assert result["metadata"]["upstreams"][0]["aws_secret_access_key"] == "REDACTED" + assert isinstance(result["metadata"]["upstreams"], tuple) + assert result["messages"] == [{"role": "user", "content": "hello"}] + + +@pytest.mark.parametrize("wrap", ["mapping", "sequence"]) +def test_redact_credentials_in_payload_hides_containers_at_the_recursion_limit(wrap): + """The recursion limit exists to bound the walk, not to grant an exemption, so a caller who + buries a credential deeper than the limit must get the container hidden rather than handed + back verbatim. Nesting through lists costs depth twice as fast as nesting through mappings, + so both shapes are pushed well past the limit here.""" + from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH + from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload + + fake_key = "sk-fake-lit6835-past-the-limit" + node = {"api_key": fake_key} + for _ in range(2 * DEFAULT_MAX_RECURSE_DEPTH + 1): + node = {"extra_body": node} if wrap == "mapping" else {"providers": [node]} + + result = redact_credentials_in_payload({**node, "max_tokens": 17}) + + assert fake_key not in str(result) + assert "REDACTED" in str(result) + assert result["max_tokens"] == 17 + + +def test_redact_credentials_in_payload_leaves_a_realistic_tool_schema_intact(): + """The bound must not eat ordinary payloads: a tool whose JSON schema nests an array of + objects inside a nested object is what agent traffic looks like, and the verbose line is + useless if those leaves come back as REDACTED.""" + from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload + + tool = { + "type": "function", + "function": { + "name": "search_orders", + "parameters": { + "type": "object", + "properties": { + "filters": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": {"sku": {"type": "string"}, "qty": {"type": "integer"}}, + }, + } + }, + } + }, + }, + }, + } + + result = redact_credentials_in_payload({"model": "gpt-4o-mini", "tools": [tool], "api_key": "sk-fake-lit6835"}) + + assert "REDACTED" not in str(result["tools"]) + assert result["tools"][0] == tool + assert result["api_key"] == "REDACTED" diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_message_id.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_message_id.py new file mode 100644 index 00000000000..7cd789529c8 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_message_id.py @@ -0,0 +1,111 @@ +""" +Streaming ``/v1/messages`` against a model that is neither Anthropic nor OpenAI is served by +translating the call onto ``/v1/chat/completions``, and the ``msg_`` id the caller is streamed +is minted right here. It is the only request id such a caller ever sees, so the spend row has +to be keyed on that same value rather than on the provider's own completion id. +""" + +import datetime +import json + +import pytest +import respx + +import litellm +from litellm.llms.anthropic.experimental_pass_through.adapters.handler import ( + LiteLLMMessagesToCompletionTransformationHandler, +) +from litellm.llms.anthropic.experimental_pass_through.adapters.streaming_iterator import ( + AnthropicStreamWrapper, +) + +MESSAGES = [{"role": "user", "content": "hello"}] + +GROQ_CHAT_URL = "https://api.groq.com/openai/v1/chat/completions" + +CHAT_SSE_BODY = ( + b'data: {"id":"chatcmpl-lit6825","object":"chat.completion.chunk","created":1,' + b'"model":"kimi-k2","choices":[{"index":0,"delta":{"role":"assistant","content":"hi"},' + b'"finish_reason":null}]}\n\n' + b'data: {"id":"chatcmpl-lit6825","object":"chat.completion.chunk","created":1,' + b'"model":"kimi-k2","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],' + b'"usage":{"prompt_tokens":3,"completion_tokens":4,"total_tokens":7}}\n\n' + b"data: [DONE]\n\n" +) + + +def _logging_obj(call_id: str): + from litellm.litellm_core_utils.litellm_logging import Logging + + return Logging( + model="kimi-k2", + messages=MESSAGES, + stream=True, + call_type="anthropic_messages", + start_time=datetime.datetime.now(datetime.timezone.utc), + litellm_call_id=call_id, + function_id="1234", + ) + + +def _streamed_message_id(raw_events: list[bytes]) -> str: + events = [json.loads(chunk.decode().split("data: ", 1)[1]) for chunk in raw_events] + message_start = next(e for e in events if e["type"] == "message_start") + return message_start["message"]["id"] + + +@pytest.fixture(autouse=True) +def _intercept_groq(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("GROQ_API_KEY", "gsk-lit6825-test") + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + litellm.in_memory_llm_clients_cache.flush_cache() + respx_mock.post(GROQ_CHAT_URL).respond( + status_code=200, + headers={"Content-Type": "text/event-stream"}, + content=CHAT_SSE_BODY, + ) + + +@pytest.mark.asyncio +async def test_async_streaming_hands_the_logging_object_the_message_id_the_caller_is_streamed(): + logging_obj = _logging_obj("6825beef-0000-4000-8000-000000000010") + + sse = await LiteLLMMessagesToCompletionTransformationHandler.async_anthropic_messages_handler( + max_tokens=1024, + messages=MESSAGES, + model="groq/kimi-k2", + stream=True, + custom_llm_provider="groq", + litellm_logging_obj=logging_obj, + ) + streamed_id = _streamed_message_id([chunk async for chunk in sse]) + + assert streamed_id.startswith("msg_") + assert logging_obj.streamed_anthropic_message_id == streamed_id + + +def test_sync_streaming_hands_the_logging_object_the_message_id_the_caller_is_streamed(): + logging_obj = _logging_obj("6825beef-0000-4000-8000-000000000011") + + sse = LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler( + max_tokens=1024, + messages=MESSAGES, + model="groq/kimi-k2", + stream=True, + custom_llm_provider="groq", + litellm_logging_obj=logging_obj, + ) + streamed_id = _streamed_message_id(list(sse)) + + assert streamed_id.startswith("msg_") + assert logging_obj.streamed_anthropic_message_id == streamed_id + + +def test_concurrent_streams_are_keyed_on_their_own_message_id(): + """Two callers streaming at once must not be handed, or logged under, one another's id.""" + first = AnthropicStreamWrapper(completion_stream=iter([]), model="kimi-k2") + second = AnthropicStreamWrapper(completion_stream=iter([]), model="kimi-k2") + + assert first._message_id != second._message_id + assert _streamed_message_id(list(first.anthropic_sse_wrapper())) == first._message_id + assert _streamed_message_id(list(second.anthropic_sse_wrapper())) == second._message_id diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_handler.py index 589dc64f9b9..3383813245a 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_handler.py @@ -1,9 +1,11 @@ +import datetime import json import os import sys from unittest.mock import AsyncMock, patch import pytest +import respx sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../.."))) @@ -15,6 +17,18 @@ from litellm.llms.anthropic.experimental_pass_through.responses_adapters.handler MESSAGES = [{"role": "user", "content": "hello"}] +RESPONSES_SSE_BODY = ( + b"event: response.created\n" + b'data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_lit6825",' + b'"object":"response","created_at":1,"status":"in_progress","model":"gpt-5.6-luna","output":[],' + b'"parallel_tool_calls":true,"tool_choice":"auto","tools":[]}}\n\n' + b"event: response.completed\n" + b'data: {"type":"response.completed","sequence_number":1,"response":{"id":"resp_lit6825",' + b'"object":"response","created_at":1,"status":"completed","model":"gpt-5.6-luna","output":[],' + b'"parallel_tool_calls":true,"tool_choice":"auto","tools":[],' + b'"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7}}}\n\n' +) + def test_build_responses_kwargs_derives_prompt_cache_key_from_user_id(): responses_kwargs = _build_responses_kwargs( @@ -82,3 +96,47 @@ async def test_streaming_message_start_reports_the_provider_local_model(requeste message_start = next(e for e in events if e["type"] == "message_start") assert message_start["message"]["model"] == expected_reported_model + + +@pytest.mark.asyncio +async def test_streaming_hands_the_logging_object_the_message_id_the_caller_is_streamed( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + """ + The bridge mints the ``msg_`` id itself, and it is the only request id a streaming + /v1/messages caller ever sees, so the spend row has to be keyed on that same value. + """ + from litellm.litellm_core_utils.litellm_logging import Logging + + monkeypatch.setenv("OPENAI_API_KEY", "sk-lit6825-test") + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + litellm.in_memory_llm_clients_cache.flush_cache() + respx_mock.post("https://api.openai.com/v1/responses").respond( + status_code=200, + headers={"Content-Type": "text/event-stream"}, + content=RESPONSES_SSE_BODY, + ) + + logging_obj = Logging( + model="gpt-5.6-luna", + messages=MESSAGES, + stream=True, + call_type="anthropic_messages", + start_time=datetime.datetime.now(datetime.timezone.utc), + litellm_call_id="6825beef-0000-4000-8000-000000000003", + function_id="1234", + ) + + sse = await LiteLLMMessagesToResponsesAPIHandler.async_anthropic_messages_handler( + max_tokens=1024, + messages=MESSAGES, + model="openai/gpt-5.6-luna", + stream=True, + custom_llm_provider="openai", + litellm_logging_obj=logging_obj, + ) + events = [json.loads(chunk.decode().split("data: ", 1)[1]) async for chunk in sse] + + message_start = next(e for e in events if e["type"] == "message_start") + assert message_start["message"]["id"].startswith("msg_") + assert logging_obj.streamed_anthropic_message_id == message_start["message"]["id"] diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_moonshot_transformation.py b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_moonshot_transformation.py new file mode 100644 index 00000000000..531f334e460 --- /dev/null +++ b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_amazon_moonshot_transformation.py @@ -0,0 +1,66 @@ +import pytest + +from litellm.llms.bedrock.chat.invoke_transformations.amazon_moonshot_transformation import ( + AmazonMoonshotConfig, +) + +AWS_AUTH_PARAMS = { + "aws_access_key_id": "AKIAEXAMPLE", + "aws_secret_access_key": "secret", + "aws_session_token": "token", + "aws_region_name": "us-west-2", + "aws_session_name": "session", + "aws_role_name": "arn:aws:iam::000000000000:role/example", + "aws_web_identity_token": "web-identity", + "aws_sts_endpoint": "https://sts.us-west-2.amazonaws.com", + "aws_bedrock_runtime_endpoint": "https://bedrock-runtime.us-west-2.amazonaws.com", + "aws_external_id": "external", +} + + +def test_transform_request_never_resolves_aws_credentials(): + """A broken credential chain must not stop the request body from being built.""" + config = AmazonMoonshotConfig() + + transformed = config.transform_request( + model="bedrock/invoke/moonshot.kimi-k2-thinking", + messages=[{"role": "user", "content": "Hello"}], + optional_params={"aws_profile_name": "litellm-profile-that-does-not-exist", "max_tokens": 16}, + litellm_params={}, + headers={}, + ) + + assert transformed["model"] == "moonshot.kimi-k2-thinking" + assert transformed["max_tokens"] == 16 + assert "aws_profile_name" not in transformed + + +@pytest.mark.parametrize("aws_param", sorted(AWS_AUTH_PARAMS)) +def test_transform_request_keeps_aws_params_out_of_the_body(aws_param: str): + config = AmazonMoonshotConfig() + + transformed = config.transform_request( + model="bedrock/invoke/moonshot.kimi-k2-thinking", + messages=[{"role": "user", "content": "Hello"}], + optional_params={aws_param: AWS_AUTH_PARAMS[aws_param]}, + litellm_params={}, + headers={}, + ) + + assert aws_param not in transformed + + +def test_transform_request_leaves_the_caller_aws_params_in_place_for_signing(): + """sign_request reads the aws_* keys off optional_params after transform_request runs.""" + config = AmazonMoonshotConfig() + optional_params = dict(AWS_AUTH_PARAMS) + + config.transform_request( + model="bedrock/invoke/moonshot.kimi-k2-thinking", + messages=[{"role": "user", "content": "Hello"}], + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + + assert optional_params == AWS_AUTH_PARAMS diff --git a/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py b/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py index 21e3239f623..c4d6896b17b 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py +++ b/tests/test_litellm/llms/bedrock/chat/test_bedrock_converse_handler.py @@ -513,3 +513,27 @@ def test_the_rust_opt_in_needs_no_sigv4_principal(): assert not {"aws_access_key_id", "aws_secret_access_key", "aws_session_token"} & params.keys() assert params["aws_region_name"] == "us-east-1" assert seen["call"][0]["api_key"] == "bedrock-bearer-token" + + +@pytest.mark.parametrize("configured_through", ["env_var", "api_key"]) +def test_bearer_token_auth_never_runs_the_sigv4_credential_chain(monkeypatch, configured_through): + """The deployment's AWS profile does not exist, so resolving SigV4 credentials + raises; a bearer-token deployment must still serve the request, since the + bearer token alone signs it.""" + if configured_through == "env_var": + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-bearer-token") + else: + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) + client = _sync_client_returning_converse_response() + + response = BedrockConverseLLM().completion( + **_completion_kwargs( + optional_params={"maxTokens": 16, "aws_profile_name": "litellm-no-such-aws-profile"}, + litellm_params={}, + client=client, + api_key="bedrock-bearer-token" if configured_through == "api_key" else None, + ) + ) + + assert response.choices[0].message.content == "hi" + assert client.post.call_args.kwargs["headers"]["Authorization"] == "Bearer bedrock-bearer-token" diff --git a/tests/test_litellm/llms/bedrock/embed/test_bedrock_embedding.py b/tests/test_litellm/llms/bedrock/embed/test_bedrock_embedding.py index 08d01127eba..50f8bbcf584 100644 --- a/tests/test_litellm/llms/bedrock/embed/test_bedrock_embedding.py +++ b/tests/test_litellm/llms/bedrock/embed/test_bedrock_embedding.py @@ -1033,3 +1033,29 @@ def test_load_credentials_assumes_role_with_external_id(monkeypatch): assert credentials.token == "assumed-session-token" assert aws_region_name == "us-east-1" assert "aws_external_id" not in optional_params + + +def test_bedrock_embedding_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch): + """The deployment's AWS profile does not exist, so resolving SigV4 credentials + raises; a bearer-token deployment must still serve the request, since the + bearer token alone signs it.""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + client = HTTPHandler() + + with patch.object(client, "post") as mock_post: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = json.dumps(titan_embedding_response) + mock_response.json = lambda: json.loads(mock_response.text) + mock_post.return_value = mock_response + + response = litellm.embedding( + model="bedrock/amazon.titan-embed-text-v1", + input=test_input, + client=client, + aws_region_name="us-west-2", + aws_profile_name="litellm-no-such-aws-profile", + ) + + assert response.data[0]["embedding"] == titan_embedding_response["embedding"] + assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer env-bearer-token-12345" diff --git a/tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py b/tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py index 7c36b2aa75f..0b11a66c100 100644 --- a/tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py +++ b/tests/test_litellm/llms/bedrock/image/test_bedrock_image_bearer_token.py @@ -135,3 +135,24 @@ class TestBedrockImageGeneration: assert response is not None assert len(response.data) > 0 mock_bedrock_image_gen.assert_called_once() + + +def test_image_generation_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch): + """The deployment's AWS profile does not exist, so resolving SigV4 credentials + raises; a bearer-token deployment must still sign the request with the + bearer token alone.""" + from litellm.llms.bedrock.image_generation.image_handler import BedrockImageGeneration + + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + + request = BedrockImageGeneration()._prepare_request( + model="amazon.nova-canvas-v1:0", + prompt="A cute baby sea otter", + optional_params={"aws_region_name": "us-west-2", "aws_profile_name": "litellm-no-such-aws-profile"}, + api_base=None, + extra_headers=None, + api_key=None, + logging_obj=Mock(), + ) + + assert request.prepped.headers["Authorization"] == "Bearer env-bearer-token-12345" diff --git a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py index 020b8df1276..58411a9ae18 100644 --- a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py +++ b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py @@ -3,6 +3,7 @@ import base64 import io from typing import cast +from unittest.mock import Mock, patch import httpx import pytest @@ -655,3 +656,23 @@ def test_transform_response_empty_images_without_error_raises(): raw_response=resp, logging_obj=None, # type: ignore[arg-type] ) + + +def test_prepare_request_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch): + """The deployment's AWS profile does not exist, so resolving SigV4 credentials + raises; a bearer-token deployment must still sign the request with the + bearer token alone.""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + + request = BedrockImageEdit()._prepare_request( + model="amazon.nova-canvas-v1:0", + image=[io.BytesIO(b"fake-png")], + prompt="make it warmer", + optional_params={"aws_region_name": "us-west-2", "aws_profile_name": "litellm-no-such-aws-profile"}, + api_base=None, + extra_headers=None, + logging_obj=Mock(), + api_key=None, + ) + + assert request.prepped.headers["Authorization"] == "Bearer env-bearer-token-12345" diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index 1d583c16ad7..023e2d8843f 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -3098,3 +3098,91 @@ async def test_a_provider_that_keeps_rejecting_is_not_retried_forever_on_the_asy ) assert len(recorder.bodies) == 2 + + +CONTAINER_NOT_FOUND_BODY = { + "error": { + "message": "Container with id 'cntr_gone' not found.", + "type": "invalid_request_error", + "param": None, + "code": None, + } +} + +INVALID_API_KEY_BODY = { + "error": { + "message": "Incorrect API key provided: sk-proj-***. You can find your API key at https://platform.openai.com/account/api-keys.", + "type": "invalid_request_error", + "param": None, + "code": "invalid_api_key", + }, + "status": 401, +} + +CONTAINER_LIST_BODY = { + "object": "list", + "data": [{"id": "cntr_a", "object": "container", "created_at": 1, "status": "running", "name": "a"}], + "first_id": "cntr_a", + "last_id": "cntr_a", + "has_more": True, +} + + +def _container_sync_client(response: httpx.Response) -> HTTPHandler: + client = HTTPHandler() + client.client = httpx.Client(transport=httpx.MockTransport(lambda _request: response)) + return client + + +def _container_async_client(response: httpx.Response) -> AsyncHTTPHandler: + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: response)) + return client + + +def test_container_retrieve_handler_raises_upstream_error_status_and_message(): + from litellm.llms.openai.containers.transformation import OpenAIContainerConfig + + with pytest.raises(BaseLLMException) as exc_info: + BaseLLMHTTPHandler().container_retrieve_handler( + container_id="cntr_gone", + container_provider_config=OpenAIContainerConfig(), + litellm_params=GenericLiteLLMParams(api_key="sk-test"), + logging_obj=Mock(), + client=_container_sync_client(httpx.Response(404, json=CONTAINER_NOT_FOUND_BODY)), + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.message == "Container with id 'cntr_gone' not found." + + +@pytest.mark.asyncio +async def test_async_container_list_handler_raises_upstream_error_status_and_message(): + from litellm.llms.openai.containers.transformation import OpenAIContainerConfig + + with pytest.raises(BaseLLMException) as exc_info: + await BaseLLMHTTPHandler().async_container_list_handler( + container_provider_config=OpenAIContainerConfig(), + litellm_params=GenericLiteLLMParams(api_key="sk-rejected"), + logging_obj=Mock(), + client=_container_async_client(httpx.Response(401, json=INVALID_API_KEY_BODY)), + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.message == INVALID_API_KEY_BODY["error"]["message"] + + +@pytest.mark.asyncio +async def test_async_container_list_handler_transforms_success_response(): + from litellm.llms.openai.containers.transformation import OpenAIContainerConfig + + response = await BaseLLMHTTPHandler().async_container_list_handler( + container_provider_config=OpenAIContainerConfig(), + litellm_params=GenericLiteLLMParams(api_key="sk-test"), + logging_obj=Mock(), + limit=1, + client=_container_async_client(httpx.Response(200, json=CONTAINER_LIST_BODY)), + ) + + assert [container.id for container in response.data] == ["cntr_a"] + assert response.has_more is True diff --git a/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py b/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py index b6281834f24..a30d35d46f2 100644 --- a/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py +++ b/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py @@ -649,6 +649,90 @@ class TestDashscopeCostCalculator: assert math.isclose(completion_cost, 200 * 2.4e-06, rel_tol=1e-10) + def test_dashscope_off_peak_reasoning_rate_replaces_the_dedicated_reasoning_rate(self): + self._register_off_peak_flat_model( + "dashscope/qwen-reasoning-rate-off-peak-test", + { + "hours_utc": self.OFF_PEAK_WINDOW, + "output_cost_per_token": 2.4e-06, + "output_cost_per_reasoning_token": 4.5e-06, + }, + ) + litellm.model_cost["dashscope/qwen-reasoning-rate-off-peak-test"]["output_cost_per_reasoning_token"] = 9e-06 + usage = Usage( + prompt_tokens=100, + completion_tokens=200, + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=50), + ) + + _, completion_cost = dashscope_cost_per_token( + model="qwen-reasoning-rate-off-peak-test", usage=usage, current_time=self.INSIDE_WINDOW + ) + assert math.isclose(completion_cost, (150 * 2.4e-06) + (50 * 4.5e-06), rel_tol=1e-10) + + _, peak_completion_cost = dashscope_cost_per_token( + model="qwen-reasoning-rate-off-peak-test", usage=usage, current_time=self.OUTSIDE_WINDOW + ) + assert math.isclose(peak_completion_cost, (150 * 4.8e-06) + (50 * 9e-06), rel_tol=1e-10) + + def test_dashscope_off_peak_cache_creation_rate_replaces_the_standard_rate(self): + self._register_off_peak_flat_model( + "dashscope/qwen-cache-creation-off-peak-test", + {"hours_utc": self.OFF_PEAK_WINDOW, "cache_creation_input_token_cost": 1.5e-06}, + ) + usage = Usage( + prompt_tokens=1000, + completion_tokens=10, + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=300, cache_creation_tokens=100), + ) + + prompt_cost, _ = dashscope_cost_per_token( + model="qwen-cache-creation-off-peak-test", usage=usage, current_time=self.INSIDE_WINDOW + ) + assert math.isclose(prompt_cost, (600 * 2.4e-06) + (300 * 2e-07) + (100 * 1.5e-06), rel_tol=1e-10) + + peak_prompt_cost, _ = dashscope_cost_per_token( + model="qwen-cache-creation-off-peak-test", usage=usage, current_time=self.OUTSIDE_WINDOW + ) + assert math.isclose(peak_prompt_cost, (600 * 2.4e-06) + (300 * 2e-07) + (100 * 3e-06), rel_tol=1e-10) + + def test_dashscope_off_peak_reasoning_and_cache_creation_rates_override_the_selected_tier(self): + self._register_tiered_model( + "dashscope/qwen-tiered-reasoning-off-peak-test", + [ + { + "range": [0, 1000], + "input_cost_per_token": 4e-07, + "cache_creation_input_token_cost": 3e-07, + "output_cost_per_token": 1.6e-06, + "output_cost_per_reasoning_token": 3.2e-06, + }, + ], + ) + litellm.model_cost["dashscope/qwen-tiered-reasoning-off-peak-test"]["off_peak_pricing"] = { + "hours_utc": self.OFF_PEAK_WINDOW, + "cache_creation_input_token_cost": 1e-07, + "output_cost_per_reasoning_token": 8e-07, + } + usage = Usage( + prompt_tokens=500, + completion_tokens=100, + prompt_tokens_details=PromptTokensDetailsWrapper(cache_creation_tokens=200), + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=40), + ) + + prompt_cost, completion_cost = dashscope_cost_per_token( + model="qwen-tiered-reasoning-off-peak-test", usage=usage, current_time=self.INSIDE_WINDOW + ) + assert math.isclose(prompt_cost, (300 * 4e-07) + (200 * 1e-07), rel_tol=1e-10) + assert math.isclose(completion_cost, (60 * 1.6e-06) + (40 * 8e-07), rel_tol=1e-10) + + peak_prompt_cost, peak_completion_cost = dashscope_cost_per_token( + model="qwen-tiered-reasoning-off-peak-test", usage=usage, current_time=self.OUTSIDE_WINDOW + ) + assert math.isclose(peak_prompt_cost, (300 * 4e-07) + (200 * 3e-07), rel_tol=1e-10) + assert math.isclose(peak_completion_cost, (60 * 1.6e-06) + (40 * 3.2e-06), rel_tol=1e-10) + def test_dashscope_off_peak_defaults_to_the_current_time(self): """The proxy's cost dispatch passes no clock, so an all-day window has to apply on the default current time.""" diff --git a/tests/test_litellm/llms/openai/test_openai_workload_identity.py b/tests/test_litellm/llms/openai/test_openai_workload_identity.py index d8d9936e9a1..db107e00df0 100644 --- a/tests/test_litellm/llms/openai/test_openai_workload_identity.py +++ b/tests/test_litellm/llms/openai/test_openai_workload_identity.py @@ -85,6 +85,31 @@ class TestResolveConfig: def test_plaintext_http_api_base_disables(self, wif_env: OpenAIWorkloadIdentityConfig) -> None: assert resolve_openai_workload_identity_config(api_key=None, api_base="http://api.openai.com/v1") is None + @pytest.mark.parametrize( + "api_base", + ( + "https://southcentralus.privatelink.api.openai.com/v1", + "https://eu.api.openai.com/v1", + "https://us.api.openai.com/v1", + ), + ) + def test_openai_backed_api_base_allows(self, wif_env: OpenAIWorkloadIdentityConfig, api_base: str) -> None: + assert resolve_openai_workload_identity_config(api_key=None, api_base=api_base) == wif_env + + @pytest.mark.parametrize( + "api_base", + ( + "https://api.openai.com.evil.example/v1", + "https://openai.com/v1", + "https://euapi.openai.com/v1", + "http://southcentralus.privatelink.api.openai.com/v1", + ), + ) + def test_lookalike_or_plaintext_api_base_disables( + self, wif_env: OpenAIWorkloadIdentityConfig, api_base: str + ) -> None: + assert resolve_openai_workload_identity_config(api_key=None, api_base=api_base) is None + def test_foreign_env_base_url_disables( self, wif_env: OpenAIWorkloadIdentityConfig, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -158,6 +183,14 @@ class TestClientConstruction: assert client.api_key == "workload-identity-auth" assert client._workload_identity_auth is not None + def test_privatelink_client_uses_workload_identity(self, wif_env: OpenAIWorkloadIdentityConfig) -> None: + client: Final = OpenAIChatCompletion()._get_openai_client( + is_async=False, api_key=None, api_base="https://southcentralus.privatelink.api.openai.com/v1" + ) + assert isinstance(client, OpenAI) + assert client.api_key == "workload-identity-auth" + assert client._workload_identity_auth is not None + def test_static_key_client_unaffected(self, wif_env: OpenAIWorkloadIdentityConfig) -> None: client: Final = OpenAIChatCompletion()._get_openai_client(is_async=False, api_key="sk-static", api_base=None) assert isinstance(client, OpenAI) @@ -231,6 +264,16 @@ class TestResponsesValidateEnvironment: ) assert headers["Authorization"] == "Bearer None" + @respx.mock + def test_privatelink_api_base_mints_bearer(self, wif_env: OpenAIWorkloadIdentityConfig) -> None: + mock_token_exchange() + headers: Final = OpenAIResponsesAPIConfig().validate_environment( + headers={}, + model="gpt-4o-mini", + litellm_params=GenericLiteLLMParams(api_base="https://southcentralus.privatelink.api.openai.com/v1"), + ) + assert headers["Authorization"] == "Bearer exchanged-bearer-token" + def test_litellm_proxy_subclass_never_mints_wif(self, wif_env: OpenAIWorkloadIdentityConfig) -> None: headers: Final = LiteLLMProxyResponsesAPIConfig().validate_environment( headers={}, model="gpt-4o-mini", litellm_params=GenericLiteLLMParams() diff --git a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py index a182656e4a8..25a961c3413 100644 --- a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py +++ b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py @@ -17,7 +17,7 @@ import pytest import litellm from litellm import completion, acompletion from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler -from litellm.llms.snowflake.chat.transformation import SnowflakeConfig +from litellm.llms.snowflake.chat.transformation import SnowflakeConfig, SnowflakeStreamingHandler from litellm.types.utils import ModelResponse @@ -114,8 +114,7 @@ class TestSnowflakeToolTransformation: ) assert transformed_request["tool_choice"] == value, ( - f"tool_choice='{value}' should pass through unchanged, " - f"got {transformed_request['tool_choice']}" + f"tool_choice='{value}' should pass through unchanged, got {transformed_request['tool_choice']}" ) def test_transform_response_with_tool_calls(self): @@ -159,9 +158,7 @@ class TestSnowflakeToolTransformation: headers={"Content-Type": "application/json"}, ) - model_response = ModelResponse( - choices=[litellm.Choices(index=0, message=litellm.Message())] - ) + model_response = ModelResponse(choices=[litellm.Choices(index=0, message=litellm.Message())]) logging_obj = MagicMock() @@ -232,9 +229,7 @@ class TestSnowflakeToolTransformation: headers={"Content-Type": "application/json"}, ) - model_response = ModelResponse( - choices=[litellm.Choices(index=0, message=litellm.Message())] - ) + model_response = ModelResponse(choices=[litellm.Choices(index=0, message=litellm.Message())]) logging_obj = MagicMock() @@ -280,9 +275,7 @@ class TestSnowflakeToolTransformation: headers={"Content-Type": "application/json"}, ) - model_response = ModelResponse( - choices=[litellm.Choices(index=0, message=litellm.Message())] - ) + model_response = ModelResponse(choices=[litellm.Choices(index=0, message=litellm.Message())]) logging_obj = MagicMock() @@ -300,10 +293,7 @@ class TestSnowflakeToolTransformation: # Verify standard response works assert isinstance(result, ModelResponse) - assert ( - result.choices[0].message.content - == "Hello! I'm doing well, thank you for asking." - ) + assert result.choices[0].message.content == "Hello! I'm doing well, thank you for asking." def test_get_supported_openai_params_includes_tools(self): """ @@ -318,6 +308,385 @@ class TestSnowflakeToolTransformation: assert "max_tokens" in supported_params +class TestSnowflakeCortexClaudeFixes: + def setup_method(self): + self.config = SnowflakeConfig() + + @staticmethod + def _transform(messages, optional_params=None): + return SnowflakeConfig().transform_request( + model="snowflake/claude-sonnet-4-6", + messages=messages, + optional_params=optional_params or {}, + litellm_params={}, + headers={}, + ) + + def test_thinking_is_offered_on_every_claude_model(self): + """Cortex documents extended thinking (budget_tokens) for Claude generally, so a + 4.6-only gate would silently drop it on the models that do support it.""" + for model in ( + "snowflake/claude-sonnet-4-6", + "snowflake/claude-sonnet-4-5", + "snowflake/claude-3-7-sonnet", + "snowflake/claude-4-opus", + ): + assert "thinking" in self.config.get_supported_openai_params(model), model + assert "thinking" not in self.config.get_supported_openai_params("snowflake/llama3.1-70b") + + def test_system_blocks_preserve_cache_control_and_strip_ttl(self): + body = self._transform( + [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are helpful", + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + } + ], + }, + {"role": "user", "content": "hi"}, + ] + ) + assert body["system"] == [{"type": "text", "text": "You are helpful", "cache_control": {"type": "ephemeral"}}] + + def test_direct_system_param_is_normalized(self): + body = self._transform( + [{"role": "user", "content": "hi"}], + {"system": [{"type": "text", "text": "direct", "cache_control": {"type": "ephemeral", "ttl": "1h"}}]}, + ) + assert body["system"] == [{"type": "text", "text": "direct", "cache_control": {"type": "ephemeral"}}] + + def test_message_and_tool_cache_control_are_normalized(self): + body = self._transform( + [ + { + "role": "user", + "content": [{"type": "text", "text": "hi", "cache_control": {"type": "ephemeral", "ttl": "1h"}}], + } + ], + { + "tools": [ + { + "name": "f", + "input_schema": {"type": "object", "properties": {}}, + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + } + ] + }, + ) + assert body["messages"][0]["content"][0]["cache_control"] == {"type": "ephemeral"} + assert body["tools"][0]["cache_control"] == {"type": "ephemeral"} + + def test_extra_body_message_override_is_normalized(self): + body = self._transform( + [{"role": "user", "content": "original"}], + { + "extra_body": { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "override", + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + } + ], + } + ] + } + }, + ) + assert body["messages"][0]["content"][0]["cache_control"] == {"type": "ephemeral"} + + def test_image_blocks_are_converted_to_anthropic_source(self): + body = self._transform( + [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,ZmFrZQ==", "format": "image/jpeg"}, + } + ], + } + ] + ) + assert body["messages"][0]["content"] == [ + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "ZmFrZQ=="}} + ] + + def test_tool_result_image_list_is_converted(self): + body = self._transform( + [ + {"role": "user", "content": "look"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "read", "arguments": "{}"}} + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": [{"type": "image_url", "image_url": {"url": "data:image/png;base64,ZmFrZQ=="}}], + }, + ] + ) + assert body["messages"][2]["content"][0]["content"] == [ + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "ZmFrZQ=="}} + ] + + def test_tool_result_preserves_cache_control(self): + """A cache breakpoint the bridge puts on a tool message must survive onto the tool_result.""" + for tool_content in ("done", [{"type": "text", "text": "done"}]): + body = self._transform( + [ + {"role": "user", "content": "look"}, + { + "role": "tool", + "tool_call_id": "call_1", + "content": tool_content, + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + }, + ] + ) + tool_result = body["messages"][1]["content"][0] + assert tool_result["cache_control"] == {"type": "ephemeral"}, tool_content + + def test_pdf_data_uri_becomes_a_document_block(self): + """A bridged pdf data URI is a document block; forwarding it as an image is malformed.""" + body = self._transform( + [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": "data:application/pdf;base64,ZmFrZQ=="}}, + ], + } + ] + ) + assert body["messages"][0]["content"] == [ + { + "type": "document", + "source": {"type": "base64", "media_type": "application/pdf", "data": "ZmFrZQ=="}, + } + ] + + def test_multipart_tool_result_preserves_text_and_converts_image(self): + body = self._transform( + [ + {"role": "user", "content": "look"}, + { + "role": "tool", + "tool_call_id": "call_1", + "content": [ + {"type": "text", "text": "first"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,ZmFrZQ=="}}, + {"type": "text", "text": "last"}, + ], + }, + ] + ) + assert body["messages"][1]["content"][0]["content"] == [ + {"type": "text", "text": "first"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "ZmFrZQ=="}}, + {"type": "text", "text": "last"}, + ] + + def test_plain_text_tool_result_remains_string(self): + body = self._transform( + [{"role": "user", "content": "look"}, {"role": "tool", "tool_call_id": "call_1", "content": "done"}] + ) + assert body["messages"][1]["content"][0]["content"] == "done" + + def test_anthropic_tool_schema_strips_only_top_level_schema_key(self): + tools = [ + { + "name": "f", + "input_schema": {"$schema": "schema", "type": "object", "properties": {"$schema": {"type": "string"}}}, + } + ] + body = self._transform([{"role": "user", "content": "hi"}], {"tools": tools}) + schema = body["tools"][0]["input_schema"] + assert "$schema" not in schema + assert "$schema" in schema["properties"] + + def test_tool_schema_strips_only_top_level_schema_key(self): + tools = [ + { + "type": "function", + "function": { + "name": "f", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {"$schema": {"type": "string"}}, + }, + }, + } + ] + body = self._transform([{"role": "user", "content": "hi"}], {"tools": tools}) + schema = body["tools"][0]["input_schema"] + assert "$schema" not in schema + assert "$schema" in schema["properties"] + + def test_streaming_tool_identity_is_emitted_only_on_start(self): + handler = SnowflakeStreamingHandler(streaming_response=[], sync_stream=True) + start = handler.chunk_parser( + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "tool_use", "id": "tool_1", "name": "read"}, + } + ) + first_delta = handler.chunk_parser( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": '{"path":'}, + } + ) + second_delta = handler.chunk_parser( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": '"/tmp"}'}, + } + ) + + def _tool_call(chunk): + return chunk.choices[0].delta.tool_calls[0] + + assert _tool_call(start).id == "tool_1" + assert _tool_call(start).function.name == "read" + assert _tool_call(first_delta).id is None + assert _tool_call(first_delta).function.name is None + assert _tool_call(second_delta).id is None + assert _tool_call(second_delta).function.name is None + assert _tool_call(first_delta).function.arguments == '{"path":' + assert _tool_call(second_delta).function.arguments == '"/tmp"}' + + def test_signed_thinking_blocks_lead_the_assistant_turn(self): + """Multi-turn tool use with thinking only works if the signed block is echoed back first.""" + body = self._transform( + [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "thinking_blocks": [ + {"type": "thinking", "thinking": "391", "signature": "Eto"}, + {"type": "thinking", "thinking": "unsigned"}, + ], + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "read", "arguments": "{}"}} + ], + }, + ] + ) + blocks = body["messages"][1]["content"] + assert blocks[0] == {"type": "thinking", "thinking": "391", "signature": "Eto"} + assert [b["type"] for b in blocks] == ["thinking", "tool_use"] + + def test_signed_thinking_blocks_lead_a_plain_text_assistant_turn(self): + """A thinking response without a tool call must also round-trip on the next request.""" + body = self._transform( + [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "391", + "thinking_blocks": [{"type": "thinking", "thinking": "391", "signature": "Eto"}], + }, + {"role": "user", "content": "continue"}, + ] + ) + assert body["messages"][1] == { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "391", "signature": "Eto"}, + {"type": "text", "text": "391"}, + ], + } + + def test_signed_thinking_blocks_preserve_list_content(self): + """Cached assistant text reaches this transform as a content list, not a string.""" + body = self._transform( + [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": [{"type": "text", "text": "391", "cache_control": {"type": "ephemeral"}}], + "thinking_blocks": [{"type": "thinking", "thinking": "391", "signature": "Eto"}], + }, + {"role": "user", "content": "continue"}, + ] + ) + assert body["messages"][1] == { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "391", "signature": "Eto"}, + {"type": "text", "text": "391", "cache_control": {"type": "ephemeral"}}, + ], + } + + def test_thinking_only_assistant_turn_sends_no_empty_text_block(self): + """Anthropic-shaped APIs reject empty text blocks, so a content-less thinking turn is thinking only.""" + body = self._transform( + [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "thinking_blocks": [{"type": "thinking", "thinking": "391", "signature": "Eto"}], + }, + {"role": "user", "content": "continue"}, + ] + ) + assert body["messages"][1]["content"] == [{"type": "thinking", "thinking": "391", "signature": "Eto"}] + + def test_streaming_surfaces_thinking_and_prompt_cache_usage(self): + """Cortex streams thinking deltas, signatures and cache counts; all must reach the caller.""" + handler = SnowflakeStreamingHandler(streaming_response=[], sync_stream=True) + handler.chunk_parser( + { + "type": "message_start", + "message": {"usage": {"input_tokens": 18, "cache_creation_input_tokens": 1323}}, + } + ) + thinking = handler.chunk_parser( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "391"}, + } + ) + signature = handler.chunk_parser( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "Eto"}, + } + ) + final = handler.chunk_parser( + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"output_tokens": 8, "cache_read_input_tokens": 1323}, + } + ) + + assert thinking.choices[0].delta.reasoning_content == "391" + assert signature.choices[0].delta.thinking_blocks[0]["signature"] == "Eto" + assert final.usage.prompt_tokens_details.cached_tokens == 1323 + + class TestSnowFlakeCompletion: model_name = "mistral" @@ -380,10 +749,7 @@ class TestSnowFlakeCompletion: # PAT key was used post_kwargs = mock_post.call_args_list[-1][1] assert "xxxxx" in post_kwargs["headers"]["Authorization"] - assert ( - post_kwargs["headers"]["X-Snowflake-Authorization-Token-Type"] - == "PROGRAMMATIC_ACCESS_TOKEN" - ) + assert post_kwargs["headers"]["X-Snowflake-Authorization-Token-Type"] == "PROGRAMMATIC_ACCESS_TOKEN" # account id was used assert "AAAA-BBBB" in post_kwargs["url"] @@ -495,9 +861,7 @@ class TestSnowflakeChatCompletion: ) mock_post.assert_called_once() else: - with patch.object( - AsyncHTTPHandler, "post", new_callable=AsyncMock, return_value=mock_resp - ) as mock_post: + with patch.object(AsyncHTTPHandler, "post", new_callable=AsyncMock, return_value=mock_resp) as mock_post: response = asyncio.run( acompletion( model="snowflake/mistral-7b", @@ -580,8 +944,4 @@ class TestSnowflakeChatCompletion: chunks_received = asyncio.run(_run()) assert len(chunks_received) > 0 - content = "".join( - c.choices[0].delta.content - for c in chunks_received - if c.choices[0].delta.content - ) + content = "".join(c.choices[0].delta.content for c in chunks_received if c.choices[0].delta.content) diff --git a/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py b/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py index fb21e2e6f6b..7970f7771fc 100644 --- a/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py +++ b/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py @@ -338,7 +338,7 @@ class TestAnthropicConfigRequest: litellm_params={}, headers={}, ) - assert body["system"] == "You are helpful." + assert body["system"] == [{"type": "text", "text": "You are helpful."}] assert all(m["role"] != "system" for m in body["messages"]) assert body["messages"][0] == {"role": "user", "content": "Hello"} @@ -422,6 +422,64 @@ class TestAnthropicConfigResponse: assert result.usage.completion_tokens == 5 assert result.usage.total_tokens == 15 + def test_prompt_cache_usage_is_surfaced(self): + """Cortex reports cache creation/read counts; dropping them hides caching and bills cached input at full price.""" + raw = httpx.Response( + 200, + json={ + "id": "msg_1", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 18, "cache_creation_input_tokens": 1323, "cache_read_input_tokens": 0}, + }, + ) + result = self.cfg.transform_response( + model="snowflake/claude-sonnet-4-6", + raw_response=raw, + model_response=ModelResponse(), + logging_obj=_mock_logging(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + assert result.usage.prompt_tokens == 1341 + assert result.usage.prompt_tokens_details.cache_creation_tokens == 1323 + assert result.usage.prompt_tokens_details.cached_tokens == 0 + + def test_thinking_block_and_signature_are_preserved(self): + """The signature must survive so a client can echo the thinking block on the next turn.""" + raw = httpx.Response( + 200, + json={ + "id": "msg_1", + "model": "claude-sonnet-4-6", + "content": [ + {"type": "thinking", "thinking": "391", "signature": "Eto"}, + {"type": "text", "text": "391"}, + ], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + ) + result = self.cfg.transform_response( + model="snowflake/claude-sonnet-4-6", + raw_response=raw, + model_response=ModelResponse(), + logging_obj=_mock_logging(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + message = result.choices[0].message + assert message.content == "391" + assert message.reasoning_content == "391" + assert message.thinking_blocks[0]["signature"] == "Eto" + def test_stop_reason_end_turn_maps_to_stop(self): raw = _make_anthropic_response() result = self.cfg.transform_response( diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py index d1d751989ea..dddc95bf54a 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_ai_common_utils.py @@ -964,6 +964,48 @@ def test_construct_target_url_with_version_prefix(): assert str(target_url) == expected_url +@pytest.mark.parametrize( + ("requested_route", "expected_url"), + [ + ( + "/projects/test-project/locations/global/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict", + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict", + ), + ( + "/projects/test-project/locations/global/publishers/anthropic/models/count-tokens:rawPredict", + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/publishers/anthropic/models/count-tokens:rawPredict", + ), + ( + "/projects/other-project/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-6:rawPredict", + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/publishers/anthropic/models/claude-sonnet-4-6:rawPredict", + ), + ( + "/projects/test-project/locations/global/cachedContents", + "https://aiplatform.googleapis.com/v1beta1/projects/test-project/locations/global/cachedContents", + ), + ( + "/v1/projects/test-project/locations/global/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict", + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict", + ), + ( + "/v1beta1/projects/test-project/locations/global/cachedContents", + "https://aiplatform.googleapis.com/v1beta1/projects/test-project/locations/global/cachedContents", + ), + ], +) +def test_construct_target_url_versionless_project_route_gets_api_version(requested_route: str, expected_url: str) -> None: + from litellm.llms.vertex_ai.common_utils import construct_target_url + + target_url = construct_target_url( + base_url="https://aiplatform.googleapis.com", + requested_route=requested_route, + vertex_project="test-project", + vertex_location="global", + ) + + assert str(target_url) == expected_url + + def test_fix_enum_types(): """ Test _fix_enum_types function removes enum fields when type is not string. diff --git a/tests/test_litellm/ocr/test_rust_bridge.py b/tests/test_litellm/ocr/test_rust_bridge.py index 0764aec7185..4afb8303d03 100644 --- a/tests/test_litellm/ocr/test_rust_bridge.py +++ b/tests/test_litellm/ocr/test_rust_bridge.py @@ -297,6 +297,25 @@ def test_native_bridge_loader_caches_absent_extension(monkeypatch): assert attempts == 1 +def test_native_bridge_loader_reset_forces_relookup(monkeypatch): + real_import = builtins.__import__ + attempts = 0 + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + nonlocal attempts + if name == "litellm.rust_bridge" and "_native" in fromlist: + attempts += 1 + raise ImportError + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + assert rust_bridge_loader.get_native_bridge() is None + rust_bridge_loader.reset_native_bridge_cache() + assert rust_bridge_loader.get_native_bridge() is None + assert attempts == 2 + + def test_native_bridge_available_reflects_loader(monkeypatch): fake_module = types.ModuleType("litellm.rust_bridge._native") monkeypatch.setattr(rust_bridge_loader, "get_native_bridge", lambda: fake_module) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py index c667db7f07c..c6f3b9cb1f4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py @@ -12,6 +12,7 @@ import pytest from fastapi import HTTPException from pydantic import ValidationError +from litellm.experimental_mcp_client.client import MCPClient from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( oauth_protected_resource_path, raise_public, @@ -94,6 +95,49 @@ def test_basic_scheme_base64_encodes_the_token(): assert spec.config.key_source.value.get_secret_value() == expected +@pytest.mark.parametrize( + "auth_type, authentication_token, expected_value, expected_header", + [ + (MCPAuth.bearer_token, "Bearer abc", "abc", ("Authorization", "Bearer abc")), + (MCPAuth.token, "token abc", "abc", ("Authorization", "token abc")), + (MCPAuth.basic, "user:pass", "dXNlcjpwYXNz", ("Authorization", "Basic dXNlcjpwYXNz")), + (MCPAuth.basic, "Basic dXNlcjpwYXNz", "dXNlcjpwYXNz", ("Authorization", "Basic dXNlcjpwYXNz")), + (MCPAuth.basic, "Basic user:pass", "dXNlcjpwYXNz", ("Authorization", "Basic dXNlcjpwYXNz")), + ], +) +def test_shared_key_normalizes_schemed_authentication_token( + auth_type, authentication_token, expected_value, expected_header +): + spec = to_server_spec(_server(auth_type=auth_type, authentication_token=authentication_token)) + assert spec is not None and isinstance(spec.config, ApiKeyConfig) + assert spec.config.key_source.value.get_secret_value() == expected_value + assert spec.config.header(expected_value) == expected_header + + +@pytest.mark.parametrize( + "auth_type, authentication_token", + [ + (MCPAuth.bearer_token, "Bearer abc"), + (MCPAuth.bearer_token, "abc"), + (MCPAuth.token, "token abc"), + (MCPAuth.token, "abc"), + (MCPAuth.basic, "user:pass"), + (MCPAuth.basic, "Basic dXNlcjpwYXNz"), + (MCPAuth.basic, "Basic user:pass"), + ], +) +def test_shared_key_authorization_matches_v1(auth_type, authentication_token): + spec = to_server_spec(_server(auth_type=auth_type, authentication_token=authentication_token)) + assert spec is not None and isinstance(spec.config, ApiKeyConfig) + + client = MCPClient(server_url="https://x", auth_type=auth_type) + client.update_auth_value(authentication_token) + + assert spec.config.header(spec.config.key_source.value.get_secret_value())[1] == client._get_auth_headers()[ + "Authorization" + ] + + @pytest.mark.parametrize( "oauth2_flow", [None, "authorization_code"], diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_store.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_store.py index 7b82e004f37..5d6d47b8c38 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_store.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_store.py @@ -7,6 +7,7 @@ round-trips exactly, a store failure never escapes into the login path, and a sa rotation re-encrypts stored rows like the sibling per-user credential tables. """ +import asyncio import json import os import time @@ -16,8 +17,10 @@ import jwt as pyjwt import pytest from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import ( + _ASSERTION_CACHE, AssertionStoreUnavailable, DbSSOAssertionStore, + SSOAssertionCache, assertion_from_sso_login, ema_assertion_retention_enabled, fetch_sso_identity_assertion, @@ -25,7 +28,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_s retain_sso_identity_assertion_for_ema, rotate_sso_identity_assertions_master_key, ) -from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper +from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper, encrypt_value_helper from litellm.types.mcp import MCPAuth SALT_KEY = "test-salt-key-for-sso-assertion-tests-1234" @@ -38,6 +41,11 @@ def _set_salt_key(monkeypatch): monkeypatch.setenv("LITELLM_SALT_KEY", SALT_KEY) +@pytest.fixture(autouse=True) +def _flush_assertion_cache(): + _ASSERTION_CACHE.flush() + + def _make_id_token(exp_offset: int = 3600, iss: str = ISSUER) -> str: return pyjwt.encode( {"iss": iss, "sub": "u1", "exp": int(time.time()) + exp_offset}, @@ -52,9 +60,7 @@ def _make_prisma(stored: dict, db_has_id_jag_server: bool = False): ``db_has_id_jag_server`` drives the retention gate's authoritative DB fallback; it is wired explicitly so the gate never reads a truthy bare MagicMock.""" prisma = MagicMock() - prisma.db.litellm_mcpservertable.find_first = AsyncMock( - return_value=MagicMock() if db_has_id_jag_server else None - ) + prisma.db.litellm_mcpservertable.find_first = AsyncMock(return_value=MagicMock() if db_has_id_jag_server else None) async def _upsert(where, data): stored[where["user_id"]] = data["update"]["assertion_b64"] @@ -235,6 +241,78 @@ async def test_persist_overwrites_previous_login(): assert fetched.refresh_token is not None +@pytest.mark.asyncio +async def test_fetch_serves_second_read_from_cache_without_db_read(): + stored = {} + prisma = _make_prisma(stored) + cache = SSOAssertionCache() + token = _make_id_token() + assertion = assertion_from_sso_login(token, "rt_1") + with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary + await persist_sso_identity_assertion("user-a", assertion, cache=cache) + first = await fetch_sso_identity_assertion("user-a", cache=cache) + second = await fetch_sso_identity_assertion("user-a", cache=cache) + assert first is not None + assert second is not None + assert first.id_token.get_secret_value() == token + assert second.id_token.get_secret_value() == token + prisma.db.litellm_ssoidentityassertion.find_unique.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_persist_busts_cache_so_relogin_is_visible_immediately(): + stored = {} + prisma = _make_prisma(stored) + cache = SSOAssertionCache() + first_token = _make_id_token(exp_offset=100) + second_token = _make_id_token(exp_offset=7200) + with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary + await persist_sso_identity_assertion("user-a", assertion_from_sso_login(first_token, None), cache=cache) + first = await fetch_sso_identity_assertion("user-a", cache=cache) + await persist_sso_identity_assertion("user-a", assertion_from_sso_login(second_token, "rt_new"), cache=cache) + second = await fetch_sso_identity_assertion("user-a", cache=cache) + assert first is not None + assert second is not None + assert first.id_token.get_secret_value() == first_token + assert second.id_token.get_secret_value() == second_token + + +@pytest.mark.asyncio +async def test_fetch_racing_a_relogin_does_not_cache_the_previous_assertion(): + stored = {} + prisma = _make_prisma(stored) + cache = SSOAssertionCache() + first_token = _make_id_token(exp_offset=100) + second_token = _make_id_token(exp_offset=7200) + db_read_started = asyncio.Event() + relogin_done = asyncio.Event() + unpaused_find_unique = prisma.db.litellm_ssoidentityassertion.find_unique + + async def _paused_find_unique(where): + row = await unpaused_find_unique(where=where) + db_read_started.set() + await relogin_done.wait() + return row + + prisma.db.litellm_ssoidentityassertion.find_unique = _paused_find_unique + with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary + await persist_sso_identity_assertion("user-a", assertion_from_sso_login(first_token, None), cache=cache) + racing_fetch = asyncio.create_task(fetch_sso_identity_assertion("user-a", cache=cache)) + await db_read_started.wait() + await persist_sso_identity_assertion( + "user-a", + assertion_from_sso_login(second_token, "rt_new"), + cache=cache, + ) + relogin_done.set() + raced = await racing_fetch + after = await fetch_sso_identity_assertion("user-a", cache=cache) + assert raced is not None + assert after is not None + assert raced.id_token.get_secret_value() == first_token + assert after.id_token.get_secret_value() == second_token + + @pytest.mark.asyncio async def test_fetch_missing_row_returns_none(): prisma = _make_prisma({}) @@ -242,6 +320,18 @@ async def test_fetch_missing_row_returns_none(): assert await fetch_sso_identity_assertion("nobody") is None +@pytest.mark.asyncio +async def test_fetch_does_not_cache_a_missing_row(): + prisma = _make_prisma({}) + cache = SSOAssertionCache() + with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary + first = await fetch_sso_identity_assertion("nobody", cache=cache) + second = await fetch_sso_identity_assertion("nobody", cache=cache) + assert first is None + assert second is None + assert prisma.db.litellm_ssoidentityassertion.find_unique.await_count == 2 + + @pytest.mark.asyncio async def test_fetch_undecryptable_row_returns_none(): prisma = _make_prisma({"user-a": "not-an-encrypted-blob"}) @@ -251,13 +341,37 @@ async def test_fetch_undecryptable_row_returns_none(): @pytest.mark.asyncio async def test_fetch_unparseable_payload_returns_none(): - from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper - prisma = _make_prisma({"user-a": encrypt_value_helper("]]not json")}) with patch("litellm.proxy.proxy_server.prisma_client", prisma): assert await fetch_sso_identity_assertion("user-a") is None +@pytest.mark.asyncio +async def test_cached_assertion_expires_after_ttl(): + stored = {} + prisma = _make_prisma(stored) + cache = SSOAssertionCache(ttl_seconds=1) + first_token = _make_id_token(exp_offset=100) + second_token = _make_id_token(exp_offset=7200) + with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary + await persist_sso_identity_assertion("user-a", assertion_from_sso_login(first_token, None), cache=cache) + first = await fetch_sso_identity_assertion("user-a", cache=cache) + await persist_sso_identity_assertion( + "user-a", + assertion_from_sso_login(second_token, "rt_new"), + cache=SSOAssertionCache(), + ) + cached = await fetch_sso_identity_assertion("user-a", cache=cache) + time.sleep(1.1) + expired = await fetch_sso_identity_assertion("user-a", cache=cache) + assert first is not None + assert cached is not None + assert expired is not None + assert first.id_token.get_secret_value() == first_token + assert cached.id_token.get_secret_value() == first_token + assert expired.id_token.get_secret_value() == second_token + + @pytest.mark.asyncio async def test_retain_noop_when_no_id_jag_server(): stored = {} @@ -357,6 +471,24 @@ async def test_db_store_converts_a_driver_failure_into_assertion_store_unavailab await DbSSOAssertionStore().fetch("alice") +@pytest.mark.asyncio +async def test_db_store_uses_injected_cache(): + stored = {} + prisma = _make_prisma(stored) + cache = SSOAssertionCache() + token = _make_id_token() + with patch("litellm.proxy.proxy_server.prisma_client", prisma): # test-quality-ok: fake DB seam has no injection boundary + await persist_sso_identity_assertion("alice", assertion_from_sso_login(token, None), cache=cache) + store = DbSSOAssertionStore(cache=cache) + first = await store.fetch("alice") + second = await store.fetch("alice") + assert first is not None + assert second is not None + assert first.id_token.get_secret_value() == token + assert second.id_token.get_secret_value() == token + prisma.db.litellm_ssoidentityassertion.find_unique.assert_awaited_once() + + @pytest.mark.asyncio async def test_db_store_returns_none_for_a_user_with_no_stored_assertion(): """An absent row stays an absence, not an outage, so a user who never signed in still gets the 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 588eba4adb7..775f6e5f3b8 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 @@ -3508,6 +3508,169 @@ def _create_oauth2_server( ) +def _create_id_lookup_oauth2_server(): + return _create_oauth2_server( + server_id="oauth-server-id", + name="oauth-server-name", + server_name="oauth-server-name", + alias="oauth-server-alias", + ) + + +@pytest.mark.asyncio +async def test_authorize_resolves_server_by_id_when_name_lookup_fails(): + from fastapi import Request + + from litellm.proxy._experimental.mcp_server import discoverable_endpoints + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + + server = _create_id_lookup_oauth2_server() + request = MagicMock(spec=Request) + request.base_url = "https://llm.example.com/" + request.headers = {} + + with ( + patch.object(global_mcp_server_manager, "get_mcp_server_by_name", return_value=None) as by_name, # test-quality-ok: resolver seam + patch.object(global_mcp_server_manager, "get_mcp_server_by_id", return_value=server) as by_id, # test-quality-ok: resolver seam + patch.object(discoverable_endpoints, "encrypt_value_helper", return_value="encrypted-state"), # test-quality-ok: flow seam + ): + response = await discoverable_endpoints.authorize( + request=request, + client_id=server.client_id, + mcp_server_name=server.server_id, + redirect_uri="http://localhost:62646/callback", + state="test_state", + ) + + assert response.status_code == 307 + assert "https://provider.com/oauth/authorize" in response.headers["location"] + by_name.assert_called_once_with(server.server_id, client_ip=None) + by_id.assert_called_once_with(server.server_id, client_ip=None) + + +@pytest.mark.asyncio +async def test_token_endpoint_resolves_server_by_id_when_name_lookup_fails(): + from fastapi import Request + + from litellm.proxy._experimental.mcp_server import discoverable_endpoints + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + + server = _create_id_lookup_oauth2_server() + request = MagicMock(spec=Request) + request.base_url = "https://llm.example.com/" + request.headers = {} + response = MagicMock() + response.json.return_value = {"access_token": "token", "token_type": "Bearer"} + response.raise_for_status = MagicMock() + client = MagicMock() + client.post = AsyncMock(return_value=response) + + with ( + patch.object(global_mcp_server_manager, "get_mcp_server_by_name", return_value=None) as by_name, # test-quality-ok: resolver seam + patch.object(global_mcp_server_manager, "get_mcp_server_by_id", return_value=server) as by_id, # test-quality-ok: resolver seam + patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=client), # test-quality-ok: HTTP seam + ): + result = await discoverable_endpoints.token_endpoint( + request=request, + grant_type="authorization_code", + code="test_code", + redirect_uri="http://localhost:62646/callback", + client_id=server.client_id, + mcp_server_name=server.server_id, + client_secret=server.client_secret, + ) + + assert json.loads(result.body)["access_token"] == "token" + by_name.assert_called_once_with(server.server_id, client_ip=None) + by_id.assert_called_once_with(server.server_id, client_ip=None) + + +@pytest.mark.asyncio +async def test_register_client_resolves_server_by_id_when_name_lookup_fails(): + from fastapi import Request + + from litellm.proxy._experimental.mcp_server import discoverable_endpoints + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + + server = _create_id_lookup_oauth2_server().model_copy( + update={"client_id": None, "client_secret": None, "registration_url": "https://provider.com/oauth/register"} + ) + request = MagicMock(spec=Request) + request.base_url = "https://llm.example.com/" + request.headers = {} + response = MagicMock() + response.json.return_value = {"client_id": "registered-client", "client_secret": "registered-secret"} + response.raise_for_status = MagicMock() + client = MagicMock() + client.post = AsyncMock(return_value=response) + + with ( + patch.object(global_mcp_server_manager, "get_mcp_server_by_name", return_value=None) as by_name, # test-quality-ok: resolver seam + patch.object(global_mcp_server_manager, "get_mcp_server_by_id", return_value=server) as by_id, # test-quality-ok: resolver seam + patch.object(discoverable_endpoints, "_read_request_body", new=AsyncMock(return_value={})), # test-quality-ok: request seam + patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=client), # test-quality-ok: HTTP seam + ): + result = await discoverable_endpoints.register_client(request=request, mcp_server_name=server.server_id) + + assert json.loads(result.body)["client_id"] == "registered-client" + by_name.assert_called_once_with(server.server_id, client_ip=None) + by_id.assert_called_once_with(server.server_id, client_ip=None) + + +@pytest.mark.asyncio +async def test_protected_resource_metadata_resolves_server_by_id_when_name_lookup_fails(): + from fastapi import Request + + from litellm.proxy._experimental.mcp_server import discoverable_endpoints + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + + server = _create_id_lookup_oauth2_server() + request = MagicMock(spec=Request) + request.base_url = "https://llm.example.com/" + request.headers = {} + + with ( + patch.object(global_mcp_server_manager, "get_mcp_server_by_name", return_value=None) as by_name, # test-quality-ok: resolver seam + patch.object(global_mcp_server_manager, "get_mcp_server_by_id", return_value=server) as by_id, # test-quality-ok: resolver seam + ): + result = await discoverable_endpoints._build_oauth_protected_resource_response( + request=request, + mcp_server_name=server.server_id, + use_standard_pattern=True, + ) + + assert result["authorization_servers"] == ["https://llm.example.com/mcp"] + assert result["resource"] == f"https://llm.example.com/mcp/{server.server_id}" + by_name.assert_called_once_with(server.server_id, client_ip=None) + by_id.assert_called_once_with(server.server_id, client_ip=None) + + +def test_authorization_server_metadata_resolves_server_by_id_when_name_lookup_fails(): + from fastapi import Request + + from litellm.proxy._experimental.mcp_server import discoverable_endpoints + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + + server = _create_id_lookup_oauth2_server() + request = MagicMock(spec=Request) + request.base_url = "https://llm.example.com/" + request.headers = {} + + with ( + patch.object(global_mcp_server_manager, "get_mcp_server_by_name", return_value=None) as by_name, # test-quality-ok: resolver seam + patch.object(global_mcp_server_manager, "get_mcp_server_by_id", return_value=server) as by_id, # test-quality-ok: resolver seam + ): + result = discoverable_endpoints._build_oauth_authorization_server_response( + request=request, + mcp_server_name=server.server_id, + ) + + assert result["scopes_supported"] == server.scopes + assert result["issuer"] == f"https://llm.example.com/{server.server_id}" + by_name.assert_called_once_with(server.server_id, client_ip=None) + by_id.assert_called_once_with(server.server_id, client_ip=None) + + @pytest.mark.asyncio async def test_authorize_root_resolves_single_oauth2_server(): """When /authorize is hit without server name and exactly 1 OAuth2 server exists, resolve it.""" 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 0fd35e674b7..9a6815a61e5 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 @@ -8201,6 +8201,129 @@ class TestPreemptive401ModeAware: await self._run(delegate, self.LITELLM_KEY_HEADERS, has_stored_token=False) +class TestSingleServerPreflightReachesIdJag: + """The connect-time preflight is what turns a credential failure into an HTTP status the client + can read. An oauth2_id_jag server has to reach it: its subject comes from the assertion stored at + SSO login, so the failure is decided before any IdP call and there is nothing later in the session + that can report it (tools/list degrades to an empty list, tools/call to 'tool not found').""" + + def _id_jag_server(self) -> MCPServer: + return MCPServer( + server_id="id-idjag", + name="idjag", + alias="idjag", + server_name="idjag", + url="https://idjag.test/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2_id_jag, + client_id="gateway-client", + client_secret="gateway-secret", + token_exchange_endpoint="https://org-idp.test/oauth2/token", + id_jag_resource_token_endpoint="https://resource-as.test/oauth2/token", + mcp_info={"server_name": "idjag"}, + ) + + async def _run(self, server: MCPServer, mcp_servers: list[str], preflight: AsyncMock) -> None: + from litellm.proxy._experimental.mcp_server import server as server_module + + with ( + patch.object( # test-quality-ok: route wiring must use the manager's configured server + server_module.global_mcp_server_manager, + "get_mcp_server_by_name", + return_value=server, + ), + patch.object( # test-quality-ok: route wiring must invoke the manager preflight + server_module.global_mcp_server_manager, + "preflight_token_exchange", + preflight, + ), + patch.object( # test-quality-ok: allowed-set resolution needs the DB; the test controls its answer + server_module, "_get_allowed_mcp_servers", AsyncMock(return_value=[server]) + ), + ): + await server_module._raise_preemptive_401_for_unauthenticated_servers( + scope={"type": "http", "method": "POST", "path": "/mcp/idjag", "headers": []}, + mcp_servers=mcp_servers, + oauth2_headers={"Authorization": "Bearer sk-litellm-virtual-key"}, + mcp_server_auth_headers=None, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-virtual-key", user_id="u-1"), + client_ip=None, + ) + + @pytest.mark.asyncio + async def test_id_jag_single_server_route_surfaces_the_preflight_status(self): + """The 412 the preflight raises must propagate out of connect, not be swallowed.""" + server = self._id_jag_server() + preflight = AsyncMock(side_effect=HTTPException(status_code=412, detail="no stored assertion")) + + with pytest.raises(HTTPException) as exc: + await self._run(server, ["idjag"], preflight) + + assert exc.value.status_code == 412 + assert preflight.await_args.kwargs["server"] is server + + @pytest.mark.asyncio + async def test_token_exchange_without_a_bearer_still_challenges_and_never_pre_flights(self): + """The already-shipped OBO path must be untouched by the call site dropping its mode test. + A token_exchange server with no inbound bearer has nothing to exchange, so it still gets the + RFC 9728 discovery challenge from the block above and the preflight is never reached; pushing + a subject-less exchange through the resolver would turn that challenge into some other status + and strand a client that only had to SSO and retry.""" + from litellm.proxy._experimental.mcp_server import server as server_module + + token_exchange = MCPServer( + server_id="id-obo", + name="obo", + alias="obo", + server_name="obo", + url="https://obo.test/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2_token_exchange, + token_exchange_endpoint="https://idp.test/oauth2/token", + client_id="cid", + client_secret="csec", + mcp_info={"server_name": "obo"}, + ) + preflight = AsyncMock() + + with ( + patch.object( # test-quality-ok: route wiring must use the manager's configured server + server_module.global_mcp_server_manager, + "get_mcp_server_by_name", + return_value=token_exchange, + ), + patch.object( # test-quality-ok: route wiring must invoke the manager preflight + server_module.global_mcp_server_manager, + "preflight_token_exchange", + preflight, + ), + pytest.raises(HTTPException) as exc, + ): + await server_module._raise_preemptive_401_for_unauthenticated_servers( + scope={"type": "http", "method": "POST", "path": "/mcp/obo", "headers": []}, + mcp_servers=["obo"], + oauth2_headers=None, + mcp_server_auth_headers=None, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-virtual-key", user_id="u-1"), + client_ip=None, + ) + + assert exc.value.status_code == 401 + headers = exc.value.headers or {} + assert "resource_metadata" in (headers.get("WWW-Authenticate") or headers.get("www-authenticate") or "") + preflight.assert_not_awaited() + + @pytest.mark.asyncio + async def test_id_jag_multi_server_route_still_absorbs_the_failure(self): + """The aggregate contract is unchanged: with more than one target the preflight does not run, + so one server with no stored assertion cannot fail the whole connect.""" + preflight = AsyncMock(side_effect=HTTPException(status_code=412, detail="no stored assertion")) + + await self._run(self._id_jag_server(), ["idjag", "other"], preflight) + + preflight.assert_not_awaited() + + def _make_obo_server(alias: str) -> MCPServer: return MCPServer( server_id=f"id-{alias}", 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 91e870d2d95..02b1a19081a 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 @@ -129,6 +129,32 @@ class TestMCPServerManager: assert added_server.args == ["-m", "server"] assert added_server.env == {"DEBUG": "1", "TEST": "1"} + def test_get_mcp_server_by_id_allows_internal_or_unspecified_client_ip(self): + manager = MCPServerManager() + server = MCPServer( + server_id="private-server", + name="private-server", + transport=MCPTransport.http, + available_on_public_internet=False, + ) + manager.registry[server.server_id] = server + + assert manager.get_mcp_server_by_id(server.server_id) is server + assert manager.get_mcp_server_by_id(server.server_id, client_ip="10.0.0.1") is server + + def test_get_mcp_server_by_id_rejects_private_server_for_public_ip(self): + manager = MCPServerManager() + server = MCPServer( + server_id="private-server", + name="private-server", + transport=MCPTransport.http, + available_on_public_internet=False, + ) + manager.registry[server.server_id] = server + + with patch.object(manager, "_get_general_settings", return_value={}): + assert manager.get_mcp_server_by_id(server.server_id, client_ip="8.8.8.8") is None + async def test_create_mcp_client_stdio(self): """Test creating MCP client for stdio transport""" manager = MCPServerManager() @@ -2590,6 +2616,232 @@ class TestMCPServerManager: await manager.preflight_token_exchange(server=server, oauth2_headers=None, user_api_key_auth=None) assert resolved == ["good-subject"] + def _id_jag_server(self, server_id: str) -> "MCPServer": + return MCPServer( + server_id=server_id, + name=f"{server_id}-server", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2_id_jag, + client_id="gateway-client", + client_secret="gateway-secret", + token_exchange_endpoint="https://org-idp.example/oauth2/token", + id_jag_resource_token_endpoint="https://resource-as.example/oauth2/token", + ) + + @pytest.mark.asyncio + async def test_preflight_id_jag_surfaces_missing_assertion_as_a_plain_412(self): + """ID-JAG's missing/expired-assertion precondition must reach the client as a 412 whose body + names the fix, at the transport edge. Without the preflight the session opens and the caller + gets a 200 with an empty tool list and then 'tool not found', which is not what happened. + 412 is a precondition, not an RFC 9728 discovery challenge, so it carries no + WWW-Authenticate: there is nothing for the client to discover and retry against.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error + from litellm.proxy._experimental.mcp_server.outbound_credentials.types import CredError + + summary = ( + "ID-JAG requires an IdP identity assertion for this user and none is stored. " + "Sign in through LiteLLM SSO so the gateway captures one." + ) + + class _FakeProvider: + async def resolve_credentials(self, subject, server): + return Error(CredError.of_precondition_required(summary)) + + manager = MCPServerManager(cred_provider=_FakeProvider()) + + with pytest.raises(HTTPException) as exc_info: + await manager.preflight_token_exchange( + server=self._id_jag_server("id-jag-preflight-412"), + oauth2_headers=None, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-virtual-key", user_id="u-1"), + ) + assert exc_info.value.status_code == 412 + assert summary in exc_info.value.detail + assert not (exc_info.value.headers or {}) + + @pytest.mark.asyncio + async def test_preflight_id_jag_surfaces_assertion_store_outage_as_503(self): + """A store outage is the other failure the session would swallow, and it is a different + answer than 412: the user has nothing to fix by signing in again.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error + from litellm.proxy._experimental.mcp_server.outbound_credentials.types import CredError + + class _FakeProvider: + async def resolve_credentials(self, subject, server): + return Error(CredError.of_upstream_unavailable("assertion store unreachable")) + + manager = MCPServerManager(cred_provider=_FakeProvider()) + + with pytest.raises(HTTPException) as exc_info: + await manager.preflight_token_exchange( + server=self._id_jag_server("id-jag-preflight-503"), + oauth2_headers=None, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-virtual-key", user_id="u-1"), + ) + assert exc_info.value.status_code == 503 + + @pytest.mark.asyncio + async def test_preflight_id_jag_preflights_litellm_key_and_skips_identity_bearer(self): + """ID-JAG preflights when Authorization carries a LiteLLM key, but skips a caller identity + bearer that the session passes through unchanged.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import ( + StaticHeaderAuth, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok + + subjects = [] + + class _FakeProvider: + async def resolve_credentials(self, subject, server): + subjects.append( + ( + subject.subject_id, + subject.inbound_token.get_secret_value() if subject.inbound_token else None, + ) + ) + return Ok(StaticHeaderAuth("Bearer minted-id-jag", header_name="Authorization")) + + manager = MCPServerManager(cred_provider=_FakeProvider()) + caller = UserAPIKeyAuth(api_key="sk-litellm-virtual-key", user_id="u-1") + + await manager.preflight_token_exchange( + server=self._id_jag_server("id-jag-preflight-key"), + oauth2_headers={"Authorization": "Bearer sk-litellm-virtual-key"}, + raw_headers={"authorization": "Bearer sk-litellm-virtual-key"}, + user_api_key_auth=caller, + ) + assert subjects == [("u-1", None)] + + await manager.preflight_token_exchange( + server=self._id_jag_server("id-jag-preflight-identity"), + oauth2_headers={"Authorization": "Bearer caller-idp-id-token"}, + raw_headers={ + "x-litellm-api-key": "Bearer sk-admission-key", + "authorization": "Bearer caller-idp-id-token", + }, + user_api_key_auth=UserAPIKeyAuth(api_key="hashed-key", user_id="u-1"), + ) + assert subjects == [("u-1", None)] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "server_fields", + [ + {"auth_type": MCPAuth.none}, + {"auth_type": MCPAuth.api_key, "authentication_token": "static-upstream-key"}, + {"auth_type": MCPAuth.bearer_token, "authentication_token": "static-upstream-key"}, + { + "auth_type": MCPAuth.oauth2, + "oauth2_flow": "client_credentials", + "client_id": "cid", + "client_secret": "csec", + "token_url": "https://idp.example.com/token", + }, + {"auth_type": MCPAuth.true_passthrough}, + ], + ) + async def test_preflight_resolves_nothing_for_a_mode_that_does_not_pre_flight(self, server_fields): + """The manager is the only thing deciding which modes pre-flight, so it has to reject every + other mode itself. The single-server call site no longer tests the mode before calling, so a + mode that falls through here would start resolving its credential a second time, at connect, + for flows that never had a connect-time resolution at all.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error + from litellm.proxy._experimental.mcp_server.outbound_credentials.types import CredError + + calls = [] + + class _FakeProvider: + async def resolve_credentials(self, subject, server): + calls.append(server.server_id) + return Error(CredError.of_misconfigured("the preflight must never get here")) + + manager = MCPServerManager(cred_provider=_FakeProvider()) + server = MCPServer( + server_id="not-pre-flighted", + name="not-pre-flighted-server", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + **server_fields, + ) + + assert ( + await manager.preflight_token_exchange( + server=server, + oauth2_headers={"Authorization": "Bearer sk-litellm-virtual-key"}, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-virtual-key", user_id="u-1"), + ) + is None + ) + assert calls == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "authorization", + [ + "Bearer subj-jwt", + "bearer subj-jwt", + "BEARER subj-jwt", + "Bearer\tsubj-jwt", + "Bearer subj-jwt", + ], + ) + async def test_preflight_token_exchange_strips_inbound_authorization_scheme(self, authorization): + """The resolver posts inbound_token verbatim as subject_token.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import ( + StaticHeaderAuth, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok + from litellm.proxy._experimental.mcp_server.outbound_credentials.types import CredError, ServerSpec, Subject + + resolved: Final[list[str | None]] = [] + + class _FakeProvider: + async def resolve_credentials( + self, subject: Subject, server: ServerSpec + ) -> Ok[StaticHeaderAuth, CredError]: + resolved.append(subject.inbound_token.get_secret_value() if subject.inbound_token else None) + return Ok(StaticHeaderAuth("Bearer MINTED", header_name="Authorization")) + + manager = MCPServerManager(cred_provider=_FakeProvider()) + server = self._token_exchange_server(f"te-preflight-auth-{authorization!r}") + + await manager.preflight_token_exchange( + server=server, + oauth2_headers={"Authorization": authorization}, + user_api_key_auth=None, + ) + + assert resolved == ["subj-jwt"] + + @pytest.mark.asyncio + async def test_preflight_token_exchange_preserves_authorization_without_separator(self): + from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import ( + StaticHeaderAuth, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok + from litellm.proxy._experimental.mcp_server.outbound_credentials.types import CredError, ServerSpec, Subject + + resolved: Final[list[str | None]] = [] + + class _FakeProvider: + async def resolve_credentials( + self, subject: Subject, server: ServerSpec + ) -> Ok[StaticHeaderAuth, CredError]: + resolved.append(subject.inbound_token.get_secret_value() if subject.inbound_token else None) + return Ok(StaticHeaderAuth("Bearer MINTED", header_name="Authorization")) + + manager = MCPServerManager(cred_provider=_FakeProvider()) + server = self._token_exchange_server("te-preflight-auth-no-separator") + + await manager.preflight_token_exchange( + server=server, + oauth2_headers={"Authorization": "Bearersubj-jwt"}, + user_api_key_auth=None, + ) + + assert resolved == ["Bearersubj-jwt"] + @pytest.mark.asyncio async def test_preflight_token_exchange_skips_discovery_for_other_auth_modes(self): """Preflight must not make unrelated auth modes depend on OAuth discovery.""" @@ -4545,6 +4797,81 @@ class TestMCPServerManager: # auth_type is none here, so a 401 from this upstream must not be dressed up as a re-auth signal assert captured["relays_upstream_auth"] is False + @pytest.mark.asyncio + @pytest.mark.parametrize( + "auth_type, authentication_token, expected_authorization", + [ + (MCPAuth.bearer_token, "Bearer abc", "Bearer abc"), + (MCPAuth.bearer_token, "abc", "Bearer abc"), + (MCPAuth.api_key, "ApiKey abc", "ApiKey abc"), + (MCPAuth.token, "token abc", "token abc"), + (MCPAuth.basic, "user:pass", "Basic dXNlcjpwYXNz"), + (MCPAuth.basic, "Basic dXNlcjpwYXNz", "Basic dXNlcjpwYXNz"), + (MCPAuth.basic, "Basic user:pass", "Basic dXNlcjpwYXNz"), + ], + ) + async def test_register_openapi_tools_normalizes_authentication_token( + self, tmp_path, monkeypatch, auth_type, authentication_token, expected_authorization + ): + manager = MCPServerManager() + spec_path = tmp_path / "openapi.json" + spec_path.write_text( + json.dumps( + { + "openapi": "3.0.0", + "info": {"title": "Demo", "version": "1.0.0"}, + "paths": { + "/health": { + "get": { + "operationId": "health_check", + "summary": "health", + } + } + }, + } + ) + ) + server = MCPServer( + server_id="openapi-server", + name="openapi-server", + server_name="openapi-server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=auth_type, + authentication_token=authentication_token, + ) + captured: dict = {} + + def fake_create_tool_function( + path, method, operation, base_url, headers=None, server_label=None, relays_upstream_auth=False + ): + captured["headers"] = headers + + async def tool_func(**kwargs): + return "ok" + + return tool_func + + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.create_tool_function", + fake_create_tool_function, + ) + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.build_input_schema", + lambda *args, **kwargs: {"type": "object", "properties": {}, "required": []}, + ) + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.tool_registry.global_mcp_tool_registry.register_tool", + lambda *args, **kwargs: None, + ) + await manager._register_openapi_tools( + spec_path=str(spec_path), + server=server, + base_url="https://example.com", + ) + + assert captured["headers"]["Authorization"] == expected_authorization + @pytest.mark.asyncio async def test_pre_call_tool_check_allowed_tools_list_allows_tool(self): """Test pre_call_tool_check allows tool when it's in allowed_tools list""" diff --git a/tests/test_litellm/proxy/agent_endpoints/auth/test_agent_permission_handler.py b/tests/test_litellm/proxy/agent_endpoints/auth/test_agent_permission_handler.py index 82528c58ae0..383b72e5c58 100644 --- a/tests/test_litellm/proxy/agent_endpoints/auth/test_agent_permission_handler.py +++ b/tests/test_litellm/proxy/agent_endpoints/auth/test_agent_permission_handler.py @@ -10,15 +10,42 @@ from unittest.mock import AsyncMock, patch import pytest -from litellm.proxy._types import UserAPIKeyAuth +from litellm.constants import UI_SESSION_TOKEN_TEAM_ID +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry from litellm.proxy.agent_endpoints.auth.agent_permission_handler import ( + AgentAccess, AgentRequestHandler, RestrictedAgentAccess, UnrestrictedAgentAccess, + accessible_agents, ) +def _registry_with(*agent_names: str) -> AgentRegistry: + registry: Final = AgentRegistry() + registry.load_agents_from_config( + [ + { + "agent_name": name, + "agent_card_params": {"name": name, "url": "http://localhost", "version": "1.0.0"}, + } + for name in agent_names + ] + ) + return registry + + +def _agent_id(registry: AgentRegistry, agent_name: str) -> str: + agent: Final = registry.get_agent_by_name(agent_name) + assert agent is not None + return agent.agent_id + + +async def _single_context(user_api_key_auth: UserAPIKeyAuth) -> list[UserAPIKeyAuth]: + return [user_api_key_auth] + + @pytest.mark.asyncio class TestAgentRequestHandler: """ @@ -265,6 +292,78 @@ class TestAgentRequestHandler: ) assert result == UnrestrictedAgentAccess() + async def test_accessible_agents_hides_ungranted_agents_from_non_admins(self): + """LIT-6862: a key with no agent grant on itself or its team must list nothing, + while a proxy admin with the same lack of grants still lists every agent.""" + registry: Final = _registry_with("alpha", "beta") + internal_user: Final = UserAPIKeyAuth( + api_key="test-key", user_id="alice", team_id="team-no-perms", user_role=LitellmUserRoles.INTERNAL_USER + ) + proxy_admin: Final = UserAPIKeyAuth( + api_key="admin-key", user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + + async def no_grant_anywhere(user_api_key_auth: UserAPIKeyAuth) -> AgentAccess: + return UnrestrictedAgentAccess() + + assert ( + await accessible_agents(internal_user, registry.get_agent_list(), no_grant_anywhere, _single_context) == () + ) + assert { + agent.agent_name + for agent in await accessible_agents( + proxy_admin, registry.get_agent_list(), no_grant_anywhere, _single_context + ) + } == {"alpha", "beta"} + + async def test_accessible_agents_lists_only_granted_agents(self): + """A grant for one agent lists that agent and hides the ungranted one.""" + registry: Final = _registry_with("alpha", "beta") + granted_user: Final = UserAPIKeyAuth( + api_key="test-key", user_id="bob", team_id="team-granted", user_role=LitellmUserRoles.INTERNAL_USER + ) + + async def alpha_only(user_api_key_auth: UserAPIKeyAuth) -> AgentAccess: + return RestrictedAgentAccess(frozenset({_agent_id(registry, "alpha")})) + + listed: Final = await accessible_agents(granted_user, registry.get_agent_list(), alpha_only, _single_context) + assert [agent.agent_name for agent in listed] == ["alpha"] + + async def test_accessible_agents_resolves_dashboard_session_through_real_teams_and_user(self): + """LIT-6862: a dashboard session carries the shared litellm-dashboard team id, which holds no + grants. Listing must union the grants of the user's real teams and of the user row instead + of treating the session as ungranted or as unrestricted.""" + registry: Final = _registry_with("alpha", "beta", "gamma") + session: Final = UserAPIKeyAuth( + api_key="session-key", + user_id="alice", + team_id=UI_SESSION_TOKEN_TEAM_ID, + user_role=LitellmUserRoles.INTERNAL_USER, + ) + admitted_user: Final = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER) + grants: Final = { + "team-granted": RestrictedAgentAccess(frozenset({_agent_id(registry, "alpha")})), + "team-no-perms": UnrestrictedAgentAccess(), + UI_SESSION_TOKEN_TEAM_ID: UnrestrictedAgentAccess(), + } + + async def effective_contexts(user_api_key_auth: UserAPIKeyAuth) -> list[UserAPIKeyAuth]: + assert user_api_key_auth is session + return [ + session.model_copy(update={"team_id": "team-granted"}), + session.model_copy(update={"team_id": "team-no-perms"}), + admitted_user, + ] + + async def resolve_access(user_api_key_auth: UserAPIKeyAuth) -> AgentAccess: + if user_api_key_auth is admitted_user: + return RestrictedAgentAccess(frozenset({_agent_id(registry, "beta")})) + assert user_api_key_auth.team_id is not None + return grants[user_api_key_auth.team_id] + + listed: Final = await accessible_agents(session, registry.get_agent_list(), resolve_access, effective_contexts) + assert {agent.agent_name for agent in listed} == {"alpha", "beta"} + async def test_get_allowed_agents_for_key_via_access_group_ids(self): """ Test that _get_allowed_agents_for_key includes agents from key's access_group_ids diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py index 067f5a9f64c..13d6cd8a68c 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py @@ -11,7 +11,6 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.agent_endpoints import endpoints as agent_endpoints from litellm.proxy.agent_endpoints.auth.agent_permission_handler import ( RestrictedAgentAccess, - UnrestrictedAgentAccess, ) from litellm.proxy.agent_endpoints.endpoints import ( _attach_keys_to_agents, @@ -550,9 +549,9 @@ class TestAgentRBACProxyAdminViewOnly: self.allowed_agents_spy.assert_awaited_once() def test_should_still_redact_secrets_for_view_only_admin(self): - """An unrestricted viewer sees the same agents as an admin but with keys + """A viewer granted every agent sees the same agents as an admin but with keys stripped; litellm_params secrets never appear in either response.""" - self.allowed_agents_spy.return_value = UnrestrictedAgentAccess() + self.allowed_agents_spy.return_value = RestrictedAgentAccess(frozenset({"agent-1", "agent-2"})) viewer_resp = self._list_agents(self.viewer_client) admin_resp = self._list_agents(self.admin_client) diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py b/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py index c83ba142011..f809fadc879 100644 --- a/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/anthropic_endpoints/test_endpoints.py @@ -125,11 +125,12 @@ class TestBlockedResponseUsage: mock_logging.post_call_failure_hook.assert_awaited_once() -class TestProxyExceptionPassthrough: +class TestProxyExceptionAnthropicEnvelope: @pytest.mark.asyncio - async def test_anthropic_response_reraises_proxy_exception_unwrapped(self): - """A 400 ProxyException from request validation must surface as-is, - not be re-wrapped into a code-500 ProxyException.""" + async def test_anthropic_response_maps_proxy_exception_to_anthropic_envelope(self): + """LIT-6468: a 400 ProxyException from request validation must surface as + Anthropic's documented {"type": "error", "error": {...}} envelope with the + original status and message, not the OpenAI {"error": {...}} envelope.""" import litellm.proxy.anthropic_endpoints.endpoints as ep import litellm.proxy.proxy_server as proxy_server from litellm.proxy._types import ProxyErrorTypes, ProxyException @@ -140,6 +141,8 @@ class TestProxyExceptionPassthrough: param="metadata", code=400, ) + request = MagicMock() + request.headers = {"x-request-id": "req_test_6468"} with ( patch.object(ep, "_read_request_body", new=AsyncMock(return_value={})), @@ -151,30 +154,61 @@ class TestProxyExceptionPassthrough: patch.object(proxy_server, "proxy_logging_obj") as mock_logging, ): mock_logging.post_call_failure_hook = AsyncMock() - with pytest.raises(ProxyException) as exc_info: - await ep.anthropic_response( - fastapi_response=MagicMock(), - request=MagicMock(), - user_api_key_dict=MagicMock(), - ) + response = await ep.anthropic_response( + fastapi_response=MagicMock(), + request=request, + user_api_key_dict=MagicMock(), + ) - assert exc_info.value is exc - assert exc_info.value.code == "400" - assert exc_info.value.param == "metadata" + assert response.status_code == 400 + body = json.loads(response.body) + assert body == { + "type": "error", + "error": { + "type": "invalid_request_error", + "message": "Invalid type for 'metadata': expected an object, but got a string instead.", + }, + "request_id": "req_test_6468", + } mock_logging.post_call_failure_hook.assert_awaited_once() + @pytest.mark.asyncio + async def test_anthropic_response_maps_429_to_rate_limit_error(self): + """The Anthropic error type follows the status code (429 -> rate_limit_error), + and a code-less exception falls back to 500 api_error.""" + import litellm.proxy.anthropic_endpoints.endpoints as ep + from litellm.proxy._types import ProxyException + + request = MagicMock() + request.headers = {} + + response = ep._anthropic_error_json_response( + ProxyException(message="Rate limit exceeded", type="rate_limit_error", param=None, code=429), + request, + ) + assert response.status_code == 429 + assert json.loads(response.body)["error"]["type"] == "rate_limit_error" + + fallback = ep._anthropic_error_json_response( + ProxyException(message="boom", type="None", param=None, code=None), + request, + ) + assert fallback.status_code == 500 + assert json.loads(fallback.body)["error"]["type"] == "api_error" + class TestHttpExceptionDictDetail: @pytest.mark.asyncio async def test_anthropic_response_serializes_dict_detail_http_exception(self): - """LIT-6466: a post_call guardrail's HTTPException(detail=) must - surface with a clean message plus provider_specific_fields, matching - /v1/chat/completions and /v1/responses, not the str() of the exception.""" + """LIT-6466 + LIT-6468: a post_call guardrail's HTTPException(detail=) + must surface as Anthropic's {"type": "error", "error": {...}} envelope with + the guardrail's clean message plus provider_specific_fields, not the str() + of the exception and not the OpenAI envelope.""" from fastapi import HTTPException import litellm.proxy.anthropic_endpoints.endpoints as ep import litellm.proxy.proxy_server as proxy_server - from litellm.proxy._types import ProxyException, UserAPIKeyAuth + from litellm.proxy._types import UserAPIKeyAuth detail = { "error": "Content blocked: keyword 'kumquat' detected", @@ -182,6 +216,8 @@ class TestHttpExceptionDictDetail: "guardrail": "keyword-block", } exc = HTTPException(status_code=400, detail=detail) + request = MagicMock() + request.headers = {} with ( patch.object(ep, "_read_request_body", new=AsyncMock(return_value={})), # test-quality-ok: endpoint reads the body via a module function; no injection seam @@ -193,17 +229,19 @@ class TestHttpExceptionDictDetail: patch.object(proxy_server, "proxy_logging_obj") as mock_logging, # test-quality-ok: module global imported at call time; no injection seam ): mock_logging.post_call_failure_hook = AsyncMock() - with pytest.raises(ProxyException) as exc_info: - await ep.anthropic_response( - fastapi_response=MagicMock(), - request=MagicMock(), - user_api_key_dict=UserAPIKeyAuth(), - ) + response = await ep.anthropic_response( + fastapi_response=MagicMock(), + request=request, + user_api_key_dict=UserAPIKeyAuth(), + ) - assert exc_info.value.message == "Content blocked: keyword 'kumquat' detected" - assert "{'error'" not in exc_info.value.message - assert exc_info.value.provider_specific_fields == detail - assert exc_info.value.code == "400" + assert response.status_code == 400 + body = json.loads(response.body) + assert body["type"] == "error" + assert body["error"]["type"] == "invalid_request_error" + assert body["error"]["message"] == "Content blocked: keyword 'kumquat' detected" + assert "{'error'" not in body["error"]["message"] + assert body["error"]["provider_specific_fields"] == detail mock_logging.post_call_failure_hook.assert_awaited_once() @@ -215,7 +253,7 @@ class TestFailureHookRequestData: handler must pass that replaced dict, not the raw request body dict.""" import litellm.proxy.anthropic_endpoints.endpoints as ep import litellm.proxy.proxy_server as proxy_server - from litellm.proxy._types import ProxyException, UserAPIKeyAuth + from litellm.proxy._types import UserAPIKeyAuth captured = {} @@ -224,18 +262,23 @@ class TestFailureHookRequestData: captured["processor_data"] = self.data raise RuntimeError("provider timeout") + request = MagicMock() + request.headers = {} + with ( patch.object(ep, "_read_request_body", new=AsyncMock(return_value={"model": "claude-sonnet"})), patch.object(ep.ProxyBaseLLMRequestProcessing, "base_process_llm_request", new=fake_process), patch.object(proxy_server, "proxy_logging_obj") as mock_logging, ): mock_logging.post_call_failure_hook = AsyncMock() - with pytest.raises(ProxyException): - await ep.anthropic_response( - fastapi_response=MagicMock(), - request=MagicMock(), - user_api_key_dict=UserAPIKeyAuth(), - ) + response = await ep.anthropic_response( + fastapi_response=MagicMock(), + request=request, + user_api_key_dict=UserAPIKeyAuth(), + ) + + assert response.status_code == 500 + assert json.loads(response.body)["error"]["message"] == "provider timeout" hook_request_data = mock_logging.post_call_failure_hook.await_args.kwargs["request_data"] assert hook_request_data is captured["processor_data"] diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index ffe3e0fee12..be83ca57e76 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -1349,6 +1349,38 @@ async def test_vector_store_access_check_early_returns( assert result == expected_result +@pytest.mark.asyncio +async def test_vector_store_access_check_skips_db_lookup_when_no_vector_stores_requested(): + """Registry returns [] (not None) for plain requests; no object permission DB lookup should happen.""" + valid_token = UserAPIKeyAuth(token="test-token", object_permission_id="perm-123") + team_object = MagicMock() + team_object.object_permission_id = "team-permission" + + mock_prisma_client = MagicMock() + find_unique = AsyncMock() + mock_prisma_client.db.litellm_objectpermissiontable.find_unique = find_unique + + mock_vector_store_registry = MagicMock() + mock_vector_store_registry.get_vector_store_ids_to_run.return_value = [] + + with ( + patch( # test-quality-ok: production auth reads these module globals; no dependency injection seam exists + "litellm.proxy.proxy_server.prisma_client", mock_prisma_client + ), + patch( # test-quality-ok: production auth reads this module global; no dependency injection seam exists + "litellm.vector_store_registry", mock_vector_store_registry + ), + ): + result = await vector_store_access_check( + request_body={"messages": [{"role": "user", "content": "test"}]}, + team_object=team_object, + valid_token=valid_token, + ) + + assert result is True + find_unique.assert_not_awaited() + + @pytest.mark.parametrize( "object_permissions,vector_store_ids,should_raise,error_type", [ diff --git a/tests/test_litellm/proxy/auth/test_model_checks.py b/tests/test_litellm/proxy/auth/test_model_checks.py index 62073f4bf51..d58683fd1e5 100644 --- a/tests/test_litellm/proxy/auth/test_model_checks.py +++ b/tests/test_litellm/proxy/auth/test_model_checks.py @@ -754,6 +754,84 @@ def test_expand_wildcard_invalid_litellm_params_passthrough(): assert result == [deployment] +def test_get_complete_model_list_excludes_wildcard_routes_by_default(): + """Regression (LIT-4108): a wildcard with a matching router deployment leaked into /v1/models.""" + from litellm import Router + from litellm.proxy.auth.model_checks import get_complete_model_list + + router = Router( + model_list=[ + { + "model_name": "bedrock/*", + "litellm_params": {"model": "bedrock/*"}, + }, + { + "model_name": "gpt-4", + "litellm_params": {"model": "openai/gpt-4"}, + }, + ] + ) + + result = get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=["bedrock/*", "gpt-4"], + user_model=None, + infer_model_from_keys=False, + return_wildcard_routes=False, + llm_router=router, + ) + + assert "bedrock/*" not in result + assert "gpt-4" in result + assert any(m.startswith("bedrock/") for m in result) + + +def test_get_complete_model_list_excludes_wildcard_routes_without_router(): + from litellm.proxy.auth.model_checks import get_complete_model_list + + result = get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=["bedrock/*", "gpt-4"], + user_model=None, + infer_model_from_keys=False, + return_wildcard_routes=False, + llm_router=None, + ) + + assert "bedrock/*" not in result + assert "gpt-4" in result + assert any(m.startswith("bedrock/") for m in result) + + +def test_get_complete_model_list_includes_wildcard_routes_when_requested(): + from litellm import Router + from litellm.proxy.auth.model_checks import get_complete_model_list + + router = Router( + model_list=[ + { + "model_name": "bedrock/*", + "litellm_params": {"model": "bedrock/*"}, + }, + ] + ) + + result = get_complete_model_list( + key_models=[], + team_models=[], + proxy_model_list=["bedrock/*"], + user_model=None, + infer_model_from_keys=False, + return_wildcard_routes=True, + llm_router=router, + ) + + assert result.count("bedrock/*") == 1 + assert any(m.startswith("bedrock/") and m != "bedrock/*" for m in result) + + def test_add_known_models_refreshes_models_by_provider_for_wildcard_expansion(): """models_by_provider was a frozen import-time snapshot of set unions, so cost map reloads (which call add_known_models) never reached wildcard expansion until a diff --git a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py index ef560bd1b7d..fcfb9342176 100644 --- a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py @@ -1,4 +1,6 @@ +import io import json +from typing import get_type_hints from unittest.mock import AsyncMock, MagicMock, patch import orjson @@ -18,9 +20,11 @@ from litellm.proxy.common_utils.http_parsing_utils import ( _safe_get_request_parsed_body, _safe_get_request_query_params, _safe_set_request_parsed_body, + coerce_numeric_form_fields, get_form_data, get_request_body, get_tags_from_request_body, + numeric_form_fields, populate_request_with_path_params, ) @@ -1029,3 +1033,79 @@ class TestGetRequestBody: mock_request = MagicMock() mock_request.method = "GET" assert await get_request_body(mock_request) == {} + + +class TestNumericFormFields: + def test_image_edit_schema_yields_only_n(self): + from litellm.types.images.main import ImageEditRequestParams + + assert dict(numeric_form_fields(get_type_hints(ImageEditRequestParams))) == {"n": int} + + def test_qualifiers_and_optionality_are_unwrapped(self): + from typing import Optional + + from typing_extensions import Annotated, NotRequired, ReadOnly, Required, TypedDict + + class Schema(TypedDict, total=False): + plain: int + optional: Optional[int] + piped: int | None + read_only: ReadOnly[int | None] + not_required: NotRequired[ReadOnly[int]] + required: Required[ReadOnly[Annotated[float, "meta"]]] + + assert dict(numeric_form_fields(get_type_hints(Schema))) == { + "plain": int, + "optional": int, + "piped": int, + "read_only": int, + "not_required": int, + "required": float, + } + + def test_non_scalar_and_bool_fields_are_skipped(self): + from typing import Any, Literal, Optional, Union + + from typing_extensions import TypedDict + + class Schema(TypedDict, total=False): + flag: bool + optional_flag: Optional[bool] + text: str + choice: Optional[Literal["high", "low"]] + numbers: list[int] + mapping: Optional[dict[str, Any]] + ambiguous: Union[int, str] + + assert dict(numeric_form_fields(get_type_hints(Schema))) == {} + + +class TestCoerceNumericFormFields: + numeric_fields = {"n": int, "temperature": float} + + def test_numeric_strings_are_parsed(self): + assert coerce_numeric_form_fields( + parsed_body={"n": "2", "temperature": "0.5"}, + numeric_fields=self.numeric_fields, + ) == {"n": 2, "temperature": 0.5} + + def test_other_fields_keep_their_string_values(self): + result = coerce_numeric_form_fields( + parsed_body={"size": "1024x1024", "prompt": "2", "quality": "high"}, + numeric_fields=self.numeric_fields, + ) + assert result == {"size": "1024x1024", "prompt": "2", "quality": "high"} + + def test_unparseable_value_is_left_for_the_provider_to_reject(self): + assert coerce_numeric_form_fields( + parsed_body={"n": "two", "temperature": ""}, + numeric_fields=self.numeric_fields, + ) == {"n": "two", "temperature": ""} + + def test_already_typed_and_non_string_values_pass_through(self): + buffer = io.BytesIO(b"png") + result = coerce_numeric_form_fields( + parsed_body={"n": 3, "temperature": None, "image": buffer}, + numeric_fields=self.numeric_fields, + ) + assert result == {"n": 3, "temperature": None, "image": buffer} diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/__init__.py b/tests/test_litellm/proxy/container_endpoints/__init__.py similarity index 100% rename from tests/rust-python-harness/strategies/e2e_parity/sdk/__init__.py rename to tests/test_litellm/proxy/container_endpoints/__init__.py diff --git a/tests/test_litellm/proxy/container_endpoints/test_endpoints.py b/tests/test_litellm/proxy/container_endpoints/test_endpoints.py new file mode 100644 index 00000000000..1beff4c82ba --- /dev/null +++ b/tests/test_litellm/proxy/container_endpoints/test_endpoints.py @@ -0,0 +1,134 @@ +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.container_endpoints import endpoints, ownership +from litellm.types.containers.main import ContainerListResponse, ContainerObject + +PROXY_SERVER_STUB = SimpleNamespace( + general_settings={}, + prisma_client=None, + llm_router=None, + proxy_config=None, + proxy_logging_obj=None, + select_data_generator=None, + user_api_base=None, + user_max_tokens=None, + user_model=None, + user_request_timeout=None, + user_temperature=None, + version="test", +) +ADMIN = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN) +NON_ADMIN = UserAPIKeyAuth(user_id="user-1") + + +@pytest.fixture(autouse=True) +def clear_allowed_container_ids_cache(): + ownership._ALLOWED_CONTAINER_IDS_CACHE.cache_dict.clear() + ownership._ALLOWED_CONTAINER_IDS_CACHE.ttl_dict.clear() + yield + ownership._ALLOWED_CONTAINER_IDS_CACHE.cache_dict.clear() + ownership._ALLOWED_CONTAINER_IDS_CACHE.ttl_dict.clear() + + +def _client(auth: UserAPIKeyAuth) -> TestClient: + app = FastAPI() + app.include_router(endpoints.router) + app.dependency_overrides[user_api_key_auth] = lambda: auth + return TestClient(app) + + +def _container(container_id: str) -> ContainerObject: + return ContainerObject(id=container_id, object="container", created_at=1, status="active") + + +def _page(*container_ids: str, has_more: bool) -> ContainerListResponse: + return ContainerListResponse( + object="list", + data=[_container(container_id) for container_id in container_ids], + has_more=has_more, + ) + + +def _upstream_pages(monkeypatch, pages_by_after) -> MagicMock: + processor_cls = MagicMock( + side_effect=lambda data: SimpleNamespace( + base_process_llm_request=AsyncMock(return_value=pages_by_after[data["after"]]) + ) + ) + monkeypatch.setattr(endpoints, "ProxyBaseLLMRequestProcessing", processor_cls) + return processor_cls + + +def _forwarded_pages(processor_cls: MagicMock): + return [(call.kwargs["data"]["after"], call.kwargs["data"]["limit"]) for call in processor_cls.call_args_list] + + +def test_list_containers_forwards_typed_pagination_params_for_admins(monkeypatch): + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", PROXY_SERVER_STUB) + processor_cls = _upstream_pages(monkeypatch, {"cntr_prev": _page("cntr_next", has_more=True)}) + + response = _client(ADMIN).get( + "/v1/containers", + params={"limit": "1", "order": "desc", "after": "cntr_prev"}, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + assert [item["id"] for item in response.json()["data"]] == ["cntr_next"] + assert response.json()["has_more"] is True + assert _forwarded_pages(processor_cls) == [("cntr_prev", 1)] + assert processor_cls.call_args.kwargs["data"]["order"] == "desc" + + +def test_list_containers_rejects_a_non_integer_limit(monkeypatch): + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", PROXY_SERVER_STUB) + processor_cls = _upstream_pages(monkeypatch, {}) + + response = _client(ADMIN).get( + "/v1/containers", + params={"limit": "abc"}, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 422 + processor_cls.assert_not_called() + + +def test_list_containers_pages_upstream_until_non_admin_keys_see_their_containers(monkeypatch): + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", PROXY_SERVER_STUB) + table = AsyncMock() + table.find_many.return_value = [SimpleNamespace(model_object_id="container:openai:cntr_owned")] + monkeypatch.setattr( + ownership, + "_get_prisma_client", + AsyncMock(return_value=SimpleNamespace(db=SimpleNamespace(litellm_managedobjecttable=table))), + ) + processor_cls = _upstream_pages( + monkeypatch, + { + None: _page("cntr_other", has_more=True), + "cntr_other": _page("cntr_owned", has_more=False), + }, + ) + + response = _client(NON_ADMIN).get( + "/v1/containers", + params={"limit": "1"}, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + body = response.json() + assert [item["id"] for item in body["data"]] == ["cntr_owned"] + assert body["first_id"] == "cntr_owned" + assert body["last_id"] == "cntr_owned" + assert body["has_more"] is False + assert _forwarded_pages(processor_cls) == [(None, 100), ("cntr_other", 100)] diff --git a/tests/test_litellm/proxy/container_endpoints/test_handler_factory.py b/tests/test_litellm/proxy/container_endpoints/test_handler_factory.py new file mode 100644 index 00000000000..a471f915071 --- /dev/null +++ b/tests/test_litellm/proxy/container_endpoints/test_handler_factory.py @@ -0,0 +1,62 @@ +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.container_endpoints import endpoints, handler_factory + +PROXY_SERVER_STUB = SimpleNamespace( + general_settings={}, + prisma_client=None, + llm_router=None, + proxy_config=None, + proxy_logging_obj=None, + select_data_generator=None, + user_api_base=None, + user_max_tokens=None, + user_model=None, + user_request_timeout=None, + user_temperature=None, + version="test", +) + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(endpoints.router) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="user-1") + return TestClient(app) + + +def test_list_container_files_forwards_declared_query_params(monkeypatch): + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", PROXY_SERVER_STUB) + monkeypatch.setattr( + handler_factory, + "assert_user_can_access_container", + AsyncMock(return_value=("cntr_123", "openai")), + ) + processor_cls = MagicMock() + processor_cls.return_value.base_process_llm_request = AsyncMock( + return_value={"object": "list", "data": [], "has_more": True} + ) + monkeypatch.setattr(handler_factory, "ProxyBaseLLMRequestProcessing", processor_cls) + + response = _client().get( + "/v1/containers/cntr_123/files", + params={"limit": "1", "order": "desc", "after": "cfile_prev", "unknown": "x"}, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + assert response.json()["has_more"] is True + assert processor_cls.return_value.base_process_llm_request.await_args.kwargs["route_type"] == "alist_container_files" + forwarded = processor_cls.call_args.kwargs["data"] + assert forwarded["container_id"] == "cntr_123" + assert forwarded["limit"] == "1" + assert forwarded["order"] == "desc" + assert forwarded["after"] == "cfile_prev" + assert "unknown" not in forwarded diff --git a/tests/test_litellm/proxy/db/test_replica_identity.py b/tests/test_litellm/proxy/db/test_replica_identity.py index ecfc6433ab1..9738fc9bd98 100644 --- a/tests/test_litellm/proxy/db/test_replica_identity.py +++ b/tests/test_litellm/proxy/db/test_replica_identity.py @@ -29,7 +29,7 @@ def test_hands_the_alter_statement_to_the_prisma_cli(): return subprocess.CompletedProcess(cmd, 0) with patch( - "litellm_proxy_extras.replica_identity.subprocess.run", side_effect=capture + "litellm_proxy_extras.replica_identity.run_prisma", side_effect=capture ): applied = apply_replica_identity_full( schema_path="/somewhere/schema.prisma", @@ -60,7 +60,7 @@ def test_hands_the_alter_statement_to_the_prisma_cli(): ) def test_every_failure_is_reported_instead_of_raised(failure): with patch( - "litellm_proxy_extras.replica_identity.subprocess.run", side_effect=failure + "litellm_proxy_extras.replica_identity.run_prisma", side_effect=failure ): assert ( apply_replica_identity_full( diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index 953e3de1519..1e44d29b610 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -5527,10 +5527,6 @@ async def test_streaming_end_of_stream_block_emits_error_frame_instead_of_trunca error frame instead. The finish chunk is withheld while the end-of-stream scan runs, so on a block it is dropped rather than relayed before the frame.""" - from litellm.llms import load_guardrail_translation_mappings - from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail import ( - unified_guardrail as unified_module, - ) from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import ( UnifiedLLMGuardrails, ) @@ -5569,20 +5565,16 @@ async def test_streaming_end_of_stream_block_emits_error_frame_instead_of_trunca yield _chunk("the forbidden ") yield _chunk("topic answer", finish_reason="stop") - unified_module.endpoint_guardrail_translation_mappings = load_guardrail_translation_mappings() - try: - with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api: - mock_api.side_effect = guardrail._get_http_exception_for_blocked_guardrail(blocked_response) + with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api: + mock_api.side_effect = guardrail._get_http_exception_for_blocked_guardrail(blocked_response) - out = [] - async for item in UnifiedLLMGuardrails().async_post_call_streaming_iterator_hook( - user_api_key_dict=UserAPIKeyAuth(api_key="test", request_route="/v1/chat/completions"), - response=_mock_stream(), - request_data={"guardrail_to_apply": guardrail, "model": "gpt-4"}, - ): - out.append(item) - finally: - unified_module.endpoint_guardrail_translation_mappings = None + out = [] + async for item in UnifiedLLMGuardrails().async_post_call_streaming_iterator_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="test", request_route="/v1/chat/completions"), + response=_mock_stream(), + request_data={"guardrail_to_apply": guardrail, "model": "gpt-4"}, + ): + out.append(item) assert len(out) == 2 assert isinstance(out[0], ModelResponseStream) @@ -5792,3 +5784,25 @@ async def test_apply_guardrail_debug_log_masks_signed_request_headers(): assert header_lines, "expected the signed-request debug line to be logged" assert any("X-Amz-Security-Token" in message for message in header_lines) assert all(session_token not in message for message in rendered_messages) + + +@pytest.mark.asyncio +async def test_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch): + """The guardrail's AWS profile does not exist, so resolving SigV4 credentials + raises; with a bearer token configured the guardrail must still run, since + the bearer token alone signs the request.""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + guardrail = BedrockGuardrail( + guardrailIdentifier="test-guardrail", + guardrailVersion="DRAFT", + aws_profile_name="litellm-no-such-aws-profile", + ) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"action": "NONE", "assessments": []} + + with patch.object(guardrail.async_handler, "post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + response = await guardrail.make_bedrock_api_request(source="INPUT", messages=[{"role": "user", "content": "hello"}]) + + assert response["action"] == "NONE" + assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer env-bearer-token-12345" 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 index d842a1ee5f9..f4af77d2e40 100644 --- 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 @@ -833,3 +833,31 @@ async def test_many_blocks_scanned_at_request_level_and_can_block(): 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) + + +@pytest.mark.asyncio +async def test_checks_bearer_token_never_runs_the_sigv4_credential_chain(monkeypatch): + """Same bearer-token rule as ApplyGuardrail: the guardrail's AWS profile does + not exist, yet the InvokeGuardrailChecks call still goes out on the bearer + token and its verdict is enforced.""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + g = BedrockGuardrail( + checks=CONTENT_FILTER_CHECKS, + content_filter_threshold=0.5, + aws_profile_name="litellm-no-such-aws-profile", + ) + payload = {"results": {"contentFilter": {"results": [{"category": "VIOLENCE", "severityScore": 0.8}]}}} + post = AsyncMock(return_value=_mock_http_response(200, payload)) + + with patch.object(g.async_handler, "post", new=post): + with pytest.raises(HTTPException) as exc: + await g.make_bedrock_api_request( + source="INPUT", + messages=[{"role": "user", "content": "hi"}], + request_data={"messages": []}, + ) + + assert exc.value.detail["bedrock_guardrail_checks"] == [ + {"check": "contentFilter", "category": "VIOLENCE", "severityScore": 0.8} + ] + assert post.call_args.kwargs["headers"]["Authorization"] == "Bearer env-bearer-token-12345" 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 a28a2a71613..a579370ad3c 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 @@ -75,19 +75,29 @@ class _NoopTranslation(BaseTranslation): return response +def _patch_translation_mappings(monkeypatch, mappings): + """Point the unified guardrail at ``mappings`` for one test, restored by pytest. + + Every override goes through this one seam: competing writers to the same state + are what leaked a stale handler map into unrelated test files (LIT-6834). + """ + monkeypatch.setattr(unified_module, "load_guardrail_translation_mappings", lambda: mappings) + + @pytest.fixture(autouse=True) -def _inject_mcp_handler_mapping(): +def _inject_mcp_handler_mapping(monkeypatch): """Inject MCP handler mapping so the unified guardrail can run inside tests.""" - unified_module.endpoint_guardrail_translation_mappings = { - CallTypes.call_mcp_tool: MCPGuardrailTranslationHandler, - CallTypes.anthropic_messages: _NoopTranslation, - CallTypes.ocr: OCRHandler, - CallTypes.aocr: OCRHandler, - CallTypes.responses: OpenAIResponsesHandler, - CallTypes.aresponses: OpenAIResponsesHandler, - } - yield - unified_module.endpoint_guardrail_translation_mappings = None + _patch_translation_mappings( + monkeypatch, + { + CallTypes.call_mcp_tool: MCPGuardrailTranslationHandler, + CallTypes.anthropic_messages: _NoopTranslation, + CallTypes.ocr: OCRHandler, + CallTypes.aocr: OCRHandler, + CallTypes.responses: OpenAIResponsesHandler, + CallTypes.aresponses: OpenAIResponsesHandler, + }, + ) class TestUnifiedLLMGuardrails: @@ -396,7 +406,7 @@ class TestUnifiedLLMGuardrails: class TestAsyncPostCallStreamingIteratorHook: @pytest.mark.asyncio - async def test_streaming_content_not_lost_on_sampled_chunks(self): + async def test_streaming_content_not_lost_on_sampled_chunks(self, monkeypatch): """ Verify that every chunk's content is preserved in the output stream. @@ -442,10 +452,7 @@ class TestUnifiedLLMGuardrails: return responses_so_far - # Override the mapping to use our content-clearing translation - unified_module.endpoint_guardrail_translation_mappings = { - CallTypes.acompletion: _ContentClearingTranslation, - } + _patch_translation_mappings(monkeypatch, {CallTypes.acompletion: _ContentClearingTranslation}) handler = UnifiedLLMGuardrails() guardrail = RecordingGuardrail() @@ -885,12 +892,8 @@ class TestStreamingTransform: 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 + def _use_openai_handler_mapping(self, monkeypatch): + _patch_translation_mappings(monkeypatch, {CallTypes.acompletion: OpenAIChatCompletionsHandler}) @pytest.mark.asyncio async def test_block_only_drops_text_rewrites(self): @@ -1719,6 +1722,10 @@ class TestAppliedGuardrailsReflectsExecution: decision and marks itself only when it actually ran (LIT-4650). Ordinary guardrails are still auto-marked by the hook after dispatch.""" + @pytest.fixture(autouse=True) + def _use_texts_only_mapping(self, monkeypatch): + _patch_translation_mappings(monkeypatch, {CallTypes.pass_through: _TextsOnlyTranslation}) + @staticmethod def _data(guardrail): return { @@ -1728,7 +1735,6 @@ class TestAppliedGuardrailsReflectsExecution: } async def _run(self, guardrail): - unified_module.endpoint_guardrail_translation_mappings = {CallTypes.pass_through: _TextsOnlyTranslation} data = self._data(guardrail) await UnifiedLLMGuardrails().async_pre_call_hook( user_api_key_dict=None, @@ -1830,10 +1836,8 @@ class TestStreamingHttpErrorFrames: silently truncates the SSE stream (PR #38722 defect 1).""" @pytest.fixture(autouse=True) - def _use_real_mappings(self): - unified_module.endpoint_guardrail_translation_mappings = load_guardrail_translation_mappings() - yield - unified_module.endpoint_guardrail_translation_mappings = None + def _use_real_mappings(self, monkeypatch): + _patch_translation_mappings(monkeypatch, load_guardrail_translation_mappings()) @pytest.mark.asyncio async def test_chat_eos_block_emits_data_error_frame(self): @@ -1938,10 +1942,8 @@ class TestStreamingGuardrailInformationBucket: guardrail_information write was diverted and /spend/logs showed null.""" @pytest.fixture(autouse=True) - def _use_real_mappings(self): - unified_module.endpoint_guardrail_translation_mappings = load_guardrail_translation_mappings() - yield - unified_module.endpoint_guardrail_translation_mappings = None + def _use_real_mappings(self, monkeypatch): + _patch_translation_mappings(monkeypatch, load_guardrail_translation_mappings()) @pytest.mark.asyncio async def test_chat_eos_scan_writes_guardrail_information_to_metadata(self): @@ -2038,11 +2040,7 @@ class TestStreamingScanDedup: @pytest.fixture(autouse=True) def _use_real_mappings(self, monkeypatch): - monkeypatch.setattr( - unified_module, - "endpoint_guardrail_translation_mappings", - load_guardrail_translation_mappings(), - ) + _patch_translation_mappings(monkeypatch, load_guardrail_translation_mappings()) @pytest.mark.asyncio async def test_chat_terminal_chunk_on_sampled_index_is_scanned_once(self): @@ -2239,3 +2237,55 @@ class TestStreamingScanDedup: assert out == chunks assert [scan["texts"] for scan in guardrail.scans] == [["abc"]] + + +class TestTranslationMappingsAreReadLive: + """The hooks must read the handler map on every call, never memoize it on the module. + + A second module-level cache is what let one test's handler map outlive its own + teardown and decide how unrelated files translated their streams (LIT-6834). + """ + + @staticmethod + def _ocr_request(guardrail): + return { + "guardrail_to_apply": guardrail, + "model": "mistral/mistral-ocr-latest", + "document": { + "type": "document_url", + "document_url": "https://arxiv.org/pdf/2201.04234", + }, + } + + async def _run_pre_call(self, guardrail): + await UnifiedLLMGuardrails().async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="test-key"), + cache=DualCache(), + data=self._ocr_request(guardrail), + call_type=CallTypes.aocr.value, + ) + + @pytest.mark.asyncio + async def test_remapping_between_calls_changes_which_handler_runs(self, monkeypatch): + _patch_translation_mappings(monkeypatch, {CallTypes.completion: _NoopTranslation}) + unmapped = RecordingGuardrail() + await self._run_pre_call(unmapped) + assert unmapped.apply_calls == [] + + _patch_translation_mappings(monkeypatch, {CallTypes.aocr: OCRHandler}) + mapped = RecordingGuardrail() + await self._run_pre_call(mapped) + assert [call["input_type"] for call in mapped.apply_calls] == ["request"] + + @pytest.mark.asyncio + async def test_module_exposes_no_second_assignable_handler_map(self, monkeypatch): + _patch_translation_mappings(monkeypatch, {CallTypes.aocr: OCRHandler}) + guardrail = RecordingGuardrail() + await self._run_pre_call(guardrail) + + assert len(guardrail.apply_calls) == 1 + assert not [ + name + for name, value in vars(unified_module).items() + if isinstance(value, dict) and CallTypes.aocr in value + ] diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index b99dcb062b4..18aa43f7d1c 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -802,7 +802,7 @@ async def test_bedrock_guardrail_prepare_request_with_api_key(): @pytest.mark.asyncio -async def test_bedrock_guardrail_prepare_request_without_api_key(): +async def test_bedrock_guardrail_prepare_request_without_api_key(monkeypatch): """Test _prepare_request method falls back to SigV4 when no api_key is provided""" from unittest.mock import Mock, patch @@ -820,18 +820,13 @@ async def test_bedrock_guardrail_prepare_request_without_api_key(): # Test data without api_key test_data = {"source": "INPUT", "content": [{"text": {"text": "test content"}}]} + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) with ( - patch( - "litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails.get_secret_str" - ) as mock_get_secret, patch("botocore.auth.SigV4Auth") as mock_sigv4_auth, patch("botocore.awsrequest.AWSRequest") as mock_aws_request, ): - # Mock no AWS_BEARER_TOKEN_BEDROCK - mock_get_secret.return_value = None - # Mock SigV4Auth mock_sigv4_instance = Mock() mock_sigv4_auth.return_value = mock_sigv4_instance @@ -857,7 +852,7 @@ async def test_bedrock_guardrail_prepare_request_without_api_key(): @pytest.mark.asyncio -async def test_bedrock_guardrail_prepare_request_with_bearer_token_env(): +async def test_bedrock_guardrail_prepare_request_with_bearer_token_env(monkeypatch): """Test _prepare_request method uses Bearer token from environment when available""" from unittest.mock import Mock, patch @@ -875,15 +870,9 @@ async def test_bedrock_guardrail_prepare_request_with_bearer_token_env(): # Test data without api_key test_data = {"source": "INPUT", "content": [{"text": {"text": "test content"}}]} + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-456") - with ( - patch( - "litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails.get_secret_str" - ) as mock_get_secret, - patch("botocore.awsrequest.AWSRequest") as mock_aws_request, - ): - - mock_get_secret.return_value = "env-bearer-token-456" + with patch("botocore.awsrequest.AWSRequest") as mock_aws_request: mock_request_instance = Mock() mock_request_instance.prepare.return_value = Mock() mock_aws_request.return_value = mock_request_instance diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py index 56661b5b843..836668de0c8 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py @@ -155,29 +155,103 @@ def test_duplicate_config_guardrail_names_get_distinct_stable_ids(): registry_module.guardrail_initializer_registry.pop("dup_name_test", None) -def test_update_in_memory_guardrail(): - handler = InMemoryGuardrailHandler() - handler.guardrail_id_to_custom_guardrail["123"] = CustomGuardrail( - guardrail_name="test-guardrail", - default_on=False, - event_hook=GuardrailEventHooks.pre_call, - ) +def _register_mode_following_initializer(guardrail_type: str): + """Registers like the shipped initializers do: construct, then add the instance to litellm's callbacks.""" + import litellm + from litellm.proxy.guardrails import guardrail_registry as registry_module - handler.update_in_memory_guardrail( - "123", - Guardrail( - guardrail_name="test-guardrail", - litellm_params=LitellmParams(guardrail="test-guardrail", mode="pre_call", default_on=True), - ), - ) - - assert ( - handler.guardrail_id_to_custom_guardrail["123"].should_run_guardrail( - data={}, event_type=GuardrailEventHooks.pre_call + def _initializer(litellm_params, guardrail): + callback = CustomGuardrail( + guardrail_name=guardrail["guardrail_name"], + supported_event_hooks=[GuardrailEventHooks.pre_call, GuardrailEventHooks.post_call], + event_hook=GuardrailEventHooks(litellm_params.mode), + default_on=True, ) - is True + litellm.logging_callback_manager.add_litellm_callback(callback) + return callback + + registry_module.guardrail_initializer_registry[guardrail_type] = _initializer + return registry_module + + +def _mode_following_db_row(guardrail_id: str, mode: str, description: str = "") -> Guardrail: + """The raw row GuardrailRegistry.update_guardrail_in_db hands back: litellm_params is a plain dict.""" + return Guardrail( + guardrail_id=guardrail_id, + guardrail_name="mode-following", + litellm_params={"guardrail": "mode_following_test", "mode": mode, "default_on": True}, + guardrail_info={"description": description}, ) - assert handler.guardrail_id_to_custom_guardrail["123"].event_hook is GuardrailEventHooks.pre_call + + +def _live_instances_named(name: str) -> int: + return sum(1 for cb_list in _all_callback_lists() for cb in cb_list if getattr(cb, "guardrail_name", None) == name) + + +def test_update_in_memory_guardrail_raw_db_row_mode_change_gates_at_the_new_stage(): + registry_module = _register_mode_following_initializer("mode_following_test") + lists = _all_callback_lists() + snapshots = [list(cb_list) for cb_list in lists] + try: + handler = InMemoryGuardrailHandler() + handler.initialize_guardrail(guardrail=_mode_following_db_row("123", "pre_call"), source="db") + original = handler.guardrail_id_to_custom_guardrail["123"] + + handler.update_in_memory_guardrail("123", _mode_following_db_row("123", "post_call")) + + replacement = handler.guardrail_id_to_custom_guardrail["123"] + assert replacement is not original + assert replacement.should_run_guardrail(data={}, event_type=GuardrailEventHooks.post_call) is True + assert replacement.should_run_guardrail(data={}, event_type=GuardrailEventHooks.pre_call) is False + assert all(original not in cb_list for cb_list in lists) + assert _live_instances_named("mode-following") == 1 + assert handler.IN_MEMORY_GUARDRAILS["123"]["litellm_params"].mode == "post_call" + assert handler.get_source("123") == "db" + finally: + registry_module.guardrail_initializer_registry.pop("mode_following_test", None) + for cb_list, snapshot in zip(lists, snapshots): + cb_list[:] = snapshot + + +def test_update_in_memory_guardrail_unchanged_params_keep_the_live_instance(): + registry_module = _register_mode_following_initializer("mode_following_test") + lists = _all_callback_lists() + snapshots = [list(cb_list) for cb_list in lists] + try: + handler = InMemoryGuardrailHandler() + handler.initialize_guardrail(guardrail=_mode_following_db_row("123", "pre_call", "old"), source="db") + original = handler.guardrail_id_to_custom_guardrail["123"] + + handler.update_in_memory_guardrail("123", _mode_following_db_row("123", "pre_call", "new")) + + assert handler.guardrail_id_to_custom_guardrail["123"] is original + assert handler.IN_MEMORY_GUARDRAILS["123"]["guardrail_info"] == {"description": "new"} + assert _live_instances_named("mode-following") == 1 + finally: + registry_module.guardrail_initializer_registry.pop("mode_following_test", None) + for cb_list, snapshot in zip(lists, snapshots): + cb_list[:] = snapshot + + +def test_update_in_memory_guardrail_invalid_row_keeps_the_previous_instance_enforcing(): + registry_module = _register_mode_following_initializer("mode_following_test") + lists = _all_callback_lists() + snapshots = [list(cb_list) for cb_list in lists] + try: + handler = InMemoryGuardrailHandler() + handler.initialize_guardrail(guardrail=_mode_following_db_row("123", "pre_call"), source="db") + + with pytest.raises(ValueError, match="not in the supported event hooks"): + handler.update_in_memory_guardrail("123", _mode_following_db_row("123", "during_call")) + + restored = handler.guardrail_id_to_custom_guardrail["123"] + assert restored.should_run_guardrail(data={}, event_type=GuardrailEventHooks.pre_call) is True + assert handler.IN_MEMORY_GUARDRAILS["123"]["litellm_params"].mode == "pre_call" + assert _live_instances_named("mode-following") == 1 + finally: + registry_module.guardrail_initializer_registry.pop("mode_following_test", None) + for cb_list, snapshot in zip(lists, snapshots): + cb_list[:] = snapshot def _make_guardrail(guardrail_id: str, name: str = "g") -> Guardrail: @@ -557,7 +631,7 @@ def test_presidio_siblings_are_tracked_and_deleted_together(): cb_list[:] = snapshot -def test_update_in_memory_guardrail_reaches_presidio_siblings_and_keeps_their_stage(): +def test_update_in_memory_guardrail_rebuilds_presidio_siblings_and_keeps_their_stage(): import litellm handler = InMemoryGuardrailHandler() @@ -591,11 +665,15 @@ def test_update_in_memory_guardrail_reaches_presidio_siblings_and_keeps_their_st ) handler.update_in_memory_guardrail(guardrail_id=PRESIDIO_SIBLINGS_GID, guardrail=updated) - assert [callback.pii_entities_config for callback in tracked] == [{"EMAIL_ADDRESS": "MASK"}] * 3 + rebuilt = _presidio_callbacks_in(litellm.callbacks) + assert len(rebuilt) == 3 + assert [callback.pii_entities_config for callback in rebuilt] == [{"EMAIL_ADDRESS": "MASK"}] * 3 assert [ - (callback.apply_to_output, callback.output_parse_pii, callback.event_hook) for callback in tracked + (callback.apply_to_output, callback.output_parse_pii, callback.event_hook) for callback in rebuilt ] == roles_before - assert _presidio_callbacks_in(litellm.callbacks) == tracked + assert not any(previous in rebuilt for previous in tracked) + assert handler.guardrail_id_to_custom_guardrail[PRESIDIO_SIBLINGS_GID] is rebuilt[0] + assert handler.guardrail_id_to_sibling_callbacks[PRESIDIO_SIBLINGS_GID] == tuple(rebuilt[1:]) finally: for cb_list, snapshot in zip(lists, snapshots): cb_list[:] = snapshot diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index e3f71692c78..0e90c107865 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -1238,6 +1238,18 @@ def test_health_liveness_endpoint(proxy_client): print(f"\n/health/liveness response time: {duration_ms:.2f}ms") +def test_health_backlog_includes_admission_control_stats(proxy_client): + response = proxy_client.get("/health/backlog") + + assert response.status_code == 200, response.text + assert set(response.json()) == { + "in_flight_requests", + "admitted_requests", + "queued_requests", + "rejected_requests", + } + + def test_health_readiness(proxy_client): """ Test /health/readiness endpoint. diff --git a/tests/test_litellm/proxy/image_endpoints/test_endpoints.py b/tests/test_litellm/proxy/image_endpoints/test_endpoints.py index 91a011a8234..203391aadad 100644 --- a/tests/test_litellm/proxy/image_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/image_endpoints/test_endpoints.py @@ -5,10 +5,13 @@ from typing import Any, Dict import orjson import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient from starlette.requests import Request from starlette.responses import Response from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.image_endpoints import endpoints @@ -115,3 +118,52 @@ async def test_image_generation_prompt_rerouting(monkeypatch): assert captured_route_request_data["prompt"] == "sanitized prompt" assert "messages" not in captured_route_request_data assert response.headers.get("x-callback-test") == "value" + + +def _image_edit_client(monkeypatch, captured: Dict[str, Any]) -> TestClient: + class CaptureProcessing: + def __init__(self, data: Dict[str, Any]) -> None: + captured.update(data) + + async def base_process_llm_request(self, **_: Any) -> Dict[str, Any]: + return {"data": [{"b64_json": "aGk="}]} + + monkeypatch.setattr(endpoints, "ProxyBaseLLMRequestProcessing", CaptureProcessing) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) + monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None) + + app = FastAPI() + app.include_router(endpoints.router) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth() + return TestClient(app) + + +def test_image_edit_multipart_n_reaches_the_provider_as_an_int(monkeypatch): + """A multipart `n` must not arrive as the string Starlette parsed it into.""" + captured: Dict[str, Any] = {} + + response = _image_edit_client(monkeypatch, captured).post( + "/v1/images/edits", + files={"image": ("tree.png", b"\x89PNG\r\n\x1a\n", "image/png")}, + data={"model": "nova-canvas", "prompt": "add a hat", "n": "2", "size": "1024x1024"}, + ) + + assert response.status_code == 200 + assert captured["n"] == 2 + assert isinstance(captured["n"], int) + assert captured["size"] == "1024x1024" + assert captured["prompt"] == "add a hat" + + +def test_image_edit_multipart_n_that_is_not_a_number_is_left_alone(monkeypatch): + """An unparseable `n` still reaches the provider, which rejects it as before.""" + captured: Dict[str, Any] = {} + + response = _image_edit_client(monkeypatch, captured).post( + "/v1/images/edits", + files={"image": ("tree.png", b"\x89PNG\r\n\x1a\n", "image/png")}, + data={"model": "nova-canvas", "prompt": "add a hat", "n": "two"}, + ) + + assert response.status_code == 200 + assert captured["n"] == "two" diff --git a/tests/test_litellm/proxy/management_endpoints/policy_endpoints/test_endpoints.py b/tests/test_litellm/proxy/management_endpoints/policy_endpoints/test_endpoints.py index 4e063dd0c5b..86f9aeafc08 100644 --- a/tests/test_litellm/proxy/management_endpoints/policy_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/policy_endpoints/test_endpoints.py @@ -225,3 +225,71 @@ def test_compute_overall_action_all_passed(): def test_compute_overall_action_empty(): assert _compute_overall_action([]) == "passed" + + +class TestEnrichPolicyTemplateStreamKeepalive: + async def _collect_endpoint_body(self, monkeypatch, interval, delay=0.3) -> tuple[list[bytes], dict]: + import asyncio + from unittest.mock import MagicMock + + import litellm + import litellm.proxy.management_endpoints.policy_endpoints.endpoints as policy_endpoints + import litellm.proxy.proxy_server as proxy_server + from fastapi.responses import StreamingResponse + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.management_endpoints.policy_endpoints.endpoints import ( + EnrichTemplateRequest, + enrich_policy_template_stream, + ) + + monkeypatch.setattr(litellm, "sse_keepalive_ping_interval_seconds", interval) + + async def _name_chunks(): + await asyncio.sleep(delay) + chunk = MagicMock() + chunk.choices = [MagicMock()] + chunk.choices[0].delta.content = "Rival Air\n" + yield chunk + + class SlowRouter: + async def acompletion(self, **kwargs): + return _name_chunks() + + async def _no_variations(competitors, model): + return {} + + monkeypatch.setattr(proxy_server, "llm_router", SlowRouter()) + monkeypatch.setattr(policy_endpoints, "_generate_competitor_variations", _no_variations) + + response = await enrich_policy_template_stream( + data=EnrichTemplateRequest( + template_id="competitor-mention-detection", + parameters={"brand_name": "Acme"}, + model="gpt-5.4-mini", + ), + request=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN), + ) + assert isinstance(response, StreamingResponse) + chunks = [chunk if isinstance(chunk, bytes) else chunk.encode() async for chunk in response.body_iterator] + return chunks, dict(response.headers) + + @pytest.mark.asyncio + async def test_endpoint_pings_while_competitor_discovery_is_still_running(self, monkeypatch): + chunks, headers = await self._collect_endpoint_body(monkeypatch, interval=0.05) + + assert headers["content-type"].startswith("text/event-stream") + assert headers["cache-control"] == "no-cache" + assert headers["x-accel-buffering"] == "no" + assert chunks[0] == b": ping\n\n" + assert chunks.count(b": ping\n\n") >= 3 + assert b'data: {"type": "competitor", "name": "Rival Air"}\n\n' in chunks + assert chunks[-1].startswith(b'data: {"type": "done"') + + @pytest.mark.asyncio + async def test_endpoint_stream_is_untouched_while_keepalives_are_unconfigured(self, monkeypatch): + chunks, _ = await self._collect_endpoint_body(monkeypatch, interval=None, delay=0.15) + + assert b": ping\n\n" not in chunks + assert chunks[0] == b'data: {"type": "competitor", "name": "Rival Air"}\n\n' + assert chunks[-1].startswith(b'data: {"type": "done"') diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index 1697b77b99a..60f9a1a55e2 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -19,11 +19,13 @@ from litellm.proxy._types import ( NewUserResponse, ProxyErrorTypes, ProxyException, + UserAPIKeyAuth, ) from litellm.proxy.management_endpoints.scim.scim_v2 import ( SCIMRosterSyncError, UserProvisionerHelpers, _apply_group_patch_updates, + _create_user_if_not_exists, _extract_group_member_ids, _extract_ids_from_path_filter, _handle_group_membership_changes, @@ -37,8 +39,8 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import ( delete_group, delete_user, get_groups, - get_users, get_service_provider_config, + get_users, merge_placeholder, patch_group, patch_team_membership, @@ -304,6 +306,85 @@ async def test_create_user_uses_default_internal_user_params_role(mocker, monkey assert called_args.user_role == LitellmUserRoles.PROXY_ADMIN +def _mock_scim_create_user_deps(mocker: MockerFixture, scim_user: SCIMUser) -> AsyncMock: + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=()) + mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) + mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_user + "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", + AsyncMock(return_value=mock_prisma_client), + ) + mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_user + "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user", + AsyncMock(return_value=scim_user), + ) + return mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_user + "litellm.proxy.management_endpoints.scim.scim_v2.new_user", + AsyncMock(return_value=NewUserRequest(user_id=scim_user.userName)), + ) + + +@pytest.mark.asyncio +async def test_create_user_without_groups_defers_to_default_team(mocker: MockerFixture, monkeypatch): + """IdPs omit groups on POST /Users; teams must stay unset so new_user applies default_internal_user_params.teams""" + scim_user = SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + userName="new-user", + emails=[SCIMUserEmail(value="new@example.com")], + ) + monkeypatch.setattr( + "litellm.default_internal_user_params", + {"teams": [{"team_id": "default-team", "max_budget_in_team": 25}]}, + raising=False, + ) + new_user_mock = _mock_scim_create_user_deps(mocker, scim_user) + + await create_user(user=scim_user) + + assert new_user_mock.call_args.kwargs["data"].teams is None + assert new_user_mock.call_args.kwargs["user_api_key_dict"] == UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + + +@pytest.mark.asyncio +async def test_create_user_with_groups_keeps_idp_teams(mocker: MockerFixture, monkeypatch): + scim_user = SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + userName="new-user", + emails=[SCIMUserEmail(value="new@example.com")], + groups=[SCIMUserGroup(value="idp-team")], + ) + monkeypatch.setattr( + "litellm.default_internal_user_params", + {"teams": [{"team_id": "default-team", "max_budget_in_team": 25}]}, + raising=False, + ) + new_user_mock = _mock_scim_create_user_deps(mocker, scim_user) + + await create_user(user=scim_user) + + assert new_user_mock.call_args.kwargs["data"].teams == ["idp-team"] + + +@pytest.mark.asyncio +async def test_create_user_if_not_exists_defers_to_default_team(mocker: MockerFixture, monkeypatch): + monkeypatch.setattr( + "litellm.default_internal_user_params", + {"teams": [{"team_id": "default-team", "max_budget_in_team": 25}]}, + raising=False, + ) + new_user_mock = mocker.patch( # test-quality-ok: new_user is imported inside the helper, not injectable + "litellm.proxy.management_endpoints.internal_user_endpoints.new_user", + AsyncMock(return_value=NewUserResponse(user_id="group-user", key="k")), + ) + + created = await _create_user_if_not_exists(user_id="group-user") + + assert created is not None + assert new_user_mock.call_args.kwargs["data"].teams is None + assert new_user_mock.call_args.kwargs["user_api_key_dict"] == UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + + @pytest.mark.asyncio async def test_scim_create_user_respects_default_role_set_via_ui(mocker, monkeypatch): """ @@ -1176,6 +1257,67 @@ async def test_update_user_success(mocker): assert call_args[1]["data"]["teams"] == ["new-team"] +@pytest.mark.asyncio +@pytest.mark.parametrize("groups", [None, []], ids=["groups-omitted", "groups-empty"]) +async def test_update_user_without_groups_preserves_memberships_and_role(mocker, monkeypatch, groups): + """Okta profile PUTs carry no `groups` or `groups: []`; neither may drop teams (and their keys) or recompute role""" + from litellm.proxy.proxy_server import proxy_config + + async def mock_get_config(): + return {"litellm_settings": {"scim_admin_group": "litellm-admins"}} + + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) + + existing_user = mocker.MagicMock() + existing_user.teams = ["litellm-admins", "engineering"] + existing_user.metadata = {} + + scim_user = SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + userName="okta-user", + name=SCIMUserName(familyName="Renamed", givenName="Okta"), + emails=[SCIMUserEmail(value="okta@example.com")], + **({} if groups is None else {"groups": groups}), + ) + response_scim_user = SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + id="okta-user", + userName="okta-user", + emails=[SCIMUserEmail(value="okta@example.com")], + ) + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value={"user_id": "okta-user"}) + + mocker.patch( # test-quality-ok: update_user's collaborators are module-level, not injectable + "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", + AsyncMock(return_value=mock_prisma_client), + ) + mocker.patch( # test-quality-ok: update_user's collaborators are module-level, not injectable + "litellm.proxy.management_endpoints.scim.scim_v2._check_user_exists", + AsyncMock(return_value=existing_user), + ) + mocker.patch( # test-quality-ok: update_user's collaborators are module-level, not injectable + "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user", + AsyncMock(return_value=response_scim_user), + ) + patch_membership = mocker.patch( # test-quality-ok: roster writes are module-level, not injectable + "litellm.proxy.management_endpoints.scim.scim_v2.patch_team_membership", + AsyncMock(), + ) + + result = await update_user(user_id="okta-user", user=scim_user) + + assert result == response_scim_user + patch_membership.assert_not_awaited() + update_data = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs["data"] + assert update_data["teams"] == ["litellm-admins", "engineering"] + assert "user_role" not in update_data + + @pytest.mark.asyncio async def test_update_user_not_found(mocker): """Should raise 404 when user doesn't exist""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py index e8f768c14ef..81816e21c10 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py @@ -12,13 +12,12 @@ from fastapi.testclient import TestClient from prisma.errors import PrismaError import litellm.proxy.proxy_server as ps -from litellm.proxy.proxy_server import app from litellm.proxy._types import ( CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth, ) - +from litellm.proxy.proxy_server import app def _make_access_group_record( @@ -58,6 +57,10 @@ def _make_access_group_record( return record +def _make_team_record(team_id: str, access_group_ids: list[str] | None = None): + return types.SimpleNamespace(team_id=team_id, access_group_ids=access_group_ids or []) + + @pytest.fixture def client_and_mocks(monkeypatch): """Setup mock prisma and admin auth for access group endpoints.""" @@ -185,7 +188,8 @@ ACCESS_GROUP_PATHS = ["/v1/access_group", "/v1/unified_access_group"] ) def test_create_access_group_success(client_and_mocks, base_path, payload): """Create access group with various payloads returns 201.""" - client, _, mock_table, *_ = client_and_mocks + client, mock_prisma, mock_table, *_ = client_and_mocks + mock_prisma.db.litellm_teamtable.find_many = AsyncMock(return_value=[_make_team_record("team-1")]) resp = client.post(base_path, json=payload) assert resp.status_code == 201 @@ -277,13 +281,45 @@ def test_create_access_group_500_on_non_constraint_prisma_error(client_and_mocks @pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) def test_list_access_groups_success_empty(client_and_mocks, base_path): - """List access groups returns empty list when none exist.""" - client, _, mock_table, *_ = client_and_mocks + """List access groups returns empty list when none exist, without querying teams.""" + client, mock_prisma, mock_table, *_ = client_and_mocks resp = client.get(base_path) assert resp.status_code == 200 assert resp.json() == [] mock_table.find_many.assert_awaited_once() + mock_prisma.db.litellm_teamtable.find_many.assert_not_awaited() + + +@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) +def test_list_access_groups_attributes_teams_per_group_with_one_query(client_and_mocks, base_path): + """List derives each group's teams from the team table in a single query, attributed per group.""" + client, mock_prisma, mock_table, *_ = client_and_mocks + mock_team_table = mock_prisma.db.litellm_teamtable + + records = [ + _make_access_group_record(access_group_id="ag-1", access_group_name="group-1"), + _make_access_group_record(access_group_id="ag-2", access_group_name="group-2"), + ] + mock_table.find_many = AsyncMock(return_value=records) + mock_team_table.find_many = AsyncMock( + return_value=[ + _make_team_record("team-x", ["ag-1"]), + _make_team_record("team-y", ["ag-2"]), + _make_team_record("team-z", ["ag-1", "ag-2"]), + ] + ) + + resp = client.get(base_path) + assert resp.status_code == 200 + body = resp.json() + assert body[0]["assigned_team_ids"] == ["team-x", "team-z"] + assert body[1]["assigned_team_ids"] == ["team-y", "team-z"] + + mock_team_table.find_many.assert_awaited_once() + carrying, listed = mock_team_table.find_many.call_args.kwargs["where"]["OR"] + assert list(carrying["access_group_ids"]["hasSome"]) == ["ag-1", "ag-2"] + assert list(listed["team_id"]["in"]) == [] @pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) @@ -373,6 +409,43 @@ def test_get_access_group_success(client_and_mocks, base_path, access_group_id): assert resp.json()["access_group_id"] == access_group_id +@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) +def test_get_access_group_derives_assigned_teams_from_team_table(client_and_mocks, base_path): + """Get drops ghost ids from the stored column and adds teams that carry the group but were never mirrored.""" + client, mock_prisma, mock_table, *_ = client_and_mocks + mock_team_table = mock_prisma.db.litellm_teamtable + + record = _make_access_group_record(access_group_id="ag-123", assigned_team_ids=["team-a", "ghost-team"]) + mock_table.find_unique = AsyncMock(return_value=record) + mock_team_table.find_many = AsyncMock( + return_value=[ + _make_team_record("team-a", ["ag-123"]), + _make_team_record("team-b", ["ag-123"]), + _make_team_record("team-c", ["ag-123"]), + ] + ) + + resp = client.get(f"{base_path}/ag-123") + assert resp.status_code == 200 + assert resp.json()["assigned_team_ids"] == ["team-a", "team-b", "team-c"] + + carrying, listed = mock_team_table.find_many.call_args.kwargs["where"]["OR"] + assert list(carrying["access_group_ids"]["hasSome"]) == ["ag-123"] + assert list(listed["team_id"]["in"]) == ["team-a", "ghost-team"] + + +def test_get_access_group_empty_column_and_no_teams_returns_empty(client_and_mocks): + """Get returns [] when the column is empty and no team carries the group.""" + client, mock_prisma, mock_table, *_ = client_and_mocks + + mock_table.find_unique = AsyncMock(return_value=_make_access_group_record(access_group_id="ag-123")) + mock_prisma.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + + resp = client.get("/v1/access_group/ag-123") + assert resp.status_code == 200 + assert resp.json()["assigned_team_ids"] == [] + + def test_get_access_group_not_found(client_and_mocks): """Get access group returns 404 when not found.""" client, _, mock_table, *_ = client_and_mocks @@ -985,6 +1058,28 @@ def test_record_to_access_group_table(): assert result.access_agent_ids == ["agent-1"] +def test_attached_team_ids_by_group_keeps_column_order_then_appends_unmirrored_teams(): + """Stored ids that resolve keep their order, ghosts drop, carriers the mirror missed append once, per group.""" + from litellm.proxy.management_endpoints.access_group_endpoints import ( + _attached_team_ids_by_group, + ) + + records = [ + _make_access_group_record(access_group_id="ag-1", assigned_team_ids=["team-b", "ghost", "team-a"]), + _make_access_group_record(access_group_id="ag-2", assigned_team_ids=[]), + ] + teams = [ + _make_team_record("team-a", ["ag-1"]), + _make_team_record("team-b", []), + _make_team_record("team-c", ["ag-1"]), + _make_team_record("team-d", ["ag-2"]), + ] + + result = _attached_team_ids_by_group(records, teams) + + assert dict(result) == {"ag-1": ("team-b", "team-a", "team-c"), "ag-2": ("team-d",)} + + # --------------------------------------------------------------------------- # Sync tests: CREATE # --------------------------------------------------------------------------- @@ -997,9 +1092,8 @@ def test_create_access_group_syncs_assigned_teams(client_and_mocks): ) mock_team_table = mock_prisma.db.litellm_teamtable - team_record = MagicMock() - team_record.team_id = "team-1" - team_record.access_group_ids = [] + team_record = _make_team_record("team-1") + mock_team_table.find_many = AsyncMock(return_value=[team_record]) mock_team_table.find_unique = AsyncMock(return_value=team_record) resp = client.post( @@ -1043,20 +1137,22 @@ def test_create_access_group_syncs_assigned_keys(client_and_mocks): assert "ag-new" in call_kwargs["data"]["access_group_ids"] -def test_create_access_group_skips_sync_for_nonexistent_team(client_and_mocks): - """Create skips updating a team that doesn't exist in DB.""" - client, mock_prisma, _, mock_cache, mock_proxy_logging = client_and_mocks +def test_create_access_group_rejects_nonexistent_team(client_and_mocks): + """Create refuses to store a team id that does not resolve to a team row.""" + client, mock_prisma, mock_access_group_table, *_ = client_and_mocks mock_team_table = mock_prisma.db.litellm_teamtable - mock_team_table.find_unique = AsyncMock(return_value=None) + mock_team_table.find_many = AsyncMock(return_value=[_make_team_record("team-real")]) resp = client.post( "/v1/access_group", json={ "access_group_name": "new-group", - "assigned_team_ids": ["nonexistent-team"], + "assigned_team_ids": ["team-real", "nonexistent-team", "also-missing"], }, ) - assert resp.status_code == 201 + assert resp.status_code == 400 + assert resp.json()["detail"] == "Unknown team ids: also-missing, nonexistent-team" + mock_access_group_table.create.assert_not_awaited() mock_team_table.update.assert_not_awaited() @@ -1065,9 +1161,8 @@ def test_create_access_group_idempotent_team_sync(client_and_mocks): client, mock_prisma, _, mock_cache, mock_proxy_logging = client_and_mocks mock_team_table = mock_prisma.db.litellm_teamtable - team_record = MagicMock() - team_record.team_id = "team-1" - team_record.access_group_ids = ["ag-new"] # already synced + team_record = _make_team_record("team-1", ["ag-new"]) + mock_team_table.find_many = AsyncMock(return_value=[team_record]) mock_team_table.find_unique = AsyncMock(return_value=team_record) resp = client.post( @@ -1095,9 +1190,8 @@ def test_update_access_group_syncs_added_teams(client_and_mocks): ) mock_access_group_table.find_unique = AsyncMock(return_value=existing) - team_record = MagicMock() - team_record.team_id = "team-new" - team_record.access_group_ids = [] + team_record = _make_team_record("team-new") + mock_team_table.find_many = AsyncMock(return_value=[_make_team_record("team-existing", ["ag-update"]), team_record]) mock_team_table.find_unique = AsyncMock(return_value=team_record) resp = client.put( @@ -1113,6 +1207,25 @@ def test_update_access_group_syncs_added_teams(client_and_mocks): assert "ag-update" in call_kwargs["data"]["access_group_ids"] +def test_update_access_group_rejects_nonexistent_team(client_and_mocks): + """Update refuses to store a team id that does not resolve to a team row and leaves the group untouched.""" + client, mock_prisma, mock_access_group_table, *_ = client_and_mocks + mock_team_table = mock_prisma.db.litellm_teamtable + + existing = _make_access_group_record(access_group_id="ag-update", assigned_team_ids=["team-existing"]) + mock_access_group_table.find_unique = AsyncMock(return_value=existing) + mock_team_table.find_many = AsyncMock(return_value=[_make_team_record("team-existing", ["ag-update"])]) + + resp = client.put( + "/v1/access_group/ag-update", + json={"assigned_team_ids": ["team-existing", "team-ghost"]}, + ) + assert resp.status_code == 400 + assert resp.json()["detail"] == "Unknown team ids: team-ghost" + mock_access_group_table.update.assert_not_awaited() + mock_team_table.update.assert_not_awaited() + + def test_update_access_group_syncs_removed_teams(client_and_mocks): """Update removes access_group_id from de-assigned teams.""" client, mock_prisma, mock_access_group_table, mock_cache, mock_proxy_logging = ( @@ -1125,9 +1238,8 @@ def test_update_access_group_syncs_removed_teams(client_and_mocks): ) mock_access_group_table.find_unique = AsyncMock(return_value=existing) - team_to_remove = MagicMock() - team_to_remove.team_id = "team-remove" - team_to_remove.access_group_ids = ["ag-update"] + team_to_remove = _make_team_record("team-remove", ["ag-update"]) + mock_team_table.find_many = AsyncMock(return_value=[_make_team_record("team-keep", ["ag-update"]), team_to_remove]) mock_team_table.find_unique = AsyncMock(return_value=team_to_remove) resp = client.put( @@ -1145,6 +1257,28 @@ def test_update_access_group_syncs_removed_teams(client_and_mocks): assert "ag-update" not in call_kwargs["data"]["access_group_ids"] +def test_update_access_group_detaches_team_the_mirror_missed(client_and_mocks): + """Update removes the group from a team that carries it but was never written to the stored column.""" + client, mock_prisma, mock_access_group_table, *_ = client_and_mocks + mock_team_table = mock_prisma.db.litellm_teamtable + + existing = _make_access_group_record(access_group_id="ag-update", assigned_team_ids=["team-keep"]) + mock_access_group_table.find_unique = AsyncMock(return_value=existing) + + unmirrored = _make_team_record("team-unmirrored", ["ag-update", "ag-other"]) + mock_team_table.find_many = AsyncMock(return_value=[_make_team_record("team-keep", ["ag-update"]), unmirrored]) + mock_team_table.find_unique = AsyncMock(return_value=unmirrored) + + resp = client.put("/v1/access_group/ag-update", json={"assigned_team_ids": ["team-keep"]}) + assert resp.status_code == 200 + + mock_team_table.find_unique.assert_awaited_once_with(where={"team_id": "team-unmirrored"}) + mock_team_table.update.assert_awaited_once() + call_kwargs = mock_team_table.update.call_args.kwargs + assert call_kwargs["where"] == {"team_id": "team-unmirrored"} + assert call_kwargs["data"]["access_group_ids"] == ["ag-other"] + + def test_update_access_group_no_team_sync_when_ids_not_in_payload(client_and_mocks): """Update does not sync teams when assigned_team_ids is absent from the payload.""" client, mock_prisma, mock_access_group_table, mock_cache, mock_proxy_logging = ( diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 022aeff4e20..0d3ea5863a2 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2014,6 +2014,67 @@ async def test_get_users_user_id_partial_match(mocker): assert captured_where_conditions["user_id"]["in"] == ["user1", "user2", "user3"] +def test_get_users_search_matches_user_id_or_email(mocker): + """ + `search` ORs a case-insensitive contains match over user_id and user_email on both the rows + query and the count, while the legacy `user_email` param keeps filtering only user_email. + """ + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + searched_user_id = "a6f5c02b-0163-45ce-815f-f88d10e95686" + mock_user_row = mocker.MagicMock() + mock_user_row.user_id = searched_user_id + mock_user_row.model_dump.return_value = { + "user_id": searched_user_id, + "user_email": "search@example.com", + "user_role": "internal_user", + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + find_many_wheres = [] + count_wheres = [] + + async def mock_find_many(*args, **kwargs): + find_many_wheres.append(kwargs["where"]) + return [mock_user_row] + + async def mock_count(*args, **kwargs): + count_wheres.append(kwargs["where"]) + return 1 + + async def mock_key_count(*args, **kwargs): + return 0 + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + mock_prisma_client.db.litellm_usertable.count = mock_count + mock_prisma_client.db.litellm_verificationtoken.count = mock_key_count + mocker.patch( # test-quality-ok: /user/list reads prisma_client off proxy_server at call time + "litellm.proxy.proxy_server.prisma_client", mock_prisma_client + ) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + search_response = client.get("/user/list", params={"search": "A6F5C02B-0163"}) + assert search_response.status_code == 200, search_response.text + expected_or = ( + {"user_id": {"contains": "A6F5C02B-0163", "mode": "insensitive"}}, + {"user_email": {"contains": "A6F5C02B-0163", "mode": "insensitive"}}, + ) + assert find_many_wheres == [{"OR": expected_or}] + assert count_wheres == [{"OR": expected_or}] + assert [user["user_id"] for user in search_response.json()["users"]] == [searched_user_id] + assert search_response.json()["total"] == 1 + + legacy_response = client.get("/user/list", params={"user_email": "search@example.com"}) + assert legacy_response.status_code == 200, legacy_response.text + assert find_many_wheres[-1] == {"user_email": {"contains": "search@example.com", "mode": "insensitive"}} + finally: + app.dependency_overrides.pop(user_api_key_auth, None) + + def test_update_internal_user_params_reset_max_budget_with_none(): """ Test that _update_internal_user_params allows setting max_budget to 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 7954a4693cc..0e4af9f75a5 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 @@ -6347,6 +6347,93 @@ def test_build_key_filter_conditions_key_hash_narrows_team_admin_visibility(): assert {"token": "hashed-token-123"} in where["AND"], f"key_hash not ANDed: {where}" +def _search_clause(search: str, token: str) -> dict: + return {"OR": [{"token": token}, {"key_alias": {"contains": search, "mode": "insensitive"}}]} + + +def test_build_key_filter_conditions_search_ors_token_and_alias_contains(): + """ + LIT-4741: `search` matches a key by its alias (case-insensitive contains) OR by + its ID (the token column), with the pasted value used verbatim. + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _build_key_filter_conditions, + ) + + hashed_where = json.loads( + json.dumps( + _build_key_filter_conditions( + user_id=None, + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id=None, + admin_team_ids=None, + search="already-hashed-token", + ) + ) + ) + assert _search_clause("already-hashed-token", "already-hashed-token") in hashed_where["AND"], ( + f"hashed search not used verbatim: {hashed_where}" + ) + + +def test_build_key_filter_conditions_search_narrows_team_admin_visibility(): + """ + LIT-4741, same class as LIT-3243: `search` must be a top-level AND so it + narrows a team admin's admin-team branch instead of being bypassed by it. + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _build_key_filter_conditions, + ) + + where = json.loads( + json.dumps( + _build_key_filter_conditions( + user_id="team-admin-user", + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id=None, + admin_team_ids=["team-a"], + member_team_ids=["team-a"], + include_created_by_keys=False, + search="member-key-id", + ) + ) + ) + + assert where.get("AND"), f"expected top-level AND, got: {where}" + assert _search_clause("member-key-id", "member-key-id") in where["AND"], f"search not ANDed: {where}" + assert json.dumps({"team_id": {"in": ["team-a"]}}) in json.dumps(where) + + +@pytest.mark.asyncio +async def test_list_key_helper_applies_search_to_prisma_where(): + """LIT-4741: `search` given to _list_key_helper must reach the Prisma where clause.""" + mock_prisma_client = AsyncMock() + mock_find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_verificationtoken.find_many = mock_find_many + mock_prisma_client.db.litellm_verificationtoken.count = AsyncMock(return_value=0) + + await _list_key_helper( + prisma_client=mock_prisma_client, + page=1, + size=50, + user_id=None, + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + search="key-id-123", + ) + + where = json.loads(json.dumps(mock_find_many.call_args.kwargs["where"])) + assert _search_clause("key-id-123", "key-id-123") in where["AND"], f"search not in Prisma where: {where}" + + @pytest.mark.asyncio async def test_generate_key_negative_max_budget(): """ @@ -14870,6 +14957,16 @@ async def test_list_keys_non_admin_cannot_opt_into_substring(): assert kwargs["user_id"] == "alice" +@pytest.mark.asyncio +async def test_list_keys_search_is_honored_for_non_admin(): + """LIT-4741: unlike substring_matching, `search` is not admin-gated. A non-admin's + search reaches the helper while their own-user scoping stays in place.""" + user = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice") + kwargs = await _list_keys_capture_helper_kwargs(user, user_id=None, search="key-id-123") + assert kwargs["search"] == "key-id-123" + assert kwargs["user_id"] == "alice" + + @pytest.mark.asyncio async def test_cli_session_token_delegation_ceiling_blocked_by_team_budget(): team = LiteLLM_TeamTableCachedObj(team_id="team-1", max_budget=50.0) 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 166fcb2863c..019ebc9807c 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -2910,6 +2910,7 @@ async def test_update_team_with_team_member_budget_duration( "metadata": {"team_member_budget_id": "budget_123"}, } mock_existing_team.metadata = {"team_member_budget_id": "budget_123"} + mock_existing_team.members_with_roles = [] mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock( return_value=mock_existing_team ) @@ -11290,6 +11291,78 @@ async def test_patch_preserves_required_metadata_key_that_post_would_wipe(): assert patch_meta == {"cost_center": "FINOPS-1", "team_notes": "edited"} # preserved by PATCH +_STORED_METADATA_WITH_BUDGET: Final = { + "team_member_budget_id": "budget-existing-123", + "team_member_key_duration": "30d", + "logging": [{"callback_name": "langfuse", "callback_type": "success"}], + "cost_center": "cc-1234", +} + + +async def _written_metadata_with_budget(kind, body): + """Like ``_written_metadata`` but the team already owns a member budget row.""" + from litellm.proxy._types import LiteLLM_BudgetTable + + with ( + patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: proxy_server module global is the endpoint's only injection point + patch( # test-quality-ok: update_team imports update_budget at call time; the module attribute is its only seam + "litellm.proxy.management_endpoints.budget_management_endpoints.update_budget", + AsyncMock(return_value=LiteLLM_BudgetTable(budget_id="budget-existing-123")), + ), + ): + return await _written_metadata(kind, dict(_STORED_METADATA_WITH_BUDGET), body) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["post", "patch"]) +@pytest.mark.parametrize( + "body", + [ + {"team_member_budget": 50.0}, + {"team_member_budget_duration": "1d"}, + {"team_member_tpm_limit": 500}, + {"team_member_rpm_limit": 5}, + ], + ids=lambda body: next(iter(body)), +) +async def test_team_member_budget_only_update_preserves_stored_metadata(kind, body): + """LIT-5150: a budget-only update that omits ``metadata`` must not replace the + stored metadata JSON with just ``{"team_member_budget_id": ...}``.""" + assert await _written_metadata_with_budget(kind, body) == _STORED_METADATA_WITH_BUDGET + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["post", "patch"]) +async def test_team_member_key_duration_only_update_preserves_stored_metadata(kind): + """LIT-5150: a metadata-backed field sent alone is merged into the stored + metadata instead of becoming the whole metadata JSON.""" + written = await _written_metadata_with_budget(kind, {"team_member_key_duration": "7d"}) + + assert written == {**_STORED_METADATA_WITH_BUDGET, "team_member_key_duration": "7d"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["post", "patch"]) +async def test_explicit_null_metadata_with_budget_field_still_clears_metadata(kind): + """``metadata: null`` is an explicit clear, so only the server-owned budget link survives.""" + written = await _written_metadata_with_budget(kind, {"metadata": None, "team_member_budget": 7.0}) + + assert written == {"team_member_budget_id": "budget-existing-123"} + + +@pytest.mark.asyncio +async def test_metadata_only_update_keeps_team_member_budget_link(): + """LIT-5150: rewriting metadata without any team member field must not drop the + server-owned ``team_member_budget_id``, or the member budget silently resets.""" + body = {"metadata": {"cost_center": "cc-9999"}} + + post_meta = await _written_metadata_with_budget("post", body) + patch_meta = await _written_metadata_with_budget("patch", body) + + assert post_meta == {"cost_center": "cc-9999", "team_member_budget_id": "budget-existing-123"} + assert patch_meta == {**_STORED_METADATA_WITH_BUDGET, "cost_center": "cc-9999"} + + @pytest.mark.asyncio @pytest.mark.parametrize( "body, field, expected", @@ -11318,17 +11391,15 @@ async def test_top_level_fields_identical_post_and_patch(body, field, expected): @pytest.mark.asyncio async def test_patch_strips_system_managed_metadata_key_like_post(): """A caller cannot inject/overwrite server-owned keys via PATCH any more than - via POST: team_member_budget_id is stripped from the write in both.""" + via POST: the stored team_member_budget_id wins over the caller's value in both.""" existing = {"team_member_budget_id": "budget-123", "cost_center": "1234"} body = {"metadata": {"team_member_budget_id": "HACKED", "cost_center": "9999"}} post_meta = await _written_metadata("post", existing, body) patch_meta = await _written_metadata("patch", existing, body) - assert "team_member_budget_id" not in post_meta - assert "team_member_budget_id" not in patch_meta - assert post_meta == {"cost_center": "9999"} - assert patch_meta == {"cost_center": "9999"} + assert post_meta == {"cost_center": "9999", "team_member_budget_id": "budget-123"} + assert patch_meta == {"cost_center": "9999", "team_member_budget_id": "budget-123"} @pytest.mark.parametrize( diff --git a/tests/test_litellm/proxy/management_endpoints/usage_endpoints/test_ai_usage_chat.py b/tests/test_litellm/proxy/management_endpoints/usage_endpoints/test_ai_usage_chat.py index 3a32b3cc128..e5616a1975d 100644 --- a/tests/test_litellm/proxy/management_endpoints/usage_endpoints/test_ai_usage_chat.py +++ b/tests/test_litellm/proxy/management_endpoints/usage_endpoints/test_ai_usage_chat.py @@ -466,3 +466,61 @@ class TestUsageAiChatServiceAccountGuard: is_admin=False, ) assert "Endpoint-level guard missing" in str(exc_info.value) + + +class TestUsageAiChatKeepalive: + async def _collect_endpoint_body(self, monkeypatch, interval, delay=0.3) -> tuple[list[bytes], dict]: + import asyncio + + import litellm + from fastapi.responses import StreamingResponse + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.management_endpoints.usage_endpoints.endpoints import ( + ChatMessage, + UsageAIChatRequest, + usage_ai_chat, + ) + + monkeypatch.setattr(litellm, "sse_keepalive_ping_interval_seconds", interval) + + async def slow_acompletion(**kwargs): + await asyncio.sleep(delay) + response = MagicMock() + response.choices = [MagicMock()] + response.choices[0].message.tool_calls = None + response.choices[0].message.content = "Total spend is $50.25" + return response + + with patch( # test-quality-ok: the stream calls the module-level litellm.acompletion directly; no injection seam + "litellm.proxy.management_endpoints.usage_endpoints.ai_usage_chat.litellm.acompletion", + new=AsyncMock(side_effect=slow_acompletion), + ): + response = await usage_ai_chat( + data=UsageAIChatRequest(messages=[ChatMessage(role="user", content="hi")], model="gpt-4o-mini"), + request=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN), + ) + assert isinstance(response, StreamingResponse) + chunks = [chunk if isinstance(chunk, bytes) else chunk.encode() async for chunk in response.body_iterator] + return chunks, dict(response.headers) + + @pytest.mark.asyncio + async def test_endpoint_pings_while_the_planning_completion_is_still_running(self, monkeypatch): + chunks, headers = await self._collect_endpoint_body(monkeypatch, interval=0.05) + + assert headers["content-type"].startswith("text/event-stream") + assert headers["cache-control"] == "no-cache" + assert headers["x-accel-buffering"] == "no" + assert chunks[0].startswith(b'data: {"type": "status"') + assert chunks[1] == b": ping\n\n" + assert chunks.count(b": ping\n\n") >= 3 + assert b'"content": "Total spend is $50.25"' in b"".join(chunks) + assert chunks[-1] == b'data: {"type": "done"}\n\n' + + @pytest.mark.asyncio + async def test_endpoint_stream_is_untouched_while_keepalives_are_unconfigured(self, monkeypatch): + chunks, _ = await self._collect_endpoint_body(monkeypatch, interval=None, delay=0.15) + + assert b": ping\n\n" not in chunks + assert chunks[0].startswith(b'data: {"type": "status"') + assert chunks[-1] == b'data: {"type": "done"}\n\n' diff --git a/tests/test_litellm/proxy/memory/test_memory_endpoints.py b/tests/test_litellm/proxy/memory/test_memory_endpoints.py index be75d980d9d..dff0e80fa77 100644 --- a/tests/test_litellm/proxy/memory/test_memory_endpoints.py +++ b/tests/test_litellm/proxy/memory/test_memory_endpoints.py @@ -615,6 +615,96 @@ class TestMemoryEndpoints: assert keys == {"user:profile"} assert body["total"] == 1 + def test_list_memory_search_matches_key_prefix_or_memory_id_within_scope(self): + """ + `search` matches a key prefix OR an exact memory_id, and stays ANDed + with the visibility filter so a pasted foreign id cannot leak a row. + """ + table = self.prisma.db.litellm_memorytable + table.rows.extend( + [ + _make_row(memory_id="mem-own", key="user:profile", user_id="user-a", team_id=None), + _make_row(memory_id="mem-target", key="project:context", user_id="user-a", team_id=None), + _make_row(memory_id="mem-foreign", key="user:secret", user_id="user-b", team_id=None), + ] + ) + client = _make_client(_user_auth("user-a", "team-a")) + with _patch_prisma(self.prisma): + by_id = client.get("/v1/memory?search=mem-target") + by_prefix = client.get("/v1/memory?search=user:") + foreign_id = client.get("/v1/memory?search=mem-foreign") + + assert by_id.status_code == 200, by_id.text + assert [m["memory_id"] for m in by_id.json()["memories"]] == ["mem-target"] + assert by_id.json()["total"] == 1 + + assert by_prefix.status_code == 200, by_prefix.text + assert {m["key"] for m in by_prefix.json()["memories"]} == {"user:profile"} + assert by_prefix.json()["total"] == 1 + + assert foreign_id.status_code == 200, foreign_id.text + assert foreign_id.json()["memories"] == [] + assert foreign_id.json()["total"] == 0 + + def test_list_memory_search_by_memory_id_for_admin_sees_any_scope(self): + """Admins have no visibility filter, so an id search returns the row whoever owns it.""" + table = self.prisma.db.litellm_memorytable + table.rows.extend( + [ + _make_row(memory_id="mem-a", key="a", user_id="user-a", team_id=None), + _make_row(memory_id="mem-b", key="b", user_id="user-b", team_id=None), + ] + ) + client = _make_client(_admin_auth()) + with _patch_prisma(self.prisma): + resp = client.get("/v1/memory?search=mem-b") + assert resp.status_code == 200, resp.text + assert [m["memory_id"] for m in resp.json()["memories"]] == ["mem-b"] + assert resp.json()["total"] == 1 + + def test_list_memory_search_wins_over_key_prefix(self): + """When both are sent, `search` decides the match and `key_prefix` is ignored.""" + table = self.prisma.db.litellm_memorytable + table.rows.extend( + [ + _make_row(memory_id="mem-own", key="user:profile", user_id="user-a", team_id=None), + _make_row(memory_id="mem-target", key="project:context", user_id="user-a", team_id=None), + ] + ) + client = _make_client(_user_auth("user-a", "team-a")) + with _patch_prisma(self.prisma): + resp = client.get("/v1/memory?search=mem-target&key_prefix=user:") + assert resp.status_code == 200, resp.text + assert [m["memory_id"] for m in resp.json()["memories"]] == ["mem-target"] + assert resp.json()["total"] == 1 + + def test_list_memory_key_prefix_never_matches_memory_id(self): + """`key_prefix` stays a pure key-prefix match; only `search` consults memory_id.""" + table = self.prisma.db.litellm_memorytable + table.rows.append(_make_row(memory_id="mem-target", key="project:context", user_id="user-a", team_id=None)) + client = _make_client(_user_auth("user-a", "team-a")) + with _patch_prisma(self.prisma): + resp = client.get("/v1/memory?key_prefix=mem-target") + assert resp.status_code == 200, resp.text + assert resp.json()["memories"] == [] + assert resp.json()["total"] == 0 + + def test_list_memory_key_exact_filter(self): + """`key` is an exact match, never a prefix.""" + table = self.prisma.db.litellm_memorytable + table.rows.extend( + [ + _make_row(memory_id="m1", key="user:profile", user_id="user-a", team_id=None), + _make_row(memory_id="m2", key="user:profile:archived", user_id="user-a", team_id=None), + ] + ) + client = _make_client(_user_auth("user-a", "team-a")) + with _patch_prisma(self.prisma): + resp = client.get("/v1/memory?key=user:profile") + assert resp.status_code == 200, resp.text + assert [m["memory_id"] for m in resp.json()["memories"]] == ["m1"] + assert resp.json()["total"] == 1 + def test_list_memory_admin_sees_all(self): table = self.prisma.db.litellm_memorytable table.rows.extend( diff --git a/tests/test_litellm/proxy/middleware/test_admission_control_middleware.py b/tests/test_litellm/proxy/middleware/test_admission_control_middleware.py new file mode 100644 index 00000000000..f1ca13daa03 --- /dev/null +++ b/tests/test_litellm/proxy/middleware/test_admission_control_middleware.py @@ -0,0 +1,402 @@ +import asyncio +import json +from typing import Final + +import pytest +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from litellm.proxy.middleware.admission_control_middleware import ( + AdmissionControlMetrics, + AdmissionControlMiddleware, + AdmissionControlSettings, + AdmissionControlState, + AdmissionControlStats, + _parse_admission_control_settings, + create_prometheus_admission_metrics, + get_admission_control_settings, +) + + +@pytest.fixture +def state() -> AdmissionControlState: + return AdmissionControlState(lambda: None) + + +async def _call( + middleware: AdmissionControlMiddleware, + path: str = "/", + root_path: str = "", +) -> tuple[Message, ...]: + messages: Final[list[Message]] = [] + + async def receive() -> Message: + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: Message) -> None: + messages.append(message) + + scope: Final[Scope] = { + "type": "http", + "path": path, + "root_path": root_path, + "method": "GET", + "headers": [], + } + await middleware(scope, receive, send) + return tuple(messages) + + +def _handler_with_release( + started: asyncio.Event, + release: asyncio.Event, +) -> ASGIApp: + async def handler(scope: Scope, receive: Receive, send: Send) -> None: + started.set() + await release.wait() + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok", "more_body": False}) + + return handler + + +def test_is_not_base_http_middleware() -> None: + assert not issubclass(AdmissionControlMiddleware, BaseHTTPMiddleware) + + +@pytest.mark.asyncio +async def test_capacity_rejects_excess_and_releases_queued_request(state: AdmissionControlState) -> None: + started: Final = asyncio.Event() + release: Final = asyncio.Event() + middleware: Final = AdmissionControlMiddleware( + _handler_with_release(started, release), + lambda: AdmissionControlSettings(1, 1, 1.0), + state, + ) + + first: Final = asyncio.create_task(_call(middleware)) + await started.wait() + second: Final = asyncio.create_task(_call(middleware)) + await asyncio.sleep(0) + assert state.get_stats().queued == 1 + + third: Final = await _call(middleware) + assert third[0]["status"] == 503 + headers: Final = dict(third[0]["headers"]) + assert headers[b"retry-after"] == b"1" + assert headers[b"content-type"] == b"application/json" + assert json.loads(third[1]["body"])["error"] == { + "message": "Worker at capacity: 1 in-flight, 1 queued requests. Retry later.", + "type": "overloaded_error", + "code": "503", + } + assert state.get_stats().rejected_total == 1 + + release.set() + assert (await first)[0]["status"] == 200 + assert (await second)[0]["status"] == 200 + assert state.get_stats() == AdmissionControlStats(0, 0, 1) + + +@pytest.mark.asyncio +async def test_pending_waiter_is_not_skipped_after_admission_is_released(state: AdmissionControlState) -> None: + started: Final = asyncio.Event() + release: Final = asyncio.Event() + third_trigger: Final = asyncio.Event() + middleware: Final = AdmissionControlMiddleware( + _handler_with_release(started, release), + lambda: AdmissionControlSettings(1, 2, 1.0), + state, + ) + + first: Final = asyncio.create_task(_call(middleware)) + await started.wait() + second: Final = asyncio.create_task(_call(middleware)) + await asyncio.sleep(0) + + async def call_third() -> tuple[Message, ...]: + await third_trigger.wait() + return await _call(middleware) + + third: Final = asyncio.create_task(call_third()) + await asyncio.sleep(0) + release.set() + third_trigger.set() + await asyncio.sleep(0) + + assert state.get_stats().queued == 2 + await asyncio.gather(first, second, third) + + +@pytest.mark.asyncio +async def test_queue_timeout_rejects_and_decrements_queue(state: AdmissionControlState) -> None: + started: Final = asyncio.Event() + release: Final = asyncio.Event() + middleware: Final = AdmissionControlMiddleware( + _handler_with_release(started, release), + lambda: AdmissionControlSettings(1, 1, 0.05), + state, + ) + + first: Final = asyncio.create_task(_call(middleware)) + await started.wait() + start_time: Final = asyncio.get_running_loop().time() + second: Final = await _call(middleware) + elapsed: Final = asyncio.get_running_loop().time() - start_time + + assert second[0]["status"] == 503 + assert elapsed < 0.5 + assert state.get_stats().queued == 0 + assert state.get_stats().rejected_total == 1 + release.set() + await first + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("root_path", "probe_path"), + ( + ("", "/health/liveliness"), + ("/proxy", "/proxy/health/liveliness"), + ("/proxy", "/proxy/metrics"), + ), +) +async def test_exempt_path_passes_through_when_saturated( + state: AdmissionControlState, + root_path: str, + probe_path: str, +) -> None: + started: Final = asyncio.Event() + release: Final = asyncio.Event() + + async def handler(scope: Scope, receive: Receive, send: Send) -> None: + if scope["path"] == "/": + started.set() + await release.wait() + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok", "more_body": False}) + + middleware: Final = AdmissionControlMiddleware(handler, lambda: AdmissionControlSettings(1, 0, 1.0), state) + + first: Final = asyncio.create_task(_call(middleware)) + await started.wait() + health: Final = await _call(middleware, probe_path, root_path) + assert health[0]["status"] == 200 + blocked: Final = await _call(middleware, "/proxy/v1/chat/completions", root_path) + assert blocked[0]["status"] == 503 + lookalike: Final = await _call(middleware, "/proxyhealth/liveliness", "/proxy") + assert lookalike[0]["status"] == 503 + release.set() + await first + + +@pytest.mark.asyncio +async def test_non_http_scope_passes_through_when_saturated(state: AdmissionControlState) -> None: + seen: Final[list[str]] = [] + + async def handler(scope: Scope, receive: Receive, send: Send) -> None: + seen.append(scope["type"]) + + middleware: Final = AdmissionControlMiddleware(handler, lambda: AdmissionControlSettings(1, 0, 1.0), state) + state.record_admission() + + async def receive() -> Message: + return {"type": "lifespan.startup"} + + async def send(message: Message) -> None: + return None + + await middleware({"type": "lifespan"}, receive, send) + assert seen == ["lifespan"] + + +@pytest.mark.asyncio +async def test_none_settings_does_not_limit_concurrency() -> None: + active: Final = [0] + peak: Final = [0] + all_started: Final = asyncio.Event() + release: Final = asyncio.Event() + + async def handler(scope: Scope, receive: Receive, send: Send) -> None: + active[0] += 1 + peak[0] = max(peak[0], active[0]) + if active[0] == 3: + all_started.set() + await release.wait() + active[0] -= 1 + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok", "more_body": False}) + + middleware: Final = AdmissionControlMiddleware(handler, lambda: None, AdmissionControlState(lambda: None)) + requests: Final = tuple(asyncio.create_task(_call(middleware)) for _ in range(3)) + await all_started.wait() + assert peak[0] == 3 + release.set() + results: Final = await asyncio.gather(*requests) + assert tuple(result[0]["status"] for result in results) == (200, 200, 200) + + +@pytest.mark.asyncio +async def test_cancelling_queued_request_does_not_leak_counter(state: AdmissionControlState) -> None: + started: Final = asyncio.Event() + release: Final = asyncio.Event() + middleware: Final = AdmissionControlMiddleware( + _handler_with_release(started, release), + lambda: AdmissionControlSettings(1, 1, 1.0), + state, + ) + + first: Final = asyncio.create_task(_call(middleware)) + await started.wait() + queued: Final = asyncio.create_task(_call(middleware)) + await asyncio.sleep(0) + queued.cancel() + with pytest.raises(asyncio.CancelledError): + await queued + assert state.get_stats().queued == 0 + release.set() + await first + + +@pytest.mark.asyncio +async def test_streaming_response_holds_admission_until_final_body(state: AdmissionControlState) -> None: + first_chunk_sent: Final = asyncio.Event() + finish_stream: Final = asyncio.Event() + + async def handler(scope: Scope, receive: Receive, send: Send) -> None: + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"first", "more_body": True}) + first_chunk_sent.set() + await finish_stream.wait() + await send({"type": "http.response.body", "body": b"last", "more_body": False}) + + middleware: Final = AdmissionControlMiddleware( + handler, + lambda: AdmissionControlSettings(1, 1, 1.0), + state, + ) + first: Final = asyncio.create_task(_call(middleware)) + await first_chunk_sent.wait() + second: Final = asyncio.create_task(_call(middleware)) + await asyncio.sleep(0) + assert not second.done() + assert state.get_stats().queued == 1 + finish_stream.set() + assert (await first)[0]["status"] == 200 + assert (await second)[0]["status"] == 200 + assert state.get_stats().admitted == 0 + assert state.get_stats().queued == 0 + + +class _FakeGauge: + def __init__(self) -> None: + self.value = 0.0 + + def inc(self, amount: float = 1) -> None: + self.value += amount + + def dec(self, amount: float = 1) -> None: + self.value -= amount + + +class _FakeCounter: + def __init__(self) -> None: + self.by_reason: Final[dict[str, _FakeGauge]] = {} + + def labels(self, reason: str) -> _FakeGauge: + return self.by_reason.setdefault(reason, _FakeGauge()) + + +@pytest.mark.asyncio +async def test_metrics_track_admitted_queued_and_rejected() -> None: + admitted: Final = _FakeGauge() + queued: Final = _FakeGauge() + rejected: Final = _FakeCounter() + state: Final = AdmissionControlState( + lambda: AdmissionControlMetrics(admitted_gauge=admitted, queued_gauge=queued, rejected_counter=rejected) + ) + started: Final = asyncio.Event() + release: Final = asyncio.Event() + middleware: Final = AdmissionControlMiddleware( + _handler_with_release(started, release), + lambda: AdmissionControlSettings(1, 1, 0.05), + state, + ) + + first: Final = asyncio.create_task(_call(middleware)) + await started.wait() + second: Final = asyncio.create_task(_call(middleware)) + await asyncio.sleep(0) + assert (admitted.value, queued.value) == (1.0, 1.0) + await _call(middleware) + assert rejected.by_reason["queue_full"].value == 1.0 + await second + assert rejected.by_reason["queue_timeout"].value == 1.0 + release.set() + await first + assert (admitted.value, queued.value) == (0.0, 0.0) + + +def test_create_prometheus_admission_metrics_registers_named_metrics() -> None: + from prometheus_client import REGISTRY + + metrics: Final = create_prometheus_admission_metrics() + if metrics is not None: + metrics.admitted_gauge.inc() + metrics.queued_gauge.inc() + metrics.rejected_counter.labels(reason="queue_full").inc() + assert REGISTRY.get_sample_value("litellm_admission_admitted_requests") == 1.0 + assert REGISTRY.get_sample_value("litellm_admission_queued_requests") == 1.0 + assert REGISTRY.get_sample_value("litellm_admission_rejected_requests_total", {"reason": "queue_full"}) is not None + assert create_prometheus_admission_metrics() is None + + +@pytest.mark.parametrize( + ("settings", "expected"), + ( + ({}, None), + ({"max_in_flight_requests_per_worker": None}, None), + ({"max_in_flight_requests_per_worker": 0}, None), + ({"max_in_flight_requests_per_worker": "many"}, None), + ({"max_in_flight_requests_per_worker": 3, "max_queued_requests_per_worker": -1}, None), + ({"max_in_flight_requests_per_worker": 3, "admission_queue_timeout_seconds": 0}, None), + ({"max_in_flight_requests_per_worker": 3, "admission_queue_timeout_seconds": -0.5}, None), + ( + {"max_in_flight_requests_per_worker": 3, "max_queued_requests_per_worker": 0}, + AdmissionControlSettings(3, 0, 1.0), + ), + ( + {"max_in_flight_requests_per_worker": 3}, + AdmissionControlSettings(3, 3, 1.0), + ), + ( + { + "max_in_flight_requests_per_worker": 3, + "max_queued_requests_per_worker": 5, + "admission_queue_timeout_seconds": 0.25, + }, + AdmissionControlSettings(3, 5, 0.25), + ), + ), +) +def test_get_admission_control_settings( + settings: dict[str, object], + expected: AdmissionControlSettings | None, +) -> None: + assert get_admission_control_settings(settings) == expected + + +def test_invalid_admission_control_settings_logs_once(caplog: pytest.LogCaptureFixture) -> None: + _parse_admission_control_settings.cache_clear() + caplog.set_level("ERROR") + settings: Final = {"max_in_flight_requests_per_worker": [1]} + + assert get_admission_control_settings(settings) is None + assert get_admission_control_settings(settings) is None + + messages: Final = tuple( + record.message + for record in caplog.records + if record.message.startswith("Ignoring invalid admission control settings") + ) + assert len(messages) == 1 diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 1d4b0264879..acb45038df0 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -319,9 +319,6 @@ class TestVertexAIPassThroughHandler: mock_handler.get_default_base_target_url.return_value = ( f"https://{test_location}-aiplatform.googleapis.com/" ) - mock_handler.update_base_target_url_with_credential_location = Mock( - return_value=f"https://{test_location}-aiplatform.googleapis.com/" - ) mock_get_handler.return_value = mock_handler # Mock create_pass_through_route to return a function that returns a mock response @@ -427,9 +424,6 @@ class TestVertexAIPassThroughHandler: mock_handler.get_default_base_target_url.return_value = ( "https://aiplatform.googleapis.com/" ) - mock_handler.update_base_target_url_with_credential_location = Mock( - return_value="https://aiplatform.googleapis.com/" - ) mock_get_handler.return_value = mock_handler # Mock create_pass_through_route to return a function that returns a mock response @@ -530,9 +524,6 @@ class TestVertexAIPassThroughHandler: mock_handler.get_default_base_target_url.return_value = ( f"https://{default_location}-aiplatform.googleapis.com/" ) - mock_handler.update_base_target_url_with_credential_location = Mock( - return_value=f"https://{default_location}-aiplatform.googleapis.com/" - ) mock_get_handler.return_value = mock_handler # Mock create_pass_through_route to return a function that returns a mock response @@ -1308,9 +1299,6 @@ class TestVertexAIDiscoveryPassThroughHandler: mock_handler.get_default_base_target_url.return_value = ( "https://discoveryengine.googleapis.com" ) - mock_handler.update_base_target_url_with_credential_location = Mock( - return_value="https://discoveryengine.googleapis.com" - ) mock_get_handler.return_value = mock_handler # Mock create_pass_through_route to return a function that returns a mock response @@ -3650,7 +3638,6 @@ class TestVertexRawPredictStreamingClassification: base_url = "https://us-east5-aiplatform.googleapis.com/" mock_handler = Mock() mock_handler.get_default_base_target_url.return_value = base_url - mock_handler.update_base_target_url_with_credential_location = Mock(return_value=base_url) module = "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints" with ( @@ -4234,6 +4221,126 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert "sk-master-1234" not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) +class TestVertexPassthroughDefaultLocationOnShortRoutes: + PROJECT = "test-project" + SHORT_ROUTE = "publishers/google/models/gemini-2.5-flash:generateContent" + + @staticmethod + def _forwarder() -> Mock: + return Mock(return_value=AsyncMock(return_value={"status": "success"})) + + async def _forward( + self, + monkeypatch, + endpoint: str, + default_config: dict | None, + headers: list[tuple[bytes, bytes]], + forwarder: Mock, + ) -> None: + from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import ( + PassthroughEndpointRouter, + ) + + async def receive(): + return {"type": "http.request", "body": b"{}", "more_body": False} + + request: Final = Request( + { + "type": "http", + "method": "POST", + "path": f"/vertex_ai/{endpoint}", + "headers": headers, + "query_string": b"", + }, + receive=receive, + ) + router: Final = PassthroughEndpointRouter() + if default_config is not None: + router.set_default_vertex_config(dict(default_config)) + module: Final = "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints" + monkeypatch.setattr(f"{module}.passthrough_endpoint_router", router) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) + mock_credentials: Final = Mock() + mock_credentials.token = "test-token" + caller: Final = UserAPIKeyAuth(api_key="test-key") + with ( + mock.patch( # test-quality-ok: the route mints its Google token through its own VertexBase, nothing injects the credential loader + "litellm.llms.vertex_ai.vertex_llm_base.VertexBase.load_auth", + return_value=(mock_credentials, self.PROJECT), + ), + mock.patch(f"{module}.create_pass_through_route", new=forwarder), + mock.patch(f"{module}.user_api_key_auth", new=AsyncMock(return_value=caller)), + ): + await vertex_proxy_route( + endpoint=endpoint, + request=request, + fastapi_response=Response(), + user_api_key_dict=caller, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("endpoint", "location", "expected_target"), + [ + ( + SHORT_ROUTE, + "global", + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/" + SHORT_ROUTE, + ), + ( + f"v1/{SHORT_ROUTE}", + "global", + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/" + SHORT_ROUTE, + ), + ( + f"v1beta1/{SHORT_ROUTE}", + "global", + "https://aiplatform.googleapis.com/v1beta1/projects/test-project/locations/global/" + SHORT_ROUTE, + ), + ( + SHORT_ROUTE, + "us-central1", + "https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/" + + SHORT_ROUTE, + ), + ], + ) + async def test_default_vertex_config_location_fills_routes_without_project_and_location( + self, monkeypatch, endpoint, location, expected_target + ): + forwarder: Final = self._forwarder() + await self._forward( + monkeypatch, + endpoint, + {"vertex_project": self.PROJECT, "vertex_location": location, "vertex_credentials": "test-creds"}, + [(b"content-type", b"application/json"), (b"authorization", b"Bearer test-key")], + forwarder, + ) + forwarded: Final = forwarder.call_args.kwargs + assert str(forwarded["target"]) == expected_target + assert forwarded["custom_headers"]["Authorization"] == "Bearer test-token" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("default_config", "headers"), + [ + (None, [(b"content-type", b"application/json"), (b"authorization", b"Bearer ya29.byo-google-oauth")]), + ( + {"vertex_project": PROJECT, "vertex_credentials": "test-creds"}, + [(b"content-type", b"application/json"), (b"authorization", b"Bearer test-key")], + ), + ], + ) + async def test_no_location_anywhere_is_a_400_not_a_500(self, monkeypatch, default_config, headers): + forwarder: Final = self._forwarder() + with pytest.raises(HTTPException) as raised: + await self._forward(monkeypatch, self.SHORT_ROUTE, default_config, headers, forwarder) + forwarder.assert_not_called() + assert raised.value.status_code == 400 + assert "/projects//locations//" in str(raised.value.detail) + assert "default_vertex_config" in str(raised.value.detail) + + class TestGetAzureAISearchIndexFromEndpoint: """The operable index is only the segment right after ``indexes``. @@ -5009,3 +5116,93 @@ class TestAzureRouterModelStreamingDispatch: assert result.status_code == 200 body = b"".join([chunk async for chunk in result.body_iterator]) assert body == upstream_body + + +class TestAzureRouterModelStreamingKeepalive: + async def _dispatch(self, monkeypatch, interval, headers_delay=0.0, body_delay=0.0) -> StreamingResponse: + import asyncio + + import litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints as ep + import litellm.proxy.proxy_server as proxy_server + from litellm.passthrough.main import AsyncPassthroughStreamingResponse + + monkeypatch.setattr(litellm, "sse_keepalive_ping_interval_seconds", interval) + + class _StallingBody(httpx.AsyncByteStream): + async def __aiter__(self): + await asyncio.sleep(body_delay) + yield b"data: hello\n\n" + + async def _upstream_response() -> httpx.Response: + await asyncio.sleep(headers_delay) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream", "x-upstream": "kept"}, + stream=_StallingBody(), + request=httpx.Request("POST", "https://my-azure.openai.azure.com/openai/deployments/gpt-5/x"), + ) + + logging_obj = MagicMock() + logging_obj.async_flush_passthrough_collected_chunks = AsyncMock() + + class StreamingRouter: + async def allm_passthrough_route(self, **kwargs): + return await AsyncPassthroughStreamingResponse( + response=_upstream_response(), + litellm_logging_obj=logging_obj, + provider_config=MagicMock(), + ) + + async def fake_get_request_body(_request): + return {"model": "gpt-5", "stream": True} + + monkeypatch.setattr(proxy_server, "llm_router", StreamingRouter()) + monkeypatch.setattr(ep, "get_request_body", fake_get_request_body) + monkeypatch.setattr(ep, "is_passthrough_request_using_router_model", lambda *a, **k: True) + + request = MagicMock(spec=Request) + request.method = "POST" + request.headers = {"content-type": "application/json"} + request.query_params = {} + + result = await azure_proxy_route( + endpoint="openai/deployments/gpt-5/chat/completions", + request=request, + fastapi_response=MagicMock(spec=Response), + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-token"), + ) + assert isinstance(result, StreamingResponse) + return result + + @pytest.mark.asyncio + async def test_pings_while_upstream_headers_are_still_pending(self, monkeypatch): + result = await self._dispatch(monkeypatch, interval=0.05, headers_delay=0.3) + + chunks = [chunk async for chunk in result.body_iterator] + + assert result.status_code == 200 + assert result.headers["x-accel-buffering"] == "no" + assert chunks[0] == b": ping\n\n" + assert chunks.count(b": ping\n\n") >= 3 + assert b"".join(chunks).endswith(b"data: hello\n\n") + + @pytest.mark.asyncio + async def test_pings_while_upstream_body_is_still_pending(self, monkeypatch): + result = await self._dispatch(monkeypatch, interval=0.05, body_delay=0.3) + + chunks = [chunk async for chunk in result.body_iterator] + + assert result.status_code == 200 + assert result.headers["x-upstream"] == "kept" + assert chunks[0] == b": ping\n\n" + assert chunks.count(b": ping\n\n") >= 3 + assert chunks[-1] == b"data: hello\n\n" + + @pytest.mark.asyncio + async def test_relays_upstream_bytes_untouched_while_keepalives_are_unconfigured(self, monkeypatch): + result = await self._dispatch(monkeypatch, interval=None, headers_delay=0.15, body_delay=0.15) + + chunks = [chunk async for chunk in result.body_iterator] + + assert result.headers["x-upstream"] == "kept" + assert chunks == [b"data: hello\n\n"] diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py index 9d1975513a1..c7696079adc 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py @@ -292,8 +292,8 @@ class TestUnifiedGuardrailCallTypeResolution: with patch.object( unified_guardrail_module, - "endpoint_guardrail_translation_mappings", - {CallTypes.pass_through: mock_handler_class}, + "load_guardrail_translation_mappings", + lambda: {CallTypes.pass_through: mock_handler_class}, ): result = await unified.async_post_call_success_hook( data=data, diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py index 6735f2a3780..e8fd5579631 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py @@ -4,6 +4,7 @@ import pytest from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( + VertexAIPassThroughHandler, _base_vertex_proxy_route, _upstream_headers_for_vertex_route, ) @@ -20,6 +21,7 @@ async def test_vertex_passthrough_load_balancing(): mock_request = MagicMock() mock_response = MagicMock() mock_handler = MagicMock() + mock_handler.get_default_base_target_url.return_value = "https://test.url" # Mock the router mock_router = MagicMock() @@ -68,7 +70,6 @@ async def test_vertex_passthrough_load_balancing(): mock_pt_router.get_vertex_credentials.return_value = MagicMock() mock_prep_headers.return_value = ( {}, - "https://test.url", False, "test-project-lb", "us-central1-lb", @@ -290,12 +291,6 @@ async def test_vertex_passthrough_forwards_anthropic_beta_header(): mock_vertex_credentials.vertex_location = "us-central1" mock_vertex_credentials.vertex_credentials = "test-credentials" - # Create mock handler - mock_handler = MagicMock() - mock_handler.update_base_target_url_with_credential_location.return_value = ( - "https://us-central1-aiplatform.googleapis.com" - ) - with ( patch.object( VertexBase, @@ -313,7 +308,6 @@ async def test_vertex_passthrough_forwards_anthropic_beta_header(): # Call the function ( headers, - base_target_url, headers_passed_through, vertex_project, vertex_location, @@ -323,8 +317,6 @@ async def test_vertex_passthrough_forwards_anthropic_beta_header(): router_credentials=None, vertex_project="test-project", vertex_location="us-central1", - base_target_url="https://us-central1-aiplatform.googleapis.com", - get_vertex_pass_through_handler=mock_handler, user_api_key_dict=UserAPIKeyAuth(api_key="sk-litellm-secret-key"), ) @@ -394,7 +386,6 @@ async def test_vertex_passthrough_drops_anthropic_beta_only_on_count_tokens( "content-type": "application/json", "Authorization": "Bearer vertex-access-token", }, - "https://aiplatform.googleapis.com", False, "test-project", "global", @@ -406,7 +397,7 @@ async def test_vertex_passthrough_drops_anthropic_beta_only_on_count_tokens( endpoint=f"{VERTEX_ANTHROPIC_MODELS_PREFIX}{model_segment}", request=MagicMock(), fastapi_response=MagicMock(), - get_vertex_pass_through_handler=MagicMock(), + get_vertex_pass_through_handler=VertexAIPassThroughHandler(), ) upstream_headers = mock_create_route.call_args.kwargs["custom_headers"] @@ -473,12 +464,6 @@ async def test_vertex_passthrough_does_not_forward_litellm_auth_token(): mock_vertex_credentials.vertex_location = "us-central1" mock_vertex_credentials.vertex_credentials = "test-credentials" - # Create mock handler - mock_handler = MagicMock() - mock_handler.update_base_target_url_with_credential_location.return_value = ( - "https://us-central1-aiplatform.googleapis.com" - ) - with ( patch.object( VertexBase, @@ -495,7 +480,6 @@ async def test_vertex_passthrough_does_not_forward_litellm_auth_token(): ( headers, - _base_target_url, _headers_passed_through, _vertex_project, _vertex_location, @@ -505,8 +489,6 @@ async def test_vertex_passthrough_does_not_forward_litellm_auth_token(): router_credentials=None, vertex_project="test-project", vertex_location="us-central1", - base_target_url="https://us-central1-aiplatform.googleapis.com", - get_vertex_pass_through_handler=mock_handler, user_api_key_dict=UserAPIKeyAuth(api_key="sk-litellm-secret-key"), ) @@ -742,7 +724,6 @@ async def test_vertex_passthrough_custom_model_name_replaced_in_url(): mock_pt_router.get_vertex_credentials.return_value = MagicMock() mock_prep_headers.return_value = ( {}, - "https://global-aiplatform.googleapis.com", False, "nv-gcpllmgwit-20250411173346", "global", diff --git a/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py b/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py index 6ebb10eff76..41c3003af34 100644 --- a/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py +++ b/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py @@ -104,97 +104,6 @@ class TestPromptVersioning: assert get_base_prompt_id(prompt_id="jack") == "jack" assert get_base_prompt_id(prompt_id="my_prompt.v10") == "my_prompt" - def test_get_latest_version_prompt_id(self): - """ - Test that get_latest_version_prompt_id returns the highest version - """ - from litellm.proxy.prompts.prompt_endpoints import get_latest_version_prompt_id - - # Mock prompt IDs dictionary - all_prompt_ids = { - "jack.v1": {}, - "jack.v2": {}, - "jack.v3": {}, - "jane.v1": {}, - "simple_prompt": {}, - } - - # Test with base prompt ID - should return latest version - assert ( - get_latest_version_prompt_id( - prompt_id="jack", all_prompt_ids=all_prompt_ids - ) - == "jack.v3" - ) - - # Test with versioned prompt ID - should still return latest version - assert ( - get_latest_version_prompt_id( - prompt_id="jack.v1", all_prompt_ids=all_prompt_ids - ) - == "jack.v3" - ) - - # Test with single version - assert ( - get_latest_version_prompt_id( - prompt_id="jane", all_prompt_ids=all_prompt_ids - ) - == "jane.v1" - ) - - # Test with non-versioned prompt - assert ( - get_latest_version_prompt_id( - prompt_id="simple_prompt", all_prompt_ids=all_prompt_ids - ) - == "simple_prompt" - ) - - # Test with non-existent prompt - assert ( - get_latest_version_prompt_id( - prompt_id="nonexistent", all_prompt_ids=all_prompt_ids - ) - == "nonexistent" - ) - - def test_construct_versioned_prompt_id(self): - """ - Test that construct_versioned_prompt_id correctly builds versioned IDs - """ - from litellm.proxy.prompts.prompt_endpoints import construct_versioned_prompt_id - - # Test with base prompt ID and version - assert ( - construct_versioned_prompt_id(prompt_id="jack_success", version=4) - == "jack_success.v4" - ) - - # Test with None version - should return base ID unchanged - assert ( - construct_versioned_prompt_id(prompt_id="jack_success", version=None) - == "jack_success" - ) - - # Test with existing versioned ID - should replace version - assert ( - construct_versioned_prompt_id(prompt_id="jack_success.v2", version=4) - == "jack_success.v4" - ) - - # Test with hyphenated prompt ID - assert ( - construct_versioned_prompt_id(prompt_id="my-prompt", version=1) - == "my-prompt.v1" - ) - - # Test with double-digit version - assert ( - construct_versioned_prompt_id(prompt_id="test_prompt", version=10) - == "test_prompt.v10" - ) - class TestPromptVersionsEndpoint: """ @@ -444,7 +353,7 @@ class TestAdminViewerReadAccess: "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" ) as mock_registry, ): - mock_registry.get_prompt_by_id.return_value = PromptSpec( + mock_registry.resolve_prompt_spec.return_value = PromptSpec( prompt_id="jack.v2", litellm_params=PromptLiteLLMParams( prompt_id="jack", @@ -453,10 +362,101 @@ class TestAdminViewerReadAccess: ), prompt_info=PromptInfo(prompt_type="db"), ) - mock_registry.IN_MEMORY_PROMPTS = {"jack.v1": {}, "jack.v2": {}} - mock_registry.get_prompt_callback_by_id.return_value = None + mock_registry.get_prompt_callback_for_prompt.return_value = None response = await get_prompt_info(prompt_id="jack", user_api_key_dict=viewer) assert response.prompt_spec.prompt_id == "jack" assert response.prompt_spec.version == 2 + + +class TestConfigPromptInfoWithEnvironment: + """ + Regression: /prompts/{id}/info with an environment param must still resolve + config-file (in-memory) prompts on a DB-backed proxy instead of 400ing. + """ + + def _registry_with_config_prompt(self): + from litellm.proxy.prompts.prompt_registry import InMemoryPromptRegistry + + registry = InMemoryPromptRegistry() + registry.IN_MEMORY_PROMPTS["envgreet::development"] = PromptSpec( + prompt_id="envgreet", + litellm_params=PromptLiteLLMParams( + prompt_id="envgreet", + prompt_integration="dotprompt", + dotprompt_content="AHOY {{user_message}}", + ), + prompt_info=PromptInfo(prompt_type="config"), + ) + return registry + + def _prisma_client_with_empty_prompt_table(self): + from unittest.mock import AsyncMock + + mock_prisma = MagicMock() + mock_prisma.db.litellm_prompttable.find_many = AsyncMock(return_value=[]) + return mock_prisma + + @pytest.mark.asyncio + async def test_get_prompt_info_with_environment_falls_back_to_registry(self): + from unittest.mock import patch + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.prompts.prompt_endpoints import get_prompt_info + + admin = UserAPIKeyAuth( + api_key="test_key", user_role=LitellmUserRoles.PROXY_ADMIN + ) + + with ( + patch( # test-quality-ok: endpoint reads prisma_client and the registry from module globals at call time; no injection seam + "litellm.proxy.proxy_server.prisma_client", + self._prisma_client_with_empty_prompt_table(), + ), + patch( # test-quality-ok: endpoint reads prisma_client and the registry from module globals at call time; no injection seam + "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY", + self._registry_with_config_prompt(), + ), + ): + response = await get_prompt_info( + prompt_id="envgreet", + environment="development", + user_api_key_dict=admin, + ) + + assert response.prompt_spec.prompt_id == "envgreet" + assert response.prompt_spec.litellm_params.dotprompt_content == "AHOY {{user_message}}" + + @pytest.mark.asyncio + async def test_get_prompt_info_with_wrong_environment_still_400s(self): + from unittest.mock import patch + + from fastapi import HTTPException + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.prompts.prompt_endpoints import get_prompt_info + + admin = UserAPIKeyAuth( + api_key="test_key", user_role=LitellmUserRoles.PROXY_ADMIN + ) + + with ( + patch( # test-quality-ok: endpoint reads prisma_client and the registry from module globals at call time; no injection seam + "litellm.proxy.proxy_server.prisma_client", + self._prisma_client_with_empty_prompt_table(), + ), + patch( # test-quality-ok: endpoint reads prisma_client and the registry from module globals at call time; no injection seam + "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY", + self._registry_with_config_prompt(), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await get_prompt_info( + prompt_id="envgreet", + environment="production", + user_api_key_dict=admin, + ) + + assert exc_info.value.status_code == 400 + assert "environment production" in exc_info.value.detail diff --git a/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py b/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py index b5792ac7572..387448bb9b3 100644 --- a/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py +++ b/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py @@ -52,8 +52,6 @@ async def test_delete_prompt_success(): with patch( "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" ) as mock_registry: - # User passes "test_prompt.v2" - # We simulate that get_prompt_by_id returns the prompt spec for v2 prompt_spec = PromptSpec( prompt_id="test_prompt.v2", litellm_params=PromptLiteLLMParams( @@ -61,7 +59,8 @@ async def test_delete_prompt_success(): ), prompt_info=PromptInfo(prompt_type="db"), ) - mock_registry.get_prompt_by_id.return_value = prompt_spec + mock_registry.resolve_prompt_spec.return_value = prompt_spec + mock_registry.has_config_prompt.return_value = False # Patch the prisma client in the endpoint module with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client): @@ -79,7 +78,7 @@ async def test_delete_prompt_success(): # 2. Memory deletion should use base ID mock_registry.delete_prompts_by_base_id.assert_called_once_with( - expected_base_id, environment=None + base_prompt_id=expected_base_id, environment=None ) assert response == { @@ -108,31 +107,14 @@ async def test_delete_prompt_by_base_id_success(): with patch( "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" ) as mock_registry: - # User passes "test_prompt" (base ID) - # 1. get_prompt_by_id("test_prompt") -> None (if it's not registered as base) - # 2. It calls get_latest_version_prompt_id -> returns "test_prompt.v3" - # 3. get_prompt_by_id("test_prompt.v3") -> returns Spec - - # Setup mocks behavior - def get_prompt_side_effect(prompt_id): - if prompt_id == "test_prompt": - return None - if prompt_id == "test_prompt.v3": - return PromptSpec( - prompt_id="test_prompt.v3", - litellm_params=PromptLiteLLMParams( - prompt_id="test_prompt", prompt_integration="dotprompt" - ), - prompt_info=PromptInfo(prompt_type="db"), - ) - return None - - mock_registry.get_prompt_by_id.side_effect = get_prompt_side_effect - mock_registry.IN_MEMORY_PROMPTS = { - "test_prompt.v1": {}, - "test_prompt.v2": {}, - "test_prompt.v3": {}, - } + mock_registry.resolve_prompt_spec.return_value = PromptSpec( + prompt_id="test_prompt.v3", + litellm_params=PromptLiteLLMParams( + prompt_id="test_prompt", prompt_integration="dotprompt" + ), + prompt_info=PromptInfo(prompt_type="db"), + ) + mock_registry.has_config_prompt.return_value = False # Patch the prisma client in the endpoint module with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client): @@ -150,7 +132,7 @@ async def test_delete_prompt_by_base_id_success(): # 2. Memory deletion should use base ID mock_registry.delete_prompts_by_base_id.assert_called_once_with( - expected_base_id, environment=None + base_prompt_id=expected_base_id, environment=None ) assert response == { @@ -169,11 +151,12 @@ async def test_delete_prompt_environment_scope_reaches_db_and_registry(): with patch( # test-quality-ok: stubs the collaborator so the test pins what the endpoint deletes "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" ) as mock_registry: - mock_registry.get_prompt_by_id.return_value = PromptSpec( + mock_registry.resolve_prompt_spec.return_value = PromptSpec( prompt_id="test_prompt.v2", litellm_params=PromptLiteLLMParams(prompt_id="test_prompt", prompt_integration="dotprompt"), - prompt_info=PromptInfo(prompt_type="db"), + prompt_info=PromptInfo(prompt_type="db", environment="production"), ) + mock_registry.has_config_prompt.return_value = False with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client): # test-quality-ok: proxy_server module global is the endpoint's only injection point response = await delete_prompt( @@ -185,7 +168,7 @@ async def test_delete_prompt_environment_scope_reaches_db_and_registry(): mock_prisma_client.db.litellm_prompttable.delete_many.assert_called_once_with( where={"prompt_id": "test_prompt", "environment": "production"} ) - mock_registry.delete_prompts_by_base_id.assert_called_once_with("test_prompt", environment="production") + mock_registry.delete_prompts_by_base_id.assert_called_once_with(base_prompt_id="test_prompt", environment="production") assert response == {"message": "Prompt test_prompt deleted successfully from production"} @@ -218,24 +201,8 @@ async def test_get_prompt_info_by_base_id(): prompt_info=PromptInfo(prompt_type="db"), ) - # When get_prompt_by_id is called with "test_prompt", return None (so it searches versions) - # When called with "test_prompt.v3", return the spec - def get_prompt_side_effect(prompt_id): - if prompt_id == "test_prompt": - return None - if prompt_id == "test_prompt.v3": - return prompt_spec_v3 - return None - - mock_registry.get_prompt_by_id.side_effect = get_prompt_side_effect - mock_registry.IN_MEMORY_PROMPTS = { - "test_prompt.v1": {}, - "test_prompt.v2": {}, - "test_prompt.v3": {}, - } - - # We also need to mock get_prompt_callback_by_id to avoid content extraction errors/logic - mock_registry.get_prompt_callback_by_id.return_value = None + mock_registry.resolve_prompt_spec.return_value = prompt_spec_v3 + mock_registry.get_prompt_callback_for_prompt.return_value = None response = await get_prompt_info( prompt_id="test_prompt", user_api_key_dict=mock_user_auth @@ -284,7 +251,7 @@ async def test_patch_prompt_row_deleted_mid_update_returns_404(): "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" ) as mock_registry, ): - mock_registry.get_prompt_by_id.return_value = existing_prompt + mock_registry.has_config_prompt.return_value = False with pytest.raises(HTTPException) as exc_info: await patch_prompt( @@ -325,7 +292,7 @@ async def test_patch_prompt_merges_unsent_fields_from_db_row_not_stale_memory(): "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" ) as mock_registry, ): - mock_registry.get_prompt_by_id.return_value = stale_in_memory + mock_registry.has_config_prompt.return_value = False mock_registry.reload_prompt.side_effect = lambda prompt: prompt response = await patch_prompt( @@ -487,7 +454,7 @@ async def test_patch_prompt_info_only_keeps_legacy_keyed_row_patchable(): "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" ) as mock_registry, ): - mock_registry.get_prompt_by_id.return_value = existing_prompt + mock_registry.has_config_prompt.return_value = False await patch_prompt( prompt_id="agent-prompt", diff --git a/tests/test_litellm/proxy/prompts/test_prompt_environment.py b/tests/test_litellm/proxy/prompts/test_prompt_environment.py index ecd89afefbe..3cb647dea13 100644 --- a/tests/test_litellm/proxy/prompts/test_prompt_environment.py +++ b/tests/test_litellm/proxy/prompts/test_prompt_environment.py @@ -191,11 +191,7 @@ async def test_update_prompt_stores_environment_and_created_by(): with patch( "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" ) as mock_registry: - mock_registry.get_prompt_by_id.return_value = PromptSpec( - prompt_id="my_prompt.v1", - litellm_params=request.litellm_params, - prompt_info=PromptInfo(prompt_type="db"), - ) + mock_registry.has_config_prompt.return_value = False mock_registry.initialize_prompt.return_value = PromptSpec( prompt_id="my_prompt.v2", litellm_params=request.litellm_params, @@ -239,7 +235,8 @@ async def test_delete_prompt_scoped_to_environment(): prompt_info=PromptInfo(prompt_type="db"), environment="staging", ) - mock_registry.get_prompt_by_id.return_value = prompt_spec + mock_registry.resolve_prompt_spec.return_value = prompt_spec + mock_registry.has_config_prompt.return_value = False with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client): await delete_prompt( @@ -251,3 +248,6 @@ async def test_delete_prompt_scoped_to_environment(): mock_prisma_client.db.litellm_prompttable.delete_many.assert_called_once_with( where={"prompt_id": "test_prompt", "environment": "staging"} ) + mock_registry.delete_prompts_by_base_id.assert_called_once_with( + base_prompt_id="test_prompt", environment="staging" + ) diff --git a/tests/test_litellm/proxy/prompts/test_prompt_registry.py b/tests/test_litellm/proxy/prompts/test_prompt_registry.py index 3008821974e..a0743b65dd7 100644 --- a/tests/test_litellm/proxy/prompts/test_prompt_registry.py +++ b/tests/test_litellm/proxy/prompts/test_prompt_registry.py @@ -1,26 +1,35 @@ import pytest import litellm -from litellm.proxy.prompts.prompt_registry import InMemoryPromptRegistry +from litellm.integrations.custom_prompt_management import CustomPromptManagement +from litellm.proxy.prompts.prompt_registry import InMemoryPromptRegistry, parse_prompt_version from litellm.types.prompts.init_prompts import PromptInfo, PromptLiteLLMParams, PromptSpec -def _db_prompt_spec(content: str) -> PromptSpec: +def _db_prompt_spec(content: str, environment: str = "development", version: int = 1) -> PromptSpec: return PromptSpec( - prompt_id="greeting.v1", + prompt_id=f"greeting.v{version}", litellm_params=PromptLiteLLMParams( prompt_id="greeting", prompt_integration="dotprompt", prompt_data={"content": content, "metadata": {}}, ), prompt_info=PromptInfo(prompt_type="db"), + version=version, + environment=environment, ) -def _served_content(registry: InMemoryPromptRegistry) -> str: - callback = registry.get_prompt_callback_by_id("greeting.v1") +def _resolved_callback(registry: InMemoryPromptRegistry, environment: str | None = None) -> CustomPromptManagement: + spec = registry.resolve_prompt_spec("greeting", environment=environment) + assert spec is not None + callback = registry.get_prompt_callback_for_prompt(prompt=spec) assert callback is not None - return callback.prompt_manager.get_prompt("greeting").content + return callback + + +def _served_content(registry: InMemoryPromptRegistry, environment: str | None = None) -> str: + return _resolved_callback(registry, environment=environment).prompt_manager.get_prompt("greeting").content @pytest.fixture @@ -32,32 +41,34 @@ def isolated_callbacks(monkeypatch: pytest.MonkeyPatch) -> list: def test_sync_prompt_from_db_reloads_row_edited_elsewhere(isolated_callbacks: list) -> None: registry = InMemoryPromptRegistry() registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with AHOY")) - stale_callback = registry.get_prompt_callback_by_id("greeting.v1") + stale_callback = _resolved_callback(registry) assert _served_content(registry) == "begin every reply with AHOY" registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with HOWDY")) assert _served_content(registry) == "begin every reply with HOWDY" - assert registry.get_prompt_by_id("greeting.v1").litellm_params.prompt_data["content"] == "begin every reply with HOWDY" + reloaded_spec = registry.resolve_prompt_spec("greeting", environment="development") + assert reloaded_spec is not None + assert reloaded_spec.litellm_params.prompt_data["content"] == "begin every reply with HOWDY" assert stale_callback not in isolated_callbacks - assert isolated_callbacks == [registry.get_prompt_callback_by_id("greeting.v1")] + assert isolated_callbacks == [_resolved_callback(registry)] def test_sync_prompt_from_db_keeps_unchanged_row_in_place(isolated_callbacks: list) -> None: registry = InMemoryPromptRegistry() registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with AHOY")) - first_callback = registry.get_prompt_callback_by_id("greeting.v1") + first_callback = _resolved_callback(registry) registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with AHOY")) - assert registry.get_prompt_callback_by_id("greeting.v1") is first_callback + assert _resolved_callback(registry) is first_callback assert isolated_callbacks == [first_callback] def test_reload_prompt_replaces_callback_without_leaking_the_old_one(isolated_callbacks: list) -> None: registry = InMemoryPromptRegistry() registry.initialize_prompt(prompt=_db_prompt_spec("begin every reply with AHOY")) - stale_callback = registry.get_prompt_callback_by_id("greeting.v1") + stale_callback = _resolved_callback(registry) reloaded = registry.reload_prompt(prompt=_db_prompt_spec("begin every reply with HOWDY")) @@ -70,7 +81,7 @@ def test_reload_prompt_replaces_callback_without_leaking_the_old_one(isolated_ca def test_reload_prompt_keeps_the_old_template_when_the_replacement_fails(isolated_callbacks: list) -> None: registry = InMemoryPromptRegistry() registry.initialize_prompt(prompt=_db_prompt_spec("begin every reply with AHOY")) - old_callback = registry.get_prompt_callback_by_id("greeting.v1") + old_callback = _resolved_callback(registry) broken = PromptSpec( prompt_id="greeting.v1", @@ -80,63 +91,134 @@ def test_reload_prompt_keeps_the_old_template_when_the_replacement_fails(isolate prompt_data={"content": "begin every reply with HOWDY", "metadata": {}}, ), prompt_info=PromptInfo(prompt_type="db"), + version=1, + environment="development", ) with pytest.raises(ValueError, match="Unsupported prompt"): registry.reload_prompt(prompt=broken) - assert registry.get_prompt_callback_by_id("greeting.v1") is old_callback + assert _resolved_callback(registry) is old_callback assert _served_content(registry) == "begin every reply with AHOY" assert isolated_callbacks == [old_callback] -def _versioned_prompt_spec(version: int, environment: str) -> PromptSpec: - return PromptSpec( - prompt_id=f"greeting.v{version}", +def test_environments_sharing_a_prompt_id_keep_separate_templates(isolated_callbacks: list) -> None: + registry = InMemoryPromptRegistry() + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with AHOY", environment="development")) + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with HOWDY", environment="production")) + + assert _served_content(registry, environment="development") == "begin every reply with AHOY" + assert _served_content(registry, environment="production") == "begin every reply with HOWDY" + assert _resolved_callback(registry, environment="development") is not _resolved_callback( + registry, environment="production" + ) + + +def test_default_resolution_prefers_production(isolated_callbacks: list) -> None: + registry = InMemoryPromptRegistry() + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with AHOY", environment="development")) + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with HOWDY", environment="production")) + + assert _served_content(registry) == "begin every reply with HOWDY" + + +@pytest.mark.parametrize("environment", ["staging", "qa"]) +def test_default_resolution_serves_the_only_environment_present(isolated_callbacks: list, environment: str) -> None: + registry = InMemoryPromptRegistry() + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with AHOY", environment=environment)) + + assert _served_content(registry) == "begin every reply with AHOY" + + +def test_resolution_picks_exact_version_and_latest_within_an_environment(isolated_callbacks: list) -> None: + registry = InMemoryPromptRegistry() + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with AHOY", environment="development", version=1)) + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with YO", environment="development", version=2)) + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with HOWDY", environment="production", version=1)) + + exact = registry.resolve_prompt_spec("greeting", version=1, environment="development") + assert exact is not None + assert exact.litellm_params.prompt_data["content"] == "begin every reply with AHOY" + + latest = registry.resolve_prompt_spec("greeting", environment="development") + assert latest is not None + assert latest.litellm_params.prompt_data["content"] == "begin every reply with YO" + + assert registry.resolve_prompt_spec("greeting", version=3, environment="development") is None + + +def test_resolution_returns_none_for_unknown_environment_or_prompt(isolated_callbacks: list) -> None: + registry = InMemoryPromptRegistry() + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with AHOY", environment="development")) + + assert registry.resolve_prompt_spec("greeting", environment="production") is None + assert registry.resolve_prompt_spec("no_such_prompt") is None + + +def test_delete_prompts_by_base_id_scoped_to_one_environment(isolated_callbacks: list) -> None: + registry = InMemoryPromptRegistry() + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with AHOY", environment="development")) + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with HOWDY", environment="production")) + production_callback = _resolved_callback(registry, environment="production") + + deleted = registry.delete_prompts_by_base_id(base_prompt_id="greeting", environment="development") + + assert deleted == ["greeting.v1::development"] + assert registry.resolve_prompt_spec("greeting", environment="development") is None + assert _resolved_callback(registry, environment="production") is production_callback + assert _served_content(registry, environment="production") == "begin every reply with HOWDY" + + deleted_rest = registry.delete_prompts_by_base_id(base_prompt_id="greeting") + + assert deleted_rest == ["greeting.v1::production"] + assert registry.resolve_prompt_spec("greeting") is None + + +def test_has_config_prompt_matches_any_version_of_the_base_id(isolated_callbacks: list) -> None: + registry = InMemoryPromptRegistry() + config_spec = PromptSpec( + prompt_id="greeting", litellm_params=PromptLiteLLMParams( prompt_id="greeting", prompt_integration="dotprompt", - prompt_data={"content": f"begin every reply with AHOY v{version}", "metadata": {}}, + prompt_data={"content": "begin every reply with AHOY", "metadata": {}}, ), - prompt_info=PromptInfo(prompt_type="db", environment=environment), - version=version, - environment=environment, + prompt_info=PromptInfo(prompt_type="config"), ) + registry.initialize_prompt(prompt=config_spec) + registry.sync_prompt_from_db(prompt=_db_prompt_spec("begin every reply with HOWDY", environment="production")) + + assert registry.has_config_prompt(base_prompt_id="greeting") is True + assert registry.has_config_prompt(base_prompt_id="other_prompt") is False def test_delete_prompts_by_base_id_removes_the_callbacks_from_litellm_callbacks(isolated_callbacks: list) -> None: registry = InMemoryPromptRegistry() - registry.initialize_prompt(prompt=_versioned_prompt_spec(1, "development")) - registry.initialize_prompt(prompt=_versioned_prompt_spec(2, "development")) + registry.initialize_prompt(prompt=_db_prompt_spec("begin every reply with AHOY", version=1)) + registry.initialize_prompt(prompt=_db_prompt_spec("begin every reply with YO", version=2)) assert len(isolated_callbacks) == 1 - deleted = registry.delete_prompts_by_base_id("greeting") + deleted = registry.delete_prompts_by_base_id(base_prompt_id="greeting") - assert sorted(deleted) == ["greeting.v1", "greeting.v2"] - assert registry.get_prompt_by_id("greeting.v1") is None - assert registry.get_prompt_callback_by_id("greeting.v2") is None + assert sorted(deleted) == ["greeting.v1::development", "greeting.v2::development"] + assert registry.resolve_prompt_spec("greeting") is None assert isolated_callbacks == [] -def test_delete_prompts_by_base_id_environment_scope_keeps_other_environments(isolated_callbacks: list) -> None: +def test_remove_prompt_is_a_no_op_for_an_unknown_registry_key(isolated_callbacks: list) -> None: registry = InMemoryPromptRegistry() - registry.initialize_prompt(prompt=_versioned_prompt_spec(1, "development")) - registry.initialize_prompt(prompt=_versioned_prompt_spec(2, "production")) - production_callback = registry.get_prompt_callback_by_id("greeting.v2") + registry.initialize_prompt(prompt=_db_prompt_spec("begin every reply with AHOY")) - deleted = registry.delete_prompts_by_base_id("greeting", environment="development") + registry.remove_prompt(registry_key="not_there.v1::development") - assert deleted == ["greeting.v1"] - assert registry.get_prompt_by_id("greeting.v1") is None - assert registry.get_prompt_by_id("greeting.v2") is not None - assert registry.get_prompt_callback_by_id("greeting.v2") is production_callback - - -def test_remove_prompt_is_a_no_op_for_an_unknown_id(isolated_callbacks: list) -> None: - registry = InMemoryPromptRegistry() - registry.initialize_prompt(prompt=_versioned_prompt_spec(1, "development")) - - registry.remove_prompt(prompt_id="not_there.v1") - - assert registry.get_prompt_by_id("greeting.v1") is not None + assert registry.resolve_prompt_spec("greeting") is not None assert len(isolated_callbacks) == 1 + + +@pytest.mark.parametrize( + ("raw_version", "expected"), + [(2, 2), ("2", 2), (None, None), ("v2", None), (True, None), (2.0, None)], +) +def test_parse_prompt_version_accepts_integers_and_json_strings(raw_version: object, expected: int | None) -> None: + assert parse_prompt_version(raw_version) == expected diff --git a/tests/test_litellm/proxy/proxy_server/test_streaming_helpers.py b/tests/test_litellm/proxy/proxy_server/test_streaming_helpers.py index fdaad567f95..87e10ce7e8d 100644 --- a/tests/test_litellm/proxy/proxy_server/test_streaming_helpers.py +++ b/tests/test_litellm/proxy/proxy_server/test_streaming_helpers.py @@ -1819,3 +1819,92 @@ async def test_run_thread_stream_is_untouched_while_keepalives_are_unconfigured( assert not any(chunk.startswith(": ping") for chunk in chunks) assert chunks[-1] == "data: [DONE]\n\n" + + +# --------------------------------------------------------------------------- +# async_queue_request: SSE keepalives during the time-to-first-token +# --------------------------------------------------------------------------- + + +async def _queue_streaming(monkeypatch, interval, delay=0.3, fails_with=None): + _patch_logging_flags(monkeypatch) + monkeypatch.setattr(litellm, "sse_keepalive_ping_interval_seconds", interval) + + router = MagicMock() + router.get_model_list.return_value = [] + + async def _schedule_after_the_scheduler_queue_drains(**kwargs): + await asyncio.sleep(delay) + if fails_with is not None: + raise fails_with + return _async_iter([_simple_chunk(content="queued reply")]) + + router.schedule_acompletion = _schedule_after_the_scheduler_queue_drains + monkeypatch.setattr(ps, "llm_router", router) + + request = MagicMock() + request.url = "http://testserver/queue/chat/completions" + request.method = "POST" + request.headers = {} + request.json = AsyncMock( + return_value={ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}], + "priority": 0, + "stream": True, + } + ) + request.is_disconnected = AsyncMock(return_value=False) + + return await ps.async_queue_request( + request=request, + fastapi_response=Response(), + user_api_key_dict=_user_auth(), + ) + + +@pytest.mark.asyncio +async def test_queue_request_pings_while_the_scheduler_is_still_waiting(monkeypatch): + response = await _queue_streaming(monkeypatch, interval=0.05) + + assert isinstance(response, StreamingResponse) + assert response.headers["x-accel-buffering"] == "no" + chunks = [chunk async for chunk in response.body_iterator] + + assert chunks[0] == b": ping\n\n" + assert chunks.count(b": ping\n\n") >= 3 + assert b'"content":"queued reply"' in chunks[-2] + assert chunks[-1] == b"data: [DONE]\n\n" + + +@pytest.mark.asyncio +async def test_queue_request_audits_a_failure_that_arrives_after_the_first_ping(monkeypatch): + audited = [] + + async def _record_failure(*, user_api_key_dict, original_exception, request_data, **kwargs): + audited.append(original_exception) + return None + + monkeypatch.setattr(ps.proxy_logging_obj, "post_call_failure_hook", _record_failure) + + boom = RuntimeError("scheduler died after the wire was already open") + response = await _queue_streaming(monkeypatch, interval=0.05, fails_with=boom) + + assert isinstance(response, StreamingResponse) + chunks = [chunk async for chunk in response.body_iterator] + + assert chunks[0] == b": ping\n\n" + assert audited == [boom] + assert json.loads(chunks[-2].removeprefix(b"data: "))["error"]["code"] == "500" + assert chunks[-1] == b"data: [DONE]\n\n" + + +@pytest.mark.asyncio +async def test_queue_request_stream_is_untouched_while_keepalives_are_unconfigured(monkeypatch): + response = await _queue_streaming(monkeypatch, interval=None, delay=0.15) + + assert isinstance(response, StreamingResponse) + chunks = [chunk if isinstance(chunk, bytes) else chunk.encode() async for chunk in response.body_iterator] + + assert not any(chunk.startswith(b": ping") for chunk in chunks) + assert chunks[-1] == b"data: [DONE]\n\n" diff --git a/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py b/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py index 0085b6ebd36..832435711c6 100644 --- a/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py +++ b/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py @@ -11,7 +11,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient - from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.proxy_server import app @@ -324,6 +323,100 @@ def test_rag_query_stream_returns_event_stream(client_internal_user): assert "data: [DONE]" in response.text +def test_rag_query_stream_pings_while_retrieval_is_still_running(client_internal_user, monkeypatch): + import asyncio + + import litellm as litellm_module + + monkeypatch.setattr(litellm_module, "sse_keepalive_ping_interval_seconds", 0.05) + + async def slow_aquery(**kwargs): + await asyncio.sleep(0.3) + return await litellm_module.acompletion( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "What is the codename?"}], + mock_response="The codename is AZURE-FALCON-42.", + stream=True, + api_key="test-key", + ) + + with ( + patch( # test-quality-ok: the handler calls the module-level litellm.aquery directly; no injection seam + "litellm.proxy.rag_endpoints.endpoints.litellm.aquery", + new=AsyncMock(side_effect=slow_aquery), + ), + patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam + patch("litellm.proxy.proxy_server.prisma_client", None), # test-quality-ok: proxy module global, no injection seam + ): + response = client_internal_user.post( + "/v1/rag/query", + json={ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "What is the codename?"}], + "retrieval_config": { + "vector_store_id": "vs_test_123", + "custom_llm_provider": "openai", + }, + "stream": True, + }, + ) + + assert response.status_code == 200, response.text + assert response.headers.get("content-type", "").startswith("text/event-stream") + assert response.headers["x-accel-buffering"] == "no" + assert response.text.startswith(": ping\n\n") + assert response.text.count(": ping\n\n") >= 3 + assert '"object":"chat.completion.chunk"' in response.text + assert response.text.endswith("data: [DONE]\n\n") + + +def test_rag_query_stream_keeps_response_headers_when_retrieval_beats_the_keepalive( + client_internal_user, monkeypatch +): + import litellm as litellm_module + + monkeypatch.setattr(litellm_module, "sse_keepalive_ping_interval_seconds", 5) + + async def fast_aquery(**kwargs): + response = await litellm_module.acompletion( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "What is the codename?"}], + mock_response="The codename is AZURE-FALCON-42.", + stream=True, + api_key="test-key", + ) + response._hidden_params["response_cost"] = 3.45e-06 + return response + + with ( + patch( # test-quality-ok: the handler calls the module-level litellm.aquery directly; no injection seam + "litellm.proxy.rag_endpoints.endpoints.litellm.aquery", + new=AsyncMock(side_effect=fast_aquery), + ), + patch("litellm.vector_store_registry", None), # test-quality-ok: proxy module global, no injection seam + patch("litellm.proxy.proxy_server.prisma_client", None), # test-quality-ok: proxy module global, no injection seam + ): + response = client_internal_user.post( + "/v1/rag/query", + json={ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "What is the codename?"}], + "retrieval_config": { + "vector_store_id": "vs_test_123", + "custom_llm_provider": "openai", + }, + "stream": True, + }, + ) + + assert response.status_code == 200, response.text + assert response.headers.get("content-type", "").startswith("text/event-stream") + assert response.headers.get("x-litellm-response-cost") == "3.45e-06" + assert not response.text.startswith(": ping") + assert '"object":"chat.completion.chunk"' in response.text + assert response.text.endswith("data: [DONE]\n\n") + + def test_rag_query_merges_managed_store_params(client_internal_user): """ Regression: /v1/rag/query must consult the managed vector store registry @@ -421,6 +514,76 @@ def test_rag_query_store_params_win_over_user_retrieval_config(client_internal_u assert forwarded_config["aws_region_name"] == "eu-west-1" +def test_rag_query_forwards_managed_store_credentials_to_search(client_internal_user): + """ + Regression for LIT-6773: the registry store's api_key / api_base and its + provider extras (Milvus outputFields, milvus_text_field) must reach the + vector store search the way the direct /v1/vector_stores/{id}/search + endpoint forwards them. Pre-fix the RAG path allowlisted them away and a + managed Milvus store 500'd with "MILVUS_API_KEY is not set". + """ + import litellm + from litellm import Router + from litellm.types.vector_stores import VectorStoreSearchResponse + + mock_vector_store = { + "vector_store_id": "customer_kb", + "custom_llm_provider": "milvus", + "litellm_params": { + "vector_store_id": "customer_kb", + "custom_llm_provider": "milvus", + "api_base": "http://127.0.0.1:19530", + "api_key": "root:Milvus", + "litellm_embedding_model": "multilingual-e5-large", + "milvus_text_field": "book_intro_text", + "outputFields": ["book_intro_text"], + }, + } + mock_registry = MagicMock() + mock_registry.get_litellm_managed_vector_store_from_registry.return_value = mock_vector_store + fake_search = AsyncMock( + return_value=VectorStoreSearchResponse(object="vector_store.search_results.page", search_query="q", data=[]) + ) + router = Router( + model_list=[ + { + "model_name": "gpt-4o-mini", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test", "mock_response": "hi"}, + } + ] + ) + + with ( + patch("litellm.vector_stores.asearch", new=fake_search), # test-quality-ok: the search boundary under test + patch.object(litellm, "vector_store_registry", mock_registry), # test-quality-ok: seeds the store under test + patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: mock-response router for completion + patch( # test-quality-ok: store access is not under test, so the request reaches the search boundary + "litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store", + new=AsyncMock(return_value=True), + ), + ): + response = client_internal_user.post( + "/v1/rag/query", + json={ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "which database is built for similarity search?"}], + "retrieval_config": {"vector_store_id": "customer_kb", "custom_llm_provider": "milvus", "top_k": 2}, + }, + ) + + assert response.status_code == 200, response.json() + fake_search.assert_awaited_once() + search_kwargs = fake_search.await_args.kwargs + assert search_kwargs["vector_store_id"] == "customer_kb" + assert search_kwargs["custom_llm_provider"] == "milvus" + assert search_kwargs["max_num_results"] == 2 + assert search_kwargs["api_base"] == "http://127.0.0.1:19530" + assert search_kwargs["api_key"] == "root:Milvus" + assert search_kwargs["litellm_embedding_model"] == "multilingual-e5-large" + assert search_kwargs["milvus_text_field"] == "book_intro_text" + assert search_kwargs["outputFields"] == ["book_intro_text"] + + @pytest.mark.parametrize( "blocked_key", ["embedding_model", "litellm_embedding_model", "litellm_embedding_config", "litellm_credential_name"], diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index f4cd8814bc1..329a33eb440 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -5,14 +5,12 @@ import hashlib import json import re from datetime import timezone +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from fastapi.testclient import TestClient - -from unittest.mock import AsyncMock, MagicMock, patch - import litellm import litellm.proxy.proxy_server as ps @@ -58,6 +56,24 @@ def _filter_logs_by_date_range(logs, where): return filtered +_SEARCH_CLAUSE_RE = re.compile( + r'\(request_id = \$(\d+) OR \("startTime" >= \(\$(\d+)::timestamptz AT TIME ZONE \'UTC\'\) ' + r'AND "startTime" <= \(\$(\d+)::timestamptz AT TIME ZONE \'UTC\'\) ' + r'AND \(api_key = \$\1 OR team_id = \$\1 OR "user" = \$\1 OR end_user = \$\1 ' + r"OR session_id = \$\1 OR model_id = \$\1\)\)\)" +) + + +def _matches_spend_log_search(log, search): + """Mirror the search clause: request_id across all time, the other id columns inside the window.""" + if log.get("request_id") == search["value"]: + return True + if not _filter_logs_by_date_range([log], {"startTime": {"gte": search["gte"], "lte": search["lte"]}}): + return False + columns = ("api_key", "team_id", "user", "end_user", "session_id", "model_id") + return any(log.get(col) == search["value"] for col in columns) + + def _reconstruct_ui_where_from_sql(sql_query, params): """ Rebuild the Prisma-style ``where`` dict the filter_fns below expect from the @@ -77,6 +93,16 @@ def _reconstruct_ui_where_from_sql(sql_query, params): def _iso(value): return value.isoformat() if hasattr(value, "isoformat") else str(value) + search_clause = _SEARCH_CLAUSE_RE.search(clause.group(1)) + if search_clause: + raw_index, start_index, end_index = (int(g) for g in search_clause.groups()) + where["search"] = { + "value": params[raw_index - 1], + "gte": _iso(params[start_index - 1]), + "lte": _iso(params[end_index - 1]), + } + remaining = clause.group(1) if search_clause is None else clause.group(1).replace(search_clause.group(0), "") + eq_cols = { "team_id": "team_id", '"user"': "user", @@ -89,7 +115,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params): } date_bounds: dict = {} metadata_conds: list = [] - for cond in (c.strip() for c in clause.group(1).split(" AND ")): + for cond in (c.strip() for c in remaining.split(" AND ")): gte = re.search(r'"startTime" >= \(\$(\d+)', cond) lte = re.search(r'"startTime" <= \(\$(\d+)', cond) alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond) @@ -2352,6 +2378,208 @@ async def test_ui_view_spend_logs_request_id_owner_scoped_by_id_only( app.dependency_overrides.pop(ps.user_api_key_auth, None) +def test_build_spend_log_search_condition_windows_every_branch_except_request_id(): + """LIT-4741: request_id matches across all time; the six other id columns only inside the window, + all comparing the pasted value verbatim.""" + start = datetime.datetime(2026, 8, 1, tzinfo=timezone.utc) + end = datetime.datetime(2026, 8, 2, tzinfo=timezone.utc) + + condition = spend_management_endpoints._build_spend_log_search_condition( + search="key-hash-7", start_date=start, end_date=end, next_param_index=3 + ) + + assert condition.sql == ( + "(request_id = $3 OR (\"startTime\" >= ($4::timestamptz AT TIME ZONE 'UTC') " + "AND \"startTime\" <= ($5::timestamptz AT TIME ZONE 'UTC') " + 'AND (api_key = $3 OR team_id = $3 OR "user" = $3 OR end_user = $3 OR session_id = $3 OR model_id = $3)))' + ) + assert condition.params == ("key-hash-7", start, end) + + +def _search_fixture_logs(today): + recent = (today - datetime.timedelta(days=1)).isoformat() + old = (today - datetime.timedelta(days=90)).isoformat() + base = { + "api_key": "hashed-other", + "user": "user-x", + "team_id": "team-x", + "end_user": "cust-x", + "session_id": "sess-x", + "model_id": "mdl-x", + "spend": 0.01, + "model": "gpt-4", + } + return [ + {**base, "request_id": "req-session", "session_id": "sess-42", "startTime": recent}, + {**base, "request_id": "req-session-old", "session_id": "sess-42", "startTime": old}, + {**base, "request_id": "req-key", "api_key": "hashed-7", "startTime": recent}, + {**base, "request_id": "req-team", "team_id": "team-7", "startTime": recent}, + {**base, "request_id": "req-user", "user": "user-7", "startTime": recent}, + {**base, "request_id": "req-end-user", "end_user": "cust-7", "startTime": recent}, + {**base, "request_id": "req-model", "model_id": "mdl-7", "startTime": recent}, + ] + + +def _search_filter_fn(logs, captured): + def filter_fn(where): + captured["where"] = where + rows = _filter_logs_by_date_range(logs, where) + if "user" in where: + rows = [row for row in rows if row["user"] == where["user"]] + if "search" in where: + rows = [row for row in rows if _matches_spend_log_search(row, where["search"])] + return rows + + return filter_fn + + +def _five_day_window(today): + return { + "start_date": (today - datetime.timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S"), + "end_date": today.strftime("%Y-%m-%d %H:%M:%S"), + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "search,expected_request_ids", + [ + ("req-session-old", {"req-session-old"}), + ("sess-42", {"req-session"}), + ("hashed-7", {"req-key"}), + ("team-7", {"req-team"}), + ("user-7", {"req-user"}), + ("cust-7", {"req-end-user"}), + ("mdl-7", {"req-model"}), + ("no-such-id", set()), + ], +) +async def test_ui_view_spend_logs_search_matches_any_id(client, monkeypatch, search, expected_request_ids): + """LIT-4741: one box matches any id column. A request_id is found across all time (the 5-day + window excludes the 90-day-old row), every other column only inside the window, and a raw + sk- key is hashed before it is compared with api_key. The window is not applied globally.""" + today = datetime.datetime.now(timezone.utc) + logs = _search_fixture_logs(today) + captured = {} + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(logs, _search_filter_fn(logs, captured)), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + try: + response = client.get( + "/spend/logs/ui", + params={"search": search, **_five_day_window(today)}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + data = response.json() + assert {row["request_id"] for row in data["data"]} == expected_request_ids + assert data["total"] == len(expected_request_ids) + assert "startTime" not in captured["where"] + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_spend_logs_v2_search_keeps_global_window(client, monkeypatch): + """The public route keeps the caller's window on the whole query, so a search only finds rows + inside it even by request_id; the windowless request_id branch is a dashboard-only relaxation.""" + today = datetime.datetime.now(timezone.utc) + logs = _search_fixture_logs(today) + captured = {} + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(logs, _search_filter_fn(logs, captured)), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + try: + response = client.get( + "/spend/logs/v2", + params={"search": "req-session-old", **_five_day_window(today)}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["data"] == [] + assert data["total"] == 0 + assert "startTime" in captured["where"] + assert captured["where"]["search"]["value"] == "req-session-old" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "params", + [ + {"search": "req-old"}, + {"search": "req-old", "request_id": "req-old"}, + ], +) +async def test_ui_view_spend_logs_search_requires_dates(client, monkeypatch, params): + """A search needs the window for its non-request_id branches, so it stays required even + alongside a request_id, which on its own may drop the window.""" + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma([], lambda where: []), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + try: + response = client.get("/spend/logs/ui", params=params, headers={"Authorization": "Bearer sk-test"}) + assert response.status_code == 400 + assert "date" in response.text.lower() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "search,expected_request_ids", + [("sess-9", {"req-own"}), ("req-foreign", set())], +) +async def test_ui_view_spend_logs_search_keeps_non_admin_scope(client, monkeypatch, search, expected_request_ids): + """A search is scoped like any other listing: an internal user only sees their own rows even + when the id is on someone else's row, and the request_id ownership shortcut is not used.""" + yesterday = (datetime.datetime.now(timezone.utc) - datetime.timedelta(days=1)).isoformat() + base = {"api_key": "hashed-key", "team_id": None, "spend": 0.01, "startTime": yesterday, "model": "gpt-4"} + logs = [ + {**base, "request_id": "req-own", "user": "internal_user_1", "session_id": "sess-9"}, + {**base, "request_id": "req-own-other", "user": "internal_user_1", "session_id": "sess-other"}, + {**base, "request_id": "req-foreign", "user": "internal_user_2", "session_id": "sess-9"}, + ] + captured = {} + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(logs, _search_filter_fn(logs, captured)), + ) + monkeypatch.setattr( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + AsyncMock(return_value=[]), + ) + ownership_check = AsyncMock() + monkeypatch.setattr( + "litellm.proxy.spend_tracking.spend_management_endpoints._assert_user_can_view_request_id", + ownership_check, + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="internal_user_1" + ) + try: + start_date, end_date = _default_date_range() + response = client.get( + "/spend/logs/ui", + params={"search": search, "start_date": start_date, "end_date": end_date}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + assert {row["request_id"] for row in response.json()["data"]} == expected_request_ids + assert captured["where"]["user"] == "internal_user_1" + ownership_check.assert_not_awaited() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + @pytest.mark.asyncio async def test_ui_view_spend_logs_unauthorized(client): # Test without authorization header @@ -3095,7 +3323,7 @@ def _compare_nested_dicts( return differences # Check for keys in actual but not in expected - for key in actual.keys(): + for key in actual: current_path = f"{path}.{key}" if path else key if current_path not in ignore_keys and key not in expected: differences.append(f"Extra key in actual: {current_path}") @@ -3265,24 +3493,22 @@ async def test_view_spend_logs_summarize_parameter(client, monkeypatch): # Return individual log entries when summarize=false return mock_spend_logs - async def group_by(self, *args, **kwargs): - # Return grouped data when summarize=true - # Simplified mock response for grouped data + async def query_raw(self, sql_query, *params): yesterday = datetime.datetime.now(timezone.utc) - timedelta(days=1) return [ { "api_key": "sk-test-key", "user": "test_user_1", "model": "gpt-3.5-turbo", - "startTime": yesterday.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - "_sum": {"spend": 0.05}, + "day": yesterday.date().isoformat(), + "spend": 0.05, }, { "api_key": "sk-test-key", "user": "test_user_1", "model": "gpt-4", - "startTime": yesterday.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - "_sum": {"spend": 0.10}, + "day": yesterday.date().isoformat(), + "spend": 0.10, }, ] @@ -3620,47 +3846,30 @@ async def test_view_spend_logs_with_date_range_summarized(client, monkeypatch): """ from datetime import datetime, timedelta, timezone - # This simulates the summarized data that Prisma's `group_by` would return. mock_summarized_response = [ { "api_key": "sk-test-key", "user": "test_user_1", "model": "gpt-4", - "startTime": (datetime.now(timezone.utc) - timedelta(days=1)).strftime( - "%Y-%m-%dT%H:%M:%S.%fZ" - ), - "_sum": {"spend": 0.15}, + "day": (datetime.now(timezone.utc) - timedelta(days=1)).date().isoformat(), + "spend": 0.15, } ] - # This mock class will replace the real Prisma client. class MockDB: - def __init__(self): - self.litellm_spendlogs = self - - async def group_by(self, *args, **kwargs): - # We assert that the `gte` and `lte` values are strings in ISO format. - # If they were datetime objects, this test would fail. - where_clause = kwargs.get("where", {}) - start_time_filter = where_clause.get("startTime", {}) - - assert "gte" in start_time_filter - assert "lte" in start_time_filter - assert isinstance(start_time_filter["gte"], str) - assert isinstance(start_time_filter["lte"], str) - assert "T" in start_time_filter["gte"] # Check for ISO format 'T' separator - - # If the assertions pass, return the mock response. + async def query_raw(self, sql_query, *params): + assert isinstance(params[0], str) + assert isinstance(params[1], str) + assert "T" in params[0] + assert "T" in params[1] return mock_summarized_response class MockPrismaClient: def __init__(self): self.db = MockDB() - # Apply the monkeypatch to replace the real prisma_client with our mock. monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrismaClient()) - # Define a date range for the test. start_date = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d") end_date = datetime.now(timezone.utc).strftime("%Y-%m-%d") @@ -3668,8 +3877,6 @@ async def test_view_spend_logs_with_date_range_summarized(client, monkeypatch): user_role=LitellmUserRoles.PROXY_ADMIN ) try: - # Call the endpoint with both start and end dates. - # We don't need `summarize=true` as it's the default. response = client.get( "/spend/logs", params={ @@ -3679,11 +3886,9 @@ async def test_view_spend_logs_with_date_range_summarized(client, monkeypatch): headers={"Authorization": "Bearer sk-test"}, ) - # ASSERTIONS assert response.status_code == 200 data = response.json() - # Check that the response is not empty and has the summarized structure. assert isinstance(data, list) assert len(data) > 0 assert "startTime" in data[0] @@ -3694,6 +3899,183 @@ async def test_view_spend_logs_with_date_range_summarized(client, monkeypatch): app.dependency_overrides.pop(ps.user_api_key_auth, None) +@pytest.mark.asyncio +async def test_view_spend_logs_summarize_groups_by_day_in_sql(client, monkeypatch): + mock_rows = [ + { + "day": "2024-01-01", + "api_key": "hashed::sk-abc", + "user": "u1", + "model": "gpt-4", + "spend": 0.1, + }, + { + "day": "2024-01-01", + "api_key": "hashed::sk-abc", + "user": "u1", + "model": "gpt-4o", + "spend": 0.2, + }, + ] + + class MockDB: + def __init__(self): + self.captured_sql = None + self.captured_params = None + + async def query_raw(self, sql_query, *params): + self.captured_sql = sql_query + self.captured_params = params + return mock_rows + + class MockPrismaClient: + def __init__(self): + self.db = MockDB() + + def hash_token(self, token): + return "hashed::" + token + + mock_prisma_client = MockPrismaClient() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + response = client.get( + "/spend/logs", + params={ + "start_date": "2024-01-01", + "end_date": "2024-01-03", + "api_key": "sk-abc", + "request_id": "req-123", + "user_id": "u1", + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + sql = mock_prisma_client.db.captured_sql + assert "date_trunc('day'" in sql + assert "GROUP BY" in sql + assert "find_many" not in sql + assert not hasattr(mock_prisma_client.db, "group_by") + assert mock_prisma_client.db.captured_params == ( + "2024-01-01T00:00:00+00:00", + "2024-01-03T00:00:00+00:00", + "hashed::sk-abc", + "req-123", + "u1", + ) + assert len(data) == 3 + assert data[0]["startTime"] == "2024-01-01" + assert data[0]["spend"] == pytest.approx(0.3) + assert data[0]["models"] == {"gpt-4": 0.1, "gpt-4o": 0.2} + assert data[0]["users"] == {"u1": pytest.approx(0.3)} + assert data[0]["hashed::sk-abc"] == pytest.approx(0.3) + assert data[1] == { + "startTime": "2024-01-02", + "spend": 0, + "users": {}, + "models": {}, + } + assert data[2] == { + "startTime": "2024-01-03", + "spend": 0, + "users": {}, + "models": {}, + } + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_view_spend_logs_summarize_empty_rows(client, monkeypatch): + class MockDB: + async def query_raw(self, sql_query, *params): + return [] + + class MockPrismaClient: + def __init__(self): + self.db = MockDB() + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrismaClient()) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + response = client.get( + "/spend/logs", + params={"start_date": "2024-01-01", "end_date": "2024-01-01"}, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + assert response.json() == [] + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_view_spend_logs_summarize_unhashed_api_key_without_padding(client, monkeypatch): + mock_rows = [ + { + "day": "2024-01-01", + "api_key": "plain-key", + "user": "u1", + "model": "gpt-4", + "spend": 0.4, + } + ] + + class MockDB: + def __init__(self): + self.captured_params = None + + async def query_raw(self, sql_query, *params): + self.captured_params = params + return mock_rows + + class MockPrismaClient: + def __init__(self): + self.db = MockDB() + + mock_prisma_client = MockPrismaClient() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + response = client.get( + "/spend/logs", + params={ + "start_date": "2024-01-01", + "end_date": "2024-01-01", + "api_key": "plain-key", + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert mock_prisma_client.db.captured_params == ( + "2024-01-01T00:00:00+00:00", + "2024-01-01T00:00:00+00:00", + "plain-key", + ) + assert data == [ + { + "startTime": "2024-01-01", + "spend": pytest.approx(0.4), + "plain-key": pytest.approx(0.4), + "users": {"u1": pytest.approx(0.4)}, + "models": {"gpt-4": pytest.approx(0.4)}, + } + ] + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + @pytest.mark.asyncio async def test_ui_view_spend_logs_with_error_code(client): """Test filtering spend logs by error code""" @@ -4602,13 +4984,14 @@ class _CaptureFilterDB: def __init__(self): self.litellm_spendlogs = self self.captured_where = None + self.captured_params = None async def find_many(self, *args, **kwargs): self.captured_where = kwargs.get("where") return [] - async def group_by(self, *args, **kwargs): - self.captured_where = kwargs.get("where") + async def query_raw(self, sql_query, *params): + self.captured_params = params return [] @@ -6351,3 +6734,46 @@ async def test_ui_view_spend_logs_group_by_session_offset_for_non_starttime_sort assert "OFFSET" in emitted_sql[1] finally: app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_search_returns_flat_rows_when_grouping_by_session(client, monkeypatch): + """The dashboard lists sessions by default; a search for an id lists every matching row instead, + so both calls of a session show up rather than one representative, and no session cursor is returned.""" + rows = [_session_representative_row("req-1", "sess-1"), _session_representative_row("req-2", "sess-1")] + + async def mock_query_raw(sql_query, *params): + if "mcp_tool_call_count" in sql_query: + return [] + grouped = "DISTINCT ON" in sql_query or "GROUP BY" in sql_query + visible = rows[:1] if grouped else rows + if "COUNT(*)" in sql_query: + return [{"total_count": len(visible)}] + return visible + + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.query_raw = AsyncMock(side_effect=mock_query_raw) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + try: + start_date, end_date = _default_date_range() + response = client.get( + "/spend/logs/ui", + params={ + "search": "sess-1", + "group_by_session": "true", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + data = response.json() + assert [row["request_id"] for row in data["data"]] == ["req-1", "req-2"] + assert data["total"] == 2 + assert "next_session_cursor" not in data + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py b/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py index 9ae932ff01f..a7de3f1d8d6 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py @@ -184,6 +184,7 @@ async def test_spend_logs_ui_wraps_params_in_at_time_zone_utc(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=1, @@ -247,6 +248,7 @@ async def test_spend_logs_ui_uses_bounded_count_not_full_scan(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=1, @@ -314,6 +316,7 @@ async def test_spend_logs_ui_caps_total_for_large_result_sets(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=1, @@ -359,6 +362,7 @@ async def test_spend_logs_ui_empty_page_reports_zero_total(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=1, @@ -406,6 +410,7 @@ async def test_spend_logs_ui_out_of_range_page_keeps_total(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=99, @@ -552,6 +557,7 @@ async def test_spend_logs_ui_group_by_session_paginates_sessions(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=1, @@ -616,6 +622,7 @@ async def test_spend_logs_ui_group_by_session_offset_pages_for_other_sorts(monke api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=2, @@ -664,6 +671,7 @@ async def test_spend_logs_ui_request_id_lookup_with_grouping_returns_exact_row(m api_key=None, user_id=None, request_id="req-deep-link", + search=None, start_date=None, end_date=None, page=1, diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 323930eee60..6a6db5ab7fe 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -3399,6 +3399,138 @@ def test_get_spend_logs_id_prefers_the_response_id_over_the_standard_logging_id( ) +@pytest.mark.asyncio +async def test_spend_log_request_id_is_the_message_id_a_bridged_streaming_caller_was_streamed(): + """A streaming /v1/messages call against a non-Anthropic model is served a msg_ id the + adapter mints itself, and it is the only request id that call ever shows the caller, so + GET /spend/logs?request_id=msg_... has to land on the row.""" + from litellm.litellm_core_utils.litellm_logging import Logging + from litellm.llms.anthropic.experimental_pass_through.responses_adapters.streaming_iterator import ( + AnthropicResponsesStreamWrapper, + ) + from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseCompletedEvent, + ResponsesAPIResponse, + ) + + logging_obj = Logging( + model="gpt-5.6", + messages=[{"role": "user", "content": "hi"}], + stream=True, + call_type="anthropic_messages", + start_time=datetime.datetime.now(timezone.utc), + litellm_call_id="6825cafe-0000-4000-8000-000000000001", + function_id="1234", + ) + logging_obj.optional_params = {} + + completed_response = ResponsesAPIResponse( + id="resp_01Lit6825Bridged", + object="response", + created_at=1767225600, + model="gpt-5.6", + status="completed", + output=[ + { + "id": "msg_bridged_output", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "epsilon", "annotations": []}], + } + ], + usage=ResponseAPIUsage(input_tokens=12, output_tokens=5, total_tokens=17), + ) + + async def _responses_stream(): + yield {"type": "response.created"} + yield {"type": "response.output_text.delta", "item_id": "msg_bridged_output", "delta": "epsilon"} + yield ResponseCompletedEvent(type="response.completed", response=completed_response) + + wrapper = AnthropicResponsesStreamWrapper( + responses_stream=_responses_stream(), + model="gpt-5.6", + litellm_logging_obj=logging_obj, + ) + sse_frames = [frame.decode() async for frame in wrapper.async_anthropic_sse_wrapper()] + + message_start_frames = [f for f in sse_frames if f.startswith("event: message_start\n")] + assert len(message_start_frames) == 1 + streamed_message_id = json.loads(message_start_frames[0].split("data: ", 1)[1])["message"]["id"] + assert streamed_message_id.startswith("msg_") + + _, _, logged_response = logging_obj._success_handler_helper_fn( + result=ResponseCompletedEvent(type="response.completed", response=completed_response), + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + ) + + assert logged_response.id == streamed_message_id + payload = get_logging_payload( + kwargs={ + "call_type": "anthropic_messages", + "model": "gpt-5.6", + "litellm_call_id": "6825cafe-0000-4000-8000-000000000001", + "litellm_params": {"metadata": {"user_api_key": "test-key"}}, + }, + response_obj=logged_response, + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + ) + assert payload["request_id"] == streamed_message_id + + +@pytest.mark.asyncio +async def test_spend_log_request_id_is_untouched_when_no_message_id_was_streamed(): + """Only the bridged streaming adapter mints a msg_ id of its own, so every other + /v1/messages call must keep the id its own response carried.""" + from litellm.litellm_core_utils.litellm_logging import Logging + from litellm.types.llms.openai import ( + ResponseAPIUsage, + ResponseCompletedEvent, + ResponsesAPIResponse, + ) + + logging_obj = Logging( + model="gpt-5.6", + messages=[{"role": "user", "content": "hi"}], + stream=False, + call_type="anthropic_messages", + start_time=datetime.datetime.now(timezone.utc), + litellm_call_id="6825cafe-0000-4000-8000-000000000002", + function_id="1234", + ) + logging_obj.optional_params = {} + + completed_response = ResponsesAPIResponse( + id="resp_01Lit6825Unbridged", + object="response", + created_at=1767225600, + model="gpt-5.6", + status="completed", + output=[ + { + "id": "msg_unbridged_output", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "epsilon", "annotations": []}], + } + ], + usage=ResponseAPIUsage(input_tokens=12, output_tokens=5, total_tokens=17), + ) + + _, _, logged_response = logging_obj._success_handler_helper_fn( + result=ResponseCompletedEvent(type="response.completed", response=completed_response), + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + ) + + assert logged_response.id + assert not logged_response.id.startswith("msg_") + + def test_batch_cost_row_does_not_collide_with_the_batch_creation_row(): """Creating a batch writes a row keyed by the batch's own id, so keying the cost row the same way makes the insert a duplicate of it. request_id is the primary key and the @@ -3956,3 +4088,207 @@ def test_caller_forged_router_metadata_is_discarded(bucket): ) metadata = json.loads(payload["metadata"]) assert metadata["router_metadata"] is None + + +ANTHROPIC_MESSAGES_RESPONSE: Final = { + "id": "msg_01Lit6806NonStreaming", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [{"type": "text", "text": "epsilon"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 14, "output_tokens": 4}, +} + +ANTHROPIC_MESSAGES_SSE_CHUNKS: Final = ( + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_01Lit6806Streaming",' + '"type":"message","role":"assistant","model":"claude-haiku-4-5","content":[],' + '"usage":{"input_tokens":14,"output_tokens":1}}}\n\n', + 'event: content_block_start\ndata: {"type":"content_block_start","index":0,' + '"content_block":{"type":"text","text":""}}\n\n', + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,' + '"delta":{"type":"text_delta","text":"epsilon"}}\n\n', + 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},' + '"usage":{"output_tokens":4}}\n\n', + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", +) + + +def _anthropic_messages_logging_obj(*, stream: bool) -> Any: + from litellm.litellm_core_utils.litellm_logging import Logging + + logging_obj = Logging( + model="claude-haiku-4-5", + messages=[{"role": "user", "content": "hi"}], + stream=stream, + call_type="anthropic_messages", + start_time=datetime.datetime.now(timezone.utc), + litellm_call_id="6806cafe-0000-4000-8000-000000000001", + function_id="1234", + ) + logging_obj.optional_params = {} + logging_obj.model_call_details["custom_llm_provider"] = "anthropic" + return logging_obj + + +def _spend_log_request_id(response_obj: Any, kwargs: dict) -> str: + payload = get_logging_payload( + kwargs=kwargs, + response_obj=response_obj, + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + ) + return payload["request_id"] + + +def test_spend_log_request_id_is_the_message_id_a_non_streaming_messages_caller_received(): + """ + POST /v1/messages hands the caller `id: msg_...`, the only request id they ever see, so + GET /spend/logs?request_id=msg_... has to find the row. + """ + logging_obj = _anthropic_messages_logging_obj(stream=False) + + logged_response = logging_obj._handle_anthropic_messages_response_logging( + result=ANTHROPIC_MESSAGES_RESPONSE + ) + + assert logged_response.id == "msg_01Lit6806NonStreaming" + assert ( + _spend_log_request_id( + response_obj=logged_response, + kwargs={ + "call_type": "anthropic_messages", + "model": "claude-haiku-4-5", + "litellm_call_id": "6806cafe-0000-4000-8000-000000000001", + "litellm_params": {"metadata": {"user_api_key": "test-key"}}, + }, + ) + == "msg_01Lit6806NonStreaming" + ) + + +def test_spend_log_request_id_is_the_message_id_a_streaming_messages_caller_received(): + """ + The streaming leg of /v1/messages logs through the Anthropic passthrough handler, which used + to stamp litellm_call_id over the msg_ id carried by the message_start event. + """ + from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( + AnthropicPassthroughLoggingHandler, + ) + from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType + + logging_obj = _anthropic_messages_logging_obj(stream=True) + logging_obj.model_call_details["stream"] = True + + logged = AnthropicPassthroughLoggingHandler._handle_logging_anthropic_collected_chunks( + litellm_logging_obj=logging_obj, + passthrough_success_handler_obj=MagicMock(), + url_route="/v1/messages", + request_body={"model": "claude-haiku-4-5"}, + endpoint_type=EndpointType.ANTHROPIC, + start_time=datetime.datetime.now(timezone.utc), + all_chunks=list(ANTHROPIC_MESSAGES_SSE_CHUNKS), + end_time=datetime.datetime.now(timezone.utc), + ) + + assert logged["result"].id == "msg_01Lit6806Streaming" + assert ( + _spend_log_request_id( + response_obj=logged["result"], + kwargs={ + **logged["kwargs"], + "call_type": "anthropic_messages", + "litellm_call_id": "6806cafe-0000-4000-8000-000000000001", + "litellm_params": {"metadata": {"user_api_key": "test-key"}}, + }, + ) + == "msg_01Lit6806Streaming" + ) + + +def test_spend_log_request_id_still_falls_back_to_litellm_call_id_without_a_provider_id(): + """ + Anthropic-compatible upstreams that omit `id` must keep landing on litellm_call_id rather + than on a fresh chatcmpl- uuid nobody can look up. + """ + logging_obj = _anthropic_messages_logging_obj(stream=True) + logging_obj.model_call_details["stream"] = True + + from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( + AnthropicPassthroughLoggingHandler, + ) + + AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload( + litellm_model_response=litellm.ModelResponse(id="chatcmpl-generated"), + model="claude-haiku-4-5", + kwargs={}, + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + logging_obj=logging_obj, + ) + assert logging_obj.model_call_details["complete_streaming_response"].id == ( + "6806cafe-0000-4000-8000-000000000001" + ) + + +def test_spend_log_request_id_for_chat_completions_is_untouched(): + """ + /v1/chat/completions callers look their rows up by the chatcmpl- id in the response body. + """ + assert ( + _spend_log_request_id( + response_obj=litellm.ModelResponse(id="chatcmpl-EJvWIw3DAhuKYuwp3jJI4Pnhp2vjv", choices=[]), + kwargs={ + "call_type": "acompletion", + "model": "gpt-5.6", + "litellm_call_id": "6806cafe-0000-4000-8000-000000000002", + "litellm_params": {"metadata": {"user_api_key": "test-key"}}, + }, + ) + == "chatcmpl-EJvWIw3DAhuKYuwp3jJI4Pnhp2vjv" + ) + + +def test_spend_log_request_id_is_the_response_id_a_bridged_messages_caller_received(): + """ + /v1/messages against a non-Anthropic model answers with the Responses id the caller then + looks their row up by, so the row must not fall back to a fresh chatcmpl- uuid. + """ + from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse + + logging_obj = _anthropic_messages_logging_obj(stream=False) + bridged_response = ResponsesAPIResponse( + id="resp_01Lit6806Bridged", + object="response", + created_at=1767225600, + model="gpt-5.6", + status="completed", + output=[ + { + "id": "msg_bridged_output", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "delta", "annotations": []}], + } + ], + usage=ResponseAPIUsage(input_tokens=13, output_tokens=5, total_tokens=18), + ) + + logged_response = logging_obj._handle_anthropic_messages_response_logging(result=bridged_response) + + assert logged_response.id == "resp_01Lit6806Bridged" + assert ( + _spend_log_request_id( + response_obj=logged_response, + kwargs={ + "call_type": "anthropic_messages", + "model": "gpt-5.6", + "litellm_call_id": "6806cafe-0000-4000-8000-000000000003", + "litellm_params": {"metadata": {"user_api_key": "test-key"}}, + }, + ) + == "resp_01Lit6806Bridged" + ) diff --git a/tests/test_litellm/proxy/test_blocked_response_usage.py b/tests/test_litellm/proxy/test_blocked_response_usage.py index 37aea8fe3aa..4f20f35e94b 100644 --- a/tests/test_litellm/proxy/test_blocked_response_usage.py +++ b/tests/test_litellm/proxy/test_blocked_response_usage.py @@ -68,12 +68,10 @@ async def test_success_hook_attaches_original_response_on_block(): user_api_key_dict = UserAPIKeyAuth(api_key="test", request_route="/chat/completions") data = {"guardrail_to_apply": guardrail, "model": "gpt-4o"} - # Inject our translation for the inferred call type (the module global is - # cached across tests, so patch it directly rather than the loader). with patch.object( ug, - "endpoint_guardrail_translation_mappings", - { + "load_guardrail_translation_mappings", + lambda: { CallTypes.acompletion: lambda: translation, CallTypes.completion: lambda: translation, }, diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 6d6aad22ca3..ea665b60b19 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -1540,8 +1540,8 @@ class TestCommonRequestProcessingHelpers: expected_error_data = { "error": { "message": "Error processing stream start", - "type": "None", - "param": "None", + "type": "internal_server_error", + "param": None, "code": str(status.HTTP_500_INTERNAL_SERVER_ERROR), } } @@ -1569,8 +1569,8 @@ class TestCommonRequestProcessingHelpers: expected_error_data = { "error": { "message": "Content blocked by guardrail", - "type": "None", - "param": "None", + "type": "invalid_request_error", + "param": None, "code": "400", } } @@ -1934,6 +1934,104 @@ class TestCommonRequestProcessingHelpers: assert mock_tracer.trace.call_count == 0 +def _stringified_none_paths(node: object, path: str = "error") -> tuple[str, ...]: + if isinstance(node, dict): + return tuple( + found + for key, value in node.items() + for found in _stringified_none_paths(value, f"{path}.{key}") + ) + if isinstance(node, (list, tuple)): + return tuple( + found + for index, value in enumerate(node) + for found in _stringified_none_paths(value, f"{path}[{index}]") + ) + return (path,) if node == "None" else () + + +def _blocked_guardrail_exception() -> HTTPException: + return HTTPException( + status_code=400, + detail={ + "error": "Violated guardrail policy", + "bedrock_guardrail_response": {"action": "GUARDRAIL_INTERVENED"}, + "guardrailIdentifier": "gf3sc1mzinjw", + "guardrailVersion": "DRAFT", + }, + ) + + +class TestGuardrailBlockErrorPayloadNeverStringifiesNone: + """Regression for LIT-6808: a blocked-guardrail error body carried the literal string + "None" for type and param instead of a real error type and JSON null.""" + + def test_non_streaming_block_payload_carries_a_real_type_and_null_param(self): + from litellm.proxy.common_request_processing import ( + proxy_exception_from_http_exception, + ) + + payload = json.loads( + json.dumps(proxy_exception_from_http_exception(_blocked_guardrail_exception(), {}).to_dict()) + ) + + assert _stringified_none_paths(payload) == () + assert payload["type"] == "invalid_request_error" + assert payload["param"] is None + assert payload["code"] == "400" + assert payload["message"] == "Violated guardrail policy" + + def test_streaming_block_frame_carries_a_real_type_and_null_param(self): + from litellm.proxy.common_request_processing import sse_error_payload + + error_status, error_obj = sse_error_payload(_blocked_guardrail_exception()) + frame = json.loads(json.dumps({"error": dict(error_obj)})) + + assert error_status == 400 + assert _stringified_none_paths(frame["error"]) == () + assert frame["error"]["type"] == "invalid_request_error" + assert frame["error"]["param"] is None + assert frame["error"]["code"] == "400" + + @pytest.mark.parametrize( + "status_code, expected_type", + [ + (400, "invalid_request_error"), + (401, "authentication_error"), + (403, "permission_error"), + (404, "invalid_request_error"), + (429, "rate_limit_error"), + (500, "internal_server_error"), + (503, "internal_server_error"), + ], + ) + def test_status_code_decides_the_type_when_the_exception_carries_none(self, status_code, expected_type): + from litellm.proxy.common_request_processing import ( + proxy_exception_from_http_exception, + ) + + payload = proxy_exception_from_http_exception( + HTTPException(status_code=status_code, detail="blocked"), {} + ).to_dict() + + assert payload["type"] == expected_type + assert payload["param"] is None + + def test_a_type_and_param_the_exception_carries_win_over_the_fallback(self): + from litellm.proxy.common_request_processing import ( + proxy_exception_from_http_exception, + ) + + exc = HTTPException(status_code=400, detail="unknown model") + exc.type = "authentication_error" + exc.param = "model" + + payload = proxy_exception_from_http_exception(exc, {}).to_dict() + + assert payload["type"] == "authentication_error" + assert payload["param"] == "model" + + class TestExtractErrorFromSSEChunk: """Tests for _extract_error_from_sse_chunk function""" @@ -2999,6 +3097,25 @@ class TestHandleLLMApiExceptionDictDetail: assert proxy_exc.message == "Content blocked by guardrail" assert proxy_exc.provider_specific_fields is None + async def test_blocked_guardrail_error_body_never_carries_the_string_none(self): + """Regression for LIT-6808: the error body a blocked request returns must carry a real + error type and JSON null rather than the literal string "None".""" + proxy_exc = await self._invoke(_blocked_guardrail_exception()) + payload = json.loads(json.dumps(proxy_exc.to_dict())) + + assert _stringified_none_paths(payload) == () + assert payload["type"] == "invalid_request_error" + assert payload["param"] is None + + async def test_unclassified_exception_error_body_never_carries_the_string_none(self): + """The same holds on the generic fallback, where nothing carries a type at all.""" + proxy_exc = await self._invoke(ValueError("Something broke")) + payload = json.loads(json.dumps(proxy_exc.to_dict())) + + assert _stringified_none_paths(payload) == () + assert payload["type"] == "internal_server_error" + assert payload["param"] is None + async def test_not_found_error_preserves_404(self): """NotFoundError with status_code=404 should map to ProxyException code=404.""" from litellm.exceptions import NotFoundError diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 4ed6a468371..aef045b4709 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -12065,10 +12065,15 @@ async def test_init_prompts_in_db_reloads_rows_patched_on_another_worker(monkeyp } return row - def served_content() -> str: - callback = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("greeting_sync.v1") + def served_callback(): + spec = IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec("greeting_sync") + assert spec is not None + callback = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_for_prompt(prompt=spec) assert callback is not None - return callback.prompt_manager.get_prompt("greeting_sync").content + return callback + + def served_content() -> str: + return served_callback().prompt_manager.get_prompt("greeting_sync").content prisma_client = MagicMock() try: @@ -12080,7 +12085,7 @@ async def test_init_prompts_in_db_reloads_rows_patched_on_another_worker(monkeyp await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) assert served_content() == "Begin every reply with HOWDY" - assert litellm.callbacks == [IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("greeting_sync.v1")] + assert litellm.callbacks == [served_callback()] finally: IN_MEMORY_PROMPT_REGISTRY.delete_prompts_by_base_id("greeting_sync") @@ -12119,22 +12124,25 @@ async def test_init_prompts_in_db_syncs_remaining_rows_when_one_row_fails(monkey ) await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) - assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id("broken_sync.v1") is None - assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("healthy_sync.v1") is not None - assert litellm.callbacks == [IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("healthy_sync.v1")] + assert IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec("broken_sync") is None + healthy_spec = IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec("healthy_sync") + assert healthy_spec is not None + healthy_callback = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_for_prompt(prompt=healthy_spec) + assert healthy_callback is not None + assert litellm.callbacks == [healthy_callback] finally: IN_MEMORY_PROMPT_REGISTRY.delete_prompts_by_base_id("healthy_sync") IN_MEMORY_PROMPT_REGISTRY.delete_prompts_by_base_id("broken_sync") @pytest.mark.asyncio -async def test_init_prompts_in_db_serves_the_newest_row_when_environments_collide_on_a_versioned_id(monkeypatch): +async def test_init_prompts_in_db_syncs_every_environment_sharing_a_versioned_id(monkeypatch): from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY from litellm.proxy.proxy_server import ProxyConfig monkeypatch.setattr(litellm, "callbacks", []) - def db_row(environment: str, content: str, updated_at: datetime) -> MagicMock: + def db_row(environment: str, content: str) -> MagicMock: row = MagicMock() row.model_dump.return_value = { "prompt_id": "greeting_env", @@ -12150,30 +12158,41 @@ async def test_init_prompts_in_db_serves_the_newest_row_when_environments_collid ), "prompt_info": json.dumps({"prompt_type": "db"}), "created_at": None, - "updated_at": updated_at, + "updated_at": None, } return row - freshly_patched = db_row( - "production", "Begin every reply with HOWDY", datetime(2026, 8, 26, 12, 0, tzinfo=timezone.utc) - ) - stale_sibling = db_row( - "development", "Begin every reply with AHOY", datetime(2026, 8, 26, 11, 0, tzinfo=timezone.utc) - ) + def served_content(environment: str | None) -> str: + spec = IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec("greeting_env", environment=environment) + assert spec is not None + callback = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_for_prompt(prompt=spec) + assert callback is not None + return callback.prompt_manager.get_prompt("greeting_env").content prisma_client = MagicMock() try: - prisma_client.db.litellm_prompttable.find_many = AsyncMock(return_value=[freshly_patched, stale_sibling]) + prisma_client.db.litellm_prompttable.find_many = AsyncMock( + return_value=[ + db_row("development", "Begin every reply with AHOY"), + db_row("production", "Begin every reply with HOWDY"), + ] + ) await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) - first_callback = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("greeting_env.v1") - assert first_callback is not None - assert first_callback.prompt_manager.get_prompt("greeting_env").content == "Begin every reply with HOWDY" + assert served_content("development") == "Begin every reply with AHOY" + assert served_content("production") == "Begin every reply with HOWDY" + assert served_content(None) == "Begin every reply with HOWDY" + prisma_client.db.litellm_prompttable.find_many = AsyncMock( + return_value=[ + db_row("development", "Begin every reply with YO"), + db_row("production", "Begin every reply with HOWDY"), + ] + ) await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) - assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("greeting_env.v1") is first_callback - assert litellm.callbacks == [first_callback] + assert served_content("development") == "Begin every reply with YO" + assert served_content("production") == "Begin every reply with HOWDY" finally: IN_MEMORY_PROMPT_REGISTRY.delete_prompts_by_base_id("greeting_env") @@ -12216,13 +12235,14 @@ async def test_init_prompts_in_db_unloads_rows_deleted_on_another_worker(monkeyp return_value=[_prompt_db_row("greeting_del", _dotprompt_params("greeting_del"))] ) await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) - assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("greeting_del.v1") is not None + loaded_spec = IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec("greeting_del") + assert loaded_spec is not None + assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_for_prompt(prompt=loaded_spec) is not None prisma_client.db.litellm_prompttable.find_many = AsyncMock(return_value=[]) await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) - assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id("greeting_del.v1") is None - assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("greeting_del.v1") is None + assert IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec("greeting_del") is None assert litellm.callbacks == [] finally: IN_MEMORY_PROMPT_REGISTRY.delete_prompts_by_base_id("greeting_del") @@ -12253,10 +12273,12 @@ async def test_init_prompts_in_db_keeps_config_prompts_when_their_id_has_no_db_r await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) - assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("greeting_cfg") is not None + surviving_spec = IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec("greeting_cfg") + assert surviving_spec is not None + assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_for_prompt(prompt=surviving_spec) is not None assert len(litellm.callbacks) == 1 finally: - IN_MEMORY_PROMPT_REGISTRY.remove_prompt(prompt_id="greeting_cfg") + IN_MEMORY_PROMPT_REGISTRY.delete_prompts_by_base_id("greeting_cfg") @pytest.mark.asyncio @@ -12272,7 +12294,9 @@ async def test_init_prompts_in_db_keeps_the_in_memory_copy_when_a_row_fails_to_p return_value=[_prompt_db_row("greeting_broken", _dotprompt_params("greeting_broken"))] ) await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) - loaded_callback = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("greeting_broken.v1") + loaded_spec = IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec("greeting_broken") + assert loaded_spec is not None + loaded_callback = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_for_prompt(prompt=loaded_spec) assert loaded_callback is not None prisma_client.db.litellm_prompttable.find_many = AsyncMock( @@ -12280,7 +12304,9 @@ async def test_init_prompts_in_db_keeps_the_in_memory_copy_when_a_row_fails_to_p ) await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) - assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("greeting_broken.v1") is loaded_callback + kept_spec = IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec("greeting_broken") + assert kept_spec is not None + assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_for_prompt(prompt=kept_spec) is loaded_callback assert litellm.callbacks == [loaded_callback] finally: IN_MEMORY_PROMPT_REGISTRY.delete_prompts_by_base_id("greeting_broken") @@ -12314,8 +12340,9 @@ async def test_init_prompts_in_db_keeps_a_prompt_created_while_the_sync_was_read prisma_client.db.litellm_prompttable.find_many = AsyncMock(side_effect=create_prompt_behind_the_select) await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) - surviving_callback = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_by_id("greeting_race.v1") - assert IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id("greeting_race.v1") is not None + surviving_spec = IN_MEMORY_PROMPT_REGISTRY.resolve_prompt_spec("greeting_race") + assert surviving_spec is not None + surviving_callback = IN_MEMORY_PROMPT_REGISTRY.get_prompt_callback_for_prompt(prompt=surviving_spec) assert surviving_callback is not None assert litellm.callbacks == [surviving_callback] finally: diff --git a/tests/test_litellm/proxy/utils/proxy_logging/test_guardrail_pipeline.py b/tests/test_litellm/proxy/utils/proxy_logging/test_guardrail_pipeline.py index e99e34d65d4..2ba58bd5644 100644 --- a/tests/test_litellm/proxy/utils/proxy_logging/test_guardrail_pipeline.py +++ b/tests/test_litellm/proxy/utils/proxy_logging/test_guardrail_pipeline.py @@ -726,10 +726,7 @@ async def test_process_prompt_template_no_op_when_no_prompt_spec(proxy_logging, from litellm.proxy.prompts import prompt_registry monkeypatch.setattr( - prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "get_prompt_callback_by_id", lambda *a, **kw: None - ) - monkeypatch.setattr( - prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "get_prompt_by_id", lambda *a, **kw: None + prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "resolve_prompt_spec", lambda *a, **kw: None ) data: Dict[str, Any] = {"messages": [{"role": "user"}], "model": "m", "temperature": 0.1} await proxy_logging._process_prompt_template( @@ -752,11 +749,11 @@ async def test_process_prompt_template_applies_when_spec_resolves(proxy_logging, monkeypatch.setattr( prompt_registry.IN_MEMORY_PROMPT_REGISTRY, - "get_prompt_callback_by_id", + "get_prompt_callback_for_prompt", lambda *a, **kw: custom_logger, ) monkeypatch.setattr( - prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "get_prompt_by_id", lambda *a, **kw: prompt_spec + prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "resolve_prompt_spec", lambda *a, **kw: prompt_spec ) logging_obj = MagicMock() @@ -802,11 +799,11 @@ async def test_process_prompt_template_async_get_prompt_error_raises(proxy_loggi prompt_spec.litellm_params = MagicMock(prompt_id="x") monkeypatch.setattr( prompt_registry.IN_MEMORY_PROMPT_REGISTRY, - "get_prompt_callback_by_id", + "get_prompt_callback_for_prompt", lambda *a, **kw: custom_logger, ) monkeypatch.setattr( - prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "get_prompt_by_id", lambda *a, **kw: prompt_spec + prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "resolve_prompt_spec", lambda *a, **kw: prompt_spec ) logging_obj = MagicMock() logging_obj.async_get_chat_completion_prompt = AsyncMock(side_effect=RuntimeError("bad prompt")) @@ -820,6 +817,82 @@ async def test_process_prompt_template_async_get_prompt_error_raises(proxy_loggi ) +@pytest.mark.asyncio +async def test_process_prompt_template_resolves_the_requested_environment(proxy_logging, monkeypatch): + from litellm.proxy.prompts import prompt_registry + + prompt_spec = MagicMock() + prompt_spec.litellm_params = MagicMock(prompt_id="greeting") + resolve_calls: list[dict] = [] + + def fake_resolve(prompt_id, version=None, environment=None): + resolve_calls.append({"prompt_id": prompt_id, "version": version, "environment": environment}) + return prompt_spec + + monkeypatch.setattr(prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "resolve_prompt_spec", fake_resolve) + monkeypatch.setattr( + prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "get_prompt_callback_for_prompt", lambda *a, **kw: MagicMock() + ) + logging_obj = MagicMock() + logging_obj.async_get_chat_completion_prompt = AsyncMock( + return_value=("m", [{"role": "user", "content": "rendered"}], {}) + ) + data: Dict[str, Any] = { + "messages": [{"role": "user", "content": "orig"}], + "model": "m", + "prompt_id": "greeting", + "prompt_version": 1, + "prompt_environment": "development", + } + await proxy_logging._process_prompt_template( + data=data, + litellm_logging_obj=logging_obj, + prompt_id="greeting", + prompt_version=1, + call_type="completion", + ) + + assert resolve_calls == [{"prompt_id": "greeting", "version": 1, "environment": "development"}] + assert "prompt_environment" not in data + assert "prompt_id" not in data + assert data["messages"] == [{"role": "user", "content": "rendered"}] + + +@pytest.mark.asyncio +async def test_pre_call_hook_matches_a_prompt_version_sent_as_a_json_string(proxy_logging, monkeypatch): + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.prompts import prompt_registry + + prompt_spec = MagicMock() + prompt_spec.litellm_params = MagicMock(prompt_id="greeting") + resolve_calls: list[dict] = [] + + def fake_resolve(prompt_id, version=None, environment=None): + resolve_calls.append({"prompt_id": prompt_id, "version": version, "environment": environment}) + return prompt_spec + + monkeypatch.setattr(prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "resolve_prompt_spec", fake_resolve) + monkeypatch.setattr( + prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "get_prompt_callback_for_prompt", lambda *a, **kw: MagicMock() + ) + logging_obj = MagicMock() + logging_obj.async_get_chat_completion_prompt = AsyncMock( + return_value=("m", [{"role": "user", "content": "rendered"}], {}) + ) + data: Dict[str, Any] = { + "messages": [{"role": "user", "content": "orig"}], + "model": "m", + "prompt_id": "greeting", + "prompt_version": "2", + "litellm_logging_obj": logging_obj, + } + + result = await proxy_logging.pre_call_hook(user_api_key_dict=UserAPIKeyAuth(), data=data, call_type="completion") + + assert resolve_calls == [{"prompt_id": "greeting", "version": 2, "environment": None}] + assert result["messages"] == [{"role": "user", "content": "rendered"}] + + @pytest.mark.asyncio async def test_process_prompt_template_aresponses_swaps_model_and_merges_input(proxy_logging, monkeypatch): from litellm.proxy.prompts import prompt_registry @@ -829,11 +902,11 @@ async def test_process_prompt_template_aresponses_swaps_model_and_merges_input(p prompt_spec.litellm_params = MagicMock(prompt_id="resolved-id") monkeypatch.setattr( prompt_registry.IN_MEMORY_PROMPT_REGISTRY, - "get_prompt_callback_by_id", + "get_prompt_callback_for_prompt", lambda *a, **kw: custom_logger, ) monkeypatch.setattr( - prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "get_prompt_by_id", lambda *a, **kw: prompt_spec + prompt_registry.IN_MEMORY_PROMPT_REGISTRY, "resolve_prompt_spec", lambda *a, **kw: prompt_spec ) logging_obj = MagicMock() diff --git a/tests/test_litellm/rag/test_main.py b/tests/test_litellm/rag/test_main.py index 51d03544910..264bcd6fb75 100644 --- a/tests/test_litellm/rag/test_main.py +++ b/tests/test_litellm/rag/test_main.py @@ -388,6 +388,72 @@ async def test_aquery_does_not_forward_connection_override_keys_to_search(): assert not (blocked & set(search_kwargs.keys())) +@pytest.mark.asyncio +async def test_aquery_forwards_vector_store_params_to_search_but_not_completion(): + """ + Regression for LIT-6773: the server-trusted vector_store_params (a managed + store's litellm_params) must reach the search call wholesale, including the + connection keys the caller allowlist blocks, while the caller's own + retrieval_config overrides stay blocked, the caller's top-level api_key and + api_base stay on the completion only, and the completion never inherits the + store's connection params. + """ + from unittest.mock import AsyncMock + + from litellm.types.vector_stores import VectorStoreSearchResponse + + fake_search = AsyncMock( + return_value=VectorStoreSearchResponse( + object="vector_store.search_results.page", search_query="q", data=[] + ) + ) + fake_completion = AsyncMock( + return_value=ModelResponse( + id="chatcmpl-test", + choices=[{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}], + model="gpt-4o-mini", + ) + ) + with ( + patch("litellm.vector_stores.asearch", new=fake_search), # test-quality-ok: the search boundary under test + patch("litellm.acompletion", new=fake_completion), # test-quality-ok: the completion boundary under test + ): + await litellm.aquery( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "hello"}], + api_key="sk-llm-key", + api_base="https://llm.example.com", + retrieval_config={ + "vector_store_id": "customer_kb", + "custom_llm_provider": "milvus", + "api_base": "https://attacker.example.com", + "api_key": "attacker-key", + }, + vector_store_params={ + "vector_store_id": "customer_kb", + "custom_llm_provider": "milvus", + "api_base": "http://127.0.0.1:19530", + "api_key": "root:Milvus", + "milvus_text_field": "book_intro_text", + "outputFields": ["book_intro_text"], + }, + ) + + fake_search.assert_awaited_once() + search_kwargs = fake_search.await_args.kwargs + assert search_kwargs["vector_store_id"] == "customer_kb" + assert search_kwargs["custom_llm_provider"] == "milvus" + assert search_kwargs["api_base"] == "http://127.0.0.1:19530" + assert search_kwargs["api_key"] == "root:Milvus" + assert search_kwargs["milvus_text_field"] == "book_intro_text" + assert search_kwargs["outputFields"] == ["book_intro_text"] + fake_completion.assert_awaited_once() + completion_kwargs = fake_completion.await_args.kwargs + assert completion_kwargs["api_key"] == "sk-llm-key" + assert completion_kwargs["api_base"] == "https://llm.example.com" + assert not ({"milvus_text_field", "outputFields"} & set(completion_kwargs)) + + def test_rag_call_types_are_registered(): """ query/aquery/ingest/aingest are @client-decorated entry points, so their diff --git a/tests/test_litellm/rust_bridge/native_route_wheel_test.py b/tests/test_litellm/rust_bridge/native_route_wheel_test.py index a89a985952b..a7f50a82a99 100644 --- a/tests/test_litellm/rust_bridge/native_route_wheel_test.py +++ b/tests/test_litellm/rust_bridge/native_route_wheel_test.py @@ -181,14 +181,6 @@ def assert_success(route: str, response: object) -> None: raise AssertionError(f"{route} returned {actual!r}, expected {expected!r}") -def assert_traced_success(route: str, response: object) -> None: - if not isinstance(response, dict): - raise TypeError(f"{route} returned {type(response).__name__}, expected a traced dict") - assert_success(route, response["response"]) - expected_function: Final = "audio_transcription" if route == "transcription" else route - assert response["trace"][0] == {"function": expected_function, "depth": 0} - - def success_value(route: str, response: dict[object, object]) -> object: if route == "ocr": return response["pages"][0]["markdown"] @@ -213,7 +205,6 @@ def exercise_sync(native: object, api_base: str) -> None: for route in ("ocr", "transcription", "messages", "chat_completions"): function: Final = getattr(native, route) assert_success(route, function(**route_kwargs(route, api_base, "success"))) - assert_traced_success(route, function(**route_kwargs(route, api_base, "success"), trace=True)) try: function(**route_kwargs(route, api_base, "429")) except (RuntimeError, native.RustUpstreamError) as error: @@ -226,7 +217,6 @@ async def exercise_async(native: object, api_base: str) -> None: for route in ("ocr", "transcription", "messages", "chat_completions"): function: Final = getattr(native, f"a{route}") assert_success(route, await function(**route_kwargs(route, api_base, "success"))) - assert_traced_success(route, await function(**route_kwargs(route, api_base, "success"), trace=True)) try: await function(**route_kwargs(route, api_base, "429")) except (RuntimeError, native.RustUpstreamError) as error: @@ -251,6 +241,8 @@ async def exercise_async_concurrency(native: object, api_base: str) -> None: def exercise_routes(native_path: Path, api_base: str) -> object: native: Final = load_native(native_path) + if hasattr(native, "_trace"): + raise AssertionError("release wheel exposed trace-parity diagnostics") exercise_sync(native, api_base) asyncio.run(exercise_async(native, api_base)) asyncio.run(exercise_async_concurrency(native, api_base)) diff --git a/tests/test_litellm/secret_managers/test_get_azure_ad_token_provider.py b/tests/test_litellm/secret_managers/test_get_azure_ad_token_provider.py index c9ec22ab0df..4bc7c21d8d2 100644 --- a/tests/test_litellm/secret_managers/test_get_azure_ad_token_provider.py +++ b/tests/test_litellm/secret_managers/test_get_azure_ad_token_provider.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch # Adds the grandparent directory to sys.path to allow importing project modules import pytest +from azure.core.exceptions import ClientAuthenticationError from litellm.secret_managers.get_azure_ad_token_provider import ( get_azure_ad_token_provider, @@ -16,6 +17,143 @@ from litellm.types.secret_managers.get_azure_ad_token_provider import ( ) +class TestDeploymentIdentityCredential: + @staticmethod + def _chain_for(credential_type): + with patch("azure.identity.get_bearer_token_provider", return_value=lambda: "token") as bearer: + get_azure_ad_token_provider( + azure_scope="https://storage.azure.com/.default", + azure_credential=credential_type, + ) + bearer.assert_called_once() + with bearer.call_args.args[0] as chain: + return {type(link).__name__ for link in chain.credentials} + + @staticmethod + def _managed_identity_client_ids(credential_type): + with patch("azure.identity.get_bearer_token_provider", return_value=lambda: "token") as bearer: + get_azure_ad_token_provider( + azure_scope="https://storage.azure.com/.default", + azure_credential=credential_type, + ) + with bearer.call_args.args[0] as chain: + return [ + (link._credential._settings or {}).get("client_id") + for link in chain.credentials + if type(link).__name__ == "ManagedIdentityCredential" + ] + + @patch.dict( + os.environ, + { + "AZURE_CLIENT_ID": "workload-identity-client-id", + "AZURE_TENANT_ID": "workload-identity-tenant-id", + "AZURE_FEDERATED_TOKEN_FILE": "/var/run/secrets/azure/tokens/azure-identity-token", + }, + clear=True, + ) + def test_deployment_identity_reaches_workload_and_managed_identity_only(self): + assert self._chain_for(AzureCredentialType.DeploymentIdentityCredential) == { + "WorkloadIdentityCredential", + "ManagedIdentityCredential", + } + + @patch.dict( + os.environ, + { + "AZURE_CLIENT_ID": "workload-identity-client-id", + "AZURE_TENANT_ID": "workload-identity-tenant-id", + "AZURE_FEDERATED_TOKEN_FILE": "/var/run/secrets/azure/tokens/azure-identity-token", + "AZURE_TOKEN_CREDENTIALS": "dev", + }, + clear=True, + ) + def test_deployment_identity_survives_a_developer_only_token_credentials_setting(self): + """AZURE_TOKEN_CREDENTIALS=dev asks the SDK for developer credentials only, which is every + credential this chain drops, so the deployment's own identity has to win over it""" + assert self._chain_for(AzureCredentialType.DeploymentIdentityCredential) == { + "WorkloadIdentityCredential", + "ManagedIdentityCredential", + } + + @patch.dict( + os.environ, + { + "AZURE_CLIENT_ID": "azure-openai-client-id", + "AZURE_CLIENT_SECRET": "azure-openai-client-secret", + "AZURE_TENANT_ID": "azure-openai-tenant-id", + }, + clear=True, + ) + def test_default_azure_credential_keeps_its_full_chain(self): + """Azure OpenAI callers pass DefaultAzureCredential and must be unaffected by the + narrowing that the storage callback asks for""" + full_chain = self._chain_for(AzureCredentialType.DefaultAzureCredential) + + assert "EnvironmentCredential" in full_chain + assert "AzureCliCredential" in full_chain + assert "EnvironmentCredential" not in self._chain_for( + AzureCredentialType.DeploymentIdentityCredential + ) + + @patch.dict( + os.environ, + { + "AZURE_CLIENT_ID": "azure-openai-client-id", + "AZURE_CLIENT_SECRET": "azure-openai-client-secret", + "AZURE_TENANT_ID": "azure-openai-tenant-id", + }, + clear=True, + ) + def test_deployment_identity_refuses_to_mint_a_token_for_a_configured_service_principal(self): + """A host carrying only an Azure OpenAI client secret must get no token at all, and the + refusal must name the identities that were actually tried""" + provider = get_azure_ad_token_provider( + azure_scope="https://storage.azure.com/.default", + azure_credential=AzureCredentialType.DeploymentIdentityCredential, + ) + + with pytest.raises(ClientAuthenticationError) as refusal: + provider() + + assert "ManagedIdentityCredential" in str(refusal.value) + assert "EnvironmentCredential" not in str(refusal.value) + assert "AzureCliCredential" not in str(refusal.value) + assert "azure-openai-client-secret" not in str(refusal.value) + + @patch.dict( + os.environ, + { + "AZURE_CLIENT_ID": "azure-openai-client-id", + "AZURE_CLIENT_SECRET": "azure-openai-client-secret", + "AZURE_TENANT_ID": "azure-openai-tenant-id", + }, + clear=True, + ) + def test_deployment_identity_still_reaches_a_system_assigned_managed_identity(self): + """AZURE_CLIENT_ID names one identity for the whole proxy, and pointing it at Azure OpenAI + must not hide the system assigned identity the host runs as""" + client_ids = self._managed_identity_client_ids(AzureCredentialType.DeploymentIdentityCredential) + + assert "azure-openai-client-id" in client_ids + assert None in client_ids + + @patch.dict( + os.environ, + { + "AZURE_CLIENT_ID": "user-assigned-identity-client-id", + "AZURE_TOKEN_CREDENTIALS": "dev", + }, + clear=True, + ) + def test_deployment_identity_keeps_the_user_assigned_identity_under_a_dev_only_setting(self): + """AZURE_TOKEN_CREDENTIALS=dev asks the SDK for developer credentials only, and the + identity a host actually runs as has to survive that""" + assert "user-assigned-identity-client-id" in self._managed_identity_client_ids( + AzureCredentialType.DeploymentIdentityCredential + ) + + class TestGetAzureAdTokenProvider: @patch.dict( os.environ, diff --git a/tests/test_litellm/test_azure_ai_grok_4_6_model_metadata.py b/tests/test_litellm/test_azure_ai_grok_4_6_model_metadata.py new file mode 100644 index 00000000000..92af1b1dba4 --- /dev/null +++ b/tests/test_litellm/test_azure_ai_grok_4_6_model_metadata.py @@ -0,0 +1,55 @@ +from pathlib import Path +from typing import Final + +import pytest +from pydantic import TypeAdapter + +from litellm import cost_per_token, get_model_info +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + +REPO_ROOT: Final = Path(__file__).parents[2] +MODEL: Final = "azure_ai/grok-4.6" +SOURCE: Final = ( + "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/" + "grok-4-6-comes-to-microsoft-foundry-models-built-for-long-horizon-reasoning-and-/4547578" +) +COST_MAP_ADAPTER: Final = TypeAdapter(dict[str, dict[str, object]]) + + +def _cost_map_entry(path: Path) -> dict[str, object]: + return COST_MAP_ADAPTER.validate_json(path.read_bytes())[MODEL] + + +@pytest.mark.usefixtures("local_model_cost_map") +def test_azure_ai_grok_4_6_is_priced_and_routed() -> None: + routed_model, provider, _, _ = get_llm_provider(model=MODEL) + assert (routed_model, provider) == ("grok-4.6", "azure_ai") + + info = get_model_info(model=routed_model, custom_llm_provider=provider) + assert info["litellm_provider"] == "azure_ai" + assert info["mode"] == "chat" + assert info["input_cost_per_token"] == 2e-06 + assert info["output_cost_per_token"] == 6e-06 + assert info["cache_read_input_token_cost"] == 5e-07 + assert info["max_input_tokens"] == 200000 + assert info["max_output_tokens"] == 128000 + assert info["max_tokens"] == 128000 + assert info["supports_function_calling"] is True + assert info["supports_prompt_caching"] is True + assert info["supports_reasoning"] is True + assert info["supports_response_schema"] is True + assert info["supports_tool_choice"] is True + assert info["supports_vision"] is True + assert info["supports_web_search"] is True + + prompt_cost, completion_cost = cost_per_token(model=MODEL, prompt_tokens=1_000_000, completion_tokens=1_000_000) + assert prompt_cost == pytest.approx(2.0) + assert completion_cost == pytest.approx(6.0) + + +def test_azure_ai_grok_4_6_entry_source_and_backup_match() -> None: + main_entry = _cost_map_entry(REPO_ROOT / "model_prices_and_context_window.json") + backup_entry = _cost_map_entry(REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json") + + assert main_entry["source"] == SOURCE + assert backup_entry == main_entry diff --git a/tests/test_litellm/test_component_entrypoint.py b/tests/test_litellm/test_component_entrypoint.py index 2536b76e91c..b0969e2c694 100644 --- a/tests/test_litellm/test_component_entrypoint.py +++ b/tests/test_litellm/test_component_entrypoint.py @@ -7,7 +7,6 @@ import re import stat import subprocess from pathlib import Path -from typing import Optional import pytest @@ -16,11 +15,15 @@ COMPONENT_ENTRYPOINT = REPO_ROOT / "docker" / "component_entrypoint.sh" PROD_ENTRYPOINT = REPO_ROOT / "docker" / "prod_entrypoint.sh" GATEWAY_DOCKERFILE = REPO_ROOT / "gateway" / "Dockerfile" BACKEND_DOCKERFILE = REPO_ROOT / "backend" / "Dockerfile" +BUILD_FROM_PIP_DOCKERFILE = REPO_ROOT / "docker" / "build_from_pip" / "Dockerfile.build_from_pip" TERRAFORM_ECS = REPO_ROOT / "terraform" / "litellm" / "aws" / "ecs.tf" TERRAFORM_CLOUDRUN = REPO_ROOT / "terraform" / "litellm" / "gcp" / "cloudrun.tf" IMAGE_ENTRYPOINT_PATH = "/app/docker/component_entrypoint.sh" +TRUTHY_USE_DDTRACE = ("true", "True", "TRUE", "tRuE") +FALSY_USE_DDTRACE = (None, "", "false", "False", "1", "yes", "on", "truex") + PYTHONPATH_SENTINEL = "/lit-entrypoint-sentinel:/app" _STUB_TEMPLATE = """#!/bin/sh @@ -34,6 +37,7 @@ _STUB_TEMPLATE = """#!/bin/sh _ENTRYPOINT_RE = re.compile(r"^ENTRYPOINT\s+(\[.*\])\s*$", re.MULTILINE) _CMD_RE = re.compile(r"^CMD\s+(\[.*\])\s*$", re.MULTILINE) +_COPY_RE = re.compile(r"^COPY\s+(?!--from)(\S+)\s+(\S+)\s*$", re.MULTILINE) _APP_TARGET_RE = re.compile(r"(?:gateway|backend)\.main:app") _TF_STRING_LOCAL_RE = re.compile(r'^\s*(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"\s*$', re.MULTILINE) _TF_INTERPOLATION_RE = re.compile(r"\$\{(local|var)\.(\w+)\}") @@ -53,7 +57,7 @@ def _write_stubs(bin_dir: Path, names: tuple[str, ...]) -> None: def _run_entrypoint( script: Path, argv: tuple[str, ...], - use_ddtrace: Optional[str], + use_ddtrace: str | None, tmp_path: Path, ) -> tuple[str, ...]: """Run `script` with stubbed executables on PATH and return the recorded lines.""" @@ -84,7 +88,7 @@ def _run_entrypoint( return tuple(record.read_text().splitlines()) if record.exists() else () -def _run_shell_command(command: str, bin_dir: Path, record: Path, use_ddtrace: Optional[str]) -> tuple[str, ...]: +def _run_shell_command(command: str, bin_dir: Path, record: Path, use_ddtrace: str | None) -> tuple[str, ...]: """Run a resolved Terraform launch command through `sh -c` and return the recorded lines.""" env = { **os.environ, @@ -184,9 +188,19 @@ def test_ddtrace_disabled_execs_the_command_directly(tmp_path: Path) -> None: ) -@pytest.mark.parametrize("use_ddtrace", [None, "", "false", "True", "TRUE", "1", "yes"]) -def test_gating_matches_the_monolithic_entrypoint(use_ddtrace: Optional[str], tmp_path: Path) -> None: - """The componentized images must honor `USE_DDTRACE` exactly as the monolith does.""" +@pytest.mark.parametrize( + "use_ddtrace, traced", + [*((v, True) for v in TRUTHY_USE_DDTRACE), *((v, False) for v in FALSY_USE_DDTRACE)], +) +def test_gating_matches_the_monolithic_entrypoint_and_get_secret_bool( + use_ddtrace: str | None, traced: bool, tmp_path: Path +) -> None: + """Both entrypoints must accept exactly the spellings `get_secret_bool` accepts. + + `ProxyStartupEvent._init_dd_tracer` reads `USE_DDTRACE` through `get_secret_bool`, which + matches `true` case-insensitively. If the shell gate were stricter, `USE_DDTRACE=True` would + give in-process LLM spans without `ddtrace-run` HTTP spans, a half-enabled state. + """ component = _run_entrypoint( COMPONENT_ENTRYPOINT, ("uvicorn", "gateway.main:app"), @@ -200,8 +214,56 @@ def test_gating_matches_the_monolithic_entrypoint(use_ddtrace: Optional[str], tm tmp_path=tmp_path / "monolith", ) - assert component[0].startswith("exec=") and monolith[0].startswith("exec=") - assert (component[0] == "exec=ddtrace-run") == (monolith[0] == "exec=ddtrace-run") + expected_exec = "exec=ddtrace-run" if traced else "exec=uvicorn" + expected_openai = "DD_TRACE_OPENAI_ENABLED=False" if traced else "DD_TRACE_OPENAI_ENABLED=" + assert component[0] == expected_exec + assert component[2] == expected_openai + assert monolith[0] == ("exec=ddtrace-run" if traced else "exec=litellm") + assert monolith[2] == expected_openai + assert monolith[1] == ("args=litellm --port 4000" if traced else "args=--port 4000") + + +def _copied_script(dockerfile: Path, image_path: str) -> Path: + """Resolve the repo file a Dockerfile `COPY`s to `image_path`, so tests run what the image ships.""" + matches = _COPY_RE.findall(dockerfile.read_text()) + sources = tuple(src for src, dst in matches if dst == image_path) + assert sources, f"{dockerfile} never COPYs anything to {image_path}" + source = REPO_ROOT / sources[-1] + assert source.is_file(), f"{dockerfile} COPYs {sources[-1]}, which does not exist in the build context" + return source + + +@pytest.mark.parametrize( + "use_ddtrace, traced", + [*((v, True) for v in TRUTHY_USE_DDTRACE), *((v, False) for v in FALSY_USE_DDTRACE)], +) +def test_build_from_pip_image_launches_litellm_through_the_prod_entrypoint( + use_ddtrace: str | None, traced: bool, tmp_path: Path +) -> None: + """Run the build_from_pip image's ENTRYPOINT + CMD through the script it actually COPYs. + + The image used to `ENTRYPOINT ["litellm"]`, so `USE_DDTRACE` was inert there at any spelling. + Resolving the ENTRYPOINT path back to its COPY source and executing it with the Dockerfile's + CMD checks the launch the container performs, not just that the Dockerfile mentions the script. + """ + entrypoint = _entrypoint_argv(BUILD_FROM_PIP_DOCKERFILE) + assert len(entrypoint) == 1, ( + f"{BUILD_FROM_PIP_DOCKERFILE} ENTRYPOINT must be the bare script so CMD reaches litellm" + ) + script = _copied_script(BUILD_FROM_PIP_DOCKERFILE, entrypoint[0]) + assert script == PROD_ENTRYPOINT, f"{BUILD_FROM_PIP_DOCKERFILE} bypasses the ddtrace-aware entrypoint" + assert f"chmod +x {entrypoint[0]}" in BUILD_FROM_PIP_DOCKERFILE.read_text() + + cmd = _cmd_argv(BUILD_FROM_PIP_DOCKERFILE) + recorded = _run_entrypoint(script, cmd, use_ddtrace=use_ddtrace, tmp_path=tmp_path) + + cmd_str = " ".join(cmd) + assert recorded == ( + "exec=ddtrace-run" if traced else "exec=litellm", + f"args=litellm {cmd_str}" if traced else f"args={cmd_str}", + "DD_TRACE_OPENAI_ENABLED=False" if traced else "DD_TRACE_OPENAI_ENABLED=", + f"PYTHONPATH={PYTHONPATH_SENTINEL}", + ) def test_entrypoint_script_is_executable() -> None: @@ -239,9 +301,9 @@ def test_component_images_make_the_entrypoint_executable(dockerfile: Path) -> No @pytest.mark.parametrize("terraform_file", TERRAFORM_LAUNCH_SITES, ids=lambda p: p.parent.name) @pytest.mark.parametrize("component", ["gateway", "backend"]) -@pytest.mark.parametrize("use_ddtrace", [None, "true", "false", "True"]) +@pytest.mark.parametrize("use_ddtrace", [*TRUTHY_USE_DDTRACE, *FALSY_USE_DDTRACE]) def test_terraform_launch_command_matches_the_script_contract( - terraform_file: Path, component: str, use_ddtrace: Optional[str], tmp_path: Path + terraform_file: Path, component: str, use_ddtrace: str | None, tmp_path: Path ) -> None: """The Terraform command and `docker/component_entrypoint.sh` must decide identically. @@ -273,7 +335,7 @@ def test_terraform_launch_command_matches_the_script_contract( assert from_terraform[2] == from_script[2], f"{terraform_file} disagrees with the script on the openai integration" assert app_target in from_terraform[1] - if use_ddtrace == "true": + if use_ddtrace in TRUTHY_USE_DDTRACE: assert from_terraform[0] == "exec=ddtrace-run" assert from_terraform[2] == "DD_TRACE_OPENAI_ENABLED=False" else: diff --git a/tests/test_litellm/test_responses_id_security.py b/tests/test_litellm/test_responses_id_security.py index 763ee4dac00..d35b9563888 100644 --- a/tests/test_litellm/test_responses_id_security.py +++ b/tests/test_litellm/test_responses_id_security.py @@ -14,7 +14,9 @@ from litellm.proxy.hooks.responses_id_security import ( _is_responses_api_create_route, ) from litellm.types.llms.openai import ( + GenericEvent, ResponseCompletedEvent, + ResponseCreatedEvent, ResponsesAPIResponse, ResponsesAPIStreamEvents, ) @@ -691,6 +693,126 @@ class TestAsyncPostCallStreamingIteratorHook: assert not responses_id_security._is_encrypted_response_id(streamed_id) +class TestStreamedGenericEventIdEncryption: + """A background stream carries event types with no typed model, which arrive as + GenericEvent holding a plain dict. Those used to skip encryption while their typed + siblings were encrypted, so one stream advertised two ids and the unencrypted one + skipped the ownership check. Asserts the property rather than one event type: every + id a client can see is the same encrypted id, and the raw one appears in no frame.""" + + RAW_ID = "resp_rawprovider123" + + @staticmethod + async def _agen(chunks): + for chunk in chunks: + yield chunk + + @classmethod + def _typed_event(cls, event_type): + return { + ResponsesAPIStreamEvents.RESPONSE_CREATED: ResponseCreatedEvent, + ResponsesAPIStreamEvents.RESPONSE_COMPLETED: ResponseCompletedEvent, + }[event_type]( + type=event_type, + response=ResponsesAPIResponse( + id=cls.RAW_ID, + created_at=0, + model="gpt-5.1", + object="response", + output=[], + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ), + ) + + @classmethod + def _background_stream(cls): + return [ + cls._typed_event(ResponsesAPIStreamEvents.RESPONSE_CREATED), + GenericEvent( + type="response.queued", + response={"id": cls.RAW_ID, "status": "queued"}, + ), + GenericEvent(type="keepalive"), + GenericEvent( + type="response.some_event_openai_adds_later", + response={"id": cls.RAW_ID, "status": "in_progress"}, + ), + cls._typed_event(ResponsesAPIStreamEvents.RESPONSE_COMPLETED), + ] + + @staticmethod + def _advertised_ids(events): + nested = (getattr(event, "response", None) for event in events) + return [ + payload["id"] if isinstance(payload, dict) else payload.id + for payload in nested + if payload is not None + ] + [ + event.id for event in events if isinstance(getattr(event, "id", None), str) + ] + + async def _drain(self, responses_id_security, monkeypatch): + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-key-abcdefghij") + + mock_auth = MagicMock() + mock_auth.user_id = "user-a" + mock_auth.team_id = "team-a" + mock_auth.request_route = "/v1/responses" + + return [ + out + async for out in responses_id_security.async_post_call_streaming_iterator_hook( + user_api_key_dict=mock_auth, + response=self._agen(self._background_stream()), + request_data={}, + ) + ] + + @pytest.mark.asyncio + async def test_every_event_advertises_the_same_encrypted_id( + self, responses_id_security, monkeypatch + ): + events = await self._drain(responses_id_security, monkeypatch) + advertised = self._advertised_ids(events) + + assert len(advertised) == 4 + assert len(set(advertised)) == 1 + + streamed_id = advertised[0] + assert streamed_id != self.RAW_ID + assert responses_id_security._is_encrypted_response_id(streamed_id) + assert responses_id_security._decrypt_response_id(streamed_id) == ( + self.RAW_ID, + "user-a", + "team-a", + ) + + @pytest.mark.asyncio + async def test_raw_provider_id_never_reaches_the_client( + self, responses_id_security, monkeypatch + ): + events = await self._drain(responses_id_security, monkeypatch) + + assert [self.RAW_ID in event.model_dump_json() for event in events] == [ + False + ] * len(events) + + @pytest.mark.asyncio + async def test_sibling_fields_survive_the_rewrite( + self, responses_id_security, monkeypatch + ): + _, queued, keepalive, later, _ = await self._drain( + responses_id_security, monkeypatch + ) + + assert queued.response["status"] == "queued" + assert later.response["status"] == "in_progress" + assert keepalive.type == "keepalive" + assert getattr(keepalive, "response", None) is None + + class TestAsyncPostCallSuccessHook: """Test async_post_call_success_hook function""" diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 26c0209bc05..5fc96bcfbb1 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3855,7 +3855,7 @@ def test_pre_call_checks_counts_responses_instructions_tokens(monkeypatch): input_only_tokens = router._count_pre_call_check_tokens(messages=None, input=short_input) with_instructions_tokens = router._count_pre_call_check_tokens( - messages=None, input=short_input, instructions=long_instructions + messages=None, input=short_input, request_kwargs={"instructions": long_instructions} ) assert with_instructions_tokens > input_only_tokens @@ -3871,6 +3871,164 @@ def test_pre_call_checks_counts_responses_instructions_tokens(monkeypatch): ) +_OVERSIZED_TOOL_DESCRIPTION = "look up the answer in the knowledge base. " * 40 + + +@pytest.mark.parametrize( + "prompt_kwargs, tool", + [ + pytest.param( + {"messages": [{"role": "user", "content": "hi"}]}, + { + "type": "function", + "function": { + "name": "lookup", + "description": _OVERSIZED_TOOL_DESCRIPTION, + "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}, + }, + }, + id="chat_completions_tool", + ), + pytest.param( + {"input": "hi"}, + { + "type": "function", + "name": "lookup", + "description": _OVERSIZED_TOOL_DESCRIPTION, + "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}, + }, + id="responses_tool", + ), + pytest.param( + {"messages": [{"role": "user", "content": "hi"}]}, + { + "name": "lookup", + "description": _OVERSIZED_TOOL_DESCRIPTION, + "input_schema": {"type": "object", "properties": {"q": {"type": "string"}}}, + }, + id="anthropic_messages_tool", + ), + ], +) +def test_pre_call_checks_counts_tool_definition_tokens(monkeypatch, prompt_kwargs, tool): + """ + Tool definitions are sent to the model as prompt tokens but never appear in + `messages` or `input`. A request whose prompt alone fits the context window but + whose prompt plus `tools` exceeds it must be rejected before dispatch, for the + Chat Completions, Responses and Anthropic Messages tool shapes alike. + """ + router = litellm.Router( + model_list=[ + {"model_name": "m", "litellm_params": {"model": "gpt-3.5-turbo"}}, + ], + enable_pre_call_checks=True, + ) + deployments = [ + {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, + ] + + prompt_only_tokens = router._count_pre_call_check_tokens( + messages=prompt_kwargs.get("messages"), input=prompt_kwargs.get("input") + ) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": prompt_only_tokens} + ) + + assert len(router._pre_call_checks(model="m", healthy_deployments=deployments, **prompt_kwargs)) == 1 + with pytest.raises(litellm.ContextWindowExceededError): + router._pre_call_checks( + model="m", + healthy_deployments=deployments, + request_kwargs={"tools": [tool]}, + **prompt_kwargs, + ) + + +@pytest.mark.parametrize( + "system", + [ + pytest.param("You are a meticulous assistant. " * 40, id="system_string"), + pytest.param( + [{"type": "text", "text": "You are a meticulous assistant. " * 40}], + id="system_blocks", + ), + ], +) +def test_pre_call_checks_counts_anthropic_system_tokens(monkeypatch, system): + """ + The Anthropic Messages API carries the system prompt as a top-level `system` field, + not as a message. Its tokens reach the model, so a request whose `messages` fit but + whose `messages` plus `system` exceed the context window must be rejected. + """ + router = litellm.Router( + model_list=[ + {"model_name": "m", "litellm_params": {"model": "gpt-3.5-turbo"}}, + ], + enable_pre_call_checks=True, + ) + deployments = [ + {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, + ] + messages = [{"role": "user", "content": "hi"}] + + messages_only_tokens = router._count_pre_call_check_tokens(messages=messages, input=None) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": messages_only_tokens}) + + assert len(router._pre_call_checks(model="m", healthy_deployments=deployments, messages=messages)) == 1 + with pytest.raises(litellm.ContextWindowExceededError): + router._pre_call_checks( + model="m", + healthy_deployments=deployments, + messages=messages, + request_kwargs={"system": system}, + ) + + +@pytest.mark.asyncio +async def test_aanthropic_messages_enforces_context_window_with_system_and_tools(): + """ + End-to-end router regression for /v1/messages: a request whose only oversized + content lives in the top-level `system` field or in `tools` must trip the pre-call + context-window check instead of being dispatched (the deployment uses mock_response, + so reaching the provider handler would return a response rather than raise). + """ + router = litellm.Router( + model_list=[ + { + "model_name": "small-ctx", + "litellm_params": {"model": "anthropic/claude-3-5-haiku-20241022", "mock_response": "hi"}, + "model_info": {"max_input_tokens": 20}, + } + ], + enable_pre_call_checks=True, + ) + messages = [{"role": "user", "content": "hi"}] + + response = await router.aanthropic_messages(model="small-ctx", messages=messages, max_tokens=5) + assert response is not None + + with pytest.raises(litellm.ContextWindowExceededError): + await router.aanthropic_messages( + model="small-ctx", + messages=messages, + max_tokens=5, + system="You are a meticulous assistant. " * 40, + ) + with pytest.raises(litellm.ContextWindowExceededError): + await router.aanthropic_messages( + model="small-ctx", + messages=messages, + max_tokens=5, + tools=[ + { + "name": "lookup", + "description": _OVERSIZED_TOOL_DESCRIPTION, + "input_schema": {"type": "object", "properties": {"q": {"type": "string"}}}, + } + ], + ) + + def test_count_pre_call_check_tokens_across_api_surfaces(): """ _count_pre_call_check_tokens must count tokens from chat `messages`, a Responses @@ -4943,6 +5101,40 @@ def test_team_wildcard_credentials_not_usable_after_delete_deployment(): ) +def test_global_wildcard_pattern_router_evicts_stale_entry_on_upsert_and_delete(): + """ + Regression for #29064: upsert_deployment removed the old deployment from + model_list but left it in the global pattern_router, so wildcard requests + round-robined between the stale and the corrected deployment. + """ + from litellm.types.router import Deployment, LiteLLM_Params + + router = litellm.Router( + model_list=[ + { + "model_name": "openai/*", + "litellm_params": {"model": "openai/openai/*", "api_key": "sk-old"}, + "model_info": {"id": "global-wildcard"}, + } + ] + ) + + router.upsert_deployment( + Deployment( + model_name="openai/*", + litellm_params=LiteLLM_Params(model="openai/*", api_key="sk-new"), + model_info={"id": "global-wildcard"}, + ) + ) + + matches = router.pattern_router.route("openai/gpt-5.2") + assert matches is not None + assert [m["litellm_params"]["api_key"] for m in matches] == ["sk-new"] + + router.delete_deployment(id="global-wildcard") + assert router.pattern_router.patterns == {} + + def test_pattern_match_router_remove_deployment(): """ remove_deployment must drop only the deployment with the given model id and @@ -7358,6 +7550,71 @@ def test_get_configured_token_limits_coerces_numeric_strings(): assert router.get_configured_token_limits("quoted-limits-model") == (32000, 8000) +def test_get_configured_mode_reads_deployment_model_info(): + router = litellm.Router( + model_list=[ + { + "model_name": "tts-model", + "litellm_params": {"model": "openai/some-unmapped-tts-model"}, + "model_info": {"mode": "audio_speech"}, + } + ] + ) + + assert router.get_configured_mode("tts-model") == "audio_speech" + + +def test_get_configured_mode_returns_none_for_unset_or_unknown(): + router = litellm.Router( + model_list=[ + { + "model_name": "no-mode-model", + "litellm_params": {"model": "openai/some-unmapped-model"}, + } + ] + ) + + assert router.get_configured_mode("no-mode-model") is None + assert router.get_configured_mode("not-a-real-model") is None + + +def test_get_configured_mode_skips_wildcard_pattern_matching(): + router = litellm.Router( + model_list=[ + { + "model_name": "bedrock/*", + "litellm_params": {"model": "bedrock/*"}, + "model_info": {"mode": "chat"}, + } + ] + ) + + with patch.object( + router.pattern_router, "route", side_effect=AssertionError("pattern route called") + ): + assert ( + router.get_configured_mode("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") + is None + ) + + +def test_get_configured_mode_treats_malformed_values_as_absent(): + malformed = ["", " ", 12345, ["chat"], {"mode": "chat"}, True] + router = litellm.Router( + model_list=[ + { + "model_name": f"bad-mode-{i}", + "litellm_params": {"model": "openai/some-unmapped-model"}, + "model_info": {"mode": bad}, + } + for i, bad in enumerate(malformed) + ] + ) + + for i in range(len(malformed)): + assert router.get_configured_mode(f"bad-mode-{i}") is None + + def test_get_configured_display_name_reads_deployment_model_info(): router = litellm.Router( model_list=[ @@ -9275,9 +9532,11 @@ class _FallbackAttemptRecorder(CustomLogger): def __init__(self): super().__init__() self.failed_targets = [] + self.breadcrumbs_per_target = [] async def log_failure_fallback_event(self, original_model_group, kwargs, original_exception): self.failed_targets.append(kwargs.get("model")) + self.breadcrumbs_per_target.append(kwargs.get("metadata", {}).get("previous_models", ())) def _cyclic_fallback_router(num_retries=0): @@ -9348,14 +9607,16 @@ async def test_retry_breadcrumbs_do_not_carry_the_walk_state(): A retry has to be configured for the walk state to reach log_retry at all.""" router = _cyclic_fallback_router(num_retries=1) capture = _LogCapture(logging.ERROR) + recorder = _FallbackAttemptRecorder() - await _drive_cyclic_fallback(router, capture) + await _drive_cyclic_fallback(router, capture, recorder) - assert router.previous_models, "no retry breadcrumbs were recorded" + breadcrumbs = [breadcrumb for hop in recorder.breadcrumbs_per_target for breadcrumb in hop] + assert breadcrumbs, "no retry breadcrumbs were recorded" assert any( - "fallback_depth" in breadcrumb for breadcrumb in router.previous_models + "fallback_depth" in breadcrumb for breadcrumb in breadcrumbs ), "no breadcrumb carried router walk state, so this test cannot see the leak" - for breadcrumb in router.previous_models: + for breadcrumb in breadcrumbs: assert "attempted_targets" not in breadcrumb @@ -9393,15 +9654,94 @@ async def test_retry_breadcrumbs_never_carry_a_forwarded_credential(container_ke container still reaches the breadcrumb, but the raw secret never does, whatever key holds it.""" router = _cyclic_fallback_router(num_retries=1) capture = _LogCapture(logging.ERROR) + metadata = {} - await _drive_cyclic_fallback(router, capture, **request_kwargs) + await _drive_cyclic_fallback(router, capture, metadata=metadata, **request_kwargs) - assert router.previous_models, "no retry breadcrumbs were recorded" - dumped = json.dumps(router.previous_models, default=str) + breadcrumbs = metadata["previous_models"] + assert breadcrumbs, "no retry breadcrumbs were recorded" + dumped = json.dumps(breadcrumbs, default=str) assert container_key in dumped, "the credential-bearing kwarg never reached the breadcrumb, so this test cannot see the leak" assert _BREADCRUMB_CREDENTIAL_CANARY not in dumped +def _always_failing_router(num_retries): + return litellm.Router( + model_list=[ + { + "model_name": "broken-group", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "sk-fake", + "mock_response": "litellm.InternalServerError", + }, + } + ], + num_retries=num_retries, + ) + + +async def _fail_one_proxy_shaped_request(router, request_marker): + """The proxy hands the router a metadata dict and a proxy_server_request whose body is a + shallow copy of the request, so body["metadata"] is the very same dict the router later + stamps previous_models onto.""" + metadata = {"request_marker": request_marker} + with pytest.raises(litellm.InternalServerError): + await router.acompletion( + model="broken-group", + messages=[{"role": "user", "content": "hi"}], + metadata=metadata, + proxy_server_request={ + "url": "http://localhost:4000/v1/chat/completions", + "method": "POST", + "headers": {}, + "body": {"model": "broken-group", "metadata": metadata}, + }, + ) + return metadata["previous_models"] + + +def _nested_breadcrumb_lists(node): + if isinstance(node, dict): + return [v for k, v in node.items() if k == "previous_models"] + [ + found for v in node.values() for found in _nested_breadcrumb_lists(v) + ] + if isinstance(node, (list, tuple)): + return [found for item in node for found in _nested_breadcrumb_lists(item)] + return [] + + +@pytest.mark.asyncio +async def test_retry_breadcrumbs_stay_per_request_and_flat_across_failing_requests(): + """Every failed attempt appends a breadcrumb to metadata["previous_models"], and the proxy's + request snapshot aliases that same metadata dict. Kept on the Router and copied wholesale, + each breadcrumb embedded every earlier one from every earlier request, so the breadcrumb + tree, and with it the debug repr of the kwargs, roughly doubled on each failed attempt until + a single-worker proxy spent minutes in the redaction regex and stopped answering.""" + router = _always_failing_router(num_retries=2) + + breadcrumbs_per_request = [ + await _fail_one_proxy_shaped_request(router, f"request-{request_number}") for request_number in range(1, 7) + ] + + for request_number, breadcrumbs in enumerate(breadcrumbs_per_request, start=1): + assert len(breadcrumbs) == 3, "one initial attempt plus two retries failed, each leaving one breadcrumb" + assert {breadcrumb["metadata"]["request_marker"] for breadcrumb in breadcrumbs} == {f"request-{request_number}"} + for breadcrumb in breadcrumbs: + assert _nested_breadcrumb_lists(breadcrumb) == [] + assert len({len(repr(breadcrumbs)) for breadcrumbs in breadcrumbs_per_request}) == 1 + + +@pytest.mark.asyncio +async def test_retry_breadcrumbs_keep_only_the_last_four_attempts(): + router = _always_failing_router(num_retries=6) + + breadcrumbs = await _fail_one_proxy_shaped_request(router, "request-1") + + assert len(breadcrumbs) == 4 + assert [breadcrumb["metadata"]["attempted_retries"] for breadcrumb in breadcrumbs] == [3, 4, 5, 6] + + @pytest.mark.asyncio async def test_fallback_traceback_stays_available_at_debug_level(): """Dropping the stack from the ERROR line is only safe because the fallback path still @@ -12553,33 +12893,3 @@ async def test_prompt_management_factory_marks_injection_for_every_deployment(mo bucket = captured.get("litellm_metadata") or captured["metadata"] assert captured["model_info"]["id"] == "provisional-dep" assert bucket["litellm_gateway_injected_cache"] == "" - - -def test_get_configured_mode_reads_deployment_model_info(): - router = Router( - model_list=[ - { - "model_name": "my-tts", - "litellm_params": {"model": "openai/some-unmapped-mode-model"}, - "model_info": {"mode": "audio_speech"}, - } - ] - ) - - assert router.get_configured_mode("my-tts") == "audio_speech" - - -@pytest.mark.parametrize("model_info", [{}, {"mode": ""}, {"mode": " "}, {"mode": 123}]) -def test_get_configured_mode_returns_none_for_unset_blank_or_unknown(model_info): - router = Router( - model_list=[ - { - "model_name": "plain-model", - "litellm_params": {"model": "openai/some-unmapped-mode-model"}, - "model_info": model_info, - } - ] - ) - - assert router.get_configured_mode("plain-model") is None - assert router.get_configured_mode("unknown-model") is None diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 5575c4328a2..38a09415ed0 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -40,6 +40,7 @@ from litellm.utils import ( _is_streaming_request, _snapshot_exception_for_hook, async_post_call_failure_deployment_hook, + async_post_call_success_deployment_hook, client, get_llm_provider, get_non_default_completion_params, @@ -5808,6 +5809,90 @@ class TestHuggingFaceConfigFetch: assert request_timeout["read"] == HF_CONFIG_FETCH_TIMEOUT_SECONDS +@pytest.mark.asyncio +async def test_success_deployment_hook_chains_past_callback_returning_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression (LIT-5863): the dispatcher must run every callback, chaining each non-None + result into the next call, instead of returning at the first callback answering non-None. + A guardrail answering with the unmodified response used to starve every callback after it.""" + from litellm.types.utils import ModelResponse + + original = ModelResponse() + replacement = ModelResponse() + + class PassthroughLogger(CustomLogger): + async def async_post_call_success_deployment_hook(self, request_data, response, call_type): + return response + + class ReplacingLogger(CustomLogger): + def __init__(self) -> None: + super().__init__() + self.seen: list = [] + + async def async_post_call_success_deployment_hook(self, request_data, response, call_type): + self.seen.append(response) + return replacement + + class ObservingLogger(CustomLogger): + def __init__(self) -> None: + super().__init__() + self.seen: list = [] + + async def async_post_call_success_deployment_hook(self, request_data, response, call_type): + self.seen.append(response) + return None + + replacer = ReplacingLogger() + observer = ObservingLogger() + monkeypatch.setattr(litellm, "callbacks", [PassthroughLogger(), replacer, observer]) + + result = await async_post_call_success_deployment_hook( + request_data={}, response=original, call_type=CallTypes.acompletion + ) + + assert replacer.seen == [original] + assert observer.seen == [replacement] + assert result is replacement + + +@pytest.mark.asyncio +async def test_registered_guardrail_does_not_starve_vector_store_search_results( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression (LIT-5863): with any guardrail registered ahead of the lazily-appended + VectorStorePreCallHook, /v1/chat/completions responses lost + provider_specific_fields["search_results"] because the guardrail answered the unmodified + response and the dispatcher stopped there.""" + from types import SimpleNamespace + + from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import ( + VectorStorePreCallHook, + ) + from litellm.types.utils import ModelResponse + + search_results: Final = [{"search_query": "coolant", "data": [{"content": [{"text": "Cryoline-9", "type": "text"}]}]}] + logging_obj = SimpleNamespace(model_call_details={"search_results": search_results}) + response = ModelResponse(choices=[{"message": {"role": "assistant", "content": "Cryoline-9"}}]) + + monkeypatch.setattr( + litellm, + "callbacks", + [CustomGuardrail(guardrail_name="dummy-guardrail"), VectorStorePreCallHook()], + ) + + result = await async_post_call_success_deployment_hook( + request_data={"litellm_logging_obj": logging_obj}, + response=response, + call_type=CallTypes.acompletion, + ) + + provider_fields = result.choices[0].message.provider_specific_fields + assert provider_fields is not None + assert provider_fields["search_results"] == search_results + + class TestIsVisionExplicitlyDisabled: """github_copilot and chatgpt run an OAuth device flow inside get_llm_provider; the explicit-disable lookup must adopt the declared prefix instead of resolving it, exactly @@ -5836,3 +5921,135 @@ class TestIsVisionExplicitlyDisabled: is_vision_explicitly_disabled("fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731") is True ) assert is_vision_explicitly_disabled("anthropic/claude-sonnet-4-5") is False + + +class TestVerboseRequestLineRedaction: + """`litellm.set_verbose = True` echoes the caller's kwargs back as a `litellm.completion(...)` + line on stdout, so a credential kwarg lands in whatever collects stdout: a terminal, a + container log drain, a CI job log. Credential-named kwargs must not survive that echo, + at any nesting depth, while ordinary params still must, or the line stops telling the + developer what they called.""" + + FAKE_API_KEY: Final = "sk-fake-lit6823-0000000000000000" + + def _verbose_request_line(self, capsys, monkeypatch, **kwargs) -> str: + monkeypatch.setattr(litellm, "set_verbose", True) + monkeypatch.setattr("litellm._logging.set_verbose", True) + capsys.readouterr() + litellm.completion( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "hello"}], + mock_response="hi", + **kwargs, + ) + captured: Final = capsys.readouterr() + return "\n".join(line for line in (captured.out + captured.err).splitlines() if "litellm.completion(" in line) + + def test_api_key_never_reaches_the_request_line(self, capsys, monkeypatch): + printed: Final = self._verbose_request_line(capsys, monkeypatch, api_key=self.FAKE_API_KEY) + + assert "litellm.completion(" in printed + assert self.FAKE_API_KEY not in printed + assert "api_key='REDACTED'" in printed + + def test_credential_headers_never_reach_the_request_line(self, capsys, monkeypatch): + printed: Final = self._verbose_request_line( + capsys, + monkeypatch, + api_key=self.FAKE_API_KEY, + extra_headers={"Authorization": "Bearer fake-lit6823-header", "x-request-id": "abc123"}, + ) + + assert "fake-lit6823-header" not in printed + assert "'Authorization': 'REDACTED'" in printed + assert "'x-request-id': 'abc123'" in printed + + def test_credentials_nested_in_a_list_never_reach_the_request_line(self, capsys, monkeypatch): + printed: Final = self._verbose_request_line( + capsys, + monkeypatch, + api_key=self.FAKE_API_KEY, + extra_body={"providers": [{"name": "openai", "api_key": "sk-fake-lit6823-nested"}]}, + ) + + assert "sk-fake-lit6823-nested" not in printed + assert "'name': 'openai'" in printed + + def test_ordinary_params_still_printed(self, capsys, monkeypatch): + printed: Final = self._verbose_request_line( + capsys, monkeypatch, api_key=self.FAKE_API_KEY, max_tokens=17, temperature=0.25 + ) + + assert "model='gpt-3.5-turbo'" in printed + assert "max_tokens=17" in printed + assert "temperature=0.25" in printed + + +class TestFinalOptionalParamsLineRedaction: + """A verbose run echoes the fully built optional params too, and `extra_body` carries whatever the + caller nested inside it straight onto that line, so a credential tucked in there lands in a terminal + or a log drain in plaintext. It has to be redacted on both surfaces `print_verbose` writes to, and the + line has to keep printing on both, because `litellm.set_verbose` and the DEBUG logger are independent + switches and neither implies the other.""" + + FAKE_NESTED_KEY: Final = "sk-fake-lit6835-nested-0000000000" + + def _complete(self, **kwargs) -> None: + litellm.completion( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "hello"}], + mock_response="hi", + **kwargs, + ) + + def _printed_line(self, capsys) -> str: + captured: Final = capsys.readouterr() + return "\n".join( + line for line in (captured.out + captured.err).splitlines() if "Final returned optional params" in line + ) + + def test_nested_credential_is_redacted_when_only_set_verbose_is_on(self, capsys, caplog, monkeypatch): + monkeypatch.setattr(litellm, "set_verbose", True) + with caplog.at_level(logging.WARNING, logger=verbose_logger.name): + capsys.readouterr() + self._complete(extra_body={"providers": [{"name": "openai", "api_key": self.FAKE_NESTED_KEY}]}) + printed: Final = self._printed_line(capsys) + + assert printed + assert self.FAKE_NESTED_KEY not in printed + assert "'api_key': 'REDACTED'" in printed + assert "'name': 'openai'" in printed + + def test_line_still_reaches_the_logger_when_only_the_debug_logger_is_on(self, capsys, caplog, monkeypatch): + monkeypatch.setattr(litellm, "set_verbose", False) + with caplog.at_level(logging.DEBUG, logger=verbose_logger.name): + self._complete(extra_body={"providers": [{"name": "openai", "api_key": self.FAKE_NESTED_KEY}]}) + logged: Final = "\n".join( + record.getMessage() + for record in caplog.records + if "Final returned optional params" in record.getMessage() + ) + + assert logged + assert self.FAKE_NESTED_KEY not in logged + assert "'name': 'openai'" in logged + + def test_nothing_is_emitted_when_neither_verbose_switch_is_on(self, capsys, caplog, monkeypatch): + monkeypatch.setattr(litellm, "set_verbose", False) + with caplog.at_level(logging.WARNING, logger=verbose_logger.name): + capsys.readouterr() + self._complete(extra_body={"providers": [{"name": "openai", "api_key": self.FAKE_NESTED_KEY}]}) + captured: Final = capsys.readouterr() + + assert "Final returned optional params" not in captured.out + captured.err + assert self.FAKE_NESTED_KEY not in captured.out + captured.err + + def test_ordinary_optional_params_still_reach_the_line(self, capsys, caplog, monkeypatch): + monkeypatch.setattr(litellm, "set_verbose", True) + with caplog.at_level(logging.WARNING, logger=verbose_logger.name): + capsys.readouterr() + self._complete(max_tokens=17, temperature=0.25) + printed: Final = self._printed_line(capsys) + + assert "'max_tokens': 17" in printed + assert "'temperature': 0.25" in printed diff --git a/tests/test_rust_python_harness.py b/tests/test_rust_python_harness.py index a2b9c8e2a76..b27d1c83597 100644 --- a/tests/test_rust_python_harness.py +++ b/tests/test_rust_python_harness.py @@ -1,206 +1,86 @@ from __future__ import annotations import importlib -import json -import os -import subprocess -import sys from pathlib import Path from typing import Final import pytest -catalog = importlib.import_module("tests.rust-python-harness.catalog") -cli = importlib.import_module("tests.rust-python-harness.cli") models = importlib.import_module("tests.rust-python-harness.shared.reporting.models") -runner = importlib.import_module("tests.rust-python-harness.shared.reporting.pytest_runner") +strategy_module = importlib.import_module("tests.rust-python-harness.shared.reporting.strategy") ui = importlib.import_module("tests.rust-python-harness.shared.reporting.ui") -ledger_module = importlib.import_module("tests.rust-python-harness.shared.parity.ledger") -mapping_validator = importlib.import_module( - "tests.rust-python-harness.strategies.unit_tests.mapping_validator" -) +mapping_validator = importlib.import_module("tests.rust-python-harness.strategies.unit_tests_mapping.mapping_validator") +mappings = importlib.import_module("tests.rust-python-harness.strategies.unit_tests_mapping.mappings") +ocr_mapping = importlib.import_module("tests.rust-python-harness.strategies.unit_tests_mapping.cases.ocr") +cli = importlib.import_module("tests.rust-python-harness.cli") -load_catalog = catalog.load_catalog -load_ledger = ledger_module.load_ledger -ledger_path_for = mapping_validator.ledger_path_for -REPO_ROOT = mapping_validator.REPO_ROOT -audit_ledger = mapping_validator.audit_ledger -build_function_report = mapping_validator.build_function_report -_pick_values = cli._pick_values -_coverage_pytest_args = cli._coverage_pytest_args -_select = cli._select -_validate_ledger = cli._validate_ledger +audit_mapping = mapping_validator.audit_mapping +UNIT_TEST_CONTRACTS = mappings.UNIT_TEST_CONTRACTS +OCR_CONTRACT = ocr_mapping.OCR_CONTRACT +REPO_ROOT = Path(__file__).resolve().parents[1] CaseResult = models.CaseResult Coverage = models.Coverage HarnessCase = models.HarnessCase HarnessRun = models.HarnessRun RunStatus = models.RunStatus -SDK_FUNCTIONS = models.SDK_FUNCTIONS -section_confidence = models.section_confidence -run_pytest = runner.run_pytest -runnable_selectors = runner.runnable_selectors -selector_matches_node = runner.selector_matches_node +ModuleCaseSpec = strategy_module.ModuleCaseSpec +NotImplementedCaseSpec = strategy_module.NotImplementedCaseSpec +SkippedCaseSpec = strategy_module.SkippedCaseSpec _format_duration = ui._format_duration -_rerun_command = ui._rerun_command _summary = ui._summary -def _case( - *, selectors: tuple[str, ...] = (), coverage: Coverage = Coverage.COMPLETE -) -> HarnessCase: +def _case(module: str = "tests.example") -> HarnessCase: return HarnessCase( strategy_id="example", strategy_label="Example", sdk_function="messages", - coverage=coverage, - selectors=selectors, + spec=ModuleCaseSpec(coverage=Coverage.COMPLETE, module=module), ) -def _manifest() -> dict[str, object]: - return { - "order": 1, - "id": "example", - "label": "Example strategy", - "description": "Example description", - "functions": { - function: {"coverage": "planned", "selectors": []} - for function in SDK_FUNCTIONS - }, - } - - -def test_should_load_the_four_harness_strategies_in_order() -> None: - strategies = load_catalog() - - assert [strategy.id for strategy in strategies] == [ - "e2e_parity", - "trace_parity", - "unit_tests", - "existing_e2e_test_sdk", - ] - assert all( - tuple(case.sdk_function for case in strategy.cases) == SDK_FUNCTIONS - for strategy in strategies - ) - - -def test_should_reject_a_manifest_missing_an_sdk_function(tmp_path: Path) -> None: - strategy_directory = tmp_path / "example" - strategy_directory.mkdir() - manifest = _manifest() - del manifest["functions"]["count_tokens"] # type: ignore[index] - (strategy_directory / "strategy.json").write_text( - json.dumps(manifest), encoding="utf-8" - ) - - with pytest.raises(ValueError, match="functions must exactly match"): - load_catalog(tmp_path) - - @pytest.mark.parametrize( - ("selector", "nodeid", "matches"), + "module", [ - ("tests/test_parity.py", "tests/test_parity.py::test_one", True), - ("tests/test_parity.py::test_one", "tests/test_parity.py::test_one", True), - ( - "tests/test_parity.py::test_one", - "tests/test_parity.py::test_one[value]", - True, - ), - ("tests/test_parity.py::test_one", "tests/test_parity.py::test_two", False), - ("tests/ocr_tests/", "tests/ocr_tests/test_ocr_mistral.py::test_one", True), - ("tests/ocr_tests/", "tests/other_tests/test_ocr_mistral.py::test_one", False), + "tests.rust-python-harness.strategies.e2e_parity.sdk.ocr.test_sdk_parity", + "tests.rust-python-harness.strategies.trace_parity.sdk.ocr.case", + "tests.rust-python-harness.strategies.trace_parity.sdk.messages.case", + "tests.rust-python-harness.strategies.trace_parity.sdk.chat_completions.case", + "tests.rust-python-harness.strategies.trace_parity.sdk.transcription.case", + "tests.rust-python-harness.strategies.trace_parity.gateway.messages.case", ], ) -def test_should_match_pytest_file_and_node_selectors( - selector: str, nodeid: str, matches: bool -) -> None: - assert selector_matches_node(selector, nodeid) is matches +def test_implemented_namespace_case_modules_remain_importable(module: str) -> None: + assert importlib.import_module(module) -def test_should_only_return_selectors_whose_files_exist(tmp_path: Path) -> None: - existing = tmp_path / "tests" / "test_parity.py" - existing.parent.mkdir() - existing.write_text("", encoding="utf-8") - case = _case( - selectors=("tests/test_parity.py", "tests/test_missing.py::test_missing") +def test_should_mark_not_implemented_and_skipped_cases_without_running() -> None: + not_implemented: Final = CaseResult( + case=HarnessCase( + strategy_id="example", + strategy_label="Example", + sdk_function="messages", + spec=NotImplementedCaseSpec(reason="No case is registered."), + ) + ) + skipped: Final = CaseResult( + case=HarnessCase( + strategy_id="example", + strategy_label="Example", + sdk_function="messages", + spec=SkippedCaseSpec(reason="The surface does not apply."), + ) ) - assert runnable_selectors((case,), tmp_path) == ("tests/test_parity.py",) + not_implemented.set_initial_status() + skipped.set_initial_status() - -def test_should_treat_an_existing_folder_selector_as_runnable(tmp_path: Path) -> None: - (tmp_path / "tests" / "ocr_tests").mkdir(parents=True) - case = _case(selectors=("tests/ocr_tests/",)) - - assert runnable_selectors((case,), tmp_path) == ("tests/ocr_tests/",) - - -def test_should_mark_planned_and_not_applicable_cases_without_running() -> None: - planned = CaseResult(case=_case(coverage=Coverage.PLANNED)) - not_applicable = CaseResult(case=_case(coverage=Coverage.NOT_APPLICABLE)) - - planned.set_initial_status() - not_applicable.set_initial_status() - - assert planned.status is RunStatus.PLANNED - assert not_applicable.status is RunStatus.NOT_APPLICABLE - - -def test_should_treat_an_all_planned_filtered_run_as_success(tmp_path: Path) -> None: - exit_code, run = run_pytest( - cases=(_case(coverage=Coverage.PLANNED),), - repo_root=tmp_path, - on_update=lambda _: None, - ) - - assert exit_code == 0 - assert next(iter(run.results.values())).status is RunStatus.PLANNED - - -@pytest.mark.parametrize("strategy_id", ("e2e_parity", "existing_e2e_test_sdk")) -def test_should_run_namespace_package_relative_imports(tmp_path: Path, strategy_id: str) -> None: - package: Final = tmp_path / "manual_suite" / "relative-tests" - package.mkdir(parents=True) - (package / "__init__.py").write_text("", encoding="utf-8") - (package / "values.py").write_text("ANSWER = 42\n", encoding="utf-8") - (package / "test_relative.py").write_text( - "from .values import ANSWER\n\ndef test_answer():\n assert ANSWER == 42\n", - encoding="utf-8", - ) - result: Final = subprocess.run( - ( - sys.executable, - "-c", - "import importlib\n" - "from pathlib import Path\n" - "cli = importlib.import_module('tests.rust-python-harness.cli')\n" - "models = importlib.import_module('tests.rust-python-harness.shared.reporting.models')\n" - f"case = models.HarnessCase(strategy_id={strategy_id!r}, strategy_label='Example', " - "sdk_function='ocr', coverage=models.Coverage.COMPLETE, " - "selectors=('manual_suite/relative-tests/',))\n" - f"code, run = cli._resolve_runner({strategy_id!r})((case,), Path.cwd(), lambda _: None)\n" - "assert code == 0, code\n" - "assert next(iter(run.results.values())).passed == 1\n", - ), - cwd=tmp_path, - env={ - **os.environ, - "PYTHONPATH": os.pathsep.join((str(tmp_path), str(Path(__file__).resolve().parents[1]))), - "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", - }, - capture_output=True, - text=True, - timeout=30, - check=False, - ) - - assert result.returncode == 0, result.stdout + result.stderr + assert not_implemented.status is RunStatus.NOT_IMPLEMENTED + assert skipped.status is RunStatus.SKIPPED def test_should_finalize_a_fully_passing_case() -> None: - result = CaseResult(case=_case(selectors=("tests/test_parity.py",))) + result = CaseResult(case=_case()) result.set_initial_status() result.collected.update({"one", "two"}) result.completed.update({"one", "two"}) @@ -212,7 +92,7 @@ def test_should_finalize_a_fully_passing_case() -> None: def test_should_replace_a_pass_with_a_teardown_error() -> None: - result = CaseResult(case=_case(selectors=("tests/test_parity.py",))) + result = CaseResult(case=_case()) result.set_initial_status() result.collected.add("one") @@ -225,136 +105,40 @@ def test_should_replace_a_pass_with_a_teardown_error() -> None: assert result.duration == pytest.approx(0.3) -def test_should_filter_the_catalog_by_strategy_and_sdk_function() -> None: - strategies = load_catalog() - - cases = _select(strategies, {"e2e_parity"}, {"messages"}) - - assert len(cases) == 1 - assert cases[0].key == "e2e_parity:messages" - - -def test_should_reject_an_unknown_strategy() -> None: - with pytest.raises(ValueError, match="Unknown strategy"): - _select(load_catalog(), {"not-real"}, set()) - - -def test_should_pick_multiple_interactive_filters() -> None: - answers = iter(["nope", "1, 3"]) - - selected = _pick_values( - "Examples", - (("one", "One"), ("two", "Two"), ("three", "Three")), - input_fn=lambda _: next(answers), - ) - - assert selected == {"one", "three"} - - def test_should_format_developer_facing_run_context() -> None: - run = HarnessRun.from_cases((_case(selectors=("tests/test_parity.py",)),)) + run = HarnessRun.from_cases((_case(),)) result = next(iter(run.results.values())) result.collected.add("tests/test_parity.py::test_one") result.record("tests/test_parity.py::test_one", RunStatus.PASSED, 1.25) assert _summary(run) == (1, 0, 0, 0) assert _format_duration(1.25) == "1.2s" - assert _rerun_command("tests/test_parity.py::test_one") == ( - "poetry run pytest tests/test_parity.py::test_one -q -o consider_namespace_packages=true" + + +def test_should_leave_functions_without_mapping_contracts_unimplemented() -> None: + assert "messages" not in UNIT_TEST_CONTRACTS + + +def test_should_derive_ocr_mapping_status_from_live_tests() -> None: + report = audit_mapping(OCR_CONTRACT, repo_root=REPO_ROOT) + + assert report.is_valid, ( + f"Missing Python tests: {list(report.missing_python_tests)}\n" + f"Missing Rust tests: {list(report.missing_rust_tests)}\n" + f"Duplicate Python mappings: {list(report.duplicate_python_mappings)}\n" + f"Invalid mapping exclusions: {list(report.invalid_mapping_exclusions)}\n" + f"Invalid parity exclusions: {list(report.invalid_unit_parity_exclusions)}" ) - assert _rerun_command("tests/test_parity.py::test_one[value with spaces]") == ( - "poetry run pytest 'tests/test_parity.py::test_one[value with spaces]' -q -o consider_namespace_packages=true" + assert report.mapped_count == len(OCR_CONTRACT.mapping.mappings) + assert report.total_count == ( + report.mapped_count + len(report.excluded_python_tests) + len(report.unmapped_python_tests) ) -def test_should_build_python_coverage_reports_below_the_target_directory( - tmp_path: Path, -) -> None: - args = _coverage_pytest_args(tmp_path) - - assert tmp_path.is_dir() - assert "--cov=litellm" in args - assert "--cov-context=test" in args - assert f"--cov-report=json:{tmp_path / 'python.json'}" in args - assert f"--cov-report=xml:{tmp_path / 'python.xml'}" in args - assert f"--cov-report=html:{tmp_path / 'python-html'}" in args - - -def test_should_report_confidence_for_each_sdk_section() -> None: - strategies = load_catalog() - cases = tuple(case for strategy in strategies for case in strategy.cases) - run = HarnessRun.from_cases(cases) - passing = run.results["e2e_parity:responses"] - passing.collected.add("tests/test_parity.py::test_one") - passing.record("tests/test_parity.py::test_one", RunStatus.PASSED) - - scores = { - score.sdk_function: score for score in section_confidence(run, strategies) - } - - assert scores["responses"].verified_strategies == 1 - assert scores["responses"].required_strategies == 4 - assert scores["responses"].percentage == 25 - assert scores["responses"].level.value == "MEDIUM" - assert scores["count_tokens"].percentage == 0 - assert scores["count_tokens"].level.value == "LOW" - - - -def test_should_report_no_ledger_for_a_function_without_one() -> None: - report = build_function_report("messages", repo_root=REPO_ROOT) - - assert report.has_ledger is False - assert report.is_clean is True - - -def test_should_report_ocr_ledger_stats_and_a_clean_audit() -> None: - ledger = load_ledger(ledger_path_for("ocr")) - - report = build_function_report("ocr", repo_root=REPO_ROOT) - - assert report.has_ledger is True - assert report.ledger.mapped_count == ledger.mapped_count - assert report.ledger.total_count == ledger.total_count - assert report.is_clean is True - - -def test_should_scope_validate_ledger_to_the_requested_function( - capsys: pytest.CaptureFixture[str], -) -> None: - exit_code = _validate_ledger({"messages"}) - - captured = capsys.readouterr() - assert exit_code == 0 - assert "messages" in captured.out - assert "no ledger yet" in captured.out - assert "ocr" not in captured.out - - -@pytest.mark.parametrize("strategy_id", (None, "e2e_parity", "trace_parity", "unit_tests", "existing_e2e_test_sdk")) -def test_should_validate_chat_completions_ledger_from_each_runner( - strategy_id: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - exit_code: Final = cli.main( - ("--validate-ledger", "--function", "chat_completions"), strategy_id=strategy_id - ) +def test_strategy_subcommand_accepts_function_filter(capsys: pytest.CaptureFixture[str]) -> None: + exit_code: Final = cli.main(["run", "unit_tests_mapping", "--function", "messages"]) captured: Final = capsys.readouterr() assert exit_code == 0 - assert "chat_completions" in captured.out - assert "no ledger yet" in captured.out - assert "ocr" not in captured.out - - -def test_should_have_every_python_and_rust_ocr_test_accounted_for_in_the_ledger() -> None: - ledger = load_ledger(ledger_path_for("ocr")) - - report = audit_ledger(ledger, repo_root=REPO_ROOT) - - assert report.is_clean, ( - "\nOCR test-parity ledger is out of sync with the live test files.\n" - f"Ledger references a Python test that no longer exists: {list(report.missing_python_tests)}\n" - f"Python test exists but is not tracked in the ledger: {list(report.stale_python_tests)}\n" - f"Ledger references a Rust test that no longer exists: {list(report.missing_rust_tests)}\n" - f"Rust test exists but is not tracked in the ledger: {list(report.stale_rust_tests)}\n" - ) + assert "- messages: not_implemented" in captured.out + assert "unit_tests_mapping:messages: not_implemented" not in captured.out diff --git a/type-discipline-budget.json b/type-discipline-budget.json index cbcb5dca443..ab1a793e09d 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -3,7 +3,7 @@ "limit": 22328 }, "LIT002": { - "limit": 26763 + "limit": 26750 }, "LIT003": { "limit": 261 @@ -27,10 +27,10 @@ "limit": 0 }, "LIT010": { - "limit": 16477 + "limit": 16468 }, "LIT011": { - "limit": 5519 + "limit": 5514 }, "LIT012": { "limit": 4489 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx index 4d18ec2ef5f..bef938cd31c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx @@ -90,7 +90,7 @@ describe("AgentsTable", () => { />, ); - const search = screen.getByPlaceholderText("Search agent names or descriptions..."); + const search = screen.getByPlaceholderText("Search agents by name, ID, or description..."); await user.type(search, "billing"); expect(screen.getByText("Billing Router")).toBeInTheDocument(); expect(screen.queryByText("Second Agent")).not.toBeInTheDocument(); @@ -101,11 +101,36 @@ describe("AgentsTable", () => { expect(screen.queryByText("Billing Router")).not.toBeInTheDocument(); }); + it("filters agents by a pasted agent_id so only that agent's row survives", async () => { + const user = userEvent.setup(); + render( + , + ); + + const search = screen.getByPlaceholderText("Search agents by name, ID, or description..."); + await user.click(search); + await user.paste("5f3c2a1b-9d8e-4f7a-b6c5-d4e3f2a1b0c9"); + expect(screen.getByText("Billing Router")).toBeInTheDocument(); + expect(screen.queryByText("Second Agent")).not.toBeInTheDocument(); + + await user.clear(search); + await user.paste("ffffffff-0000-4000-8000-000000000000"); + expect(screen.queryByText("Billing Router")).not.toBeInTheDocument(); + expect(screen.queryByText("Second Agent")).not.toBeInTheDocument(); + expect(screen.getByText("No matching agents")).toBeInTheDocument(); + }); + it("shows the no-match empty state when the search matches nothing", async () => { const user = userEvent.setup(); render(); - await user.type(screen.getByPlaceholderText("Search agent names or descriptions..."), "zzzz"); + await user.type(screen.getByPlaceholderText("Search agents by name, ID, or description..."), "zzzz"); expect(screen.queryByText("Test Agent")).not.toBeInTheDocument(); expect(screen.getByText("No matching agents")).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx index 35ed6b66425..aceb07e2e9a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx @@ -55,7 +55,12 @@ const AgentsTable: React.FC = ({ const [sorting, setSorting] = useState(DEFAULT_SORTING); const [searchTerm, setSearchTerm] = useState(""); const filteredAgents = useMemo( - () => filterBySearchTerm(agents, searchTerm, (agent) => [agent.agent_name, agent.agent_card_params?.description]), + () => + filterBySearchTerm(agents, searchTerm, (agent) => [ + agent.agent_name, + agent.agent_id, + agent.agent_card_params?.description, + ]), [agents, searchTerm], ); @@ -83,7 +88,7 @@ const AgentsTable: React.FC = ({ setSearchTerm(e.target.value)} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx index 24ec9043d21..2de078c8a33 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx @@ -12,6 +12,7 @@ import { ComboboxList, } from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { CacheField } from "./cacheSettingsFields"; @@ -64,6 +65,36 @@ const CacheFormField: React.FC = ({ field, embeddingModels, /> ); } + if (field.type === "select") { + const options = field.options ?? []; + const { id, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy, name, onBlur, disabled } = rest; + return ( + + ); + } if (field.type === "model-select") { const selected = embeddingModels.find((model) => model.value === value) ?? null; return ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts index 04dd6ff6038..9ebb9c191ed 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts @@ -1,4 +1,17 @@ -export type CacheFieldType = "string" | "password" | "integer" | "float" | "boolean" | "list" | "model-select"; +export type CacheFieldType = + | "string" + | "password" + | "integer" + | "float" + | "boolean" + | "list" + | "model-select" + | "select"; + +export interface CacheFieldOption { + readonly value: string; + readonly label: string; +} export type RedisType = "node" | "cluster" | "sentinel" | "semantic"; @@ -18,6 +31,7 @@ export interface CacheField { readonly helpText: string; readonly redisType: RedisType | null; readonly defaultValue?: string | number | boolean; + readonly options?: readonly CacheFieldOption[]; readonly rules?: CacheFieldRule[]; // Credential field: never prefilled into the form, and dropped from the save // payload when left untouched so the redacted marker is never persisted. @@ -179,6 +193,20 @@ export const CACHE_FIELDS: readonly CacheField[] = [ helpText: "Embedding model for semantic cache", redisType: "semantic", }, + { + name: "semantic_cache_scope", + label: "Semantic Cache Scope", + type: "select", + section: "semantic", + helpText: + "Who can share a semantic cache hit. Key shares hits between all end users of a key/team/org. End user also isolates per end user; requests without an end user fall back to the key scope.", + redisType: "semantic", + defaultValue: "key", + options: [ + { value: "key", label: "Key (shared by all end users of the key/team/org)" }, + { value: "end_user", label: "End user (isolated per end user)" }, + ], + }, { name: "ssl", label: "SSL", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.test.ts index c530519ee06..c851f4aad96 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.test.ts @@ -25,6 +25,7 @@ describe("buildInitialValues", () => { const values = buildInitialValues({}); expect(values.port).toBe("6379"); expect(values.similarity_threshold).toBe("0.8"); + expect(values.semantic_cache_scope).toBe("key"); expect(values.ssl).toBe(false); expect(values.db).toBe(""); }); @@ -75,6 +76,13 @@ describe("buildCachePayload", () => { expect(payload.similarity_threshold).toBe(0.9); }); + it("should send the semantic cache scope only for a semantic cache", () => { + const semantic = buildCachePayload("semantic", { semantic_cache_scope: "end_user" }, { forTesting: false }); + expect(semantic.semantic_cache_scope).toBe("end_user"); + const node = buildCachePayload("node", { semantic_cache_scope: "end_user" }, { forTesting: false }); + expect(node).not.toHaveProperty("semantic_cache_scope"); + }); + it("should keep type redis when testing a semantic cache so the test endpoint accepts it", () => { const payload = buildCachePayload("semantic", { similarity_threshold: 0.9 }, { forTesting: true }); expect(payload.type).toBe("redis"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.integration.test.tsx index 07cc73cddc4..918d8947151 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.integration.test.tsx @@ -138,6 +138,26 @@ describe("CacheSettings advanced settings round-trip", () => { expect(updateCacheSettingsCall.mock.calls[0][1]).not.toHaveProperty("redis_startup_nodes"); }); + it("saves the semantic cache scope picked from the select and shows the loaded value", async () => { + getCacheSettingsCall.mockResolvedValue({ + current_values: { redis_type: "semantic", host: "redis.internal", semantic_cache_scope: "key" }, + }); + const user = userEvent.setup(); + renderSettings(); + const trigger = await screen.findByLabelText("Semantic Cache Scope"); + expect(trigger).toHaveTextContent("Key (shared by all end users of the key/team/org)"); + + await user.click(trigger); + await user.click(await screen.findByRole("option", { name: "End user (isolated per end user)" })); + await save(user); + + await waitFor(() => expect(updateCacheSettingsCall).toHaveBeenCalledTimes(1)); + expect(updateCacheSettingsCall.mock.calls[0][1]).toMatchObject({ + type: "redis-semantic", + semantic_cache_scope: "end_user", + }); + }); + it("does not block the save on a malformed value inside a collapsed advanced section", async () => { getCacheSettingsCall.mockResolvedValue({ current_values: { host: "redis.internal" } }); const user = userEvent.setup(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts index 8c9b33f2c3e..84be7e2ef49 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts @@ -518,6 +518,24 @@ describe("useKeys", () => { const callUrl = mockFetch.mock.calls[0][0]; expect(callUrl).not.toContain("agent_id"); }); + + it("sends the combined alias-or-ID search as the search param, separate from key_alias and key_hash", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockKeysResponse, + }); + + const { result } = renderHook(() => useKeys(1, 10, { search: "pasted-key-id" }), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const callUrl = new URL(mockFetch.mock.calls[0][0], "http://localhost"); + expect(callUrl.searchParams.get("search")).toBe("pasted-key-id"); + expect(callUrl.searchParams.has("key_alias")).toBe(false); + expect(callUrl.searchParams.has("key_hash")).toBe(false); + }); }); describe("useDeletedKeys", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index 94ded01679d..7e7089e685f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -40,6 +40,7 @@ export interface KeyListCallOptions { selectedKeyAlias?: string | null; userID?: string | null; keyHash?: string | null; + search?: string | null; sortBy?: string | null; sortOrder?: string | null; expand?: string | null; @@ -61,6 +62,7 @@ const keyListCall = async (accessToken: string, page: number, pageSize: number, organization_id: options.organizationID, key_alias: options.selectedKeyAlias, key_hash: options.keyHash, + search: options.search, user_id: options.userID, page, size: pageSize, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx index 5100b998b80..984b8135466 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx @@ -95,6 +95,7 @@ describe("MemoryTable", () => { it("shows the filtered-empty copy when a search is active", () => { render(); expect(screen.getByText("No matching memories")).toBeInTheDocument(); + expect(screen.getByText("No memories match your search.")).toBeInTheDocument(); expect(screen.queryByText("No memories stored yet")).not.toBeInTheDocument(); }); @@ -128,6 +129,7 @@ describe("MemoryTable", () => { const onRefresh = vi.fn(); render(); + expect(screen.getByPlaceholderText("Search by key prefix or memory ID…")).toBeInTheDocument(); fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "u" } }); expect(onSearchChange).toHaveBeenCalledWith("u"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.tsx index 50dd04ee14c..3e37faafe15 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.tsx @@ -36,7 +36,7 @@ function MemoryEmptyState({ hasActiveSearch }: { hasActiveSearch: boolean }) {
{hasActiveSearch - ? "No memories have keys starting with your search." + ? "No memories match your search." : "Memories your agents store under /v1/memory will appear here."}
@@ -81,7 +81,7 @@ export function MemoryTable({ table={table} searchValue={searchValue} onSearchChange={onSearchChange} - searchPlaceholder='Filter by key prefix, e.g. "user:"' + searchPlaceholder="Search by key prefix or memory ID…" onRefresh={onRefresh} isRefreshing={isRefreshing} showViewOptions={false} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx index 9ccef5357b9..b703df652c2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx @@ -1,8 +1,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, render, screen } from "@testing-library/react"; +import type { PaginationState } from "@tanstack/react-table"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRow } from "@/components/networking"; @@ -13,10 +14,13 @@ interface CapturedTableProps { rowCount: number; data: MemoryRow[]; hasActiveSearch: boolean; + onSearchChange: (value: string) => void; + onPaginationChange: (state: PaginationState) => void; onViewClick: (row: MemoryRow) => void; } const captured = vi.hoisted(() => ({ current: null as CapturedTableProps | null })); +const fetchMemoryListMock = vi.hoisted(() => vi.fn()); vi.mock("./MemoryTable", () => ({ MemoryTable: function MemoryTableMock(props: CapturedTableProps) { @@ -25,6 +29,15 @@ vi.mock("./MemoryTable", () => ({ }, })); +vi.mock("@/components/networking", async (importOriginal) => ({ + ...(await importOriginal()), + fetchMemoryList: fetchMemoryListMock, +})); + +vi.mock("@tanstack/react-pacer/debouncer", () => ({ + useDebouncedValue: (value: unknown) => [value, { cancel: vi.fn(), flush: vi.fn() }], +})); + const renderView = (accessToken: string | null) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( @@ -35,6 +48,28 @@ const renderView = (accessToken: string | null) => { }; describe("MemoryView", () => { + beforeEach(() => { + fetchMemoryListMock.mockReset(); + fetchMemoryListMock.mockResolvedValue({ memories: [], total: 0 }); + }); + + it("queries the server with the search box value as `search` and resets to page 1", async () => { + renderView("token"); + await waitFor(() => expect(fetchMemoryListMock).toHaveBeenCalled()); + + act(() => captured.current?.onPaginationChange({ pageIndex: 2, pageSize: 50 })); + await waitFor(() => + expect(fetchMemoryListMock).toHaveBeenLastCalledWith("token", expect.objectContaining({ page: 3 })), + ); + + act(() => captured.current?.onSearchChange("mem-abc123")); + + await waitFor(() => + expect(fetchMemoryListMock).toHaveBeenLastCalledWith("token", { search: "mem-abc123", page: 1, pageSize: 50 }), + ); + expect(captured.current?.hasActiveSearch).toBe(true); + }); + it("keeps the table out of the skeleton state when the token is null (disabled query)", () => { renderView(null); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx index 1d2e5150a62..58d4e42aa96 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx @@ -43,10 +43,8 @@ export const MemoryView: React.FC = ({ accessToken }) => { queryKey: [MEMORY_LIST_KEY, debouncedSearch, pagination.pageIndex, pagination.pageSize], queryFn: () => { if (!accessToken) throw new Error("Access token required"); - // Prefix search matches the Redis-style mental model (namespace scan): - // typing "user:" finds "user:profile", "user:prefs", etc. return fetchMemoryList(accessToken, { - keyPrefix: debouncedSearch || undefined, + search: debouncedSearch || undefined, page: pagination.pageIndex + 1, pageSize: pagination.pageSize, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.test.tsx index 52efd6407f8..edbb897fb2f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.test.tsx @@ -65,11 +65,13 @@ describe("PromptTable", () => { expect(within(rows[1]).getByText("prompt-older")).toBeInTheDocument(); }); - it("should call onPromptClick when the prompt ID is clicked", async () => { + it("should call onPromptClick with the row's environment, defaulting to development", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: "prompt-newer" })); - expect(mockOnPromptClick).toHaveBeenCalledWith("prompt-newer"); + expect(mockOnPromptClick).toHaveBeenCalledWith("prompt-newer", "production"); + await user.click(screen.getByRole("button", { name: "prompt-older" })); + expect(mockOnPromptClick).toHaveBeenCalledWith("prompt-older", "development"); }); it("should label the environment and default missing environments to development", () => { @@ -83,7 +85,7 @@ describe("PromptTable", () => { render(); await user.click(screen.getByTestId("prompt-actions-prompt-newer")); await user.click(await screen.findByTestId("prompt-action-delete")); - expect(mockOnDeleteClick).toHaveBeenCalledWith("prompt-newer", "prompt-newer"); + expect(mockOnDeleteClick).toHaveBeenCalledWith("prompt-newer", "prompt-newer", "production"); }); it("should copy the prompt ID through the actions menu", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx index 47d4f64f254..c766042ac44 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx @@ -13,8 +13,8 @@ import { ModelGroupInfo } from "./prompt_utils"; interface PromptTableProps { promptsList: PromptSpec[]; isLoading: boolean; - onPromptClick?: (id: string) => void; - onDeleteClick?: (id: string, name: string) => void; + onPromptClick?: (id: string, environment: string) => void; + onDeleteClick?: (id: string, name: string, environment: string) => void; accessToken: string | null; isAdmin: boolean; } @@ -74,7 +74,9 @@ const PromptTable: React.FC = ({ prompt.prompt_id || String(index)} + getRowId={(prompt, index) => + prompt.prompt_id ? `${prompt.prompt_id}::${prompt.environment || "development"}` : String(index) + } sortingMode="client" sorting={sorting} onSortingChange={setSorting} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTableColumns.tsx index ae584ef6df6..f927a6d1486 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTableColumns.tsx @@ -64,7 +64,7 @@ function PromptModelCell({ prompt, modelHubData }: { prompt: PromptSpec; modelHu interface PromptRowActionsProps { prompt: PromptSpec; isAdmin: boolean; - onDeleteClick?: (id: string, name: string) => void; + onDeleteClick?: (id: string, name: string, environment: string) => void; } function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsProps) { @@ -91,7 +91,13 @@ function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsPr onDeleteClick?.(prompt.prompt_id, prompt.prompt_id || "Unknown Prompt")} + onClick={() => + onDeleteClick?.( + prompt.prompt_id, + prompt.prompt_id || "Unknown Prompt", + prompt.environment || "development", + ) + } > Delete @@ -106,8 +112,8 @@ function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsPr interface PromptTableColumnsDeps { modelHubData: Map; isAdmin: boolean; - onPromptClick?: (id: string) => void; - onDeleteClick?: (id: string, name: string) => void; + onPromptClick?: (id: string, environment: string) => void; + onDeleteClick?: (id: string, name: string, environment: string) => void; } export const getPromptTableColumns = ({ @@ -128,7 +134,11 @@ export const getPromptTableColumns = ({ title={row.original.prompt_id} titleClassName="font-mono text-xs font-normal" className="max-w-60" - onClick={onPromptClick ? () => onPromptClick(row.original.prompt_id) : undefined} + onClick={ + onPromptClick + ? () => onPromptClick(row.original.prompt_id, row.original.environment || "development") + : undefined + } /> ), }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx index 3b0ee3a3ce2..a1c7280422f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx @@ -16,21 +16,31 @@ vi.mock("./PromptTable", () => ({ __esModule: true, default: ({ isLoading, + onPromptClick, onDeleteClick, }: { isLoading: boolean; - onDeleteClick: (id: string, name: string) => void; + onPromptClick: (id: string, environment: string) => void; + onDeleteClick: (id: string, name: string, environment: string) => void; }) => (
{isLoading ? "table-loading" : "table-loaded"} - +
), })); -vi.mock("./prompt_info", () => ({ __esModule: true, default: () =>
prompt-info-view
})); +vi.mock("./prompt_info", () => ({ + __esModule: true, + default: ({ initialEnvironment }: { initialEnvironment?: string }) => ( +
prompt-info-view:{initialEnvironment ?? "none"}
+ ), +})); vi.mock("./add_prompt_form", () => ({ __esModule: true, default: ({ visible }: { visible: boolean }) => (visible ?
add-prompt-form
: null), @@ -141,6 +151,22 @@ describe("PromptsPanel toolbar", () => { }); }); +describe("PromptsPanel row navigation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetPromptsList.mockResolvedValue({ prompts: [] } as never); + }); + + it("should open the info view preselected to the clicked row's environment", async () => { + const user = userEvent.setup(); + renderPanel("Admin"); + + await user.click(await screen.findByRole("button", { name: "row-open" })); + + expect(screen.getByText("prompt-info-view:staging")).toBeInTheDocument(); + }); +}); + describe("PromptsPanel delete confirmation", () => { beforeEach(() => { vi.clearAllMocks(); @@ -154,13 +180,13 @@ describe("PromptsPanel delete confirmation", () => { await user.click(await screen.findByRole("button", { name: "row-delete" })); - expect(await screen.findByText(/delete prompt: my-prompt/i)).toBeInTheDocument(); + expect(await screen.findByText(/the staging copy of prompt: my-prompt/i)).toBeInTheDocument(); expect(screen.getByText(/cannot be undone/i)).toBeInTheDocument(); expect(mockDeletePromptCall).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: /^delete$/i })); - await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1")); + await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1", "staging")); }); it("should abandon the delete when the confirmation is dismissed", async () => { @@ -168,11 +194,11 @@ describe("PromptsPanel delete confirmation", () => { renderPanel("Admin"); await user.click(await screen.findByRole("button", { name: "row-delete" })); - await screen.findByText(/delete prompt: my-prompt/i); + await screen.findByText(/the staging copy of prompt: my-prompt/i); await user.click(screen.getByRole("button", { name: /cancel/i })); - await waitFor(() => expect(screen.queryByText(/delete prompt: my-prompt/i)).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByText(/the staging copy of prompt: my-prompt/i)).not.toBeInTheDocument()); expect(mockDeletePromptCall).not.toHaveBeenCalled(); }); @@ -187,14 +213,14 @@ describe("PromptsPanel delete confirmation", () => { renderPanel("Admin"); await user.click(await screen.findByRole("button", { name: "row-delete" })); - await screen.findByText(/delete prompt: my-prompt/i); + await screen.findByText(/the staging copy of prompt: my-prompt/i); await user.click(screen.getByRole("button", { name: /^delete$/i })); - await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1")); + await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1", "staging")); await user.keyboard("{Escape}"); - expect(screen.getByText(/delete prompt: my-prompt/i)).toBeInTheDocument(); + expect(screen.getByText(/the staging copy of prompt: my-prompt/i)).toBeInTheDocument(); finishDelete(); - await waitFor(() => expect(screen.queryByText(/delete prompt: my-prompt/i)).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByText(/the staging copy of prompt: my-prompt/i)).not.toBeInTheDocument()); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx index 3594e933704..2d8d905c480 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx @@ -41,11 +41,12 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { const [isLoading, setIsLoading] = useState(true); const [selectedEnvironment, setSelectedEnvironment] = useState(undefined); const [selectedPromptId, setSelectedPromptId] = useState(null); + const [selectedPromptEnvironment, setSelectedPromptEnvironment] = useState(undefined); const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [showEditorView, setShowEditorView] = useState(false); const [editPromptData, setEditPromptData] = useState(null); const [isDeleting, setIsDeleting] = useState(false); - const [promptToDelete, setPromptToDelete] = useState<{ id: string; name: string } | null>(null); + const [promptToDelete, setPromptToDelete] = useState<{ id: string; name: string; environment: string } | null>(null); // Admin Viewer follows the read-parity rule: see prompts, no writes. const canModify = userRole ? isProxyAdminRole(userRole) : false; @@ -71,8 +72,9 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { fetchPrompts(); }, [accessToken, selectedEnvironment]); - const handlePromptClick = (promptId: string) => { + const handlePromptClick = (promptId: string, environment: string) => { setSelectedPromptId(promptId); + setSelectedPromptEnvironment(environment); }; const handleAddPrompt = () => { @@ -111,8 +113,8 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { setSelectedPromptId(null); }; - const handleDeleteClick = (promptId: string, promptName: string) => { - setPromptToDelete({ id: promptId, name: promptName }); + const handleDeleteClick = (promptId: string, promptName: string, environment: string) => { + setPromptToDelete({ id: promptId, name: promptName, environment }); }; const handleDeleteConfirm = async () => { @@ -120,8 +122,8 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { setIsDeleting(true); try { - await deletePromptCall(accessToken, promptToDelete.id); - toast.success(`Prompt "${promptToDelete.name}" deleted successfully`); + await deletePromptCall(accessToken, promptToDelete.id, promptToDelete.environment); + toast.success(`Prompt "${promptToDelete.name}" deleted successfully from ${promptToDelete.environment}`); fetchPrompts(); // Refresh the list } catch (error) { console.error("Error deleting prompt:", error); @@ -148,6 +150,7 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { ) : selectedPromptId ? ( setSelectedPromptId(null)} accessToken={accessToken} isAdmin={canModify} @@ -219,7 +222,8 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { Delete Prompt - Are you sure you want to delete prompt: {promptToDelete.name} ? This action cannot be undone. + Are you sure you want to delete the {promptToDelete.environment} copy of prompt: {promptToDelete.name}? + This action cannot be undone. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.test.tsx index a1b4ad52634..7fa44a4dfe5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.test.tsx @@ -44,4 +44,28 @@ describe("PromptCodeSnippets", () => { expect(screen.getByRole("combobox", { name: "Language" })).toHaveTextContent("Python (OpenAI SDK)"); }); + + it("includes the viewed environment in every generated request", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + render( + , + ); + await user.click(screen.getByRole("button", { name: /get code/i })); + await screen.findByText("Generated Code"); + + await user.click(screen.getByRole("button", { name: /copy to clipboard/i })); + expect(await navigator.clipboard.readText()).toContain('"prompt_environment": "development"'); + + await user.click(screen.getByRole("tab", { name: "With Version" })); + await user.click(screen.getByRole("button", { name: /copy to clipboard/i })); + const versionSnippet = await navigator.clipboard.readText(); + expect(versionSnippet).toContain('"prompt_environment": "development"'); + expect(versionSnippet).toContain('"prompt_version": 2'); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.tsx index af7d6421265..a6adc160674 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.tsx @@ -22,6 +22,7 @@ interface PromptCodeSnippetsProps { promptVariables?: Record; accessToken: string | null; version?: string; + environment?: string; proxySettings?: { PROXY_BASE_URL?: string; LITELLM_UI_API_DOC_BASE_URL?: string | null; @@ -34,6 +35,7 @@ const PromptCodeSnippets: React.FC = ({ promptVariables = {}, accessToken, version = "1", + environment, proxySettings, }) => { const syntaxTheme = useSyntaxTheme(coy); @@ -64,6 +66,9 @@ const PromptCodeSnippets: React.FC = ({ // Generate code based on selected language and tab const generateCode = () => { const hasVariables = Object.keys(promptVariables).length > 0; + const curlEnvironment = environment ? `,\n "prompt_environment": "${environment}"` : ""; + const pythonEnvironment = environment ? `,\n "prompt_environment": "${environment}"` : ""; + const jsEnvironment = environment ? `,\n prompt_environment: "${environment}"` : ""; if (selectedLanguage === "curl") { if (selectedTab === "basic") { @@ -72,7 +77,7 @@ const PromptCodeSnippets: React.FC = ({ -H 'Authorization: Bearer ${effectiveApiKey}' \\ -d '{ "model": "${model}", - "prompt_id": "${promptId}"${ + "prompt_id": "${promptId}"${curlEnvironment}${ hasVariables ? `, "prompt_variables": ${JSON.stringify(promptVariables, null, 6).replace(/\n/g, "\n ")}` @@ -85,7 +90,7 @@ const PromptCodeSnippets: React.FC = ({ -H 'Authorization: Bearer ${effectiveApiKey}' \\ -d '{ "model": "${model}", - "prompt_id": "${promptId}"${ + "prompt_id": "${promptId}"${curlEnvironment}${ hasVariables ? `, "prompt_variables": ${JSON.stringify(promptVariables, null, 6).replace(/\n/g, "\n ")}` @@ -104,7 +109,7 @@ const PromptCodeSnippets: React.FC = ({ -H 'Authorization: Bearer ${effectiveApiKey}' \\ -d '{ "model": "${model}", - "prompt_id": "${promptId}", + "prompt_id": "${promptId}"${curlEnvironment}, "prompt_version": ${version}, "messages": [ { @@ -127,7 +132,7 @@ client = openai.OpenAI( response = client.chat.completions.create( model="${model}", extra_body={ - "prompt_id": "${promptId}"${ + "prompt_id": "${promptId}"${pythonEnvironment}${ hasVariables ? `, "prompt_variables": ${JSON.stringify(promptVariables, null, 8).replace(/\n/g, "\n ")}` @@ -145,7 +150,7 @@ response = client.chat.completions.create( {"role": "user", "content": "hi"} ], extra_body={ - "prompt_id": "${promptId}"${ + "prompt_id": "${promptId}"${pythonEnvironment}${ hasVariables ? `, "prompt_variables": ${JSON.stringify(promptVariables, null, 8).replace(/\n/g, "\n ")}` @@ -163,7 +168,7 @@ response = client.chat.completions.create( {"role": "user", "content": "Who are u"} ], extra_body={ - "prompt_id": "${promptId}", + "prompt_id": "${promptId}"${pythonEnvironment}, "prompt_version": ${version} } ) @@ -186,9 +191,9 @@ async function main() { model: "${model}", ${ hasVariables - ? `prompt_id: "${promptId}", + ? `prompt_id: "${promptId}"${jsEnvironment}, prompt_variables: ${JSON.stringify(promptVariables, null, 8).replace(/\n/g, "\n ")}` - : `prompt_id: "${promptId}"` + : `prompt_id: "${promptId}"${jsEnvironment}` } }); @@ -206,9 +211,9 @@ async function main() { ], ${ hasVariables - ? `prompt_id: "${promptId}", + ? `prompt_id: "${promptId}"${jsEnvironment}, prompt_variables: ${JSON.stringify(promptVariables, null, 8).replace(/\n/g, "\n ")}` - : `prompt_id: "${promptId}"` + : `prompt_id: "${promptId}"${jsEnvironment}` } }); @@ -224,7 +229,7 @@ async function main() { messages: [ { role: "user", content: "Who are u" } ], - prompt_id: "${promptId}", + prompt_id: "${promptId}"${jsEnvironment}, prompt_version: ${version} }); @@ -241,7 +246,7 @@ main();`; if (isModalVisible) { setGeneratedCode(generateCode()); } - }, [isModalVisible, selectedLanguage, selectedTab, promptId, model, promptVariables]); + }, [isModalVisible, selectedLanguage, selectedTab, promptId, model, promptVariables, version, environment]); return ( <> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.test.tsx index 5194cdd4e63..3afc00e37a5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.test.tsx @@ -2,7 +2,9 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import PromptEditorHeader from "./PromptEditorHeader"; -vi.mock("./PromptCodeSnippets", () => ({ default: () => })); +vi.mock("./PromptCodeSnippets", () => ({ + default: ({ environment }: { environment?: string }) => , +})); describe("PromptEditorHeader", () => { it("preserves navigation, naming, and save actions", () => { @@ -48,5 +50,6 @@ describe("PromptEditorHeader", () => { ); expect(screen.getByRole("combobox", { name: "Environment" })).toHaveTextContent(label); + expect(screen.getByRole("button", { name: "Get Code" })).toHaveAttribute("data-environment", environment); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.tsx index eea9755054f..04cac01365a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.tsx @@ -89,6 +89,7 @@ const PromptEditorHeader: React.FC = ({ promptVariables={promptVariables} accessToken={accessToken} version={version?.replace("v", "") || "1"} + environment={environment} proxySettings={proxySettings} /> {editMode && onShowHistory && ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx index bb29f12ac42..b14d5c3d91f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.test.tsx @@ -12,7 +12,9 @@ vi.mock("@/components/networking", () => ({ })); vi.mock("./prompt_editor_view/PromptCodeSnippets", () => ({ - default: () =>
, + default: ({ environment }: { environment?: string }) => ( +
+ ), })); const promptWithoutTemplate = { @@ -29,6 +31,67 @@ const promptWithoutTemplate = { environments: [], }; +describe("PromptInfoView environment scoping", () => { + beforeEach(() => { + vi.mocked(networking.getPromptInfo).mockReset().mockResolvedValue(promptWithoutTemplate); + vi.mocked(networking.getPromptVersions).mockReset().mockResolvedValue({ prompts: [] }); + }); + + it("fetches the initial environment it was opened with", async () => { + render( + , + ); + + await screen.findByRole("tab", { name: "Raw JSON" }); + expect(networking.getPromptInfo).toHaveBeenCalledWith("sk-test", "support-reply", "staging"); + }); + + it("fetches the serve default when opened without an environment", async () => { + render(); + + await screen.findByRole("tab", { name: "Raw JSON" }); + expect(networking.getPromptInfo).toHaveBeenCalledWith("sk-test", "support-reply", undefined); + }); +}); + +describe("PromptInfoView code snippets", () => { + beforeEach(() => { + vi.mocked(networking.getPromptVersions).mockReset().mockResolvedValue({ prompts: [] }); + }); + + it.each([ + ["a prompt with several environments", "staging", ["development", "staging"]], + ["a config prompt with no environment list", "development", []], + ])("hands the viewed environment of %s to the code snippets", async (_label, environment, environments) => { + vi.mocked(networking.getPromptInfo) + .mockReset() + .mockResolvedValue({ + ...promptWithoutTemplate, + prompt_spec: { ...promptWithoutTemplate.prompt_spec, environment }, + environments, + }); + + render( + , + ); + + await screen.findByRole("tab", { name: "Raw JSON" }); + expect(screen.getByTestId("prompt-code-snippets")).toHaveAttribute("data-environment", environment); + }); +}); + describe("PromptInfoView tabs", () => { beforeEach(() => { vi.mocked(networking.getPromptInfo).mockReset().mockResolvedValue(promptWithoutTemplate); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx index c6a6f09fcaa..062e1d84a0a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_info.tsx @@ -20,6 +20,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " export interface PromptInfoProps { promptId: string; + initialEnvironment?: string; onClose: () => void; accessToken: string | null; isAdmin: boolean; @@ -27,7 +28,15 @@ export interface PromptInfoProps { onEdit?: (promptData: any) => void; } -const PromptInfoView: React.FC = ({ promptId, onClose, accessToken, isAdmin, onDelete, onEdit }) => { +const PromptInfoView: React.FC = ({ + promptId, + initialEnvironment, + onClose, + accessToken, + isAdmin, + onDelete, + onEdit, +}) => { const [promptData, setPromptData] = useState(null); const [promptTemplate, setPromptTemplate] = useState(null); const [rawApiResponse, setRawApiResponse] = useState(null); @@ -43,7 +52,7 @@ const PromptInfoView: React.FC = ({ promptId, onClose, accessTo const [selectedVersion, setSelectedVersion] = useState(null); const [loadingVersions, setLoadingVersions] = useState(false); - // Initial fetch — no environment filter, gets default + all environments list + // Fetches the requested environment (or the serve-time default when omitted) plus the environments list const fetchPromptInfo = async (environment?: string) => { try { setLoading(true); @@ -89,7 +98,7 @@ const PromptInfoView: React.FC = ({ promptId, onClose, accessTo setSelectedEnv(null); setEnvironments([]); setVersionHistory([]); - fetchPromptInfo(); + fetchPromptInfo(initialEnvironment); }, [promptId, accessToken]); // When environment changes (user clicks tab), re-fetch — skip initial mount @@ -212,6 +221,7 @@ const PromptInfoView: React.FC = ({ promptId, onClose, accessTo promptVariables={extractTemplateVariables(promptTemplate?.content)} accessToken={accessToken} version={currentVersion} + environment={selectedEnv ?? promptData.environment} /> -
-
- { - setSelectedTagId(tag.name); - setEditTag(true); - }} - onDelete={handleDelete} - onSelectTag={setSelectedTagId} - /> -
+
+ { + setSelectedTagId(tag.name); + setEditTag(true); + }} + onDelete={handleDelete} + onSelectTag={setSelectedTagId} + />
{/* Create Tag Modal */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 095bdbf7250..6b0423067fd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -323,5 +323,26 @@ describe("ViewUserDashboard", () => { expect(latest[2]).toBe(1); }); }); + + it("sends the toolbar search as the combined search param instead of user_email", async () => { + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + const searchedUserId = "a6f5c02b-0163-45ce-815f-f88d10e95686"; + await user.type(screen.getByPlaceholderText("Search by email or ID…"), searchedUserId); + + await waitFor(() => { + const latest = userListCall.mock.calls[userListCall.mock.calls.length - 1]; + expect(latest[11]).toBe(searchedUserId); + }); + const latest = userListCall.mock.calls[userListCall.mock.calls.length - 1]; + expect(latest[1]).toBeNull(); + expect(latest[4]).toBeNull(); + expect(latest[2]).toBe(1); + }); }); }); 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 7a3133840be..1ed23e7f523 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 @@ -65,7 +65,7 @@ const ViewUserDashboard: React.FC = ({ const [sorting, setSorting] = useState(DEFAULT_SORTING); const [columnFilters, setColumnFilters] = useState([]); const [searchInput, setSearchInput] = useState(""); - const [searchEmail] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); + const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); const [rowSelection, setRowSelection] = useState({}); const [selectionMode, setSelectionMode] = useState(false); @@ -222,12 +222,12 @@ const ViewUserDashboard: React.FC = ({ const ssoUserIdFilter = getFilterValue("sso_user_id"); const userRoleFilter = getFilterValue("user_role"); const teamFilter = getFilterValue("team"); - const emailFilter = searchEmail.trim() || null; + const searchFilter = searchQuery.trim() || null; const userListQueryFilters = { page: pagination.pageIndex + 1, pageSize: pagination.pageSize, - email: emailFilter, + search: searchFilter, userId: userIdFilter, ssoUserId: ssoUserIdFilter, role: userRoleFilter, @@ -247,13 +247,14 @@ const ViewUserDashboard: React.FC = ({ userIdFilter ? [userIdFilter] : null, pagination.pageIndex + 1, pagination.pageSize, - emailFilter, + null, userRoleFilter ?? null, teamFilter ?? null, ssoUserIdFilter ?? null, sortBy, sortOrder, orgAdminOrgIds ? orgAdminOrgIds.map((o) => o.organization_id) : null, + searchFilter, ); }, enabled: Boolean(accessToken && token && userRole && userID), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx index 26663f5d9e2..875df74652c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx @@ -158,7 +158,7 @@ export function UsersTable({ table={table} searchValue={searchValue} onSearchChange={onSearchChange} - searchPlaceholder="Search by email…" + searchPlaceholder="Search by email or ID…" onOpenFilters={() => setFiltersOpen(true)} filterLabels={FILTER_LABELS} formatFilterValue={formatFilterValue} 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 f74f444ce26..1745c710c51 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 @@ -137,8 +137,8 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID />
) : ( -
-
+
+

Vector Store Management

diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 475e3dcd70b..74f44db5cb9 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -400,7 +400,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, } return ( -
+
{publicPage == false ? (
{/* Header with Title, Description and URL */} diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index ef58237a6aa..4be91f22339 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -559,6 +559,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser { key: "your-teams", label: "Your Teams", + className: "flex min-h-0 flex-1 flex-col", children: ( <> = ({ accessToken, userID, userRole, premiumUser { key: "available-teams", label: "Available Teams", + className: "min-h-0 flex-1 overflow-y-auto", children: , }, ...(isProxyAdminRole(userRole || "") @@ -615,6 +617,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser { key: "default-settings", label: "Default Team Settings", + className: "min-h-0 flex-1 overflow-y-auto", children: , }, ] @@ -622,7 +625,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser ]; return ( -
+
{selectedTeamId ? ( = ({ accessToken, userID, userRole, premiumUser premiumUser={premiumUser} /> ) : ( - + } title="Teams" @@ -674,7 +677,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser )} /> {tabItems.map((item) => ( - + {item.children} ))} diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx index 0b38d51e36a..3fb19e522f3 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx @@ -164,7 +164,7 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet isLoading={isLoading} loadingMessage="Loading teams..." noDataMessage="No teams found" - maxBodyHeight="calc(75vh - 210px)" + fillHeight size="compact" toolbar={(table) => ( <> diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index c69662b69dc..881b0b93ff9 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -542,6 +542,19 @@ describe("server-side filtering – the LIT-4080 regression guard", () => { expect((lastCall[2] ?? {}).userID).toBeUndefined(); }); }); + + it("sends the search box as the combined alias-or-ID search rather than the key-alias filter", async () => { + renderWithProviders(); + + fireEvent.change(screen.getByPlaceholderText(/Search by key alias or ID/), { target: { value: mockKey.token } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: mockKey.token })); + }); + const lastOptions = mockUseKeys.mock.calls.at(-1)?.[2]; + expect(lastOptions?.selectedKeyAlias).toBeUndefined(); + expect(lastOptions?.keyHash).toBeUndefined(); + }); }); describe("pagination display – total count comes from useKeys", () => { @@ -663,7 +676,7 @@ describe("table state lives in the URL so it survives leaving and returning to t expect(mockUseKeys).toHaveBeenLastCalledWith( 3, 25, - expect.objectContaining({ selectedKeyAlias: "prod", sortBy: "spend", sortOrder: "asc" }), + expect.objectContaining({ search: "prod", sortBy: "spend", sortOrder: "asc" }), ); }); expect(screen.getByPlaceholderText(/Search by key alias/)).toHaveValue("prod"); @@ -736,7 +749,7 @@ describe("table state lives in the URL so it survives leaving and returning to t fireEvent.change(screen.getByPlaceholderText(/Search by key alias/), { target: { value: "prod" } }); await waitFor(() => { - expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ selectedKeyAlias: "prod" })); + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: "prod" })); }); await waitFor(() => { expect(lastSearchParam(onUrlUpdate, "page")).toBeNull(); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index c424966a0a3..f28ae27b6bc 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -118,7 +118,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { const keyListOptions = { teamID: appliedFilters.team_id || undefined, organizationID: appliedFilters.org_id || undefined, - selectedKeyAlias: searchQuery.trim() || undefined, + search: searchQuery.trim() || undefined, userID: appliedFilters.user_id || undefined, keyHash: appliedFilters.key_hash || undefined, sortBy, @@ -256,7 +256,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { } return ( -
+
} title="Virtual Keys" @@ -283,7 +283,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { isLoading={isLoading} loadingMessage="Loading keys..." noDataMessage="No keys found" - maxBodyHeight="calc(75vh - 210px)" + fillHeight size="compact" toolbar={(table) => ( <> @@ -291,7 +291,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { table={table} searchValue={searchInput} onSearchChange={handleSearchChange} - searchPlaceholder="Search by key alias…" + searchPlaceholder="Search by key alias or ID…" onRefresh={() => refetch?.()} isRefreshing={isFetching} onOpenFilters={() => setFiltersOpen(true)} diff --git a/ui/litellm-dashboard/src/components/add_model/AffinityControls.tsx b/ui/litellm-dashboard/src/components/add_model/AffinityControls.tsx index 4d9e2122739..9022d424369 100644 --- a/ui/litellm-dashboard/src/components/add_model/AffinityControls.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AffinityControls.tsx @@ -1,26 +1,58 @@ import React from "react"; +import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; -import { DEFAULT_DEPLOYMENT_AFFINITY } from "./ComplexityRouterConfig"; +import { DEFAULT_DEPLOYMENT_AFFINITY, DEFAULT_SESSION_AFFINITY_TTL_SECONDS } from "./ComplexityRouterConfig"; export const AffinityControls: React.FC<{ value: ComplexityRouterConfigValue; onChange: (value: ComplexityRouterConfigValue) => void; -}> = ({ value, onChange }) => ( - <> -
- onChange({ ...value, deployment_affinity: deploymentAffinity })} - aria-label="Pin a session to one deployment per model group" - /> - Pin a session to one deployment per model group -
- - Keeps a session on the same deployment within a group, so provider prompt caches stay warm. Turn off to - load-balance every turn. - - -); +}> = ({ value, onChange }) => { + const [ttlDraft, setTtlDraft] = React.useState(null); + const commitTtl = (raw: string) => { + setTtlDraft(null); + if (raw.trim() === "") { + onChange({ ...value, session_affinity_ttl_seconds: undefined }); + return; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return; + onChange({ ...value, session_affinity_ttl_seconds: Math.max(1, Math.round(parsed)) }); + }; + + return ( + <> +
+ onChange({ ...value, deployment_affinity: deploymentAffinity })} + aria-label="Pin a session to one deployment per model group" + /> + Pin a session to one deployment per model group +
+ + Keeps a session on the same deployment within a group, so provider prompt caches stay warm. Turn off to + load-balance every turn. + +
+ + setTtlDraft(event.target.value)} + onBlur={(event) => commitTtl(event.target.value)} + /> + + Refreshes after every request that reuses a pin. Empty tracks the backend default of{" "} + {DEFAULT_SESSION_AFFINITY_TTL_SECONDS} seconds. + +
+ + ); +}; 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 02cb0543e17..bdca5205b2e 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -947,6 +947,46 @@ describe("ComplexityRouterConfig affinity panel", () => { expect(screen.getByRole("switch", { name: "Pin a session to one deployment per model group" })).not.toBeChecked(); }); + + it("writes an idle TTL on blur and keeps the partial input as a draft while typing", () => { + const onChange = vi.fn(); + renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Affinity")); + + const ttl = screen.getByLabelText("How long a pin survives idle (seconds)"); + expect(ttl).toHaveAttribute("placeholder", "3600"); + fireEvent.change(ttl, { target: { value: "300" } }); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.blur(ttl); + + expect(onChange).toHaveBeenCalledWith({ ...defaultValue, session_affinity_ttl_seconds: 300 }); + }); + + it("clearing the idle TTL returns the router to its backend default", () => { + const onChange = vi.fn(); + const value = { ...defaultValue, session_affinity_ttl_seconds: 300 }; + renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Affinity")); + + const ttl = screen.getByLabelText("How long a pin survives idle (seconds)"); + expect(ttl).toHaveValue("300"); + fireEvent.change(ttl, { target: { value: "" } }); + fireEvent.blur(ttl); + + expect(onChange).toHaveBeenCalledWith({ ...value, session_affinity_ttl_seconds: undefined }); + }); + + it("clamps a non-positive idle TTL to the backend's minimum", () => { + const onChange = vi.fn(); + renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Affinity")); + + const ttl = screen.getByLabelText("How long a pin survives idle (seconds)"); + fireEvent.change(ttl, { target: { value: "0" } }); + fireEvent.blur(ttl); + + expect(onChange).toHaveBeenCalledWith({ ...defaultValue, session_affinity_ttl_seconds: 1 }); + }); }); describe("ComplexityRouterConfig default model", () => { diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 791fd1fcfbb..06363830d64 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -58,6 +58,7 @@ export const DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE = 3; export const DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS = 8000; export const MIN_QUOTED_CONTEXT_TURN_CHARS = 120; export const DEFAULT_SESSION_AFFINITY = false; +export const DEFAULT_SESSION_AFFINITY_TTL_SECONDS = 3600; export const DEFAULT_DEPLOYMENT_AFFINITY = true; export type ClassificationMode = "every_request" | "user_turn"; @@ -411,6 +412,7 @@ export interface ComplexityRouterConfigValue { hybrid_boundary_margin?: number; classification_mode?: ClassificationMode; session_affinity?: boolean; + session_affinity_ttl_seconds?: number; modality_routing?: boolean; modality_pin_override?: boolean; deployment_affinity?: boolean; diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx index 836b4c6ff3d..d2f6b10c3a6 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx @@ -19,7 +19,6 @@ vi.mock( "@/app/(dashboard)/hooks/autoRouter/useAutoRouterPresets", async () => await import("../../../tests/mocks/autoRouterPresets"), ); - const getAllPresets = (): AutoRouterPreset[] => BUNDLED_PRESETS; const getPresetByKey = (key: string): AutoRouterPreset | undefined => BUNDLED_PRESETS.find((p) => p.key === key); @@ -155,6 +154,58 @@ describe("AddAutoRouterTab", () => { expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument(); }); + it("hides automatic setup when no available model is recommended", async () => { + mockFetchAvailableModels.mockResolvedValue([ + { model_group: "unknown-model-a", mode: "chat" }, + { model_group: "unknown-model-b", mode: "chat" }, + ]); + renderWithProviders(); + + openTemplateDropdown(); + await waitFor(() => expect(optionByLabel("Anthropic Family")).toHaveTextContent("Missing:")); + expect(screen.queryByTestId("configure-automatically-button")).not.toBeInTheDocument(); + }); + + it("mixes preferred tier models even when one complete preset is available", async () => { + const anthropicPreset = getPresetByKey("anthropic_family")!; + mockFetchAvailableModels.mockResolvedValue( + [...getRequiredModelsInPreset(anthropicPreset), "gpt-5.6-luna"].map((model_group) => ({ + model_group, + mode: "chat", + })), + ); + mockFetchAllModelDeployments.mockResolvedValue([]); + renderWithProviders(); + + const button = await screen.findByTestId("configure-automatically-button"); + await userEvent.click(button); + + expect( + screen.getByText( + /Simple: gpt-5.6-luna.*Medium: claude-sonnet-5.*Complex: claude-opus-5.*Reasoning: claude-opus-5/, + ), + ).toBeInTheDocument(); + expect(toast.success).not.toHaveBeenCalledWith(expect.stringContaining("Configured with")); + }); + + it("mixes available models from the preferred tier catalog when no complete template fits", async () => { + mockFetchAvailableModels.mockResolvedValue( + ["gpt-5.6-luna", "claude-sonnet-5", "gpt-5.6-sol"].map((model_group) => ({ + model_group, + mode: "chat", + })), + ); + mockFetchAllModelDeployments.mockResolvedValue([]); + renderWithProviders(); + + const button = await screen.findByTestId("configure-automatically-button"); + await userEvent.click(button); + + expect( + screen.getByText(/Simple: gpt-5.6-luna.*Medium: claude-sonnet-5.*Complex: gpt-5.6-sol.*Reasoning: gpt-5.6-sol/), + ).toBeInTheDocument(); + }); + // Nothing is filled in, so there is nothing to submit. The button reports that itself instead of // accepting a click and answering with a toast. it("offers no submit at all until every tier has a model", async () => { @@ -493,7 +544,7 @@ describe("AddAutoRouterTab", () => { }); }); - it("carries session affinity turned on through to the create payload", async () => { + it("carries session affinity turned on and its idle window through to the create payload", async () => { const user = userEvent.setup(); vi.mocked(getMissingTiersError).mockReturnValue(null); @@ -503,12 +554,17 @@ describe("AddAutoRouterTab", () => { expandDetailedConfiguration(); await user.click(screen.getByText("Advanced: Classification Method")); await user.click(await screen.findByRole("radio", { name: /Once per session/ })); + await user.click(screen.getByText("Advanced: Affinity")); + const ttl = await screen.findByLabelText("How long a pin survives idle (seconds)"); + fireEvent.change(ttl, { target: { value: "300" } }); + fireEvent.blur(ttl); await user.click(screen.getByRole("button", { name: /add auto router/i })); await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled()); expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config).toMatchObject({ session_affinity: true, + session_affinity_ttl_seconds: 300, }); }); 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 c335beade91..a548c2c6533 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 @@ -64,6 +64,7 @@ import { } from "@/lib/autorouter_presets"; import { useAutoRouterPresets } from "@/app/(dashboard)/hooks/autoRouter/useAutoRouterPresets"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { buildAutomaticRouterConfig, buildPreferredTierModels } from "./auto_setup"; interface AddAutoRouterTabProps { handleOk: () => void; @@ -242,6 +243,7 @@ const AddAutoRouterTab: React.FC = ({ refetch: refetchPresets, } = useAutoRouterPresets(); const presets = presetsData ?? NO_PRESETS; + const automaticSetupLoading = modelsLoading || presetsPending; const presetsUnavailable = presetsError && presetsData === undefined; // react-query keeps the last successful list around when a later refetch fails, so isError alone // can't tell "never loaded" apart from "loaded, then a background refetch errored" - only the @@ -266,6 +268,14 @@ const AddAutoRouterTab: React.FC = ({ ), [modelInfo], ); + const preferredTierModels = React.useMemo( + () => buildPreferredTierModels(presets, availability), + [presets, availability], + ); + const automaticRouterConfig = React.useMemo( + () => buildAutomaticRouterConfig(modelInfo, deployments ?? [], preferredTierModels), + [modelInfo, deployments, preferredTierModels], + ); // A preset's models can only be trusted against a successfully loaded list. Selection and the // greyed-out state derive from this one function, so a preset that cannot be selected can never @@ -313,6 +323,14 @@ const AddAutoRouterTab: React.FC = ({ setEscalationKeywords(prefill.escalationKeywords); }; + const handleAutomaticSetup = () => { + if (automaticRouterConfig === null) return; + setSelectedPreset(undefined); + applyPrefill({ ...buildEmptyPrefill(), complexityRouterConfig: automaticRouterConfig }); + setDetailsExpanded(false); + toast.success("Automatic setup created", { description: tierConfigSummary(automaticRouterConfig) }); + }; + const handlePresetChange = (presetKey: string | undefined) => { if (!presetKey || presetKey === "custom") { setSelectedPreset(presetKey); @@ -388,6 +406,7 @@ const AddAutoRouterTab: React.FC = ({ reasoningOverrideMinScore: complexityRouterConfig.reasoning_override_min_score, enableContextWindowEscalation: complexityRouterConfig.enable_context_window_escalation, contextWindowEscalationBuffer: complexityRouterConfig.context_window_escalation_buffer, + sessionAffinityTtlSeconds: complexityRouterConfig.session_affinity_ttl_seconds, }; const submitRecommendedRouter = async (name: string) => { @@ -499,77 +518,92 @@ const AddAutoRouterTab: React.FC = ({
handleAutoRouterSubmit())} noValidate> - - {({ ref, ...field }) => } - -
- - + )} + - return ( - -
-
{preset.label}
-
{preset.description}
- {disabledHint &&
{disabledHint}
} - {matchedHint &&
{matchedHint}
} -
-
- ); - })} - -
-
Custom Configuration
-
Define your auto router from scratch
-
-
- - - {modelsUnverifiable && ( -
- Could not load available models.{" "} - -
- )} - {presetsPending &&
Loading templates...
} - {presetsUnavailable && ( -
- Could not load templates, so only Custom Configuration is shown.{" "} - -
+ {!automaticSetupLoading && automaticRouterConfig && ( + )} + +
+ + + {modelsUnverifiable && ( +
+ Could not load available models.{" "} + +
+ )} + {presetsPending &&
Loading templates...
} + {presetsUnavailable && ( +
+ Could not load templates, so only Custom Configuration is shown.{" "} + +
+ )} +
{requiresTeamScope && ( diff --git a/ui/litellm-dashboard/src/components/add_model/auto_setup.test.ts b/ui/litellm-dashboard/src/components/add_model/auto_setup.test.ts new file mode 100644 index 00000000000..c5784db4501 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/auto_setup.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import type { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels"; +import { buildModelAvailability } from "@/lib/autorouter_presets"; +import { buildAutomaticRouterConfig, buildPreferredTierModels, type PreferredTierModels } from "./auto_setup"; + +const models = (...names: string[]) => names.map((model_group) => ({ model_group, mode: "chat" })); +const reasoningModel = (model_group: string, supported_reasoning_efforts: string[]) => ({ + model_group, + mode: "chat", + supports_reasoning: true, + supported_reasoning_efforts, +}); +const deployment = (model_name: string, model = model_name): AutoRouterDeployment => ({ + model_name, + litellm_params: { model }, +}); +const tierModels = (config: ReturnType) => + config && Object.values(config.tiers).map((tier) => (typeof tier === "string" ? tier : tier[0])); + +describe("buildPreferredTierModels", () => { + it("recognizes curated models that are not in a preset", () => { + const available = ["gpt-5.6-luna", "claude-sonnet-5", "grok-4.6", "deepseek-v4-pro"]; + const availability = buildModelAvailability(available, []); + const preferred = buildPreferredTierModels([], availability); + const expected: PreferredTierModels = { + SIMPLE: ["gpt-5.6-luna"], + MEDIUM: ["claude-sonnet-5"], + COMPLEX: ["deepseek-v4-pro", "grok-4.6"], + REASONING: ["deepseek-v4-pro", "grok-4.6"], + }; + + expect(preferred).toEqual(expected); + }); + + it("prefers the current model ladder over older preset entries", () => { + const availability = buildModelAvailability(["gpt-5.6-luna", "gpt-4o-mini"], []); + const preferred = buildPreferredTierModels( + [ + { + key: "old", + label: "Old", + description: "Old model", + complexity_router_config: { + tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + classifier_type: "heuristic_v2", + }, + }, + ], + availability, + ); + + expect(preferred.SIMPLE).toEqual(["gpt-5.6-luna", "gpt-4o-mini"]); + }); +}); + +describe("buildAutomaticRouterConfig", () => { + it("selects one preferred model for each tier", () => { + const preferred: PreferredTierModels = { + SIMPLE: ["simple"], + MEDIUM: ["medium"], + COMPLEX: ["complex"], + REASONING: ["reasoning"], + }; + + expect( + tierModels(buildAutomaticRouterConfig(models("simple", "medium", "complex", "reasoning"), [], preferred)), + ).toEqual(["simple", "medium", "complex", "reasoning"]); + }); + + it.each([ + { + provider: "OpenAI", + available: ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-6-astra"], + expected: ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-6-astra", "gpt-6-astra"], + supportedEfforts: ["low", "medium", "high", "xhigh", "max"], + effort: "max", + }, + { + provider: "Anthropic", + available: ["claude-haiku-4-5", "claude-sonnet-5", "claude-opus-5"], + expected: ["claude-haiku-4-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-5"], + supportedEfforts: ["low", "medium", "high", "max"], + effort: "max", + }, + { + provider: "Google", + available: ["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-pro-preview"], + expected: ["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-pro-preview", "gemini-3.1-pro-preview"], + supportedEfforts: ["low", "medium", "high"], + effort: "high", + }, + { + provider: "DeepSeek", + available: ["deepseek-v4-flash", "deepseek-v4-pro"], + expected: ["deepseek-v4-flash", "deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-pro"], + supportedEfforts: ["none", "high"], + effort: "high", + }, + { + provider: "xAI", + available: ["grok-4.6"], + expected: ["grok-4.6", "grok-4.6", "grok-4.6", "grok-4.6"], + supportedEfforts: ["low", "medium", "high", "xhigh"], + effort: "xhigh", + }, + ])( + "uses the current $provider ladder and strongest advertised reasoning effort", + ({ available, expected, supportedEfforts, effort }) => { + const availability = buildModelAvailability(available, []); + const preferred = buildPreferredTierModels([], availability); + const modelInfo = models(...available).map((model) => + model.model_group === expected[3] ? reasoningModel(model.model_group, supportedEfforts) : model, + ); + + const config = buildAutomaticRouterConfig(modelInfo, [], preferred); + + expect(tierModels(config)).toEqual(expected); + expect(config?.tier_model_params).toEqual({ + REASONING: { [expected[3]]: { reasoning_effort: effort } }, + }); + }, + ); + + it("never exceeds the selected model group's advertised reasoning efforts", () => { + const available = ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]; + const availability = buildModelAvailability(available, []); + const preferred = buildPreferredTierModels([], availability); + const modelInfo = [ + ...models("gpt-5.6-luna", "gpt-5.6-terra"), + reasoningModel("gpt-5.6-sol", ["none", "low", "medium", "high", "xhigh"]), + ]; + + const config = buildAutomaticRouterConfig(modelInfo, [], preferred); + + expect(config?.tier_model_params).toEqual({ + REASONING: { "gpt-5.6-sol": { reasoning_effort: "xhigh" } }, + }); + }); + + it("leaves reasoning effort unset when the proxy does not report supported values", () => { + const available = ["grok-4.6"]; + const availability = buildModelAvailability(available, []); + const preferred = buildPreferredTierModels([], availability); + + const config = buildAutomaticRouterConfig(models(...available), [], preferred); + + expect(config?.tier_model_params).toBeUndefined(); + }); + + it("reuses the closest available tier when a tier has no match", () => { + const preferred: PreferredTierModels = { + SIMPLE: ["simple"], + MEDIUM: [], + COMPLEX: ["complex"], + REASONING: [], + }; + + expect(tierModels(buildAutomaticRouterConfig(models("simple", "complex"), [], preferred))).toEqual([ + "simple", + "simple", + "complex", + "complex", + ]); + }); + + it("returns null when none of the available models are recommended", () => { + const preferred: PreferredTierModels = { + SIMPLE: ["missing-simple"], + MEDIUM: ["missing-medium"], + COMPLEX: ["missing-complex"], + REASONING: ["missing-reasoning"], + }; + + expect(buildAutomaticRouterConfig(models("unknown-model"), [], preferred)).toBeNull(); + }); + + it("ignores non-chat models and existing auto routers", () => { + const preferred: PreferredTierModels = { + SIMPLE: ["gpt-4o-mini", "smart-router"], + MEDIUM: [], + COMPLEX: [], + REASONING: [], + }; + const available = [ + { model_group: "gpt-4o-mini", mode: "chat" }, + { model_group: "image-model", mode: "image_generation" }, + { model_group: "smart-router", mode: "chat" }, + ]; + + expect( + tierModels( + buildAutomaticRouterConfig(available, [deployment("smart-router", "auto_router/complexity_router")], preferred), + ), + ).toEqual(["gpt-4o-mini", "gpt-4o-mini", "gpt-4o-mini", "gpt-4o-mini"]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/auto_setup.ts b/ui/litellm-dashboard/src/components/add_model/auto_setup.ts new file mode 100644 index 00000000000..cd7d587e2c2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/auto_setup.ts @@ -0,0 +1,95 @@ +import { isAutoRouterDeployment, type AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels"; +import type { ModelGroup } from "@/components/llm_calls/fetch_models"; +import { resolveAvailableModel, type AutoRouterPreset, type ModelAvailability } from "@/lib/autorouter_presets"; +import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; + +const TIER_NAMES = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"] as const; +type TierName = (typeof TIER_NAMES)[number]; +export type PreferredTierModels = Record; + +const REASONING_EFFORT_STRENGTH = ["max", "xhigh", "high", "medium", "low", "minimal", "none"] as const; + +const CURRENT_TIER_MODELS: PreferredTierModels = { + SIMPLE: ["gpt-5.6-luna", "claude-haiku-4-5", "gemini-3.5-flash-lite", "deepseek-v4-flash"], + MEDIUM: ["gpt-5.6-terra", "claude-sonnet-5", "gemini-3.8-flash", "deepseek-v4-flash"], + COMPLEX: ["gpt-6-astra", "gpt-5.6-sol", "claude-opus-5", "gemini-3.1-pro-preview", "deepseek-v4-pro", "grok-4.6"], + REASONING: ["gpt-6-astra", "gpt-5.6-sol", "claude-opus-5", "gemini-3.1-pro-preview", "deepseek-v4-pro", "grok-4.6"], +}; + +export const buildPreferredTierModels = ( + presets: AutoRouterPreset[], + availability: ModelAvailability, +): PreferredTierModels => + Object.fromEntries( + TIER_NAMES.map((tier) => [ + tier, + Array.from( + new Set( + [ + ...CURRENT_TIER_MODELS[tier], + ...presets.flatMap((preset) => preset.complexity_router_config.tiers[tier]), + ].flatMap((model) => { + const resolved = resolveAvailableModel(model, availability); + return resolved ? [resolved] : []; + }), + ), + ), + ]), + ) as PreferredTierModels; + +const selectPreferredTierModels = ( + preferredByTier: PreferredTierModels, + usableNames: ReadonlySet, +): [string, string, string, string] | null => { + const preferred = TIER_NAMES.map((tier) => preferredByTier[tier].find((name) => usableNames.has(name))); + const candidates = preferred.flatMap((model, tier) => (model ? [{ model, tier }] : [])); + if (candidates.length === 0) return null; + + const nearest = (tier: number): string => + [...candidates].sort( + (left, right) => Math.abs(left.tier - tier) - Math.abs(right.tier - tier) || left.tier - right.tier, + )[0].model; + return preferred.map((model, tier) => model ?? nearest(tier)) as [string, string, string, string]; +}; + +export const buildAutomaticRouterConfig = ( + models: ModelGroup[], + deployments: AutoRouterDeployment[], + preferredByTier: PreferredTierModels, +): ComplexityRouterConfigValue | null => { + const autoRouterNames: ReadonlySet = new Set( + deployments + .filter(isAutoRouterDeployment) + .flatMap((deployment) => (deployment.model_name ? [deployment.model_name] : [])), + ); + const names = Array.from( + new Set( + models + .filter((model) => model.mode === undefined || model.mode === "chat") + .map((model) => model.model_group) + .filter((name) => name && !name.startsWith("auto_router/") && !autoRouterNames.has(name)), + ), + ); + if (names.length === 0) return null; + const usableNames: ReadonlySet = new Set(names); + const selected = selectPreferredTierModels(preferredByTier, usableNames); + if (selected === null) return null; + + const supportedReasoningEfforts = models.find( + (model) => model.model_group === selected[3], + )?.supported_reasoning_efforts; + const reasoningEffort = REASONING_EFFORT_STRENGTH.find((effort) => supportedReasoningEfforts?.includes(effort)); + + return { + tiers: { + SIMPLE: [selected[0]], + MEDIUM: [selected[1]], + COMPLEX: [selected[2]], + REASONING: [selected[3]], + }, + classifier_type: "heuristic_v2", + ...(reasoningEffort && { + tier_model_params: { REASONING: { [selected[3]]: { reasoning_effort: reasoningEffort } } }, + }), + }; +}; 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 53ab859baa0..9ee555f5dd2 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 @@ -73,6 +73,15 @@ describe("buildComplexityRouterConfig", () => { expect(config.context_window_escalation_buffer).toBe(0.9); }); + it("omits session_affinity_ttl_seconds when untouched, so the router tracks the backend default", () => { + expect(buildComplexityRouterConfig(baseParams)).not.toHaveProperty("session_affinity_ttl_seconds"); + }); + + it("emits an explicit session affinity idle window", () => { + const config = buildComplexityRouterConfig({ ...baseParams, sessionAffinityTtlSeconds: 300 }); + expect(config.session_affinity_ttl_seconds).toBe(300); + }); + it("trims escalation keywords and drops blank entries", () => { const config = buildComplexityRouterConfig({ ...baseParams, 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 b8af55e8c8a..956e593a234 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 @@ -138,6 +138,7 @@ export interface BuildComplexityRouterConfigParams { tierModelParams?: TierModelParamsByTier; enableContextWindowEscalation?: boolean; contextWindowEscalationBuffer?: number; + sessionAffinityTtlSeconds?: number; } /** @@ -175,6 +176,7 @@ export interface ComplexityRouterConfigPayload { hybrid_boundary_margin?: number; classification_mode: ClassificationMode; session_affinity: boolean; + session_affinity_ttl_seconds?: number; deployment_affinity: boolean; modality_routing: boolean; modality_pin_override: boolean; @@ -456,6 +458,7 @@ export const buildComplexityRouterConfig = ({ tierModelParams, enableContextWindowEscalation, contextWindowEscalationBuffer, + sessionAffinityTtlSeconds, }: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => { const serializedTierModelConfigs = customTierSet ? serializeTierModelConfigs( @@ -522,6 +525,9 @@ export const buildComplexityRouterConfig = ({ ...(contextWindowEscalationBuffer !== undefined && { context_window_escalation_buffer: contextWindowEscalationBuffer, }), + ...(sessionAffinityTtlSeconds !== undefined && { + session_affinity_ttl_seconds: sessionAffinityTtlSeconds, + }), ...scorerKnobs, }; if (!customTierSet) return payload; diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts index f24f3033901..877b35199a4 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts +++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts @@ -257,6 +257,43 @@ describe("buildUpdatedComplexityRouterConfig session affinity", () => { }); }); +describe("buildUpdatedComplexityRouterConfig session affinity ttl", () => { + it("writes an edited idle window", () => { + const result = buildUpdatedComplexityRouterConfig(STORED, { ...FORM_VALUE, session_affinity_ttl_seconds: 300 }); + expect(result.session_affinity_ttl_seconds).toBe(300); + }); + + it("carries a stored idle window through an untouched open-and-save", () => { + const stored = { ...STORED, session_affinity_ttl_seconds: 900 }; + const result = buildUpdatedComplexityRouterConfig(stored, hydrateComplexityRouterConfig(stored, undefined)); + expect(result.session_affinity_ttl_seconds).toBe(900); + }); + + it("drops the key when the field is cleared, so the router goes back to tracking the backend default", () => { + const result = buildUpdatedComplexityRouterConfig( + { ...STORED, session_affinity_ttl_seconds: 900 }, + { ...FORM_VALUE, session_affinity_ttl_seconds: undefined }, + ); + expect(result).not.toHaveProperty("session_affinity_ttl_seconds"); + }); + + it("keeps the idle window on a custom tier set, whose deployment pin still uses it", () => { + const result = buildUpdatedComplexityRouterConfig(STORED, { + ...FORM_VALUE, + session_affinity_ttl_seconds: 300, + custom_tier_set: { + tiers: [ + { id: "a", name: "CASUAL", definition: "small talk", models: ["gpt-4o-mini"] }, + { id: "b", name: "AUDIT", definition: "security review", models: ["o1"] }, + ], + fallback_tier_id: "a", + }, + }); + expect(result.session_affinity).toBe(false); + expect(result.session_affinity_ttl_seconds).toBe(300); + }); +}); + describe("buildUpdatedComplexityRouterConfig modality pin override", () => { it("writes modality_pin_override explicitly both ways", () => { expect( @@ -529,6 +566,7 @@ describe("managed keys survive an untouched open-and-save", () => { classifier_fallback: "default_model", classification_mode: "user_turn", session_affinity: true, + session_affinity_ttl_seconds: 300, modality_routing: true, modality_pin_override: true, deployment_affinity: false, diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx index 6067e72e547..970bcaa545f 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx @@ -550,6 +550,49 @@ describe("EditAutoRouterModal deployment affinity", () => { expect(savedConfig().deployment_affinity).toBe(false); }); + it("preserves an idle TTL through an untouched save", async () => { + const user = userEvent.setup(); + renderWithStoredConfig({ ...STORED_CONFIG, session_affinity_ttl_seconds: 300 }); + + await user.click(await screen.findByText("Advanced: Affinity")); + expect(await screen.findByLabelText("How long a pin survives idle (seconds)")).toHaveValue("300"); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedConfig().session_affinity_ttl_seconds).toBe(300); + }); + + it("persists an edited idle TTL", async () => { + const user = userEvent.setup(); + renderWithStoredConfig(STORED_CONFIG); + + await user.click(await screen.findByText("Advanced: Affinity")); + const ttl = await screen.findByLabelText("How long a pin survives idle (seconds)"); + fireEvent.change(ttl, { target: { value: "300" } }); + fireEvent.blur(ttl); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedConfig().session_affinity_ttl_seconds).toBe(300); + }); + + it("removes the idle TTL when cleared", async () => { + const user = userEvent.setup(); + renderWithStoredConfig({ ...STORED_CONFIG, session_affinity_ttl_seconds: 300 }); + + await user.click(await screen.findByText("Advanced: Affinity")); + const ttl = await screen.findByLabelText("How long a pin survives idle (seconds)"); + fireEvent.change(ttl, { target: { value: "" } }); + fireEvent.blur(ttl); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedConfig()).not.toHaveProperty("session_affinity_ttl_seconds"); + }); + // modality_pin_override is a managed key, so the modal rewrites it from form state on save. A // hydration gap would silently turn a stored override off on the next untouched save. it("shows a stored modality_pin_override=true as on and preserves it through an untouched save", async () => { 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 54766df93ec..ea1e5cba6a3 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 @@ -104,6 +104,7 @@ export interface StoredComplexityRouterConfig { dimension_weights?: unknown; reasoning_override_min_score?: unknown; session_affinity?: unknown; + session_affinity_ttl_seconds?: unknown; modality_routing?: unknown; modality_pin_override?: unknown; deployment_affinity?: unknown; @@ -182,6 +183,11 @@ export const hydrateComplexityRouterConfig = ( reasoning_override_min_score: hydrateReasoningOverrideMinScore(parsedConfig.reasoning_override_min_score), session_affinity: typeof parsedConfig.session_affinity === "boolean" ? parsedConfig.session_affinity : DEFAULT_SESSION_AFFINITY, + session_affinity_ttl_seconds: + typeof parsedConfig.session_affinity_ttl_seconds === "number" && + Number.isFinite(parsedConfig.session_affinity_ttl_seconds) + ? parsedConfig.session_affinity_ttl_seconds + : undefined, modality_routing: typeof parsedConfig.modality_routing === "boolean" ? parsedConfig.modality_routing : false, modality_pin_override: typeof parsedConfig.modality_pin_override === "boolean" ? parsedConfig.modality_pin_override : false, @@ -224,6 +230,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([ "hybrid_boundary_margin", "classification_mode", "session_affinity", + "session_affinity_ttl_seconds", "modality_routing", "modality_pin_override", "deployment_affinity", @@ -322,6 +329,7 @@ export const buildUpdatedComplexityRouterConfig = ( classifierContextIncludeAssistantTurns: value.classifier_context_include_assistant_turns, classifierFallback: value.classifier_fallback, sessionAffinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY, + sessionAffinityTtlSeconds: value.session_affinity_ttl_seconds, modalityRouting: value.modality_routing ?? false, modalityPinOverride: value.modality_pin_override ?? false, deploymentAffinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY, diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 335a5f8b816..9df9e9a9209 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -815,3 +815,87 @@ describe("daily activity api_key filter", () => { expect(requestedUrl(mockFetch)).toContain("user_id="); }); }); + +describe("userListCall search serialization", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + const mockOkFetch = () => { + const emptyPage = { users: [], total: 0, page: 1, page_size: 25, total_pages: 0 }; + const body = JSON.stringify(emptyPage); + const mockFetch = vi.fn().mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue(body) } as any); + global.fetch = mockFetch as any; + return mockFetch; + }; + + const lastParams = (mockFetch: ReturnType) => { + const [url] = mockFetch.mock.calls.at(-1) ?? []; + return new URL(url as string, "http://example.com").searchParams; + }; + + it("sends the combined search term as search, not user_email", async () => { + const mockFetch = mockOkFetch(); + + await Networking.userListCall("token", null, 1, 25, null, null, null, null, null, null, null, "a6f5c02b"); + + expect(lastParams(mockFetch).get("search")).toBe("a6f5c02b"); + expect(lastParams(mockFetch).has("user_email")).toBe(false); + }); + + it("omits search when no search term is given and keeps user_email as before", async () => { + const mockFetch = mockOkFetch(); + + await Networking.userListCall("token", null, 1, 25, "ada@example.com"); + + expect(lastParams(mockFetch).has("search")).toBe(false); + expect(lastParams(mockFetch).get("user_email")).toBe("ada@example.com"); + }); +}); + +describe("fetchMemoryList search serialization", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + const mockOkFetch = () => { + const emptyPage = { memories: [], total: 0 }; + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(emptyPage) } as any); + global.fetch = mockFetch as any; + return mockFetch; + }; + + const lastParams = (mockFetch: ReturnType) => { + const [url] = mockFetch.mock.calls.at(-1) ?? []; + return new URL(url as string, "http://example.com").searchParams; + }; + + it("sends the search box value as search and omits key_prefix and key", async () => { + const mockFetch = mockOkFetch(); + + await Networking.fetchMemoryList("token", { search: "mem-abc123", page: 1, pageSize: 50 }); + + const params = lastParams(mockFetch); + expect(params.get("search")).toBe("mem-abc123"); + expect(params.has("key_prefix")).toBe(false); + expect(params.has("key")).toBe(false); + expect(params.get("page")).toBe("1"); + expect(params.get("page_size")).toBe("50"); + }); + + it("keeps key_prefix and key working when no search is given", async () => { + const mockFetch = mockOkFetch(); + + await Networking.fetchMemoryList("token", { keyPrefix: "user:" }); + expect(lastParams(mockFetch).get("key_prefix")).toBe("user:"); + expect(lastParams(mockFetch).has("search")).toBe(false); + + await Networking.fetchMemoryList("token", { key: "user:profile" }); + expect(lastParams(mockFetch).get("key")).toBe("user:profile"); + expect(lastParams(mockFetch).has("search")).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 7e0f6c7e4f5..1384679a88a 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1033,6 +1033,7 @@ export const userListCall = async ( sortBy: string | null = null, sortOrder: "asc" | "desc" | null = null, organizationIds: string[] | null = null, + search: string | null = null, ) => { /** * Get all available teams on proxy @@ -1051,6 +1052,7 @@ export const userListCall = async ( sort_by: sortBy || undefined, sort_order: sortOrder || undefined, organization_ids: organizationIds && organizationIds.length > 0 ? organizationIds.join(",") : undefined, + search: search || undefined, }, })) as UserListResponse; return data; @@ -2054,6 +2056,7 @@ interface UiSpendLogsParams { exclude_internal_health_checks?: boolean; group_by_session?: boolean; session_cursor?: string; + search?: string; } interface UiSpendLogsCallOptions { @@ -4694,9 +4697,12 @@ export const updatePromptCall = async (accessToken: string, promptId: string, pr } }; -export const deletePromptCall = async (accessToken: string, promptId: string) => { +export const deletePromptCall = async (accessToken: string, promptId: string, environment?: string) => { try { - const data = await apiClient.delete(`/prompts/${promptId}`, { accessToken }); + const data = await apiClient.delete(`/prompts/${promptId}`, { + accessToken, + query: { environment: environment || undefined }, + }); return data; } catch (error) { console.error("Failed to delete prompt:", error); @@ -6558,6 +6564,7 @@ interface UiAuditLogsParams { changed_by_api_key?: string; object_team_id?: string; object_key_hash?: string; + search?: string | null; sort_by?: string; sort_order?: "asc" | "desc"; } @@ -8056,15 +8063,18 @@ export const fetchMemoryList = async ( options: { key?: string; keyPrefix?: string; + search?: string; page?: number; pageSize?: number; } = {}, ): Promise => { const base = proxyBaseUrl ? `${proxyBaseUrl}/v1/memory` : `/v1/memory`; const params = new URLSearchParams(); - // keyPrefix takes precedence — backend also does, but we omit `key` + // Backend precedence is search > key_prefix > key; only the winner is sent // to keep the URL clean and intent obvious. - if (options.keyPrefix) { + if (options.search) { + params.append("search", options.search); + } else if (options.keyPrefix) { params.append("key_prefix", options.keyPrefix); } else if (options.key) { params.append("key", options.key); 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 ee3a88acba0..7ada45947cb 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -334,7 +334,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const fetchPrompts = async () => { try { const response = await getPromptsList(accessToken); - setPromptsList(response.prompts.map((prompt) => prompt.prompt_id)); + setPromptsList(Array.from(new Set(response.prompts.map((prompt) => prompt.prompt_id)))); } catch (error) { console.error("Failed to fetch prompts:", error); } 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 f502ea77127..149554a3ac3 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx @@ -613,8 +613,11 @@ describe("DataTable layout", () => { it("makes the header sticky and constrains body height when maxBodyHeight is set", () => { render(); - expect(screen.getByTestId("data-table-head")).toHaveClass("sticky"); - expect(screen.getByTestId("data-table-scroller")).toHaveStyle({ maxHeight: "240px" }); + const scroller = screen.getByTestId("data-table-scroller"); + expect(scroller).toHaveStyle({ maxHeight: "240px" }); + expect(scroller).toHaveClass("overflow-auto"); + expect(scroller).toHaveClass("[&_[data-slot=table-container]]:overflow-visible"); + expect(screen.getByTestId("data-table-head")).toHaveClass("sticky", "bg-background"); }); it("caps fillHeight at the parent's height instead of stretching to it, so a short table stays short", () => { diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx index 60267606951..17a5fe42d1c 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -59,18 +59,22 @@ const noop = () => {}; /** * Height-filling mode. The table still sizes to its rows; the parent's height is only a ceiling, so * a short table keeps its footer under the last row and a long one scrolls its rows instead of the - * page. `table-container` is the Table primitive's own overflow-x wrapper; left as a scroll box it - * captures the sticky header and the header scrolls away with the rows. And rows pass under that - * header, which the semi-transparent header row tint alone would not hide. + * page. */ const FILL_CLASSES = { outer: "flex max-h-full min-h-0 flex-col", frame: "flex min-h-0 flex-col", - body: "min-h-0 [&_[data-slot=table-container]]:overflow-visible", + body: "min-h-0", +} as const; + +const NO_FILL_CLASSES = { outer: "", frame: "", body: "" } as const; + +const STICKY_CLASSES = { + body: "[&_[data-slot=table-container]]:overflow-visible", header: "bg-background", } as const; -const NO_FILL_CLASSES = { outer: "", frame: "", body: "", header: "" } as const; +const NO_STICKY_CLASSES = { body: "", header: "" } as const; function columnDefId(column: ColumnDef): string | undefined { if ("id" in column && typeof column.id === "string") { @@ -533,6 +537,7 @@ export function DataTable(props: DataTableProps { @@ -593,13 +598,13 @@ export function DataTable(props: DataTableProps{toolbar(table)}
}
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index aedc04283f5..a993c1f6bd6 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -1538,6 +1538,35 @@ describe("TeamInfoView", () => { }); }); + describe("team member settings", () => { + it("should populate Default Key Duration from the team's stored metadata", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ metadata: { team_member_key_duration: "30d" } }), + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.queryAllByText("Test Team").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 user.click(await screen.findByRole("button", { name: /team member settings/i })); + + await waitFor(() => { + expect(screen.getByLabelText(/^Default Key Duration/)).toHaveValue("30d"); + }); + }); + }); + describe("guardrails dropdown grouping", () => { const guardrail = (name: string, defaultOn: boolean) => ({ guardrail_name: name, diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index c2b8cd3cc56..23ecd19fdd7 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -335,7 +335,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]): default_team_member_models: info.default_team_member_models || [], team_member_budget: info.team_member_budget_table?.max_budget, team_member_budget_duration: info.team_member_budget_table?.budget_duration, - team_member_key_duration: info.team_member_key_duration, + team_member_key_duration: info.metadata?.team_member_key_duration, team_member_tpm_limit: info.team_member_budget_table?.tpm_limit, team_member_rpm_limit: info.team_member_budget_table?.rpm_limit, budget_duration: info.budget_duration, diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index 8bf5d639d6c..0d9d0988aa1 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -30,6 +30,8 @@ vi.mock("@tanstack/react-pacer/debouncer", () => ({ const mockUseKeys = useKeys as MockedFunction; +const KEY_HASH = "88a145505dd6e87e2ea166fcef1e4b53948dbdb32af6431dfd05ec06b571ee52"; + const createMockKey = (overrides: Partial = {}): KeyResponse => ({ token: "sk-test123", @@ -277,7 +279,7 @@ describe("TeamVirtualKeysTable", () => { ); }); - it("maps the search box to a server-side key-alias query", async () => { + it("maps the Key ID drawer filter to a server-side useKeys query and clears it", async () => { const user = userEvent.setup(); mockUseKeys.mockReturnValue({ data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 }, @@ -288,11 +290,42 @@ describe("TeamVirtualKeysTable", () => { renderWithProviders(); - fireEvent.change(await screen.findByTestId("datatable-search"), { target: { value: "check-002" } }); + await user.click(await screen.findByTestId("datatable-filters-trigger")); + const drawerBody = await screen.findByTestId("filter-drawer-body"); + fireEvent.change(within(drawerBody).getByPlaceholderText("Enter Key ID…"), { target: { value: KEY_HASH } }); + await user.click(screen.getByTestId("filter-drawer-apply")); await waitFor(() => - expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ selectedKeyAlias: "check-002" })), + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ keyHash: KEY_HASH })), ); + expect(screen.getByTestId("filter-chip-key_hash")).toHaveTextContent("Key ID"); + + await user.click(screen.getByTestId("datatable-clear-filters")); + await waitFor(() => + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ keyHash: undefined })), + ); + }); + + it("maps the search box to the combined alias-or-ID search rather than the key-alias filter", async () => { + mockUseKeys.mockReturnValue({ + data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + + renderWithProviders(); + + const searchBox = await screen.findByTestId("datatable-search"); + expect(searchBox).toHaveAttribute("placeholder", "Search by key alias or ID…"); + fireEvent.change(searchBox, { target: { value: KEY_HASH } }); + + await waitFor(() => + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: KEY_HASH })), + ); + const lastOptions = mockUseKeys.mock.calls.at(-1)?.[2]; + expect(lastOptions?.selectedKeyAlias).toBeUndefined(); + expect(lastOptions?.keyHash).toBeUndefined(); }); it("should show Loading keys when isPending", async () => { diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index bd7faa41ee9..b0380255b95 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -68,19 +68,17 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi const pageIndex = tablePagination.pageIndex; const pageSize = tablePagination.pageSize; - const { - data: keys, - isPending: isLoading, - isFetching, - refetch, - } = useKeys(pageIndex + 1, pageSize, { + const keyListOptions = { teamID: teamId, - selectedKeyAlias: searchQuery.trim() || undefined, + search: searchQuery.trim() || undefined, userID: getFilterValue("user_id"), + keyHash: getFilterValue("key_hash"), sortBy: sortBy || undefined, sortOrder: sortOrder || undefined, expand: "user", - }); + }; + + const { data: keys, isPending: isLoading, isFetching, refetch } = useKeys(pageIndex + 1, pageSize, keyListOptions); const displayKeys = useMemo(() => { const kList = keys?.keys || []; @@ -445,7 +443,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi }, []); return ( -
+
{selectedKey ? ( ) : ( -
+
( <> @@ -481,11 +478,11 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi table={table} searchValue={searchInput} onSearchChange={handleSearchChange} - searchPlaceholder="Search by key alias…" + searchPlaceholder="Search by key alias or ID…" onRefresh={() => refetch?.()} isRefreshing={isFetching} onOpenFilters={() => setFiltersOpen(true)} - filterLabels={{ user_id: "User ID" }} + filterLabels={{ user_id: "User ID", key_hash: "Key ID" }} /> {({ get, set }) => ( - - set("user_id", event.target.value)} - placeholder="Filter by user ID…" - /> - + <> + + set("user_id", event.target.value)} + placeholder="Filter by user ID…" + /> + + + set("key_hash", event.target.value)} + placeholder="Enter Key ID…" + /> + + )} diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index 10b1983b54b..8b506329595 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -425,6 +425,22 @@ describe("KeyEditView", () => { expect(screen.getByText("Policies")).toBeInTheDocument(); }); + it("lists a prompt existing in several environments once in the dropdown", async () => { + vi.mocked(getPromptsList).mockResolvedValueOnce({ + prompts: [ + { prompt_id: "envgreet", litellm_params: {}, prompt_info: { prompt_type: "db" }, environment: "development" }, + { prompt_id: "envgreet", litellm_params: {}, prompt_info: { prompt_type: "db" }, environment: "production" }, + ], + }); + + renderAs("Admin"); + + const prompts = await screen.findByLabelText(/Prompts/); + await userEvent.type(prompts, "envgreet"); + + expect(await screen.findAllByRole("option", { name: "envgreet" })).toHaveLength(1); + }); + it("should omit both fields and fire neither admin-only request for an internal user", async () => { renderAs("Internal User"); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 1330be2788b..9edfba4b981 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -165,7 +165,7 @@ export function KeyEditView({ if (!accessToken) return; try { const response = await getPromptsList(accessToken); - setPromptsList(response.prompts.map((prompt) => prompt.prompt_id)); + setPromptsList(Array.from(new Set(response.prompts.map((prompt) => prompt.prompt_id)))); } catch (error) { console.error("Failed to fetch prompts:", error); } diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index 614b3b7af62..dcadc141103 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -216,24 +216,22 @@ const UserDashboard: React.FC = ({ const canCreateKey = userRole !== "Admin Viewer" && userRole !== "proxy_admin_viewer"; return ( -
-
- - ) : undefined - } - /> -
+
+ + ) : undefined + } + />
); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.test.tsx new file mode 100644 index 00000000000..3b27f663b8e --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.test.tsx @@ -0,0 +1,145 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { chooseSelectOption } from "../../../tests/test-utils"; +import AuditLogsPanel from "./AuditLogsPanel"; + +vi.mock("../networking", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, uiAuditLogsCall: vi.fn() }; +}); + +// Resolve the debounced search synchronously so typed input reaches the query within the test tick. +vi.mock("@tanstack/react-pacer/debouncer", () => ({ + useDebouncedValue: (value: unknown) => [value, { cancel: vi.fn(), flush: vi.fn() }], +})); + +import { uiAuditLogsCall } from "../networking"; + +type AuditLogsParams = NonNullable[0]["params"]>; + +const PAGE_SIZE = 50; + +const ID_PARAM_KEYS = [ + "search", + "object_id", + "changed_by", + "object_team_id", + "object_key_hash", + "action", + "table_name", +] as const satisfies readonly (keyof AuditLogsParams)[]; + +const respondWith = (total: number) => { + const response = { audit_logs: [], total, page: 1, page_size: PAGE_SIZE, total_pages: Math.ceil(total / PAGE_SIZE) }; + return vi.mocked(uiAuditLogsCall).mockResolvedValue(response); +}; + +const lastCall = () => vi.mocked(uiAuditLogsCall).mock.calls.at(-1)?.[0]; +const sentIdParams = () => ID_PARAM_KEYS.filter((key) => lastCall()?.params?.[key] !== undefined); + +const defaultProps = { + accessToken: "sk-test", + token: "jwt-test", + userRole: "Admin", + userID: "user-1", + isActive: true, + premiumUser: true, +}; + +const renderPanel = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +}; + +const TEXT_FILTERS: { filterId: string; placeholder: string; paramKey: keyof AuditLogsParams }[] = [ + { filterId: "object_id", placeholder: "Enter object ID…", paramKey: "object_id" }, + { filterId: "changed_by", placeholder: "Enter user ID…", paramKey: "changed_by" }, + { filterId: "team_id", placeholder: "Enter team ID…", paramKey: "object_team_id" }, + { filterId: "key_hash", placeholder: "Enter key hash…", paramKey: "object_key_hash" }, +]; + +const SELECT_FILTERS: { + label: string; + comboboxIndex: number; + option: string; + paramKey: keyof AuditLogsParams; + value: string; +}[] = [ + { label: "Action", comboboxIndex: 0, option: "Created", paramKey: "action", value: "created" }, + { label: "Table", comboboxIndex: 1, option: "Teams", paramKey: "table_name", value: "LiteLLM_TeamTable" }, +]; + +describe("AuditLogsPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + respondWith(0); + }); + + it("sends the typed search as params.search and returns to the first page", async () => { + const user = userEvent.setup(); + respondWith(120); + renderPanel(); + await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled()); + expect(lastCall()?.params?.search).toBeUndefined(); + + await user.click(screen.getByTestId("pagination-next")); + await waitFor(() => expect(lastCall()?.page).toBe(2)); + + await user.type(screen.getByTestId("datatable-search"), "team-abc"); + + await waitFor(() => expect(lastCall()?.params?.search).toBe("team-abc")); + expect(lastCall()?.page).toBe(1); + expect(sentIdParams()).toEqual(["search"]); + }); + + it("trims the search and drops params.search once the box is cleared", async () => { + const user = userEvent.setup(); + renderPanel(); + const input = await screen.findByTestId("datatable-search"); + + await user.type(input, " abc"); + await waitFor(() => expect(lastCall()?.params?.search).toBe("abc")); + + await user.clear(input); + + await waitFor(() => expect(lastCall()?.params?.search).toBeUndefined()); + expect(sentIdParams()).toEqual([]); + }); + + it.each(TEXT_FILTERS)("maps the $filterId drawer filter to params.$paramKey", async ({ placeholder, paramKey }) => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled()); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + fireEvent.change(await screen.findByPlaceholderText(placeholder), { target: { value: "val-1" } }); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastCall()?.params?.[paramKey]).toBe("val-1")); + expect(sentIdParams()).toEqual([paramKey]); + }); + + it.each(SELECT_FILTERS)( + "maps the $label drawer select to params.$paramKey", + async ({ comboboxIndex, option, paramKey, value }) => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled()); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + const triggers = await screen.findAllByRole("combobox"); + await chooseSelectOption(user, triggers[comboboxIndex], option); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastCall()?.params?.[paramKey]).toBe(value)); + expect(sentIdParams()).toEqual([paramKey]); + }, + ); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx index 81bd4a19f76..5ce4ca6053f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx @@ -1,7 +1,9 @@ import { useCallback, useState } from "react"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table"; import { resolveLogoSrc } from "@/lib/assetPaths"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { uiAuditLogsCall } from "../networking"; import { AuditLogEntry } from "./AuditLogsTableColumns"; import { AuditLogsTable } from "./AuditLogsTable"; @@ -39,9 +41,13 @@ export default function AuditLogsPanel({ }: AuditLogsProps) { const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE }); const [columnFilters, setColumnFilters] = useState([]); + const [searchInput, setSearchInput] = useState(""); + const [debouncedSearch] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); const [selectedLog, setSelectedLog] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const searchTerm = debouncedSearch.trim(); + const getFilterValue = (columnId: string): string | undefined => { const entry = columnFilters.find((filter) => filter.id === columnId); return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined; @@ -50,7 +56,7 @@ export default function AuditLogsPanel({ const canQueryAuditLogs = !!accessToken && !!token && !!userRole && !!userID && isActive && premiumUser; const query = useQuery({ - queryKey: ["audit_logs", pagination.pageIndex, pagination.pageSize, columnFilters], + queryKey: ["audit_logs", pagination.pageIndex, pagination.pageSize, columnFilters, searchTerm], queryFn: async () => { if (!accessToken) { return { audit_logs: [], total: 0, page: 1, page_size: pagination.pageSize, total_pages: 0 }; @@ -60,6 +66,7 @@ export default function AuditLogsPanel({ page: pagination.pageIndex + 1, page_size: pagination.pageSize, params: { + search: searchTerm || undefined, object_id: getFilterValue("object_id"), changed_by: getFilterValue("changed_by"), object_key_hash: getFilterValue("key_hash"), @@ -80,6 +87,11 @@ export default function AuditLogsPanel({ setPagination((prev) => ({ ...prev, pageIndex: 0 })); }, []); + const handleSearchChange = useCallback((value: string) => { + setSearchInput(value); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); + const handleViewLog = useCallback((log: AuditLogEntry) => { setSelectedLog(log); setDrawerOpen(true); @@ -128,6 +140,8 @@ export default function AuditLogsPanel({ onPaginationChange={setPagination} columnFilters={columnFilters} onColumnFiltersChange={handleColumnFiltersChange} + searchValue={searchInput} + onSearchChange={handleSearchChange} onRefresh={() => query.refetch()} onViewLog={handleViewLog} /> diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx index 7349c3019ae..d8e549715af 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx @@ -120,6 +120,24 @@ describe("AuditLogsTable", () => { expect(screen.getByText("No matching audit logs")).toBeInTheDocument(); }); + it("renders the toolbar search box from the search props and forwards typed input", () => { + const onSearchChange = vi.fn(); + renderTable({ searchValue: "team-", onSearchChange }); + + const input = screen.getByPlaceholderText("Search audit logs by ID…"); + expect(input).toHaveValue("team-"); + + fireEvent.change(input, { target: { value: "team-7" } }); + expect(onSearchChange).toHaveBeenCalledWith("team-7"); + }); + + it("treats an active search as a filter for the empty state", () => { + const emptySearchResult = { data: [], rowCount: 0, searchValue: "zzz", onSearchChange: vi.fn() }; + renderTable(emptySearchResult); + + expect(screen.getByText("No matching audit logs")).toBeInTheDocument(); + }); + it("renders active filter chips with human-readable labels", () => { const filters: ColumnFiltersState = [{ id: "action", value: "created" }]; renderTable({ columnFilters: filters }); diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx index 799505ed07c..cef828838e2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx @@ -24,6 +24,8 @@ interface AuditLogsTableProps { onPaginationChange: OnChangeFn; columnFilters: ColumnFiltersState; onColumnFiltersChange: OnChangeFn; + searchValue?: string; + onSearchChange?: (value: string) => void; onRefresh: () => void; onViewLog: (log: AuditLogEntry) => void; } @@ -102,11 +104,14 @@ export function AuditLogsTable({ onPaginationChange, columnFilters, onColumnFiltersChange, + searchValue, + onSearchChange, onRefresh, onViewLog, }: AuditLogsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }), [onViewLog]); + const hasActiveSearch = Boolean(searchValue?.trim()); return ( 0} />} + noDataMessage={ 0 || hasActiveSearch} />} size="compact" toolbar={(table) => ( <> setFiltersOpen(true)} diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx index 93066c106ee..be0d0049c13 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx @@ -54,6 +54,14 @@ vi.mock("./LogDetailsDrawer", () => ({ }, })); +const debounce = vi.hoisted(() => ({ settled: null as string | null })); + +vi.mock("@tanstack/react-pacer/debouncer", () => ({ + useDebouncedValue: vi.fn((value: unknown) => [debounce.settled ?? value, { cancel: vi.fn(), flush: vi.fn() }]), +})); + +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { uiSpendLogsCall } from "../networking"; const logEntry = (overrides: Partial): LogEntry => ({ @@ -136,6 +144,7 @@ describe("RequestLogsPanel", () => { sessionStorage.clear(); testQueryClient.clear(); respondWith([]); + debounce.settled = null; }); describe("server-grouped session pagination (#38060)", () => { @@ -322,9 +331,8 @@ describe("RequestLogsPanel", () => { }); }); - describe("search by request id (LIT-3981)", () => { - it("sends the typed request id to the server on the first page instead of filtering the loaded rows", async () => { - const user = userEvent.setup(); + describe("search by any id (LIT-3981, LIT-4741)", () => { + it("sends the typed id to the server as search on the first page instead of filtering the loaded rows", async () => { renderPanel(); await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); @@ -334,9 +342,54 @@ describe("RequestLogsPanel", () => { await waitFor(() => { const call = lastCall(); if (!call) throw new Error("uiSpendLogsCall was not called"); - expect(call.params?.request_id).toBe("req-on-another-page"); + expect(call.params?.search).toBe("req-on-another-page"); expect(call.page).toBe(1); }); + expect(lastCall()?.params?.request_id).toBeUndefined(); + expect(lastCall()?.params?.session_cursor).toBeUndefined(); + }); + + it("sends the debounced value to the server while the box shows what is being typed", async () => { + debounce.settled = "settled-id"; + renderPanel(); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + + fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "still-typing" } }); + + expect(screen.getByTestId("datatable-search")).toHaveValue("still-typing"); + await waitFor(() => + expect(useDebouncedValue).toHaveBeenLastCalledWith("still-typing", { wait: DEBOUNCE_WAIT_MS }), + ); + await waitFor(() => expect(lastCall()?.params?.search).toBe("settled-id")); + const sentLiveValue = vi + .mocked(uiSpendLogsCall) + .mock.calls.some(([options]) => options.params?.search === "still-typing"); + expect(sentLiveValue).toBe(false); + }); + + it("shows a Search chip whose remove button clears the box and restores the unsearched listing", async () => { + const user = userEvent.setup(); + vi.mocked(uiSpendLogsCall).mockImplementation(async ({ params }) => { + const data = + params?.search === "sess-42" + ? [logEntry({ request_id: "req-sess", session_id: "sess-42" })] + : [logEntry({ request_id: "req-initial" })]; + return { data, total: data.length, page: 1, page_size: 50, total_pages: 1 }; + }); + renderPanel(); + + await waitFor(() => expect(row("req-initial")).not.toBeNull()); + fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "sess-42" } }); + await waitFor(() => expect(row("req-sess")).not.toBeNull()); + expect(row("req-initial")).toBeNull(); + expect(screen.getByTestId("filter-chip-search")).toHaveTextContent("Search:sess-42"); + + await user.click(screen.getByRole("button", { name: "Remove Search filter" })); + + expect(screen.getByTestId("datatable-search")).toHaveValue(""); + await waitFor(() => expect(row("req-initial")).not.toBeNull()); + expect(row("req-sess")).toBeNull(); }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index 96cf2bd5dde..9b99c6af923 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -1,11 +1,13 @@ "use client"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; import moment from "moment"; import { useCallback, useEffect, useMemo, useState } from "react"; import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import type { KeyResponse } from "../key_team_helpers/key_list"; import { keyInfoV1Call, uiSpendLogsCall } from "../networking"; import KeyInfoView from "../templates/key_info_view"; @@ -75,12 +77,22 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, sessionStorage.setItem("excludeInternalHealthChecks", JSON.stringify(excludeInternalHealthChecks)); }, [excludeInternalHealthChecks]); + const searchTerm = useMemo(() => { + const entry = columnFilters.find((filter) => filter.id === LOG_FILTER_IDS.SEARCH); + return typeof entry?.value === "string" ? entry.value : ""; + }, [columnFilters]); + const [debouncedSearch] = useDebouncedValue(searchTerm, { wait: DEBOUNCE_WAIT_MS }); + const queryColumnFilters = useMemo(() => { + const others = columnFilters.filter((filter) => filter.id !== LOG_FILTER_IDS.SEARCH); + return debouncedSearch === "" ? others : [...others, { id: LOG_FILTER_IDS.SEARCH, value: debouncedSearch }]; + }, [columnFilters, debouncedSearch]); + const { logsQuery, filteredLogs, allTeams, usesSessionCursor } = useLogFilterLogic({ accessToken, token, userRole, userID, - columnFilters, + columnFilters: queryColumnFilters, activeTab: isActive ? "request logs" : "inactive", isLiveTail, excludeInternalHealthChecks, @@ -155,15 +167,10 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, const rows: LogEntry[] = filteredLogs.data; - const searchTerm = useMemo(() => { - const entry = columnFilters.find((filter) => filter.id === LOG_FILTER_IDS.REQUEST_ID); - return typeof entry?.value === "string" ? entry.value : ""; - }, [columnFilters]); - const handleSearchChange = useCallback((value: string) => { setColumnFilters((previous) => { - const others = previous.filter((filter) => filter.id !== LOG_FILTER_IDS.REQUEST_ID); - return value === "" ? others : [...others, { id: LOG_FILTER_IDS.REQUEST_ID, value }]; + const others = previous.filter((filter) => filter.id !== LOG_FILTER_IDS.SEARCH); + return value === "" ? others : [...others, { id: LOG_FILTER_IDS.SEARCH, value }]; }); setSessionCursors({}); setPagination((previous) => ({ ...previous, pageIndex: 0 })); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx index 4159b3b699b..db108ee681c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx @@ -86,6 +86,7 @@ export function RequestLogsTable({ data={data} columns={columns} getRowId={(row) => row.request_id} + fillHeight sortingMode="server" sorting={sorting} onSortingChange={onSortingChange} @@ -108,7 +109,7 @@ export function RequestLogsTable({ table={table} searchValue={searchValue} onSearchChange={onSearchChange} - searchPlaceholder="Search by Request ID" + searchPlaceholder="Search logs by ID…" onRefresh={onRefresh} isRefreshing={isRefreshing} onOpenFilters={() => setFiltersOpen(true)} diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index f20285dffa0..aadf90fad6c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -27,6 +27,9 @@ const AUDIT_LOGS_TAB: LogsTab = { id: "audit logs", label: "Audit Logs" }; const DELETED_KEYS_TAB: LogsTab = { id: "deleted keys", label: "Deleted Keys" }; const DELETED_TEAMS_TAB: LogsTab = { id: "deleted teams", label: "Deleted Teams" }; +const tabContentClassName = (tabId: LogsTabId): string => + tabId === REQUEST_LOGS_TAB.id ? "flex min-h-0 flex-1 flex-col" : "min-h-0 flex-1 overflow-y-auto"; + export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) { const [activeTab, setActiveTab] = useState(REQUEST_LOGS_TAB.id); const canViewAuditLogs = useCan("viewAuditLogs"); @@ -78,8 +81,8 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p }; return ( -
- setActiveTab(value as LogsTabId)}> +
+ setActiveTab(value as LogsTabId)} className="min-h-0 flex-1"> {tabs.map((tab) => ( @@ -88,7 +91,7 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p ))} {tabs.map((tab) => ( - + {renderPanel(tab.id)} ))} diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index 1b738db097d..6af791cc50e 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -91,6 +91,7 @@ describe("useLogFilterLogic", () => { { id: LOG_FILTER_IDS.ERROR_CODE, value: "429", param: "error_code" }, { id: LOG_FILTER_IDS.ERROR_MESSAGE, value: "rate limited", param: "error_message" }, { id: LOG_FILTER_IDS.USER_ID, value: "user-9", param: "user_id" }, + { id: LOG_FILTER_IDS.SEARCH, value: "any-id", param: "search" }, ]; it.each(cases)("sends $id as $param", async ({ id, value, param }) => { diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 90f0f0a60f1..3d368527ad9 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -33,6 +33,7 @@ export const LOG_FILTER_IDS = { PUBLIC_MODEL_OR_SEARCH_TOOL: "model", REQUEST_ID: "request_id", USER_ID: "user_id", + SEARCH: "search", } as const; export const LOG_FILTER_LABELS: Record = { @@ -48,6 +49,7 @@ export const LOG_FILTER_LABELS: Record = { [LOG_FILTER_IDS.SESSION_ID]: "Session ID", [LOG_FILTER_IDS.MODEL_ID]: "Model", [LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool", + [LOG_FILTER_IDS.SEARCH]: "Search", }; export interface LogsWindow { @@ -175,6 +177,7 @@ export function useLogFilterLogic({ api_key: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_HASH), team_id: getFilterValue(columnFilters, LOG_FILTER_IDS.TEAM_ID), request_id: getFilterValue(columnFilters, LOG_FILTER_IDS.REQUEST_ID), + search: getFilterValue(columnFilters, LOG_FILTER_IDS.SEARCH), session_id: getFilterValue(columnFilters, LOG_FILTER_IDS.SESSION_ID), user_id: userIdFilter, end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER), diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts index 3dd9911c794..ffede7b2a6b 100644 --- a/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts +++ b/ui/litellm-dashboard/src/lib/autorouter_presets.test.ts @@ -83,9 +83,19 @@ describe("autorouter_presets", () => { expect(config.tier_boundaries).toBeUndefined(); expect(config.token_thresholds).toBeUndefined(); expect(config.dimension_weights).toBeUndefined(); + expect(config.session_affinity_ttl_seconds).toBeUndefined(); } }); + it("carries a preset's session affinity idle window into the prefilled form state", () => { + const config = getPresetByKey("anthropic_family")!.complexity_router_config; + const prefill = buildPresetPrefill({ ...config, session_affinity_ttl_seconds: 300 }, groupsOnly([])); + expect(prefill.complexityRouterConfig.session_affinity_ttl_seconds).toBe(300); + expect( + buildPresetPrefill(config, groupsOnly([])).complexityRouterConfig.session_affinity_ttl_seconds, + ).toBeUndefined(); + }); + it("keeps the model-family presets on the heuristic classifier", () => { for (const key of ["anthropic_family", "gemini_family", "openai_family"]) { expect(getPresetByKey(key)!.complexity_router_config.classifier_type).toBe("heuristic"); diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts index ff482bd23b8..2aafbfcfcd9 100644 --- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts +++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts @@ -152,7 +152,7 @@ export const deploymentRefsFromModelInfo = ( return row.model_name && underlyingModels.length > 0 ? [{ modelGroup: row.model_name, underlyingModels }] : []; }); -const resolveAvailableModel = (requiredModel: string, availability: ModelAvailability): string | undefined => { +export const resolveAvailableModel = (requiredModel: string, availability: ModelAvailability): string | undefined => { const { modelGroups, underlyingIndex } = availability; if (modelGroups.has(requiredModel)) return requiredModel; const normalized = normalizeModelName(requiredModel); @@ -284,6 +284,7 @@ export const buildPresetPrefill = ( classifier_context_include_assistant_turns: config.classifier_context_include_assistant_turns, classification_mode: config.classification_mode ?? DEFAULT_CLASSIFICATION_MODE, session_affinity: config.session_affinity ?? DEFAULT_SESSION_AFFINITY, + session_affinity_ttl_seconds: config.session_affinity_ttl_seconds, deployment_affinity: config.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY, modality_routing: config.modality_routing ?? false, modality_pin_override: config.modality_pin_override ?? false, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 5246aaf6904..3dcfeb64866 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16606,6 +16606,8 @@ export interface paths { * Get list of users by sso_ids. Comma separated list of sso_ids. * user_email: Optional[str] * Filter users by partial email match + * search: Optional[str] + * Combined search: matches users whose user_id or user_email contains the value (case-insensitive) * team: Optional[str] * Filter users by team id. Will match if user has this team in their teams array. * page: int @@ -25499,6 +25501,12 @@ export interface components { * @description Documents all the fields supported by `general_settings` in config.yaml */ ConfigGeneralSettings: { + /** + * Admission Queue Timeout Seconds + * @description maximum time a request waits for a worker slot + * @default 1 + */ + admission_queue_timeout_seconds: number; /** * Alert To Webhook Url * @description Mapping of alert type to webhook url. e.g. `alert_to_webhook_url: {'budget_alerts': 'https://nothooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'}` @@ -25707,11 +25715,21 @@ export interface components { * @description max file size in MB for /v1/files uploads, for any purpose, if a file is larger than this size it will be rejected before being forwarded to the provider */ max_file_size_mb?: number | null; + /** + * Max In Flight Requests Per Worker + * @description maximum concurrent requests handled by each worker + */ + max_in_flight_requests_per_worker?: number | null; /** * Max Parallel Requests * @description maximum parallel requests for each api key */ max_parallel_requests?: number | null; + /** + * Max Queued Requests Per Worker + * @description maximum requests waiting for a worker slot + */ + max_queued_requests_per_worker?: number | null; /** * Max Request Size Mb * @description max request size in MB, if a request is larger than this size it will be rejected @@ -40902,6 +40920,8 @@ export interface operations { object_team_id?: string | null; /** @description Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only) */ object_key_hash?: string | null; + /** @description Match a row whose id, object_id, changed_by, or changed_by_api_key equals this value */ + search?: string | null; /** @description Column to sort by (e.g. 'updated_at', 'action', 'table_name') */ sort_by?: string | null; /** @description Sort order ('asc' or 'desc') */ @@ -44068,7 +44088,11 @@ export interface operations { }; list_containers_containers_get: { parameters: { - query?: never; + query?: { + after?: string | null; + limit?: number | null; + order?: string | null; + }; header?: never; path?: never; cookie?: never; @@ -44084,6 +44108,15 @@ export interface operations { "application/json": unknown; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; create_container_containers_post: { @@ -49607,6 +49640,8 @@ export interface operations { key_hash?: string | null; /** @description Filter keys by key alias. Exact match by default; set substring_matching=true (admin only) for case-insensitive substring matching. */ key_alias?: string | null; + /** @description Combined search: matches keys whose token (key hash) equals the value OR whose key_alias contains it (case-insensitive). */ + search?: string | null; /** @description Return full key object */ return_full_object?: boolean; /** @description Include all keys for teams that user is an admin of. */ @@ -56865,6 +56900,8 @@ export interface operations { group_by_session?: boolean; /** @description Keyset cursor '||' from a previous group_by_session page. UI route only, honored when sorting by startTime */ session_cursor?: string | null; + /** @description Match a log whose request_id, api_key (hash), team_id, user, end_user, session_id, or model_id equals this value. request_id matches across all time; the other columns match inside start_date/end_date, which stay required */ + search?: string | null; }; header?: never; path?: never; @@ -56981,6 +57018,8 @@ export interface operations { group_by_session?: boolean; /** @description Keyset cursor '||' from a previous group_by_session page. UI route only, honored when sorting by startTime */ session_cursor?: string | null; + /** @description Match a log whose request_id, api_key (hash), team_id, user, end_user, session_id, or model_id equals this value. request_id matches across all time; the other columns match inside start_date/end_date, which stay required */ + search?: string | null; }; header?: never; path?: never; @@ -59835,6 +59874,8 @@ export interface operations { sso_user_ids?: string | null; /** @description Filter users by partial email match */ user_email?: string | null; + /** @description Combined search: matches users whose 'user_id' or 'user_email' contains the value (case-insensitive). */ + search?: string | null; /** @description Filter users by team id */ team?: string | null; /** @description Page number */ @@ -61110,7 +61151,11 @@ export interface operations { }; list_containers_v1_containers_get: { parameters: { - query?: never; + query?: { + after?: string | null; + limit?: number | null; + order?: string | null; + }; header?: never; path?: never; cookie?: never; @@ -61126,6 +61171,15 @@ export interface operations { "application/json": unknown; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; create_container_v1_containers_post: { @@ -63278,6 +63332,8 @@ export interface operations { key?: string | null; /** @description Filter by key prefix (Redis-style namespace scan). Mutually exclusive with `key`; if both are provided, `key_prefix` wins. */ key_prefix?: string | null; + /** @description Match entries whose key starts with this value or whose memory_id equals it. Takes precedence over `key_prefix` and `key` when provided. */ + search?: string | null; page?: number; page_size?: number; };