Merge branch 'main' into litellm_ui_fix_guardrail_edit

This commit is contained in:
Yogeshwaran Ravichandran
2026-01-24 16:26:44 +05:30
committed by GitHub
277 changed files with 8323 additions and 1047 deletions
+27 -11
View File
@@ -119,7 +119,7 @@ jobs:
username: ${DOCKERHUB_USERNAME}
password: ${DOCKERHUB_PASSWORD}
working_directory: ~/project
parallelism: 4
steps:
- checkout
- setup_google_dns
@@ -207,12 +207,24 @@ jobs:
- run:
name: Run tests
command: |
pwd
ls
# Add --timeout to kill hanging tests after 300s (5 min)
# Add -v to show test names as they run for debugging
# Add --tb=short for shorter tracebacks
python -m pytest -vv tests/local_testing --cov=litellm --cov-report=xml --junitxml=test-results/junit.xml --durations=20 -k "not test_python_38.py and not test_basic_python_version.py and not router and not assistants and not langfuse and not caching and not cache" -n 4 --timeout=300 --timeout_method=thread
mkdir test-results
# Discover test files
TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_*.py")
echo "$TEST_FILES" | circleci tests run \
--split-by=timings \
--verbose \
--command="xargs python -m pytest \
-vv \
--cov=litellm \
--cov-report=xml \
--junitxml=test-results/junit.xml \
--durations=20 \
-k \"not test_python_38.py and not test_basic_python_version.py and not router and not assistants and not langfuse and not caching and not cache\" \
-n 4 \
--timeout=300 \
no_output_timeout: 120m
- run:
name: Rename the coverage files
@@ -499,7 +511,6 @@ jobs:
username: ${DOCKERHUB_USERNAME}
password: ${DOCKERHUB_PASSWORD}
working_directory: ~/project
steps:
- checkout
- setup_google_dns
@@ -513,6 +524,7 @@ jobs:
pip install "pytest-cov==5.0.0"
pip install "pytest-retry==1.6.3"
pip install "pytest-asyncio==0.21.1"
pip install "pytest-xdist==3.6.1"
pip install semantic_router --no-deps
pip install aurelio_sdk --no-deps
# Run pytest and generate JUnit XML report
@@ -1743,13 +1755,14 @@ jobs:
pip install "pytest-cov==5.0.0"
pip install "pytest-asyncio==0.21.1"
pip install "respx==0.22.0"
pip install "pytest-xdist==3.6.1"
# Run pytest and generate JUnit XML report
- run:
name: Run tests
command: |
pwd
ls
python -m pytest -vv tests/image_gen_tests --cov=litellm --cov-report=xml -x -v --junitxml=test-results/junit.xml --durations=5
python -m pytest -vv tests/image_gen_tests -n 4 --cov=litellm --cov-report=xml -x -v --junitxml=test-results/junit.xml --durations=5
no_output_timeout: 120m
- run:
name: Rename the coverage files
@@ -1792,6 +1805,7 @@ jobs:
pip install "mlflow==2.17.2"
pip install "anthropic==0.52.0"
pip install "blockbuster==1.5.24"
pip install "pytest-xdist==3.6.1"
# Run pytest and generate JUnit XML report
- setup_litellm_enterprise_pip
- run:
@@ -1799,7 +1813,7 @@ jobs:
command: |
pwd
ls
python -m pytest -vv tests/logging_callback_tests --cov=litellm --cov-report=xml -s -v --junitxml=test-results/junit.xml --durations=5
python -m pytest -vv tests/logging_callback_tests --cov=litellm -n 4 --cov-report=xml -s -v --junitxml=test-results/junit.xml --durations=5
no_output_timeout: 120m
- run:
name: Rename the coverage files
@@ -2192,6 +2206,8 @@ jobs:
pip install "asyncio==3.4.3"
pip install "PyGithub==1.59.1"
pip install "openai==1.100.1"
pip install "litellm[proxy]"
pip install "pytest-xdist==3.6.1"
- run:
name: Install dockerize
command: |
@@ -2268,7 +2284,7 @@ jobs:
command: |
pwd
ls
python -m pytest -s -vv tests/*.py -x --junitxml=test-results/junit.xml --durations=5 --ignore=tests/otel_tests --ignore=tests/spend_tracking_tests --ignore=tests/pass_through_tests --ignore=tests/proxy_admin_ui_tests --ignore=tests/load_tests --ignore=tests/llm_translation --ignore=tests/llm_responses_api_testing --ignore=tests/mcp_tests --ignore=tests/guardrails_tests --ignore=tests/image_gen_tests --ignore=tests/pass_through_unit_tests
python -m pytest -s -vv tests/*.py -x --junitxml=test-results/junit.xml -n 4 --durations=5 --ignore=tests/otel_tests --ignore=tests/spend_tracking_tests --ignore=tests/pass_through_tests --ignore=tests/proxy_admin_ui_tests --ignore=tests/load_tests --ignore=tests/llm_translation --ignore=tests/llm_responses_api_testing --ignore=tests/mcp_tests --ignore=tests/guardrails_tests --ignore=tests/image_gen_tests --ignore=tests/pass_through_unit_tests
no_output_timeout: 120m
# Store test results
+1
View File
@@ -1,5 +1,6 @@
.python-version
.venv
.venv_policy_test
.env
.newenv
newenv/*
+15
View File
@@ -138,6 +138,21 @@ run_grype_scans() {
"CVE-2026-22184" # zlib untgz buffer overflow - untgz unused + no fixed Wolfi build yet
"GHSA-58pv-8j8x-9vj2" # jaraco.context path traversal - setuptools vendored only (v5.3.0), not used in application code (using v6.1.0+)
"GHSA-r6q2-hw4h-h46w" # node-tar not used by application runtime, Linux-only container, not affect by macOS APFS-specific exploit
"GHSA-8rrh-rw8j-w5fx" # wheel is from chainguard and will be handled by then TODO: Remove this after Chainguard updates the wheel
"CVE-2025-59465" # We do not use Node in application runtime, only used for building Admin UI
"CVE-2025-55131" # We do not use Node in application runtime, only used for building Admin UI
"CVE-2025-59466" # We do not use Node in application runtime, only used for building Admin UI
"CVE-2025-55130" # We do not use Node in application runtime, only used for building Admin UI
"CVE-2025-59467" # We do not use Node in application runtime, only used for building Admin UI
"CVE-2026-21637" # We do not use Node in application runtime, only used for building Admin UI
"CVE-2025-15281" # No fix available yet
"CVE-2026-0865" # No fix available yet
"CVE-2025-15282" # No fix available yet
"CVE-2026-0672" # No fix available yet
"CVE-2025-15366" # No fix available yet
"CVE-2025-15367" # No fix available yet
"CVE-2025-12781" # No fix available yet
"CVE-2025-11468" # No fix available yet
)
# Build JSON array of allowlisted CVE IDs for jq
@@ -611,6 +611,8 @@ router_settings:
| GALILEO_USERNAME | Username for Galileo authentication
| GOOGLE_SECRET_MANAGER_PROJECT_ID | Project ID for Google Secret Manager
| GCS_BUCKET_NAME | Name of the Google Cloud Storage bucket
| GCS_MOCK | Enable mock mode for GCS integration testing. When set to true, intercepts GCS API calls and returns mock responses without making actual network calls. Default is false
| GCS_MOCK_LATENCY_MS | Mock latency in milliseconds for GCS API calls when mock mode is enabled. Simulates network round-trip time. Default is 150ms
| GCS_PATH_SERVICE_ACCOUNT | Path to the Google Cloud service account JSON file
| GCS_FLUSH_INTERVAL | Flush interval for GCS logging (in seconds). Specify how often you want a log to be sent to GCS. **Default is 20 seconds**
| GCS_BATCH_SIZE | Batch size for GCS logging. Specify after how many logs you want to flush to GCS. If `BATCH_SIZE` is set to 10, logs are flushed every 10 logs. **Default is 2048**
@@ -697,6 +699,8 @@ router_settings:
| LANGFUSE_FLUSH_INTERVAL | Interval for flushing Langfuse logs
| LANGFUSE_TRACING_ENVIRONMENT | Environment for Langfuse tracing
| LANGFUSE_HOST | Host URL for Langfuse service
| LANGFUSE_MOCK | Enable mock mode for Langfuse integration testing. When set to true, intercepts Langfuse API calls and returns mock responses without making actual network calls. Default is false
| LANGFUSE_MOCK_LATENCY_MS | Mock latency in milliseconds for Langfuse API calls when mock mode is enabled. Simulates network round-trip time. Default is 100ms
| LANGFUSE_PUBLIC_KEY | Public key for Langfuse authentication
| LANGFUSE_RELEASE | Release version of Langfuse integration
| LANGFUSE_SECRET_KEY | Secret key for Langfuse authentication
+1
View File
@@ -200,6 +200,7 @@ Example `requirements.txt`
```shell
litellm[proxy]==1.57.3 # Specify the litellm version you want to use
litellm-enterprise
prometheus_client
langfuse
prisma
@@ -11,7 +11,7 @@ import Image from '@theme/IdealImage';
This is a free LiteLLM Enterprise feature.
Available via the `litellm[proxy]` package or any `litellm` docker image.
Available via the `litellm` docker image. If you are using the pip package, you must install [`litellm-enterprise`](https://pypi.org/project/litellm-enterprise/).
:::
@@ -0,0 +1,46 @@
# Spend Update Queue Full Warnings
## Overview
The "Spend update queue is full" warning occurs in high-volume LiteLLM proxy deployments when the internal spend tracking queue reaches capacity. This is a protective mechanism to prevent memory issues during traffic spikes.
## Warning Message
```
WARNING:litellm.proxy.db.db_transaction_queue.spend_update_queue:Spend update queue is full. Aggregating entries to prevent memory issues.
```
## Root Cause
The spend update queue has a default maximum size of 10,000 entries (`MAX_SIZE_IN_MEMORY_QUEUE=10000`). When this limit is reached:
1. New spend tracking entries are aggregated instead of queued individually
2. This prevents memory exhaustion but may slightly delay spend updates
3. The warning indicates your deployment is processing requests faster than the database can handle spend updates
## Solutions
### 1. Increase Queue Size
Set the `MAX_SIZE_IN_MEMORY_QUEUE` environment variable to a higher value:
```bash
MAX_SIZE_IN_MEMORY_QUEUE=50000
```
**Tradeoffs:**
Higher queue sizes store more items in memory - provision at least 8GB RAM for large queues
- Recommended for deployments with consistent high traffic
### 2. Horizontal Scaling
Deploy multiple proxy instances with load balancing. This distributes the spend tracking load across multiple queues, reducing the pressure on any single instance's spend update queue.
## Related Configuration
```yaml
# Environment variables
MAX_SIZE_IN_MEMORY_QUEUE: 10000 # Default queue size
```
@@ -62,7 +62,7 @@ This means you can now use Claude Code's web search tool with any provider, not
Proxy Admins can configure web search interception in their LiteLLM proxy config to enable this capability for their teams using Claude Code with Bedrock, Azure, or any other supported provider.
[**Learn more →**](../../docs/tutorials/claude_code_websearch.md)
[**Learn more →**](https://docs.litellm.ai/docs/tutorials/claude_code_websearch)
---
+1
View File
@@ -1017,6 +1017,7 @@ const sidebars = {
items: [
"troubleshoot/cpu_issues",
"troubleshoot/memory_issues",
"troubleshoot/spend_queue_warnings",
],
},
],
Binary file not shown.
@@ -0,0 +1,51 @@
-- AlterTable
ALTER TABLE "LiteLLM_DeletedTeamTable" ADD COLUMN "policies" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN "policies" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "LiteLLM_TeamTable" ADD COLUMN "policies" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "LiteLLM_UserTable" ADD COLUMN "policies" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN "policies" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CreateTable
CREATE TABLE "LiteLLM_PolicyTable" (
"policy_id" TEXT NOT NULL,
"policy_name" TEXT NOT NULL,
"inherit" TEXT,
"description" TEXT,
"guardrails_add" TEXT[] DEFAULT ARRAY[]::TEXT[],
"guardrails_remove" TEXT[] DEFAULT ARRAY[]::TEXT[],
"condition" JSONB DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" TEXT,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" TEXT,
CONSTRAINT "LiteLLM_PolicyTable_pkey" PRIMARY KEY ("policy_id")
);
-- CreateTable
CREATE TABLE "LiteLLM_PolicyAttachmentTable" (
"attachment_id" TEXT NOT NULL,
"policy_name" TEXT NOT NULL,
"scope" TEXT,
"teams" TEXT[] DEFAULT ARRAY[]::TEXT[],
"keys" TEXT[] DEFAULT ARRAY[]::TEXT[],
"models" TEXT[] DEFAULT ARRAY[]::TEXT[],
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" TEXT,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" TEXT,
CONSTRAINT "LiteLLM_PolicyAttachmentTable_pkey" PRIMARY KEY ("attachment_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "LiteLLM_PolicyTable_policy_name_key" ON "LiteLLM_PolicyTable"("policy_name");
@@ -124,8 +124,9 @@ model LiteLLM_TeamTable {
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
model_spend Json @default("{}")
model_max_budget Json @default("{}")
router_settings Json? @default("{}")
router_settings Json? @default("{}")
team_member_permissions String[] @default([])
policies String[] @default([])
model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id])
@@ -156,6 +157,7 @@ model LiteLLM_DeletedTeamTable {
model_max_budget Json @default("{}")
router_settings Json? @default("{}")
team_member_permissions String[] @default([])
policies String[] @default([])
model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases
// Original timestamps from team creation/updates
@@ -197,6 +199,7 @@ model LiteLLM_UserTable {
budget_duration String?
budget_reset_at DateTime?
allowed_cache_controls String[] @default([])
policies String[] @default([])
model_spend Json @default("{}")
model_max_budget Json @default("{}")
created_at DateTime? @default(now()) @map("created_at")
@@ -283,6 +286,7 @@ model LiteLLM_VerificationToken {
budget_reset_at DateTime?
allowed_cache_controls String[] @default([])
allowed_routes String[] @default([])
policies String[] @default([])
model_spend Json @default("{}")
model_max_budget Json @default("{}")
budget_id String?
@@ -327,6 +331,7 @@ model LiteLLM_DeletedVerificationToken {
budget_reset_at DateTime?
allowed_cache_controls String[] @default([])
allowed_routes String[] @default([])
policies String[] @default([])
model_spend Json @default("{}")
model_max_budget Json @default("{}")
router_settings Json? @default("{}")
@@ -863,3 +868,32 @@ model LiteLLM_SkillsTable {
updated_at DateTime @default(now()) @updatedAt
updated_by String?
}
// Policy table for storing guardrail policies
model LiteLLM_PolicyTable {
policy_id String @id @default(uuid())
policy_name String @unique
inherit String? // Name of parent policy to inherit from
description String?
guardrails_add String[] @default([])
guardrails_remove String[] @default([])
condition Json? @default("{}") // Policy conditions (e.g., model matching)
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
updated_by String?
}
// Policy attachment table for defining where policies apply
model LiteLLM_PolicyAttachmentTable {
attachment_id String @id @default(uuid())
policy_name String // Name of the policy to attach
scope String? // Use '*' for global scope
teams String[] @default([]) // Team aliases or patterns
keys String[] @default([]) // Key aliases or patterns
models String[] @default([]) // Model names or patterns
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
updated_by String?
}
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "litellm-proxy-extras"
version = "0.4.25"
version = "0.4.26"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
authors = ["BerriAI"]
readme = "README.md"
@@ -22,7 +22,7 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.commitizen]
version = "0.4.25"
version = "0.4.26"
version_files = [
"pyproject.toml:version",
"../requirements.txt:litellm-proxy-extras==",
+315 -142
View File
@@ -13,18 +13,20 @@ from litellm.types.utils import StandardLoggingPayload
if TYPE_CHECKING:
from opentelemetry.trace import Span
from litellm.integrations._types.open_inference import (
MessageAttributes,
ImageAttributes,
SpanAttributes,
AudioAttributes,
EmbeddingAttributes,
OpenInferenceSpanKindValues
)
class ArizeOTELAttributes(BaseLLMObsOTELAttributes):
@staticmethod
@override
def set_messages(span: "Span", kwargs: Dict[str, Any]):
from litellm.integrations._types.open_inference import (
MessageAttributes,
SpanAttributes,
)
messages = kwargs.get("messages")
# for /chat/completions
@@ -56,7 +58,6 @@ class ArizeOTELAttributes(BaseLLMObsOTELAttributes):
def set_response_output_messages(span: "Span", response_obj):
"""
Sets output message attributes on the span from the LLM response.
Args:
span: The OpenTelemetry span to set attributes on
response_obj: The response object containing choices with messages
@@ -88,112 +89,243 @@ class ArizeOTELAttributes(BaseLLMObsOTELAttributes):
)
def _set_tool_attributes(span: "Span", optional_params: dict):
"""Helper to set tool and function call attributes on span."""
from litellm.integrations._types.open_inference import (
MessageAttributes,
SpanAttributes,
ToolCallAttributes,
)
tools = optional_params.get("tools")
if tools:
for idx, tool in enumerate(tools):
function = tool.get("function")
if not function:
continue
prefix = f"{SpanAttributes.LLM_TOOLS}.{idx}"
safe_set_attribute(
span, f"{prefix}.{SpanAttributes.TOOL_NAME}", function.get("name")
)
safe_set_attribute(
span,
f"{prefix}.{SpanAttributes.TOOL_DESCRIPTION}",
function.get("description"),
)
safe_set_attribute(
span,
f"{prefix}.{SpanAttributes.TOOL_PARAMETERS}",
json.dumps(function.get("parameters")),
)
functions = optional_params.get("functions")
if functions:
for idx, function in enumerate(functions):
prefix = f"{MessageAttributes.MESSAGE_TOOL_CALLS}.{idx}"
safe_set_attribute(
span,
f"{prefix}.{ToolCallAttributes.TOOL_CALL_FUNCTION_NAME}",
function.get("name"),
)
def _set_response_attributes(span: "Span", response_obj):
"""Helper to set response output and token usage attributes on span."""
from litellm.integrations._types.open_inference import (
MessageAttributes,
SpanAttributes,
)
if not hasattr(response_obj, "get"):
return
_set_choice_outputs(span, response_obj, MessageAttributes, SpanAttributes)
_set_image_outputs(span, response_obj, ImageAttributes, SpanAttributes)
_set_audio_outputs(span, response_obj, AudioAttributes, SpanAttributes)
_set_embedding_outputs(span, response_obj, EmbeddingAttributes, SpanAttributes)
_set_structured_outputs(span, response_obj, MessageAttributes, SpanAttributes)
_set_usage_outputs(span, response_obj, SpanAttributes)
def _set_choice_outputs(span: "Span", response_obj, msg_attrs, span_attrs):
for idx, choice in enumerate(response_obj.get("choices", [])):
response_message = choice.get("message", {})
safe_set_attribute(
span,
SpanAttributes.OUTPUT_VALUE,
span_attrs.OUTPUT_VALUE,
response_message.get("content", ""),
)
prefix = f"{SpanAttributes.LLM_OUTPUT_MESSAGES}.{idx}"
prefix = f"{span_attrs.LLM_OUTPUT_MESSAGES}.{idx}"
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_ROLE}",
f"{prefix}.{msg_attrs.MESSAGE_ROLE}",
response_message.get("role"),
)
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_CONTENT}",
f"{prefix}.{msg_attrs.MESSAGE_CONTENT}",
response_message.get("content", ""),
)
output_items = response_obj.get("output", [])
if output_items:
for i, item in enumerate(output_items):
prefix = f"{SpanAttributes.LLM_OUTPUT_MESSAGES}.{i}"
if hasattr(item, "type"):
item_type = item.type
if item_type == "reasoning" and hasattr(item, "summary"):
for summary in item.summary:
if hasattr(summary, "text"):
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_REASONING_SUMMARY}",
summary.text,
)
elif item_type == "message" and hasattr(item, "content"):
message_content = ""
content_list = item.content
if content_list and len(content_list) > 0:
first_content = content_list[0]
message_content = getattr(first_content, "text", "")
message_role = getattr(item, "role", "assistant")
safe_set_attribute(span, SpanAttributes.OUTPUT_VALUE, message_content)
safe_set_attribute(span, f"{prefix}.{MessageAttributes.MESSAGE_CONTENT}", message_content)
safe_set_attribute(span, f"{prefix}.{MessageAttributes.MESSAGE_ROLE}", message_role)
def _set_image_outputs(span: "Span", response_obj, image_attrs, span_attrs):
images = response_obj.get("data", [])
for i, image in enumerate(images):
img_url = image.get("url")
if img_url is None and image.get("b64_json"):
img_url = f"data:image/png;base64,{image.get('b64_json')}"
if not img_url:
continue
if i == 0:
safe_set_attribute(span, span_attrs.OUTPUT_VALUE, img_url)
safe_set_attribute(span, f"{image_attrs.IMAGE_URL}.{i}", img_url)
def _set_audio_outputs(span: "Span", response_obj, audio_attrs, span_attrs):
audio = response_obj.get("audio", [])
for i, audio_item in enumerate(audio):
audio_url = audio_item.get("url")
if audio_url is None and audio_item.get("b64_json"):
audio_url = f"data:audio/wav;base64,{audio_item.get('b64_json')}"
if audio_url:
if i == 0:
safe_set_attribute(span, span_attrs.OUTPUT_VALUE, audio_url)
safe_set_attribute(span, f"{audio_attrs.AUDIO_URL}.{i}", audio_url)
audio_mime = audio_item.get("mime_type")
if audio_mime:
safe_set_attribute(span, f"{audio_attrs.AUDIO_MIME_TYPE}.{i}", audio_mime)
audio_transcript = audio_item.get("transcript")
if audio_transcript:
safe_set_attribute(span, f"{audio_attrs.AUDIO_TRANSCRIPT}.{i}", audio_transcript)
def _set_embedding_outputs(span: "Span", response_obj, embedding_attrs, span_attrs):
embeddings = response_obj.get("data", [])
for i, embedding_item in enumerate(embeddings):
embedding_vector = embedding_item.get("embedding")
if embedding_vector:
if i == 0:
safe_set_attribute(
span,
span_attrs.OUTPUT_VALUE,
str(embedding_vector),
)
safe_set_attribute(
span,
f"{embedding_attrs.EMBEDDING_VECTOR}.{i}",
str(embedding_vector),
)
embedding_text = embedding_item.get("text")
if embedding_text:
safe_set_attribute(
span,
f"{embedding_attrs.EMBEDDING_TEXT}.{i}",
str(embedding_text),
)
def _set_structured_outputs(span: "Span", response_obj, msg_attrs, span_attrs):
output_items = response_obj.get("output", [])
for i, item in enumerate(output_items):
prefix = f"{span_attrs.LLM_OUTPUT_MESSAGES}.{i}"
if not hasattr(item, "type"):
continue
item_type = item.type
if item_type == "reasoning" and hasattr(item, "summary"):
for summary in item.summary:
if hasattr(summary, "text"):
safe_set_attribute(
span,
f"{prefix}.{msg_attrs.MESSAGE_REASONING_SUMMARY}",
summary.text,
)
elif item_type == "message" and hasattr(item, "content"):
message_content = ""
content_list = item.content
if content_list and len(content_list) > 0:
first_content = content_list[0]
message_content = getattr(first_content, "text", "")
message_role = getattr(item, "role", "assistant")
safe_set_attribute(span, span_attrs.OUTPUT_VALUE, message_content)
safe_set_attribute(span, f"{prefix}.{msg_attrs.MESSAGE_CONTENT}", message_content)
safe_set_attribute(span, f"{prefix}.{msg_attrs.MESSAGE_ROLE}", message_role)
def _set_usage_outputs(span: "Span", response_obj, span_attrs):
usage = response_obj and response_obj.get("usage")
if usage:
safe_set_attribute(span, SpanAttributes.LLM_TOKEN_COUNT_TOTAL, usage.get("total_tokens"))
completion_tokens = usage.get("completion_tokens") or usage.get("output_tokens")
if completion_tokens:
safe_set_attribute(span, SpanAttributes.LLM_TOKEN_COUNT_COMPLETION, completion_tokens)
prompt_tokens = usage.get("prompt_tokens") or usage.get("input_tokens")
if prompt_tokens:
safe_set_attribute(span, SpanAttributes.LLM_TOKEN_COUNT_PROMPT, prompt_tokens)
reasoning_tokens = usage.get("output_tokens_details", {}).get("reasoning_tokens")
if reasoning_tokens:
safe_set_attribute(span, SpanAttributes.LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING, reasoning_tokens)
if not usage:
return
safe_set_attribute(span, span_attrs.LLM_TOKEN_COUNT_TOTAL, usage.get("total_tokens"))
completion_tokens = usage.get("completion_tokens") or usage.get("output_tokens")
if completion_tokens:
safe_set_attribute(span, span_attrs.LLM_TOKEN_COUNT_COMPLETION, completion_tokens)
prompt_tokens = usage.get("prompt_tokens") or usage.get("input_tokens")
if prompt_tokens:
safe_set_attribute(span, span_attrs.LLM_TOKEN_COUNT_PROMPT, prompt_tokens)
reasoning_tokens = usage.get("output_tokens_details", {}).get("reasoning_tokens")
if reasoning_tokens:
safe_set_attribute(span, span_attrs.LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING, reasoning_tokens)
def _infer_open_inference_span_kind(call_type: Optional[str]) -> str:
"""
Map LiteLLM call types to OpenInference span kinds.
"""
if not call_type:
return OpenInferenceSpanKindValues.UNKNOWN.value
lowered = str(call_type).lower()
if "embed" in lowered:
return OpenInferenceSpanKindValues.EMBEDDING.value
if "rerank" in lowered:
return OpenInferenceSpanKindValues.RERANKER.value
if "search" in lowered:
return OpenInferenceSpanKindValues.RETRIEVER.value
if "moderation" in lowered or "guardrail" in lowered:
return OpenInferenceSpanKindValues.GUARDRAIL.value
if lowered == "call_mcp_tool" or lowered == "mcp" or lowered.endswith("tool"):
return OpenInferenceSpanKindValues.TOOL.value
if "asend_message" in lowered or "a2a" in lowered or "assistant" in lowered:
return OpenInferenceSpanKindValues.AGENT.value
if any(
keyword in lowered
for keyword in (
"completion",
"chat",
"image",
"audio",
"speech",
"transcription",
"generate_content",
"response",
"videos",
"realtime",
"pass_through",
"anthropic_messages",
"ocr",
)
):
return OpenInferenceSpanKindValues.LLM.value
if any(keyword in lowered for keyword in ("file", "batch", "container", "fine_tuning_job")):
return OpenInferenceSpanKindValues.CHAIN.value
return OpenInferenceSpanKindValues.UNKNOWN.value
def _set_tool_attributes(
span: "Span", optional_tools: Optional[list], metadata_tools: Optional[list]
):
"""set tool attributes on span from optional_params or tool call metadata"""
if optional_tools:
for idx, tool in enumerate(optional_tools):
if not isinstance(tool, dict):
continue
function = tool.get("function") if isinstance(tool.get("function"), dict) else None
if not function:
continue
tool_name = function.get("name")
if tool_name:
safe_set_attribute(span, f"{SpanAttributes.LLM_TOOLS}.{idx}.name", tool_name)
tool_description = function.get("description")
if tool_description:
safe_set_attribute(span, f"{SpanAttributes.LLM_TOOLS}.{idx}.description", tool_description)
params = function.get("parameters")
if params is not None:
safe_set_attribute(span, f"{SpanAttributes.LLM_TOOLS}.{idx}.parameters", json.dumps(params))
if metadata_tools and isinstance(metadata_tools, list):
for idx, tool in enumerate(metadata_tools):
if not isinstance(tool, dict):
continue
tool_name = tool.get("name")
if tool_name:
safe_set_attribute(
span,
f"{SpanAttributes.LLM_INVOCATION_PARAMETERS}.tools.{idx}.name",
tool_name,
)
tool_description = tool.get("description")
if tool_description:
safe_set_attribute(
span,
f"{SpanAttributes.LLM_INVOCATION_PARAMETERS}.tools.{idx}.description",
tool_description,
)
def set_attributes(
@@ -202,70 +334,42 @@ def set_attributes(
"""
Populates span with OpenInference-compliant LLM attributes for Arize and Phoenix tracing.
"""
from litellm.integrations._types.open_inference import (
OpenInferenceSpanKindValues,
SpanAttributes,
)
try:
# Remove secret_fields to prevent leaking sensitive data (e.g., authorization headers)
optional_params = kwargs.get("optional_params", {})
if isinstance(optional_params, dict):
optional_params.pop("secret_fields", None)
litellm_params = kwargs.get("litellm_params", {})
optional_params = _sanitize_optional_params(kwargs.get("optional_params"))
litellm_params = kwargs.get("litellm_params", {}) or {}
standard_logging_payload: Optional[StandardLoggingPayload] = kwargs.get(
"standard_logging_object"
)
if standard_logging_payload is None:
raise ValueError("standard_logging_object not found in kwargs")
metadata = (
standard_logging_payload.get("metadata")
if standard_logging_payload
else None
metadata = standard_logging_payload.get("metadata") if standard_logging_payload else None
_set_metadata_attributes(span, metadata, SpanAttributes)
metadata_tools = _extract_metadata_tools(metadata)
optional_tools = _extract_optional_tools(optional_params)
call_type = standard_logging_payload.get("call_type")
_set_request_attributes(
span=span,
kwargs=kwargs,
standard_logging_payload=standard_logging_payload,
optional_params=optional_params,
litellm_params=litellm_params,
response_obj=response_obj,
span_attrs=SpanAttributes,
)
if metadata is not None:
safe_set_attribute(span, SpanAttributes.METADATA, safe_dumps(metadata))
if kwargs.get("model"):
safe_set_attribute(span, SpanAttributes.LLM_MODEL_NAME, kwargs.get("model"))
span_kind = _infer_open_inference_span_kind(call_type=call_type)
_set_tool_attributes(span, optional_tools, metadata_tools)
if (optional_tools or metadata_tools) and span_kind != OpenInferenceSpanKindValues.TOOL.value:
span_kind = OpenInferenceSpanKindValues.TOOL.value
safe_set_attribute(span, "llm.request.type", standard_logging_payload["call_type"])
safe_set_attribute(span, SpanAttributes.LLM_PROVIDER, litellm_params.get("custom_llm_provider", "Unknown"))
if optional_params.get("max_tokens"):
safe_set_attribute(span, "llm.request.max_tokens", optional_params.get("max_tokens"))
if optional_params.get("temperature"):
safe_set_attribute(span, "llm.request.temperature", optional_params.get("temperature"))
if optional_params.get("top_p"):
safe_set_attribute(span, "llm.request.top_p", optional_params.get("top_p"))
safe_set_attribute(span, "llm.is_streaming", str(optional_params.get("stream", False)))
if optional_params.get("user"):
safe_set_attribute(span, "llm.user", optional_params.get("user"))
if response_obj and response_obj.get("id"):
safe_set_attribute(span, "llm.response.id", response_obj.get("id"))
if response_obj and response_obj.get("model"):
safe_set_attribute(span, "llm.response.model", response_obj.get("model"))
safe_set_attribute(span, SpanAttributes.OPENINFERENCE_SPAN_KIND, OpenInferenceSpanKindValues.LLM.value)
safe_set_attribute(span, SpanAttributes.OPENINFERENCE_SPAN_KIND, span_kind)
attributes.set_messages(span, kwargs)
_set_tool_attributes(span=span, optional_params=optional_params)
model_params = (
standard_logging_payload.get("model_parameters")
if standard_logging_payload
else None
)
if model_params:
safe_set_attribute(span, SpanAttributes.LLM_INVOCATION_PARAMETERS, safe_dumps(model_params))
if model_params.get("user"):
user_id = model_params.get("user")
if user_id is not None:
safe_set_attribute(span, SpanAttributes.USER_ID, user_id)
model_params = standard_logging_payload.get("model_parameters") if standard_logging_payload else None
_set_model_params(span, model_params, SpanAttributes)
_set_response_attributes(span=span, response_obj=response_obj)
@@ -275,3 +379,72 @@ def set_attributes(
)
if hasattr(span, "record_exception"):
span.record_exception(e)
def _sanitize_optional_params(optional_params: Optional[dict]) -> dict:
if not isinstance(optional_params, dict):
return {}
optional_params.pop("secret_fields", None)
return optional_params
def _set_metadata_attributes(span: "Span", metadata: Optional[Any], span_attrs) -> None:
if metadata is not None:
safe_set_attribute(span, span_attrs.METADATA, safe_dumps(metadata))
def _extract_metadata_tools(metadata: Optional[Any]) -> Optional[list]:
if not isinstance(metadata, dict):
return None
llm_obj = metadata.get("llm")
if isinstance(llm_obj, dict):
return llm_obj.get("tools")
return None
def _extract_optional_tools(optional_params: dict) -> Optional[list]:
return optional_params.get("tools") if isinstance(optional_params, dict) else None
def _set_request_attributes(
span: "Span",
kwargs,
standard_logging_payload: StandardLoggingPayload,
optional_params: dict,
litellm_params: dict,
response_obj,
span_attrs,
):
if kwargs.get("model"):
safe_set_attribute(span, span_attrs.LLM_MODEL_NAME, kwargs.get("model"))
safe_set_attribute(span, "llm.request.type", standard_logging_payload.get("call_type"))
safe_set_attribute(span, span_attrs.LLM_PROVIDER, litellm_params.get("custom_llm_provider", "Unknown"))
if optional_params.get("max_tokens"):
safe_set_attribute(span, "llm.request.max_tokens", optional_params.get("max_tokens"))
if optional_params.get("temperature"):
safe_set_attribute(span, "llm.request.temperature", optional_params.get("temperature"))
if optional_params.get("top_p"):
safe_set_attribute(span, "llm.request.top_p", optional_params.get("top_p"))
safe_set_attribute(span, "llm.is_streaming", str(optional_params.get("stream", False)))
if optional_params.get("user"):
safe_set_attribute(span, "llm.user", optional_params.get("user"))
if response_obj and response_obj.get("id"):
safe_set_attribute(span, "llm.response.id", response_obj.get("id"))
if response_obj and response_obj.get("model"):
safe_set_attribute(span, "llm.response.model", response_obj.get("model"))
def _set_model_params(span: "Span", model_params: Optional[dict], span_attrs) -> None:
if not model_params:
return
safe_set_attribute(span, span_attrs.LLM_INVOCATION_PARAMETERS, safe_dumps(model_params))
if model_params.get("user"):
user_id = model_params.get("user")
if user_id is not None:
safe_set_attribute(span, span_attrs.USER_ID, user_id)
@@ -2,6 +2,13 @@ import json
import os
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
from litellm.integrations.gcs_bucket.gcs_bucket_mock_client import (
should_use_gcs_mock,
create_mock_gcs_client,
mock_vertex_auth_methods,
)
from litellm._logging import verbose_logger
from litellm.integrations.custom_batch_logger import CustomBatchLogger
from litellm.llms.custom_httpx.http_handler import (
@@ -20,6 +27,12 @@ IAM_AUTH_KEY = "IAM_AUTH"
class GCSBucketBase(CustomBatchLogger):
def __init__(self, bucket_name: Optional[str] = None, **kwargs) -> None:
self.is_mock_mode = should_use_gcs_mock()
if self.is_mock_mode:
mock_vertex_auth_methods()
create_mock_gcs_client()
self.async_httpx_client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.LoggingCallback
)
@@ -0,0 +1,236 @@
"""
Mock client for GCS Bucket integration testing.
This module intercepts GCS API calls and Vertex AI auth calls, returning successful
mock responses, allowing full code execution without making actual network calls.
Usage:
Set GCS_MOCK=true in environment variables or config to enable mock mode.
"""
import httpx
import json
import asyncio
from datetime import timedelta
from typing import Dict, Optional
from litellm._logging import verbose_logger
# Store original methods for restoration
_original_async_handler_post = None
_original_async_handler_get = None
_original_async_handler_delete = None
# Track if mocks have been initialized to avoid duplicate initialization
_mocks_initialized = False
# Default mock latency in seconds (simulates network round-trip)
# Typical GCS API calls take 100-300ms for uploads, 50-150ms for GET/DELETE
_MOCK_LATENCY_SECONDS = float(__import__("os").getenv("GCS_MOCK_LATENCY_MS", "150")) / 1000.0
class MockGCSResponse:
"""Mock httpx.Response that satisfies GCS API requirements."""
def __init__(self, status_code: int = 200, json_data: Optional[Dict] = None, url: Optional[str] = None, elapsed_seconds: float = 0.0):
self.status_code = status_code
self._json_data = json_data or {"kind": "storage#object", "name": "mock-object"}
self.headers = httpx.Headers({})
self.is_success = status_code < 400
self.is_error = status_code >= 400
self.is_redirect = 300 <= status_code < 400
self.url = httpx.URL(url) if url else httpx.URL("")
# Set realistic elapsed time based on mock latency
elapsed_time = elapsed_seconds if elapsed_seconds > 0 else _MOCK_LATENCY_SECONDS
self.elapsed = timedelta(seconds=elapsed_time)
self._text = json.dumps(self._json_data)
self._content = self._text.encode("utf-8")
@property
def text(self) -> str:
"""Return response text."""
return self._text
@property
def content(self) -> bytes:
"""Return response content."""
return self._content
def json(self) -> Dict:
"""Return JSON response data."""
return self._json_data
def read(self) -> bytes:
"""Read response content."""
return self._content
def raise_for_status(self):
"""Raise exception for error status codes."""
if self.status_code >= 400:
raise Exception(f"HTTP {self.status_code}")
async def _mock_async_handler_post(self, url, data=None, json=None, params=None, headers=None, timeout=None, stream=False, logging_obj=None, files=None, content=None):
"""Monkey-patched AsyncHTTPHandler.post that intercepts GCS calls."""
# Only mock GCS API calls
if isinstance(url, str) and "storage.googleapis.com" in url:
verbose_logger.info(f"[GCS MOCK] POST to {url}")
# Simulate network latency
await asyncio.sleep(_MOCK_LATENCY_SECONDS)
return MockGCSResponse(
status_code=200,
json_data={"kind": "storage#object", "name": "mock-object"},
url=url,
elapsed_seconds=_MOCK_LATENCY_SECONDS
)
# For non-GCS calls, use original method
if _original_async_handler_post is not None:
return await _original_async_handler_post(self, url=url, data=data, json=json, params=params, headers=headers, timeout=timeout, stream=stream, logging_obj=logging_obj, files=files, content=content)
# Fallback: if original not set, raise error
raise RuntimeError("Original AsyncHTTPHandler.post not available")
async def _mock_async_handler_get(self, url, params=None, headers=None, follow_redirects=None):
"""Monkey-patched AsyncHTTPHandler.get that intercepts GCS calls."""
# Only mock GCS API calls
if isinstance(url, str) and "storage.googleapis.com" in url:
verbose_logger.info(f"[GCS MOCK] GET to {url}")
# Simulate network latency
await asyncio.sleep(_MOCK_LATENCY_SECONDS)
return MockGCSResponse(
status_code=200,
json_data={"data": "mock-log-data"},
url=url,
elapsed_seconds=_MOCK_LATENCY_SECONDS
)
# For non-GCS calls, use original method
if _original_async_handler_get is not None:
return await _original_async_handler_get(self, url=url, params=params, headers=headers, follow_redirects=follow_redirects)
# Fallback: if original not set, raise error
raise RuntimeError("Original AsyncHTTPHandler.get not available")
async def _mock_async_handler_delete(self, url, data=None, json=None, params=None, headers=None, timeout=None, stream=False, content=None):
"""Monkey-patched AsyncHTTPHandler.delete that intercepts GCS calls."""
# Only mock GCS API calls
if isinstance(url, str) and "storage.googleapis.com" in url:
verbose_logger.info(f"[GCS MOCK] DELETE to {url}")
# Simulate network latency
await asyncio.sleep(_MOCK_LATENCY_SECONDS)
return MockGCSResponse(
status_code=204,
json_data={},
url=url,
elapsed_seconds=_MOCK_LATENCY_SECONDS
)
# For non-GCS calls, use original method
if _original_async_handler_delete is not None:
return await _original_async_handler_delete(self, url=url, data=data, json=json, params=params, headers=headers, timeout=timeout, stream=stream, content=content)
# Fallback: if original not set, raise error
raise RuntimeError("Original AsyncHTTPHandler.delete not available")
def create_mock_gcs_client():
"""
Monkey-patch AsyncHTTPHandler methods to intercept GCS calls.
AsyncHTTPHandler is used by LiteLLM's get_async_httpx_client() which is what
GCSBucketBase uses for making API calls.
This function is idempotent - it only initializes mocks once, even if called multiple times.
"""
global _original_async_handler_post, _original_async_handler_get, _original_async_handler_delete
global _mocks_initialized
# If already initialized, skip
if _mocks_initialized:
return
verbose_logger.debug("[GCS MOCK] Initializing GCS mock client...")
# Patch AsyncHTTPHandler methods (used by LiteLLM's custom httpx handler)
if _original_async_handler_post is None:
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
_original_async_handler_post = AsyncHTTPHandler.post
AsyncHTTPHandler.post = _mock_async_handler_post # type: ignore
verbose_logger.debug("[GCS MOCK] Patched AsyncHTTPHandler.post")
if _original_async_handler_get is None:
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
_original_async_handler_get = AsyncHTTPHandler.get
AsyncHTTPHandler.get = _mock_async_handler_get # type: ignore
verbose_logger.debug("[GCS MOCK] Patched AsyncHTTPHandler.get")
if _original_async_handler_delete is None:
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
_original_async_handler_delete = AsyncHTTPHandler.delete
AsyncHTTPHandler.delete = _mock_async_handler_delete # type: ignore
verbose_logger.debug("[GCS MOCK] Patched AsyncHTTPHandler.delete")
verbose_logger.debug(f"[GCS MOCK] Mock latency set to {_MOCK_LATENCY_SECONDS*1000:.0f}ms")
verbose_logger.debug("[GCS MOCK] GCS mock client initialization complete")
_mocks_initialized = True
def mock_vertex_auth_methods():
"""
Monkey-patch Vertex AI auth methods to return fake tokens.
This prevents auth failures when GCS_MOCK is enabled.
This function is idempotent - it only patches once, even if called multiple times.
"""
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
# Store original methods if not already stored
if not hasattr(VertexBase, '_original_ensure_access_token_async'):
setattr(VertexBase, '_original_ensure_access_token_async', VertexBase._ensure_access_token_async)
setattr(VertexBase, '_original_ensure_access_token', VertexBase._ensure_access_token)
setattr(VertexBase, '_original_get_token_and_url', VertexBase._get_token_and_url)
async def _mock_ensure_access_token_async(self, credentials, project_id, custom_llm_provider):
"""Mock async auth method - returns fake token."""
verbose_logger.debug("[GCS MOCK] Vertex AI auth: _ensure_access_token_async called")
return ("mock-gcs-token", "mock-project-id")
def _mock_ensure_access_token(self, credentials, project_id, custom_llm_provider):
"""Mock sync auth method - returns fake token."""
verbose_logger.debug("[GCS MOCK] Vertex AI auth: _ensure_access_token called")
return ("mock-gcs-token", "mock-project-id")
def _mock_get_token_and_url(self, model, auth_header, vertex_credentials, vertex_project,
vertex_location, gemini_api_key, stream, custom_llm_provider, api_base):
"""Mock get_token_and_url - returns fake token."""
verbose_logger.debug("[GCS MOCK] Vertex AI auth: _get_token_and_url called")
return ("mock-gcs-token", "https://storage.googleapis.com")
# Patch the methods
VertexBase._ensure_access_token_async = _mock_ensure_access_token_async # type: ignore
VertexBase._ensure_access_token = _mock_ensure_access_token # type: ignore
VertexBase._get_token_and_url = _mock_get_token_and_url # type: ignore
verbose_logger.debug("[GCS MOCK] Patched Vertex AI auth methods")
def should_use_gcs_mock() -> bool:
"""
Determine if GCS should run in mock mode.
Checks the GCS_MOCK environment variable.
Returns:
bool: True if mock mode should be enabled
"""
import os
from litellm.secret_managers.main import str_to_bool
mock_mode = os.getenv("GCS_MOCK", "false")
result = str_to_bool(mock_mode)
# Ensure we return a bool, not None
result = bool(result) if result is not None else False
if result:
verbose_logger.info("GCS Mock Mode: ENABLED - API calls will be mocked")
return result
+21 -7
View File
@@ -25,6 +25,10 @@ from litellm.litellm_core_utils.core_helpers import (
reconstruct_model_name,
)
from litellm.litellm_core_utils.redact_messages import redact_user_api_key_info
from litellm.integrations.langfuse.langfuse_mock_client import (
create_mock_langfuse_client,
should_use_langfuse_mock,
)
from litellm.llms.custom_httpx.http_handler import _get_httpx_client
from litellm.secret_managers.main import str_to_bool
from litellm.types.integrations.langfuse import *
@@ -119,8 +123,14 @@ class LangFuseLogger:
self.langfuse_flush_interval = LangFuseLogger._get_langfuse_flush_interval(
flush_interval
)
http_client = _get_httpx_client()
self.langfuse_client = http_client.client
if should_use_langfuse_mock():
self.langfuse_client = create_mock_langfuse_client()
self.is_mock_mode = True
else:
http_client = _get_httpx_client()
self.langfuse_client = http_client.client
self.is_mock_mode = False
parameters = {
"public_key": self.public_key,
@@ -139,11 +149,15 @@ class LangFuseLogger:
# set the current langfuse project id in the environ
# this is used by Alerting to link to the correct project
try:
project_id = self.Langfuse.client.projects.get().data[0].id
os.environ["LANGFUSE_PROJECT_ID"] = project_id
except Exception:
project_id = None
if self.is_mock_mode:
os.environ["LANGFUSE_PROJECT_ID"] = "mock-project-id"
verbose_logger.debug("Langfuse Mock: Using mock project ID")
else:
try:
project_id = self.Langfuse.client.projects.get().data[0].id
os.environ["LANGFUSE_PROJECT_ID"] = project_id
except Exception:
project_id = None
if os.getenv("UPSTREAM_LANGFUSE_SECRET_KEY") is not None:
upstream_langfuse_debug = (
@@ -0,0 +1,121 @@
"""
Mock httpx client for Langfuse integration testing.
This module intercepts Langfuse API calls and returns successful mock responses,
allowing full code execution without making actual network calls.
Usage:
Set LANGFUSE_MOCK=true in environment variables or config to enable mock mode.
"""
import httpx
import json
from datetime import timedelta
from typing import Dict, Optional
from litellm._logging import verbose_logger
_original_httpx_post = None
# Default mock latency in seconds (simulates network round-trip)
# Typical Langfuse API calls take 50-150ms
_MOCK_LATENCY_SECONDS = float(__import__("os").getenv("LANGFUSE_MOCK_LATENCY_MS", "100")) / 1000.0
class MockLangfuseResponse:
"""Mock httpx.Response that satisfies Langfuse SDK requirements."""
def __init__(self, status_code: int = 200, json_data: Optional[Dict] = None, url: Optional[str] = None, elapsed_seconds: float = 0.0):
self.status_code = status_code
self._json_data = json_data or {"status": "success"}
self.headers = httpx.Headers({})
self.is_success = status_code < 400
self.is_error = status_code >= 400
self.is_redirect = 300 <= status_code < 400
self.url = httpx.URL(url) if url else httpx.URL("")
# Set realistic elapsed time based on mock latency
elapsed_time = elapsed_seconds if elapsed_seconds > 0 else _MOCK_LATENCY_SECONDS
self.elapsed = timedelta(seconds=elapsed_time)
self._text = json.dumps(self._json_data)
self._content = self._text.encode("utf-8")
@property
def text(self) -> str:
return self._text
@property
def content(self) -> bytes:
return self._content
def json(self) -> Dict:
return self._json_data
def read(self) -> bytes:
return self._content
def raise_for_status(self):
if self.status_code >= 400:
raise Exception(f"HTTP {self.status_code}")
def _is_langfuse_url(url) -> bool:
"""Check if URL is a Langfuse domain."""
try:
parsed_url = httpx.URL(url) if isinstance(url, str) else url
hostname = parsed_url.host or ""
return (
hostname.endswith(".langfuse.com") or
hostname == "langfuse.com" or
(hostname in ("localhost", "127.0.0.1") and "langfuse" in str(parsed_url).lower())
)
except Exception:
return False
def _mock_httpx_post(self, url, **kwargs):
"""Monkey-patched httpx.Client.post that intercepts Langfuse calls."""
if _is_langfuse_url(url):
verbose_logger.info(f"[LANGFUSE MOCK] POST to {url}")
return MockLangfuseResponse(status_code=200, json_data={"status": "success"}, url=url, elapsed_seconds=_MOCK_LATENCY_SECONDS)
if _original_httpx_post is not None:
return _original_httpx_post(self, url, **kwargs)
def create_mock_langfuse_client():
"""
Monkey-patch httpx.Client.post to intercept Langfuse calls.
Returns a real httpx.Client instance - the monkey-patch intercepts all calls.
"""
global _original_httpx_post
if _original_httpx_post is None:
_original_httpx_post = httpx.Client.post
httpx.Client.post = _mock_httpx_post # type: ignore
verbose_logger.debug("[LANGFUSE MOCK] Patched httpx.Client.post")
return httpx.Client()
def should_use_langfuse_mock() -> bool:
"""
Determine if Langfuse should run in mock mode.
Checks the LANGFUSE_MOCK environment variable.
Returns:
bool: True if mock mode should be enabled
"""
import os
from litellm.secret_managers.main import str_to_bool
mock_mode = os.getenv("LANGFUSE_MOCK", "false")
result = str_to_bool(mock_mode)
result = bool(result) if result is not None else False
if result:
verbose_logger.info("Langfuse Mock Mode: ENABLED - API calls will be mocked")
return result
+13
View File
@@ -17,6 +17,10 @@ from litellm.types.utils import (
StandardCallbackDynamicParams,
StandardLoggingPayload,
)
from litellm.integrations._types.open_inference import (
OpenInferenceSpanKindValues,
SpanAttributes,
)
# OpenTelemetry imports moved to individual functions to avoid import errors when not installed
@@ -660,6 +664,9 @@ class OpenTelemetry(CustomLogger):
self._maybe_log_raw_request(
kwargs, response_obj, start_time, end_time, span
)
# Ensure proxy-request parent span is annotated with the actual operation kind
if parent_span is not None and parent_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME:
self.set_attributes(parent_span, kwargs, response_obj)
else:
# Do not create primary span (keep hierarchy shallow when parent exists)
from opentelemetry.trace import Status, StatusCode
@@ -1106,6 +1113,12 @@ class OpenTelemetry(CustomLogger):
context=context,
)
self.safe_set_attribute(
span=guardrail_span,
key=SpanAttributes.OPENINFERENCE_SPAN_KIND,
value=OpenInferenceSpanKindValues.GUARDRAIL.value,
)
self.safe_set_attribute(
span=guardrail_span,
key="guardrail_name",
+23 -11
View File
@@ -205,6 +205,11 @@ _in_memory_loggers: List[Any] = []
### GLOBAL VARIABLES ###
# Cache custom pricing keys as frozenset for O(1) lookups instead of looping through 49 keys
_CUSTOM_PRICING_KEYS: frozenset = frozenset(
CustomPricingLiteLLMParams.model_fields.keys()
)
sentry_sdk_instance = None
capture_exception = None
add_breadcrumb = None
@@ -539,10 +544,8 @@ class Logging(LiteLLMLoggingBaseClass):
if "stream_options" in additional_params:
self.stream_options = additional_params["stream_options"]
## check if custom pricing set ##
custom_pricing_keys = CustomPricingLiteLLMParams.model_fields.keys()
for key in custom_pricing_keys:
if litellm_params.get(key) is not None:
self.custom_pricing = True
if any(litellm_params.get(key) is not None for key in _CUSTOM_PRICING_KEYS & litellm_params.keys()):
self.custom_pricing = True
if "custom_llm_provider" in self.model_call_details:
self.custom_llm_provider = self.model_call_details["custom_llm_provider"]
@@ -4248,15 +4251,21 @@ def use_custom_pricing_for_model(litellm_params: Optional[dict]) -> bool:
if litellm_params is None:
return False
# Check litellm_params using set intersection (only check keys that exist in both)
matching_keys = _CUSTOM_PRICING_KEYS & litellm_params.keys()
for key in matching_keys:
if litellm_params.get(key) is not None:
return True
# Check model_info
metadata: dict = litellm_params.get("metadata", {}) or {}
model_info: dict = metadata.get("model_info", {}) or {}
custom_pricing_keys = CustomPricingLiteLLMParams.model_fields.keys()
for key in custom_pricing_keys:
if litellm_params.get(key, None) is not None:
return True
elif model_info.get(key, None) is not None:
return True
if model_info:
matching_keys = _CUSTOM_PRICING_KEYS & model_info.keys()
for key in matching_keys:
if model_info.get(key) is not None:
return True
return False
@@ -4652,7 +4661,10 @@ class StandardLoggingPayloadSetup:
@staticmethod
def strip_trailing_slash(api_base: Optional[str]) -> Optional[str]:
if api_base:
return api_base.rstrip("/")
if api_base.endswith("//"):
return api_base.rstrip("/")
if api_base[-1] == "/":
return api_base[:-1]
return api_base
@staticmethod
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[1979],{15143:function(e,n,r){Promise.resolve().then(r.bind(r,37492))},25512:function(e,n,r){"use strict";r.d(n,{P:function(){return l.Z},Q:function(){return t.Z}});var l=r(27281),t=r(43227)},37492:function(e,n,r){"use strict";r.r(n);var l=r(57437),t=r(66600),u=r(80443);n.default=()=>{let{token:e,accessToken:n,userRole:r,userId:i,premiumUser:o}=(0,u.Z)();return(0,l.jsx)(t.Z,{accessToken:n,token:e,userRole:r,userID:i,premiumUser:o})}},90246:function(e,n,r){"use strict";function l(e){let n=[e];return{all:n,lists:()=>[...n,"list"],list:e=>[...n,"list",{params:e}],details:()=>[...n,"detail"],detail:e=>[...n,"detail",e]}}r.d(n,{n:function(){return l}})},76191:function(e,n,r){"use strict";r.d(n,{p:function(){return i}});var l=r(19250),t=r(11713);let u=(0,r(90246).n)("uiConfig"),i=()=>(0,t.a)({queryKey:u.list({}),queryFn:async()=>await (0,l.getUiConfig)(),staleTime:864e5,gcTime:864e5})},80443:function(e,n,r){"use strict";var l=r(19250),t=r(3914),u=r(14474),i=r(99376),o=r(2265),a=r(76191);n.Z=()=>{var e,n,r,s,d,c;let m=(0,i.useRouter)(),{data:_,isLoading:p}=(0,a.p)(),f="undefined"!=typeof document?(0,t.e)("token"):null;(0,o.useEffect)(()=>{!p&&(!f||(null==_?void 0:_.admin_ui_disabled))&&m.replace("".concat((0,l.getProxyBaseUrl)(),"/ui/login"))},[f,m,p,_]);let v=(0,o.useMemo)(()=>{if(!f)return null;try{return(0,u.o)(f)}catch(e){return(0,t.b)(),m.replace("".concat((0,l.getProxyBaseUrl)(),"/ui/login")),null}},[f,m]);return{token:f,accessToken:null!==(e=null==v?void 0:v.key)&&void 0!==e?e:null,userId:null!==(n=null==v?void 0:v.user_id)&&void 0!==n?n:null,userEmail:null!==(r=null==v?void 0:v.user_email)&&void 0!==r?r:null,userRole:function(e){if(!e)return"Undefined Role";switch(e.toLowerCase()){case"app_owner":case"demo_app_owner":return"App Owner";case"app_admin":case"proxy_admin":return"Admin";case"proxy_admin_viewer":return"Admin Viewer";case"org_admin":return"Org Admin";case"internal_user":return"Internal User";case"internal_user_viewer":case"internal_viewer":return"Internal Viewer";case"app_user":return"App User";default:return"Unknown Role"}}(null!==(s=null==v?void 0:v.user_role)&&void 0!==s?s:null),premiumUser:null!==(d=null==v?void 0:v.premium_user)&&void 0!==d?d:null,disabledPersonalKeyCreation:null!==(c=null==v?void 0:v.disabled_non_admin_personal_key_creation)&&void 0!==c?c:null,showSSOBanner:(null==v?void 0:v.login_method)==="username_password"}}},10703:function(e,n,r){"use strict";r.d(n,{p:function(){return t}});var l=r(19250);let t=async e=>{try{let n=await (0,l.modelHubCall)(e);if(console.log("model_info:",n),(null==n?void 0:n.data.length)>0){let e=n.data.map(e=>({model_group:e.model_group,mode:null==e?void 0:e.mode}));return e.sort((e,n)=>e.model_group.localeCompare(n.model_group)),e}return[]}catch(e){throw console.error("Error fetching model info:",e),e}}},24199:function(e,n,r){"use strict";r.d(n,{Z:function(){return u}});var l=r(57437);r(2265);var t=r(30150),u=e=>{let{step:n=.01,style:r={width:"100%"},placeholder:u="Enter a numerical value",min:i,max:o,onChange:a,...s}=e;return(0,l.jsx)(t.Z,{onWheel:e=>e.currentTarget.blur(),step:n,style:r,placeholder:u,min:i,max:o,onChange:a,...s})}}},function(e){e.O(0,[1047,9028,9409,1713,4865,1442,2926,5333,1108,5733,4817,8049,6600,2971,2117,1744],function(){return e(e.s=15143)}),_N_E=e.O()}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

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