diff --git a/.circleci/config.yml b/.circleci/config.yml index 6ffc09cf6e6..5449016013c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1084,6 +1084,49 @@ jobs: paths: - ocr_coverage.xml - ocr_coverage + search_testing: + docker: + - image: cimg/python:3.11 + auth: + username: ${DOCKERHUB_USERNAME} + password: ${DOCKERHUB_PASSWORD} + working_directory: ~/project + + steps: + - checkout + - setup_google_dns + - run: + name: Install Dependencies + command: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + pip install "pytest==7.3.1" + pip install "pytest-retry==1.6.3" + pip install "pytest-cov==5.0.0" + pip install "pytest-asyncio==0.21.1" + pip install "respx==0.22.0" + # Run pytest and generate JUnit XML report + - run: + name: Run tests + command: | + pwd + ls + python -m pytest -vv tests/search_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 + no_output_timeout: 120m + - run: + name: Rename the coverage files + command: | + mv coverage.xml search_coverage.xml + mv .coverage search_coverage + + # Store test results + - store_test_results: + path: test-results + - persist_to_workspace: + root: . + paths: + - search_coverage.xml + - search_coverage litellm_mapped_tests: docker: - image: cimg/python:3.11 @@ -2827,7 +2870,7 @@ jobs: python -m venv venv . venv/bin/activate pip install coverage - coverage combine llm_translation_coverage llm_responses_api_coverage ocr_coverage mcp_coverage logging_coverage audio_coverage litellm_router_coverage local_testing_coverage litellm_assistants_api_coverage auth_ui_unit_tests_coverage langfuse_coverage caching_coverage litellm_proxy_unit_tests_coverage image_gen_coverage pass_through_unit_tests_coverage batches_coverage litellm_security_tests_coverage guardrails_coverage + coverage combine llm_translation_coverage llm_responses_api_coverage ocr_coverage search_coverage mcp_coverage logging_coverage audio_coverage litellm_router_coverage local_testing_coverage litellm_assistants_api_coverage auth_ui_unit_tests_coverage langfuse_coverage caching_coverage litellm_proxy_unit_tests_coverage image_gen_coverage pass_through_unit_tests_coverage batches_coverage litellm_security_tests_coverage guardrails_coverage coverage xml - codecov/upload: file: ./coverage.xml @@ -3381,6 +3424,12 @@ workflows: only: - main - /litellm_.*/ + - search_testing: + filters: + branches: + only: + - main + - /litellm_.*/ - litellm_mapped_enterprise_tests: filters: branches: @@ -3437,6 +3486,7 @@ workflows: - guardrails_testing - llm_responses_api_testing - ocr_testing + - search_testing - litellm_mapped_tests - litellm_mapped_enterprise_tests - batches_testing @@ -3501,6 +3551,7 @@ workflows: - google_generate_content_endpoint_testing - llm_responses_api_testing - ocr_testing + - search_testing - litellm_mapped_tests - litellm_mapped_enterprise_tests - batches_testing diff --git a/.gitignore b/.gitignore index e1045032d46..aa973201fd1 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,4 @@ litellm/proxy/to_delete_loadtest_work/* update_model_cost_map.py tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py litellm/proxy/_experimental/out/guardrails/index.html +scripts/test_vertex_ai_search.py diff --git a/docs/my-website/docs/extras/creating_adapters.md b/docs/my-website/docs/extras/creating_adapters.md new file mode 100644 index 00000000000..42e48f6ab3f --- /dev/null +++ b/docs/my-website/docs/extras/creating_adapters.md @@ -0,0 +1,206 @@ +# Call any LiteLLM model in your custom format + +Use this to call any LiteLLM supported `.completion()` model, in your custom format. Useful if you have a custom API and want to support any LiteLLM supported model. + +## How it works + +Your request → Adapter translates to OpenAI format → LiteLLM processes it → Adapter translates response back → Your response + +## Create an Adapter + +Inherit from `CustomLogger` and implement 3 methods: + +```python +from litellm.integrations.custom_logger import CustomLogger +from litellm.types.llms.openai import ChatCompletionRequest +from litellm.types.utils import ModelResponse + +class MyAdapter(CustomLogger): + def translate_completion_input_params(self, kwargs) -> ChatCompletionRequest: + """Convert your format → OpenAI format""" + # Example: Anthropic to OpenAI + return { + "model": kwargs["model"], + "messages": self._convert_messages(kwargs["messages"]), + "max_tokens": kwargs.get("max_tokens"), + } + + def translate_completion_output_params(self, response: ModelResponse): + """Convert OpenAI format → your format""" + # Return your provider's response format + return MyProviderResponse( + id=response.id, + content=response.choices[0].message.content, + usage=response.usage, + ) + + def translate_completion_output_params_streaming(self, completion_stream): + """Handle streaming responses""" + return MyStreamWrapper(completion_stream) +``` + +## Register it + +```python +import litellm + +my_adapter = MyAdapter() +litellm.adapters = [{"id": "my_provider", "adapter": my_adapter}] +``` + +## Use it + +```python +from litellm import adapter_completion + +# Now you can use your provider's format with any LiteLLM model +response = adapter_completion( + adapter_id="my_provider", + model="gpt-4", # or any LiteLLM model + messages=[{"role": "user", "content": "hello"}], + max_tokens=100 +) +``` + +### Streaming + +```python +stream = adapter_completion( + adapter_id="my_provider", + model="gpt-4", + messages=[{"role": "user", "content": "hello"}], + stream=True +) + +for chunk in stream: + print(chunk) +``` + +### Async + +```python +from litellm import aadapter_completion + +response = await aadapter_completion( + adapter_id="my_provider", + model="gpt-4", + messages=[{"role": "user", "content": "hello"}] +) +``` + +## Example: Anthropic Adapter + +Here's how we translate Anthropic's format: + +### Input Translation + +```python +def translate_completion_input_params(self, kwargs): + model = kwargs.pop("model") + messages = kwargs.pop("messages") + + # Convert Anthropic messages to OpenAI format + openai_messages = [] + for msg in messages: + if msg["role"] == "user": + openai_messages.append({ + "role": "user", + "content": msg["content"] + }) + + # Handle system message + if "system" in kwargs: + openai_messages.insert(0, { + "role": "system", + "content": kwargs.pop("system") + }) + + return { + "model": model, + "messages": openai_messages, + **kwargs # pass through other params + } +``` + +### Output Translation + +```python +def translate_completion_output_params(self, response): + return AnthropicResponse( + id=response.id, + type="message", + role="assistant", + content=[{ + "type": "text", + "text": response.choices[0].message.content + }], + usage={ + "input_tokens": response.usage.prompt_tokens, + "output_tokens": response.usage.completion_tokens + } + ) +``` + +### Streaming + +```python +from litellm.types.utils import AdapterCompletionStreamWrapper + +class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): + def __init__(self, completion_stream, model): + super().__init__(completion_stream) + self.model = model + self.first_chunk = True + + async def __anext__(self): + # First chunk + if self.first_chunk: + self.first_chunk = False + return {"type": "message_start", "message": {...}} + + # Stream chunks + async for chunk in self.completion_stream: + return { + "type": "content_block_delta", + "delta": {"text": chunk.choices[0].delta.content} + } + + # Last chunk + return {"type": "message_stop"} + +def translate_completion_output_params_streaming(self, stream, model): + return AnthropicStreamWrapper(stream, model) +``` + +## Use with Proxy + +Add to your proxy config: + +```yaml +general_settings: + pass_through_endpoints: + - path: "/v1/messages" + target: "my_module.MyAdapter" +``` + +Then call it: + +```bash +curl http://localhost:4000/v1/messages \ + -H "Authorization: Bearer sk-1234" \ + -d '{"model": "gpt-4", "messages": [...]}' +``` + +## Real Example + +Check out the full Anthropic adapter: +- [transformation.py](https://github.com/BerriAI/litellm/blob/main/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py) +- [handler.py](https://github.com/BerriAI/litellm/blob/main/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py) +- [streaming_iterator.py](https://github.com/BerriAI/litellm/blob/main/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py) + +## That's it + +1. Create a class that inherits `CustomLogger` +2. Implement the 3 translation methods +3. Register with `litellm.adapters = [{"id": "...", "adapter": ...}]` +4. Call with `adapter_completion(adapter_id="...")` diff --git a/docs/my-website/docs/generateContent.md b/docs/my-website/docs/generateContent.md index e6823ebf05d..4453e5ce06d 100644 --- a/docs/my-website/docs/generateContent.md +++ b/docs/my-website/docs/generateContent.md @@ -1,7 +1,7 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# Google AI generateContent +# /generateContent Use LiteLLM to call Google AI's generateContent endpoints for text generation, multimodal interactions, and streaming responses. diff --git a/docs/my-website/docs/observability/braintrust.md b/docs/my-website/docs/observability/braintrust.md index e6b4fe769bc..645ce074ca5 100644 --- a/docs/my-website/docs/observability/braintrust.md +++ b/docs/my-website/docs/observability/braintrust.md @@ -75,6 +75,12 @@ It is recommended that you include the `project_id` or `project_name` to ensure You can customize the span name in Braintrust logging by passing `span_name` in the metadata. By default, the span name is set to "Chat Completion". +### Custom Span Attributes + +You can customize the span id, root span name and span parents in Braintrust logging by passing `span_id`, `root_span_id` and `span_parents` in the metadata. +`span_parents` should be a string containing a list of span ids, joined by , + + diff --git a/docs/my-website/docs/observability/sentry.md b/docs/my-website/docs/observability/sentry.md index b7992e35c54..46b19331b24 100644 --- a/docs/my-website/docs/observability/sentry.md +++ b/docs/my-website/docs/observability/sentry.md @@ -61,6 +61,12 @@ print(response) These options are useful for high-volume applications where sampling a subset of errors and transactions provides sufficient visibility while managing costs. +#### Sentry Environment +- **SENTRY_ENVIRONMENT**: Specifies the environment name for your Sentry events (e.g., "production", "staging", "development") + - Helps organize and filter errors by deployment environment in Sentry dashboard + - Example: `os.environ["SENTRY_ENVIRONMENT"] = "staging"` + - If not set, Sentry will use 'production' as the default environment + ## Redacting Messages, Response Content from Sentry Logging Set `litellm.turn_off_message_logging=True` This will prevent the messages and responses from being logged to sentry, but request metadata will still be logged. diff --git a/docs/my-website/docs/ocr.md b/docs/my-website/docs/ocr.md index eb2d80c97a0..2cb87edc461 100644 --- a/docs/my-website/docs/ocr.md +++ b/docs/my-website/docs/ocr.md @@ -5,6 +5,7 @@ | Cost Tracking | ✅ | | Logging | ✅ (Basic Logging not supported) | | Load Balancing | ✅ | +| Supported Providers | `mistral`, `azure_ai` | :::tip @@ -260,4 +261,5 @@ The response follows Mistral's OCR format with the following structure: | Provider | Link to Usage | |-------------|--------------------| | Mistral AI | [Usage](#quick-start) | +| Azure AI | [Usage](../docs/providers/azure_ocr) | diff --git a/docs/my-website/docs/providers/azure/azure_speech.md b/docs/my-website/docs/providers/azure/azure_speech.md index a888eb40d64..3bcc3ab931f 100644 --- a/docs/my-website/docs/providers/azure/azure_speech.md +++ b/docs/my-website/docs/providers/azure/azure_speech.md @@ -1,10 +1,17 @@ # Azure Text to Speech (tts) -Convert text to natural-sounding speech using Azure OpenAI's Text to Speech models. Supports multiple voices and audio formats. +## Overview + +| Property | Details | +|-------|-------| +| Description | Convert text to natural-sounding speech using Azure OpenAI's Text to Speech models | +| Provider Route on LiteLLM | `azure/` | +| Supported Operations | `/audio/speech` | +| Link to Provider Doc | [Azure OpenAI TTS ↗](https://learn.microsoft.com/en-us/azure/ai-services/openai/text-to-speech-quickstart) ## Quick Start -**LiteLLM SDK** +### **LiteLLM SDK** ```python showLineNumbers title="SDK Usage" from litellm import speech @@ -26,7 +33,7 @@ response = speech( response.stream_to_file(speech_file_path) ``` -**LiteLLM PROXY** +### **LiteLLM PROXY** ```yaml showLineNumbers title="proxy_config.yaml" model_list: diff --git a/docs/my-website/docs/providers/azure_ai_speech.md b/docs/my-website/docs/providers/azure_ai_speech.md index d358af4c6c8..434a796a2fb 100644 --- a/docs/my-website/docs/providers/azure_ai_speech.md +++ b/docs/my-website/docs/providers/azure_ai_speech.md @@ -136,6 +136,168 @@ response = speech( | `wav` | riff-24khz-16bit-mono-pcm | 24kHz | | `pcm` | raw-24khz-16bit-mono-pcm | 24kHz | +## Sending Azure-Specific Params + +Azure AI Speech supports advanced SSML features through optional parameters: + +- `style`: Speaking style (e.g., "cheerful", "sad", "angry", "whispering") +- `styledegree`: Style intensity (0.01 to 2) +- `role`: Voice role (e.g., "Girl", "Boy", "SeniorFemale", "SeniorMale") +- `lang`: Language code for multilingual voices (e.g., "es-ES", "fr-FR", "hi-IN") + +### **LiteLLM SDK** + +#### Custom Azure Voice + +```python showLineNumbers title="Custom Azure Voice" +from litellm import speech + +response = speech( + model="azure/speech/azure-tts", + voice="en-US-AndrewNeural", # Use Azure voice directly + input="Hello, this is a test", + api_base="https://eastus.tts.speech.microsoft.com", + api_key=os.environ["AZURE_TTS_API_KEY"], + response_format="mp3" +) +response.stream_to_file("speech.mp3") +``` + +#### Speaking Style + +```python showLineNumbers title="Speaking Style" +from litellm import speech + +response = speech( + model="azure/speech/azure-tts", + voice="en-US-JennyNeural", # Must be a voice that supports styles + input="Who are you? What is chicken dinner?", + api_base="https://eastus.tts.speech.microsoft.com", + api_key=os.environ["AZURE_TTS_API_KEY"], + style="whispering", # Azure-specific: cheerful, sad, angry, whispering, etc. +) +response.stream_to_file("speech.mp3") +``` + +#### Style with Degree and Role + +```python showLineNumbers title="Style with Degree and Role" +from litellm import speech + +response = speech( + model="azure/speech/azure-tts", + voice="en-US-AriaNeural", + input="Good morning! How are you today?", + api_base="https://eastus.tts.speech.microsoft.com", + api_key=os.environ["AZURE_TTS_API_KEY"], + style="cheerful", # Azure-specific: Speaking style + styledegree="2", # Azure-specific: 0.01 to 2 (intensity) + role="SeniorFemale", # Azure-specific: Girl, Boy, SeniorFemale, etc. +) +response.stream_to_file("speech.mp3") +``` + +#### Language Override for Multilingual Voices + +```python showLineNumbers title="Language Override" +from litellm import speech + +response = speech( + model="azure/speech/azure-tts", + voice="en-US-AvaMultilingualNeural", # Multilingual voice + input="आप कौन हैं? चिकन डिनर क्या है?", # Hindi text + api_base="https://eastus.tts.speech.microsoft.com", + api_key=os.environ["AZURE_TTS_API_KEY"], + lang="hi-IN", # Azure-specific: Override language +) +response.stream_to_file("speech.mp3") +``` + +### **LiteLLM AI Gateway (CURL)** + +First, ensure you have set up your proxy config as shown in the [LiteLLM Proxy setup](#quick-start) above. + +**Using the model name from your config:** + +```yaml +model_list: + - model_name: azure-speech # This is what you'll use in your API calls + litellm_params: + model: azure/speech/azure-tts + api_base: https://eastus.tts.speech.microsoft.com + api_key: os.environ/AZURE_TTS_API_KEY +``` + +#### Custom Azure Voice + +```bash +curl http://0.0.0.0:4000/v1/audio/speech \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "azure-speech", + "voice": "en-US-AndrewNeural", + "input": "Hello, this is a test" + }' \ + --output speech.mp3 +``` + +#### Speaking Style + +```bash +curl http://0.0.0.0:4000/v1/audio/speech \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "azure-speech", + "input": "Who are you? What is chicken dinner?", + "voice": "en-US-JennyNeural", + "style": "whispering" + }' \ + --output speech.mp3 +``` + +#### Style with Degree and Role + +```bash +curl http://0.0.0.0:4000/v1/audio/speech \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "azure-speech", + "voice": "en-US-AriaNeural", + "input": "Good morning! How are you today?", + "style": "cheerful", + "styledegree": "2", + "role": "SeniorFemale" + }' \ + --output speech.mp3 +``` + +#### Language Override + +```bash +curl http://0.0.0.0:4000/v1/audio/speech \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "azure-speech", + "input": "आप कौन हैं? चिकन डिनर क्या है?", + "voice": "en-US-AvaMultilingualNeural", + "lang": "hi-IN" + }' \ + --output speech.mp3 +``` + +### Azure-Specific Parameters Reference + +| Parameter | Description | Example Values | Notes | +|-----------|-------------|----------------|-------| +| `style` | Speaking style | `cheerful`, `sad`, `angry`, `excited`, `friendly`, `hopeful`, `shouting`, `terrified`, `unfriendly`, `whispering` | Only supported by certain voices. See [Azure voice styles documentation](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-synthesis-markup-voice#use-speaking-styles-and-roles) | +| `styledegree` | Style intensity | `0.01` to `2` | Higher values = more intense. Default is `1` | +| `role` | Voice role | `Girl`, `Boy`, `YoungAdultFemale`, `YoungAdultMale`, `OlderAdultFemale`, `OlderAdultMale`, `SeniorFemale`, `SeniorMale` | Only supported by certain voices | +| `lang` | Language code | `es-ES`, `fr-FR`, `de-DE`, `hi-IN`, etc. | For multilingual voices. Overrides the default language | + ## Async Support ```python showLineNumbers title="Async Usage" diff --git a/docs/my-website/docs/providers/azure_ocr.md b/docs/my-website/docs/providers/azure_ocr.md new file mode 100644 index 00000000000..c93e995c43e --- /dev/null +++ b/docs/my-website/docs/providers/azure_ocr.md @@ -0,0 +1,154 @@ +# Azure AI OCR + +## Overview + +| Property | Details | +|-------|-------| +| Description | Azure AI OCR provides document intelligence capabilities powered by Mistral, enabling text extraction from PDFs and images | +| Provider Route on LiteLLM | `azure_ai/` | +| Supported Operations | `/ocr` | +| Link to Provider Doc | [Azure AI ↗](https://ai.azure.com/) + +Extract text from documents and images using Azure AI's OCR models, powered by Mistral. + +## Quick Start + +### **LiteLLM SDK** + +```python showLineNumbers title="SDK Usage" +import litellm +import os + +# Set environment variables +os.environ["AZURE_AI_API_KEY"] = "" +os.environ["AZURE_AI_API_BASE"] = "" + +# OCR with PDF URL +response = litellm.ocr( + model="azure_ai/mistral-document-ai-2505", + document={ + "type": "document_url", + "document_url": "https://example.com/document.pdf" + } +) + +# Access extracted text +for page in response.pages: + print(page.text) +``` + +### **LiteLLM PROXY** + +```yaml showLineNumbers title="proxy_config.yaml" +model_list: + - model_name: azure-ocr + litellm_params: + model: azure_ai/mistral-document-ai-2505 + api_key: "os.environ/AZURE_AI_API_KEY" + api_base: "os.environ/AZURE_AI_API_BASE" + model_info: + mode: ocr +``` + +## Document Types + +Azure AI OCR supports both PDFs and images. + +### PDF Documents + +```python showLineNumbers title="PDF OCR" +response = litellm.ocr( + model="azure_ai/mistral-document-ai-2505", + document={ + "type": "document_url", + "document_url": "https://example.com/document.pdf" + } +) +``` + +### Image Documents + +```python showLineNumbers title="Image OCR" +response = litellm.ocr( + model="azure_ai/mistral-document-ai-2505", + document={ + "type": "image_url", + "image_url": "https://example.com/image.png" + } +) +``` + +### Base64 Encoded Documents + +```python showLineNumbers title="Base64 PDF" +import base64 + +# Read and encode PDF +with open("document.pdf", "rb") as f: + pdf_base64 = base64.b64encode(f.read()).decode() + +response = litellm.ocr( + model="azure_ai/mistral-document-ai-2505", + document={ + "type": "document_url", + "document_url": f"data:application/pdf;base64,{pdf_base64}" + } +) +``` + +## Supported Parameters + +```python showLineNumbers title="All Parameters" +response = litellm.ocr( + model="azure_ai/mistral-document-ai-2505", + document={ # Required: Document to process + "type": "document_url", + "document_url": "https://..." + }, + include_image_base64=True, # Optional: Include base64 images + pages=[0, 1, 2], # Optional: Specific pages to process + image_limit=10 # Optional: Limit number of images +) +``` + +## Response Format + +```python showLineNumbers title="Response Structure" +# Response has the following structure +response.pages # List of pages with extracted text +response.model # Model used +response.object # "ocr" +response.usage_info # Token usage information + +# Access page content +for page in response.pages: + print(f"Page {page.page_number}:") + print(page.text) +``` + +## Async Support + +```python showLineNumbers title="Async Usage" +import litellm + +response = await litellm.aocr( + model="azure_ai/mistral-document-ai-2505", + document={ + "type": "document_url", + "document_url": "https://example.com/document.pdf" + } +) +``` + +## Important Notes + +:::info URL Conversion +Azure AI OCR endpoints don't have internet access. LiteLLM automatically converts public URLs to base64 data URIs before sending requests to Azure AI. +::: + +## Supported Models + +- `mistral-document-ai-2505` - Latest Mistral OCR model on Azure AI + +Use the Azure AI provider prefix: `azure_ai/` + diff --git a/docs/my-website/docs/providers/openai.md b/docs/my-website/docs/providers/openai.md index 3fad78dc80e..e8fd076b51f 100644 --- a/docs/my-website/docs/providers/openai.md +++ b/docs/my-website/docs/providers/openai.md @@ -4,6 +4,10 @@ import TabItem from '@theme/TabItem'; # OpenAI LiteLLM supports OpenAI Chat + Embedding calls. +:::tip +**We recommend using `litellm.responses()` / Responses API** for the latest OpenAI models (GPT-5, gpt-5-codex, o3-mini, etc.) +::: + ### Required API Keys ```python diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index e657d0b6cdc..d27875da58f 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -340,6 +340,7 @@ router_settings: | router_general_settings | RouterGeneralSettings | [SDK-Only] Router general settings - contains optimizations like 'async_only_mode'. [Docs](../routing.md#router-general-settings) | | optional_pre_call_checks | List[str] | List of pre-call checks to add to the router. Currently supported: 'router_budget_limiting', 'prompt_caching' | | ignore_invalid_deployments | boolean | If true, ignores invalid deployments. Default for proxy is True - to prevent invalid models from blocking other models from being loaded. | +| search_tools | List[SearchToolTypedDict] | List of search tool configurations for Search API integration. Each tool specifies a search_tool_name and litellm_params with search_provider, api_key, api_base, etc. [Further Docs](../search.md) | ### environment variables - Reference @@ -456,6 +457,7 @@ router_settings: | DEFAULT_CLIENT_DISCONNECT_CHECK_TIMEOUT_SECONDS | Timeout in seconds for checking client disconnection. Default is 1 | DEFAULT_COOLDOWN_TIME_SECONDS | Duration in seconds to cooldown a model after failures. Default is 5 | DEFAULT_CRON_JOB_LOCK_TTL_SECONDS | Time-to-live for cron job locks in seconds. Default is 60 (1 minute) +| DEFAULT_DATAFORSEO_LOCATION_CODE | Default location code for DataForSEO search API. Default is 2250 (France) | DEFAULT_FAILURE_THRESHOLD_PERCENT | Threshold percentage of failures to cool down a deployment. Default is 0.5 (50%) | DEFAULT_FLUSH_INTERVAL_SECONDS | Default interval in seconds for flushing operations. Default is 5 | DEFAULT_HEALTH_CHECK_INTERVAL | Default interval in seconds for health checks. Default is 300 (5 minutes) @@ -558,6 +560,8 @@ router_settings: | GITHUB_COPILOT_ACCESS_TOKEN_FILE | File to store GitHub Copilot access token for `github_copilot` llm provider | GREENSCALE_API_KEY | API key for Greenscale service | GREENSCALE_ENDPOINT | Endpoint URL for Greenscale service +| GRAYSWAN_API_BASE | Base URL for GraySwan API. Default is https://api.grayswan.ai +| GRAYSWAN_API_KEY | API key for GraySwan Cygnal service | GOOGLE_APPLICATION_CREDENTIALS | Path to Google Cloud credentials JSON file | GOOGLE_CLIENT_ID | Client ID for Google OAuth | GOOGLE_CLIENT_SECRET | Client secret for Google OAuth diff --git a/docs/my-website/docs/proxy/dynamic_rate_limit.md b/docs/my-website/docs/proxy/dynamic_rate_limit.md index 06d49dfaf0f..9c875a51eba 100644 --- a/docs/my-website/docs/proxy/dynamic_rate_limit.md +++ b/docs/my-website/docs/proxy/dynamic_rate_limit.md @@ -136,9 +136,16 @@ model_list: litellm_settings: callbacks: ["dynamic_rate_limiter_v3"] - priority_reservation: - "prod": 0.9 # 90% reserved for production (9 RPM) - "dev": 0.1 # 10% reserved for development (1 RPM) + priority_reservation: + "prod": 0.9 # 90% reserved for production (9 RPM) + "dev": 0.1 # 10% reserved for development (1 RPM) + # Alternative format: + # "prod": + # type: "rpm" # Reserve based on requests per minute + # value: 9 # 9 RPM = 90% of 10 RPM capacity + # "dev": + # type: "tpm" # Reserve based on tokens per minute + # value: 100 # 100 TPM priority_reservation_settings: default_priority: 0 # Weight (0%) assigned to keys without explicit priority metadata saturation_threshold: 0.50 # A model is saturated if it has hit 50% of its RPM limit @@ -150,10 +157,12 @@ general_settings: **Configuration Details:** -`priority_reservation`: Dict[str, float] +`priority_reservation`: Dict[str, Union[float, PriorityReservationDict]] - **Key (str)**: Priority level name (can be any string like "prod", "dev", "critical", etc.) -- **Value (float)**: Percentage of total TPM/RPM to reserve (0.0 to 1.0) -- **Note**: Values should sum to 1.0 or less +- **Value**: Either a float (0.0-1.0) or dict with `type` and `value` + - Float: `0.9` = 90% of capacity + - Dict: `{"type": "rpm", "value": 9}` = 9 requests/min + - Supported types: `"percent"`, `"rpm"`, `"tpm"` `priority_reservation_settings`: Object (Optional) - **default_priority (float)**: Weight/percentage (0.0 to 1.0) assigned to API keys that have no priority metadata set (defaults to 0.5) diff --git a/docs/my-website/docs/proxy/guardrails/grayswan.md b/docs/my-website/docs/proxy/guardrails/grayswan.md new file mode 100644 index 00000000000..dbd9258292b --- /dev/null +++ b/docs/my-website/docs/proxy/guardrails/grayswan.md @@ -0,0 +1,147 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Gray Swan Cygnal Guardrail + +Use [Gray Swan Cygnal](https://docs.grayswan.ai/cygnal/monitor-requests) to continuously monitor conversations for policy violations, indirect prompt injection (IPI), jailbreak attempts, and other safety risks. + +Cygnal returns a `violation` score between `0` and `1` (higher means more likely to violate policy), plus metadata such as violated rule indices, mutation detection, and IPI flags. LiteLLM can automatically block or monitor requests based on this signal. + +--- + +## Quick Start + +### 1. Obtain Credentials + +1. Create a Gray Swan account and generate a Cygnal API key. +2. Configure environment variables for the LiteLLM proxy host: + +```bash +export GRAYSWAN_API_KEY="your-grayswan-key" +``` + +### 2. Configure `config.yaml` + +Add a guardrail entry that references the Gray Swan integration. Below is a balanced example that monitors both input and output but only blocks once the violation score reaches the configured threshold. + +```yaml +model_list: + - model_name: openai/gpt-4.1-mini + litellm_params: + model: openai/gpt-4.1-mini + api_key: os.environ/OPENAI_API_KEY + +guardrails: + - guardrail_name: "cygnal-monitor" + litellm_params: + guardrail: grayswan + mode: [pre_call, post_call] # monitor both input and output + api_key: os.environ/GRAYSWAN_API_KEY + optional_params: + on_flagged_action: monitor # or "block" + violation_threshold: 0.5 # score >= threshold is flagged + reasoning_mode: hybrid # off | hybrid | thinking + categories: + safety: "Detect jailbreaks and policy violations" + policy_id: "your-cygnal-policy-id" + default_on: true + +general_settings: + master_key: "your-litellm-master-key" + +litellm_settings: + set_verbose: true +``` + +### 3. Launch the Proxy + +```bash +litellm --config config.yaml --port 4000 +``` + +--- + +## Choosing Guardrail Modes + +Gray Swan can run during `pre_call`, `during_call`, and `post_call` stages. Combine modes based on your latency and coverage requirements. + +| Mode | When it Runs | Protects | Typical Use Case | +|--------------|-------------------|-----------------------|------------------| +| `pre_call` | Before LLM call | User input only | Block prompt injection before it reaches the model | +| `during_call`| Parallel to call | User input only | Low-latency monitoring without blocking | +| `post_call` | After response | Full conversation | Scan output for policy violations, leaked secrets, or IPI | + + + + +```yaml +guardrails: + - guardrail_name: "cygnal-monitor-only" + litellm_params: + guardrail: grayswan + mode: "during_call" + api_key: os.environ/GRAYSWAN_API_KEY + optional_params: + on_flagged_action: monitor + violation_threshold: 0.6 + default_on: true +``` + +Best for visibility without blocking. Alerts are logged via LiteLLM’s standard logging callbacks. + + + + +```yaml +guardrails: + - guardrail_name: "cygnal-block-input" + litellm_params: + guardrail: grayswan + mode: "pre_call" + api_key: os.environ/GRAYSWAN_API_KEY + optional_params: + on_flagged_action: block + violation_threshold: 0.4 + categories: + pii: "Detect sensitive data" + default_on: true +``` + +Stops malicious or sensitive prompts before any tokens are generated. + + + + +```yaml +guardrails: + - guardrail_name: "cygnal-full-coverage" + litellm_params: + guardrail: grayswan + mode: [pre_call, post_call] + api_key: os.environ/GRAYSWAN_API_KEY + optional_params: + on_flagged_action: block + violation_threshold: 0.5 + reasoning_mode: thinking + policy_id: "policy-id-from-grayswan" + default_on: true +``` + +Provides the strongest enforcement by inspecting both prompts and responses. + + + + +--- + +## Configuration Reference + +| Parameter | Type | Description | +|---------------------------------------|-----------------|-------------| +| `api_key` | string | Gray Swan Cygnal API key. Reads from `GRAYSWAN_API_KEY` if omitted. | +| `mode` | string or list | Guardrail stages (`pre_call`, `during_call`, `post_call`). | +| `optional_params.on_flagged_action` | string | `monitor` (log only) or `block` (raise `HTTPException`). | +| `.optional_params.violation_threshold`| number (0-1) | Scores at or above this value are considered violations. | +| `optional_params.reasoning_mode` | string | `off`, `hybrid`, or `thinking`. Enables Cygnal’s reasoning capabilities. | +| `optional_params.categories` | object | Map of custom category names to descriptions. | +| `optional_params.policy_id` | string | Gray Swan policy identifier. | diff --git a/docs/my-website/docs/proxy/health.md b/docs/my-website/docs/proxy/health.md index c96753648b8..7df7685f335 100644 --- a/docs/my-website/docs/proxy/health.md +++ b/docs/my-website/docs/proxy/health.md @@ -213,6 +213,20 @@ model_list: mode: realtime ``` +### OCR Models + +To run OCR health checks, specify the mode as "ocr" in your config for the relevant model. + +```yaml +model_list: + - model_name: mistral/mistral-ocr-latest + litellm_params: + model: mistral/mistral-ocr-latest + api_key: os.environ/MISTRAL_API_KEY + model_info: + mode: ocr +``` + ### Wildcard Routes For wildcard routes, you can specify a `health_check_model` in your config.yaml. This model will be used for health checks for that wildcard route. diff --git a/docs/my-website/docs/proxy/logging.md b/docs/my-website/docs/proxy/logging.md index ff2591daad2..497e6e95a52 100644 --- a/docs/my-website/docs/proxy/logging.md +++ b/docs/my-website/docs/proxy/logging.md @@ -602,15 +602,15 @@ print(response) Use this if you want to control which LiteLLM-specific fields are logged as tags by the LiteLLM proxy. By default LiteLLM Proxy logs no LiteLLM-specific fields -| LiteLLM specific field | Description | Example Value | -|---------------------------|-----------------------------------------------------------------------------------------|------------------------------------------------| -| `cache_hit` | Indicates whether a cache hit occurred (True) or not (False) | `true`, `false` | -| `cache_key` | The Cache key used for this request | `d2b758c****` | -| `proxy_base_url` | The base URL for the proxy server, the value of env var `PROXY_BASE_URL` on your server | `https://proxy.example.com` | -| `user_api_key_alias` | An alias for the LiteLLM Virtual Key. | `prod-app1` | -| `user_api_key_user_id` | The unique ID associated with a user's API key. | `user_123`, `user_456` | -| `user_api_key_user_email` | The email associated with a user's API key. | `user@example.com`, `admin@example.com` | -| `user_api_key_team_alias` | An alias for a team associated with an API key. | `team_alpha`, `dev_team` | +| LiteLLM specific field | Description | Example Value | +| ------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------- | +| `cache_hit` | Indicates whether a cache hit occurred (True) or not (False) | `true`, `false` | +| `cache_key` | The Cache key used for this request | `d2b758c****` | +| `proxy_base_url` | The base URL for the proxy server, the value of env var `PROXY_BASE_URL` on your server | `https://proxy.example.com` | +| `user_api_key_alias` | An alias for the LiteLLM Virtual Key. | `prod-app1` | +| `user_api_key_user_id` | The unique ID associated with a user's API key. | `user_123`, `user_456` | +| `user_api_key_user_email` | The email associated with a user's API key. | `user@example.com`, `admin@example.com` | +| `user_api_key_team_alias` | An alias for a team associated with an API key. | `team_alpha`, `dev_team` | **Usage** @@ -1111,10 +1111,10 @@ Log LLM Logs to [Google Cloud Storage Buckets](https://cloud.google.com/storage? ::: -| Property | Details | -|----------|---------| -| Description | Log LLM Input/Output to cloud storage buckets | -| Load Test Benchmarks | [Benchmarks](https://docs.litellm.ai/docs/benchmarks) | +| Property | Details | +| ---------------------------- | -------------------------------------------------------------- | +| Description | Log LLM Input/Output to cloud storage buckets | +| Load Test Benchmarks | [Benchmarks](https://docs.litellm.ai/docs/benchmarks) | | Google Docs on Cloud Storage | [Google Cloud Storage](https://cloud.google.com/storage?hl=en) | @@ -1196,8 +1196,8 @@ Log LLM Logs/SpendLogs to [Google Cloud Storage PubSub Topic](https://cloud.goog ::: -| Property | Details | -|----------|---------| +| Property | Details | +| ----------- | ------------------------------------------------------------------ | | Description | Log LiteLLM `SpendLogs Table` to Google Cloud Storage PubSub Topic | When to use `gcs_pubsub`? @@ -1388,10 +1388,10 @@ On s3 bucket, you will see the object key as `my-test-path/my-team-alias/...` ## AWS SQS -| Property | Details | -|----------|---------| -| Description | Log LLM Input/Output to AWS SQS Queue | -| AWS Docs on SQS | [AWS SQS](https://aws.amazon.com/sqs/) | +| Property | Details | +| -------------------- | ------------------------------------------------------------------------------------- | +| Description | Log LLM Input/Output to AWS SQS Queue | +| AWS Docs on SQS | [AWS SQS](https://aws.amazon.com/sqs/) | | Fields Logged to SQS | LiteLLM [Standard Logging Payload is logged for each LLM call](../proxy/logging_spec) | @@ -1465,9 +1465,9 @@ Log LLM Logs to [Azure Data Lake Storage](https://learn.microsoft.com/en-us/azur ::: -| Property | Details | -|----------|---------| -| Description | Log LLM Input/Output to Azure Blob Storage (Bucket) | +| Property | Details | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Description | Log LLM Input/Output to Azure Blob Storage (Bucket) | | Azure Docs on Data Lake Storage | [Azure Data Lake Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/data-lake-storage-introduction) | @@ -1966,9 +1966,9 @@ This is an Enterprise only feature [Get Started with Enterprise here](https://gi ::: -| Property | Details | -|----------|---------| -| Description | Log LLM Input/Output to a custom API endpoint | +| Property | Details | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Description | Log LLM Input/Output to a custom API endpoint | | Logged Payload | `List[StandardLoggingPayload]` LiteLLM logs a list of [`StandardLoggingPayload` objects](https://docs.litellm.ai/docs/proxy/logging_spec) to your endpoint | @@ -1995,10 +1995,10 @@ litellm_settings: 2. Set Environment Variables for the custom API endpoint -| Environment Variable | Details | Required | -|----------|---------|----------| -| `GENERIC_LOGGER_ENDPOINT` | The endpoint + route we should send callback logs to | Yes | -| `GENERIC_LOGGER_HEADERS` | Optional: Set headers to be sent to the custom API endpoint | No, this is optional | +| Environment Variable | Details | Required | +| ------------------------- | ----------------------------------------------------------- | -------------------- | +| `GENERIC_LOGGER_ENDPOINT` | The endpoint + route we should send callback logs to | Yes | +| `GENERIC_LOGGER_HEADERS` | Optional: Set headers to be sent to the custom API endpoint | No, this is optional | ```shell showLineNumbers title=".env" GENERIC_LOGGER_ENDPOINT="https://webhook-test.com/30343bc33591bc5e6dc44217ceae3e0a" @@ -2428,6 +2428,7 @@ export SENTRY_DSN="your-sentry-dsn" # Optional: Configure Sentry sampling rates export SENTRY_API_SAMPLE_RATE="1.0" # Controls what percentage of errors are sent (default: 1.0 = 100%) export SENTRY_API_TRACE_RATE="1.0" # Controls what percentage of transactions are sampled for performance monitoring (default: 1.0 = 100%) +export SENTRY_ENVIRONMENT="development" # Controls the Sentry Environment (default: production) ``` ```yaml diff --git a/docs/my-website/docs/search/dataforseo.md b/docs/my-website/docs/search/dataforseo.md new file mode 100644 index 00000000000..ac6f3bb15a7 --- /dev/null +++ b/docs/my-website/docs/search/dataforseo.md @@ -0,0 +1,91 @@ +# DataForSEO Search + +**Get API Access:** [DataForSEO](https://dataforseo.com/) + +## Setup + +1. Go to [DataForSEO](https://dataforseo.com/) and create an account +2. Navigate to your account dashboard +3. Generate API credentials: + - You'll receive a **login** (username) + - You'll receive a **password** +4. Set up your environment variables: + - `DATAFORSEO_LOGIN` - Your DataForSEO login/username + - `DATAFORSEO_PASSWORD` - Your DataForSEO password + +## LiteLLM Python SDK + +```python showLineNumbers title="DataForSEO Search" +import os +from litellm import search + +os.environ["DATAFORSEO_LOGIN"] = "your-login" +os.environ["DATAFORSEO_PASSWORD"] = "your-password" + +response = search( + query="latest AI developments", + search_provider="dataforseo", + max_results=10 +) +``` + +## LiteLLM AI Gateway + +### 1. Setup config.yaml + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gpt-4 + litellm_params: + model: gpt-4 + api_key: os.environ/OPENAI_API_KEY + +search_tools: + - search_tool_name: dataforseo-search + litellm_params: + search_provider: dataforseo + api_key: "os.environ/DATAFORSEO_LOGIN:os.environ/DATAFORSEO_PASSWORD" +``` + +### 2. Start the proxy + +```bash +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +### 3. Test the search endpoint + +```bash showLineNumbers title="Test Request" +curl http://0.0.0.0:4000/v1/search/dataforseo-search \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "latest AI developments", + "max_results": 10 + }' +``` + +## Provider-specific Parameters + +```python showLineNumbers title="DataForSEO Search with Provider-specific Parameters" +import os +from litellm import search + +os.environ["DATAFORSEO_LOGIN"] = "your-login" +os.environ["DATAFORSEO_PASSWORD"] = "your-password" + +response = search( + query="AI developments", + search_provider="dataforseo", + max_results=10, + # DataForSEO-specific parameters + country="United States", # Country name for location_name + language_code="en", # Language code + depth=20, # Number of results (max 700) + device="desktop", # Device type ('desktop', 'mobile', 'tablet') + os="windows" # Operating system +) +``` + diff --git a/docs/my-website/docs/search/exa_ai.md b/docs/my-website/docs/search/exa_ai.md new file mode 100644 index 00000000000..c1356940ee7 --- /dev/null +++ b/docs/my-website/docs/search/exa_ai.md @@ -0,0 +1,77 @@ +# Exa AI Search + +**Get API Key:** [https://exa.ai](https://exa.ai) + +## LiteLLM Python SDK + +```python showLineNumbers title="Exa AI Search" +import os +from litellm import search + +os.environ["EXA_API_KEY"] = "exa-..." + +response = search( + query="latest AI developments", + search_provider="exa_ai", + max_results=5 +) +``` + +## LiteLLM AI Gateway + +### 1. Setup config.yaml + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gpt-4 + litellm_params: + model: gpt-4 + api_key: os.environ/OPENAI_API_KEY + +search_tools: + - search_tool_name: exa-search + litellm_params: + search_provider: exa_ai + api_key: os.environ/EXA_API_KEY +``` + +### 2. Start the proxy + +```bash +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +### 3. Test the search endpoint + +```bash showLineNumbers title="Test Request" +curl http://0.0.0.0:4000/v1/search/exa-search \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "latest AI developments", + "max_results": 5 + }' +``` + +## Provider-specific Parameters + +```python showLineNumbers title="Exa AI Search with Provider-specific Parameters" +import os +from litellm import search + +os.environ["EXA_API_KEY"] = "exa-..." + +response = search( + query="AI research papers", + search_provider="exa_ai", + max_results=10, + search_domain_filter=["arxiv.org"], + # Exa-specific parameters + type="neural", # 'neural', 'keyword', or 'auto' + contents={"text": True}, # Request text content + use_autoprompt=True # Enable Exa's autoprompt +) +``` + diff --git a/docs/my-website/docs/search/google_pse.md b/docs/my-website/docs/search/google_pse.md new file mode 100644 index 00000000000..3e15a5bdc48 --- /dev/null +++ b/docs/my-website/docs/search/google_pse.md @@ -0,0 +1,101 @@ +# Google Programmable Search Engine (PSE) + +**Get API Key:** [Google Cloud Console](https://console.cloud.google.com/apis/credentials) +**Create Search Engine:** [Programmable Search Engine](https://programmablesearchengine.google.com/) + +## Setup + +1. Go to [Google Developers Programmable Search Engine](https://programmablesearchengine.google.com/) and log in or create an account +2. Click the **Add** button in the control panel +3. Enter a search engine name and configure properties: + - Choose which sites to search (entire web or specific sites) + - Set language and other preferences + - Verify you're not a robot +4. Click **Create** button +5. Once created, you'll see: + - **Search engine ID (cx)** - Copy this for `GOOGLE_PSE_ENGINE_ID` + - Instructions to get your API key +6. Generate API key: + - Go to [Google Cloud Console - Credentials](https://console.cloud.google.com/apis/credentials) + - Create a new API key or use existing one + - Enable **Custom Search API** for your project + - Copy the API key for `GOOGLE_PSE_API_KEY` + +## LiteLLM Python SDK + +```python showLineNumbers title="Google PSE Search" +import os +from litellm import search + +os.environ["GOOGLE_PSE_API_KEY"] = "AIza..." +os.environ["GOOGLE_PSE_ENGINE_ID"] = "your-search-engine-id" + +response = search( + query="latest AI developments", + search_provider="google_pse", + max_results=10 +) +``` + +## LiteLLM AI Gateway + +### 1. Setup config.yaml + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gpt-4 + litellm_params: + model: gpt-4 + api_key: os.environ/OPENAI_API_KEY + +search_tools: + - search_tool_name: google-search + litellm_params: + search_provider: google_pse + api_key: os.environ/GOOGLE_PSE_API_KEY + search_engine_id: os.environ/GOOGLE_PSE_ENGINE_ID +``` + +### 2. Start the proxy + +```bash +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +### 3. Test the search endpoint + +```bash showLineNumbers title="Test Request" +curl http://0.0.0.0:4000/v1/search/google-search \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "latest AI developments", + "max_results": 10 + }' +``` + +## Provider-specific Parameters + +```python showLineNumbers title="Google PSE Search with Provider-specific Parameters" +import os +from litellm import search + +os.environ["GOOGLE_PSE_API_KEY"] = "AIza..." +os.environ["GOOGLE_PSE_ENGINE_ID"] = "your-search-engine-id" + +response = search( + query="latest AI research papers", + search_provider="google_pse", + max_results=10, + search_domain_filter=["arxiv.org"], + # Google PSE-specific parameters (use actual Google PSE API parameter names) + dateRestrict="m6", # 'm6' = last 6 months, 'd7' = last 7 days + lr="lang_en", # Language restriction (e.g., 'lang_en', 'lang_es') + safe="active", # Search safety level ('active' or 'off') + exactTerms="machine learning", # Phrase that all documents must contain + fileType="pdf" # File type to restrict results to +) +``` + diff --git a/docs/my-website/docs/search/index.md b/docs/my-website/docs/search/index.md new file mode 100644 index 00000000000..1a54d323e0b --- /dev/null +++ b/docs/my-website/docs/search/index.md @@ -0,0 +1,272 @@ +# Overview + +| Feature | Supported | +|---------|-----------| +| Supported Providers | `perplexity`, `tavily`, `parallel_ai`, `exa_ai`, `google_pse`, `dataforseo` | +| Cost Tracking | ✅ | +| Logging | ✅ | +| Load Balancing | ❌ | + +:::tip + +LiteLLM follows the [Perplexity API request/response for the Search API](https://docs.perplexity.ai/api-reference/search-post) + +::: + +:::info + +Supported from LiteLLM v1.78.7+ +::: + +## **LiteLLM Python SDK Usage** +### Quick Start + +```python showLineNumbers title="Basic Search" +from litellm import search +import os + +os.environ["PERPLEXITYAI_API_KEY"] = "pplx-..." + +response = search( + query="latest AI developments in 2024", + search_provider="perplexity", + max_results=5 +) + +# Access search results +for result in response.results: + print(f"{result.title}: {result.url}") + print(f"Snippet: {result.snippet}\n") +``` + +### Async Usage + +```python showLineNumbers title="Async Search" +from litellm import asearch +import os, asyncio + +os.environ["PERPLEXITYAI_API_KEY"] = "pplx-..." + +async def search_async(): + response = await asearch( + query="machine learning research papers", + search_provider="perplexity", + max_results=10, + search_domain_filter=["arxiv.org", "nature.com"] + ) + + # Access search results + for result in response.results: + print(f"{result.title}: {result.url}") + print(f"Snippet: {result.snippet}") + +asyncio.run(search_async()) +``` + +### Optional Parameters + +```python showLineNumbers title="Search with Options" +response = search( + query="AI developments", + search_provider="perplexity", + # Unified parameters (work across all providers) + max_results=10, # Maximum number of results (1-20) + search_domain_filter=["arxiv.org"], # Filter to specific domains + country="US", # Country code filter + max_tokens_per_page=1024 # Max tokens per page +) +``` + +## **LiteLLM AI Gateway Usage** + +LiteLLM provides a Perplexity API compatible `/search` endpoint for search calls. + +**Setup** + +Add this to your litellm proxy config.yaml + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gpt-4 + litellm_params: + model: gpt-4 + api_key: os.environ/OPENAI_API_KEY + +search_tools: + - search_tool_name: perplexity-search + litellm_params: + search_provider: perplexity + api_key: os.environ/PERPLEXITYAI_API_KEY + + - search_tool_name: tavily-search + litellm_params: + search_provider: tavily + api_key: os.environ/TAVILY_API_KEY +``` + +Start litellm + +```bash +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +### Test Request + +**Option 1: Search tool name in URL (Recommended - keeps body Perplexity-compatible)** + +```bash showLineNumbers title="cURL Request" +curl http://0.0.0.0:4000/v1/search/perplexity-search \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "latest AI developments 2024", + "max_results": 5, + "search_domain_filter": ["arxiv.org", "nature.com"], + "country": "US" + }' +``` + +**Option 2: Search tool name in body** + +```bash showLineNumbers title="cURL Request with search_tool_name in body" +curl http://0.0.0.0:4000/v1/search \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "search_tool_name": "perplexity-search", + "query": "latest AI developments 2024", + "max_results": 5 + }' +``` + +### Load Balancing + +Configure multiple search providers for automatic load balancing and fallbacks: + +```yaml showLineNumbers title="config.yaml with load balancing" +search_tools: + - search_tool_name: my-search + litellm_params: + search_provider: perplexity + api_key: os.environ/PERPLEXITYAI_API_KEY + + - search_tool_name: my-search + litellm_params: + search_provider: tavily + api_key: os.environ/TAVILY_API_KEY + + - search_tool_name: my-search + litellm_params: + search_provider: exa_ai + api_key: os.environ/EXA_API_KEY + +router_settings: + routing_strategy: simple-shuffle # or 'least-busy', 'latency-based-routing' +``` + +Test with load balancing: + +```bash +curl http://0.0.0.0:4000/v1/search/my-search \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "AI developments", + "max_results": 10 + }' +``` + +## **Request/Response Format** + +:::info + +LiteLLM follows the **Perplexity Search API specification**. + +See the [official Perplexity Search documentation](https://docs.perplexity.ai/api-reference/search-post) for complete details. + +::: + +### Example Request + +```json showLineNumbers title="Search Request" +{ + "query": "latest AI developments 2024", + "max_results": 10, + "search_domain_filter": ["arxiv.org", "nature.com"], + "country": "US", + "max_tokens_per_page": 1024 +} +``` + +### Request Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `query` | string or array | Yes | Search query. Can be a single string or array of strings | +| `search_provider` | string | Yes (SDK) | The search provider to use: `"perplexity"`, `"tavily"`, `"parallel_ai"`, `"exa_ai"`, or `"google_pse"` | +| `search_tool_name` | string | Yes (Proxy) | Name of the search tool configured in `config.yaml` | +| `max_results` | integer | No | Maximum number of results to return (1-20). Default: 10 | +| `search_domain_filter` | array | No | List of domains to filter results (max 20 domains) | +| `max_tokens_per_page` | integer | No | Maximum tokens per page to process. Default: 1024 | +| `country` | string | No | Country code filter (e.g., `"US"`, `"GB"`, `"DE"`) | + +**Query Format Examples:** + +```python +# Single query +query = "AI developments" + +# Multiple queries +query = ["AI developments", "machine learning trends"] +``` + +### Response Format + +The response follows Perplexity's search format with the following structure: + +```json showLineNumbers title="Search Response" +{ + "object": "search", + "results": [ + { + "title": "Latest Advances in Artificial Intelligence", + "url": "https://arxiv.org/paper/example", + "snippet": "This paper discusses recent developments in AI...", + "date": "2024-01-15" + }, + { + "title": "Machine Learning Breakthroughs", + "url": "https://nature.com/articles/ml-breakthrough", + "snippet": "Researchers have achieved new milestones...", + "date": "2024-01-10" + } + ] +} +``` + +#### Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `object` | string | Always `"search"` for search responses | +| `results` | array | List of search results | +| `results[].title` | string | Title of the search result | +| `results[].url` | string | URL of the search result | +| `results[].snippet` | string | Text snippet from the result | +| `results[].date` | string | Optional publication or last updated date | + +## **Supported Providers** + +| Provider | Environment Variable | `search_provider` Value | +|----------|---------------------|------------------------| +| Perplexity AI | `PERPLEXITYAI_API_KEY` | `perplexity` | +| Tavily | `TAVILY_API_KEY` | `tavily` | +| Exa AI | `EXA_API_KEY` | `exa_ai` | +| Parallel AI | `PARALLEL_AI_API_KEY` | `parallel_ai` | +| Google PSE | `GOOGLE_PSE_API_KEY`, `GOOGLE_PSE_ENGINE_ID` | `google_pse` | +| DataForSEO | `DATAFORSEO_LOGIN`, `DATAFORSEO_PASSWORD` | `dataforseo` | + +See the individual provider documentation for detailed setup instructions and provider-specific parameters. + diff --git a/docs/my-website/docs/search/parallel_ai.md b/docs/my-website/docs/search/parallel_ai.md new file mode 100644 index 00000000000..a7118f9a3bf --- /dev/null +++ b/docs/my-website/docs/search/parallel_ai.md @@ -0,0 +1,75 @@ +# Parallel AI Search + +**Get API Key:** [https://www.parallel.ai](https://www.parallel.ai) + +## LiteLLM Python SDK + +```python showLineNumbers title="Parallel AI Search" +import os +from litellm import search + +os.environ["PARALLEL_AI_API_KEY"] = "..." + +response = search( + query="latest AI developments", + search_provider="parallel_ai", + max_results=5 +) +``` + +## LiteLLM AI Gateway + +### 1. Setup config.yaml + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gpt-4 + litellm_params: + model: gpt-4 + api_key: os.environ/OPENAI_API_KEY + +search_tools: + - search_tool_name: parallel-search + litellm_params: + search_provider: parallel_ai + api_key: os.environ/PARALLEL_AI_API_KEY +``` + +### 2. Start the proxy + +```bash +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +### 3. Test the search endpoint + +```bash showLineNumbers title="Test Request" +curl http://0.0.0.0:4000/v1/search/parallel-search \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "latest AI developments", + "max_results": 5 + }' +``` + +## Provider-specific Parameters + +```python showLineNumbers title="Parallel AI Search with Provider-specific Parameters" +import os +from litellm import search + +os.environ["PARALLEL_AI_API_KEY"] = "..." + +response = search( + query="latest developments in quantum computing", + search_provider="parallel_ai", + max_results=5, + # Parallel AI-specific parameters + processor="pro", # 'base' or 'pro' + max_chars_per_result=500 # Max characters per result +) +``` + diff --git a/docs/my-website/docs/search/perplexity.md b/docs/my-website/docs/search/perplexity.md new file mode 100644 index 00000000000..61419c45937 --- /dev/null +++ b/docs/my-website/docs/search/perplexity.md @@ -0,0 +1,57 @@ +# Perplexity AI Search + +**Get API Key:** [https://www.perplexity.ai/settings/api](https://www.perplexity.ai/settings/api) + +## LiteLLM Python SDK + +```python showLineNumbers title="Perplexity Search" +import os +from litellm import search + +os.environ["PERPLEXITYAI_API_KEY"] = "pplx-..." + +response = search( + query="latest AI developments", + search_provider="perplexity", + max_results=5 +) +``` + +## LiteLLM AI Gateway + +### 1. Setup config.yaml + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gpt-4 + litellm_params: + model: gpt-4 + api_key: os.environ/OPENAI_API_KEY + +search_tools: + - search_tool_name: perplexity-search + litellm_params: + search_provider: perplexity + api_key: os.environ/PERPLEXITYAI_API_KEY +``` + +### 2. Start the proxy + +```bash +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +### 3. Test the search endpoint + +```bash showLineNumbers title="Test Request" +curl http://0.0.0.0:4000/v1/search/perplexity-search \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "latest AI developments", + "max_results": 5 + }' +``` + diff --git a/docs/my-website/docs/search/tavily.md b/docs/my-website/docs/search/tavily.md new file mode 100644 index 00000000000..e0fcffcd107 --- /dev/null +++ b/docs/my-website/docs/search/tavily.md @@ -0,0 +1,77 @@ +# Tavily Search + +**Get API Key:** [https://tavily.com](https://tavily.com) + +## LiteLLM Python SDK + +```python showLineNumbers title="Tavily Search" +import os +from litellm import search + +os.environ["TAVILY_API_KEY"] = "tvly-..." + +response = search( + query="latest AI developments", + search_provider="tavily", + max_results=5 +) +``` + +## LiteLLM AI Gateway + +### 1. Setup config.yaml + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gpt-4 + litellm_params: + model: gpt-4 + api_key: os.environ/OPENAI_API_KEY + +search_tools: + - search_tool_name: tavily-search + litellm_params: + search_provider: tavily + api_key: os.environ/TAVILY_API_KEY +``` + +### 2. Start the proxy + +```bash +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +### 3. Test the search endpoint + +```bash showLineNumbers title="Test Request" +curl http://0.0.0.0:4000/v1/search/tavily-search \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "latest AI developments", + "max_results": 5 + }' +``` + +## Provider-specific Parameters + +```python showLineNumbers title="Tavily Search with Provider-specific Parameters" +import os +from litellm import search + +os.environ["TAVILY_API_KEY"] = "tvly-..." + +response = search( + query="latest tech news", + search_provider="tavily", + max_results=5, + # Tavily-specific parameters + topic="news", # 'general', 'news', 'finance' + search_depth="advanced", # 'basic', 'advanced' + include_answer=True, # Include AI-generated answer + include_raw_content=True # Include raw HTML content +) +``` + diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index d16a569627f..93c1af1ce2e 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -37,6 +37,7 @@ const sidebars = { "proxy/guardrails/azure_content_guardrail", "proxy/guardrails/bedrock", "proxy/guardrails/enkryptai", + "proxy/guardrails/grayswan", "proxy/guardrails/lasso_security", "proxy/guardrails/guardrails_ai", "proxy/guardrails/lakera_ai", @@ -307,6 +308,7 @@ const sidebars = { ], }, "text_completion", + "bedrock_converse", "embedding/supported_embedding", { type: "category", @@ -326,6 +328,7 @@ const sidebars = { }, "generateContent", "apply_guardrail", + "bedrock_invoke", { type: "category", label: "/images", @@ -346,9 +349,8 @@ const sidebars = { "mcp_guardrail", ] }, + "anthropic_unified", "moderation", - "bedrock_invoke", - "bedrock_converse", "ocr", { type: "category", @@ -379,7 +381,19 @@ const sidebars = { "realtime", "rerank", "response_api", - "anthropic_unified", + { + type: "category", + label: "/search", + items: [ + "search/index", + "search/perplexity", + "search/tavily", + "search/exa_ai", + "search/parallel_ai", + "search/google_pse", + "search/dataforseo", + ] + }, { type: "category", label: "/vector_stores", @@ -400,6 +414,11 @@ const sidebars = { slug: "/providers", }, items: [ + { + type: "doc", + id: "provider_registration/index", + label: "Integrate as a Model Provider", + }, { type: "category", label: "OpenAI", @@ -426,6 +445,7 @@ const sidebars = { label: "Azure AI", items: [ "providers/azure_ai", + "providers/azure_ocr", "providers/azure_ai_speech", "providers/azure_ai_img", ] @@ -581,7 +601,8 @@ const sidebars = { "guides/finetuned_models", "guides/security_settings", "proxy/veo_video_generation", - "reasoning_content" + "reasoning_content", + "extras/creating_adapters", ] }, @@ -739,11 +760,6 @@ const sidebars = { "proxy_server", ], }, - { - type: "doc", - id: "provider_registration/index", - label: "Integrate as a Model Provider", - }, "troubleshoot", ], }; diff --git a/docs/my-website/src/pages/index.md b/docs/my-website/src/pages/index.md index 2c89d28a626..1dc2995c5fe 100644 --- a/docs/my-website/src/pages/index.md +++ b/docs/my-website/src/pages/index.md @@ -214,6 +214,92 @@ response = completion( +### Responses API + +Use `litellm.responses()` for advanced models that support reasoning content like GPT-5, o3, etc. + + + + +```python +from litellm import responses +import os + +## set ENV variables +os.environ["OPENAI_API_KEY"] = "your-api-key" + +response = responses( + model="gpt-5-mini", + messages=[{ "content": "What is the capital of France?","role": "user"}], + reasoning_effort="medium" +) + +print(response) +print(response.choices[0].message.content) # response +print(response.choices[0].message.reasoning_content) # reasoning + +``` + + + + +```python +from litellm import responses +import os + +## set ENV variables +os.environ["ANTHROPIC_API_KEY"] = "your-api-key" + +response = responses( + model="claude-3.5-sonnet", + messages=[{ "content": "What is the capital of France?","role": "user"}] +) +``` + + + + + +```python +from litellm import responses +import os + +# auth: run 'gcloud auth application-default' +os.environ["VERTEX_PROJECT"] = "jr-smith-386718" +os.environ["VERTEX_LOCATION"] = "us-central1" + +response = responses( + model="chat-bison", + messages=[{ "content": "What is the capital of France?","role": "user"}] +) +``` + + + + + +```python +from litellm import responses +import os + +## set ENV variables +os.environ["AZURE_API_KEY"] = "" +os.environ["AZURE_API_BASE"] = "" +os.environ["AZURE_API_VERSION"] = "" + +# azure call +response = responses( + "azure/", + messages = [{ "content": "What is the capital of France?","role": "user"}] +) + +print(response) +``` + + + + + ### Streaming Set `stream=True` in the `completion` args. @@ -504,6 +590,10 @@ model_list: api_base: os.environ/AZURE_API_BASE # runs os.getenv("AZURE_API_BASE") api_key: os.environ/AZURE_API_KEY # runs os.getenv("AZURE_API_KEY") api_version: "2023-07-01-preview" + +litellm_settings: + master_key: sk-1234 + database_url: postgres:// ``` ### Step 2. RUN Docker Image @@ -524,6 +614,9 @@ docker run \ #### Step 2: Make ChatCompletions Request to Proxy + + + ```python import openai # openai v1.0.0+ client = openai.OpenAI(api_key="anything",base_url="http://0.0.0.0:4000") # set proxy to base_url @@ -538,6 +631,28 @@ response = client.chat.completions.create(model="gpt-3.5-turbo", messages = [ print(response) ``` + + + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-1234", + base_url="http://0.0.0.0:4000" +) + +response = client.responses.create( + model="gpt-5", + input="Tell me a three sentence bedtime story about a unicorn." +) + +print(response) +``` + + + + ## More details - [exception mapping](../../docs/exception_mapping) diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.2.28-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.2.28-py3-none-any.whl new file mode 100644 index 00000000000..7332547689f Binary files /dev/null and b/litellm-proxy-extras/dist/litellm_proxy_extras-0.2.28-py3-none-any.whl differ diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.2.29-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.2.29-py3-none-any.whl new file mode 100644 index 00000000000..7252419182e Binary files /dev/null and b/litellm-proxy-extras/dist/litellm_proxy_extras-0.2.29-py3-none-any.whl differ diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.2.29.tar.gz b/litellm-proxy-extras/dist/litellm_proxy_extras-0.2.29.tar.gz new file mode 100644 index 00000000000..e04cbc23243 Binary files /dev/null and b/litellm-proxy-extras/dist/litellm_proxy_extras-0.2.29.tar.gz differ diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20251023141814_add_search_tool_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20251023141814_add_search_tool_table/migration.sql new file mode 100644 index 00000000000..4cbe4a7184f --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20251023141814_add_search_tool_table/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "LiteLLM_SearchToolsTable" ( + "search_tool_id" TEXT NOT NULL, + "search_tool_name" TEXT NOT NULL, + "litellm_params" JSONB NOT NULL, + "search_tool_info" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LiteLLM_SearchToolsTable_pkey" PRIMARY KEY ("search_tool_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "LiteLLM_SearchToolsTable_search_tool_name_key" ON "LiteLLM_SearchToolsTable"("search_tool_name"); + diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index a13af1afc5f..9cb9edc9268 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -570,4 +570,14 @@ model LiteLLM_HealthCheckTable { @@index([model_name]) @@index([checked_at]) @@index([status]) +} + +// Search Tools table for storing search tool configurations +model LiteLLM_SearchToolsTable { + search_tool_id String @id @default(uuid()) + search_tool_name String @unique + litellm_params Json + search_tool_info Json? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt } \ No newline at end of file diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 8af7c212f52..9548c6ce324 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm-proxy-extras" -version = "0.2.27" +version = "0.2.29" 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.2.27" +version = "0.2.29" version_files = [ "pyproject.toml:version", "../requirements.txt:litellm-proxy-extras==", diff --git a/litellm/__init__.py b/litellm/__init__.py index a871279d37f..9df8daebc09 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -28,6 +28,7 @@ from litellm.types.utils import ( all_litellm_params, all_litellm_params as _litellm_completion_params, CredentialItem, + PriorityReservationDict, ) # maintain backwards compatibility for root param from litellm._logging import ( set_verbose, @@ -89,7 +90,7 @@ from litellm.types.proxy.management_endpoints.ui_sso import ( DefaultTeamSSOParams, LiteLLM_UpperboundKeyGenerateParams, ) -from litellm.types.utils import StandardKeyGenerationConfig, LlmProviders +from litellm.types.utils import StandardKeyGenerationConfig, LlmProviders, SearchProviders from litellm.types.utils import PriorityReservationSettings from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.logging_callback_manager import LoggingCallbackManager @@ -156,7 +157,7 @@ _custom_logger_compatible_callbacks_literal = Literal[ "cloudzero", "posthog", ] -configured_cold_storage_logger: Optional[ +cold_storage_custom_logger: Optional[ _custom_logger_compatible_callbacks_literal ] = None logged_real_time_event_types: Optional[Union[List[str], Literal["*"]]] = None @@ -369,7 +370,7 @@ disable_copilot_system_to_assistant: bool = False # If false (default), convert public_model_groups: Optional[List[str]] = None public_model_groups_links: Dict[str, str] = {} #### REQUEST PRIORITIZATION ####### -priority_reservation: Optional[Dict[str, float]] = None +priority_reservation: Optional[Dict[str, Union[float, PriorityReservationDict]]] = None priority_reservation_settings: "PriorityReservationSettings" = ( PriorityReservationSettings() ) @@ -1334,6 +1335,7 @@ from .rerank_api.main import * from .llms.anthropic.experimental_pass_through.messages.handler import * from .responses.main import * from .ocr.main import * +from .search.main import * from .realtime_api.main import _arealtime from .fine_tuning.main import * from .files.main import * diff --git a/litellm/_redis.py b/litellm/_redis.py index e6ac323ff5a..a86ebd9ea9e 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -78,6 +78,7 @@ def _get_redis_cluster_kwargs(client=None): available_args.append("redis_connect_func") # Needed for sync clusters and IAM detection available_args.append("gcp_service_account") available_args.append("gcp_ssl_ca_certs") + available_args.append("max_connections") return available_args @@ -376,7 +377,7 @@ def get_redis_client(**env_overrides): def get_redis_async_client( - **env_overrides, + connection_pool: Optional[async_redis.BlockingConnectionPool] = None, **env_overrides, ) -> Union[async_redis.Redis, async_redis.RedisCluster]: redis_kwargs = _get_redis_client_logic(**env_overrides) if "url" in redis_kwargs and redis_kwargs["url"] is not None: @@ -447,6 +448,10 @@ def get_redis_async_client( if "sentinel_nodes" in redis_kwargs and "service_name" in redis_kwargs: return _init_async_redis_sentinel(redis_kwargs) _pretty_print_redis_config(redis_kwargs=redis_kwargs) + + if connection_pool is not None: + redis_kwargs["connection_pool"] = connection_pool + return async_redis.Redis( **redis_kwargs, ) diff --git a/litellm/constants.py b/litellm/constants.py index c25977e2ee6..8553ca6ced6 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -274,6 +274,11 @@ ANTHROPIC_WEB_SEARCH_TOOL_MAX_USES = { } DEFAULT_IMAGE_ENDPOINT_MODEL = "dall-e-2" +### DATAFORSEO CONSTANTS ### +DEFAULT_DATAFORSEO_LOCATION_CODE = int( + os.getenv("DEFAULT_DATAFORSEO_LOCATION_CODE", 2250) +) # Default to France (2250) - lower number, commonly used location + LITELLM_CHAT_PROVIDERS = [ "openai", "openai_like", diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index fc120fae85d..37c76e5584b 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -29,6 +29,7 @@ from litellm.llms.anthropic.cost_calculation import ( from litellm.llms.azure.cost_calculation import ( cost_per_token as azure_openai_cost_per_token, ) +from litellm.llms.base_llm.search.transformation import SearchResponse from litellm.llms.bedrock.cost_calculation import ( cost_per_token as bedrock_cost_per_token, ) @@ -315,6 +316,16 @@ def cost_per_token( # noqa: PLR0915 custom_llm_provider=custom_llm_provider, duration=audio_transcription_file_duration, ) + elif call_type == "search" or call_type == "asearch": + # Search providers use per-query pricing + from litellm.search import search_provider_cost_per_query + + return search_provider_cost_per_query( + model=model, + custom_llm_provider=custom_llm_provider, + number_of_queries=number_of_queries or 1, + optional_params=response._hidden_params if response and hasattr(response, "_hidden_params") else None + ) elif custom_llm_provider == "vertex_ai": cost_router = google_cost_router( model=model_without_prefix, @@ -1094,6 +1105,7 @@ def response_cost_calculator( LiteLLMRealtimeStreamLoggingObject, OpenAIModerationResponse, Response, + SearchResponse, ], model: str, custom_llm_provider: Optional[str], @@ -1114,6 +1126,8 @@ def response_cost_calculator( "speech", "rerank", "arerank", + "search", + "asearch", ], optional_params: dict, cache_hit: Optional[bool] = None, diff --git a/litellm/images/main.py b/litellm/images/main.py index 2a8b62bce24..63603411fb1 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -711,6 +711,16 @@ def image_edit( # add images / or return a single image images = image if isinstance(image, list) else [image] + headers_from_kwargs = kwargs.get("headers") + merged_extra_headers: Dict[str, Any] = {} + if isinstance(headers_from_kwargs, dict): + merged_extra_headers.update(headers_from_kwargs) + if isinstance(extra_headers, dict): + merged_extra_headers.update(extra_headers) + + if merged_extra_headers: + extra_headers = dict(merged_extra_headers) + # get llm provider logic litellm_params = GenericLiteLLMParams(**kwargs) model, custom_llm_provider, _, _ = get_llm_provider( diff --git a/litellm/integrations/braintrust_logging.py b/litellm/integrations/braintrust_logging.py index 5bc6afb6dbc..364fa3f5def 100644 --- a/litellm/integrations/braintrust_logging.py +++ b/litellm/integrations/braintrust_logging.py @@ -206,6 +206,20 @@ class BraintrustLogger(CustomLogger): # Allow metadata override for span name span_name = dynamic_metadata.get("span_name", "Chat Completion") + + # Span parents is a special case + span_parents = dynamic_metadata.get("span_parents") + + # Convert comma-separated string to list if present + if span_parents: + span_parents = [s.strip() for s in span_parents.split(",") if s.strip()] + + # Add optional span attributes only if present + span_attributes = { + "span_id": dynamic_metadata.get("span_id"), + "root_span_id": dynamic_metadata.get("root_span_id"), + "span_parents": span_parents, + } request_data = { "id": litellm_call_id, @@ -214,6 +228,12 @@ class BraintrustLogger(CustomLogger): "tags": tags, "span_attributes": {"name": span_name, "type": "llm"}, } + + # Only add those that are not None (or falsy) + for key, value in span_attributes.items(): + if value: + request_data[key] = value + if choices is not None: request_data["output"] = [choice.dict() for choice in choices] else: diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index a8cf106b61c..32fde2d0987 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -227,8 +227,9 @@ class OpenTelemetry(CustomLogger): PeriodicExportingMetricReader, ) + normalized_endpoint = self._normalize_otel_endpoint(self.config.endpoint, 'metrics') _metric_exporter = OTLPMetricExporter( - endpoint=self.config.endpoint, + endpoint=normalized_endpoint, headers=OpenTelemetry._get_headers_dictionary(self.config.headers), preferred_temporality={Histogram: AggregationTemporality.DELTA}, ) @@ -268,22 +269,20 @@ class OpenTelemetry(CustomLogger): return from opentelemetry._logs import set_logger_provider - from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider from opentelemetry.sdk._logs.export import BatchLogRecordProcessor # set up log pipeline if logger_provider is None: - logger_provider = OTLoggerProvider() + litellm_resource = _get_litellm_resource() + logger_provider = OTLoggerProvider(resource=litellm_resource) # Only add OTLP exporter if we created the logger provider ourselves - logger_provider.add_log_record_processor( - BatchLogRecordProcessor( - OTLPLogExporter( - endpoint=self.config.endpoint, - headers=self._get_headers_dictionary(self.config.headers), - ) + log_exporter = self._get_log_exporter() + if log_exporter: + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(log_exporter) # type: ignore[arg-type] ) - ) + set_logger_provider(logger_provider) def log_success_event(self, kwargs, response_obj, start_time, end_time): @@ -658,10 +657,15 @@ class OpenTelemetry(CustomLogger): if not self.config.enable_events: return - from opentelemetry._logs import LogRecord, get_logger + from opentelemetry._logs import SeverityNumber, get_logger, get_logger_provider + from opentelemetry.sdk._logs import LogRecord as SdkLogRecord otel_logger = get_logger(LITELLM_LOGGER_NAME) + # Get the resource from the logger provider + logger_provider = get_logger_provider() + resource = getattr(logger_provider, '_resource', None) or _get_litellm_resource() + parent_ctx = span.get_span_context() provider = (kwargs.get("litellm_params") or {}).get( "custom_llm_provider", "Unknown" @@ -676,15 +680,18 @@ class OpenTelemetry(CustomLogger): if self.message_logging and msg.get("content"): attrs["gen_ai.prompt"] = msg["content"] - otel_logger.emit( - LogRecord( - attributes=attrs, - body=msg.copy(), - trace_id=parent_ctx.trace_id, - span_id=parent_ctx.span_id, - trace_flags=parent_ctx.trace_flags, - ) + log_record = SdkLogRecord( + timestamp=self._to_ns(datetime.now()), + trace_id=parent_ctx.trace_id, + span_id=parent_ctx.span_id, + trace_flags=parent_ctx.trace_flags, + severity_number=SeverityNumber.INFO, + severity_text="INFO", + body=msg.copy(), + resource=resource, + attributes=attrs, ) + otel_logger.emit(log_record) # per-choice events for idx, choice in enumerate(response_obj.get("choices", [])): @@ -705,15 +712,18 @@ class OpenTelemetry(CustomLogger): if self.message_logging and body_msg.get("content"): body["message"]["content"] = body_msg["content"] - otel_logger.emit( - LogRecord( - attributes=attrs, - body=body, - trace_id=parent_ctx.trace_id, - span_id=parent_ctx.span_id, - trace_flags=parent_ctx.trace_flags, - ) + log_record = SdkLogRecord( + timestamp=self._to_ns(datetime.now()), + trace_id=parent_ctx.trace_id, + span_id=parent_ctx.span_id, + trace_flags=parent_ctx.trace_flags, + severity_number=SeverityNumber.INFO, + severity_text="INFO", + body=body, + resource=resource, + attributes=attrs, ) + otel_logger.emit(log_record) def _create_guardrail_span( self, kwargs: Optional[dict], context: Optional[Context] @@ -1292,9 +1302,10 @@ class OpenTelemetry(CustomLogger): "OpenTelemetry: intiializing http exporter. Value of OTEL_EXPORTER: %s", self.OTEL_EXPORTER, ) + normalized_endpoint = self._normalize_otel_endpoint(self.OTEL_ENDPOINT, 'traces') return BatchSpanProcessor( OTLPSpanExporterHTTP( - endpoint=self.OTEL_ENDPOINT, headers=_split_otel_headers + endpoint=normalized_endpoint, headers=_split_otel_headers ), ) elif self.OTEL_EXPORTER == "otlp_grpc" or self.OTEL_EXPORTER == "grpc": @@ -1302,9 +1313,10 @@ class OpenTelemetry(CustomLogger): "OpenTelemetry: intiializing grpc exporter. Value of OTEL_EXPORTER: %s", self.OTEL_EXPORTER, ) + normalized_endpoint = self._normalize_otel_endpoint(self.OTEL_ENDPOINT, 'traces') return BatchSpanProcessor( OTLPSpanExporterGRPC( - endpoint=self.OTEL_ENDPOINT, headers=_split_otel_headers + endpoint=normalized_endpoint, headers=_split_otel_headers ), ) else: @@ -1314,6 +1326,145 @@ class OpenTelemetry(CustomLogger): ) return BatchSpanProcessor(ConsoleSpanExporter()) + def _get_log_exporter(self): + """ + Get the appropriate log exporter based on the configuration. + """ + verbose_logger.debug( + "OpenTelemetry Logger, initializing log exporter \nself.OTEL_EXPORTER: %s\nself.OTEL_ENDPOINT: %s\nself.OTEL_HEADERS: %s", + self.OTEL_EXPORTER, + self.OTEL_ENDPOINT, + self.OTEL_HEADERS, + ) + + _split_otel_headers = OpenTelemetry._get_headers_dictionary(self.OTEL_HEADERS) + + # Normalize endpoint for logs - ensure it points to /v1/logs instead of /v1/traces + normalized_endpoint = self._normalize_otel_endpoint(self.OTEL_ENDPOINT, 'logs') + + verbose_logger.debug( + "OpenTelemetry: Log endpoint normalized from %s to %s", + self.OTEL_ENDPOINT, + normalized_endpoint, + ) + + if hasattr(self.OTEL_EXPORTER, "export"): + # Custom exporter provided + verbose_logger.debug( + "OpenTelemetry: Using custom log exporter. Value of OTEL_EXPORTER: %s", + self.OTEL_EXPORTER, + ) + return self.OTEL_EXPORTER + + if self.OTEL_EXPORTER == "console": + from opentelemetry.sdk._logs.export import ConsoleLogExporter + verbose_logger.debug( + "OpenTelemetry: Using console log exporter. Value of OTEL_EXPORTER: %s", + self.OTEL_EXPORTER, + ) + return ConsoleLogExporter() + elif ( + self.OTEL_EXPORTER == "otlp_http" + or self.OTEL_EXPORTER == "http/protobuf" + or self.OTEL_EXPORTER == "http/json" + ): + from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter + verbose_logger.debug( + "OpenTelemetry: Using HTTP log exporter. Value of OTEL_EXPORTER: %s, endpoint: %s", + self.OTEL_EXPORTER, + normalized_endpoint, + ) + return OTLPLogExporter( + endpoint=normalized_endpoint, headers=_split_otel_headers + ) + elif self.OTEL_EXPORTER == "otlp_grpc" or self.OTEL_EXPORTER == "grpc": + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter + verbose_logger.debug( + "OpenTelemetry: Using gRPC log exporter. Value of OTEL_EXPORTER: %s, endpoint: %s", + self.OTEL_EXPORTER, + normalized_endpoint, + ) + return OTLPLogExporter( + endpoint=normalized_endpoint, headers=_split_otel_headers + ) + else: + verbose_logger.warning( + "OpenTelemetry: Unknown log exporter '%s', defaulting to console. Supported: console, otlp_http, otlp_grpc", + self.OTEL_EXPORTER, + ) + from opentelemetry.sdk._logs.export import ConsoleLogExporter + return ConsoleLogExporter() + + def _normalize_otel_endpoint( + self, + endpoint: Optional[str], + signal_type: str + ) -> Optional[str]: + """ + Normalize the endpoint URL for a specific OpenTelemetry signal type. + + The OTLP exporters expect endpoints to use signal-specific paths: + - traces: /v1/traces + - metrics: /v1/metrics + - logs: /v1/logs + + This method ensures the endpoint has the correct path for the given signal type. + + Args: + endpoint: The endpoint URL to normalize + signal_type: The telemetry signal type ('traces', 'metrics', or 'logs') + + Returns: + Normalized endpoint URL with the correct signal path + + Examples: + _normalize_otel_endpoint("http://collector:4318/v1/traces", "logs") + -> "http://collector:4318/v1/logs" + + _normalize_otel_endpoint("http://collector:4318", "traces") + -> "http://collector:4318/v1/traces" + + _normalize_otel_endpoint("http://collector:4318/v1/logs", "metrics") + -> "http://collector:4318/v1/metrics" + """ + if not endpoint: + return endpoint + + # Validate signal_type + valid_signals = {'traces', 'metrics', 'logs'} + if signal_type not in valid_signals: + verbose_logger.warning( + "Invalid signal_type '%s' provided to _normalize_otel_endpoint. " + "Valid values: %s. Returning endpoint unchanged.", + signal_type, + valid_signals + ) + return endpoint + + # Remove trailing slash + endpoint = endpoint.rstrip('/') + + # Check if endpoint already ends with the correct signal path + target_path = f'/v1/{signal_type}' + if endpoint.endswith(target_path): + return endpoint + + # Replace existing signal path with the target signal path + other_signals = valid_signals - {signal_type} + for other_signal in other_signals: + other_path = f'/v1/{other_signal}' + if endpoint.endswith(other_path): + endpoint = endpoint.rsplit('/', 1)[0] + f'/{signal_type}' + return endpoint + + # No existing signal path found, append the target path + if not endpoint.endswith('/v1'): + endpoint = endpoint + target_path + else: + endpoint = endpoint + f'/{signal_type}' + + return endpoint + @staticmethod def _get_headers_dictionary(headers: Optional[Union[str, dict]]) -> Dict[str, str]: """ diff --git a/litellm/litellm_core_utils/health_check_helpers.py b/litellm/litellm_core_utils/health_check_helpers.py index 2f412479937..9cbee7fc70d 100644 --- a/litellm/litellm_core_utils/health_check_helpers.py +++ b/litellm/litellm_core_utils/health_check_helpers.py @@ -2,11 +2,14 @@ Helper functions for health check calls. """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable, Dict, Literal, Optional if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging +# Minimal PDF for health checks - base64 encoded 1-page PDF with just "test" +TEST_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMSAwIFIKL01lZGlhQm94IFswIDAgNjEyIDc5Ml0KL0NvbnRlbnRzIDQgMCBSCi9SZXNvdXJjZXMgPDwvRm9udCA8PC9GMSAyIDAgUj4+Pj4+PgplbmRvYmoKNCAwIG9iago8PC9MZW5ndGggNDQ+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjEwMCA3MDAgVGQKKHRlc3QpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKMiAwIG9iago8PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYT4+CmVuZG9iagoxIDAgb2JqCjw8L1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDE+PgplbmRvYmoKNSAwIG9iago8PC9UeXBlIC9DYXRhbG9nCi9QYWdlcyAxIDAgUj4+CmVuZG9iagp0cmFpbGVyCjw8L1NpemUgNgovUm9vdCA1IDAgUj4+CnN0YXJ0eHJlZgozMjQKJSVFT0Y=" + class HealthCheckHelpers: @@ -78,3 +81,108 @@ class HealthCheckHelpers: return { "tags": [LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME], } + + @staticmethod + def get_mode_handlers( + model: str, + custom_llm_provider: str, + model_params: dict, + prompt: Optional[str] = None, + input: Optional[list] = None, + ) -> Dict[ + Literal[ + "chat", + "completion", + "embedding", + "audio_speech", + "audio_transcription", + "image_generation", + "rerank", + "realtime", + "batch", + "responses", + "ocr", + ], + Callable, + ]: + """ + Returns a dictionary of mode handlers for health check calls. + + Mode Handlers are Callables that need to be run for execution of the health check call. + + Args: + model: The model name + custom_llm_provider: The LLM provider + model_params: The model parameters + prompt: Optional prompt for health check + input: Optional input for health check + + Returns: + Dictionary mapping mode names to their handler functions + """ + import litellm + from litellm.litellm_core_utils.audio_utils.utils import ( + get_audio_file_for_health_check, + ) + from litellm.litellm_core_utils.health_check_utils import _filter_model_params + from litellm.realtime_api.main import _realtime_health_check + + return { + "chat": lambda: litellm.acompletion( + **model_params, + ), + "completion": lambda: litellm.atext_completion( + **_filter_model_params(model_params=model_params), + prompt=prompt or "test", + ), + "embedding": lambda: litellm.aembedding( + **_filter_model_params(model_params=model_params), + input=input or ["test"], + ), + "audio_speech": lambda: litellm.aspeech( + **{ + **_filter_model_params(model_params=model_params), + **( + {"voice": "alloy"} + if "voice" + not in _filter_model_params(model_params=model_params) + else {} + ), + }, + input=prompt or "test", + ), + "audio_transcription": lambda: litellm.atranscription( + **_filter_model_params(model_params=model_params), + file=get_audio_file_for_health_check(), + ), + "image_generation": lambda: litellm.aimage_generation( + **_filter_model_params(model_params=model_params), + prompt=prompt, + ), + "rerank": lambda: litellm.arerank( + **_filter_model_params(model_params=model_params), + query=prompt or "", + documents=["my sample text"], + ), + "realtime": lambda: _realtime_health_check( + model=model, + custom_llm_provider=custom_llm_provider, + api_base=model_params.get("api_base", None), + api_key=model_params.get("api_key", None), + api_version=model_params.get("api_version", None), + ), + "batch": lambda: litellm.alist_batches( + **_filter_model_params(model_params=model_params), + ), + "responses": lambda: litellm.aresponses( + **_filter_model_params(model_params=model_params), + input=prompt or "test", + ), + "ocr": lambda: litellm.aocr( + **_filter_model_params(model_params=model_params), + document={ + "type": "document_url", + "document_url": TEST_PDF_URL, + }, + ), + } \ No newline at end of file diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 773ff29e371..8e042ef0d79 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1197,7 +1197,7 @@ class Logging(LiteLLMLoggingBaseClass): total_cost=total_cost, tool_usage_cost=cost_for_built_in_tools_cost_usd_dollar, ) - + # Store discount information if provided if original_cost is not None: self.cost_breakdown["original_cost"] = original_cost @@ -1205,7 +1205,7 @@ class Logging(LiteLLMLoggingBaseClass): self.cost_breakdown["discount_percent"] = discount_percent if discount_amount is not None: self.cost_breakdown["discount_amount"] = discount_amount - + def _response_cost_calculator( @@ -3103,7 +3103,7 @@ def _get_masked_values( ( v[: unmasked_length // 2] + "*" * number_of_asterisks - + v[-unmasked_length // 2 :] + + v[-unmasked_length // 2:] ) if ( isinstance(v, str) @@ -3114,7 +3114,7 @@ def _get_masked_values( ( v[: unmasked_length // 2] + "*" * (len(v) - unmasked_length) - + v[-unmasked_length // 2 :] + + v[-unmasked_length // 2:] ) if (isinstance(v, str) and len(v) > unmasked_length) else ("*****" if isinstance(v, str) else v) @@ -3165,6 +3165,7 @@ def set_callbacks(callback_list, function_id=None): # noqa: PLR0915 event_scrubber=EventScrubber( denylist=SENTRY_DENYLIST, pii_denylist=SENTRY_PII_DENYLIST ), + environment=os.environ.get("SENTRY_ENVIRONMENT", "production"), ) capture_exception = sentry_sdk_instance.capture_exception add_breadcrumb = sentry_sdk_instance.add_breadcrumb @@ -4280,8 +4281,8 @@ class StandardLoggingPayloadSetup: from litellm.integrations.s3 import get_s3_object_key # Only generate object key if cold storage is configured - configured_cold_storage_logger = litellm.configured_cold_storage_logger - if configured_cold_storage_logger is None: + cold_storage_custom_logger = litellm.cold_storage_custom_logger + if cold_storage_custom_logger is None: return None try: @@ -4294,7 +4295,7 @@ class StandardLoggingPayloadSetup: # Try to get the actual logger instance from the logger name try: custom_logger = litellm.logging_callback_manager.get_active_custom_logger_for_callback_name( - configured_cold_storage_logger + cold_storage_custom_logger ) if ( custom_logger @@ -4471,12 +4472,12 @@ def _get_status_fields( ) -> "StandardLoggingPayloadStatusFields": """ Determine status fields based on request status and guardrail information. - + Args: status: Overall request status ("success" or "failure") guardrail_information: Guardrail information from metadata error_str: Error string if any - + Returns: StandardLoggingPayloadStatusFields with llm_api_status and guardrail_status """ @@ -4489,10 +4490,10 @@ def _get_status_fields( "guardrail_failed_to_respond": "guardrail_failed_to_respond", # direct "not_run": "not_run" } - + # Set LLM API status llm_api_status: StandardLoggingPayloadStatus = status - + ######################################################### # Map - guardrail_information.guardrail_status to guardrail_status diff --git a/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py b/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py index b6113661777..7d3af4ad2f8 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py +++ b/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py @@ -314,9 +314,23 @@ class StandardBuiltInToolCostTracking: if isinstance(response_object, ModelResponse): # chat completions only include url_citation annotations when a web search call is made - return StandardBuiltInToolCostTracking.response_includes_annotation_type( + has_url_citations = StandardBuiltInToolCostTracking.response_includes_annotation_type( response_object=response_object, annotation_type="url_citation" ) + if has_url_citations: + return True + # Fallback: Check usage object for providers that use usage instead of annotations + # (e.g., Vertex AI Gemini uses usage.prompt_tokens_details.web_search_requests) + if usage is not None: + if ( + hasattr(usage, "prompt_tokens_details") + and usage.prompt_tokens_details is not None + and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper) + and hasattr(usage.prompt_tokens_details, "web_search_requests") + and usage.prompt_tokens_details.web_search_requests is not None + ): + return True + return False elif isinstance(response_object, ResponsesAPIResponse): # response api explicitly includes web_search_call in the output return StandardBuiltInToolCostTracking.response_includes_output_type( diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index 1516ed089ee..d621cb209d7 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -50,8 +50,6 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): try: # Ensure required fields are present for ResponseReasoningItem item_data = dict(item) - if "id" not in item_data: - item_data["id"] = f"rs_{hash(str(item_data))}" if "summary" not in item_data: item_data["summary"] = ( item_data.get("reasoning_content", "")[:100] + "..." diff --git a/litellm/llms/azure/text_to_speech/transformation.py b/litellm/llms/azure/text_to_speech/transformation.py index cfabd43ea29..0f8911ac2b8 100644 --- a/litellm/llms/azure/text_to_speech/transformation.py +++ b/litellm/llms/azure/text_to_speech/transformation.py @@ -4,7 +4,7 @@ Azure AVA (Cognitive Services) Text-to-Speech transformation Maps OpenAI TTS spec to Azure Cognitive Services TTS API """ -from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Union +from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Tuple, Union from urllib.parse import urlparse import httpx @@ -32,6 +32,7 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig): """ # Azure endpoint domains + DEFAULT_VOICE = "en-US-AriaNeural" COGNITIVE_SERVICES_DOMAIN = "api.cognitive.microsoft.com" TTS_SPEECH_DOMAIN = "tts.speech.microsoft.com" TTS_ENDPOINT_PATH = "/cognitiveservices/v1" @@ -134,6 +135,9 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig): def get_supported_openai_params(self, model: str) -> list: """ Azure AVA TTS supports these OpenAI parameters + + Note: Azure also supports additional SSML-specific parameters (style, styledegree, role) + which can be passed but are not part of the OpenAI spec """ return ["voice", "response_format", "speed"] @@ -154,28 +158,93 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig): """ rate_percentage = int((speed - 1.0) * 100) return f"{rate_percentage:+d}%" + + def _build_express_as_element( + self, + content: str, + style: Optional[str] = None, + styledegree: Optional[str] = None, + role: Optional[str] = None, + ) -> str: + """ + Build mstts:express-as element with optional style, styledegree, and role attributes + + Args: + content: The inner content to wrap + style: Speaking style (e.g., "cheerful", "sad", "angry") + styledegree: Style intensity (0.01 to 2) + role: Voice role (e.g., "Girl", "Boy", "SeniorFemale", "SeniorMale") + + Returns: + Content wrapped in mstts:express-as if any attributes provided, otherwise raw content + """ + if not (style or styledegree or role): + return content + + express_as_attrs = [] + if style: + express_as_attrs.append(f"style='{style}'") + if styledegree: + express_as_attrs.append(f"styledegree='{styledegree}'") + if role: + express_as_attrs.append(f"role='{role}'") + + express_as_attrs_str = " ".join(express_as_attrs) + return f"{content}" + + def _get_voice_language( + self, + voice_name: Optional[str], + explicit_lang: Optional[str] = None, + ) -> Optional[str]: + """ + Get the language for the voice element's xml:lang attribute + + Args: + voice_name: The Azure voice name (e.g., "en-US-AriaNeural") + explicit_lang: Explicitly provided language code (takes precedence) + + Returns: + Language code if available (e.g., "es-ES"), or None + + Examples: + - explicit_lang="es-ES" → "es-ES" (explicit takes precedence) + - voice_name="en-US-AriaNeural", explicit_lang=None → None (use default from voice) + - voice_name="en-US-AvaMultilingualNeural", explicit_lang="fr-FR" → "fr-FR" + """ + # If explicit language is provided, use it (for multilingual voices) + if explicit_lang: + return explicit_lang + + # For non-multilingual voices, we don't need to set xml:lang on the voice element + # The voice name already encodes the language (e.g., en-US-AriaNeural) + # Only return a language if explicitly set + return None def map_openai_params( self, model: str, optional_params: Dict, - drop_params: bool, - ) -> Dict: + voice: Optional[Union[str, Dict]] = None, + drop_params: bool = False, + kwargs: Dict = {}, + ) -> Tuple[Optional[str], Dict]: """ Map OpenAI parameters to Azure AVA TTS parameters """ mapped_params = {} - + ########################################################## # Map voice - if "voice" in optional_params: - voice = optional_params["voice"] - # If it's already an Azure voice, use it directly - if isinstance(voice, str): - if voice in self.VOICE_MAPPINGS: - mapped_params["voice"] = self.VOICE_MAPPINGS[voice] - else: - # Assume it's already an Azure voice name - mapped_params["voice"] = voice + # OpenAI uses voice as a required param, hence not in optional_params + ########################################################## + # If it's already an Azure voice, use it directly + mapped_voice: Optional[str] = None + if isinstance(voice, str): + if voice in self.VOICE_MAPPINGS: + mapped_voice = self.VOICE_MAPPINGS[voice] + else: + # Assume it's already an Azure voice name + mapped_voice = voice # Map response format if "response_format" in optional_params: @@ -195,7 +264,19 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig): if speed is not None: mapped_params["rate"] = self._convert_speed_to_azure_rate(speed=speed) - return mapped_params + # Pass through Azure-specific SSML parameters + if "style" in kwargs: + mapped_params["style"] = kwargs["style"] + + if "styledegree" in kwargs: + mapped_params["styledegree"] = kwargs["styledegree"] + + if "role" in kwargs: + mapped_params["role"] = kwargs["role"] + + if "lang" in kwargs: + mapped_params["lang"] = kwargs["lang"] + return mapped_voice, mapped_params def validate_environment( self, @@ -315,11 +396,17 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig): Note: optional_params should already be mapped via map_openai_params in main.py + Supports Azure-specific SSML features: + - style: Speaking style (e.g., "cheerful", "sad", "angry") + - styledegree: Style intensity (0.01 to 2) + - role: Voice role (e.g., "Girl", "Boy", "SeniorFemale", "SeniorMale") + - lang: Language code for multilingual voices (e.g., "es-ES", "fr-FR") + Returns: TextToSpeechRequestData: Contains SSML body and Azure-specific headers """ # Get voice (already mapped in main.py, or use default) - azure_voice = optional_params.get("voice", "en-US-AriaNeural") + azure_voice = voice or self.DEFAULT_VOICE # Get output format (already mapped in main.py) output_format = optional_params.get( @@ -329,6 +416,10 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig): # Build SSML rate = optional_params.get("rate", "0%") + style = optional_params.get("style") + styledegree = optional_params.get("styledegree") + role = optional_params.get("role") + lang = optional_params.get("lang") # Escape XML special characters in input text escaped_input = ( @@ -339,15 +430,38 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig): .replace("'", "'") ) - ssml_body = f""" - - - - {escaped_input} - - - - """ + # Determine if we need mstts namespace (for express-as element) + use_mstts = style or role or styledegree + + # Build the xmlns attributes + if use_mstts: + xmlns = "xmlns='http://www.w3.org/2001/10/synthesis' xmlns:mstts='https://www.w3.org/2001/mstts'" + else: + xmlns = "xmlns='http://www.w3.org/2001/10/synthesis'" + + # Build the inner content with prosody + prosody_content = f"{escaped_input}" + + # Wrap in mstts:express-as if style or role is specified + voice_content = self._build_express_as_element( + content=prosody_content, + style=style, + styledegree=styledegree, + role=role, + ) + + # Build voice element with optional xml:lang attribute + voice_lang = self._get_voice_language( + voice_name=azure_voice, + explicit_lang=lang, + ) + voice_lang_attr = f" xml:lang='{voice_lang}'" if voice_lang else "" + + ssml_body = f""" + + {voice_content} + +""" return { "ssml_body": ssml_body, diff --git a/litellm/llms/base_llm/search/__init__.py b/litellm/llms/base_llm/search/__init__.py new file mode 100644 index 00000000000..5a46482ed43 --- /dev/null +++ b/litellm/llms/base_llm/search/__init__.py @@ -0,0 +1,15 @@ +""" +Base Search API module. +""" +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) + +__all__ = [ + "BaseSearchConfig", + "SearchResponse", + "SearchResult", +] + diff --git a/litellm/llms/base_llm/search/transformation.py b/litellm/llms/base_llm/search/transformation.py new file mode 100644 index 00000000000..14941911f17 --- /dev/null +++ b/litellm/llms/base_llm/search/transformation.py @@ -0,0 +1,169 @@ +""" +Base Search transformation configuration. +""" +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union + +import httpx +from pydantic import PrivateAttr + +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.types.llms.base import LiteLLMPydanticObjectBase + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + + +class SearchResult(LiteLLMPydanticObjectBase): + """Single search result.""" + title: str + url: str + snippet: str + date: Optional[str] = None + last_updated: Optional[str] = None + + model_config = {"extra": "allow"} + + +class SearchResponse(LiteLLMPydanticObjectBase): + """ + Standard Search response format. + Standardized to Perplexity Search format - other providers should transform to this format. + """ + results: List[SearchResult] + object: str = "search" + + model_config = {"extra": "allow"} + + # Define private attributes using PrivateAttr + _hidden_params: dict = PrivateAttr(default_factory=dict) + + +class BaseSearchConfig: + """ + Base configuration for Search transformations. + Handles provider-agnostic Search operations. + """ + + def __init__(self) -> None: + pass + + @staticmethod + def ui_friendly_name() -> str: + """ + UI-friendly name for the search provider. + Override in provider-specific implementations. + """ + return "Unknown Search Provider" + + def get_http_method(self) -> Literal["GET", "POST"]: + """ + Get HTTP method for search requests. + Override in provider-specific implementations if needed. + + Returns: + HTTP method ('GET' or 'POST'). Default is 'POST'. + """ + return "POST" + + @staticmethod + def get_supported_perplexity_optional_params() -> set: + """ + Get the set of Perplexity unified search parameters. + These are the standard parameters that providers should transform from. + + Returns: + Set of parameter names that are part of the unified spec + """ + return { + "max_results", + "search_domain_filter", + "country", + "max_tokens_per_page", + } + + def validate_environment( + self, + headers: Dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, + ) -> Dict: + """ + Validate environment and return headers. + Override in provider-specific implementations. + """ + return headers + + def get_complete_url( + self, + api_base: Optional[str], + optional_params: dict, + data: Optional[Union[Dict, List[Dict]]] = None, + **kwargs, + ) -> str: + """ + Get complete URL for Search endpoint. + + Args: + api_base: Base URL for the API + optional_params: Optional parameters for the request + data: Transformed request body from transform_search_request(). + Some providers (e.g., Google PSE) use GET requests and need + the request body to construct query parameters in the URL. + Can be a dict or list of dicts depending on provider. + **kwargs: Additional keyword arguments + + Returns: + Complete URL for the search endpoint + + Note: + Override in provider-specific implementations. + """ + raise NotImplementedError("get_complete_url must be implemented by provider") + + def transform_search_request( + self, + query: Union[str, List[str]], + optional_params: dict, + **kwargs, + ) -> Union[Dict, List[Dict]]: + """ + Transform Search request to provider-specific format. + Override in provider-specific implementations. + + Args: + query: Search query (string or list of strings) + optional_params: Optional parameters for the request + + Returns: + Dict with request data + """ + raise NotImplementedError("transform_search_request must be implemented by provider") + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs, + ) -> SearchResponse: + """ + Transform provider-specific Search response to standard format. + Override in provider-specific implementations. + """ + raise NotImplementedError("transform_search_response must be implemented by provider") + + def get_error_class( + self, + error_message: str, + status_code: int, + headers: dict, + ) -> Exception: + """Get appropriate error class for the provider.""" + return BaseLLMException( + status_code=status_code, + message=error_message, + headers=headers, + ) + diff --git a/litellm/llms/base_llm/text_to_speech/transformation.py b/litellm/llms/base_llm/text_to_speech/transformation.py index 88211337047..31f581cec0f 100644 --- a/litellm/llms/base_llm/text_to_speech/transformation.py +++ b/litellm/llms/base_llm/text_to_speech/transformation.py @@ -1,6 +1,6 @@ import types from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Dict, Optional, TypedDict +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, TypedDict, Union import httpx @@ -68,8 +68,10 @@ class BaseTextToSpeechConfig(ABC): self, model: str, optional_params: Dict, - drop_params: bool, - ) -> Dict: + voice: Optional[Union[str, Dict]] = None, + drop_params: bool = False, + kwargs: Dict = {}, + ) -> Tuple[Optional[str], Dict]: """ Map OpenAI TTS parameters to provider-specific parameters """ diff --git a/litellm/llms/cohere/embed/v1_transformation.py b/litellm/llms/cohere/embed/v1_transformation.py index e55899a4afa..1a4bc393e84 100644 --- a/litellm/llms/cohere/embed/v1_transformation.py +++ b/litellm/llms/cohere/embed/v1_transformation.py @@ -123,10 +123,23 @@ class CohereEmbeddingConfig: """ embeddings = response_json["embeddings"] output_data = [] - for idx, embedding in enumerate(embeddings): - output_data.append( - {"object": "embedding", "index": idx, "embedding": embedding} - ) + is_embeddings_by_type = response_json.get("response_type") == "embeddings_by_type" + if is_embeddings_by_type: + for embedding_type in embeddings: + for idx, embedding in enumerate(embeddings[embedding_type]): + output_data.append( + { + "object": "embedding", + "index": idx, + "embedding": embedding, + "type": embedding_type, + } + ) + else: + for idx, embedding in enumerate(embeddings): + output_data.append( + {"object": "embedding", "index": idx, "embedding": embedding} + ) model_response.object = "list" model_response.data = output_data model_response.model = model diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 28fb5f0269e..56e5f0c948c 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -44,9 +44,8 @@ from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig, OCRResponse from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig from litellm.llms.base_llm.rerank.transformation import BaseRerankConfig from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig -from litellm.llms.base_llm.text_to_speech.transformation import ( - BaseTextToSpeechConfig, -) +from litellm.llms.base_llm.search.transformation import BaseSearchConfig, SearchResponse +from litellm.llms.base_llm.text_to_speech.transformation import BaseTextToSpeechConfig from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, @@ -1309,8 +1308,10 @@ class BaseLLMHTTPHandler: # Data is always a dict for Mistral OCR format if not isinstance(transformed_result.data, dict): - raise ValueError(f"Expected dict data for OCR request, got {type(transformed_result.data)}") - + raise ValueError( + f"Expected dict data for OCR request, got {type(transformed_result.data)}" + ) + data = transformed_result.data ## LOGGING @@ -1373,8 +1374,10 @@ class BaseLLMHTTPHandler: # Data is always a dict for Mistral OCR format if not isinstance(transformed_result.data, dict): - raise ValueError(f"Expected dict data for OCR request, got {type(transformed_result.data)}") - + raise ValueError( + f"Expected dict data for OCR request, got {type(transformed_result.data)}" + ) + data = transformed_result.data ## LOGGING @@ -1545,6 +1548,193 @@ class BaseLLMHTTPHandler: logging_obj=logging_obj, ) + def search( + self, + query: Union[str, List[str]], + optional_params: dict, + timeout: Union[float, httpx.Timeout], + logging_obj: LiteLLMLoggingObj, + api_key: Optional[str], + api_base: Optional[str], + custom_llm_provider: str, + client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None, + asearch: bool = False, + headers: Optional[Dict[str, Any]] = None, + provider_config: Optional[BaseSearchConfig] = None, + ) -> Union[SearchResponse, Coroutine[Any, Any, SearchResponse]]: + """ + Sync Search handler. + """ + if provider_config is None: + raise ValueError( + f"No provider config found for provider: {custom_llm_provider}" + ) + + if asearch is True: + return self.async_search( + query=query, + optional_params=optional_params, + timeout=timeout, + logging_obj=logging_obj, + api_key=api_key, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + client=client, + headers=headers, + provider_config=provider_config, + ) + + # Validate environment and get headers + headers = provider_config.validate_environment( + api_key=api_key, + api_base=api_base, + headers=headers or {}, + ) + + + + # Transform the request + data = provider_config.transform_search_request( + query=query, + optional_params=optional_params, + ) + + # Get complete URL (pass data for providers that need request body for URL construction) + complete_url = provider_config.get_complete_url( + api_base=api_base, + optional_params=optional_params, + data=data, + ) + + ## LOGGING + logging_obj.pre_call( + input=query if isinstance(query, str) else str(query), + api_key=api_key, + additional_args={ + "complete_input_dict": data, + "api_base": complete_url, + "headers": headers, + }, + ) + + if client is None or not isinstance(client, HTTPHandler): + client = _get_httpx_client() + + # Check HTTP method from provider config + http_method = provider_config.get_http_method() + + try: + if http_method == "GET": + # Make GET request (URL already contains query params from get_complete_url) + # Note: timeout is set on the client itself, not per-request for GET + response = client.get( + url=complete_url, + headers=headers, + ) + else: + # Make POST request with JSON data + response = client.post( + url=complete_url, + headers=headers, + json=data, + timeout=timeout, + ) + except Exception as e: + raise self._handle_error(e=e, provider_config=provider_config) + + return provider_config.transform_search_response( + raw_response=response, + logging_obj=logging_obj, + ) + + async def async_search( + self, + query: Union[str, List[str]], + optional_params: dict, + timeout: Union[float, httpx.Timeout], + logging_obj: LiteLLMLoggingObj, + api_key: Optional[str], + api_base: Optional[str], + custom_llm_provider: str, + client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None, + headers: Optional[Dict[str, Any]] = None, + provider_config: Optional[BaseSearchConfig] = None, + ) -> SearchResponse: + """ + Async Search handler. + """ + if provider_config is None: + raise ValueError( + f"No provider config found for provider: {custom_llm_provider}" + ) + + # Validate environment and get headers + headers = provider_config.validate_environment( + api_key=api_key, + api_base=api_base, + headers=headers or {}, + ) + + # Transform the request first + data = provider_config.transform_search_request( + query=query, + optional_params=optional_params, + ) + + # Get complete URL (pass data for providers that need request body for URL construction) + complete_url = provider_config.get_complete_url( + api_base=api_base, + optional_params=optional_params, + data=data, + ) + + ## LOGGING + logging_obj.pre_call( + input=query if isinstance(query, str) else str(query), + api_key=api_key, + additional_args={ + "complete_input_dict": data, + "api_base": complete_url, + "headers": headers, + }, + ) + + if client is None or not isinstance(client, AsyncHTTPHandler): + # For search providers, use special Search provider type + from litellm.types.llms.custom_http import httpxSpecialProvider + async_httpx_client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.Search + ) + else: + async_httpx_client = client + + # Check HTTP method from provider config + http_method = provider_config.get_http_method().upper() + + try: + if http_method == "GET": + # Make async GET request (URL already contains query params from get_complete_url) + # Note: timeout is set on the client itself, not per-request for GET + response = await async_httpx_client.get( + url=complete_url, + headers=headers, + ) + else: + # Make async POST request with JSON data + response = await async_httpx_client.post( + url=complete_url, + headers=headers, + json=data, + timeout=timeout, + ) + except Exception as e: + raise self._handle_error(e=e, provider_config=provider_config) + + return provider_config.transform_search_response( + raw_response=response, + logging_obj=logging_obj, + ) + async def async_anthropic_messages_handler( self, model: str, @@ -3285,6 +3475,7 @@ class BaseLLMHTTPHandler: BaseAnthropicMessagesConfig, BaseBatchesConfig, BaseOCRConfig, + BaseSearchConfig, BaseTextToSpeechConfig, "BasePassthroughConfig", ], diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index a1370074238..dd136c54264 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -43,6 +43,7 @@ from litellm.types.llms.openai import ( ChatCompletionThinkingBlock, ChatCompletionToolChoiceFunctionParam, ChatCompletionToolChoiceObjectParam, + ChatCompletionToolParam, ) from litellm.types.utils import ( ChatCompletionMessageToolCall, @@ -217,6 +218,21 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): databricks_tool = self.convert_anthropic_tool_to_databricks_tool(tool) return databricks_tool + def remove_cache_control_flag_from_messages_and_tools( + self, + model: str, # allows overrides to selectively run this + messages: List[AllMessageValues], + tools: Optional[List["ChatCompletionToolParam"]] = None, + ) -> Tuple[List[AllMessageValues], Optional[List["ChatCompletionToolParam"]]]: + """ + Override the parent class method to preserve cache_control for models on Databricks. + Databricks supports Anthropic-style cache control for Claude models. + Databricks ignores the cache_control flag with other models. + """ + # TODO: Think about how to best design the request transformation so that + # every request doesn't have to be transformed for to OpenAI and Anthropic request formats. + return messages, tools + def map_openai_params( self, non_default_params: dict, diff --git a/litellm/llms/dataforseo/search/__init__.py b/litellm/llms/dataforseo/search/__init__.py new file mode 100644 index 00000000000..28990c1af3e --- /dev/null +++ b/litellm/llms/dataforseo/search/__init__.py @@ -0,0 +1,11 @@ +""" +DataForSEO Search Module + +This module provides search functionality using DataForSEO's SERP API. +DataForSEO offers comprehensive search engine data with high accuracy. +""" + +from .transformation import DataForSEOSearchConfig + +__all__ = ["DataForSEOSearchConfig"] + diff --git a/litellm/llms/dataforseo/search/transformation.py b/litellm/llms/dataforseo/search/transformation.py new file mode 100644 index 00000000000..86b472f61b8 --- /dev/null +++ b/litellm/llms/dataforseo/search/transformation.py @@ -0,0 +1,209 @@ +""" +Calls DataForSEO SERP API to search the web. + +DataForSEO API Reference: https://docs.dataforseo.com/v3/serp/google/organic/live/advanced/?bash +""" +from typing import Any, Dict, List, Literal, Optional, Union + +import httpx + +from litellm.constants import DEFAULT_DATAFORSEO_LOCATION_CODE +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) +from litellm.secret_managers.main import get_secret_str + + +class DataForSEOSearchConfig(BaseSearchConfig): + """ + Configuration for DataForSEO SERP API search. + + DataForSEO uses HTTP Basic Auth with login:password credentials. + API endpoint: https://api.dataforseo.com/v3/serp/google/organic/live/advanced + """ + + DATAFORSEO_API_BASE = "https://api.dataforseo.com/v3/serp/google/organic/live/advanced" + + @staticmethod + def ui_friendly_name() -> str: + return "DataForSEO" + + def get_http_method(self) -> Literal["GET", "POST"]: + """ + DataForSEO uses POST requests with JSON body. + """ + return "POST" + + def validate_environment( + self, + headers: Dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, + ) -> Dict: + """ + Validate DataForSEO environment and set up authentication. + + DataForSEO uses HTTP Basic Auth with login:password format. + The credentials should be in DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD env vars, + or passed as api_key in "login:password" format. + """ + import base64 + + # Get login and password + login = get_secret_str("DATAFORSEO_LOGIN") + password = get_secret_str("DATAFORSEO_PASSWORD") + + # If api_key is provided in "login:password" format, use it + if api_key and ":" in api_key: + login, password = api_key.split(":", 1) + + if not login: + raise ValueError("DATAFORSEO_LOGIN is not set. Set `DATAFORSEO_LOGIN` environment variable or pass credentials in api_key parameter.") + + if not password: + raise ValueError("DATAFORSEO_PASSWORD is not set. Set `DATAFORSEO_PASSWORD` environment variable or pass credentials in api_key parameter.") + + # Create Basic Auth header + credentials = f"{login}:{password}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers["Authorization"] = f"Basic {encoded_credentials}" + headers["Content-Type"] = "application/json" + + return headers + + def get_complete_url( + self, + api_base: Optional[str], + optional_params: dict, + data: Optional[Union[Dict, List[Dict]]] = None, + **kwargs, + ) -> str: + """ + Get complete URL for DataForSEO SERP API endpoint. + + DataForSEO uses POST requests, so no query parameters in URL. + """ + return api_base or get_secret_str("DATAFORSEO_API_BASE") or self.DATAFORSEO_API_BASE + + def transform_search_request( + self, + query: Union[str, List[str]], + optional_params: dict, + api_key: Optional[str] = None, + **kwargs, + ) -> Union[Dict, List[Dict]]: + """ + Transform Search request to DataForSEO SERP API format. + + Args: + query: Search query (string or list of strings). DataForSEO supports single string queries. + optional_params: Optional parameters for the request + - max_results: Maximum number of search results → maps to `depth` (max 700) + - country: Country name → maps to `location_name` + - search_domain_filter: Domain to filter results → maps to `domain` + - Plus any DataForSEO-specific parameters (location_code, language_code, device, os, etc.) + api_key: DataForSEO credentials (login:password format) + + Returns: + List[Dict]: Request body for DataForSEO API (array of task objects as required by API) + """ + # DataForSEO expects an array of task objects + task: Dict[str, Any] = {} + + # Convert query to string if it's a list + if isinstance(query, list): + query = query[0] if query else "" + + # Required field: keyword + task["keyword"] = query + + # Map unified parameters to DataForSEO parameters + if "max_results" in optional_params and optional_params["max_results"]: + # DataForSEO uses 'depth' for number of results (max 700) + depth = min(int(optional_params["max_results"]), 700) + task["depth"] = depth + + if "country" in optional_params and optional_params["country"]: + # DataForSEO uses location_code (e.g., 2840 for USA) + # For simplicity, we'll use location_name which accepts country names + task["location_name"] = optional_params["country"] + + if "search_domain_filter" in optional_params and optional_params["search_domain_filter"]: + # DataForSEO uses 'domain' parameter to filter by domain + task["domain"] = optional_params["search_domain_filter"] + + # Add defaults if not specified + if "language_code" not in task and "language_name" not in task: + task["language_code"] = "en" + + # DataForSEO requires a location - use default from constants if not specified + if "location_code" not in task and "location_name" not in task: + task["location_code"] = DEFAULT_DATAFORSEO_LOCATION_CODE + + # Pass through all other parameters as-is + for param, value in optional_params.items(): + if param not in self.get_supported_perplexity_optional_params() and param not in task: + task[param] = value + + # DataForSEO API expects an array of tasks + return [task] + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs, + ) -> SearchResponse: + """ + Transform DataForSEO SERP API response to LiteLLM unified SearchResponse format. + + DataForSEO → LiteLLM mappings: + - tasks[0].result[*].items[*].title → SearchResult.title + - tasks[0].result[*].items[*].url → SearchResult.url + - tasks[0].result[*].items[*].description → SearchResult.snippet + - No date/last_updated fields in standard response (set to None) + + Args: + raw_response: Raw httpx response from DataForSEO API + logging_obj: Logging object for tracking + + Returns: + SearchResponse with standardized format + """ + response_json = raw_response.json() + + # Transform results to SearchResult objects + results = [] + + # DataForSEO wraps results in tasks array + if "tasks" in response_json and len(response_json["tasks"]) > 0: + task = response_json["tasks"][0] + + # Check if task was successful + if task.get("status_code") == 20000 and "result" in task: + # Result is an array, take first element + if len(task["result"]) > 0: + result = task["result"][0] + + # Items contain the actual search results + for item in result.get("items", []): + # Only process organic search results + if item.get("type") == "organic": + search_result = SearchResult( + title=item.get("title", ""), + url=item.get("url", ""), + snippet=item.get("description", ""), + date=None, # DataForSEO doesn't provide date in standard response + last_updated=None, + ) + results.append(search_result) + + return SearchResponse( + results=results, + object="search", + ) + diff --git a/litellm/llms/exa_ai/search/__init__.py b/litellm/llms/exa_ai/search/__init__.py new file mode 100644 index 00000000000..b647d2cd80f --- /dev/null +++ b/litellm/llms/exa_ai/search/__init__.py @@ -0,0 +1,7 @@ +""" +Exa AI Search API module. +""" +from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig + +__all__ = ["ExaAISearchConfig"] + diff --git a/litellm/llms/exa_ai/search/transformation.py b/litellm/llms/exa_ai/search/transformation.py new file mode 100644 index 00000000000..6b51c6cf25d --- /dev/null +++ b/litellm/llms/exa_ai/search/transformation.py @@ -0,0 +1,188 @@ +""" +Calls Exa AI's /search endpoint to search the web. + +Exa AI API Reference: https://docs.exa.ai/reference/search +""" +from typing import Dict, List, Optional, TypedDict, Union + +import httpx + +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) +from litellm.secret_managers.main import get_secret_str + + +class _ExaAISearchRequestRequired(TypedDict): + """Required fields for Exa AI Search API request.""" + query: str # Required - search query + + +class ExaAISearchRequest(_ExaAISearchRequestRequired, total=False): + """ + Exa AI Search API request format. + Based on: https://docs.exa.ai/reference/search + """ + type: str # Optional - search type ('keyword', 'neural', 'fast', 'auto'), default 'auto' + category: str # Optional - data category ('company', 'research paper', 'news', 'pdf', 'github', 'tweet', 'personal site', 'linkedin profile', 'financial report') + userLocation: str # Optional - two-letter ISO country code + numResults: int # Optional - number of results (max 100), default 10 + includeDomains: List[str] # Optional - list of domains to include + excludeDomains: List[str] # Optional - list of domains to exclude + startCrawlDate: str # Optional - crawl date filter (ISO 8601 format) + endCrawlDate: str # Optional - crawl date filter (ISO 8601 format) + startPublishedDate: str # Optional - published date filter (ISO 8601 format) + endPublishedDate: str # Optional - published date filter (ISO 8601 format) + includeText: List[str] # Optional - strings that must be present in webpage text + excludeText: List[str] # Optional - strings that must not be present in webpage text + context: Union[bool, dict] # Optional - format results for LLMs + moderation: bool # Optional - enable content moderation, default false + contents: dict # Optional - content retrieval options + + +class ExaAISearchConfig(BaseSearchConfig): + EXA_AI_API_BASE = "https://api.exa.ai" + + @staticmethod + def ui_friendly_name() -> str: + return "Exa AI" + + def validate_environment( + self, + headers: Dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, + ) -> Dict: + """ + Validate environment and return headers. + """ + api_key = api_key or get_secret_str("EXA_API_KEY") + if not api_key: + raise ValueError("EXA_API_KEY is not set. Set `EXA_API_KEY` environment variable.") + headers["x-api-key"] = api_key + headers["Content-Type"] = "application/json" + return headers + + def get_complete_url( + self, + api_base: Optional[str], + optional_params: dict, + data: Optional[Union[Dict, List[Dict]]] = None, + **kwargs, + ) -> str: + """ + Get complete URL for Search endpoint. + """ + api_base = api_base or get_secret_str("EXA_API_BASE") or self.EXA_AI_API_BASE + + # Append "/search" to the api base if it's not already there + if not api_base.endswith("/search"): + api_base = f"{api_base}/search" + + return api_base + + + def transform_search_request( + self, + query: Union[str, List[str]], + optional_params: dict, + **kwargs, + ) -> Dict: + """ + Transform Search request to Exa AI API format. + + Transforms Perplexity unified spec parameters: + - query → query (same) + - max_results → numResults + - search_domain_filter → includeDomains + - country → userLocation + - max_tokens_per_page → (not applicable, ignored) + + All other Exa-specific parameters are passed through as-is. + + Args: + query: Search query (string or list of strings). Exa AI only supports single string queries. + optional_params: Optional parameters for the request + + Returns: + Dict with typed request data following ExaAISearchRequest spec + """ + if isinstance(query, list): + # Exa AI only supports single string queries, join with spaces + query = " ".join(query) + + request_data: ExaAISearchRequest = { + "query": query, + } + + # Transform Perplexity unified spec parameters to Exa format + if "max_results" in optional_params: + request_data["numResults"] = optional_params["max_results"] + + if "search_domain_filter" in optional_params: + request_data["includeDomains"] = optional_params["search_domain_filter"] + + if "country" in optional_params: + request_data["userLocation"] = optional_params["country"] + + # Convert to dict before dynamic key assignments + result_data = dict(request_data) + + # pass through all other parameters as-is + for param, value in optional_params.items(): + if param not in self.get_supported_perplexity_optional_params() and param not in result_data: + result_data[param] = value + + # By default, request text content if not explicitly specified + # Exa AI doesn't return content/text unless explicitly requested + if "contents" not in result_data: + result_data["contents"] = {"text": True} + + return result_data + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs, + ) -> SearchResponse: + """ + Transform Exa AI API response to LiteLLM unified SearchResponse format. + + Exa AI → LiteLLM mappings: + - results[].title → SearchResult.title + - results[].url → SearchResult.url + - results[].text → SearchResult.snippet + - results[].publishedDate → SearchResult.date + - No last_updated field in Exa AI response (set to None) + + Args: + raw_response: Raw httpx response from Exa AI API + logging_obj: Logging object for tracking + + Returns: + SearchResponse with standardized format + """ + response_json = raw_response.json() + + # Transform results to SearchResult objects + results = [] + for result in response_json.get("results", []): + search_result = SearchResult( + title=result.get("title", ""), + url=result.get("url", ""), + snippet=result.get("text", ""), # Exa AI uses "text" for content + date=result.get("publishedDate"), # ISO 8601 datetime string + last_updated=None, # Exa AI doesn't provide last_updated in response + ) + results.append(search_result) + + return SearchResponse( + results=results, + object="search", + ) + diff --git a/litellm/llms/google_pse/search/__init__.py b/litellm/llms/google_pse/search/__init__.py new file mode 100644 index 00000000000..cda3f360f9d --- /dev/null +++ b/litellm/llms/google_pse/search/__init__.py @@ -0,0 +1,8 @@ +""" +Google Programmable Search Engine (PSE) API module. +""" +from litellm.llms.google_pse.search.transformation import GooglePSESearchConfig + +__all__ = ["GooglePSESearchConfig"] + + diff --git a/litellm/llms/google_pse/search/transformation.py b/litellm/llms/google_pse/search/transformation.py new file mode 100644 index 00000000000..c1ba9cfe629 --- /dev/null +++ b/litellm/llms/google_pse/search/transformation.py @@ -0,0 +1,242 @@ +""" +Calls Google Programmable Search Engine (PSE) API to search the web. + +Google PSE API Reference: https://developers.google.com/custom-search/v1/reference/rest/v1/cse/list +""" +from typing import Dict, List, Literal, Optional, TypedDict, Union + +import httpx + +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) +from litellm.secret_managers.main import get_secret_str + + +class _GooglePSESearchRequestRequired(TypedDict): + """Required fields for Google PSE Search API request.""" + q: str # Required - search query + cx: str # Required - Programmable Search Engine ID + key: str # Required - API key + + +class GooglePSESearchRequest(_GooglePSESearchRequestRequired, total=False): + """ + Google Programmable Search Engine API request format. + Based on: https://developers.google.com/custom-search/v1/reference/rest/v1/cse/list + """ + num: int # Optional - number of results (1-10), default 10 + start: int # Optional - index of first result (default 1) + cr: str # Optional - country restrict (e.g., 'countryUS', 'countryGB') + dateRestrict: str # Optional - restricts results by date (e.g., 'd[number]', 'w[number]', 'm[number]', 'y[number]') + exactTerms: str # Optional - phrase that all documents must contain + excludeTerms: str # Optional - word or phrase to exclude + fileType: str # Optional - file type to restrict results to + filter: str # Optional - controls duplicate content filtering ('0'=off, '1'=on) + gl: str # Optional - geolocation of end user (2-letter country code) + hq: str # Optional - append query terms to query + imgSize: str # Optional - returns images of specified size + imgType: str # Optional - returns images of specified type + linkSite: str # Optional - specifies all search results should contain a link to a URL + lr: str # Optional - language restrict (e.g., 'lang_en', 'lang_es') + orTerms: str # Optional - provides additional search terms + relatedSite: str # Optional - specifies all search results should be pages related to URL + rights: str # Optional - filters based on licensing + safe: str # Optional - search safety level ('active', 'off') + searchType: str # Optional - specifies search type ('image') + siteSearch: str # Optional - restricts results to URLs from specified site + siteSearchFilter: str # Optional - controls whether to include or exclude siteSearch ('e'=exclude, 'i'=include) + sort: str # Optional - sort expression + + +class GooglePSESearchConfig(BaseSearchConfig): + GOOGLE_PSE_API_BASE = "https://www.googleapis.com/customsearch/v1" + + @staticmethod + def ui_friendly_name() -> str: + return "Google PSE" + + def get_http_method(self) -> Literal["GET", "POST"]: + """ + Google PSE uses GET requests with query parameters. + """ + return "GET" + + def validate_environment( + self, + headers: Dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, + ) -> Dict: + """ + Validate environment and return headers. + + Google PSE uses API key as a query parameter, not in headers. + This method is called but headers are not used for authentication. + """ + api_key = api_key or get_secret_str("GOOGLE_PSE_API_KEY") + if not api_key: + raise ValueError("GOOGLE_PSE_API_KEY is not set. Set `GOOGLE_PSE_API_KEY` environment variable.") + + # Also check for search engine ID + search_engine_id = kwargs.get("search_engine_id") or get_secret_str("GOOGLE_PSE_ENGINE_ID") + if not search_engine_id: + raise ValueError("GOOGLE_PSE_ENGINE_ID is not set. Set `GOOGLE_PSE_ENGINE_ID` environment variable or pass `search_engine_id` parameter.") + + headers["Content-Type"] = "application/json" + return headers + + def get_complete_url( + self, + api_base: Optional[str], + optional_params: dict, + data: Optional[Union[Dict, List[Dict]]] = None, + **kwargs, + ) -> str: + """ + Get complete URL for Search endpoint with query parameters. + + Google PSE uses GET requests, so we build the full URL with query params here. + The transformed request body (data) contains the parameters needed for the URL. + """ + from urllib.parse import urlencode + + api_base = api_base or get_secret_str("GOOGLE_PSE_API_BASE") or self.GOOGLE_PSE_API_BASE + + # Build query parameters from the transformed request body + if data and isinstance(data, dict) and "_google_pse_params" in data: + params = data["_google_pse_params"] + query_string = urlencode(params) + return f"{api_base}?{query_string}" + + return api_base + + + def transform_search_request( + self, + query: Union[str, List[str]], + optional_params: dict, + api_key: Optional[str] = None, + search_engine_id: Optional[str] = None, + **kwargs, + ) -> Dict: + """ + Transform Search request to Google PSE API format. + + Transforms Perplexity unified spec parameters: + - query → q (same) + - max_results → num + - search_domain_filter → siteSearch + - country → gl + - max_tokens_per_page → (not applicable, ignored) + + All other Google PSE-specific parameters are passed through as-is. + + Args: + query: Search query (string or list of strings). Google PSE supports single string queries. + optional_params: Optional parameters for the request + api_key: Google API key + search_engine_id: Google Programmable Search Engine ID (cx parameter) + + Returns: + Dict with typed request data following GooglePSESearchRequest spec + """ + if isinstance(query, list): + # Google PSE only supports single string queries + query = " ".join(query) + + # Get API credentials + api_key = api_key or get_secret_str("GOOGLE_PSE_API_KEY") + search_engine_id = search_engine_id or get_secret_str("GOOGLE_PSE_ENGINE_ID") + + if not api_key: + raise ValueError("GOOGLE_PSE_API_KEY is required") + if not search_engine_id: + raise ValueError("GOOGLE_PSE_ENGINE_ID is required") + + request_data: GooglePSESearchRequest = { + "q": query, + "cx": search_engine_id, + "key": api_key, + } + + # Transform unified spec parameters to Google PSE format + if "max_results" in optional_params: + # Google PSE supports 1-10 results per request + num_results = min(optional_params["max_results"], 10) + request_data["num"] = num_results + + if "search_domain_filter" in optional_params: + # Convert list to single domain (take first if multiple) + domains = optional_params["search_domain_filter"] + if isinstance(domains, list) and len(domains) > 0: + request_data["siteSearch"] = domains[0] + request_data["siteSearchFilter"] = "i" # include + elif isinstance(domains, str): + request_data["siteSearch"] = domains + request_data["siteSearchFilter"] = "i" # include + + if "country" in optional_params: + # Google PSE uses 2-letter country codes for gl parameter + request_data["gl"] = optional_params["country"].upper() + + # Convert to dict before dynamic key assignments + result_data = dict(request_data) + + # Pass through all other parameters as-is + for param, value in optional_params.items(): + if param not in self.get_supported_perplexity_optional_params() and param not in result_data: + result_data[param] = value + + # Store params in special key for URL building (Google PSE uses GET not POST) + # Return a wrapper dict that stores params for get_complete_url to use + return { + "_google_pse_params": result_data, + } + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs, + ) -> SearchResponse: + """ + Transform Google PSE API response to LiteLLM unified SearchResponse format. + + Google PSE → LiteLLM mappings: + - items[].title → SearchResult.title + - items[].link → SearchResult.url + - items[].snippet → SearchResult.snippet + - No date/last_updated fields in Google PSE response (set to None) + + Args: + raw_response: Raw httpx response from Google PSE API + logging_obj: Logging object for tracking + + Returns: + SearchResponse with standardized format + """ + response_json = raw_response.json() + + # Transform results to SearchResult objects + results = [] + for item in response_json.get("items", []): + search_result = SearchResult( + title=item.get("title", ""), + url=item.get("link", ""), + snippet=item.get("snippet", ""), + date=None, # Google PSE doesn't provide date in standard response + last_updated=None, # Google PSE doesn't provide last_updated in response + ) + results.append(search_result) + + return SearchResponse( + results=results, + object="search", + ) + + diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index b740eb122fd..9c8700daf83 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -188,7 +188,7 @@ class OllamaChatConfig(BaseConfig): if model.startswith("gpt-oss"): optional_params["think"] = value else: - optional_params["think"] = True + optional_params["think"] = value in {"low", "medium", "high"} ### FUNCTION CALLING LOGIC ### if param == "tools": ## CHECK IF MODEL SUPPORTS TOOL CALLING ## diff --git a/litellm/llms/ollama/completion/transformation.py b/litellm/llms/ollama/completion/transformation.py index b476e5c8a63..c4d08c83a2a 100644 --- a/litellm/llms/ollama/completion/transformation.py +++ b/litellm/llms/ollama/completion/transformation.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, List, Optional, from httpx._models import Headers, Response import litellm +from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_str_from_messages, ) @@ -183,7 +184,7 @@ class OllamaConfig(BaseConfig): if model.startswith("gpt-oss"): optional_params["think"] = value else: - optional_params["think"] = True + optional_params["think"] = value in {"low", "medium", "high"} elif param == "response_format" and isinstance(value, dict): if value["type"] == "json_object": optional_params["format"] = "json" @@ -577,6 +578,18 @@ class OllamaTextCompletionResponseIterator(BaseModelResponseIterator): ] ) else: - raise Exception(f"Unable to parse ollama chunk - {chunk}") + # In this case, 'thinking' is not present in the chunk, chunk["done"] is false, + # and chunk["response"] is falsy (None or empty string), + # but Ollama is just starting to stream, so it should be processed as a normal dict + return ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta(reasoning_content=""), + ) + ] + ) + # raise Exception(f"Unable to parse ollama chunk - {chunk}") except Exception as e: + verbose_proxy_logger.error(f"Unable to parse ollama chunk - {chunk}") raise e diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 1e949e434d3..c3abd5155db 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -123,8 +123,6 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): try: # Ensure required fields are present for ResponseReasoningItem item_data = dict(item) - if "id" not in item_data: - item_data["id"] = f"rs_{hash(str(item_data))}" if "summary" not in item_data: item_data["summary"] = ( item_data.get("reasoning_content", "")[:100] + "..." diff --git a/litellm/llms/parallel_ai/search/__init__.py b/litellm/llms/parallel_ai/search/__init__.py new file mode 100644 index 00000000000..cc2ff91ea33 --- /dev/null +++ b/litellm/llms/parallel_ai/search/__init__.py @@ -0,0 +1,7 @@ +""" +Parallel AI Search API module. +""" +from litellm.llms.parallel_ai.search.transformation import ParallelAISearchConfig + +__all__ = ["ParallelAISearchConfig"] + diff --git a/litellm/llms/parallel_ai/search/transformation.py b/litellm/llms/parallel_ai/search/transformation.py new file mode 100644 index 00000000000..95919b85c2f --- /dev/null +++ b/litellm/llms/parallel_ai/search/transformation.py @@ -0,0 +1,201 @@ +""" +Calls Parallel AI's /search endpoint to search the web. + +Parallel AI API Reference: https://docs.parallel.ai/api-reference/search-and-extract-api-beta/search +""" +from typing import Dict, List, Optional, TypedDict, Union + +import httpx + +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) +from litellm.secret_managers.main import get_secret_str + + +class _ParallelAISourcePolicy(TypedDict, total=False): + """Source policy for Parallel AI search results.""" + allowed_domains: List[str] # Optional - list of allowed domains + disallowed_domains: List[str] # Optional - list of disallowed domains + + +class _ParallelAISearchRequestRequired(TypedDict): + """Required fields for Parallel AI Search API request.""" + # Note: At least one of objective or search_queries must be provided + pass + + +class ParallelAISearchRequest(_ParallelAISearchRequestRequired, total=False): + """ + Parallel AI Search API request format. + Based on: https://docs.parallel.ai/api-reference/search-and-extract-api-beta/search + """ + objective: str # Optional - natural-language description of search goal + search_queries: List[str] # Optional - list of keyword search queries + processor: str # Optional - search processor ('base', 'pro'), default 'base' + max_results: int # Optional - maximum number of results, default 10 + max_chars_per_result: int # Optional - max characters per result excerpt + source_policy: _ParallelAISourcePolicy # Optional - source policy for allowed/disallowed domains + + +class ParallelAISearchConfig(BaseSearchConfig): + PARALLEL_AI_API_BASE = "https://api.parallel.ai" + PARALLEL_HEADER_SEARCH_EXTRACT_VALUE = "search-extract-2025-10-10" + + @staticmethod + def ui_friendly_name() -> str: + return "Parallel AI" + + def validate_environment( + self, + headers: Dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, + ) -> Dict: + """ + Validate environment and return headers. + """ + api_key = api_key or get_secret_str("PARALLEL_AI_API_KEY") or get_secret_str("PARALLEL_API_KEY") + if not api_key: + raise ValueError("PARALLEL_API_KEY is not set. Set `PARALLEL_API_KEY` environment variable.") + headers["x-api-key"] = api_key + headers["Content-Type"] = "application/json" + headers["parallel-beta"] = self.PARALLEL_HEADER_SEARCH_EXTRACT_VALUE + return headers + + def get_complete_url( + self, + api_base: Optional[str], + optional_params: dict, + data: Optional[Union[Dict, List[Dict]]] = None, + **kwargs, + ) -> str: + """ + Get complete URL for Search endpoint. + """ + api_base = api_base or get_secret_str("PARALLEL_AI_API_BASE") or self.PARALLEL_AI_API_BASE + + # Parallel AI search endpoint is at /v1beta/search + if not api_base.endswith("/v1beta/search"): + if api_base.endswith("/"): + api_base = f"{api_base}v1beta/search" + else: + api_base = f"{api_base}/v1beta/search" + + return api_base + + def _transform_query_to_objective(self, query: Union[str, List[str]]) -> str: + """ + Transform query to objective. + """ + if isinstance(query, list): + return " ".join(query) + return query + + + def transform_search_request( + self, + query: Union[str, List[str]], + optional_params: dict, + **kwargs, + ) -> Dict: + """ + Transform Search request to Parallel AI API format. + + Args: + query: Search query (string or list of strings) + - If string: maps to `objective` (natural language) + - If list: maps to `search_queries` (keyword queries) + optional_params: Optional parameters for the request + - max_results: Maximum number of search results (default 10) + - search_domain_filter: List of domains to include -> maps to `source_policy.allowed_domains` + - exclude_domains: List of domains to exclude -> maps to `source_policy.disallowed_domains` + - processor: Search processor ('base', 'pro') + - max_chars_per_result: Max characters per result excerpt + + Returns: + Dict with typed request data following ParallelAISearchRequest spec + """ + request_data: ParallelAISearchRequest = {} + + # Map query to objective (string or list both become objective) + if isinstance(query, list): + request_data["objective"] = self._transform_query_to_objective(query) + else: + request_data["objective"] = query + + # Transform Perplexity unified spec parameters to Parallel AI format + if "max_results" in optional_params: + request_data["max_results"] = optional_params["max_results"] + + # Map domain filters to source_policy + source_policy: _ParallelAISourcePolicy = {} + + if "search_domain_filter" in optional_params: + source_policy["allowed_domains"] = optional_params["search_domain_filter"] + + if "exclude_domains" in optional_params: + source_policy["disallowed_domains"] = optional_params["exclude_domains"] + + if source_policy: + request_data["source_policy"] = source_policy + + # Convert to dict before dynamic key assignments + result_data = dict(request_data) + + # pass through all other parameters as-is + for param, value in optional_params.items(): + if param not in self.get_supported_perplexity_optional_params() and param not in result_data: + result_data[param] = value + + return result_data + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs, + ) -> SearchResponse: + """ + Transform Parallel AI API response to LiteLLM unified SearchResponse format. + + Parallel AI → LiteLLM mappings: + - results[].title → SearchResult.title + - results[].url → SearchResult.url + - results[].excerpts (array) → SearchResult.snippet (joined string) + - No date/last_updated fields in Parallel AI response (set to None) + + Args: + raw_response: Raw httpx response from Parallel AI API + logging_obj: Logging object for tracking + + Returns: + SearchResponse with standardized format + """ + response_json = raw_response.json() + + # Transform results to SearchResult objects + results = [] + for result in response_json.get("results", []): + # Join excerpts array into a single snippet string + excerpts = result.get("excerpts", []) + snippet = " ... ".join(excerpts) if excerpts else "" + + search_result = SearchResult( + title=result.get("title", ""), + url=result.get("url", ""), + snippet=snippet, + date=None, # Parallel AI doesn't provide date in response + last_updated=None, # Parallel AI doesn't provide last_updated in response + ) + results.append(search_result) + + return SearchResponse( + results=results, + object="search", + ) + diff --git a/litellm/llms/perplexity/search/transformation.py b/litellm/llms/perplexity/search/transformation.py new file mode 100644 index 00000000000..f1dc0909b4d --- /dev/null +++ b/litellm/llms/perplexity/search/transformation.py @@ -0,0 +1,159 @@ +""" +Calls Perplexity's /search endpoint to search the web. +""" +from typing import Dict, List, Optional, TypedDict, Union + +import httpx + +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) +from litellm.secret_managers.main import get_secret_str + + +class _PerplexitySearchRequestRequired(TypedDict): + """Required fields for Perplexity Search API request.""" + query: Union[str, List[str]] # Required - search query or queries + + +class PerplexitySearchRequest(_PerplexitySearchRequestRequired, total=False): + """ + Perplexity Search API request format. + Based on: https://docs.perplexity.ai/api-reference/search-post + """ + max_results: int # Optional - maximum number of results (1-20), default 10 + search_domain_filter: List[str] # Optional - list of domains to filter (max 20) + max_tokens_per_page: int # Optional - max tokens per page, default 1024 + country: str # Optional - country code filter (e.g., 'US', 'GB', 'DE') + + +class PerplexitySearchConfig(BaseSearchConfig): + PERPLEXITY_API_BASE = "https://api.perplexity.ai" + + @staticmethod + def ui_friendly_name() -> str: + return "Perplexity" + + def validate_environment( + self, + headers: Dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, + ) -> Dict: + """ + Validate environment and return headers. + """ + api_key = api_key or get_secret_str("PERPLEXITYAI_API_KEY") + if not api_key: + raise ValueError("PERPLEXITYAI_API_KEY is not set. Set `PERPLEXITYAI_API_KEY` environment variable.") + headers["Authorization"] = f"Bearer {api_key}" + headers["Content-Type"] = "application/json" + return headers + + def get_complete_url( + self, + api_base: Optional[str], + optional_params: dict, + data: Optional[Union[Dict, List[Dict]]] = None, + **kwargs, + ) -> str: + """ + Get complete URL for Search endpoint. + """ + api_base = api_base or get_secret_str("PERPLEXITY_API_BASE") or self.PERPLEXITY_API_BASE + + # append "/search" to the api base if it's not already there + if not api_base.endswith("/search"): + api_base = f"{api_base}/search" + + return api_base + + + def transform_search_request( + self, + query: Union[str, List[str]], + optional_params: dict, + **kwargs, + ) -> Dict: + """ + Transform Search request to Perplexity API format. + + Note: LiteLLM's native spec is the perplexity search spec. + + There's no transformation needed for the request data. + + https://docs.perplexity.ai/api-reference/search-post + + Args: + query: Search query (string or list of strings) + optional_params: Optional parameters for the request + - max_results: Maximum number of search results (1-20) + - search_domain_filter: List of domains to filter (max 20) + - max_tokens_per_page: Max tokens per page (default 1024) + - country: Country code filter (e.g., 'US', 'GB', 'DE') + + Returns: + Dict with typed request data following PerplexitySearchRequest spec + """ + request_data: PerplexitySearchRequest = { + "query": query, + } + + # Add optional parameters following Perplexity API spec (only if not None) + max_results = optional_params.get("max_results") + if max_results is not None: + request_data["max_results"] = max_results + + search_domain_filter = optional_params.get("search_domain_filter") + if search_domain_filter is not None: + request_data["search_domain_filter"] = search_domain_filter + + max_tokens_per_page = optional_params.get("max_tokens_per_page") + if max_tokens_per_page is not None: + request_data["max_tokens_per_page"] = max_tokens_per_page + + country = optional_params.get("country") + if country is not None: + request_data["country"] = country + + return dict(request_data) + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs, + ) -> SearchResponse: + """ + Transform Perplexity API response to standard SearchResponse format. + + Args: + raw_response: Raw httpx response from Perplexity API + logging_obj: Logging object for tracking + + Returns: + SearchResponse with standardized format + """ + response_json = raw_response.json() + + # Transform results to SearchResult objects + results = [] + for result in response_json.get("results", []): + search_result = SearchResult( + title=result.get("title", ""), + url=result.get("url", ""), + snippet=result.get("snippet", ""), + date=result.get("date"), + last_updated=result.get("last_updated"), + ) + results.append(search_result) + + return SearchResponse( + results=results, + object="search", + ) + diff --git a/litellm/llms/tavily/search/__init__.py b/litellm/llms/tavily/search/__init__.py new file mode 100644 index 00000000000..4753928806b --- /dev/null +++ b/litellm/llms/tavily/search/__init__.py @@ -0,0 +1,7 @@ +""" +Tavily Search API module. +""" +from litellm.llms.tavily.search.transformation import TavilySearchConfig + +__all__ = ["TavilySearchConfig"] + diff --git a/litellm/llms/tavily/search/transformation.py b/litellm/llms/tavily/search/transformation.py new file mode 100644 index 00000000000..7fc33416a0b --- /dev/null +++ b/litellm/llms/tavily/search/transformation.py @@ -0,0 +1,187 @@ +""" +Calls Tavily's /search endpoint to search the web. + +Tavily API Reference: https://docs.tavily.com/documentation/api-reference/endpoint/search +""" +from typing import Dict, List, Optional, TypedDict, Union + +import httpx + +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) +from litellm.secret_managers.main import get_secret_str + + +class _TavilySearchRequestRequired(TypedDict): + """Required fields for Tavily Search API request.""" + query: str # Required - search query + + +class TavilySearchRequest(_TavilySearchRequestRequired, total=False): + """ + Tavily Search API request format. + Based on: https://docs.tavily.com/documentation/api-reference/endpoint/search + """ + max_results: int # Optional - maximum number of results (0-20), default 5 + include_domains: List[str] # Optional - list of domains to include (max 300) + exclude_domains: List[str] # Optional - list of domains to exclude (max 150) + topic: str # Optional - category of search ('general', 'news', 'finance'), default 'general' + search_depth: str # Optional - depth of search ('basic', 'advanced'), default 'basic' + include_answer: Union[bool, str] # Optional - include LLM-generated answer + include_raw_content: Union[bool, str] # Optional - include raw HTML content + include_images: bool # Optional - perform image search + include_image_descriptions: bool # Optional - add descriptions for images + include_favicon: bool # Optional - include favicon URL + time_range: str # Optional - time range filter ('day', 'week', 'month', 'year', 'd', 'w', 'm', 'y') + start_date: str # Optional - start date filter (YYYY-MM-DD) + end_date: str # Optional - end date filter (YYYY-MM-DD) + country: str # Optional - country code filter (e.g., 'US', 'GB', 'DE') + + +class TavilySearchConfig(BaseSearchConfig): + TAVILY_API_BASE = "https://api.tavily.com" + + @staticmethod + def ui_friendly_name() -> str: + return "Tavily" + + def validate_environment( + self, + headers: Dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, + ) -> Dict: + """ + Validate environment and return headers. + """ + api_key = api_key or get_secret_str("TAVILY_API_KEY") + if not api_key: + raise ValueError("TAVILY_API_KEY is not set. Set `TAVILY_API_KEY` environment variable.") + headers["Authorization"] = f"Bearer {api_key}" + headers["Content-Type"] = "application/json" + return headers + + def get_complete_url( + self, + api_base: Optional[str], + optional_params: dict, + data: Optional[Union[Dict, List[Dict]]] = None, + **kwargs, + ) -> str: + """ + Get complete URL for Search endpoint. + """ + api_base = api_base or get_secret_str("TAVILY_API_BASE") or self.TAVILY_API_BASE + + # Append "/search" to the api base if it's not already there + if not api_base.endswith("/search"): + api_base = f"{api_base}/search" + + return api_base + + + def transform_search_request( + self, + query: Union[str, List[str]], + optional_params: dict, + **kwargs, + ) -> Dict: + """ + Transform Search request to Tavily API format. + + Args: + query: Search query (string or list of strings). Tavily only supports single string queries. + optional_params: Optional parameters for the request + - max_results: Maximum number of search results (0-20) + - search_domain_filter: List of domains to include (max 300) -> maps to `include_domains` + - exclude_domains: List of domains to exclude (max 150) + - topic: Category of search ('general', 'news', 'finance') + - search_depth: Depth of search ('basic', 'advanced') + - include_answer: Include LLM-generated answer (bool or 'basic', 'advanced') + - include_raw_content: Include raw HTML content (bool or 'markdown', 'text') + - include_images: Perform image search (bool) + - include_image_descriptions: Add descriptions for images (bool) + - include_favicon: Include favicon URL (bool) + - time_range: Time range filter ('day', 'week', 'month', 'year', 'd', 'w', 'm', 'y') + - start_date: Start date filter (YYYY-MM-DD) + - end_date: End date filter (YYYY-MM-DD) + - country: Country code filter (e.g., 'US', 'GB', 'DE') + + Returns: + Dict with typed request data following TavilySearchRequest spec + """ + if isinstance(query, list): + # Tavily only supports single string queries + query = " ".join(query) + + request_data: TavilySearchRequest = { + "query": query, + } + + # Transform Perplexity unified spec parameters to Tavily format + if "max_results" in optional_params: + request_data["max_results"] = optional_params["max_results"] + + if "search_domain_filter" in optional_params: + request_data["include_domains"] = optional_params["search_domain_filter"] + + if "country" in optional_params: + # Tavily expects lowercase country names + request_data["country"] = optional_params["country"].lower() + + # Convert to dict before dynamic key assignments + result_data = dict(request_data) + + # pass through all other parameters as-is + for param, value in optional_params.items(): + if param not in self.get_supported_perplexity_optional_params() and param not in result_data: + result_data[param] = value + + return result_data + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs, + ) -> SearchResponse: + """ + Transform Tavily API response to LiteLLM unified SearchResponse format. + + Tavily → LiteLLM mappings: + - results[].title → SearchResult.title + - results[].url → SearchResult.url + - results[].content → SearchResult.snippet + - No date/last_updated fields in Tavily response (set to None) + + Args: + raw_response: Raw httpx response from Tavily API + logging_obj: Logging object for tracking + + Returns: + SearchResponse with standardized format + """ + response_json = raw_response.json() + + # Transform results to SearchResult objects + results = [] + for result in response_json.get("results", []): + search_result = SearchResult( + title=result.get("title", ""), + url=result.get("url", ""), + snippet=result.get("content", ""), # Tavily uses "content" instead of "snippet" + date=None, # Tavily doesn't provide date in response + last_updated=None, # Tavily doesn't provide last_updated in response + ) + results.append(search_result) + + return SearchResponse( + results=results, + object="search", + ) + diff --git a/litellm/llms/vertex_ai/vector_stores/__init__.py b/litellm/llms/vertex_ai/vector_stores/__init__.py index f3c210a973c..98da2c581a8 100644 --- a/litellm/llms/vertex_ai/vector_stores/__init__.py +++ b/litellm/llms/vertex_ai/vector_stores/__init__.py @@ -1,3 +1,4 @@ -from .transformation import VertexVectorStoreConfig +from .rag_api.transformation import VertexVectorStoreConfig +from .search_api.transformation import VertexSearchAPIVectorStoreConfig -__all__ = ["VertexVectorStoreConfig"] \ No newline at end of file +__all__ = ["VertexVectorStoreConfig", "VertexSearchAPIVectorStoreConfig"] diff --git a/litellm/llms/vertex_ai/vector_stores/transformation.py b/litellm/llms/vertex_ai/vector_stores/rag_api/transformation.py similarity index 100% rename from litellm/llms/vertex_ai/vector_stores/transformation.py rename to litellm/llms/vertex_ai/vector_stores/rag_api/transformation.py diff --git a/litellm/llms/vertex_ai/vector_stores/search_api/transformation.py b/litellm/llms/vertex_ai/vector_stores/search_api/transformation.py new file mode 100644 index 00000000000..3d13c99840c --- /dev/null +++ b/litellm/llms/vertex_ai/vector_stores/search_api/transformation.py @@ -0,0 +1,241 @@ +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union + +import httpx + +from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig +from litellm.llms.vertex_ai.vertex_llm_base import VertexBase +from litellm.types.router import GenericLiteLLMParams +from litellm.types.vector_stores import ( + VectorStoreCreateOptionalRequestParams, + VectorStoreCreateResponse, + VectorStoreResultContent, + VectorStoreSearchOptionalRequestParams, + VectorStoreSearchResponse, + VectorStoreSearchResult, +) + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + + +class VertexSearchAPIVectorStoreConfig(BaseVectorStoreConfig, VertexBase): + """ + Configuration for Vertex AI Search API Vector Store + + This implementation uses the Vertex AI Search API for vector store operations. + """ + + def __init__(self): + super().__init__() + + def validate_environment( + self, headers: dict, litellm_params: Optional[GenericLiteLLMParams] + ) -> dict: + """ + Validate and set up authentication for Vertex AI RAG API + """ + litellm_params = litellm_params or GenericLiteLLMParams() + + # Get credentials and project info + vertex_credentials = self.get_vertex_ai_credentials(dict(litellm_params)) + vertex_project = self.get_vertex_ai_project(dict(litellm_params)) + + # Get access token using the base class method + access_token, project_id = self._ensure_access_token( + credentials=vertex_credentials, + project_id=vertex_project, + custom_llm_provider="vertex_ai", + ) + + headers.update( + { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + ) + + return headers + + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + Get the Base endpoint for Vertex AI Search API + """ + vertex_location = self.get_vertex_ai_location(litellm_params) + vertex_project = self.get_vertex_ai_project(litellm_params) + engine_id = litellm_params.get("vector_store_id") + collection_id = ( + litellm_params.get("vertex_collection_id") or "default_collection" + ) + if api_base: + return api_base.rstrip("/") + + # Vertex AI Search API endpoint for search + return ( + f"https://discoveryengine.googleapis.com/v1/" + f"projects/{vertex_project}/locations/{vertex_location}/" + f"collections/{collection_id}/engines/{engine_id}/servingConfigs/default_config" + ) + + def transform_search_vector_store_request( + self, + vector_store_id: str, + query: Union[str, List[str]], + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + api_base: str, + litellm_logging_obj: LiteLLMLoggingObj, + litellm_params: dict, + ) -> Tuple[str, Dict[str, Any]]: + """ + Transform search request for Vertex AI RAG API + """ + # Convert query to string if it's a list + if isinstance(query, list): + query = " ".join(query) + + # Vertex AI RAG API endpoint for retrieving contexts + url = f"{api_base}:search" + + # Construct full rag corpus path + # Build the request body for Vertex AI Search API + request_body = {"query": query, "pageSize": 10} + + ######################################################### + # Update logging object with details of the request + ######################################################### + litellm_logging_obj.model_call_details["query"] = query + + return url, request_body + + def transform_search_vector_store_response( + self, response: httpx.Response, litellm_logging_obj: LiteLLMLoggingObj + ) -> VectorStoreSearchResponse: + """ + Transform Vertex AI Search API response to standard vector store search response + + Handles the format from Discovery Engine Search API which returns: + { + "results": [ + { + "id": "...", + "document": { + "derivedStructData": { + "title": "...", + "link": "...", + "snippets": [...] + } + } + } + ] + } + """ + try: + response_json = response.json() + + # Extract results from Vertex AI Search API response + results = response_json.get("results", []) + + # Transform results to standard format + search_results: List[VectorStoreSearchResult] = [] + for result in results: + document = result.get("document", {}) + derived_data = document.get("derivedStructData", {}) + + # Extract text content from snippets + snippets = derived_data.get("snippets", []) + text_content = "" + + if snippets: + # Combine all snippets into one text + text_parts = [ + snippet.get("snippet", snippet.get("htmlSnippet", "")) + for snippet in snippets + ] + text_content = " ".join(text_parts) + + # If no snippets, use title as fallback + if not text_content: + text_content = derived_data.get("title", "") + + content = [ + VectorStoreResultContent( + text=text_content, + type="text", + ) + ] + + # Extract file/document information + document_link = derived_data.get("link", "") + document_title = derived_data.get("title", "") + document_id = result.get("id", "") + + # Use link as file_id if available, otherwise use document ID + file_id = document_link if document_link else document_id + filename = document_title if document_title else "Unknown Document" + + # Build attributes with available metadata + attributes = { + "document_id": document_id, + } + + if document_link: + attributes["link"] = document_link + if document_title: + attributes["title"] = document_title + + # Add display link if available + display_link = derived_data.get("displayLink", "") + if display_link: + attributes["displayLink"] = display_link + + # Add formatted URL if available + formatted_url = derived_data.get("formattedUrl", "") + if formatted_url: + attributes["formattedUrl"] = formatted_url + + # Note: Search API doesn't provide explicit scores in the response + # You can use the position/rank as an implicit score + score = 1.0 / ( + float(search_results.__len__() + 1) + ) # Decreasing score based on position + + result_obj = VectorStoreSearchResult( + score=score, + content=content, + file_id=file_id, + filename=filename, + attributes=attributes, + ) + search_results.append(result_obj) + + return VectorStoreSearchResponse( + object="vector_store.search_results.page", + search_query=litellm_logging_obj.model_call_details.get("query", ""), + data=search_results, + ) + + except Exception as e: + raise self.get_error_class( + error_message=str(e), + status_code=response.status_code, + headers=response.headers, + ) + + def transform_create_vector_store_request( + self, + vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams, + api_base: str, + ) -> Tuple[str, Dict]: + raise NotImplementedError + + def transform_create_vector_store_response( + self, response: httpx.Response + ) -> VectorStoreCreateResponse: + raise NotImplementedError diff --git a/litellm/main.py b/litellm/main.py index f93387df4b8..8e3f9a0b3d8 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -5706,17 +5706,19 @@ def speech( # noqa: PLR0915 # Map OpenAI params to provider-specific params if config exists if text_to_speech_provider_config is not None: - optional_params = text_to_speech_provider_config.map_openai_params( + voice, optional_params = text_to_speech_provider_config.map_openai_params( model=model, optional_params=optional_params, + voice=voice, drop_params=False, + kwargs=kwargs, ) logging_obj: Logging = cast(Logging, kwargs.get("litellm_logging_obj")) logging_obj.update_environment_variables( model=model, user=user, - optional_params={}, + optional_params=optional_params, litellm_params={ "litellm_call_id": litellm_call_id, "proxy_server_request": proxy_server_request, @@ -5960,6 +5962,7 @@ async def ahealth_check( "rerank", "realtime", "responses", + "ocr", ] ] = "chat", prompt: Optional[str] = None, @@ -6022,57 +6025,13 @@ async def ahealth_check( litellm_logging_obj=litellm_logging_obj, ) - mode_handlers = { - "chat": lambda: litellm.acompletion( - **model_params, - ), - "completion": lambda: litellm.atext_completion( - **_filter_model_params(model_params), - prompt=prompt or "test", - ), - "embedding": lambda: litellm.aembedding( - **_filter_model_params(model_params), - input=input or ["test"], - ), - "audio_speech": lambda: litellm.aspeech( - **{ - **_filter_model_params(model_params), - **( - {"voice": "alloy"} - if "voice" not in _filter_model_params(model_params) - else {} - ), - }, - input=prompt or "test", - ), - "audio_transcription": lambda: litellm.atranscription( - **_filter_model_params(model_params), - file=get_audio_file_for_health_check(), - ), - "image_generation": lambda: litellm.aimage_generation( - **_filter_model_params(model_params), - prompt=prompt, - ), - "rerank": lambda: litellm.arerank( - **_filter_model_params(model_params), - query=prompt or "", - documents=["my sample text"], - ), - "realtime": lambda: _realtime_health_check( - model=model, - custom_llm_provider=custom_llm_provider, - api_base=model_params.get("api_base", None), - api_key=model_params.get("api_key", None), - api_version=model_params.get("api_version", None), - ), - "batch": lambda: litellm.alist_batches( - **_filter_model_params(model_params), - ), - "responses": lambda: litellm.aresponses( - **_filter_model_params(model_params), - input=prompt or "test", - ), - } + mode_handlers = HealthCheckHelpers.get_mode_handlers( + model=model, + custom_llm_provider=custom_llm_provider, + model_params=model_params, + prompt=prompt, + input=input, + ) if mode in mode_handlers: _response = await mode_handlers[mode]() diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 25f4ea9ff90..4804ae1de74 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1,4 +1,44 @@ { + "sample_spec": { + "code_interpreter_cost_per_session": 0.0, + "computer_use_input_cost_per_1k_tokens": 0.0, + "computer_use_output_cost_per_1k_tokens": 0.0, + "deprecation_date": "date when the model becomes deprecated in the format YYYY-MM-DD", + "file_search_cost_per_1k_calls": 0.0, + "file_search_cost_per_gb_per_day": 0.0, + "input_cost_per_audio_token": 0.0, + "input_cost_per_token": 0.0, + "litellm_provider": "one of https://docs.litellm.ai/docs/providers", + "max_input_tokens": "max input tokens, if the provider specifies it. if not default to max_tokens", + "max_output_tokens": "max output tokens, if the provider specifies it. if not default to max_tokens", + "max_tokens": "LEGACY parameter. set to max_output_tokens if provider specifies it. IF not set to max_input_tokens, if provider specifies it.", + "mode": "one of: chat, embedding, completion, image_generation, audio_transcription, audio_speech, image_generation, moderation, rerank, search", + "output_cost_per_reasoning_token": 0.0, + "output_cost_per_token": 0.0, + "search_context_cost_per_query": { + "search_context_size_high": 0.0, + "search_context_size_low": 0.0, + "search_context_size_medium": 0.0 + }, + "supported_regions": [ + "global", + "us-west-2", + "eu-west-1", + "ap-southeast-1", + "ap-northeast-1" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_web_search": true, + "vector_store_cost_per_gb_per_day": 0.0 + }, "1024-x-1024/50-steps/bedrock/amazon.nova-canvas-v1:0": { "litellm_provider": "bedrock", "max_input_tokens": 2600, @@ -471,6 +511,26 @@ "supports_tool_choice": true, "supports_vision": true }, + "anthropic.claude-3-7-sonnet-20240620-v1:0": { + "cache_creation_input_token_cost": 4.5e-06, + "cache_read_input_token_cost": 3.6e-07, + "input_cost_per_token": 3.6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.8e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "anthropic.claude-3-7-sonnet-20250219-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, @@ -4284,6 +4344,26 @@ "mode": "chat", "output_cost_per_token": 1.5e-06 }, + "bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0": { + "cache_creation_input_token_cost": 4.5e-06, + "cache_read_input_token_cost": 3.6e-07, + "input_cost_per_token": 3.6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.8e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", @@ -6380,6 +6460,11 @@ "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, + "dataforseo/search": { + "input_cost_per_query": 0.003, + "litellm_provider": "dataforseo", + "mode": "search" + }, "davinci-002": { "input_cost_per_token": 2e-06, "litellm_provider": "text-completion-openai", @@ -7720,6 +7805,31 @@ "output_cost_per_token": 0.0, "output_vector_size": 2560 }, + "exa_ai/search": { + "litellm_provider": "exa_ai", + "mode": "search", + "tiered_pricing": [ + { + "input_cost_per_query": 5e-03, + "max_results_range": [ + 0, + 25 + ] + }, + { + "input_cost_per_query": 25e-03, + "max_results_range": [ + 26, + 100 + ] + } + ] + }, + "perplexity/search": { + "input_cost_per_query": 5e-03, + "litellm_provider": "perplexity", + "mode": "search" + }, "elevenlabs/scribe_v1": { "input_cost_per_second": 6.11e-05, "litellm_provider": "elevenlabs", @@ -12131,6 +12241,11 @@ "video" ] }, + "google_pse/search": { + "input_cost_per_query": 0.005, + "litellm_provider": "google_pse", + "mode": "search" + }, "global.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, @@ -18722,6 +18837,16 @@ "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, + "parallel_ai/search": { + "input_cost_per_query": 0.004, + "litellm_provider": "parallel_ai", + "mode": "search" + }, + "parallel_ai/search-pro": { + "input_cost_per_query": 0.009, + "litellm_provider": "parallel_ai", + "mode": "search" + }, "perplexity/codellama-34b-instruct": { "input_cost_per_token": 3.5e-07, "litellm_provider": "perplexity", @@ -19501,46 +19626,7 @@ "supports_reasoning": true, "source": "https://cloud.sambanova.ai/plans/pricing" }, - "sample_spec": { - "code_interpreter_cost_per_session": 0.0, - "computer_use_input_cost_per_1k_tokens": 0.0, - "computer_use_output_cost_per_1k_tokens": 0.0, - "deprecation_date": "date when the model becomes deprecated in the format YYYY-MM-DD", - "file_search_cost_per_1k_calls": 0.0, - "file_search_cost_per_gb_per_day": 0.0, - "input_cost_per_audio_token": 0.0, - "input_cost_per_token": 0.0, - "litellm_provider": "one of https://docs.litellm.ai/docs/providers", - "max_input_tokens": "max input tokens, if the provider specifies it. if not default to max_tokens", - "max_output_tokens": "max output tokens, if the provider specifies it. if not default to max_tokens", - "max_tokens": "LEGACY parameter. set to max_output_tokens if provider specifies it. IF not set to max_input_tokens, if provider specifies it.", - "mode": "one of: chat, embedding, completion, image_generation, audio_transcription, audio_speech, image_generation, moderation, rerank", - "output_cost_per_reasoning_token": 0.0, - "output_cost_per_token": 0.0, - "search_context_cost_per_query": { - "search_context_size_high": 0.0, - "search_context_size_low": 0.0, - "search_context_size_medium": 0.0 - }, - "supported_regions": [ - "global", - "us-west-2", - "eu-west-1", - "ap-southeast-1", - "ap-northeast-1" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_vision": true, - "supports_web_search": true, - "vector_store_cost_per_gb_per_day": 0.0 - }, + "snowflake/claude-3-5-sonnet": { "litellm_provider": "snowflake", "max_input_tokens": 18000, @@ -19771,6 +19857,16 @@ "mode": "image_generation", "output_cost_per_pixel": 0.0 }, + "tavily/search": { + "input_cost_per_query": 0.008, + "litellm_provider": "tavily", + "mode": "search" + }, + "tavily/search-advanced": { + "input_cost_per_query": 0.016, + "litellm_provider": "tavily", + "mode": "search" + }, "text-bison": { "input_cost_per_character": 2.5e-07, "litellm_provider": "vertex_ai-text-models", diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 5e5099426a0..de4f6fafc3c 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -1,5 +1,5 @@ import json -from typing import Optional, Tuple +from typing import Optional from urllib.parse import urlencode, urlparse, urlunparse from fastapi import APIRouter, Form, HTTPException, Request @@ -19,32 +19,47 @@ router = APIRouter( ) -def encode_state_with_base_url(base_url: str, original_state: str) -> str: +def encode_state_with_base_url( + base_url: str, + original_state: str, + code_challenge: Optional[str] = None, + code_challenge_method: Optional[str] = None, + client_redirect_uri: Optional[str] = None, +) -> str: """ - Encode the base_url and original state using encryption. + Encode the base_url, original state, and PKCE parameters using encryption. Args: base_url: The base URL to encode original_state: The original state parameter + code_challenge: PKCE code challenge from client + code_challenge_method: PKCE code challenge method from client + client_redirect_uri: Original redirect_uri from client Returns: - An encrypted string that encodes both values + An encrypted string that encodes all values """ - state_data = {"base_url": base_url, "original_state": original_state} + state_data = { + "base_url": base_url, + "original_state": original_state, + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + "client_redirect_uri": client_redirect_uri, + } state_json = json.dumps(state_data, sort_keys=True) encrypted_state = encrypt_value_helper(state_json) return encrypted_state -def decode_state_hash(encrypted_state: str) -> Tuple[str, str]: +def decode_state_hash(encrypted_state: str) -> dict: """ - Decode an encrypted state to retrieve the base_url and original state. + Decode an encrypted state to retrieve all OAuth session data. Args: encrypted_state: The encrypted string to decode Returns: - A tuple of (base_url, original_state) + A dict containing base_url, original_state, and optional PKCE parameters Raises: Exception: If decryption fails or data is malformed @@ -54,7 +69,7 @@ def decode_state_hash(encrypted_state: str) -> Tuple[str, str]: raise ValueError("Failed to decrypt state parameter") state_data = json.loads(decrypted_json) - return state_data["base_url"], state_data["original_state"] + return state_data @router.get("/{mcp_server_name}/authorize") @@ -65,8 +80,12 @@ async def authorize( redirect_uri: str, state: str = "", mcp_server_name: Optional[str] = None, + code_challenge: Optional[str] = None, + code_challenge_method: Optional[str] = None, + response_type: Optional[str] = None, + scope: Optional[str] = None, ): - # Redirect to real GitHub OAuth + # Redirect to real OAuth provider with PKCE support from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) @@ -90,15 +109,30 @@ async def authorize( base_url = urlunparse(parsed._replace(query="")) request_base_url = str(request.base_url).rstrip("/") - # Encode the base_url and original state in a unique hash - encoded_state = encode_state_with_base_url(base_url, state) + # Encode the base_url, original state, PKCE params, and client redirect_uri in encrypted state + encoded_state = encode_state_with_base_url( + base_url=base_url, + original_state=state, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + client_redirect_uri=redirect_uri, + ) + # Build params for upstream OAuth provider params = { "client_id": mcp_server.client_id, "redirect_uri": f"{request_base_url}/callback", - "scope": " ".join(mcp_server.scopes), + "scope": scope or " ".join(mcp_server.scopes), "state": encoded_state, + "response_type": response_type or "code", } + + # Forward PKCE parameters if present + if code_challenge: + params["code_challenge"] = code_challenge + if code_challenge_method: + params["code_challenge_method"] = code_challenge_method + return RedirectResponse(f"{mcp_server.authorization_url}?{urlencode(params)}") @@ -110,15 +144,16 @@ async def token_endpoint( redirect_uri: str = Form(None), client_id: str = Form(...), client_secret: str = Form(...), + code_verifier: str = Form(None), ): """ - Accept the authorization code from Claude and exchange it for GitHub token. - Forward the GitHub token back to Claude in standard OAuth format. + Accept the authorization code from client and exchange it for OAuth token. + Supports PKCE flow by forwarding code_verifier to upstream provider. - 1. Call the token endpoint - 2. Store the user's PAT in the db - and generate a LiteLLM virtual key - 2. Return the token - 3. Return a virtual key in this response + 1. Call the token endpoint with PKCE parameters + 2. Store the user's token in the db - and generate a LiteLLM virtual key + 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, @@ -136,41 +171,60 @@ async def token_endpoint( proxy_base_url = str(request.base_url).rstrip("/") - # Exchange code for real GitHub token + # Build token request data + token_data = { + "grant_type": "authorization_code", + "client_id": mcp_server.client_id, + "client_secret": mcp_server.client_secret, + "code": code, + "redirect_uri": f"{proxy_base_url}/callback", + } + + # Forward PKCE code_verifier if present + if code_verifier: + token_data["code_verifier"] = code_verifier + + # Exchange code for real OAuth token async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) response = await async_client.post( mcp_server.token_url, headers={"Accept": "application/json"}, - data={ - "client_id": mcp_server.client_id, - "client_secret": mcp_server.client_secret, - "code": code, - "redirect_uri": f"{proxy_base_url}/callback", - }, + data=token_data, ) response.raise_for_status() - github_token = response.json()["access_token"] + token_response = response.json() + access_token = token_response["access_token"] - # Return to Claude in expected OAuth 2 format + # Return to client in expected OAuth 2 format + # Only include fields that have values + result = { + "access_token": access_token, + "token_type": token_response.get("token_type", "Bearer"), + "expires_in": token_response.get("expires_in", 3600), + } - ### return a virtual key in this response + # Add optional fields only if they exist + if "refresh_token" in token_response and token_response["refresh_token"]: + result["refresh_token"] = token_response["refresh_token"] + if "scope" in token_response and token_response["scope"]: + result["scope"] = token_response["scope"] - return JSONResponse( - {"access_token": github_token, "token_type": "Bearer", "expires_in": 3600} - ) + return JSONResponse(result) @router.get("/callback") async def callback(code: str, state: str): try: - # Decode the state hash to get base_url and original state - base_url, original_state = decode_state_hash(state) + # Decode the state hash to get base_url, original state, and PKCE params + state_data = decode_state_hash(state) + base_url = state_data["base_url"] + original_state = state_data["original_state"] - # Exchange code for token with GitHub + # Forward code and original state back to client params = {"code": code, "state": original_state} - # Forward token to Claude ephemeral endpoint + # Forward to client's callback endpoint complete_returned_url = f"{base_url}?{urlencode(params)}" return RedirectResponse(url=complete_returned_url, status_code=302) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index f313a673827..c25f3938e43 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -1092,12 +1092,18 @@ class MCPServerManager: GuardrailRaisedException: If guardrails block the call HTTPException: If an HTTP error occurs """ - # Get server-specific auth header if available + # Get server-specific auth header if available (case-insensitive) + # FIX: Added case-insensitive matching to handle auth header keys that may not match + # the exact case of server alias/name (e.g., '1litellmagcgateway' vs '1LiteLLMAGCGateway') server_auth_header: Optional[Union[Dict[str, str], str]] = None - if mcp_server_auth_headers and mcp_server.alias: - server_auth_header = mcp_server_auth_headers.get(mcp_server.alias) - elif mcp_server_auth_headers and mcp_server.server_name: - server_auth_header = mcp_server_auth_headers.get(mcp_server.server_name) + if mcp_server_auth_headers: + # Normalize keys for case-insensitive lookup + normalized_headers = {k.lower(): v for k, v in mcp_server_auth_headers.items()} + + if mcp_server.alias: + server_auth_header = normalized_headers.get(mcp_server.alias.lower()) + if server_auth_header is None and mcp_server.server_name: + server_auth_header = normalized_headers.get(mcp_server.server_name.lower()) # Fall back to deprecated mcp_auth_header if no server-specific header found if server_auth_header is None: diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 6a9c425a81b..e427507a4b8 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -212,6 +212,7 @@ if MCP_AVAILABLE: from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException from litellm.proxy.proxy_server import add_litellm_data_to_request, proxy_config + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler try: data = await request.json() @@ -221,7 +222,23 @@ if MCP_AVAILABLE: user_api_key_dict=user_api_key_dict, proxy_config=proxy_config, ) - return await call_mcp_tool(**data) + + # FIX: Extract MCP auth headers from request + # The UI sends bearer token in x-mcp-auth header and server-specific headers, + # but they weren't being extracted and passed to call_mcp_tool. + # This fix ensures auth headers are properly extracted from the HTTP request + # and passed through to the MCP server for authentication. + mcp_auth_header = MCPRequestHandler._get_mcp_auth_header_from_headers(request.headers) + mcp_server_auth_headers = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(request.headers) + + # Add extracted headers to data dict to pass to call_mcp_tool + if mcp_auth_header: + data["mcp_auth_header"] = mcp_auth_header + if mcp_server_auth_headers: + data["mcp_server_auth_headers"] = mcp_server_auth_headers + + result = await call_mcp_tool(**data) + return result except BlockedPiiEntityError as e: verbose_logger.error(f"BlockedPiiEntityError in MCP tool call: {str(e)}") raise HTTPException( diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index c0a369cbc25..7725446d10c 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -3,5 +3,34 @@ model_list: litellm_params: model: bedrock/global.anthropic.claude-sonnet-4-5-20250929-v1:0 -litellm_settings: - callbacks: ["otel"] \ No newline at end of file +vector_store_registry: + - vector_store_name: "vertex-ai-litellm-website-knowledgebase" + litellm_params: + vector_store_id: "test-litellm-app_1761094730750" + custom_llm_provider: "vertex_ai/search_api" + vertex_project: "test-litellm-app" + vertex_location: "us-central1" + vector_store_description: "Vertex AI vector store for the Litellm website knowledgebase" + vector_store_metadata: + source: "https://www.litellm.com/docs" +mcp_servers: + github_mcp: + url: "https://api.githubcopilot.com/mcp" + auth_type: oauth2 + authorization_url: https://github.com/login/oauth/authorize + token_url: https://github.com/login/oauth/access_token + client_id: os.environ/GITHUB_OAUTH_CLIENT_ID + client_secret: os.environ/GITHUB_OAUTH_CLIENT_SECRET + scopes: ["public_repo", "user:email"] + +general_settings: + pass_through_endpoints: + - path: "/fake-openai-proxy-10" # Route on LiteLLM Proxy + target: "https://webhook.site/74bbcc59-a61f-4028-81e2-9e06814e81fe" # Target endpoint + headers: # Headers to forward + Authorization: "bearer sk-1234" + content-type: application/json + accept: application/json + auth: true + include_subpath: true + cost_per_request: 0 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index a8376d89440..36446e65e66 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -323,6 +323,14 @@ class LiteLLMRoutes(enum.Enum): "/v1/vector_stores", "/vector_stores/{vector_store_id}/search", "/v1/vector_stores/{vector_store_id}/search", + + # search + "/search", + "/v1/search", + + # OCR + "/ocr", + "/v1/ocr", ] mapped_pass_through_routes = [ @@ -1620,6 +1628,10 @@ class PassThroughGenericEndpoint(LiteLLMPydanticObjectBase): default=0.0, description="The USD cost per request to the target endpoint. This is used to calculate the cost of the request to the target endpoint.", ) + auth: bool = Field( + default=False, + description="Whether authentication is required for the pass-through endpoint. If True, requests to the endpoint will require a valid LiteLLM API key.", + ) class PassThroughEndpointResponse(LiteLLMPydanticObjectBase): diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index a92e8c8cbd4..e4456b71779 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -998,7 +998,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 ) # Check 4. Token Spend is under budget - if route in LiteLLMRoutes.llm_api_routes.value: + if RouteChecks.is_llm_api_route(route=route): await _virtual_key_max_budget_check( valid_token=valid_token, proxy_logging_obj=proxy_logging_obj, diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index a256eae0325..8c87622ad6e 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -311,6 +311,7 @@ class ProxyBaseLLMRequestProcessing: "avector_store_search", "avector_store_create", "aocr", + "asearch", ], version: Optional[str] = None, user_model: Optional[str] = None, @@ -402,6 +403,7 @@ class ProxyBaseLLMRequestProcessing: "avector_store_search", "avector_store_create", "aocr", + "asearch", ], proxy_logging_obj: ProxyLogging, general_settings: dict, diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index c146536e632..29da0ec40e6 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -809,6 +809,105 @@ def _get_list_element_options(field_annotation: Any) -> Optional[List[str]]: return None +def _should_skip_optional_params(field_name: str, field_annotation: Any) -> bool: + """Check if optional_params field should be skipped (not meaningfully overridden).""" + if field_name != "optional_params": + return False + + if field_annotation is None: + return True + + # Check if the annotation is still a generic TypeVar (not specialized) + if isinstance(field_annotation, TypeVar) or ( + hasattr(field_annotation, "__origin__") + and field_annotation.__origin__ is TypeVar + ): + return True + + # Also skip if it's a generic type that wasn't specialized + if hasattr(field_annotation, "__name__") and field_annotation.__name__ in ( + "T", + "TypeVar", + ): + return True + + # Handle Optional[T] where T is still a TypeVar + if hasattr(field_annotation, "__args__"): + non_none_args = [arg for arg in field_annotation.__args__ if arg is not type(None)] + if non_none_args and isinstance(non_none_args[0], TypeVar): + return True + + return False + + +def _unwrap_optional_type(field_annotation: Any) -> Any: + """Unwrap Optional types to get the actual type.""" + if ( + hasattr(field_annotation, "__origin__") + and field_annotation.__origin__ is Union + and hasattr(field_annotation, "__args__") + ): + # For Optional[BaseModel], get the non-None type + args = field_annotation.__args__ + non_none_args = [arg for arg in args if arg is not type(None)] + if non_none_args: + return non_none_args[0] + return field_annotation + + +def _build_field_dict( + field: Any, + field_annotation: Any, + description: str, + required: bool, +) -> Dict[str, Any]: + """Build field dictionary for non-nested fields.""" + # Determine the field type from annotation + field_type = _get_field_type_from_annotation(field_annotation) + + # Check for custom UI type override + field_json_schema_extra = getattr(field, "json_schema_extra", {}) + if field_json_schema_extra and "ui_type" in field_json_schema_extra: + field_type = field_json_schema_extra["ui_type"].value + elif field_json_schema_extra and "type" in field_json_schema_extra: + field_type = field_json_schema_extra["type"] + + # Add the field to the dictionary + field_dict = { + "description": description, + "required": required, + "type": field_type, + } + + # Extract options from type annotations + if field_type == "dict": + # For Dict[Literal[...], T] types, extract key options + dict_key_options = _get_dict_key_options(field_annotation) + if dict_key_options: + field_dict["dict_key_options"] = dict_key_options + + # Extract value type for the dict values + dict_value_type = _get_dict_value_type(field_annotation) + field_dict["dict_value_type"] = dict_value_type + + elif field_type == "array": + # For List[Literal[...]] types, extract element options + list_element_options = _get_list_element_options(field_annotation) + if list_element_options: + field_dict["options"] = list_element_options + field_dict["type"] = "multiselect" + + # Add options if they exist in json_schema_extra (this takes precedence) + if field_json_schema_extra and "options" in field_json_schema_extra: + field_dict["options"] = field_json_schema_extra["options"] + + # Add default value if it exists + if field.default is not None and field.default is not ...: + field_dict["default_value"] = field.default + + return field_dict + + def _extract_fields_recursive( model: Type[BaseModel], depth: int = 0, @@ -823,48 +922,21 @@ def _extract_fields_recursive( fields = {} for field_name, field in model.model_fields.items(): - # Skip optional_params if it's not meaningfully overridden - if field_name == "optional_params": - field_annotation = field.annotation - if field_annotation is None: - continue - # Check if the annotation is still a generic TypeVar (not specialized) - if isinstance(field_annotation, TypeVar) or ( - hasattr(field_annotation, "__origin__") - and field_annotation.__origin__ is TypeVar - ): - # Skip this field as it's not meaningfully overridden - continue - # Also skip if it's a generic type that wasn't specialized - if hasattr(field_annotation, "__name__") and field_annotation.__name__ in ( - "T", - "TypeVar", - ): - continue - - # Get field metadata - description = field.description or field_name - - # Check if this field is required - required = field.is_required() - - # Check if the field annotation is a BaseModel subclass field_annotation = field.annotation + + # Skip optional_params if it's not meaningfully overridden + if _should_skip_optional_params(field_name=field_name, field_annotation=field_annotation): + continue # Handle Optional types and get the actual type if field_annotation is None: continue - if ( - hasattr(field_annotation, "__origin__") - and field_annotation.__origin__ is Union - and hasattr(field_annotation, "__args__") - ): - # For Optional[BaseModel], get the non-None type - args = field_annotation.__args__ - non_none_args = [arg for arg in args if arg is not type(None)] - if non_none_args: - field_annotation = non_none_args[0] + field_annotation = _unwrap_optional_type(field_annotation=field_annotation) + + # Get field metadata + description = field.description or field_name + required = field.is_required() # Check if this is a BaseModel subclass is_basemodel_subclass = ( @@ -885,50 +957,12 @@ def _extract_fields_recursive( "fields": nested_fields, } else: - # Determine the field type from annotation - field_type = _get_field_type_from_annotation(field_annotation) - - # Check for custom UI type override - field_json_schema_extra = getattr(field, "json_schema_extra", {}) - if field_json_schema_extra and "ui_type" in field_json_schema_extra: - field_type = field_json_schema_extra["ui_type"].value - elif field_json_schema_extra and "type" in field_json_schema_extra: - field_type = field_json_schema_extra["type"] - - # Add the field to the dictionary - field_dict = { - "description": description, - "required": required, - "type": field_type, - } - - # Extract options from type annotations - if field_type == "dict": - # For Dict[Literal[...], T] types, extract key options - dict_key_options = _get_dict_key_options(field_annotation) - if dict_key_options: - field_dict["dict_key_options"] = dict_key_options - - # Extract value type for the dict values - dict_value_type = _get_dict_value_type(field_annotation) - field_dict["dict_value_type"] = dict_value_type - - elif field_type == "array": - # For List[Literal[...]] types, extract element options - list_element_options = _get_list_element_options(field_annotation) - if list_element_options: - field_dict["options"] = list_element_options - field_dict["type"] = "multiselect" - - # Add options if they exist in json_schema_extra (this takes precedence) - if field_json_schema_extra and "options" in field_json_schema_extra: - field_dict["options"] = field_json_schema_extra["options"] - - # Add default value if it exists - if field.default is not None and field.default is not ...: - field_dict["default_value"] = field.default - - fields[field_name] = field_dict + fields[field_name] = _build_field_dict( + field=field, + field_annotation=field_annotation, + description=description, + required=required, + ) return fields diff --git a/litellm/proxy/guardrails/guardrail_hooks/grayswan/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/grayswan/__init__.py new file mode 100644 index 00000000000..389340014f8 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/grayswan/__init__.py @@ -0,0 +1,74 @@ +"""Gray Swan Cygnal guardrail integration for LiteLLM.""" + +from typing import TYPE_CHECKING + +from litellm.types.guardrails import SupportedGuardrailIntegrations + +from .grayswan import ( + GraySwanGuardrail, + GraySwanGuardrailAPIError, + GraySwanGuardrailMissingSecrets, +) + +if TYPE_CHECKING: + from litellm.types.guardrails import Guardrail, LitellmParams + + +def initialize_guardrail( + litellm_params: "LitellmParams", guardrail: "Guardrail" +) -> GraySwanGuardrail: + import litellm + + guardrail_name = guardrail.get("guardrail_name") + if not guardrail_name: + raise ValueError("Gray Swan guardrail requires a guardrail_name") + + optional_params = getattr(litellm_params, "optional_params", None) + + grayswan_guardrail = GraySwanGuardrail( + guardrail_name=guardrail_name, + api_key=litellm_params.api_key, + api_base=litellm_params.api_base, + on_flagged_action=_get_config_value( + litellm_params, optional_params, "on_flagged_action" + ), + violation_threshold=_get_config_value( + litellm_params, optional_params, "violation_threshold" + ), + reasoning_mode=_get_config_value( + litellm_params, optional_params, "reasoning_mode" + ), + categories=_get_config_value(litellm_params, optional_params, "categories"), + policy_id=_get_config_value(litellm_params, optional_params, "policy_id"), + event_hook=litellm_params.mode, + default_on=litellm_params.default_on, + ) + + litellm.logging_callback_manager.add_litellm_callback(grayswan_guardrail) + return grayswan_guardrail + + +def _get_config_value(litellm_params, optional_params, attribute_name): + if optional_params is not None: + value = getattr(optional_params, attribute_name, None) + if value is not None: + return value + return getattr(litellm_params, attribute_name, None) + + +guardrail_initializer_registry = { + SupportedGuardrailIntegrations.GRAYSWAN.value: initialize_guardrail, +} + + +guardrail_class_registry = { + SupportedGuardrailIntegrations.GRAYSWAN.value: GraySwanGuardrail, +} + + +__all__ = [ + "GraySwanGuardrail", + "GraySwanGuardrailAPIError", + "GraySwanGuardrailMissingSecrets", + "initialize_guardrail", +] diff --git a/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py b/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py new file mode 100644 index 00000000000..00e30d3e5ba --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py @@ -0,0 +1,365 @@ +"""Gray Swan Cygnal guardrail integration.""" + +import os +from typing import Any, Dict, Literal, Optional, Union + +from fastapi import HTTPException + +from litellm._logging import verbose_proxy_logger +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, + httpxSpecialProvider, +) +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.common_utils.callback_utils import ( + add_guardrail_to_applied_guardrails_header, +) +from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.utils import LLMResponseTypes + + +class GraySwanGuardrailMissingSecrets(Exception): + """Raised when the Gray Swan API key is missing.""" + + +class GraySwanGuardrailAPIError(Exception): + """Raised when the Gray Swan API returns an error.""" + + +class GraySwanGuardrail(CustomGuardrail): + """ + Guardrail that calls Gray Swan's Cygnal monitoring endpoint. + + see: https://docs.grayswan.ai/cygnal/monitor-requests + """ + + SUPPORTED_ON_FLAGGED_ACTIONS = {"block", "monitor"} + DEFAULT_ON_FLAGGED_ACTION = "monitor" + BASE_API_URL = "https://api.grayswan.ai" + MONITOR_PATH = "/cygnal/monitor" + SUPPORTED_REASONING_MODES = {"off", "hybrid", "thinking"} + + def __init__( + self, + guardrail_name: Optional[str] = "grayswan", + api_key: Optional[str] = None, + api_base: Optional[str] = None, + on_flagged_action: Optional[str] = None, + violation_threshold: Optional[float] = None, + reasoning_mode: Optional[str] = None, + categories: Optional[Dict[str, str]] = None, + policy_id: Optional[str] = None, + **kwargs: Any, + ) -> None: + self.async_handler = get_async_httpx_client( + llm_provider=httpxSpecialProvider.GuardrailCallback + ) + + api_key_value = api_key or os.getenv("GRAYSWAN_API_KEY") + if not api_key_value: + raise GraySwanGuardrailMissingSecrets( + "Gray Swan API key missing. Set `GRAYSWAN_API_KEY` or pass `api_key`." + ) + self.api_key: str = api_key_value + + base = api_base or os.getenv("GRAYSWAN_API_BASE") or self.BASE_API_URL + self.api_base = base.rstrip("/") + self.monitor_url = f"{self.api_base}{self.MONITOR_PATH}" + + action = on_flagged_action + if action and action.lower() in self.SUPPORTED_ON_FLAGGED_ACTIONS: + self.on_flagged_action = action.lower() + else: + if action: + verbose_proxy_logger.warning( + "Gray Swan Guardrail: Unsupported on_flagged_action '%s', defaulting to '%s'.", + action, + self.DEFAULT_ON_FLAGGED_ACTION, + ) + self.on_flagged_action = self.DEFAULT_ON_FLAGGED_ACTION + + self.violation_threshold = self._resolve_threshold(violation_threshold) + self.reasoning_mode = self._resolve_reasoning_mode(reasoning_mode) + self.categories = categories + self.policy_id = policy_id + + supported_event_hooks = [ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.during_call, + GuardrailEventHooks.post_call, + ] + + super().__init__( + guardrail_name=guardrail_name, + supported_event_hooks=supported_event_hooks, + **kwargs, + ) + + # ------------------------------------------------------------------ + # Guardrail hook entry points + # ------------------------------------------------------------------ + + @log_guardrail_information + async def async_pre_call_hook( + self, + user_api_key_dict: UserAPIKeyAuth, + cache, + data: dict, + call_type: Literal[ + "completion", + "text_completion", + "embeddings", + "image_generation", + "moderation", + "audio_transcription", + "pass_through_endpoint", + "rerank", + "mcp_call", + "anthropic_messages", + ], + ) -> Optional[Union[Exception, str, dict]]: + if ( + self.should_run_guardrail( + data=data, event_type=GuardrailEventHooks.pre_call + ) + is not True + ): + return data + + verbose_proxy_logger.debug("Gray Swan Guardrail: pre-call hook triggered") + + messages = data.get("messages") + if not messages: + verbose_proxy_logger.debug("Gray Swan Guardrail: No messages in data") + return data + + dynamic_body = self.get_guardrail_dynamic_request_body_params(data) or {} + + payload = self._prepare_payload(messages, dynamic_body) + if payload is None: + verbose_proxy_logger.debug( + "Gray Swan Guardrail: no content to scan; skipping request" + ) + return data + + await self.run_grayswan_guardrail(payload) + add_guardrail_to_applied_guardrails_header( + request_data=data, guardrail_name=self.guardrail_name + ) + return data + + @log_guardrail_information + async def async_moderation_hook( + self, + data: dict, + user_api_key_dict: UserAPIKeyAuth, + call_type: Literal[ + "completion", + "embeddings", + "image_generation", + "moderation", + "audio_transcription", + "responses", + "mcp_call", + "anthropic_messages", + ], + ) -> Optional[Union[Exception, str, dict]]: + if ( + self.should_run_guardrail( + data=data, event_type=GuardrailEventHooks.during_call + ) + is not True + ): + return data + + verbose_proxy_logger.debug("GraySwan Guardrail: during-call hook triggered") + + messages = data.get("messages") + if not messages: + verbose_proxy_logger.debug("Gray Swan Guardrail: No messages in data") + return data + + dynamic_body = self.get_guardrail_dynamic_request_body_params(data) or {} + + payload = self._prepare_payload(messages, dynamic_body) + if payload is None: + verbose_proxy_logger.debug( + "Gray Swan Guardrail: no content to scan; skipping request" + ) + return data + + await self.run_grayswan_guardrail(payload) + add_guardrail_to_applied_guardrails_header( + request_data=data, guardrail_name=self.guardrail_name + ) + return data + + @log_guardrail_information + async def async_post_call_success_hook( + self, + data: dict, + user_api_key_dict: UserAPIKeyAuth, + response: LLMResponseTypes, + ) -> LLMResponseTypes: + if ( + self.should_run_guardrail( + data=data, event_type=GuardrailEventHooks.post_call + ) + is not True + ): + return response + + verbose_proxy_logger.debug("GraySwan Guardrail: post-call hook triggered") + + response_dict = response.model_dump() if hasattr(response, "model_dump") else {} + response_messages = [ + msg if isinstance(msg, dict) else msg.model_dump() + for choice in response_dict.get("choices", []) + if isinstance(choice, dict) + for msg in [choice.get("message")] + if msg is not None + ] + + if not response_messages: + verbose_proxy_logger.debug( + "Gray Swan Guardrail: no response messages detected; skipping post-call scan" + ) + return response + + dynamic_body = self.get_guardrail_dynamic_request_body_params(data) or {} + + payload = self._prepare_payload(response_messages, dynamic_body) + if payload is None: + verbose_proxy_logger.debug( + "Gray Swan Guardrail: no content to scan; skipping request" + ) + return response + + await self.run_grayswan_guardrail(payload) + add_guardrail_to_applied_guardrails_header( + request_data=data, guardrail_name=self.guardrail_name + ) + return response + + # ------------------------------------------------------------------ + # Core GraySwan interaction + # ------------------------------------------------------------------ + + async def run_grayswan_guardrail(self, payload: dict): + headers = self._prepare_headers() + + try: + response = await self.async_handler.post( + url=self.monitor_url, + headers=headers, + json=payload, + timeout=30.0, + ) + response.raise_for_status() + result = response.json() + verbose_proxy_logger.debug( + "Gray Swan Guardrail: monitor response %s", safe_dumps(result) + ) + except HTTPException: + raise + except Exception as exc: # pragma: no cover - depends on HTTP client behaviour + verbose_proxy_logger.exception( + "Gray Swan Guardrail: API request failed: %s", exc + ) + raise GraySwanGuardrailAPIError(str(exc)) from exc + + self._process_grayswan_response(result) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _prepare_headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "grayswan-api-key": self.api_key, + } + + def _prepare_payload( + self, messages: list[dict], dynamic_body: dict + ) -> Optional[Dict[str, Any]]: + payload: Dict[str, Any] = {} + payload["messages"] = messages + + categories = dynamic_body.get("categories") or self.categories + if categories: + payload["categories"] = categories + + policy_id = dynamic_body.get("policy_id") or self.policy_id + if policy_id: + payload["policy_id"] = policy_id + + reasoning_mode = dynamic_body.get("reasoning_mode") or self.reasoning_mode + if reasoning_mode: + payload["reasoning_mode"] = reasoning_mode + + return payload + + def _process_grayswan_response(self, response_json: Dict[str, Any]) -> None: + violation_score = float(response_json.get("violation", 0.0) or 0.0) + violated_rules = response_json.get("violated_rules", []) + mutation_detected = response_json.get("mutation") + ipi_detected = response_json.get("ipi") + + flagged = violation_score >= self.violation_threshold + if not flagged: + verbose_proxy_logger.debug( + "Gray Swan Guardrail: request passed (score=%s, rules=%s)", + violation_score, + violated_rules, + ) + return + + verbose_proxy_logger.warning( + "Gray Swan Guardrail: violation score %.3f exceeds threshold %.3f", + violation_score, + self.violation_threshold, + ) + + if self.on_flagged_action == "block": + raise HTTPException( + status_code=400, + detail={ + "error": "Blocked by Gray Swan Guardrail", + "violation": violation_score, + "violated_rules": violated_rules, + "mutation": mutation_detected, + "ipi": ipi_detected, + }, + ) + + def _resolve_threshold(self, threshold: Optional[float]) -> float: + if threshold is not None: + return min(max(threshold, 0.0), 1.0) + return 0.5 + + def _resolve_reasoning_mode(self, candidate: Optional[str]) -> Optional[str]: + if candidate is None: + return None + normalised = candidate.strip().lower() + if normalised in self.SUPPORTED_REASONING_MODES: + return normalised + verbose_proxy_logger.warning( + "Gray Swan Guardrail: ignoring unsupported reasoning_mode '%s'", + candidate, + ) + return None + + @staticmethod + def get_config_model(): + from litellm.types.proxy.guardrails.guardrail_hooks.grayswan import ( + GraySwanGuardrailConfigModel, + ) + + return GraySwanGuardrailConfigModel diff --git a/litellm/proxy/health_check.py b/litellm/proxy/health_check.py index c1103f2c12c..a0483a08bde 100644 --- a/litellm/proxy/health_check.py +++ b/litellm/proxy/health_check.py @@ -138,6 +138,7 @@ def _update_litellm_params_for_health_check( - gets a short `messages` param for health check - updates the `model` param with the `health_check_model` if it exists Doc: https://docs.litellm.ai/docs/proxy/health#wildcard-routes - updates the `voice` param with the `health_check_voice` for `audio_speech` mode if it exists Doc: https://docs.litellm.ai/docs/proxy/health#text-to-speech-models + - updates the `model` param with the Bedrock base model name if it is a Bedrock model """ litellm_params["messages"] = _get_random_llm_message() _health_check_model = model_info.get("health_check_model", None) @@ -145,6 +146,9 @@ def _update_litellm_params_for_health_check( litellm_params["model"] = _health_check_model if model_info.get("mode", None) == "audio_speech": litellm_params["voice"] = model_info.get("health_check_voice", "alloy") + if "bedrock" in litellm_params["model"]: + from litellm.llms.bedrock.common_utils import BedrockModelInfo + litellm_params["model"] = BedrockModelInfo.get_base_model(litellm_params["model"]) return litellm_params diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 9964b6c94cc..d8ce39aaf8a 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -620,7 +620,7 @@ async def shared_health_check_status_endpoint( Returns information about Redis connectivity, lock status, and cache status. """ - from litellm.proxy.proxy_server import use_shared_health_check, redis_usage_cache + from litellm.proxy.proxy_server import redis_usage_cache, use_shared_health_check if not use_shared_health_check: return { @@ -636,7 +636,9 @@ async def shared_health_check_status_endpoint( } try: - from litellm.proxy.health_check_utils.shared_health_check_manager import SharedHealthCheckManager + from litellm.proxy.health_check_utils.shared_health_check_manager import ( + SharedHealthCheckManager, + ) shared_health_manager = SharedHealthCheckManager( redis_cache=redis_usage_cache, @@ -910,6 +912,7 @@ async def test_model_connection( "batch", "rerank", "realtime", + "ocr", ] ] = fastapi.Body("chat", description="The mode to test the model with"), litellm_params: Dict = fastapi.Body( diff --git a/litellm/proxy/hooks/dynamic_rate_limiter.py b/litellm/proxy/hooks/dynamic_rate_limiter.py index 4e0eea1eab8..e7ddc9c36d8 100644 --- a/litellm/proxy/hooks/dynamic_rate_limiter.py +++ b/litellm/proxy/hooks/dynamic_rate_limiter.py @@ -17,6 +17,8 @@ from litellm.proxy._types import UserAPIKeyAuth from litellm.types.router import ModelGroupInfo from litellm.utils import get_utc_datetime +from .rate_limiter_utils import convert_priority_to_percent + class DynamicRateLimiterCache: """ @@ -99,6 +101,11 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger): - active_projects: int or null """ try: + # Get model info first for conversion + model_group_info: Optional[ + ModelGroupInfo + ] = self.llm_router.get_model_group_info(model_group=model) + weight: float = 1 if ( litellm.priority_reservation is None @@ -115,7 +122,8 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger): "PREMIUM FEATURE: Reserving tpm/rpm by priority is a premium feature. Please add a 'LITELLM_LICENSE' to your .env to enable this.\nGet a license: https://docs.litellm.ai/docs/proxy/enterprise." ) else: - weight = litellm.priority_reservation[priority] + value = litellm.priority_reservation[priority] + weight = convert_priority_to_percent(value, model_group_info) active_projects = await self.internal_usage_cache.async_get_cache( model=model @@ -124,9 +132,6 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger): current_model_tpm, current_model_rpm, ) = await self.llm_router.get_model_group_usage(model_group=model) - model_group_info: Optional[ - ModelGroupInfo - ] = self.llm_router.get_model_group_info(model_group=model) total_model_tpm: Optional[int] = None total_model_rpm: Optional[int] = None if model_group_info is not None: diff --git a/litellm/proxy/hooks/dynamic_rate_limiter_v3.py b/litellm/proxy/hooks/dynamic_rate_limiter_v3.py index 92b8dcf7ec3..d266aadc7b3 100644 --- a/litellm/proxy/hooks/dynamic_rate_limiter_v3.py +++ b/litellm/proxy/hooks/dynamic_rate_limiter_v3.py @@ -18,6 +18,7 @@ from litellm.proxy.hooks.parallel_request_limiter_v3 import ( RateLimitDescriptorRateLimitObject, _PROXY_MaxParallelRequestsHandler_v3, ) +from litellm.proxy.hooks.rate_limiter_utils import convert_priority_to_percent from litellm.proxy.utils import InternalUsageCache from litellm.types.router import ModelGroupInfo @@ -48,7 +49,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): def update_variables(self, llm_router: Router): self.llm_router = llm_router - def _get_priority_weight(self, priority: Optional[str]) -> float: + def _get_priority_weight(self, priority: Optional[str], model_info: Optional[ModelGroupInfo] = None) -> float: """Get the weight for a given priority from litellm.priority_reservation""" weight: float = litellm.priority_reservation_settings.default_priority if ( @@ -64,19 +65,25 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): "PREMIUM FEATURE: Reserving tpm/rpm by priority is a premium feature. Please add a 'LITELLM_LICENSE' to your .env to enable this.\nGet a license: https://docs.litellm.ai/docs/proxy/enterprise." ) else: - weight = litellm.priority_reservation[priority] + value = litellm.priority_reservation[priority] + weight = convert_priority_to_percent(value, model_info) return weight - def _normalize_priority_weights(self) -> Dict[str, float]: + def _normalize_priority_weights(self, model_info: ModelGroupInfo) -> Dict[str, float]: """ Normalize priority weights if they sum to > 1.0 Handles over-allocation: {key_a: 0.60, key_b: 0.80} -> {key_a: 0.43, key_b: 0.57} + Converts absolute rpm/tpm values to percentages based on model capacity. """ if litellm.priority_reservation is None: return {} - weights = dict(litellm.priority_reservation) + # Convert all values to percentages first + weights: Dict[str, float] = {} + for k, v in litellm.priority_reservation.items(): + weights[k] = convert_priority_to_percent(v, model_info) + total_weight = sum(weights.values()) if total_weight > 1.0: @@ -93,6 +100,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): model: str, priority: Optional[str], normalized_weights: Dict[str, float], + model_info: Optional[ModelGroupInfo] = None, ) -> tuple[float, str]: """ Get priority weight and pool key for a given priority. @@ -104,6 +112,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): model: Model name priority: Priority level (None for default) normalized_weights: Pre-computed normalized weights + model_info: Model configuration (optional, for fallback conversion) Returns: tuple: (priority_weight, priority_key) @@ -117,7 +126,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): if has_explicit_priority and priority is not None: # Explicit priority: get its specific allocation - priority_weight = normalized_weights.get(priority, self._get_priority_weight(priority)) + priority_weight = normalized_weights.get(priority, self._get_priority_weight(priority, model_info)) # Use unique key per priority level priority_key = f"{model}:{priority}" else: @@ -232,11 +241,12 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): return descriptors # Get normalized priority weight and pool key - normalized_weights = self._normalize_priority_weights() + normalized_weights = self._normalize_priority_weights(model_group_info) priority_weight, priority_key = self._get_priority_allocation( model=model, priority=priority, normalized_weights=normalized_weights, + model_info=model_group_info, ) rate_limit_config: RateLimitDescriptorRateLimitObject = {} diff --git a/litellm/proxy/hooks/rate_limiter_utils.py b/litellm/proxy/hooks/rate_limiter_utils.py new file mode 100644 index 00000000000..c6b0ef65bc6 --- /dev/null +++ b/litellm/proxy/hooks/rate_limiter_utils.py @@ -0,0 +1,55 @@ +""" +Shared utility functions for rate limiter hooks. +""" + +from typing import Optional, Union + +from litellm.types.router import ModelGroupInfo +from litellm.types.utils import PriorityReservationDict + + +def convert_priority_to_percent( + value: Union[float, PriorityReservationDict], model_info: Optional[ModelGroupInfo] +) -> float: + """ + Convert priority reservation value to percentage (0.0-1.0). + + Supports three formats: + 1. Plain float/int: 0.9 -> 0.9 (90%) + 2. Dict with percent: {"type": "percent", "value": 0.9} -> 0.9 + 3. Dict with rpm: {"type": "rpm", "value": 900} -> 900/model_rpm + 4. Dict with tpm: {"type": "tpm", "value": 900000} -> 900000/model_tpm + + Args: + value: Priority value as float or dict with type/value keys + model_info: Model configuration containing rpm/tpm limits + + Returns: + float: Percentage value between 0.0 and 1.0 + """ + if isinstance(value, (int, float)): + return float(value) + + if isinstance(value, dict): + val_type = value.get("type", "percent") + val_num = value.get("value", 1.0) + + if val_type == "percent": + return float(val_num) + elif ( + val_type == "rpm" + and model_info + and model_info.rpm + and model_info.rpm > 0 + ): + return float(val_num) / model_info.rpm + elif ( + val_type == "tpm" + and model_info + and model_info.tpm + and model_info.tpm > 0 + ): + return float(val_num) / model_info.tpm + + # Fallback: treat as percent + return float(val_num) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 6f9f04e5cc2..567905e62e1 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -545,12 +545,23 @@ async def handle_bedrock_passthrough_router_model( request: Request, request_body: dict, llm_router: litellm.Router, + user_api_key_dict: UserAPIKeyAuth, + proxy_logging_obj, + general_settings: dict, + proxy_config, + select_data_generator, + user_model: Optional[str], + user_temperature: Optional[float], + user_request_timeout: Optional[float], + user_max_tokens: Optional[int], + user_api_base: Optional[str], + version: Optional[str], ) -> Union[Response, StreamingResponse]: """ Handle Bedrock passthrough for router models (models defined in config.yaml). - This helper delegates to llm_router.allm_passthrough_route for proper credential - and configuration management from the router. + Uses the same common processing path as non-router models to ensure + metadata and hooks are properly initialized. Args: model: The router model name (e.g., "aws/anthropic/bedrock-claude-3-5-sonnet-v1") @@ -558,10 +569,16 @@ async def handle_bedrock_passthrough_router_model( request: The FastAPI request object request_body: The parsed request body llm_router: The LiteLLM router instance + user_api_key_dict: The user API key authentication dictionary + (additional args for common processing) Returns: Response or StreamingResponse depending on endpoint type """ + from fastapi import Response as FastAPIResponse + + from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing + # Detect streaming based on endpoint is_streaming = any(action in endpoint for action in BEDROCK_STREAMING_ACTIONS) @@ -569,83 +586,45 @@ async def handle_bedrock_passthrough_router_model( f"Bedrock router passthrough: model='{model}', endpoint='{endpoint}', streaming={is_streaming}" ) - # Call router passthrough + # Use the common processing path (same as non-router models) + # This ensures all metadata, hooks, and logging are properly initialized + data: Dict[str, Any] = {} + base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data) + + data["model"] = model + data["method"] = request.method + data["endpoint"] = endpoint + data["data"] = request_body + data["custom_llm_provider"] = "bedrock" + + # Use the common passthrough processing to handle metadata and hooks + # This also handles all response formatting (streaming/non-streaming) and exceptions try: - result = await llm_router.allm_passthrough_route( + result = await base_llm_response_processor.base_passthrough_process_llm_request( + request=request, + fastapi_response=FastAPIResponse(), + user_api_key_dict=user_api_key_dict, + 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=model, - method=request.method, - endpoint=endpoint, - request_query_params=request.query_params, - request_headers=dict(request.headers), - stream=is_streaming, - 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, - ) - except httpx.HTTPStatusError as e: - # Handle HTTP errors from the provider by converting to HTTPException - error_body = await e.response.aread() - error_text = error_body.decode("utf-8") - - raise HTTPException( - status_code=e.response.status_code, - detail={"error": error_text}, + 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, ) + return result except Exception as e: - from litellm.llms.base_llm.chat.transformation import BaseLLMException - - # If it's a BaseLLMException (from non-HTTP errors), convert to HTTPException - if isinstance(e, BaseLLMException): - raise HTTPException( - status_code=e.status_code, - detail={"error": e.message}, - ) - # Re-raise any other exceptions - raise e - - # Handle streaming response - if is_streaming: - import inspect - - if inspect.isasyncgen(result): - # AsyncGenerator case - return StreamingResponse( - content=result, - status_code=200, - headers={"content-type": "application/vnd.amazon.eventstream"}, - ) - else: - # httpx.Response case - 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, - ), - ) - - # Handle 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, - ), - ) + # Use common exception handling + raise await base_llm_response_processor._handle_llm_api_exception( + e=e, + user_api_key_dict=user_api_key_dict, + proxy_logging_obj=proxy_logging_obj, + ) async def handle_bedrock_count_tokens( @@ -778,6 +757,7 @@ async def bedrock_llm_proxy_route( ) # If router model, use dedicated router passthrough handler + # This uses the same common processing path as non-router models if is_router_model and llm_router: return await handle_bedrock_passthrough_router_model( model=model, @@ -785,6 +765,17 @@ async def bedrock_llm_proxy_route( request=request, request_body=request_body, llm_router=llm_router, + user_api_key_dict=user_api_key_dict, + proxy_logging_obj=proxy_logging_obj, + general_settings=general_settings, + proxy_config=proxy_config, + select_data_generator=select_data_generator, + 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, ) # Fall back to existing implementation for direct Bedrock models diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index 2e8ebd15f58..c727d14dc99 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -9,12 +9,15 @@ model_list: model: bedrock/us.anthropic.claude-3-5-sonnet-20240620-v1:0 aws_region_name: us-west-2 custom_llm_provider: bedrock + - -guardrails: - - guardrail_name: "bedrock-pre-guard" +# like MCPs/vector stores +search_tools: + - search_tool_name: litellm-search litellm_params: - guardrail: bedrock # supported values: "aporia", "bedrock", "lakera" - mode: "pre_call" - guardrailIdentifier: ff6ujrregl1q - guardrailVersion: "DRAFT" + search_provider: perplexity + api_key: os.environ/PERPLEXITYAI_API_KEY + - search_tool_name: exa-search + litellm_params: + search_provider: exa_ai + api_key: os.environ/EXA_API_KEY diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 1360433e321..3d892938ab4 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -262,7 +262,9 @@ from litellm.proxy.management_endpoints.customer_endpoints import ( from litellm.proxy.management_endpoints.internal_user_endpoints import ( router as internal_user_router, ) -from litellm.proxy.management_endpoints.internal_user_endpoints import user_update +from litellm.proxy.management_endpoints.internal_user_endpoints import ( + user_update, +) from litellm.proxy.management_endpoints.key_management_endpoints import ( delete_verification_tokens, duration_in_seconds, @@ -310,7 +312,9 @@ from litellm.proxy.ocr_endpoints.endpoints import router as ocr_router from litellm.proxy.openai_files_endpoints.files_endpoints import ( router as openai_files_router, ) -from litellm.proxy.openai_files_endpoints.files_endpoints import set_files_config +from litellm.proxy.openai_files_endpoints.files_endpoints import ( + set_files_config, +) from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( passthrough_endpoint_router, ) @@ -331,6 +335,10 @@ from litellm.proxy.public_endpoints import router as public_endpoints_router from litellm.proxy.rerank_endpoints.endpoints import router as rerank_router from litellm.proxy.response_api_endpoints.endpoints import router as response_router from litellm.proxy.route_llm_request import route_request +from litellm.proxy.search_endpoints.endpoints import router as search_router +from litellm.proxy.search_endpoints.search_tool_management import ( + router as search_tool_management_router, +) from litellm.proxy.spend_tracking.cloudzero_endpoints import router as cloudzero_router from litellm.proxy.spend_tracking.spend_management_endpoints import ( router as spend_management_router, @@ -392,9 +400,15 @@ from litellm.types.proxy.management_endpoints.ui_sso import ( LiteLLM_UpperboundKeyGenerateParams, ) from litellm.types.realtime import RealtimeQueryParams -from litellm.types.router import DeploymentTypedDict +from litellm.types.router import ( + DeploymentTypedDict, +) from litellm.types.router import ModelInfo as RouterModelInfo -from litellm.types.router import RouterGeneralSettings, updateDeployment +from litellm.types.router import ( + RouterGeneralSettings, + SearchToolTypedDict, + updateDeployment, +) from litellm.types.scheduler import DefaultPriorities from litellm.types.secret_managers.main import ( KeyManagementSettings, @@ -1870,6 +1884,45 @@ class ProxyConfig: credential_list = [CredentialItem(**cred) for cred in credential_list_dict] return credential_list + def parse_search_tools(self, config: dict) -> Optional[List[SearchToolTypedDict]]: + """ + Parse and validate search tools from config. + Loads environment variables and casts to SearchToolTypedDict. + + Args: + config: Config dictionary containing search_tools + + Returns: + List of validated SearchToolTypedDict or None if not configured + """ + search_tools_raw = config.get("search_tools", None) + if not search_tools_raw: + return None + + search_tools_parsed: List[SearchToolTypedDict] = [] + + print( # noqa + "\033[32mLiteLLM: Proxy initialized with Search Tools:\033[0m" + ) # noqa + + for search_tool in search_tools_raw: + # Display loaded search tool + search_tool_name = search_tool.get("search_tool_name", "") + search_provider = search_tool.get("litellm_params", {}).get("search_provider", "") + print(f"\033[32m {search_tool_name} ({search_provider})\033[0m") # noqa + + # Cast to SearchToolTypedDict for type safety + try: + search_tool_typed: SearchToolTypedDict = SearchToolTypedDict(**search_tool) # type: ignore + search_tools_parsed.append(search_tool_typed) + except Exception as e: + verbose_proxy_logger.error( + f"Error parsing search tool {search_tool_name}: {str(e)}" + ) + continue + + return search_tools_parsed if search_tools_parsed else None + def _load_environment_variables(self, config: dict): ## ENVIRONMENT VARIABLES global premium_user @@ -2372,6 +2425,9 @@ class ProxyConfig: assistant_settings["litellm_params"][k] = v assistants_config = AssistantsTypedDict(**assistant_settings) # type: ignore + ## SEARCH TOOLS SETTINGS + search_tools: Optional[List[SearchToolTypedDict]] = self.parse_search_tools(config) + ## /fine_tuning/jobs endpoints config finetuning_config = config.get("finetune_settings", None) set_fine_tuning_config(config=finetuning_config) @@ -2391,10 +2447,11 @@ class ProxyConfig: if router_settings and isinstance(router_settings, dict): arg_spec = inspect.getfullargspec(litellm.Router) - # model list already set + # model list and search_tools already set exclude_args = { "self", "model_list", + "search_tools", } available_args = [x for x in arg_spec.args if x not in exclude_args] @@ -2405,6 +2462,7 @@ class ProxyConfig: router = litellm.Router( **router_params, assistants_config=assistants_config, + search_tools=search_tools, router_general_settings=RouterGeneralSettings( async_only_mode=True # only init async clients ), @@ -3267,6 +3325,9 @@ class ProxyConfig: if self._should_load_db_object(object_type="prompts"): await self._init_prompts_in_db(prisma_client=prisma_client) + if self._should_load_db_object(object_type="search_tools"): + await self._init_search_tools_in_db(prisma_client=prisma_client) + if self._should_load_db_object(object_type="model_cost_map"): await self._check_and_reload_model_cost_map(prisma_client=prisma_client) @@ -3460,7 +3521,44 @@ class ProxyConfig: str(e) ) ) - + + async def _init_search_tools_in_db(self, prisma_client: PrismaClient): + """ + Initialize search tools from database into the router on startup. + """ + global llm_router + + from litellm.proxy.search_endpoints.search_tool_registry import SearchToolRegistry + from litellm.router_utils.search_api_router import SearchAPIRouter + + try: + search_tools = await SearchToolRegistry.get_all_search_tools_from_db(prisma_client=prisma_client) + + verbose_proxy_logger.info( + f"Loading {len(search_tools)} search tool(s) from database into router" + ) + + if llm_router is not None: + # Add search tools to the router + await SearchAPIRouter.update_router_search_tools( + router_instance=llm_router, + search_tools=search_tools + ) + verbose_proxy_logger.info( + f"Successfully loaded {len(search_tools)} search tool(s) into router" + ) + else: + verbose_proxy_logger.debug( + "Router not initialized yet, search tools will be added when router is created" + ) + + except Exception as e: + verbose_proxy_logger.exception( + "litellm.proxy.proxy_server.py::ProxyConfig:_init_search_tools_in_db - {}".format( + str(e) + ) + ) + async def _init_pass_through_endpoints_in_db(self): from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( initialize_pass_through_endpoints_in_db, @@ -9800,6 +9898,7 @@ app.include_router(batches_router) app.include_router(public_endpoints_router) app.include_router(rerank_router) app.include_router(ocr_router) +app.include_router(search_router) app.include_router(image_router) app.include_router(fine_tuning_router) app.include_router(vector_store_router) @@ -9823,6 +9922,7 @@ app.include_router(cloudzero_router) app.include_router(caching_router) app.include_router(analytics_router) app.include_router(guardrails_router) +app.include_router(search_tool_management_router) app.include_router(prompts_router) app.include_router(callback_management_endpoints_router) app.include_router(debugging_endpoints_router) diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index f7b4cf0cbe1..3afe564f7a1 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -26,6 +26,7 @@ ROUTE_ENDPOINT_MAPPING = { "aimage_edit": "/images/edits", "acancel_responses": "/responses/{response_id}/cancel", "aocr": "/ocr", + "asearch": "/search", } @@ -100,6 +101,7 @@ async def route_request( "avector_store_search", "avector_store_create", "aocr", + "asearch", ], ): """ @@ -182,6 +184,7 @@ async def route_request( "alist_input_items", "avector_store_create", "avector_store_search", + "asearch" ]: # moderation endpoint does not require `model` parameter return getattr(llm_router, f"{route_type}")(**data) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index a13af1afc5f..9cb9edc9268 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -570,4 +570,14 @@ model LiteLLM_HealthCheckTable { @@index([model_name]) @@index([checked_at]) @@index([status]) +} + +// Search Tools table for storing search tool configurations +model LiteLLM_SearchToolsTable { + search_tool_id String @id @default(uuid()) + search_tool_name String @unique + litellm_params Json + search_tool_info Json? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt } \ No newline at end of file diff --git a/litellm/proxy/search_endpoints/__init__.py b/litellm/proxy/search_endpoints/__init__.py new file mode 100644 index 00000000000..92b88f783ca --- /dev/null +++ b/litellm/proxy/search_endpoints/__init__.py @@ -0,0 +1,8 @@ +# litellm/proxy/search_endpoints/__init__.py + +from .search_tool_registry import SearchToolRegistry + +__all__ = [ + "SearchToolRegistry", +] + diff --git a/litellm/proxy/search_endpoints/endpoints.py b/litellm/proxy/search_endpoints/endpoints.py new file mode 100644 index 00000000000..da5388f389d --- /dev/null +++ b/litellm/proxy/search_endpoints/endpoints.py @@ -0,0 +1,162 @@ +#### Search Endpoints ##### + +import orjson +from fastapi import APIRouter, Depends, Request, Response +from fastapi.responses import ORJSONResponse + +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 + +router = APIRouter() + + +@router.post( + "/v1/search/{search_tool_name}", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, + tags=["search"], +) +@router.post( + "/search/{search_tool_name}", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, + tags=["search"], +) +@router.post( + "/v1/search", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, + tags=["search"], +) +@router.post( + "/search", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, + tags=["search"], +) +async def search( + request: Request, + fastapi_response: Response, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + search_tool_name: Optional[str] = None, +): + """ + Search endpoint for performing web searches. + + Follows the Perplexity Search API spec: + https://docs.perplexity.ai/api-reference/search-post + + The search_tool_name can be passed either: + 1. In the URL path: /v1/search/{search_tool_name} + 2. In the request body: {"search_tool_name": "..."} + + Example with search_tool_name in URL (recommended - keeps body Perplexity-compatible): + ```bash + curl -X POST "http://localhost:4000/v1/search/litellm-search" \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "query": "latest AI developments 2024", + "max_results": 5, + "search_domain_filter": ["arxiv.org", "nature.com"], + "country": "US" + }' + ``` + + Example with search_tool_name in body: + ```bash + curl -X POST "http://localhost:4000/v1/search" \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "search_tool_name": "litellm-search", + "query": "latest AI developments 2024", + "max_results": 5, + "search_domain_filter": ["arxiv.org", "nature.com"], + "country": "US" + }' + ``` + + Request Body Parameters (when search_tool_name not in URL): + - search_tool_name (str, required if not in URL): Name of the search tool configured in router + - query (str or list[str], required): Search query + - max_results (int, optional): Maximum number of results (1-20), default 10 + - search_domain_filter (list[str], optional): List of domains to filter (max 20) + - max_tokens_per_page (int, optional): Max tokens per page, default 1024 + - country (str, optional): Country code filter (e.g., 'US', 'GB', 'DE') + + When using URL path parameter, only Perplexity-compatible parameters are needed in body: + - query (str or list[str], required): Search query + - max_results (int, optional): Maximum number of results (1-20), default 10 + - search_domain_filter (list[str], optional): List of domains to filter (max 20) + - max_tokens_per_page (int, optional): Max tokens per page, default 1024 + - country (str, optional): Country code filter (e.g., 'US', 'GB', 'DE') + + Response follows Perplexity Search API format: + ```json + { + "object": "search", + "results": [ + { + "title": "Result title", + "url": "https://example.com", + "snippet": "Result snippet...", + "date": "2024-01-01", + "last_updated": "2024-01-01" + } + ] + } + ``` + """ + from litellm.proxy.proxy_server import ( + general_settings, + llm_router, + proxy_config, + proxy_logging_obj, + select_data_generator, + user_api_base, + user_max_tokens, + user_model, + user_request_timeout, + user_temperature, + version, + ) + + # Read request body + body = await request.body() + data = orjson.loads(body) + + # If search_tool_name is provided in URL path, use it (takes precedence over body) + if search_tool_name is not None: + data["search_tool_name"] = search_tool_name + + # Process request using ProxyBaseLLMRequestProcessing + processor = ProxyBaseLLMRequestProcessing(data=data) + try: + return await processor.base_process_llm_request( + request=request, + fastapi_response=fastapi_response, + user_api_key_dict=user_api_key_dict, + route_type="asearch", + 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, + ) + diff --git a/litellm/proxy/search_endpoints/search_tool_management.py b/litellm/proxy/search_endpoints/search_tool_management.py new file mode 100644 index 00000000000..21a4db75a01 --- /dev/null +++ b/litellm/proxy/search_endpoints/search_tool_management.py @@ -0,0 +1,559 @@ +""" +CRUD ENDPOINTS FOR SEARCH TOOLS +""" +from datetime import datetime +from typing import Any, Dict, List, Union + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from litellm._logging import verbose_proxy_logger +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.search_endpoints.search_tool_registry import SearchToolRegistry +from litellm.types.search import ( + ListSearchToolsResponse, + SearchTool, + SearchToolInfoResponse, +) +from litellm.types.utils import SearchProviders + +#### SEARCH TOOLS ENDPOINTS #### + +router = APIRouter() +SEARCH_TOOL_REGISTRY = SearchToolRegistry() + + +def _convert_datetime_to_str(value: Union[datetime, str, None]) -> Union[str, None]: + """ + Convert datetime object to ISO format string. + + Args: + value: datetime object, string, or None + + Returns: + ISO format string or original value if already string or None + """ + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return value + + +@router.get( + "/search_tools/list", + tags=["Search Tools"], + dependencies=[Depends(user_api_key_auth)], + response_model=ListSearchToolsResponse, +) +async def list_search_tools(): + """ + List all search tools that are available in the database. + + Example Request: + ```bash + curl -X GET "http://localhost:4000/search_tools/list" -H "Authorization: Bearer " + ``` + + Example Response: + ```json + { + "search_tools": [ + { + "search_tool_id": "123e4567-e89b-12d3-a456-426614174000", + "search_tool_name": "litellm-search", + "litellm_params": { + "search_provider": "perplexity", + "api_key": "sk-***", + "api_base": "https://api.perplexity.ai" + }, + "search_tool_info": { + "description": "Perplexity search tool" + }, + "created_at": "2023-11-09T12:34:56.789Z", + "updated_at": "2023-11-09T12:34:56.789Z" + } + ] + } + ``` + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Prisma client not initialized") + + try: + search_tools = await SEARCH_TOOL_REGISTRY.get_all_search_tools_from_db( + prisma_client=prisma_client + ) + + search_tool_configs: List[SearchToolInfoResponse] = [] + for search_tool in search_tools: + search_tool_configs.append( + SearchToolInfoResponse( + search_tool_id=search_tool.get("search_tool_id"), + search_tool_name=search_tool.get("search_tool_name", ""), + litellm_params=dict(search_tool.get("litellm_params", {})), + search_tool_info=search_tool.get("search_tool_info"), + created_at=_convert_datetime_to_str(search_tool.get("created_at")), + updated_at=_convert_datetime_to_str(search_tool.get("updated_at")), + ) + ) + + return ListSearchToolsResponse(search_tools=search_tool_configs) + except Exception as e: + verbose_proxy_logger.exception(f"Error getting search tools from db: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class CreateSearchToolRequest(BaseModel): + search_tool: SearchTool + + +@router.post( + "/search_tools", + tags=["Search Tools"], + dependencies=[Depends(user_api_key_auth)], +) +async def create_search_tool(request: CreateSearchToolRequest): + """ + Create a new search tool. + + Example Request: + ```bash + curl -X POST "http://localhost:4000/search_tools" \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{ + "search_tool": { + "search_tool_name": "litellm-search", + "litellm_params": { + "search_provider": "perplexity", + "api_key": "sk-..." + }, + "search_tool_info": { + "description": "Perplexity search tool" + } + } + }' + ``` + + Example Response: + ```json + { + "search_tool_id": "123e4567-e89b-12d3-a456-426614174000", + "search_tool_name": "litellm-search", + "litellm_params": { + "search_provider": "perplexity", + "api_key": "sk-..." + }, + "search_tool_info": { + "description": "Perplexity search tool" + }, + "created_at": "2023-11-09T12:34:56.789Z", + "updated_at": "2023-11-09T12:34:56.789Z" + } + ``` + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Prisma client not initialized") + + try: + result = await SEARCH_TOOL_REGISTRY.add_search_tool_to_db( + search_tool=request.search_tool, prisma_client=prisma_client + ) + + verbose_proxy_logger.debug( + f"Successfully added search tool '{result.get('search_tool_name')}' to database. " + f"Router will be updated by the cron job." + ) + + return result + except Exception as e: + verbose_proxy_logger.exception(f"Error adding search tool to db: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class UpdateSearchToolRequest(BaseModel): + search_tool: SearchTool + + +@router.put( + "/search_tools/{search_tool_id}", + tags=["Search Tools"], + dependencies=[Depends(user_api_key_auth)], +) +async def update_search_tool(search_tool_id: str, request: UpdateSearchToolRequest): + """ + Update an existing search tool. + + Example Request: + ```bash + curl -X PUT "http://localhost:4000/search_tools/123e4567-e89b-12d3-a456-426614174000" \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{ + "search_tool": { + "search_tool_name": "updated-search", + "litellm_params": { + "search_provider": "perplexity", + "api_key": "sk-new-key" + }, + "search_tool_info": { + "description": "Updated search tool" + } + } + }' + ``` + + Example Response: + ```json + { + "search_tool_id": "123e4567-e89b-12d3-a456-426614174000", + "search_tool_name": "updated-search", + "litellm_params": { + "search_provider": "perplexity", + "api_key": "sk-new-key" + }, + "search_tool_info": { + "description": "Updated search tool" + }, + "created_at": "2023-11-09T12:34:56.789Z", + "updated_at": "2023-11-09T13:45:12.345Z" + } + ``` + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Prisma client not initialized") + + try: + # Check if search tool exists + existing_tool = await SEARCH_TOOL_REGISTRY.get_search_tool_by_id_from_db( + search_tool_id=search_tool_id, prisma_client=prisma_client + ) + + if existing_tool is None: + raise HTTPException( + status_code=404, + detail=f"Search tool with ID {search_tool_id} not found", + ) + + result = await SEARCH_TOOL_REGISTRY.update_search_tool_in_db( + search_tool_id=search_tool_id, + search_tool=request.search_tool, + prisma_client=prisma_client, + ) + + verbose_proxy_logger.debug( + f"Successfully updated search tool '{result.get('search_tool_name')}' in database. " + f"Router will be updated by the cron job." + ) + + return result + except HTTPException as e: + raise e + except Exception as e: + verbose_proxy_logger.exception(f"Error updating search tool: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete( + "/search_tools/{search_tool_id}", + tags=["Search Tools"], + dependencies=[Depends(user_api_key_auth)], +) +async def delete_search_tool(search_tool_id: str): + """ + Delete a search tool. + + Example Request: + ```bash + curl -X DELETE "http://localhost:4000/search_tools/123e4567-e89b-12d3-a456-426614174000" \\ + -H "Authorization: Bearer " + ``` + + Example Response: + ```json + { + "message": "Search tool 123e4567-e89b-12d3-a456-426614174000 deleted successfully", + "search_tool_name": "litellm-search" + } + ``` + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Prisma client not initialized") + + try: + # Check if search tool exists + existing_tool = await SEARCH_TOOL_REGISTRY.get_search_tool_by_id_from_db( + search_tool_id=search_tool_id, prisma_client=prisma_client + ) + + if existing_tool is None: + raise HTTPException( + status_code=404, + detail=f"Search tool with ID {search_tool_id} not found", + ) + + result = await SEARCH_TOOL_REGISTRY.delete_search_tool_from_db( + search_tool_id=search_tool_id, prisma_client=prisma_client + ) + + verbose_proxy_logger.debug( + "Successfully deleted search tool from database. " + "Router will be updated by the cron job." + ) + + return result + except HTTPException as e: + raise e + except Exception as e: + verbose_proxy_logger.exception(f"Error deleting search tool: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get( + "/search_tools/{search_tool_id}", + tags=["Search Tools"], + dependencies=[Depends(user_api_key_auth)], +) +async def get_search_tool_info(search_tool_id: str): + """ + Get detailed information about a specific search tool by ID. + + Example Request: + ```bash + curl -X GET "http://localhost:4000/search_tools/123e4567-e89b-12d3-a456-426614174000" \\ + -H "Authorization: Bearer " + ``` + + Example Response: + ```json + { + "search_tool_id": "123e4567-e89b-12d3-a456-426614174000", + "search_tool_name": "litellm-search", + "litellm_params": { + "search_provider": "perplexity", + "api_key": "sk-***" + }, + "search_tool_info": { + "description": "Perplexity search tool" + }, + "created_at": "2023-11-09T12:34:56.789Z", + "updated_at": "2023-11-09T12:34:56.789Z" + } + ``` + """ + from litellm.litellm_core_utils.litellm_logging import _get_masked_values + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Prisma client not initialized") + + try: + result = await SEARCH_TOOL_REGISTRY.get_search_tool_by_id_from_db( + search_tool_id=search_tool_id, prisma_client=prisma_client + ) + + if result is None: + raise HTTPException( + status_code=404, + detail=f"Search tool with ID {search_tool_id} not found", + ) + + # Mask sensitive data + litellm_params_dict = dict(result.get("litellm_params", {})) + masked_litellm_params_dict = _get_masked_values( + litellm_params_dict, + unmasked_length=4, + number_of_asterisks=4, + ) + + return SearchToolInfoResponse( + search_tool_id=result.get("search_tool_id"), + search_tool_name=result.get("search_tool_name", ""), + litellm_params=masked_litellm_params_dict, + search_tool_info=result.get("search_tool_info"), + created_at=_convert_datetime_to_str(result.get("created_at")), + updated_at=_convert_datetime_to_str(result.get("updated_at")), + ) + except HTTPException as e: + raise e + except Exception as e: + verbose_proxy_logger.exception(f"Error getting search tool info: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class TestSearchToolConnectionRequest(BaseModel): + litellm_params: Dict[str, Any] + + +@router.post( + "/search_tools/test_connection", + tags=["Search Tools"], + dependencies=[Depends(user_api_key_auth)], +) +async def test_search_tool_connection(request: TestSearchToolConnectionRequest): + """ + Test connection to a search provider with the given configuration. + + Makes a simple test search query to verify the API key and configuration are valid. + + Example Request: + ```bash + curl -X POST "http://localhost:4000/search_tools/test_connection" \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{ + "litellm_params": { + "search_provider": "perplexity", + "api_key": "sk-..." + } + }' + ``` + + Example Response (Success): + ```json + { + "status": "success", + "message": "Successfully connected to perplexity search provider", + "test_query": "test", + "results_count": 5 + } + ``` + + Example Response (Failure): + ```json + { + "status": "error", + "message": "Authentication failed: Invalid API key", + "error_type": "AuthenticationError" + } + ``` + """ + try: + from litellm.search import asearch + + # Extract params from request + litellm_params = request.litellm_params + search_provider = litellm_params.get("search_provider") + api_key = litellm_params.get("api_key") + api_base = litellm_params.get("api_base") + + if not search_provider: + raise HTTPException( + status_code=400, + detail="search_provider is required in litellm_params" + ) + + verbose_proxy_logger.debug( + f"Testing connection to search provider: {search_provider}" + ) + + # Make a simple test search query with max_results=1 to minimize cost + test_query = "test" + response = await asearch( + query=test_query, + search_provider=search_provider, + api_key=api_key, + api_base=api_base, + max_results=1, # Minimize results to reduce cost + timeout=10.0, # 10 second timeout for test + ) + + verbose_proxy_logger.debug( + f"Successfully tested connection to {search_provider} search provider" + ) + + return { + "status": "success", + "message": f"Successfully connected to {search_provider} search provider", + "test_query": test_query, + "results_count": len(response.results) if response and response.results else 0, + } + + except Exception as e: + error_message = str(e) + error_type = type(e).__name__ + + verbose_proxy_logger.exception( + f"Failed to connect to search provider: {error_message}" + ) + + # Return error details in a structured format + return { + "status": "error", + "message": error_message, + "error_type": error_type, + } + + +@router.get( + "/search_tools/ui/available_providers", + tags=["Search Tools"], + dependencies=[Depends(user_api_key_auth)], +) +async def get_available_search_providers(): + """ + Get the list of available search providers with their configuration fields. + + Auto-discovers search providers and their UI-friendly names from transformation configs. + + Example Request: + ```bash + curl -X GET "http://localhost:4000/search_tools/ui/available_providers" \\ + -H "Authorization: Bearer " + ``` + + Example Response: + ```json + { + "providers": [ + { + "provider_name": "perplexity", + "ui_friendly_name": "Perplexity" + }, + { + "provider_name": "tavily", + "ui_friendly_name": "Tavily" + } + ] + } + ``` + """ + try: + from litellm.utils import ProviderConfigManager + + available_providers = [] + + # Auto-discover providers from SearchProviders enum + for provider in SearchProviders: + try: + # Get the config class for this provider + config = ProviderConfigManager.get_provider_search_config(provider=provider) + + if config is not None: + # Get the UI-friendly name from the config class + ui_name = config.ui_friendly_name() + + available_providers.append({ + "provider_name": provider.value, + "ui_friendly_name": ui_name, + }) + except Exception as e: + verbose_proxy_logger.debug( + f"Could not get config for search provider {provider.value}: {e}" + ) + continue + + return {"providers": available_providers} + except Exception as e: + verbose_proxy_logger.exception(f"Error getting available search providers: {e}") + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/litellm/proxy/search_endpoints/search_tool_registry.py b/litellm/proxy/search_endpoints/search_tool_registry.py new file mode 100644 index 00000000000..bab92d21de8 --- /dev/null +++ b/litellm/proxy/search_endpoints/search_tool_registry.py @@ -0,0 +1,241 @@ +""" +Search Tool Registry for managing search tool configurations. +""" +from datetime import datetime, timezone +from typing import List, Optional + +from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.proxy.utils import PrismaClient +from litellm.types.search import SearchTool + + +class SearchToolRegistry: + """ + Handles adding, removing, and getting search tools in DB + in memory. + """ + + def __init__(self): + pass + + @staticmethod + def _convert_prisma_to_dict(prisma_obj) -> dict: + """ + Convert Prisma result to dict with datetime objects as ISO format strings. + + Args: + prisma_obj: Prisma model instance + + Returns: + Dict with datetime fields converted to ISO strings + """ + result = dict(prisma_obj) + # Convert datetime objects to ISO format strings + if "created_at" in result and result["created_at"]: + result["created_at"] = result["created_at"].isoformat() + if "updated_at" in result and result["updated_at"]: + result["updated_at"] = result["updated_at"].isoformat() + return result + + ########################################################### + ########### DB management helpers for search tools ######## + ########################################################### + + async def add_search_tool_to_db( + self, search_tool: SearchTool, prisma_client: PrismaClient + ): + """ + Add a search tool to the database. + + Args: + search_tool: Search tool configuration + prisma_client: Prisma client instance + + Returns: + Dict with created search tool data + """ + try: + search_tool_name = search_tool.get("search_tool_name") + litellm_params: str = safe_dumps(dict(search_tool.get("litellm_params", {}))) + search_tool_info: str = safe_dumps(search_tool.get("search_tool_info", {})) + + # Create search tool in DB + created_search_tool = await prisma_client.db.litellm_searchtoolstable.create( + data={ + "search_tool_name": search_tool_name, + "litellm_params": litellm_params, + "search_tool_info": search_tool_info, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + ) + + # Add search_tool_id to the returned search tool object + search_tool_dict = dict(search_tool) + search_tool_dict["search_tool_id"] = created_search_tool.search_tool_id + search_tool_dict["created_at"] = created_search_tool.created_at.isoformat() + search_tool_dict["updated_at"] = created_search_tool.updated_at.isoformat() + + return search_tool_dict + except Exception as e: + verbose_proxy_logger.exception(f"Error adding search tool to DB: {str(e)}") + raise Exception(f"Error adding search tool to DB: {str(e)}") + + async def delete_search_tool_from_db( + self, search_tool_id: str, prisma_client: PrismaClient + ): + """ + Delete a search tool from the database. + + Args: + search_tool_id: ID of search tool to delete + prisma_client: Prisma client instance + + Returns: + Dict with success message + """ + try: + # Get search tool before deletion for response + existing_tool = await prisma_client.db.litellm_searchtoolstable.find_unique( + where={"search_tool_id": search_tool_id} + ) + + if not existing_tool: + raise Exception(f"Search tool with ID {search_tool_id} not found") + + # Delete from DB + await prisma_client.db.litellm_searchtoolstable.delete( + where={"search_tool_id": search_tool_id} + ) + + return { + "message": f"Search tool {search_tool_id} deleted successfully", + "search_tool_name": existing_tool.search_tool_name, + } + except Exception as e: + verbose_proxy_logger.exception(f"Error deleting search tool from DB: {str(e)}") + raise Exception(f"Error deleting search tool from DB: {str(e)}") + + async def update_search_tool_in_db( + self, search_tool_id: str, search_tool: SearchTool, prisma_client: PrismaClient + ): + """ + Update a search tool in the database. + + Args: + search_tool_id: ID of search tool to update + search_tool: Updated search tool configuration + prisma_client: Prisma client instance + + Returns: + Dict with updated search tool data + """ + try: + search_tool_name = search_tool.get("search_tool_name") + litellm_params: str = safe_dumps(dict(search_tool.get("litellm_params", {}))) + search_tool_info: str = safe_dumps(search_tool.get("search_tool_info", {})) + + # Update in DB + updated_search_tool = await prisma_client.db.litellm_searchtoolstable.update( + where={"search_tool_id": search_tool_id}, + data={ + "search_tool_name": search_tool_name, + "litellm_params": litellm_params, + "search_tool_info": search_tool_info, + "updated_at": datetime.now(timezone.utc), + }, + ) + + # Convert to dict with ISO formatted datetimes + return self._convert_prisma_to_dict(updated_search_tool) + except Exception as e: + verbose_proxy_logger.exception(f"Error updating search tool in DB: {str(e)}") + raise Exception(f"Error updating search tool in DB: {str(e)}") + + @staticmethod + async def get_all_search_tools_from_db( + prisma_client: PrismaClient, + ) -> List[SearchTool]: + """ + Get all search tools from the database. + + Args: + prisma_client: Prisma client instance + + Returns: + List of search tool configurations + """ + try: + search_tools_from_db = ( + await prisma_client.db.litellm_searchtoolstable.find_many( + order={"created_at": "desc"}, + ) + ) + + search_tools: List[SearchTool] = [] + for search_tool in search_tools_from_db: + # Convert Prisma result to dict with ISO formatted datetimes + search_tool_dict = SearchToolRegistry._convert_prisma_to_dict(search_tool) + search_tools.append(SearchTool(**search_tool_dict)) # type: ignore + + return search_tools + except Exception as e: + verbose_proxy_logger.exception(f"Error getting search tools from DB: {str(e)}") + raise Exception(f"Error getting search tools from DB: {str(e)}") + + async def get_search_tool_by_id_from_db( + self, search_tool_id: str, prisma_client: PrismaClient + ) -> Optional[SearchTool]: + """ + Get a search tool by its ID from the database. + + Args: + search_tool_id: ID of search tool to retrieve + prisma_client: Prisma client instance + + Returns: + Search tool configuration or None if not found + """ + try: + search_tool = await prisma_client.db.litellm_searchtoolstable.find_unique( + where={"search_tool_id": search_tool_id} + ) + + if not search_tool: + return None + + # Convert Prisma result to dict with ISO formatted datetimes + search_tool_dict = self._convert_prisma_to_dict(search_tool) + return SearchTool(**search_tool_dict) # type: ignore + except Exception as e: + verbose_proxy_logger.exception(f"Error getting search tool from DB: {str(e)}") + raise Exception(f"Error getting search tool from DB: {str(e)}") + + async def get_search_tool_by_name_from_db( + self, search_tool_name: str, prisma_client: PrismaClient + ) -> Optional[SearchTool]: + """ + Get a search tool by its name from the database. + + Args: + search_tool_name: Name of search tool to retrieve + prisma_client: Prisma client instance + + Returns: + Search tool configuration or None if not found + """ + try: + search_tool = await prisma_client.db.litellm_searchtoolstable.find_unique( + where={"search_tool_name": search_tool_name} + ) + + if not search_tool: + return None + + # Convert Prisma result to dict with ISO formatted datetimes + search_tool_dict = self._convert_prisma_to_dict(search_tool) + return SearchTool(**search_tool_dict) # type: ignore + except Exception as e: + verbose_proxy_logger.exception(f"Error getting search tool from DB: {str(e)}") + raise Exception(f"Error getting search tool from DB: {str(e)}") + diff --git a/litellm/proxy/spend_tracking/cold_storage_handler.py b/litellm/proxy/spend_tracking/cold_storage_handler.py index 133403deae2..262d14fad7b 100644 --- a/litellm/proxy/spend_tracking/cold_storage_handler.py +++ b/litellm/proxy/spend_tracking/cold_storage_handler.py @@ -56,6 +56,6 @@ class ColdStorageHandler: def _select_custom_logger_for_cold_storage( self, ) -> Optional[_custom_logger_compatible_callbacks_literal]: - cold_storage_custom_logger: Optional[_custom_logger_compatible_callbacks_literal] = litellm.configured_cold_storage_logger + cold_storage_custom_logger: Optional[_custom_logger_compatible_callbacks_literal] = litellm.cold_storage_custom_logger return cold_storage_custom_logger diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 0065edeb0e9..737856c00cf 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -3628,8 +3628,14 @@ def join_paths(base_path: str, route: str) -> str: if not route: return base_path - # Join with single slash - return f"{base_path}/{route}" + # Check if base_path already ends with the route to avoid duplication + if base_path.endswith(f"/{route}"): + final_path = base_path + else: + # Join with single slash + final_path = f"{base_path}/{route}" + + return final_path def get_custom_url(request_base_url: str, route: Optional[str] = None) -> str: diff --git a/litellm/responses/litellm_completion_transformation/session_handler.py b/litellm/responses/litellm_completion_transformation/session_handler.py index d1e009e62dd..4f2c51edc57 100644 --- a/litellm/responses/litellm_completion_transformation/session_handler.py +++ b/litellm/responses/litellm_completion_transformation/session_handler.py @@ -236,10 +236,10 @@ class ResponsesSessionHandler: """ Only check cold storage when both are true 1. `LITELLM_TRUNCATED_PAYLOAD_FIELD` is in the proxy server request dict - 2. `litellm.configured_cold_storage_logger` is not None + 2. `litellm.cold_storage_custom_logger` is not None """ from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD - configured_cold_storage_custom_logger = litellm.configured_cold_storage_logger + configured_cold_storage_custom_logger = litellm.cold_storage_custom_logger if configured_cold_storage_custom_logger is None: return False if proxy_server_request_dict is None: diff --git a/litellm/router.py b/litellm/router.py index 1d768415ff5..18499cd6ac0 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -140,6 +140,7 @@ from litellm.types.router import ( RouterRateLimitError, RouterRateLimitErrorBasic, RoutingStrategy, + SearchToolTypedDict, ) from litellm.types.services import ServiceTypes from litellm.types.utils import ( @@ -204,6 +205,8 @@ class Router: ] = None, ## ASSISTANTS API ## assistants_config: Optional[AssistantsTypedDict] = None, + ## SEARCH API ## + search_tools: Optional[List[SearchToolTypedDict]] = None, ## CACHING ## redis_url: Optional[str] = None, redis_host: Optional[str] = None, @@ -363,6 +366,7 @@ class Router: ) self.assistants_config = assistants_config + self.search_tools = search_tools or [] self.deployment_names: List = ( [] ) # names of models under litellm_params. ex. azure/chatgpt-v-2 @@ -879,6 +883,13 @@ class Router: self.aocr = self.factory_function(aocr, call_type="aocr") self.ocr = self.factory_function(ocr, call_type="ocr") + # Search routes + ######################################################### + from litellm.search import asearch, search + + self.asearch = self.factory_function(asearch, call_type="asearch") + self.search = self.factory_function(search, call_type="search") + def validate_fallbacks(self, fallback_param: Optional[List]): """ Validate the fallbacks parameter. @@ -2705,6 +2716,37 @@ class Router: self.fail_calls[model] += 1 raise e + async def _asearch_with_fallbacks( + self, original_function: Callable, **kwargs + ): + """ + Helper function to make a search API call through the router with load balancing and fallbacks. + Reuses the router's retry/fallback infrastructure. + """ + from litellm.router_utils.search_api_router import SearchAPIRouter + + return await SearchAPIRouter.async_search_with_fallbacks( + router_instance=self, + original_function=original_function, + **kwargs, + ) + + async def _asearch_with_fallbacks_helper( + self, model: str, original_generic_function: Callable, **kwargs + ): + """ + Helper function for search API calls - selects a search tool and calls the original function. + Called by async_function_with_fallbacks for each retry attempt. + """ + from litellm.router_utils.search_api_router import SearchAPIRouter + + return await SearchAPIRouter.async_search_with_fallbacks_helper( + router_instance=self, + model=model, + original_generic_function=original_generic_function, + **kwargs, + ) + async def _ageneric_api_call_with_fallbacks( self, model: str, original_function: Callable, **kwargs ): @@ -3580,6 +3622,8 @@ class Router: "vector_store_create", "aocr", "ocr", + "asearch", + "search", "aadapter_generate_content" ] = "assistants", ): @@ -3598,6 +3642,7 @@ class Router: "vector_store_search", "vector_store_create", "ocr", + "search", ): def sync_wrapper( @@ -3628,6 +3673,11 @@ class Router: return await self._pass_through_moderation_endpoint_factory( original_function=original_function, **kwargs ) + elif call_type in ("asearch", "search"): + return await self._asearch_with_fallbacks( + original_function=original_function, + **kwargs, + ) elif call_type in ( "anthropic_messages", "aresponses", diff --git a/litellm/router_utils/search_api_router.py b/litellm/router_utils/search_api_router.py new file mode 100644 index 00000000000..247df099462 --- /dev/null +++ b/litellm/router_utils/search_api_router.py @@ -0,0 +1,212 @@ +""" +Router utilities for Search API integration. + +Handles search tool selection, load balancing, and fallback logic for search requests. +""" + +import asyncio +import random +import traceback +from functools import partial +from typing import Any, Callable + +from litellm._logging import verbose_router_logger + + +class SearchAPIRouter: + """ + Static utility class for routing search API calls through the LiteLLM router. + + Provides methods for search tool selection, load balancing, and fallback handling. + """ + + @staticmethod + async def update_router_search_tools(router_instance: Any, search_tools: list): + """ + Update the router with search tools from the database. + + This method is called by a cron job to sync search tools from DB to router. + + Args: + router_instance: The Router instance to update + search_tools: List of search tool configurations from the database + """ + try: + from litellm.types.router import SearchToolTypedDict + + verbose_router_logger.debug(f"Adding {len(search_tools)} search tools to router") + + # Convert search tools to the format expected by the router + router_search_tools: list = [] + for tool in search_tools: + # Create dict that matches SearchToolTypedDict structure + router_search_tool: SearchToolTypedDict = { # type: ignore + "search_tool_id": tool.get("search_tool_id"), + "search_tool_name": tool.get("search_tool_name"), + "litellm_params": tool.get("litellm_params", {}), + "search_tool_info": tool.get("search_tool_info"), + } + router_search_tools.append(router_search_tool) + + # Update the router's search_tools list + router_instance.search_tools = router_search_tools + + verbose_router_logger.info( + f"Successfully updated router with {len(router_search_tools)} search tool(s)" + ) + + except Exception as e: + verbose_router_logger.exception( + f"Error updating router with search tools: {str(e)}" + ) + raise e + + @staticmethod + def get_matching_search_tools( + router_instance: Any, + search_tool_name: str, + ) -> list: + """ + Get all search tools matching the given name. + + Args: + router_instance: The Router instance + search_tool_name: Name of the search tool to find + + Returns: + List of matching search tool configurations + + Raises: + ValueError: If no matching search tools are found + """ + matching_tools = [ + tool for tool in router_instance.search_tools + if tool.get("search_tool_name") == search_tool_name + ] + + if not matching_tools: + raise ValueError(f"Search tool '{search_tool_name}' not found in router.search_tools") + + return matching_tools + + @staticmethod + async def async_search_with_fallbacks( + router_instance: Any, + original_function: Callable, + **kwargs, + ): + """ + Helper function to make a search API call through the router with load balancing and fallbacks. + Reuses the router's retry/fallback infrastructure. + + Args: + router_instance: The Router instance + original_function: The original litellm.asearch function + **kwargs: Search parameters including search_tool_name, query, etc. + + Returns: + SearchResponse from the search API + """ + try: + search_tool_name = kwargs.get("search_tool_name", kwargs.get("model")) + + if not search_tool_name: + raise ValueError("search_tool_name or model parameter is required for search") + + # Set up kwargs for the fallback system + kwargs["model"] = search_tool_name # Use model field for compatibility with fallback system + kwargs["original_generic_function"] = original_function + # Bind router_instance to the helper method using partial + kwargs["original_function"] = partial( + SearchAPIRouter.async_search_with_fallbacks_helper, + router_instance=router_instance, + ) + + # Update kwargs before fallbacks (for logging, metadata, etc) + router_instance._update_kwargs_before_fallbacks( + model=search_tool_name, kwargs=kwargs, metadata_variable_name="litellm_metadata" + ) + + verbose_router_logger.debug( + f"Inside SearchAPIRouter.async_search_with_fallbacks() - search_tool_name: {search_tool_name}; kwargs: {kwargs}" + ) + + # Use the existing retry/fallback infrastructure + response = await router_instance.async_function_with_fallbacks(**kwargs) + return response + + except Exception as e: + from litellm.router_utils.handle_error import send_llm_exception_alert + + asyncio.create_task( + send_llm_exception_alert( + litellm_router_instance=router_instance, + request_kwargs=kwargs, + error_traceback_str=traceback.format_exc(), + original_exception=e, + ) + ) + raise e + + @staticmethod + async def async_search_with_fallbacks_helper( + router_instance: Any, + model: str, + original_generic_function: Callable, + **kwargs, + ): + """ + Helper function for search API calls - selects a search tool and calls the original function. + Called by async_function_with_fallbacks for each retry attempt. + + Args: + router_instance: The Router instance + model: The search tool name (passed as model for compatibility) + original_generic_function: The original litellm.asearch function + **kwargs: Search parameters + + Returns: + SearchResponse from the selected search provider + """ + search_tool_name = model # model field contains the search_tool_name + + try: + # Find matching search tools + matching_tools = SearchAPIRouter.get_matching_search_tools( + router_instance=router_instance, + search_tool_name=search_tool_name, + ) + + # Simple random selection for load balancing across multiple providers with same name + # For search tools, we use simple random choice since they don't have TPM/RPM constraints + selected_tool = random.choice(matching_tools) + + # Extract search provider and other params from litellm_params + litellm_params = selected_tool.get("litellm_params", {}) + search_provider = litellm_params.get("search_provider") + api_key = litellm_params.get("api_key") + api_base = litellm_params.get("api_base") + + if not search_provider: + raise ValueError(f"search_provider not found in litellm_params for search tool '{search_tool_name}'") + + verbose_router_logger.debug( + f"Selected search tool with provider: {search_provider}" + ) + + # Call the original search function with the provider config + response = await original_generic_function( + search_provider=search_provider, + api_key=api_key, + api_base=api_base, + **kwargs, + ) + + return response + + except Exception as e: + verbose_router_logger.error( + f"Error in SearchAPIRouter.async_search_with_fallbacks_helper for {search_tool_name}: {str(e)}" + ) + raise e + diff --git a/litellm/search/__init__.py b/litellm/search/__init__.py new file mode 100644 index 00000000000..a91dff7060a --- /dev/null +++ b/litellm/search/__init__.py @@ -0,0 +1,8 @@ +""" +LiteLLM Search API module. +""" +from litellm.search.cost_calculator import search_provider_cost_per_query +from litellm.search.main import asearch, search + +__all__ = ["search", "asearch", "search_provider_cost_per_query"] + diff --git a/litellm/search/cost_calculator.py b/litellm/search/cost_calculator.py new file mode 100644 index 00000000000..1dc155d748a --- /dev/null +++ b/litellm/search/cost_calculator.py @@ -0,0 +1,52 @@ +""" +Cost calculation for search providers. +""" +from typing import Optional, Tuple + +from litellm.utils import get_model_info + + +def search_provider_cost_per_query( + model: str, + custom_llm_provider: Optional[str] = None, + number_of_queries: int = 1, + optional_params: Optional[dict] = None, +) -> Tuple[float, float]: + """ + Calculate cost for search-only providers. + + Returns (input_cost, output_cost) where input_cost = queries * cost_per_query + Supports tiered pricing based on max_results parameter. + + Args: + model: Model name (e.g., "exa_ai/search", "tavily/search") + custom_llm_provider: Provider name (e.g., "exa_ai", "tavily") + number_of_queries: Number of search queries performed (default: 1) + optional_params: Optional parameters including max_results for tiered pricing + + Returns: + Tuple of (input_cost, output_cost) where output_cost is always 0.0 + """ + model_info = get_model_info(model=model, custom_llm_provider=custom_llm_provider) + + # Check for tiered pricing (e.g., Exa AI based on max_results) + tiered_pricing = model_info.get("tiered_pricing") + if tiered_pricing and isinstance(tiered_pricing, list): + max_results = (optional_params or {}).get("max_results", 10) # default 10 results + cost_per_query = 0.0 + + for tier in tiered_pricing: + range_min, range_max = tier["max_results_range"] + if range_min <= max_results <= range_max: + cost_per_query = tier["input_cost_per_query"] + break + else: + # Fallback to highest tier if out of range + cost_per_query = tiered_pricing[-1]["input_cost_per_query"] + else: + # Simple flat rate + cost_per_query = float(model_info.get("input_cost_per_query") or 0.0) + + total_cost = number_of_queries * cost_per_query + return (total_cost, 0.0) # (input_cost, output_cost) + diff --git a/litellm/search/main.py b/litellm/search/main.py new file mode 100644 index 00000000000..c87694e70f5 --- /dev/null +++ b/litellm/search/main.py @@ -0,0 +1,325 @@ +""" +Main Search function for LiteLLM. +""" +import asyncio +import contextvars +from functools import partial +from typing import Any, Coroutine, Dict, List, Optional, Union + +import httpx + +import litellm +from litellm._logging import verbose_logger +from litellm.constants import request_timeout +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.search.transformation import BaseSearchConfig, SearchResponse +from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler +from litellm.types.utils import SearchProviders +from litellm.utils import ProviderConfigManager, client, filter_out_litellm_params + +####### ENVIRONMENT VARIABLES ################### +base_llm_http_handler = BaseLLMHTTPHandler() +################################################# + + +def _build_search_optional_params( + max_results: Optional[int] = None, + search_domain_filter: Optional[List[str]] = None, + max_tokens_per_page: Optional[int] = None, + country: Optional[str] = None, +) -> Dict[str, Any]: + """ + Helper function to build optional_params dict from Perplexity Search API parameters. + + Args: + max_results: Maximum number of results (1-20) + search_domain_filter: List of domains to filter (max 20) + max_tokens_per_page: Max tokens per page + country: Country code filter + + Returns: + Dict with non-None optional parameters + """ + optional_params: Dict[str, Any] = {} + + if max_results is not None: + optional_params["max_results"] = max_results + if search_domain_filter is not None: + optional_params["search_domain_filter"] = search_domain_filter + if max_tokens_per_page is not None: + optional_params["max_tokens_per_page"] = max_tokens_per_page + if country is not None: + optional_params["country"] = country + + return optional_params + + +@client +async def asearch( + query: Union[str, List[str]], + search_provider: str, + max_results: Optional[int] = None, + search_domain_filter: Optional[List[str]] = None, + max_tokens_per_page: Optional[int] = None, + country: Optional[str] = None, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + extra_headers: Optional[Dict[str, Any]] = None, + **kwargs, +) -> SearchResponse: + """ + Async Search function. + + Args: + query: Search query (string or list of strings) + search_provider: Provider name (e.g., "perplexity") + max_results: Optional maximum number of results (1-20), default 10 + search_domain_filter: Optional list of domains to filter (max 20) + max_tokens_per_page: Optional max tokens per page, default 1024 + country: Optional country code filter (e.g., 'US', 'GB', 'DE') + api_key: Optional API key + api_base: Optional API base URL + timeout: Optional timeout + extra_headers: Optional extra headers + **kwargs: Additional parameters + + Returns: + SearchResponse with results list following Perplexity format + + Example: + ```python + import litellm + + # Basic search + response = await litellm.asearch( + query="latest AI developments 2024", + search_provider="perplexity" + ) + + # Search with options + response = await litellm.asearch( + query="AI developments", + search_provider="perplexity", + max_results=10, + search_domain_filter=["arxiv.org", "nature.com"], + max_tokens_per_page=1024, + country="US" + ) + + # Access results + for result in response.results: + print(f"{result.title}: {result.url}") + print(f"Snippet: {result.snippet}") + ``` + """ + local_vars = locals() + try: + loop = asyncio.get_event_loop() + kwargs["asearch"] = True + + func = partial( + search, + query=query, + search_provider=search_provider, + max_results=max_results, + search_domain_filter=search_domain_filter, + max_tokens_per_page=max_tokens_per_page, + country=country, + api_key=api_key, + api_base=api_base, + timeout=timeout, + extra_headers=extra_headers, + **kwargs, + ) + + ctx = contextvars.copy_context() + func_with_context = partial(ctx.run, func) + init_response = await loop.run_in_executor(None, func_with_context) + + if asyncio.iscoroutine(init_response): + response = await init_response + else: + response = init_response + + if response is None: + raise ValueError( + f"Got an unexpected None response from the Search API: {response}" + ) + + return response + except Exception as e: + model_name = f"{search_provider}/search" + raise litellm.exception_type( + model=model_name, + custom_llm_provider=search_provider, + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + + +@client +def search( + query: Union[str, List[str]], + search_provider: str, + max_results: Optional[int] = None, + search_domain_filter: Optional[List[str]] = None, + max_tokens_per_page: Optional[int] = None, + country: Optional[str] = None, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + timeout: Optional[Union[float, httpx.Timeout]] = None, + extra_headers: Optional[Dict[str, Any]] = None, + **kwargs, +) -> Union[SearchResponse, Coroutine[Any, Any, SearchResponse]]: + """ + Synchronous Search function. + + Args: + query: Search query (string or list of strings) + search_provider: Provider name (e.g., "perplexity") + max_results: Optional maximum number of results (1-20), default 10 + search_domain_filter: Optional list of domains to filter (max 20) + max_tokens_per_page: Optional max tokens per page, default 1024 + country: Optional country code filter (e.g., 'US', 'GB', 'DE') + api_key: Optional API key + api_base: Optional API base URL + timeout: Optional timeout + extra_headers: Optional extra headers + **kwargs: Additional parameters + + Returns: + SearchResponse with results list following Perplexity format + + Example: + ```python + import litellm + + # Basic search + response = litellm.search( + query="latest AI developments 2024", + search_provider="perplexity" + ) + + # Search with options + response = litellm.search( + query="AI developments", + search_provider="perplexity", + max_results=10, + search_domain_filter=["arxiv.org", "nature.com"], + max_tokens_per_page=1024, + country="US" + ) + + # Multi-query search + response = litellm.search( + query=["AI developments", "machine learning trends"], + search_provider="perplexity" + ) + + # Access results + for result in response.results: + print(f"{result.title}: {result.url}") + print(f"Snippet: {result.snippet}") + if result.date: + print(f"Date: {result.date}") + ``` + """ + local_vars = locals() + try: + litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") # type: ignore + litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None) + _is_async = kwargs.pop("asearch", False) is True + + # Validate query parameter + if not isinstance(query, (str, list)): + raise ValueError(f"query must be a string or list of strings, got {type(query)}") + + if isinstance(query, list) and not all(isinstance(q, str) for q in query): + raise ValueError("All items in query list must be strings") + + # Get provider config + search_provider_config: Optional[BaseSearchConfig] = ( + ProviderConfigManager.get_provider_search_config( + provider=SearchProviders(search_provider), + ) + ) + + if search_provider_config is None: + raise ValueError( + f"Search is not supported for provider: {search_provider}" + ) + + verbose_logger.debug( + f"Search call - provider: {search_provider}" + ) + + # Build optional_params from explicit parameters + optional_params = _build_search_optional_params( + max_results=max_results, + search_domain_filter=search_domain_filter, + max_tokens_per_page=max_tokens_per_page, + country=country, + ) + + # Filter out internal LiteLLM parameters from kwargs + filtered_kwargs = filter_out_litellm_params(kwargs=kwargs) + + # Add remaining kwargs to optional_params (for provider-specific params) + for key, value in filtered_kwargs.items(): + if key not in optional_params: + optional_params[key] = value + + verbose_logger.debug(f"Search optional_params: {optional_params}") + + # Validate environment and get headers + headers = search_provider_config.validate_environment( + api_key=api_key, + api_base=api_base, + headers=extra_headers or {}, + ) + + # Get complete URL + complete_url = search_provider_config.get_complete_url( + api_base=api_base, + optional_params=optional_params, + ) + + # Pre Call logging + model_name = f"{search_provider}/search" + litellm_logging_obj.update_environment_variables( + model=model_name, + optional_params=optional_params, + litellm_params={ + "litellm_call_id": litellm_call_id, + "api_base": complete_url, + }, + custom_llm_provider=search_provider, + ) + + # Call the handler + response = base_llm_http_handler.search( + query=query, + optional_params=optional_params, + timeout=timeout or request_timeout, + logging_obj=litellm_logging_obj, + api_key=api_key, + api_base=complete_url, + custom_llm_provider=search_provider, + asearch=_is_async, + headers=headers, + provider_config=search_provider_config, + ) + + return response + except Exception as e: + model_name = f"{search_provider}/search" + raise litellm.exception_type( + model=model_name, + custom_llm_provider=search_provider, + original_exception=e, + completion_kwargs=local_vars, + extra_kwargs=kwargs, + ) + diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 115cce9fb94..5e718f7801f 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -8,6 +8,9 @@ from typing_extensions import Required, TypedDict from litellm.types.proxy.guardrails.guardrail_hooks.enkryptai import ( EnkryptAIGuardrailConfigs, ) +from litellm.types.proxy.guardrails.guardrail_hooks.grayswan import ( + GraySwanGuardrailConfigModel, +) """ Pydantic object defining how to set guardrails on litellm proxy @@ -35,6 +38,7 @@ class SupportedGuardrailIntegrations(Enum): PANGEA = "pangea" LASSO = "lasso" PILLAR = "pillar" + GRAYSWAN = "grayswan" PANW_PRISMA_AIRS = "panw_prisma_airs" AZURE_PROMPT_SHIELD = "azure/prompt_shield" AZURE_TEXT_MODERATIONS = "azure/text_moderations" @@ -45,6 +49,7 @@ class SupportedGuardrailIntegrations(Enum): JAVELIN = "javelin" ENKRYPTAI = "enkryptai" + class Role(Enum): SYSTEM = "system" ASSISTANT = "assistant" @@ -518,6 +523,7 @@ class LitellmParams( LakeraV2GuardrailConfigModel, LassoGuardrailConfigModel, PillarGuardrailConfigModel, + GraySwanGuardrailConfigModel, NomaGuardrailConfigModel, ToolPermissionGuardrailConfigModel, JavelinGuardrailConfigModel, diff --git a/litellm/types/llms/custom_http.py b/litellm/types/llms/custom_http.py index 8759dedec6a..e7d2b9dabcc 100644 --- a/litellm/types/llms/custom_http.py +++ b/litellm/types/llms/custom_http.py @@ -20,6 +20,7 @@ class httpxSpecialProvider(str, Enum): PassThroughEndpoint = "pass_through_endpoint" PromptFactory = "prompt_factory" SSO_HANDLER = "sso_handler" + Search = "search" VerifyTypes = Union[str, bool, ssl.SSLContext] diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index b2d170514af..a9333e0061d 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -1408,13 +1408,20 @@ class ImageGenerationPartialImageEvent(BaseLiteLLMOpenAIResponseObject): b64_json: str -class ErrorEvent(BaseLiteLLMOpenAIResponseObject): - type: Literal[ResponsesAPIStreamEvents.ERROR] - code: Optional[str] +class ErrorEventError(BaseLiteLLMOpenAIResponseObject): + """Nested error object within ErrorEvent""" + type: str # e.g., 'invalid_request_error' + code: str # e.g., 'context_length_exceeded' message: str param: Optional[str] +class ErrorEvent(BaseLiteLLMOpenAIResponseObject): + type: Literal[ResponsesAPIStreamEvents.ERROR] + sequence_number: int + error: ErrorEventError + + class GenericEvent(BaseLiteLLMOpenAIResponseObject): type: str diff --git a/litellm/types/llms/vertex_ai.py b/litellm/types/llms/vertex_ai.py index f1f7ac2c661..cb2075981a8 100644 --- a/litellm/types/llms/vertex_ai.py +++ b/litellm/types/llms/vertex_ai.py @@ -178,6 +178,10 @@ class GeminiThinkingConfig(TypedDict, total=False): GeminiResponseModalities = Literal["TEXT", "IMAGE", "AUDIO", "VIDEO"] +GeminiImageAspectRatio = Literal["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"] + +class GeminiImageConfig(TypedDict, total=False): + aspectRatio: GeminiImageAspectRatio class PrebuiltVoiceConfig(TypedDict): voiceName: str @@ -206,6 +210,7 @@ class GenerationConfig(TypedDict, total=False): responseLogprobs: bool logprobs: int responseModalities: List[GeminiResponseModalities] + imageConfig: GeminiImageConfig thinkingConfig: GeminiThinkingConfig diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/base.py b/litellm/types/proxy/guardrails/guardrail_hooks/base.py index d2607e89128..0acda1ba927 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/base.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Generic, TypeVar +from typing import Generic, Optional, TypeVar from pydantic import BaseModel, Field @@ -9,7 +9,8 @@ T = TypeVar("T", bound=BaseModel) class GuardrailConfigModel(BaseModel, Generic[T], ABC): """Base model for guardrail configuration""" - optional_params: T = Field( + optional_params: Optional[T] = Field( + default=None, description="Optional parameters for the guardrail", ) diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/grayswan.py b/litellm/types/proxy/guardrails/guardrail_hooks/grayswan.py new file mode 100644 index 00000000000..d50ae95ee39 --- /dev/null +++ b/litellm/types/proxy/guardrails/guardrail_hooks/grayswan.py @@ -0,0 +1,53 @@ +"""Gray Swan guardrail configuration models.""" + +from typing import Dict, Optional + +from pydantic import BaseModel, Field + +from .base import GuardrailConfigModel + + +class GraySwanGuardrailConfigModelOptionalParams(BaseModel): + """Optional parameters for the Gray Swan guardrail.""" + + on_flagged_action: Optional[str] = Field( + default="monitor", + description="Action when a violation is detected: 'block' rejects the call, 'monitor' logs only.", + ) + violation_threshold: Optional[float] = Field( + default=0.5, + ge=0.0, + le=1.0, + description="Threshold between 0 and 1 at which Gray Swan violations trigger the configured action.", + ) + reasoning_mode: Optional[str] = Field( + default=None, + description="Gray Swan reasoning mode override. Accepted values: 'off', 'hybrid', 'thinking'.", + ) + policy_id: Optional[str] = Field( + default=None, + description="Gray Swan policy identifier to apply during monitoring.", + ) + categories: Optional[Dict[str, str]] = Field( + default=None, + description="Default Gray Swan category definitions to send with each request.", + ) + + +class GraySwanGuardrailConfigModel( + GuardrailConfigModel[GraySwanGuardrailConfigModelOptionalParams] +): + """Configuration parameters for the Gray Swan guardrail.""" + + api_key: Optional[str] = Field( + default=None, + description="API key for Gray Swan. Reads from the `GRAYSWAN_API_KEY` environment variable when omitted.", + ) + api_base: Optional[str] = Field( + default=None, + description="Override for the Gray Swan API base URL. Defaults to https://api.grayswan.ai and can be set via `GRAYSWAN_API_BASE`.", + ) + + @staticmethod + def ui_friendly_name() -> str: + return "Gray Swan Guardrail" diff --git a/litellm/types/router.py b/litellm/types/router.py index 3482df19c37..354e97d14d7 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -17,6 +17,7 @@ from .completion import CompletionRequest from .embedding import EmbeddingRequest from .llms.openai import OpenAIFileObject from .llms.vertex_ai import VERTEX_CREDENTIALS_TYPES +from .search import SearchProvider from .utils import CustomPricingLiteLLMParams, ModelResponse @@ -162,9 +163,6 @@ class CredentialLiteLLMParams(BaseModel): watsonx_region_name: Optional[str] = None - - - class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): """ LiteLLM Params without 'model' arg (used across completion / assistants api) @@ -209,6 +207,9 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): s3_bucket_name: Optional[str] = None gcs_bucket_name: Optional[str] = None + # Vector Store Params + vector_store_id: Optional[str] = None + def __init__( self, custom_llm_provider: Optional[str] = None, @@ -601,6 +602,35 @@ class AssistantsTypedDict(TypedDict): litellm_params: LiteLLMParamsTypedDict +class SearchToolLiteLLMParams(TypedDict, total=False): + """ + LiteLLM params for search tools. + Search tools don't require a 'model' field like regular deployments. + """ + search_provider: Required[SearchProvider] + api_key: Optional[str] + api_base: Optional[str] + timeout: Optional[Union[float, str, httpx.Timeout]] + max_retries: Optional[int] + + +class SearchToolTypedDict(TypedDict): + """ + Configuration for a search tool in the router. + + Example: + { + "search_tool_name": "litellm-search", + "litellm_params": { + "search_provider": "perplexity", + "api_key": "os.environ/PERPLEXITYAI_API_KEY" + } + } + """ + search_tool_name: Required[str] + litellm_params: Required[SearchToolLiteLLMParams] + + class FineTuningConfig(BaseModel): custom_llm_provider: Literal["azure", "openai"] diff --git a/litellm/types/search.py b/litellm/types/search.py new file mode 100644 index 00000000000..661a2feda33 --- /dev/null +++ b/litellm/types/search.py @@ -0,0 +1,75 @@ +""" +LiteLLM Search API Types + +This module defines types for the unified search API across different providers. +""" +from typing import List, Optional + +from typing_extensions import Required, TypedDict + +from litellm.types.utils import SearchProviders + +# Re-export SearchProviders as SearchProvider for backwards compatibility +SearchProvider = SearchProviders + +__all__ = ["SearchProvider", "SearchProviders"] + + + +class SearchToolLiteLLMParams(TypedDict, total=False): + """ + LiteLLM params for search tools configuration. + """ + search_provider: Required[str] + api_key: Optional[str] + api_base: Optional[str] + timeout: Optional[float] + max_retries: Optional[int] + + +class SearchTool(TypedDict, total=False): + """ + Search tool configuration. + + Example: + { + "search_tool_id": "123e4567-e89b-12d3-a456-426614174000", + "search_tool_name": "litellm-search", + "litellm_params": { + "search_provider": "perplexity", + "api_key": "sk-..." + }, + "search_tool_info": { + "description": "Perplexity search tool" + } + } + """ + search_tool_id: Optional[str] + search_tool_name: Required[str] + litellm_params: Required[SearchToolLiteLLMParams] + search_tool_info: Optional[dict] + created_at: Optional[str] + updated_at: Optional[str] + + +class SearchToolInfoResponse(TypedDict, total=False): + """Response model for search tool information.""" + search_tool_id: Optional[str] + search_tool_name: str + litellm_params: dict + search_tool_info: Optional[dict] + created_at: Optional[str] + updated_at: Optional[str] + + +class ListSearchToolsResponse(TypedDict): + """Response model for listing search tools.""" + search_tools: List[SearchToolInfoResponse] + + +class AvailableSearchProvider(TypedDict): + """Information about an available search provider.""" + provider_name: str + ui_friendly_name: str + + diff --git a/litellm/types/utils.py b/litellm/types/utils.py index d744bb9d38b..8a100f9c35e 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -239,6 +239,8 @@ class CallTypes(str, Enum): speech = "speech" rerank = "rerank" arerank = "arerank" + search = "search" + asearch = "asearch" arealtime = "_arealtime" create_batch = "create_batch" acreate_batch = "acreate_batch" @@ -321,6 +323,8 @@ CallTypesLiteral = Literal[ "speech", "rerank", "arerank", + "search", + "asearch", "_arealtime", "create_batch", "acreate_batch", @@ -2538,6 +2542,23 @@ class LlmProviders(str, Enum): LlmProvidersSet = {provider.value for provider in LlmProviders} +class SearchProviders(str, Enum): + """ + Enum for search provider types. + Separate from LlmProviders for semantic clarity. + """ + PERPLEXITY = "perplexity" + TAVILY = "tavily" + PARALLEL_AI = "parallel_ai" + EXA_AI = "exa_ai" + GOOGLE_PSE = "google_pse" + DATAFORSEO = "dataforseo" + + +# Create a set of all search provider values for quick lookup +SearchProvidersSet = {provider.value for provider in SearchProviders} + + class LiteLLMLoggingBaseClass: """ Base class for logging pre and post call @@ -2763,6 +2784,25 @@ CostResponseTypes = Union[ ] +class PriorityReservationDict(TypedDict, total=False): + """ + Dictionary format for priority reservation values. + + Used in litellm.priority_reservation to specify how much capacity to reserve + for each priority level. Supports three formats: + 1. Percentage-based: {"type": "percent", "value": 0.9} -> 90% of capacity + 2. RPM-based: {"type": "rpm", "value": 900} -> 900 requests per minute + 3. TPM-based: {"type": "tpm", "value": 900000} -> 900,000 tokens per minute + + Attributes: + type: The type of value - "percent", "rpm", or "tpm". Defaults to "percent". + value: The numeric value. For percent (0.0-1.0), for rpm/tpm (absolute value). + """ + + type: Literal["percent", "rpm", "tpm"] + value: float + + class PriorityReservationSettings(BaseModel): """ Settings for priority-based rate limiting reservation. diff --git a/litellm/utils.py b/litellm/utils.py index 8b2bf62a91b..d50a28d54ff 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -144,9 +144,8 @@ from litellm.llms.base_llm.google_genai.transformation import ( BaseGoogleGenAIGenerateContentConfig, ) from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig -from litellm.llms.base_llm.text_to_speech.transformation import ( - BaseTextToSpeechConfig, -) +from litellm.llms.base_llm.search.transformation import BaseSearchConfig +from litellm.llms.base_llm.text_to_speech.transformation import BaseTextToSpeechConfig from litellm.llms.bedrock.common_utils import BedrockModelInfo from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.mistral.ocr.transformation import MistralOCRConfig @@ -196,6 +195,7 @@ from litellm.types.utils import ( ProviderField, ProviderSpecificModelInfo, RawRequestTypedDict, + SearchProviders, SelectTokenizerResponse, StreamingChoices, TextChoices, @@ -912,6 +912,7 @@ def _get_wrapper_timeout( return timeout + def check_coroutine(value) -> bool: return get_coroutine_checker().is_async_callable(value) @@ -991,9 +992,7 @@ def post_call_processing( ].message.content # type: ignore if model_response is not None: ### POST-CALL RULES ### - rules_obj.post_call_rules( - input=model_response, model=model - ) + rules_obj.post_call_rules(input=model_response, model=model) ### JSON SCHEMA VALIDATION ### if litellm.enable_json_schema_validation is True: try: @@ -1009,9 +1008,9 @@ def post_call_processing( optional_params["response_format"], dict, ) - and optional_params[ - "response_format" - ].get("json_schema") + and optional_params["response_format"].get( + "json_schema" + ) is not None ): json_response_format = optional_params[ @@ -1039,9 +1038,7 @@ def post_call_processing( if ( optional_params is not None and "response_format" in optional_params - and isinstance( - optional_params["response_format"], dict - ) + and isinstance(optional_params["response_format"], dict) and "type" in optional_params["response_format"] and optional_params["response_format"]["type"] == "json_object" @@ -1075,7 +1072,6 @@ def post_call_processing( def client(original_function): # noqa: PLR0915 rules_obj = Rules() - @wraps(original_function) def wrapper(*args, **kwargs): # noqa: PLR0915 # DO NOT MOVE THIS. It always needs to run first @@ -3064,6 +3060,32 @@ def _remove_unsupported_params( return non_default_params +def filter_out_litellm_params(kwargs: dict) -> dict: + """ + Filter out LiteLLM internal parameters from kwargs dict. + + Returns a new dict containing only non-LiteLLM parameters that should be + passed to external provider APIs. + + Args: + kwargs: Dictionary that may contain LiteLLM internal parameters + + Returns: + Dictionary with LiteLLM internal parameters filtered out + + Example: + >>> kwargs = {"query": "test", "shared_session": session_obj, "metadata": {}} + >>> filtered = filter_out_litellm_params(kwargs) + >>> # filtered = {"query": "test"} + """ + + return { + key: value + for key, value in kwargs.items() + if key not in all_litellm_params + } + + class PreProcessNonDefaultParams: @staticmethod def base_pre_process_non_default_params( @@ -5001,7 +5023,9 @@ def _get_model_info_helper( # noqa: PLR0915 tpm=_model_info.get("tpm", None), rpm=_model_info.get("rpm", None), ocr_cost_per_page=_model_info.get("ocr_cost_per_page", None), - annotation_cost_per_page=_model_info.get("annotation_cost_per_page", None), + annotation_cost_per_page=_model_info.get( + "annotation_cost_per_page", None + ), ) except Exception as e: verbose_logger.debug(f"Error getting model info: {e}") @@ -7227,6 +7251,7 @@ class ProviderConfigManager: from litellm.llms.sagemaker.embedding.transformation import ( SagemakerEmbeddingConfig, ) + return SagemakerEmbeddingConfig.get_model_config(model) return None @@ -7462,6 +7487,7 @@ class ProviderConfigManager: @staticmethod def get_provider_vector_stores_config( provider: LlmProviders, + api_type: Optional[str] = None, ) -> Optional[BaseVectorStoreConfig]: """ v2 vector store config, use this for new vector store integrations @@ -7479,11 +7505,18 @@ class ProviderConfigManager: return AzureOpenAIVectorStoreConfig() elif litellm.LlmProviders.VERTEX_AI == provider: - from litellm.llms.vertex_ai.vector_stores.transformation import ( - VertexVectorStoreConfig, - ) + if api_type == "rag_api" or api_type is None: # default to rag_api + from litellm.llms.vertex_ai.vector_stores.rag_api.transformation import ( + VertexVectorStoreConfig, + ) - return VertexVectorStoreConfig() + return VertexVectorStoreConfig() + elif api_type == "search_api": + from litellm.llms.vertex_ai.vector_stores.search_api.transformation import ( + VertexSearchAPIVectorStoreConfig, + ) + + return VertexSearchAPIVectorStoreConfig() elif litellm.LlmProviders.BEDROCK == provider: from litellm.llms.bedrock.vector_stores.transformation import ( BedrockVectorStoreConfig, @@ -7622,6 +7655,41 @@ class ProviderConfigManager: return None return config_class() + @staticmethod + def get_provider_search_config( + provider: "SearchProviders", + ) -> Optional["BaseSearchConfig"]: + """ + Get Search configuration for a given provider. + """ + from litellm.llms.dataforseo.search.transformation import ( + DataForSEOSearchConfig, + ) + from litellm.llms.exa_ai.search.transformation import ( + ExaAISearchConfig, + ) + from litellm.llms.google_pse.search.transformation import ( + GooglePSESearchConfig, + ) + from litellm.llms.parallel_ai.search.transformation import ( + ParallelAISearchConfig, + ) + from litellm.llms.perplexity.search.transformation import PerplexitySearchConfig + from litellm.llms.tavily.search.transformation import TavilySearchConfig + + PROVIDER_TO_CONFIG_MAP = { + SearchProviders.PERPLEXITY: PerplexitySearchConfig, + SearchProviders.TAVILY: TavilySearchConfig, + SearchProviders.PARALLEL_AI: ParallelAISearchConfig, + SearchProviders.EXA_AI: ExaAISearchConfig, + SearchProviders.GOOGLE_PSE: GooglePSESearchConfig, + SearchProviders.DATAFORSEO: DataForSEOSearchConfig, + } + config_class = PROVIDER_TO_CONFIG_MAP.get(provider, None) + if config_class is None: + return None + return config_class() + @staticmethod def get_provider_text_to_speech_config( model: str, diff --git a/litellm/vector_stores/main.py b/litellm/vector_stores/main.py index 01e06e1306d..3cbbfea1804 100644 --- a/litellm/vector_stores/main.py +++ b/litellm/vector_stores/main.py @@ -1,6 +1,7 @@ """ LiteLLM SDK Functions for Creating and Searching Vector Stores """ + import asyncio import contextvars from functools import partial @@ -10,6 +11,7 @@ import httpx import litellm from litellm.constants import request_timeout +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.types.router import GenericLiteLLMParams @@ -42,12 +44,12 @@ def mock_vector_store_search_response( content=[ VectorStoreResultContent( text="This is a sample search result from the vector store.", - type="text" + type="text", ) - ] + ], ) ] - + return VectorStoreSearchResponse( object="vector_store.search_results.page", search_query="sample query", @@ -79,7 +81,7 @@ def mock_vector_store_create_response( last_active_at=None, metadata=None, ) - + return mock_response @@ -166,14 +168,14 @@ def create( ) -> Union[VectorStoreCreateResponse, Coroutine[Any, Any, VectorStoreCreateResponse]]: """ Create a vector store. - + Args: name: The name of the vector store. file_ids: A list of File IDs that the vector store should use. expires_after: The expiration policy for the vector store. chunking_strategy: The chunking strategy used to chunk the file(s). metadata: Set of 16 key-value pairs that can be attached to an object. - + Returns: VectorStoreCreateResponse containing the created vector store details. """ @@ -198,9 +200,18 @@ def create( if custom_llm_provider is None: custom_llm_provider = "openai" + api_type, custom_llm_provider, _, _ = get_llm_provider( + model=custom_llm_provider, + custom_llm_provider=None, + litellm_params=None, + ) + # get provider config - using vector store custom logger for now - vector_store_provider_config = ProviderConfigManager.get_provider_vector_stores_config( - provider=litellm.LlmProviders(custom_llm_provider), + vector_store_provider_config = ( + ProviderConfigManager.get_provider_vector_stores_config( + provider=litellm.LlmProviders(custom_llm_provider), + api_type=api_type, + ) ) if vector_store_provider_config is None: @@ -209,7 +220,7 @@ def create( ) local_vars.update(kwargs) - + # Get VectorStoreCreateOptionalRequestParams with only valid parameters vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams = ( VectorStoreRequestUtils.get_requested_vector_store_create_optional_param( @@ -242,7 +253,7 @@ def create( _is_async=_is_async, client=kwargs.get("client"), ) - + return response except Exception as e: raise litellm.exception_type( @@ -340,7 +351,7 @@ def search( ) -> Union[VectorStoreSearchResponse, Coroutine[Any, Any, VectorStoreSearchResponse]]: """ Search a vector store for relevant chunks based on a query and file attributes filter. - + Args: vector_store_id: The ID of the vector store to search. query: A query string or array for the search. @@ -348,7 +359,7 @@ def search( max_num_results: Maximum number of results to return (1-50, default 10). ranking_options: Optional ranking options for search. rewrite_query: Whether to rewrite the natural language query for vector search. - + Returns: VectorStoreSearchResponse containing the search results. """ @@ -375,7 +386,7 @@ def search( pass # get llm provider logic - litellm_params = GenericLiteLLMParams(**kwargs) + litellm_params = GenericLiteLLMParams(vector_store_id=vector_store_id, **kwargs) ## MOCK RESPONSE LOGIC if litellm_params.mock_response and isinstance( @@ -390,9 +401,22 @@ def search( if custom_llm_provider is None: custom_llm_provider = "openai" + if "/" in custom_llm_provider: + api_type, custom_llm_provider, _, _ = get_llm_provider( + model=custom_llm_provider, + custom_llm_provider=None, + litellm_params=None, + ) + else: + api_type = None + custom_llm_provider = custom_llm_provider + # get provider config - using vector store custom logger for now - vector_store_provider_config = ProviderConfigManager.get_provider_vector_stores_config( - provider=litellm.LlmProviders(custom_llm_provider), + vector_store_provider_config = ( + ProviderConfigManager.get_provider_vector_stores_config( + provider=litellm.LlmProviders(custom_llm_provider), + api_type=api_type, + ) ) if vector_store_provider_config is None: @@ -401,7 +425,7 @@ def search( ) local_vars.update(kwargs) - + # Get VectorStoreSearchOptionalRequestParams with only valid parameters vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams = ( VectorStoreRequestUtils.get_requested_vector_store_search_optional_param( @@ -438,7 +462,7 @@ def search( _is_async=_is_async, client=kwargs.get("client"), ) - + return response except Exception as e: raise litellm.exception_type( @@ -447,4 +471,4 @@ def search( original_exception=e, completion_kwargs=local_vars, extra_kwargs=kwargs, - ) \ No newline at end of file + ) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 25f4ea9ff90..4804ae1de74 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1,4 +1,44 @@ { + "sample_spec": { + "code_interpreter_cost_per_session": 0.0, + "computer_use_input_cost_per_1k_tokens": 0.0, + "computer_use_output_cost_per_1k_tokens": 0.0, + "deprecation_date": "date when the model becomes deprecated in the format YYYY-MM-DD", + "file_search_cost_per_1k_calls": 0.0, + "file_search_cost_per_gb_per_day": 0.0, + "input_cost_per_audio_token": 0.0, + "input_cost_per_token": 0.0, + "litellm_provider": "one of https://docs.litellm.ai/docs/providers", + "max_input_tokens": "max input tokens, if the provider specifies it. if not default to max_tokens", + "max_output_tokens": "max output tokens, if the provider specifies it. if not default to max_tokens", + "max_tokens": "LEGACY parameter. set to max_output_tokens if provider specifies it. IF not set to max_input_tokens, if provider specifies it.", + "mode": "one of: chat, embedding, completion, image_generation, audio_transcription, audio_speech, image_generation, moderation, rerank, search", + "output_cost_per_reasoning_token": 0.0, + "output_cost_per_token": 0.0, + "search_context_cost_per_query": { + "search_context_size_high": 0.0, + "search_context_size_low": 0.0, + "search_context_size_medium": 0.0 + }, + "supported_regions": [ + "global", + "us-west-2", + "eu-west-1", + "ap-southeast-1", + "ap-northeast-1" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_web_search": true, + "vector_store_cost_per_gb_per_day": 0.0 + }, "1024-x-1024/50-steps/bedrock/amazon.nova-canvas-v1:0": { "litellm_provider": "bedrock", "max_input_tokens": 2600, @@ -471,6 +511,26 @@ "supports_tool_choice": true, "supports_vision": true }, + "anthropic.claude-3-7-sonnet-20240620-v1:0": { + "cache_creation_input_token_cost": 4.5e-06, + "cache_read_input_token_cost": 3.6e-07, + "input_cost_per_token": 3.6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.8e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "anthropic.claude-3-7-sonnet-20250219-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, @@ -4284,6 +4344,26 @@ "mode": "chat", "output_cost_per_token": 1.5e-06 }, + "bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0": { + "cache_creation_input_token_cost": 4.5e-06, + "cache_read_input_token_cost": 3.6e-07, + "input_cost_per_token": 3.6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.8e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", @@ -6380,6 +6460,11 @@ "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, + "dataforseo/search": { + "input_cost_per_query": 0.003, + "litellm_provider": "dataforseo", + "mode": "search" + }, "davinci-002": { "input_cost_per_token": 2e-06, "litellm_provider": "text-completion-openai", @@ -7720,6 +7805,31 @@ "output_cost_per_token": 0.0, "output_vector_size": 2560 }, + "exa_ai/search": { + "litellm_provider": "exa_ai", + "mode": "search", + "tiered_pricing": [ + { + "input_cost_per_query": 5e-03, + "max_results_range": [ + 0, + 25 + ] + }, + { + "input_cost_per_query": 25e-03, + "max_results_range": [ + 26, + 100 + ] + } + ] + }, + "perplexity/search": { + "input_cost_per_query": 5e-03, + "litellm_provider": "perplexity", + "mode": "search" + }, "elevenlabs/scribe_v1": { "input_cost_per_second": 6.11e-05, "litellm_provider": "elevenlabs", @@ -12131,6 +12241,11 @@ "video" ] }, + "google_pse/search": { + "input_cost_per_query": 0.005, + "litellm_provider": "google_pse", + "mode": "search" + }, "global.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, @@ -18722,6 +18837,16 @@ "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, + "parallel_ai/search": { + "input_cost_per_query": 0.004, + "litellm_provider": "parallel_ai", + "mode": "search" + }, + "parallel_ai/search-pro": { + "input_cost_per_query": 0.009, + "litellm_provider": "parallel_ai", + "mode": "search" + }, "perplexity/codellama-34b-instruct": { "input_cost_per_token": 3.5e-07, "litellm_provider": "perplexity", @@ -19501,46 +19626,7 @@ "supports_reasoning": true, "source": "https://cloud.sambanova.ai/plans/pricing" }, - "sample_spec": { - "code_interpreter_cost_per_session": 0.0, - "computer_use_input_cost_per_1k_tokens": 0.0, - "computer_use_output_cost_per_1k_tokens": 0.0, - "deprecation_date": "date when the model becomes deprecated in the format YYYY-MM-DD", - "file_search_cost_per_1k_calls": 0.0, - "file_search_cost_per_gb_per_day": 0.0, - "input_cost_per_audio_token": 0.0, - "input_cost_per_token": 0.0, - "litellm_provider": "one of https://docs.litellm.ai/docs/providers", - "max_input_tokens": "max input tokens, if the provider specifies it. if not default to max_tokens", - "max_output_tokens": "max output tokens, if the provider specifies it. if not default to max_tokens", - "max_tokens": "LEGACY parameter. set to max_output_tokens if provider specifies it. IF not set to max_input_tokens, if provider specifies it.", - "mode": "one of: chat, embedding, completion, image_generation, audio_transcription, audio_speech, image_generation, moderation, rerank", - "output_cost_per_reasoning_token": 0.0, - "output_cost_per_token": 0.0, - "search_context_cost_per_query": { - "search_context_size_high": 0.0, - "search_context_size_low": 0.0, - "search_context_size_medium": 0.0 - }, - "supported_regions": [ - "global", - "us-west-2", - "eu-west-1", - "ap-southeast-1", - "ap-northeast-1" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_vision": true, - "supports_web_search": true, - "vector_store_cost_per_gb_per_day": 0.0 - }, + "snowflake/claude-3-5-sonnet": { "litellm_provider": "snowflake", "max_input_tokens": 18000, @@ -19771,6 +19857,16 @@ "mode": "image_generation", "output_cost_per_pixel": 0.0 }, + "tavily/search": { + "input_cost_per_query": 0.008, + "litellm_provider": "tavily", + "mode": "search" + }, + "tavily/search-advanced": { + "input_cost_per_query": 0.016, + "litellm_provider": "tavily", + "mode": "search" + }, "text-bison": { "input_cost_per_character": 2.5e-07, "litellm_provider": "vertex_ai-text-models", diff --git a/poetry.lock b/poetry.lock index 1fc0b3afcad..04f6f1e1935 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -6,6 +6,7 @@ version = "2.4.4" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8"}, {file = "aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745"}, @@ -17,6 +18,7 @@ version = "3.10.11" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "aiohttp-3.10.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e"}, {file = "aiohttp-3.10.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298"}, @@ -121,7 +123,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.12.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] [[package]] name = "aiosignal" @@ -129,6 +131,7 @@ version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, @@ -143,6 +146,8 @@ version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" optional = true python-versions = ">=3.6" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, @@ -154,6 +159,8 @@ version = "1.17.0" description = "A database migration tool for SQLAlchemy." optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99"}, {file = "alembic-1.17.0.tar.gz", hash = "sha256:4652a0b3e19616b57d652b82bfa5e38bf5dbea0813eed971612671cb9e90c0fe"}, @@ -174,6 +181,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -188,6 +196,7 @@ version = "4.5.2" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f"}, {file = "anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b"}, @@ -201,7 +210,7 @@ typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -210,6 +219,8 @@ version = "3.11.0" description = "In-process task scheduler with Cron-like capabilities" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da"}, {file = "apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133"}, @@ -227,7 +238,7 @@ mongodb = ["pymongo (>=3.0)"] redis = ["redis (>=3.0)"] rethinkdb = ["rethinkdb (>=2.4.0)"] sqlalchemy = ["sqlalchemy (>=1.4)"] -test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6", "anyio (>=4.5.2)", "gevent", "pytest", "pytz", "twisted"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytz", "twisted ; python_version < \"3.14\""] tornado = ["tornado (>=4.3)"] twisted = ["twisted"] zookeeper = ["kazoo"] @@ -236,8 +247,10 @@ zookeeper = ["kazoo"] name = "async-timeout" version = "5.0.1" description = "Timeout context manager for asyncio programs" -optional = false +optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_full_version < \"3.11.3\" and (extra == \"extra-proxy\" or extra == \"proxy\") or python_version <= \"3.10\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -249,18 +262,19 @@ version = "25.3.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] [[package]] name = "azure-core" @@ -268,6 +282,7 @@ version = "1.33.0" description = "Microsoft Azure Core Library for Python" optional = false python-versions = ">=3.8" +groups = ["main", "proxy-dev"] files = [ {file = "azure_core-1.33.0-py3-none-any.whl", hash = "sha256:9b5b6d0223a1d38c37500e6971118c1e0f13f54951e6893968b38910bc9cda8f"}, {file = "azure_core-1.33.0.tar.gz", hash = "sha256:f367aa07b5e3005fec2c1e184b882b0b039910733907d001c20fb08ebb8c0eb9"}, @@ -288,6 +303,7 @@ version = "1.21.0" description = "Microsoft Azure Identity Library for Python" optional = false python-versions = ">=3.8" +groups = ["main", "proxy-dev"] files = [ {file = "azure_identity-1.21.0-py3-none-any.whl", hash = "sha256:258ea6325537352440f71b35c3dffe9d240eae4a5126c1b7ce5efd5766bd9fd9"}, {file = "azure_identity-1.21.0.tar.gz", hash = "sha256:ea22ce6e6b0f429bc1b8d9212d5b9f9877bd4c82f1724bfa910760612c07a9a6"}, @@ -306,6 +322,8 @@ version = "4.9.0" description = "Microsoft Azure Key Vault Secrets Client Library for Python" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"extra-proxy\"" files = [ {file = "azure_keyvault_secrets-4.9.0-py3-none-any.whl", hash = "sha256:33c7e2aca2cc2092cebc8c6e96eca36a5cc30c767e16ea429c5fa21270e9fba6"}, {file = "azure_keyvault_secrets-4.9.0.tar.gz", hash = "sha256:2a03bb2ffd9a0d6c8ad1c330d9d0310113985a9de06607ece378fd72a5889fe1"}, @@ -322,6 +340,8 @@ version = "12.26.0" description = "Microsoft Azure Blob Storage Client Library for Python" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "azure_storage_blob-12.26.0-py3-none-any.whl", hash = "sha256:8c5631b8b22b4f53ec5fff2f3bededf34cfef111e2af613ad42c9e6de00a77fe"}, {file = "azure_storage_blob-12.26.0.tar.gz", hash = "sha256:5dd7d7824224f7de00bfeb032753601c982655173061e242f13be6e26d78d71f"}, @@ -342,6 +362,8 @@ version = "2.17.0" description = "Internationalization utilities" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, @@ -351,7 +373,7 @@ files = [ pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} [package.extras] -dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] [[package]] name = "backoff" @@ -359,10 +381,12 @@ version = "2.2.1" description = "Function decoration for backoff and retry" optional = false python-versions = ">=3.7,<4.0" +groups = ["main", "dev"] files = [ {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, ] +markers = {main = "python_version >= \"3.9\" and (extra == \"semantic-router\" or extra == \"proxy\") or extra == \"proxy\""} [[package]] name = "backports-zoneinfo" @@ -370,6 +394,8 @@ version = "0.2.1" description = "Backport of the standard library zoneinfo module" optional = true python-versions = ">=3.6" +groups = ["main"] +markers = "extra == \"proxy\" and python_version < \"3.9\"" files = [ {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, @@ -398,6 +424,7 @@ version = "23.12.1" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"}, {file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"}, @@ -434,7 +461,7 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -444,6 +471,8 @@ version = "1.9.0" description = "Fast, simple object-to-object and broadcast signaling" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, @@ -455,6 +484,8 @@ version = "1.36.0" description = "The AWS SDK for Python" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "boto3-1.36.0-py3-none-any.whl", hash = "sha256:d0ca7a58ce25701a52232cc8df9d87854824f1f2964b929305722ebc7959d5a9"}, {file = "boto3-1.36.0.tar.gz", hash = "sha256:159898f51c2997a12541c0e02d6e5a8fe2993ddb307b9478fd9a339f98b57e00"}, @@ -474,6 +505,8 @@ version = "1.36.26" description = "Low-level, data-driven core of boto 3." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "botocore-1.36.26-py3-none-any.whl", hash = "sha256:4e3f19913887a58502e71ef8d696fe7eaa54de7813ff73390cd5883f837dfa6e"}, {file = "botocore-1.36.26.tar.gz", hash = "sha256:4a63bcef7ecf6146fd3a61dc4f9b33b7473b49bdaf1770e9aaca6eee0c9eab62"}, @@ -496,6 +529,8 @@ version = "5.5.2" description = "Extensible memoizing collections and decorators" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "python_version >= \"3.10\" and (extra == \"mlflow\" or extra == \"extra-proxy\") or extra == \"extra-proxy\"" files = [ {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, @@ -507,6 +542,7 @@ version = "2025.10.5" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, @@ -518,6 +554,8 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] +markers = "python_version < \"3.10\" and platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -591,12 +629,111 @@ files = [ [package.dependencies] pycparser = "*" +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev", "proxy-dev"] +markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "charset-normalizer" version = "3.4.3" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, @@ -685,6 +822,7 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -699,6 +837,8 @@ version = "3.1.1" description = "Pickler class to extend the standard pickle.Pickler functionality" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "cloudpickle-3.1.1-py3-none-any.whl", hash = "sha256:c8c5a44295039331ee9dad40ba100a9c7297b6f988e50e87ccdf3765a668350e"}, {file = "cloudpickle-3.1.1.tar.gz", hash = "sha256:b216fa8ae4019d5482a8ac3c95d8f6346115d8835911fd4aefd1a445e4242c64"}, @@ -710,6 +850,8 @@ version = "4.57" description = "Python SDK for the Cohere API" optional = true python-versions = ">=3.8,<4.0" +groups = ["main"] +markers = "python_version >= \"3.9\" and extra == \"semantic-router\"" files = [ {file = "cohere-4.57-py3-none-any.whl", hash = "sha256:479bdea81ae119e53f671f1ae808fcff9df88211780525d7ef2f7b99dfb32e59"}, {file = "cohere-4.57.tar.gz", hash = "sha256:71ace0204a92d1a2a8d4b949b88b353b4f22fc645486851924284cc5a0eb700d"}, @@ -729,10 +871,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\" and (extra == \"utils\" or extra == \"semantic-router\") and python_version >= \"3.9\" or sys_platform == \"win32\" and extra == \"utils\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", proxy-dev = "platform_system == \"Windows\""} [[package]] name = "coloredlogs" @@ -740,6 +884,8 @@ version = "15.0.1" description = "Colored terminal output for Python's logging module" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +markers = "python_version >= \"3.9\" and extra == \"extra-proxy\" and python_version < \"3.14\"" files = [ {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"}, {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"}, @@ -757,6 +903,8 @@ version = "6.9.0" description = "Add colours to the output of Python's logging module." optional = true python-versions = ">=3.6" +groups = ["main"] +markers = "python_version >= \"3.9\" and extra == \"semantic-router\"" files = [ {file = "colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff"}, {file = "colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2"}, @@ -774,6 +922,8 @@ version = "1.3.2" description = "Python library for calculating contours of 2D quadrilateral grids" optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934"}, {file = "contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989"}, @@ -850,6 +1000,7 @@ version = "43.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, @@ -899,6 +1050,8 @@ version = "0.12.1" description = "Composable style cycles" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, @@ -914,6 +1067,8 @@ version = "0.67.0" description = "Databricks SDK for Python (Beta)" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "databricks_sdk-0.67.0-py3-none-any.whl", hash = "sha256:ef49e49db45ed12c015a32a6f9d4ba395850f25bb3dcffdcaf31a5167fe03ee2"}, {file = "databricks_sdk-0.67.0.tar.gz", hash = "sha256:f923227babcaad428b0c2eede2755ebe9deb996e2c8654f179eb37f486b37a36"}, @@ -924,9 +1079,9 @@ google-auth = ">=2.0,<3.0" requests = ">=2.28.1,<3" [package.extras] -dev = ["autoflake", "black", "build", "databricks-connect", "httpx", "ipython", "ipywidgets", "isort", "langchain-openai", "openai", "pycodestyle", "pyfakefs", "pytest", "pytest-cov", "pytest-mock", "pytest-rerunfailures", "pytest-xdist (>=3.6.1,<4.0)", "requests-mock", "wheel"] +dev = ["autoflake", "black", "build", "databricks-connect", "httpx", "ipython", "ipywidgets", "isort", "langchain-openai ; python_version > \"3.7\"", "openai", "pycodestyle", "pyfakefs", "pytest", "pytest-cov", "pytest-mock", "pytest-rerunfailures", "pytest-xdist (>=3.6.1,<4.0)", "requests-mock", "wheel"] notebook = ["ipython (>=8,<10)", "ipywidgets (>=8,<9)"] -openai = ["httpx", "langchain-openai", "openai"] +openai = ["httpx", "langchain-openai ; python_version > \"3.7\"", "openai"] [[package]] name = "deprecated" @@ -934,16 +1089,18 @@ version = "1.2.18" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec"}, {file = "deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d"}, ] +markers = {main = "python_version >= \"3.10\""} [package.dependencies] wrapt = ">=1.10,<2" [package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools", "tox"] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"] [[package]] name = "diskcache" @@ -951,6 +1108,8 @@ version = "5.6.3" description = "Disk Cache -- Disk and file backed persistent cache." optional = true python-versions = ">=3" +groups = ["main"] +markers = "extra == \"caching\"" files = [ {file = "diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19"}, {file = "diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc"}, @@ -962,6 +1121,7 @@ version = "1.9.0" description = "Distro - an OS platform information API" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, @@ -973,6 +1133,8 @@ version = "2.6.1" description = "DNS toolkit" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, @@ -993,6 +1155,8 @@ version = "7.1.0" description = "A Python library for the Docker Engine API." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, @@ -1015,6 +1179,8 @@ version = "0.20.1" description = "Docutils -- Python Documentation Utilities" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, @@ -1026,6 +1192,8 @@ version = "2.3.0" description = "A robust email address syntax and deliverability validation library." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, @@ -1041,6 +1209,8 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["main", "dev", "proxy-dev"] +markers = "python_version <= \"3.10\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, @@ -1058,10 +1228,12 @@ version = "0.115.14" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca"}, {file = "fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739"}, ] +markers = {main = "python_version >= \"3.10\" and (extra == \"mlflow\" or extra == \"proxy\") or extra == \"proxy\""} [package.dependencies] pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" @@ -1078,6 +1250,7 @@ version = "1.7.5" description = "FastAPI without reliance on CDNs for docs" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "fastapi_offline-1.7.5-py3-none-any.whl", hash = "sha256:00369632d604e8156b9ca9ab9c65e58ad8beff83d1ffc7bdbcec4a86173d51b4"}, {file = "fastapi_offline-1.7.5.tar.gz", hash = "sha256:07a58cb8d8fab68ba625698414b4cac833bb2d94d82dc0fbc2a8519bee7af87d"}, @@ -1095,6 +1268,8 @@ version = "0.16.0" description = "FastAPI plugin to enable SSO to most common providers (such as Facebook login, Google login and login via Microsoft Office 365 Account)" optional = true python-versions = "<4.0,>=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "fastapi_sso-0.16.0-py3-none-any.whl", hash = "sha256:3a66a942474ef9756d3a9d8b945d55bd9faf99781facdb9b87a40b73d6d6b0c3"}, {file = "fastapi_sso-0.16.0.tar.gz", hash = "sha256:f3941f986347566b7d3747c710cf474a907f581bfb6697ff3bb3e44eb76b438c"}, @@ -1113,6 +1288,8 @@ version = "1.12.1" description = "Fast read/write of AVRO files" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.9\" and extra == \"semantic-router\"" files = [ {file = "fastavro-1.12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:00650ca533907361edda22e6ffe8cf87ab2091c5d8aee5c8000b0f2dcdda7ed3"}, {file = "fastavro-1.12.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac76d6d95f909c72ee70d314b460b7e711d928845771531d823eb96a10952d26"}, @@ -1174,6 +1351,7 @@ version = "0.13.5" description = "Python bindings to Rust's UUID library." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "fastuuid-0.13.5-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b9edf8ee30718aee787cdd2e9e1ff3d4a3ec6ddb32fba0a23fa04956df69ab07"}, {file = "fastuuid-0.13.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f67ea1e25c5e782f7fb5aaa5208f157d950401dd9321ce56bcc6d4dc3d72ed60"}, @@ -1250,6 +1428,7 @@ version = "3.16.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, @@ -1258,7 +1437,7 @@ files = [ [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] -typing = ["typing-extensions (>=4.12.2)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] [[package]] name = "flake8" @@ -1266,6 +1445,7 @@ version = "6.1.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.8.1" +groups = ["dev"] files = [ {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, @@ -1282,6 +1462,8 @@ version = "3.1.2" description = "A simple framework for building complex web applications." optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"}, {file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"}, @@ -1305,6 +1487,8 @@ version = "4.60.1" description = "Tools to manipulate font files" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28"}, {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15"}, @@ -1367,17 +1551,17 @@ files = [ ] [package.extras] -all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "pycairo", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0)", "xattr", "zopfli (>=0.1.4)"] +all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0) ; python_version <= \"3.12\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] graphite = ["lz4 (>=1.7.4.2)"] -interpolatable = ["munkres", "pycairo", "scipy"] +interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""] lxml = ["lxml (>=4.0)"] pathops = ["skia-pathops (>=0.5.0)"] plot = ["matplotlib"] repacker = ["uharfbuzz (>=0.23.0)"] symfont = ["sympy"] -type1 = ["xattr"] -unicode = ["unicodedata2 (>=15.1.0)"] -woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] +type1 = ["xattr ; sys_platform == \"darwin\""] +unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""] +woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] [[package]] name = "frozenlist" @@ -1385,6 +1569,7 @@ version = "1.5.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a"}, {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb"}, @@ -1486,6 +1671,7 @@ version = "2025.3.0" description = "File-system specification" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "fsspec-2025.3.0-py3-none-any.whl", hash = "sha256:efb87af3efa9103f94ca91a7f8cb7a4df91af9f74fc106c9c7ea0efd7277c1b3"}, {file = "fsspec-2025.3.0.tar.gz", hash = "sha256:a935fd1ea872591f2b5148907d103488fc523295e6c64b835cfad8c3eca44972"}, @@ -1525,6 +1711,8 @@ version = "4.0.12" description = "Git Object Database" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf"}, {file = "gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571"}, @@ -1539,6 +1727,8 @@ version = "3.1.45" description = "GitPython is a Python library used to interact with Git repositories" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77"}, {file = "gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c"}, @@ -1549,7 +1739,7 @@ gitdb = ">=4.0.1,<5" [package.extras] doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] -test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"] +test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] [[package]] name = "google-api-core" @@ -1557,6 +1747,8 @@ version = "2.25.2" description = "Google API client core library" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "python_version >= \"3.14\" and extra == \"extra-proxy\"" files = [ {file = "google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7"}, {file = "google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300"}, @@ -1573,7 +1765,7 @@ requests = ">=2.18.0,<3.0.0" [package.extras] async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] -grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0)", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0)"] +grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\""] grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] @@ -1583,6 +1775,8 @@ version = "2.26.0" description = "Google API client core library" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.14\" and extra == \"extra-proxy\"" files = [ {file = "google_api_core-2.26.0-py3-none-any.whl", hash = "sha256:2b204bd0da2c81f918e3582c48458e24c11771f987f6258e6e227212af78f3ed"}, {file = "google_api_core-2.26.0.tar.gz", hash = "sha256:e6e6d78bd6cf757f4aee41dcc85b07f485fbb069d5daa3afb126defba1e91a62"}, @@ -1592,23 +1786,23 @@ files = [ google-auth = ">=2.14.1,<3.0.0" googleapis-common-protos = ">=1.56.2,<2.0.0" grpcio = [ - {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\" and python_version < \"3.14\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, {version = ">=1.33.2,<2.0.0", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, ] grpcio-status = [ - {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\" and python_version < \"3.14\""}, - {version = ">=1.33.2,<2.0.0", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, + {version = ">=1.33.2,<2.0.0", optional = true, markers = "extra == \"grpc\""}, ] proto-plus = [ + {version = ">=1.22.3,<2.0.0"}, {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, - {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""}, ] protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" requests = ">=2.18.0,<3.0.0" [package.extras] async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] -grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0)", "grpcio (>=1.75.1,<2.0.0)", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0)", "grpcio-status (>=1.75.1,<2.0.0)"] +grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.75.1,<2.0.0) ; python_version >= \"3.14\""] grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] @@ -1618,6 +1812,8 @@ version = "2.41.1" description = "Google Authentication Library" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "python_version >= \"3.10\" and (extra == \"mlflow\" or extra == \"extra-proxy\") or extra == \"extra-proxy\"" files = [ {file = "google_auth-2.41.1-py2.py3-none-any.whl", hash = "sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d"}, {file = "google_auth-2.41.1.tar.gz", hash = "sha256:b76b7b1f9e61f0cb7e88870d14f6a94aeef248959ef6992670efee37709cbfd2"}, @@ -1631,11 +1827,11 @@ rsa = ">=3.1.4,<5" [package.extras] aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] enterprise-cert = ["cryptography", "pyopenssl"] -pyjwt = ["cryptography (<39.0.0)", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"] -pyopenssl = ["cryptography (<39.0.0)", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +pyjwt = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"] +pyopenssl = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] reauth = ["pyu2f (>=0.1.5)"] requests = ["requests (>=2.20.0,<3.0.0)"] -testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0)", "cryptography (<39.0.0)", "cryptography (>=38.0.3)", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] +testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] urllib3 = ["packaging", "urllib3"] [[package]] @@ -1644,6 +1840,8 @@ version = "2.19.1" description = "Google Cloud Iam API client library" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"extra-proxy\"" files = [ {file = "google_cloud_iam-2.19.1-py3-none-any.whl", hash = "sha256:11b08b86d82510021f9dd9f0beb5a08219e070deab09e28d4c0ce49f8c70997d"}, {file = "google_cloud_iam-2.19.1.tar.gz", hash = "sha256:f059c369ad98af6be3401f0f5d087775d775fb96833be1e9ab8048c422fb1bf4"}, @@ -1654,8 +1852,8 @@ google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" grpc-google-iam-v1 = ">=0.12.4,<1.0.0" proto-plus = [ + {version = ">=1.22.3,<2.0.0"}, {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, - {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""}, ] protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" @@ -1665,6 +1863,8 @@ version = "2.24.2" description = "Google Cloud Kms API client library" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"extra-proxy\"" files = [ {file = "google_cloud_kms-2.24.2-py2.py3-none-any.whl", hash = "sha256:368209b035dfac691a467c1cf50986d8b1b26cac1166bdfbaa25d738df91ff7b"}, {file = "google_cloud_kms-2.24.2.tar.gz", hash = "sha256:e9e18bbfafd1a4035c76c03fb5ff03f4f57f596d08e1a9ede7e69ec0151b27a1"}, @@ -1683,10 +1883,12 @@ version = "1.70.0" description = "Common protobufs used in Google APIs" optional = false python-versions = ">=3.7" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8"}, {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, ] +markers = {main = "extra == \"extra-proxy\""} [package.dependencies] grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""} @@ -1701,6 +1903,8 @@ version = "3.4.3" description = "GraphQL Framework for Python" optional = true python-versions = "*" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71"}, {file = "graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa"}, @@ -1722,6 +1926,8 @@ version = "3.2.6" description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL." optional = true python-versions = "<4,>=3.6" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "graphql_core-3.2.6-py3-none-any.whl", hash = "sha256:78b016718c161a6fb20a7d97bbf107f331cd1afe53e45566c59f776ed7f0b45f"}, {file = "graphql_core-3.2.6.tar.gz", hash = "sha256:c08eec22f9e40f0bd61d805907e3b3b1b9a320bc606e23dc145eebca07c8fbab"}, @@ -1733,6 +1939,8 @@ version = "3.2.0" description = "Relay library for graphql-core" optional = true python-versions = ">=3.6,<4" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c"}, {file = "graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5"}, @@ -1747,6 +1955,8 @@ version = "3.2.4" description = "Lightweight in-process concurrent programming" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\") and extra == \"mlflow\"" files = [ {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, @@ -1814,6 +2024,8 @@ version = "0.14.2" description = "IAM API client library" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"extra-proxy\"" files = [ {file = "grpc_google_iam_v1-0.14.2-py3-none-any.whl", hash = "sha256:a3171468459770907926d56a440b2bb643eec1d7ba215f48f3ecece42b4d8351"}, {file = "grpc_google_iam_v1-0.14.2.tar.gz", hash = "sha256:b3e1fc387a1a329e41672197d0ace9de22c78dd7d215048c4c78712073f7bd20"}, @@ -1830,6 +2042,7 @@ version = "1.70.0" description = "HTTP/2-based RPC framework" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "grpcio-1.70.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:95469d1977429f45fe7df441f586521361e235982a0b39e33841549143ae2851"}, {file = "grpcio-1.70.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:ed9718f17fbdb472e33b869c77a16d0b55e166b100ec57b016dc7de9c8d236bf"}, @@ -1887,6 +2100,7 @@ files = [ {file = "grpcio-1.70.0-cp39-cp39-win_amd64.whl", hash = "sha256:a31d7e3b529c94e930a117b2175b2efd179d96eb3c7a21ccb0289a8ab05b645c"}, {file = "grpcio-1.70.0.tar.gz", hash = "sha256:8d1584a68d5922330025881e63a6c1b54cc8117291d382e4fa69339b6d914c56"}, ] +markers = {main = "extra == \"extra-proxy\""} [package.extras] protobuf = ["grpcio-tools (>=1.70.0)"] @@ -1897,6 +2111,8 @@ version = "1.62.3" description = "Status proto mapping for gRPC" optional = true python-versions = ">=3.6" +groups = ["main"] +markers = "extra == \"extra-proxy\"" files = [ {file = "grpcio-status-1.62.3.tar.gz", hash = "sha256:289bdd7b2459794a12cf95dc0cb727bd4a1742c37bd823f760236c937e53a485"}, {file = "grpcio_status-1.62.3-py3-none-any.whl", hash = "sha256:f9049b762ba8de6b1086789d8315846e094edac2c50beaf462338b301a8fd4b8"}, @@ -1913,6 +2129,8 @@ version = "23.0.0" description = "WSGI HTTP Server for UNIX" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "platform_system != \"Windows\" and (extra == \"mlflow\" or extra == \"proxy\") and python_version >= \"3.10\" or extra == \"proxy\"" files = [ {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, @@ -1934,6 +2152,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -1945,6 +2164,7 @@ version = "4.1.0" description = "HTTP/2 State-Machine based protocol implementation" optional = false python-versions = ">=3.6.1" +groups = ["proxy-dev"] files = [ {file = "h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d"}, {file = "h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb"}, @@ -1960,6 +2180,8 @@ version = "1.1.10" description = "Fast transfer of large files with the Hugging Face Hub." optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\"" files = [ {file = "hf_xet-1.1.10-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:686083aca1a6669bc85c21c0563551cbcdaa5cf7876a91f3d074a030b577231d"}, {file = "hf_xet-1.1.10-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:71081925383b66b24eedff3013f8e6bbd41215c3338be4b94ba75fd75b21513b"}, @@ -1980,6 +2202,7 @@ version = "4.0.0" description = "Pure-Python HPACK header compression" optional = false python-versions = ">=3.6.1" +groups = ["proxy-dev"] files = [ {file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"}, {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, @@ -1991,6 +2214,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -2012,6 +2236,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -2024,7 +2249,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -2036,6 +2261,8 @@ version = "0.4.3" description = "Consume Server-Sent Event (SSE) messages with HTTPX." optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"proxy\"" files = [ {file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"}, {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, @@ -2047,6 +2274,7 @@ version = "0.35.3" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" +groups = ["main"] files = [ {file = "huggingface_hub-0.35.3-py3-none-any.whl", hash = "sha256:0e3a01829c19d86d03793e4577816fe3bdfc1602ac62c7fb220d593d351224ba"}, {file = "huggingface_hub-0.35.3.tar.gz", hash = "sha256:350932eaa5cc6a4747efae85126ee220e4ef1b54e29d31c3b45c5612ddf0b32a"}, @@ -2063,16 +2291,16 @@ tqdm = ">=4.42.1" typing-extensions = ">=3.7.4.3" [package.extras] -all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0)", "mypy (>=1.14.1,<1.15.0)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] cli = ["InquirerPy (==0.3.4)"] -dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0)", "mypy (>=1.14.1,<1.15.0)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] hf-transfer = ["hf-transfer (>=0.1.4)"] hf-xet = ["hf-xet (>=1.1.2,<2.0.0)"] inference = ["aiohttp"] mcp = ["aiohttp", "mcp (>=1.8.0)", "typer"] oauth = ["authlib (>=1.3.2)", "fastapi", "httpx", "itsdangerous"] -quality = ["libcst (>=1.4.0)", "mypy (==1.15.0)", "mypy (>=1.14.1,<1.15.0)", "ruff (>=0.9.0)", "ty"] +quality = ["libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "ruff (>=0.9.0)", "ty"] tensorflow = ["graphviz", "pydot", "tensorflow"] tensorflow-testing = ["keras (<3.0)", "tensorflow"] testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] @@ -2085,6 +2313,8 @@ version = "10.0" description = "Human friendly output for text interfaces using Python" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +markers = "python_version >= \"3.9\" and extra == \"extra-proxy\" and python_version < \"3.14\"" files = [ {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, @@ -2099,6 +2329,7 @@ version = "0.15.0" description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" optional = false python-versions = ">=3.7" +groups = ["proxy-dev"] files = [ {file = "hypercorn-0.15.0-py3-none-any.whl", hash = "sha256:5008944999612fd188d7a1ca02e89d20065642b89503020ac392dfed11840730"}, {file = "hypercorn-0.15.0.tar.gz", hash = "sha256:d517f68d5dc7afa9a9d50ecefb0f769f466ebe8c1c18d2c2f447a24e763c9a63"}, @@ -2116,7 +2347,7 @@ wsproto = ">=0.14.0" docs = ["pydata_sphinx_theme", "sphinxcontrib_mermaid"] h3 = ["aioquic (>=0.9.0,<1.0)"] trio = ["exceptiongroup (>=1.1.0)", "trio (>=0.22.0)"] -uvloop = ["uvloop"] +uvloop = ["uvloop ; platform_system != \"Windows\""] [[package]] name = "hyperframe" @@ -2124,6 +2355,7 @@ version = "6.0.1" description = "HTTP/2 framing layer for Python" optional = false python-versions = ">=3.6.1" +groups = ["proxy-dev"] files = [ {file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"}, {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"}, @@ -2135,6 +2367,7 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -2149,6 +2382,8 @@ version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, @@ -2160,6 +2395,7 @@ version = "6.11.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "importlib_metadata-6.11.0-py3-none-any.whl", hash = "sha256:f0afba6205ad8f8947c7d338b5342d5db2afbfd82f9cbef7879a9539cc12eb9b"}, {file = "importlib_metadata-6.11.0.tar.gz", hash = "sha256:1231cf92d825c9e03cfc4da076a16de6422c863558229ea0b22b675657463443"}, @@ -2171,7 +2407,7 @@ zipp = ">=0.5" [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +testing = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)", "pytest-ruff"] [[package]] name = "importlib-resources" @@ -2179,6 +2415,8 @@ version = "6.4.5" description = "Read resources from Python packages" optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "python_version < \"3.9\"" files = [ {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, @@ -2188,7 +2426,7 @@ files = [ zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] @@ -2201,6 +2439,7 @@ version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -2212,6 +2451,8 @@ version = "0.7.2" description = "An ISO 8601 date/time/duration parser and formatter" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"extra-proxy\" or extra == \"proxy\"" files = [ {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, @@ -2223,6 +2464,8 @@ version = "2.2.0" description = "Safely pass data to untrusted environments and back." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, @@ -2234,6 +2477,7 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["main", "proxy-dev"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -2251,6 +2495,7 @@ version = "0.9.1" description = "Fast iterable JSON parser." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "jiter-0.9.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c0163baa7ee85860fdc14cc39263014500df901eeffdf94c1eab9a2d713b2a9d"}, {file = "jiter-0.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:514d4dd845e0af4da15112502e6fcb952f0721f27f17e530454e379472b90c14"}, @@ -2336,6 +2581,8 @@ version = "1.0.1" description = "JSON Matching Expressions" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, @@ -2347,6 +2594,8 @@ version = "1.5.2" description = "Lightweight pipelining with Python functions" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241"}, {file = "joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55"}, @@ -2358,6 +2607,7 @@ version = "4.23.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, @@ -2381,6 +2631,7 @@ version = "2023.12.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, @@ -2396,6 +2647,8 @@ version = "1.4.9" description = "A fast implementation of the Cassowary constraint solver" optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b"}, {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f"}, @@ -2506,6 +2759,7 @@ version = "2.54.1" description = "A client library for accessing langfuse" optional = false python-versions = "<4.0,>=3.8.1" +groups = ["dev"] files = [ {file = "langfuse-2.54.1-py3-none-any.whl", hash = "sha256:1f1261cf763886758c70e192133340ff296169cc0930cde725eee52d467eb661"}, {file = "langfuse-2.54.1.tar.gz", hash = "sha256:7efc70799740ffa0ac7e04066e0596fb6433e8e501fc850c6a4e7967de6de8a7"}, @@ -2531,6 +2785,8 @@ version = "0.1.20" description = "Package for LiteLLM Enterprise features" optional = true python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "litellm_enterprise-0.1.20-py3-none-any.whl", hash = "sha256:744a79956a8cd7748ef4c3f40d5a564c61519834e706beafbc0b931162773ae8"}, {file = "litellm_enterprise-0.1.20.tar.gz", hash = "sha256:f6b8dd75b53bd835c68caf6402a8bae744a150db7bb6b0e617178c6056ac6c01"}, @@ -2538,13 +2794,15 @@ files = [ [[package]] name = "litellm-proxy-extras" -version = "0.2.27" +version = "0.2.29" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." optional = true python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ - {file = "litellm_proxy_extras-0.2.27-py3-none-any.whl", hash = "sha256:be8209829cb0d7b93d69e6f554a4da305f9ced3fe3d6d4070e7e338675fe464c"}, - {file = "litellm_proxy_extras-0.2.27.tar.gz", hash = "sha256:1b874fd025486647bdae6aef4c8bd2842a98afa2fa748408ff9cd967afdf7f10"}, + {file = "litellm_proxy_extras-0.2.29-py3-none-any.whl", hash = "sha256:27b7efc69829ed8745de7f469110c1f6a82e4f994bd8de3ac6b16dc2806a14b0"}, + {file = "litellm_proxy_extras-0.2.29.tar.gz", hash = "sha256:236c1cf8d9b0128392bb843ff8553918b0a9c299f2b3bfdc9ecc6b4547ce195e"}, ] [[package]] @@ -2553,6 +2811,8 @@ version = "1.3.10" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"}, {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"}, @@ -2572,6 +2832,8 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -2596,6 +2858,7 @@ version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" +groups = ["main", "proxy-dev"] files = [ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, @@ -2665,6 +2928,8 @@ version = "3.10.7" description = "Python plotting package" optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "matplotlib-3.10.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7ac81eee3b7c266dd92cee1cd658407b16c57eed08c7421fa354ed68234de380"}, {file = "matplotlib-3.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667ecd5d8d37813a845053d8f5bf110b534c3c9f30e69ebd25d4701385935a6d"}, @@ -2743,6 +3008,7 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -2754,6 +3020,8 @@ version = "1.12.4" description = "Model Context Protocol SDK" optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"proxy\"" files = [ {file = "mcp-1.12.4-py3-none-any.whl", hash = "sha256:7aa884648969fab8e78b89399d59a683202972e12e6bc9a1c88ce7eda7743789"}, {file = "mcp-1.12.4.tar.gz", hash = "sha256:0765585e9a3a5916a3c3ab8659330e493adc7bd8b2ca6120c2d7a0c43e034ca5"}, @@ -2783,6 +3051,8 @@ version = "0.1.2" description = "Markdown URL utilities" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -2794,6 +3064,8 @@ version = "0.4.1" description = "" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.9\" and extra == \"extra-proxy\" and python_version < \"3.14\"" files = [ {file = "ml_dtypes-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1fe8b5b5e70cd67211db94b05cfd58dace592f24489b038dc6f9fe347d2e07d5"}, {file = "ml_dtypes-0.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c09a6d11d8475c2a9fd2bc0695628aec105f97cab3b3a3fb7c9660348ff7d24"}, @@ -2816,10 +3088,10 @@ files = [ [package.dependencies] numpy = [ + {version = ">=1.23.3", markers = "python_version >= \"3.11\""}, + {version = ">1.20"}, + {version = ">=1.21.2", markers = "python_version >= \"3.10\""}, {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, - {version = ">1.20", markers = "python_version < \"3.10\""}, - {version = ">=1.23.3", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, - {version = ">=1.21.2", markers = "python_version >= \"3.10\" and python_version < \"3.11\""}, ] [package.extras] @@ -2831,6 +3103,8 @@ version = "3.3.2" description = "MLflow is an open source platform for the complete machine learning lifecycle" optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "mlflow-3.3.2-py3-none-any.whl", hash = "sha256:df2bfb11bf0ed3a39cf3cefd1a114ecdcd9c44291358b4b818e3bed50878b444"}, {file = "mlflow-3.3.2.tar.gz", hash = "sha256:ab9a5ffda0c05c6ba40e3c1ba4beef8f29fef0d61454f8c9485b54b1ec3e6894"}, @@ -2872,6 +3146,8 @@ version = "3.3.2" description = "MLflow is an open source platform for the complete machine learning lifecycle" optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "mlflow_skinny-3.3.2-py3-none-any.whl", hash = "sha256:e565b08de309b9716d4f89362e0a9217d82a3c28d8d553988e0eaad6cbfe4eea"}, {file = "mlflow_skinny-3.3.2.tar.gz", hash = "sha256:cf9ad0acb753bafdcdc60d9d18a7357f2627fb0c627ab3e3b97f632958a1008b"}, @@ -2914,6 +3190,8 @@ version = "3.3.2" description = "MLflow Tracing SDK is an open-source, lightweight Python package that only includes the minimum set of dependencies and functionality to instrument your code/models/agents with MLflow Tracing." optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "mlflow_tracing-3.3.2-py3-none-any.whl", hash = "sha256:9a3175fb3b069c9f541c7a60a663f482b3fcb4ca8f3583da3fdf036a50179e05"}, {file = "mlflow_tracing-3.3.2.tar.gz", hash = "sha256:003ad9c66f884e8e8bb2f5d219b5be9bcd41bb65d77a7264d8aaada853d64050"}, @@ -2934,6 +3212,7 @@ version = "1.34.0" description = "The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect." optional = false python-versions = ">=3.8" +groups = ["main", "proxy-dev"] files = [ {file = "msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1"}, {file = "msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f"}, @@ -2945,7 +3224,7 @@ PyJWT = {version = ">=1.0.0,<3", extras = ["crypto"]} requests = ">=2.0.0,<3" [package.extras] -broker = ["pymsalruntime (>=0.14,<0.19)", "pymsalruntime (>=0.17,<0.19)", "pymsalruntime (>=0.18,<0.19)"] +broker = ["pymsalruntime (>=0.14,<0.19) ; python_version >= \"3.6\" and platform_system == \"Windows\"", "pymsalruntime (>=0.17,<0.19) ; python_version >= \"3.8\" and platform_system == \"Darwin\"", "pymsalruntime (>=0.18,<0.19) ; python_version >= \"3.8\" and platform_system == \"Linux\""] [[package]] name = "msal-extensions" @@ -2953,6 +3232,7 @@ version = "1.3.0" description = "Microsoft Authentication Library extensions (MSAL EX) provides a persistence API that can save your data on disk, encrypted on Windows, macOS and Linux. Concurrent data access will be coordinated by a file lock mechanism." optional = false python-versions = ">=3.7" +groups = ["main", "proxy-dev"] files = [ {file = "msal_extensions-1.3.0-py3-none-any.whl", hash = "sha256:105328ddcbdd342016c9949d8f89e3917554740c8ab26669c0fa0e069e730a0e"}, {file = "msal_extensions-1.3.0.tar.gz", hash = "sha256:96918996642b38c78cd59b55efa0f06fd1373c90e0949be8615697c048fba62c"}, @@ -2970,6 +3250,7 @@ version = "6.1.0" description = "multidict implementation" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, @@ -3074,6 +3355,7 @@ version = "1.14.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, @@ -3133,6 +3415,7 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -3144,6 +3427,7 @@ version = "1.9.1" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "proxy-dev"] files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, @@ -3155,6 +3439,8 @@ version = "1.26.4" description = "Fundamental package for array computing in Python" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.9\" and (python_version >= \"3.10\" or extra == \"extra-proxy\" or extra == \"semantic-router\") and (python_version < \"3.14\" or extra == \"semantic-router\" or extra == \"mlflow\") and (python_version < \"3.12\" or extra == \"semantic-router\" or extra == \"mlflow\" or extra == \"extra-proxy\") and (extra == \"extra-proxy\" or extra == \"semantic-router\" or extra == \"mlflow\")" files = [ {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, @@ -3200,6 +3486,8 @@ version = "1.7.0" description = "Sphinx extension to support docstrings in Numpy format" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "numpydoc-1.7.0-py3-none-any.whl", hash = "sha256:5a56419d931310d79a06cfc2a126d1558700feeb9b4f3d8dcae1a8134be829c9"}, {file = "numpydoc-1.7.0.tar.gz", hash = "sha256:866e5ae5b6509dcf873fc6381120f5c31acf13b135636c1a81d68c166a95f921"}, @@ -3211,7 +3499,7 @@ tabulate = ">=0.8.10" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} [package.extras] -developer = ["pre-commit (>=3.3)", "tomli"] +developer = ["pre-commit (>=3.3)", "tomli ; python_version < \"3.11\""] doc = ["matplotlib (>=3.5)", "numpy (>=1.22)", "pydata-sphinx-theme (>=0.13.3)", "sphinx (>=7)"] test = ["matplotlib", "pytest", "pytest-cov"] @@ -3221,6 +3509,8 @@ version = "3.3.1" description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"}, {file = "oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9"}, @@ -3237,6 +3527,7 @@ version = "1.109.1" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315"}, {file = "openai-1.109.1.tar.gz", hash = "sha256:d173ed8dbca665892a6db099b4a2dfac624f94d20a93f46eb0b56aae940ed869"}, @@ -3264,10 +3555,12 @@ version = "1.25.0" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "opentelemetry_api-1.25.0-py3-none-any.whl", hash = "sha256:757fa1aa020a0f8fa139f8959e53dec2051cc26b832e76fa839a6d76ecefd737"}, {file = "opentelemetry_api-1.25.0.tar.gz", hash = "sha256:77c4985f62f2614e42ce77ee4c9da5fa5f0bc1e1821085e9a47533a9323ae869"}, ] +markers = {main = "python_version >= \"3.10\""} [package.dependencies] deprecated = ">=1.2.6" @@ -3279,6 +3572,7 @@ version = "1.25.0" description = "OpenTelemetry Collector Exporters" optional = false python-versions = ">=3.8" +groups = ["dev", "proxy-dev"] files = [ {file = "opentelemetry_exporter_otlp-1.25.0-py3-none-any.whl", hash = "sha256:d67a831757014a3bc3174e4cd629ae1493b7ba8d189e8a007003cacb9f1a6b60"}, {file = "opentelemetry_exporter_otlp-1.25.0.tar.gz", hash = "sha256:ce03199c1680a845f82e12c0a6a8f61036048c07ec7a0bd943142aca8fa6ced0"}, @@ -3294,6 +3588,7 @@ version = "1.25.0" description = "OpenTelemetry Protobuf encoding" optional = false python-versions = ">=3.8" +groups = ["dev", "proxy-dev"] files = [ {file = "opentelemetry_exporter_otlp_proto_common-1.25.0-py3-none-any.whl", hash = "sha256:15637b7d580c2675f70246563363775b4e6de947871e01d0f4e3881d1848d693"}, {file = "opentelemetry_exporter_otlp_proto_common-1.25.0.tar.gz", hash = "sha256:c93f4e30da4eee02bacd1e004eb82ce4da143a2f8e15b987a9f603e0a85407d3"}, @@ -3308,6 +3603,7 @@ version = "1.25.0" description = "OpenTelemetry Collector Protobuf over gRPC Exporter" optional = false python-versions = ">=3.8" +groups = ["dev", "proxy-dev"] files = [ {file = "opentelemetry_exporter_otlp_proto_grpc-1.25.0-py3-none-any.whl", hash = "sha256:3131028f0c0a155a64c430ca600fd658e8e37043cb13209f0109db5c1a3e4eb4"}, {file = "opentelemetry_exporter_otlp_proto_grpc-1.25.0.tar.gz", hash = "sha256:c0b1661415acec5af87625587efa1ccab68b873745ca0ee96b69bb1042087eac"}, @@ -3328,6 +3624,7 @@ version = "1.25.0" description = "OpenTelemetry Collector Protobuf over HTTP Exporter" optional = false python-versions = ">=3.8" +groups = ["dev", "proxy-dev"] files = [ {file = "opentelemetry_exporter_otlp_proto_http-1.25.0-py3-none-any.whl", hash = "sha256:2eca686ee11b27acd28198b3ea5e5863a53d1266b91cda47c839d95d5e0541a6"}, {file = "opentelemetry_exporter_otlp_proto_http-1.25.0.tar.gz", hash = "sha256:9f8723859e37c75183ea7afa73a3542f01d0fd274a5b97487ea24cb683d7d684"}, @@ -3348,6 +3645,7 @@ version = "1.25.0" description = "OpenTelemetry Python Proto" optional = false python-versions = ">=3.8" +groups = ["dev", "proxy-dev"] files = [ {file = "opentelemetry_proto-1.25.0-py3-none-any.whl", hash = "sha256:f07e3341c78d835d9b86665903b199893befa5e98866f63d22b00d0b7ca4972f"}, {file = "opentelemetry_proto-1.25.0.tar.gz", hash = "sha256:35b6ef9dc4a9f7853ecc5006738ad40443701e52c26099e197895cbda8b815a3"}, @@ -3362,10 +3660,12 @@ version = "1.25.0" description = "OpenTelemetry Python SDK" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "opentelemetry_sdk-1.25.0-py3-none-any.whl", hash = "sha256:d97ff7ec4b351692e9d5a15af570c693b8715ad78b8aafbec5c7100fe966b4c9"}, {file = "opentelemetry_sdk-1.25.0.tar.gz", hash = "sha256:ce7fc319c57707ef5bf8b74fb9f8ebdb8bfafbe11898410e0d2a761d08a98ec7"}, ] +markers = {main = "python_version >= \"3.10\""} [package.dependencies] opentelemetry-api = "1.25.0" @@ -3378,10 +3678,12 @@ version = "0.46b0" description = "OpenTelemetry Semantic Conventions" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "opentelemetry_semantic_conventions-0.46b0-py3-none-any.whl", hash = "sha256:6daef4ef9fa51d51855d9f8e0ccd3a1bd59e0e545abe99ac6203804e36ab3e07"}, {file = "opentelemetry_semantic_conventions-0.46b0.tar.gz", hash = "sha256:fbc982ecbb6a6e90869b15c1673be90bd18c8a56ff1cffc0864e38e2edffaefa"}, ] +markers = {main = "python_version >= \"3.10\""} [package.dependencies] opentelemetry-api = "1.25.0" @@ -3392,6 +3694,8 @@ version = "3.10.15" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "orjson-3.10.15-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:552c883d03ad185f720d0c09583ebde257e41b9521b74ff40e08b7dec4559c04"}, {file = "orjson-3.10.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e3e8d438d02e4854f70bfdc03a6bcdb697358dbaa6bcd19cbe24d24ece1f8"}, @@ -3480,6 +3784,7 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -3491,6 +3796,8 @@ version = "2.3.3" description = "Powerful data structures for data analysis, time series, and statistics" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c"}, {file = "pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a"}, @@ -3551,9 +3858,9 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, - {version = ">=1.22.4", markers = "python_version < \"3.11\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -3590,6 +3897,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -3601,6 +3909,8 @@ version = "11.3.0" description = "Python Imaging Library (Fork)" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, @@ -3716,7 +4026,7 @@ fpx = ["olefile"] mic = ["olefile"] test-arrow = ["pyarrow"] tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] -typing = ["typing-extensions"] +typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] [[package]] @@ -3725,6 +4035,8 @@ version = "1.3.10" description = "Resolve a name to an object." optional = false python-versions = ">=3.6" +groups = ["main"] +markers = "python_version < \"3.9\"" files = [ {file = "pkgutil_resolve_name-1.3.10-py3-none-any.whl", hash = "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e"}, {file = "pkgutil_resolve_name-1.3.10.tar.gz", hash = "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174"}, @@ -3736,6 +4048,7 @@ version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, @@ -3752,6 +4065,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -3767,6 +4081,8 @@ version = "1.34.0" description = "Blazingly fast DataFrame library" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"proxy\"" files = [ {file = "polars-1.34.0-py3-none-any.whl", hash = "sha256:40d2f357b4d9e447ad28bd2c9923e4318791a7c18eb68f31f1fbf11180f41391"}, {file = "polars-1.34.0.tar.gz", hash = "sha256:5de5f871027db4b11bcf39215a2d6b13b4a80baf8a55c5862d4ebedfd5cd4013"}, @@ -3800,7 +4116,7 @@ rt64 = ["polars-runtime-64 (==1.34.0)"] rtcompat = ["polars-runtime-compat (==1.34.0)"] sqlalchemy = ["polars[pandas]", "sqlalchemy"] style = ["great-tables (>=0.8.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; platform_system == \"Windows\""] xlsx2csv = ["xlsx2csv (>=0.8.0)"] xlsxwriter = ["xlsxwriter"] @@ -3810,6 +4126,8 @@ version = "1.34.0" description = "Blazingly fast DataFrame library" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"proxy\"" files = [ {file = "polars_runtime_32-1.34.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2878f9951e91121afe60c25433ef270b9a221e6ebf3de5f6642346b38cab3f03"}, {file = "polars_runtime_32-1.34.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:fbc329c7d34a924228cc5dcdbbd4696d94411a3a5b15ad8bb868634c204e1951"}, @@ -3826,6 +4144,7 @@ version = "2.0.0" description = "A pure-Python implementation of the HTTP/2 priority tree" optional = false python-versions = ">=3.6.1" +groups = ["proxy-dev"] files = [ {file = "priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa"}, {file = "priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0"}, @@ -3837,6 +4156,7 @@ version = "0.11.0" description = "Prisma Client Python is an auto-generated and fully type-safe database client" optional = false python-versions = ">=3.7.0" +groups = ["main", "proxy-dev"] files = [ {file = "prisma-0.11.0-py3-none-any.whl", hash = "sha256:22bb869e59a2968b99f3483bb417717273ffbc569fd1e9ceed95e5614cbaf53a"}, {file = "prisma-0.11.0.tar.gz", hash = "sha256:3f2f2fd2361e1ec5ff655f2a04c7860c2f2a5bc4c91f78ca9c5c6349735bf693"}, @@ -3862,6 +4182,7 @@ version = "0.20.0" description = "Python client for the Prometheus monitoring system." optional = false python-versions = ">=3.8" +groups = ["proxy-dev"] files = [ {file = "prometheus_client-0.20.0-py3-none-any.whl", hash = "sha256:cde524a85bce83ca359cc837f28b8c0db5cac7aa653a588fd7e84ba061c329e7"}, {file = "prometheus_client-0.20.0.tar.gz", hash = "sha256:287629d00b147a32dcb2be0b9df905da599b2d82f80377083ec8463309a4bb89"}, @@ -3876,6 +4197,7 @@ version = "0.2.0" description = "Accelerated property cache" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58"}, {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b"}, @@ -3983,6 +4305,8 @@ version = "1.26.1" description = "Beautiful, Pythonic protocol buffers" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"extra-proxy\"" files = [ {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, @@ -4000,6 +4324,7 @@ version = "4.25.8" description = "" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "protobuf-4.25.8-cp310-abi3-win32.whl", hash = "sha256:504435d831565f7cfac9f0714440028907f1975e4bed228e58e72ecfff58a1e0"}, {file = "protobuf-4.25.8-cp310-abi3-win_amd64.whl", hash = "sha256:bd551eb1fe1d7e92c1af1d75bdfa572eff1ab0e5bf1736716814cdccdb2360f9"}, @@ -4013,6 +4338,7 @@ files = [ {file = "protobuf-4.25.8-py3-none-any.whl", hash = "sha256:15a0af558aa3b13efef102ae6e4f3efac06f1eea11afb3a57db2901447d9fb59"}, {file = "protobuf-4.25.8.tar.gz", hash = "sha256:6135cf8affe1fc6f76cced2641e4ea8d3e59518d1f24ae41ba97bcad82d397cd"}, ] +markers = {main = "python_version >= \"3.10\" and (extra == \"mlflow\" or extra == \"extra-proxy\") or extra == \"extra-proxy\""} [[package]] name = "pyarrow" @@ -4020,6 +4346,8 @@ version = "21.0.0" description = "Python library for Apache Arrow" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "pyarrow-21.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e563271e2c5ff4d4a4cbeb2c83d5cf0d4938b891518e676025f7268c6fe5fe26"}, {file = "pyarrow-21.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79"}, @@ -4075,6 +4403,8 @@ version = "0.6.1" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\" and (extra == \"mlflow\" or extra == \"extra-proxy\") or extra == \"extra-proxy\"" files = [ {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, @@ -4086,6 +4416,8 @@ version = "0.4.2" description = "A collection of ASN.1-based protocols modules" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\" and (extra == \"mlflow\" or extra == \"extra-proxy\") or extra == \"extra-proxy\"" files = [ {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, @@ -4100,6 +4432,7 @@ version = "2.11.1" description = "Python style guide checker" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, @@ -4111,6 +4444,8 @@ version = "2.23" description = "C parser in Python" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] +markers = "platform_python_implementation != \"PyPy\" and (implementation_name != \"PyPy\" or python_version < \"3.10\")" files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, @@ -4122,6 +4457,7 @@ version = "2.10.6" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"}, {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"}, @@ -4135,7 +4471,7 @@ typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -4143,6 +4479,7 @@ version = "2.27.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, @@ -4255,6 +4592,8 @@ version = "2.11.0" description = "Settings management using Pydantic" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"proxy\"" files = [ {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, @@ -4278,6 +4617,7 @@ version = "3.1.0" description = "passive checker of Python programs" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, @@ -4289,6 +4629,8 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"utils\" or extra == \"proxy\"" files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -4303,6 +4645,7 @@ version = "2.9.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.8" +groups = ["main", "proxy-dev"] files = [ {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, @@ -4317,38 +4660,14 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] -[[package]] -name = "pynacl" -version = "1.5.0" -description = "Python binding to the Networking and Cryptography (NaCl) library" -optional = true -python-versions = ">=3.6" -files = [ - {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, - {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, -] - -[package.dependencies] -cffi = ">=1.4.1" - -[package.extras] -docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] -tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] - [[package]] name = "pynacl" version = "1.6.0" description = "Python binding to the Networking and Cryptography (NaCl) library" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "pynacl-1.6.0-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:f46386c24a65383a9081d68e9c2de909b1834ec74ff3013271f1bca9c2d233eb"}, {file = "pynacl-1.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dea103a1afcbc333bc0e992e64233d360d393d1e63d0bc88554f572365664348"}, @@ -4380,7 +4699,10 @@ files = [ ] [package.dependencies] -cffi = {version = ">=1.4.1", markers = "platform_python_implementation != \"PyPy\" and python_version < \"3.14\""} +cffi = [ + {version = ">=1.4.1", markers = "platform_python_implementation != \"PyPy\" and python_version < \"3.14\""}, + {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""}, +] [package.extras] docs = ["sphinx (<7)", "sphinx_rtd_theme"] @@ -4392,6 +4714,8 @@ version = "3.2.5" description = "pyparsing - Classes and methods to define and execute parsing grammars" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e"}, {file = "pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6"}, @@ -4406,6 +4730,8 @@ version = "3.5.4" description = "A python implementation of GNU readline." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.9\" and sys_platform == \"win32\" and extra == \"extra-proxy\" and python_version < \"3.14\"" files = [ {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"}, {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"}, @@ -4420,6 +4746,7 @@ version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, @@ -4442,6 +4769,7 @@ version = "0.21.2" description = "Pytest support for asyncio" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b"}, {file = "pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45"}, @@ -4460,6 +4788,7 @@ version = "3.14.1" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, @@ -4477,6 +4806,8 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +markers = "python_version >= \"3.10\" and (extra == \"mlflow\" or extra == \"proxy\") or extra == \"proxy\"" files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -4491,6 +4822,7 @@ version = "1.0.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.8" +groups = ["main", "proxy-dev"] files = [ {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, @@ -4505,6 +4837,8 @@ version = "0.0.18" description = "A streaming multipart parser for Python" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "python_multipart-0.0.18-py3-none-any.whl", hash = "sha256:efe91480f485f6a361427a541db4796f9e1591afc0fb8e7a4ba06bfbc6708996"}, {file = "python_multipart-0.0.18.tar.gz", hash = "sha256:7a68db60c8bfb82e460637fa4750727b45af1d5e2ed215593f917f64694d34fe"}, @@ -4516,6 +4850,8 @@ version = "3.1.0" description = "Universally unique lexicographically sortable identifier" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.9\" and extra == \"extra-proxy\" and python_version < \"3.14\"" files = [ {file = "python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619"}, {file = "python_ulid-3.1.0.tar.gz", hash = "sha256:ff0410a598bc5f6b01b602851a3296ede6f91389f913a5d5f8c496003836f636"}, @@ -4530,6 +4866,8 @@ version = "2025.2" description = "World timezone definitions, modern and historical" optional = true python-versions = "*" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\" or python_version < \"3.9\" and extra == \"utils\"" files = [ {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, @@ -4541,6 +4879,8 @@ version = "311" description = "Python for Window Extensions" optional = true python-versions = "*" +groups = ["main"] +markers = "python_version >= \"3.10\" and sys_platform == \"win32\" and (extra == \"proxy\" or extra == \"mlflow\")" files = [ {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, @@ -4570,6 +4910,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -4652,6 +4993,8 @@ version = "5.3.1" description = "Python client for Redis database and key-value store" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.9\" and (extra == \"extra-proxy\" or extra == \"proxy\") and python_version < \"3.14\" or extra == \"proxy\"" files = [ {file = "redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97"}, {file = "redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c"}, @@ -4671,6 +5014,8 @@ version = "0.4.1" description = "Python client library and CLI for using Redis as a vector database" optional = true python-versions = "<3.14,>=3.9" +groups = ["main"] +markers = "python_version >= \"3.9\" and extra == \"extra-proxy\" and python_version < \"3.14\"" files = [ {file = "redisvl-0.4.1-py3-none-any.whl", hash = "sha256:6db5d5bc95b1fe8032a1cdae74ce1c65bc7fe9054e5429b5d34d5a91d28bae5f"}, {file = "redisvl-0.4.1.tar.gz", hash = "sha256:fd6a36426ba94792c0efca20915c31232d4ee3cc58eb23794a62c142696401e6"}, @@ -4680,8 +5025,8 @@ files = [ coloredlogs = ">=15.0,<16.0" ml-dtypes = ">=0.4.0,<0.5.0" numpy = [ - {version = ">=1.26.0,<3", markers = "python_version >= \"3.12\""}, {version = ">=1,<2", markers = "python_version < \"3.12\""}, + {version = ">=1.26.0,<3", markers = "python_version >= \"3.12\""}, ] pydantic = ">=2,<3" python-ulid = ">=3.0.0,<4.0.0" @@ -4695,7 +5040,7 @@ bedrock = ["boto3[bedrock] (>=1.36.0,<2.0.0)"] cohere = ["cohere (>=4.44)"] mistralai = ["mistralai (>=1.0.0)"] openai = ["openai (>=1.13.0,<2.0.0)"] -sentence-transformers = ["scipy (<1.15)", "scipy (>=1.15,<2.0)", "sentence-transformers (>=3.4.0,<4.0.0)"] +sentence-transformers = ["scipy (<1.15) ; python_version < \"3.10\"", "scipy (>=1.15,<2.0) ; python_version >= \"3.10\"", "sentence-transformers (>=3.4.0,<4.0.0)"] vertexai = ["google-cloud-aiplatform (>=1.26,<2.0)", "protobuf (>=5.29.1,<6.0.0)"] voyageai = ["voyageai (>=0.2.2)"] @@ -4705,6 +5050,7 @@ version = "0.35.1" description = "JSON Referencing + Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, @@ -4720,6 +5066,7 @@ version = "2024.11.6" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, @@ -4823,6 +5170,7 @@ version = "2.31.0" description = "Python HTTP for Humans." optional = false python-versions = ">=3.7" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, @@ -4844,6 +5192,7 @@ version = "1.12.1" description = "Mock out responses from the requests package" optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401"}, {file = "requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563"}, @@ -4861,6 +5210,8 @@ version = "0.8.0" description = "Resend Python SDK" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"extra-proxy\"" files = [ {file = "resend-0.8.0-py2.py3-none-any.whl", hash = "sha256:adc1515dadf4f4fc6b90db55a237f0f37fc56fd74287a986519a8a187fdb661d"}, {file = "resend-0.8.0.tar.gz", hash = "sha256:94142394701724dbcfcd8f760f675c662a1025013e741dd7cc773ca885526257"}, @@ -4875,6 +5226,7 @@ version = "0.25.8" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c"}, {file = "responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4"}, @@ -4886,7 +5238,7 @@ requests = ">=2.30.0,<3.0" urllib3 = ">=1.25.10,<3.0" [package.extras] -tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-PyYAML", "types-requests"] [[package]] name = "respx" @@ -4894,6 +5246,7 @@ version = "0.22.0" description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0"}, {file = "respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91"}, @@ -4908,6 +5261,8 @@ version = "13.7.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = true python-versions = ">=3.7.0" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, @@ -4927,6 +5282,7 @@ version = "0.20.1" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "rpds_py-0.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a649dfd735fff086e8a9d0503a9f0c7d01b7912a333c7ae77e1515c08c146dad"}, {file = "rpds_py-0.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f16bc1334853e91ddaaa1217045dd7be166170beec337576818461268a3de67f"}, @@ -5039,6 +5395,8 @@ version = "2.3.3" description = "RQ is a simple, lightweight, library for creating background jobs, and processing them." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "rq-2.3.3-py3-none-any.whl", hash = "sha256:2202c4409c4c527ac4bee409867d6c02515dd110030499eb0de54c7374aee0ce"}, {file = "rq-2.3.3.tar.gz", hash = "sha256:20c41c977b6f27c852a41bd855893717402bae7b8d9607dca21fe9dd55453e22"}, @@ -5054,6 +5412,8 @@ version = "4.9.1" description = "Pure-Python RSA implementation" optional = true python-versions = "<4,>=3.6" +groups = ["main"] +markers = "python_version >= \"3.10\" and (extra == \"mlflow\" or extra == \"extra-proxy\") or extra == \"extra-proxy\"" files = [ {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, @@ -5068,6 +5428,7 @@ version = "0.1.15" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5fe8d54df166ecc24106db7dd6a68d44852d14eb0729ea4672bb4d96c320b7df"}, {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f0bfbb53c4b4de117ac4d6ddfd33aa5fc31beeaa21d23c45c6dd249faf9126f"}, @@ -5094,6 +5455,8 @@ version = "0.11.3" description = "An Amazon S3 Transfer Manager" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "s3transfer-0.11.3-py3-none-any.whl", hash = "sha256:ca855bdeb885174b5ffa95b9913622459d4ad8e331fc98eb01e6d5eb6a30655d"}, {file = "s3transfer-0.11.3.tar.gz", hash = "sha256:edae4977e3a122445660c7c114bba949f9d191bae3b34a096f18a1c8c354527a"}, @@ -5111,6 +5474,8 @@ version = "1.7.2" description = "A set of python modules for machine learning and data mining" optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f"}, {file = "scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c"}, @@ -5166,6 +5531,8 @@ version = "1.15.3" description = "Fundamental algorithms for scientific computing in Python" optional = true python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c"}, {file = "scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253"}, @@ -5221,7 +5588,7 @@ numpy = ">=1.23.5,<2.5" [package.extras] dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"] doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.0.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"] -test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] +test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] [[package]] name = "semantic-router" @@ -5229,6 +5596,8 @@ version = "0.0.20" description = "Super fast semantic router for AI decision making" optional = true python-versions = ">=3.9,<4.0" +groups = ["main"] +markers = "python_version >= \"3.9\" and extra == \"semantic-router\"" files = [ {file = "semantic_router-0.0.20-py3-none-any.whl", hash = "sha256:7a713401564fb6cf22b566046ad32a4224e4f357be8de6583ca3b9ee328c8f95"}, {file = "semantic_router-0.0.20.tar.gz", hash = "sha256:26119a4628ca72b2fa9eacd446ea763b6f1925a661a34e26945433d2601efac7"}, @@ -5244,7 +5613,7 @@ pydantic = ">=2.5.3,<3.0.0" pyyaml = ">=6.0.1,<7.0.0" [package.extras] -fastembed = ["fastembed (>=0.1.3,<0.2.0)"] +fastembed = ["fastembed (>=0.1.3,<0.2.0) ; python_version < \"3.12\""] hybrid = ["pinecone-text (>=0.7.1,<0.8.0)"] local = ["llama-cpp-python (>=0.2.28,<0.3.0)", "torch (>=2.1.0,<3.0.0)", "transformers (>=4.36.2,<5.0.0)"] @@ -5254,6 +5623,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "proxy-dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -5265,6 +5635,8 @@ version = "5.0.2" description = "A pure Python implementation of a sliding window memory map manager" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"}, {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, @@ -5276,6 +5648,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -5287,6 +5660,8 @@ version = "3.0.1" description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, @@ -5298,6 +5673,8 @@ version = "7.1.2" description = "Python documentation generator" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe"}, {file = "sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f"}, @@ -5333,6 +5710,8 @@ version = "1.0.4" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, @@ -5348,6 +5727,8 @@ version = "1.0.2" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." optional = true python-versions = ">=3.5" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, @@ -5363,6 +5744,8 @@ version = "2.0.1" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, @@ -5378,6 +5761,8 @@ version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" optional = true python-versions = ">=3.5" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, @@ -5392,6 +5777,8 @@ version = "1.0.3" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." optional = true python-versions = ">=3.5" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, @@ -5407,6 +5794,8 @@ version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." optional = true python-versions = ">=3.5" +groups = ["main"] +markers = "extra == \"utils\"" files = [ {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, @@ -5422,6 +5811,8 @@ version = "2.0.44" description = "Database Abstraction Library" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "SQLAlchemy-2.0.44-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:471733aabb2e4848d609141a9e9d56a427c0a038f4abf65dd19d7a21fd563632"}, {file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48bf7d383a35e668b984c805470518b635d48b95a3c57cb03f37eaa3551b5f9f"}, @@ -5517,6 +5908,8 @@ version = "0.5.3" description = "A non-validating SQL parser." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, @@ -5532,6 +5925,8 @@ version = "2.1.3" description = "SSE plugin for Starlette" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"proxy\"" files = [ {file = "sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772"}, {file = "sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169"}, @@ -5551,14 +5946,15 @@ version = "0.44.0" description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "starlette-0.44.0-py3-none-any.whl", hash = "sha256:19edeb75844c16dcd4f9dd72f22f9108c1539f3fc9c4c88885654fef64f85aea"}, {file = "starlette-0.44.0.tar.gz", hash = "sha256:e35166950a3ccccc701962fe0711db0bc14f2ecd37c6f9fe5e3eae0cbaea8715"}, ] +markers = {main = "python_version >= \"3.10\" and (extra == \"mlflow\" or extra == \"proxy\") or extra == \"proxy\""} [package.dependencies] anyio = ">=3.4.0,<5" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] @@ -5569,6 +5965,8 @@ version = "0.9.0" description = "Pretty-print tabular data" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "python_version >= \"3.9\" and (extra == \"extra-proxy\" or extra == \"utils\") and python_version < \"3.14\" or extra == \"utils\"" files = [ {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, @@ -5583,6 +5981,8 @@ version = "0.2.2" description = "backport of asyncio.TaskGroup, asyncio.Runner and asyncio.timeout" optional = false python-versions = "*" +groups = ["proxy-dev"] +markers = "python_version <= \"3.10\"" files = [ {file = "taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb"}, {file = "taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d"}, @@ -5598,6 +5998,8 @@ version = "9.1.2" description = "Retry code until it succeeds" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.9\" and extra == \"extra-proxy\" and python_version < \"3.14\"" files = [ {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, @@ -5613,6 +6015,8 @@ version = "3.6.0" description = "threadpoolctl" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb"}, {file = "threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e"}, @@ -5624,6 +6028,7 @@ version = "0.7.0" description = "tiktoken is a fast BPE tokeniser for use with OpenAI's models" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "tiktoken-0.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485f3cc6aba7c6b6ce388ba634fbba656d9ee27f766216f45146beb4ac18b25f"}, {file = "tiktoken-0.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e54be9a2cd2f6d6ffa3517b064983fb695c9a9d8aa7d574d1ef3c3f931a99225"}, @@ -5676,6 +6081,7 @@ version = "0.21.0" description = "" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "tokenizers-0.21.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:3c4c93eae637e7d2aaae3d376f06085164e1660f89304c0ab2b1d08a406636b2"}, {file = "tokenizers-0.21.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:f53ea537c925422a2e0e92a24cce96f6bc5046bbef24a1652a5edc8ba975f62e"}, @@ -5708,6 +6114,8 @@ version = "2.3.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] +markers = "python_version <= \"3.10\"" files = [ {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, @@ -5759,6 +6167,7 @@ version = "0.13.3" description = "Style preserving TOML library" optional = false python-versions = ">=3.8" +groups = ["main", "proxy-dev"] files = [ {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, @@ -5770,6 +6179,7 @@ version = "4.67.1" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, @@ -5791,6 +6201,7 @@ version = "1.16.0.20241221" description = "Typing stubs for cffi" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types_cffi-1.16.0.20241221-py3-none-any.whl", hash = "sha256:e5b76b4211d7a9185f6ab8d06a106d56c7eb80af7cdb8bfcb4186ade10fb112f"}, {file = "types_cffi-1.16.0.20241221.tar.gz", hash = "sha256:1c96649618f4b6145f58231acb976e0b448be6b847f7ab733dabe62dfbff6591"}, @@ -5805,6 +6216,7 @@ version = "24.1.0.20240722" description = "Typing stubs for pyOpenSSL" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39"}, {file = "types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54"}, @@ -5820,6 +6232,7 @@ version = "6.0.12.20241230" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6"}, {file = "types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c"}, @@ -5831,6 +6244,7 @@ version = "4.6.0.20241004" description = "Typing stubs for redis" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e"}, {file = "types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed"}, @@ -5846,6 +6260,8 @@ version = "2.31.0.6" description = "Typing stubs for requests" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.10\"" files = [ {file = "types-requests-2.31.0.6.tar.gz", hash = "sha256:cd74ce3b53c461f1228a9b783929ac73a666658f223e28ed29753771477b3bd0"}, {file = "types_requests-2.31.0.6-py3-none-any.whl", hash = "sha256:a2db9cb228a81da8348b49ad6db3f5519452dd20a9c1e1a868c83c5fe88fd1a9"}, @@ -5860,6 +6276,8 @@ version = "2.32.0.20241016" description = "Typing stubs for requests" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version >= \"3.10\"" files = [ {file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"}, {file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"}, @@ -5874,6 +6292,7 @@ version = "75.8.0.20250110" description = "Typing stubs for setuptools" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types_setuptools-75.8.0.20250110-py3-none-any.whl", hash = "sha256:a9f12980bbf9bcdc23ecd80755789085bad6bfce4060c2275bc2b4ca9f2bc480"}, {file = "types_setuptools-75.8.0.20250110.tar.gz", hash = "sha256:96f7ec8bbd6e0a54ea180d66ad68ad7a1d7954e7281a710ea2de75e355545271"}, @@ -5885,6 +6304,8 @@ version = "1.26.25.14" description = "Typing stubs for urllib3" optional = false python-versions = "*" +groups = ["dev"] +markers = "python_version < \"3.10\"" files = [ {file = "types-urllib3-1.26.25.14.tar.gz", hash = "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f"}, {file = "types_urllib3-1.26.25.14-py3-none-any.whl", hash = "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e"}, @@ -5896,6 +6317,7 @@ version = "4.13.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, @@ -5907,6 +6329,8 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"proxy\"" files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -5921,6 +6345,8 @@ version = "2025.2" description = "Provider of IANA time zone data" optional = true python-versions = ">=2" +groups = ["main"] +markers = "python_version >= \"3.10\" and platform_system == \"Windows\" and (extra == \"proxy\" or extra == \"mlflow\") or python_version >= \"3.10\" and extra == \"mlflow\" or platform_system == \"Windows\" and extra == \"proxy\"" files = [ {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, @@ -5932,6 +6358,8 @@ version = "5.2" description = "tzinfo object for the local timezone" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"}, {file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"}, @@ -5950,14 +6378,16 @@ version = "1.26.20" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["main", "dev", "proxy-dev"] +markers = "python_version < \"3.10\"" files = [ {file = "urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"}, {file = "urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"}, ] [package.extras] -brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and platform_python_implementation == \"CPython\"", "brotli (>=1.0.9) ; python_version >= \"3\" and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] @@ -5966,13 +6396,15 @@ version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] +markers = "python_version >= \"3.10\"" files = [ {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -5983,6 +6415,8 @@ version = "0.29.0" description = "The lightning-fast ASGI server." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "python_version >= \"3.10\" and (extra == \"mlflow\" or extra == \"proxy\") or extra == \"proxy\"" files = [ {file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"}, {file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"}, @@ -5994,7 +6428,7 @@ h11 = ">=0.8" typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] [[package]] name = "uvloop" @@ -6002,6 +6436,8 @@ version = "0.21.0" description = "Fast implementation of asyncio event loop on top of libuv" optional = true python-versions = ">=3.8.0" +groups = ["main"] +markers = "sys_platform != \"win32\" and extra == \"proxy\"" files = [ {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"}, {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"}, @@ -6053,6 +6489,8 @@ version = "3.0.2" description = "Waitress WSGI server" optional = true python-versions = ">=3.9.0" +groups = ["main"] +markers = "python_version >= \"3.10\" and platform_system == \"Windows\" and extra == \"mlflow\"" files = [ {file = "waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e"}, {file = "waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f"}, @@ -6068,6 +6506,8 @@ version = "13.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee"}, {file = "websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7"}, @@ -6163,6 +6603,8 @@ version = "3.1.3" description = "The comprehensive WSGI web application library." optional = true python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.10\" and extra == \"mlflow\"" files = [ {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, @@ -6180,6 +6622,7 @@ version = "1.17.3" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, @@ -6263,6 +6706,7 @@ files = [ {file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"}, {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"}, ] +markers = {main = "python_version >= \"3.10\""} [[package]] name = "wsproto" @@ -6270,6 +6714,7 @@ version = "1.2.0" description = "WebSockets state-machine based protocol implementation" optional = false python-versions = ">=3.7.0" +groups = ["proxy-dev"] files = [ {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, @@ -6284,6 +6729,7 @@ version = "1.15.2" description = "Yet another URL library" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "yarl-1.15.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e4ee8b8639070ff246ad3649294336b06db37a94bdea0d09ea491603e0be73b8"}, {file = "yarl-1.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a7cf963a357c5f00cb55b1955df8bbe68d2f2f65de065160a1c26b85a1e44172"}, @@ -6396,17 +6842,18 @@ version = "3.20.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "proxy-dev"] files = [ {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [extras] @@ -6418,6 +6865,6 @@ semantic-router = ["semantic-router"] utils = ["numpydoc"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = ">=3.8.1,<4.0, !=3.9.7" -content-hash = "ee4b9e40ff989a3944b42791100faaf200ea24c7000889b7c0ecc02906dcc5e7" +content-hash = "b72f62d84741b3b34e9966f668dcc88a9d5a15a3015116548a3fb423f7aafc3e" diff --git a/pyproject.toml b/pyproject.toml index 9723c5d83fb..d4dd55a7063 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm" -version = "1.78.6" +version = "1.78.8" description = "Library to easily interface with LLM API providers" authors = ["BerriAI"] license = "MIT" @@ -59,7 +59,7 @@ websockets = {version = "^13.1.0", optional = true} boto3 = {version = "1.36.0", optional = true} redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"} mcp = {version = "^1.10.0", optional = true, python = ">=3.10"} -litellm-proxy-extras = {version = "0.2.27", optional = true} +litellm-proxy-extras = {version = "0.2.29", optional = true} rich = {version = "13.7.1", optional = true} litellm-enterprise = {version = "0.1.20", optional = true} diskcache = {version = "^5.6.1", optional = true} @@ -157,7 +157,7 @@ requires = ["poetry-core", "wheel"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "1.78.6" +version = "1.78.8" version_files = [ "pyproject.toml:^version" ] diff --git a/requirements.txt b/requirements.txt index 67cac4f4b01..958d4fed4cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ sentry_sdk==2.21.0 # for sentry error handling detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests cryptography==44.0.1 tzdata==2025.1 # IANA time zone database -litellm-proxy-extras==0.2.27 # for proxy extras - e.g. prisma migrations +litellm-proxy-extras==0.2.29 # for proxy extras - e.g. prisma migrations ### LITELLM PACKAGE DEPENDENCIES python-dotenv==1.0.1 # for env tiktoken==0.8.0 # for calculating usage diff --git a/schema.prisma b/schema.prisma index a13af1afc5f..9cb9edc9268 100644 --- a/schema.prisma +++ b/schema.prisma @@ -570,4 +570,14 @@ model LiteLLM_HealthCheckTable { @@index([model_name]) @@index([checked_at]) @@index([status]) +} + +// Search Tools table for storing search tool configurations +model LiteLLM_SearchToolsTable { + search_tool_id String @id @default(uuid()) + search_tool_name String @unique + litellm_params Json + search_tool_info Json? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt } \ No newline at end of file diff --git a/tests/audio_tests/test_audio_speech.py b/tests/audio_tests/test_audio_speech.py index 8861686ab13..c0bb6f72807 100644 --- a/tests/audio_tests/test_audio_speech.py +++ b/tests/audio_tests/test_audio_speech.py @@ -376,6 +376,93 @@ async def test_azure_ava_tts_async(): # assert response cost is greater than 0 print("Response cost: ", response._hidden_params["response_cost"]) assert response._hidden_params["response_cost"] > 0 - + except Exception as e: pytest.fail(f"Test failed with exception: {str(e)}") + + +@pytest.mark.asyncio +async def test_azure_ava_tts_with_custom_voice(): + """ + Test that when using a custom Azure voice (en-US-AndrewNeural), + the SSML request body contains the selected voice. + """ + from unittest.mock import AsyncMock, MagicMock, patch + import httpx + + # Mock response + mock_response_content = b"fake_audio_data" + mock_httpx_response = MagicMock(spec=httpx.Response) + mock_httpx_response.content = mock_response_content + mock_httpx_response.status_code = 200 + mock_httpx_response.headers = {"content-type": "audio/mpeg"} + + with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post") as mock_post: + mock_post.return_value = mock_httpx_response + + response = await litellm.aspeech( + model="azure/speech/azure-tts", + voice="en-US-AndrewNeural", + input="Hello, this is a test", + api_base="https://eastus.tts.speech.microsoft.com", + api_key="fake-key", + response_format="mp3", + ) + + # Verify the mock was called + assert mock_post.called + + # Get the call arguments + call_args = mock_post.call_args + ssml_body = call_args.kwargs.get("data") + + # Verify the SSML contains the custom voice + assert ssml_body is not None + assert "en-US-AndrewNeural" in ssml_body + assert "Hello, this is a test" in ssml_body + assert "400k tokens to trigger token limit error + oversized_text = "This is a test sentence. " * 50000 # ~400k tokens + + # This will raise ValidationError instead of showing the real error + response = await litellm.aresponses( + model="gpt-5-mini", + input=oversized_text, + stream=True + ) + + async for event in response: + print(event) # Never reaches here - ValidationError is raised \ No newline at end of file diff --git a/tests/llm_translation/test_databricks.py b/tests/llm_translation/test_databricks.py index 7828314baa3..77d803c061f 100644 --- a/tests/llm_translation/test_databricks.py +++ b/tests/llm_translation/test_databricks.py @@ -54,6 +54,125 @@ def mock_chat_response() -> Dict[str, Any]: } +def mock_chat_response_claude_prompt_caching() -> Dict[str, Any]: + return { + "id": "msg_01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "object": "chat.completion", + "created": 1761118943, + "model": "claude-3-7-sonnet", # Mock model name for testing + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The text you've provided consists entirely of the phrase \"example text\" repeated many times without any variation or additional content. There is no specific information, narrative, argument, or structured content to explain. This appears to be placeholder or filler text that would typically be replaced with actual content in a final document.", + "refusal": None, + "function_call": None, + "tool_calls": None, + "annotations": None, + "audio": None, + }, + "finish_reason": "stop", + "logprobs": None, + } + ], + "usage": { + "prompt_tokens": 1556, + "completion_tokens": 65, + "total_tokens": 1621, + "completion_tokens_details": None, + "prompt_tokens_details": None, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 1552, + }, + "service_tier": None, + "system_fingerprint": None, + } + +def mock_chat_response_claude_prompt_caching_repeat() -> Dict[str, Any]: + return { + "id": "msg_01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "object": "chat.completion", + "created": 1761118943, + "model": "claude-3-7-sonnet", # Mock model name for testing + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The text you've provided consists entirely of the phrase \"example text\" repeated many times without any variation or additional content. There is no specific information, narrative, argument, or structured content to explain. This appears to be placeholder or filler text that would typically be replaced with actual content in a final document.", + "refusal": None, + "function_call": None, + "tool_calls": None, + "annotations": None, + "audio": None, + }, + "finish_reason": "stop", + "logprobs": None, + } + ], + "usage": { + "prompt_tokens": 1556, + "completion_tokens": 65, + "total_tokens": 1621, + "completion_tokens_details": None, + "prompt_tokens_details": None, + "cache_read_input_tokens": 1552, + "cache_creation_input_tokens": 0, + }, + "service_tier": None, + "system_fingerprint": None, + } + + +def mock_chat_response_nonclaude_prompt_caching() -> Dict[str, Any]: + return { + "id": "msg_01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "object": "chat.completion", + "created": 1761119150, + "model": "gpt-oss-20b", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": [ + { + "type": "reasoning", + "summary": [ + { + "type": "summary_text", + "text": "The user just posted a block of text repeated: \"example textexample\" many times. It is unclear what they want. The instruction says: \"You are a helpful assistant that explains the content of the given text.\" So I need to explain the content.\n\nThe content is basically a repeated phrase 'example textexample' many times, possibly a demonstration of repeated words or filler text. Perhaps they test that the assistant enumerates or condenses. Should I explain that it is a repeated phrase used maybe as placeholder text? It looks like a placeholder or filler. Could say that it's essentially nonsense.\n\nExplain that the text consists of the word \"example\" concatenated with \"text\" repeated many times. It's not meaningful content. Might indicate filler text for page layout.\n\nAlternatively, explain why repeated 'example textexample' (without whitespace in some places?) is repeated. This could be a test. The user probably expects a response like: \"It says 'example textexample' several times.\" So I should summarize: The text is a repeated phrase used as filler.\n\nGiven the instruction, let's explain the content. Mention that it's repetitive placeholder, no meaningful content, just repeated phrase. Also note that \"example text\" repeated words. No specific meaning beyond being placeholder.\n\nSo respond: This is basically a placeholder used in design documents: the phrase \"example text\" repeated to fill a space, no distinct meaning beyond placeholder usage. 'text' might be part of the 'example text' phrase or 'textexample' it's concatenated. These might serve to fill text boxes, test fonts, etc.\n\nAlso mention the pattern: Could be used for testing text rendering, typographic layouts, measuring dimensions.\n\nAnswer accordingly." + } + ] + }, + { + "type": "text", + "text": "The passage you pasted is essentially a block of **placeholder text**. \nIt repeats the phrase \"example textexample\" (or \"example text\" in some places) over and over again. There isn't any hidden message, concept, or argument buried in it – the purpose is purely to fill space, imitate real content, or test something like typography, layout, or rendering.\n\nIn design and copy‑editing, such repeated strings are often used to:\n\n* **Fill a page or template** so the designer can see how multiple lines of content will look.\n* **Test the appearance of fonts, line‑height, paragraph spacing, and other typographic settings.**\n* **Serve as a stand" + } + ], + "refusal": None, + "function_call": None, + "tool_calls": None, + "annotations": None, + "audio": None, + }, + "finish_reason": "stop", + "logprobs": None, + } + ], + "usage": { + "prompt_tokens": 1638, + "completion_tokens": 500, + "total_tokens": 2138, + "completion_tokens_details": None, + "prompt_tokens_details": None, + }, + "service_tier": None, + "system_fingerprint": None, + } + + def mock_chat_streaming_response_chunks() -> List[str]: return [ json.dumps( @@ -712,3 +831,190 @@ async def test_databricks_embeddings(sync_mode): # assert response.data[0]["embedding"] == [0.1, 0.2, 0.3] except Exception as e: pytest.fail(f"Error occurred: {e}") + + +def test_completion_with_prompt_caching_claude_model(monkeypatch): + base_url = "https://my.workspace.cloud.databricks.com/serving-endpoints" + api_key = "dapimykey" + monkeypatch.setenv("DATABRICKS_API_BASE", base_url) + monkeypatch.setenv("DATABRICKS_API_KEY", api_key) + + sync_handler = HTTPHandler() + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = mock_chat_response_claude_prompt_caching() + + mock_text = 'example text' * 512 + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a helpful assistant that explains the content of the given text." + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": mock_text, + "cache_control": {"type": "ephemeral"} + } + ] + } + ] + + with patch.object(HTTPHandler, "post", return_value=mock_response) as mock_post: + response = litellm.completion( + model="databricks/databricks-claude-3-7-sonnet", + messages=messages, + client=sync_handler, + temperature=0.5 + ) + assert ( + mock_post.call_args.kwargs["headers"]["Content-Type"] == "application/json" + ) + assert ( + mock_post.call_args.kwargs["headers"]["Authorization"] + == f"Bearer {api_key}" + ) + assert mock_post.call_args.kwargs["url"] == f"{base_url}/chat/completions" + assert mock_post.call_args.kwargs["stream"] == False + + # TODO: add test for entire expected output schema in the future + # Check the response object returned from litellm.completion() + assert 'claude-3-7-sonnet' in response['model'] + assert response['usage']['cache_read_input_tokens'] == 0 + assert response['usage']['cache_creation_input_tokens'] == 1552 + assert response['usage']['prompt_tokens'] == 1556 + assert response['usage']['completion_tokens'] == 65 + assert response['usage']['total_tokens'] == 1621 + + +def test_completion_with_prompt_caching_claude_model_repeat(monkeypatch): + base_url = "https://my.workspace.cloud.databricks.com/serving-endpoints" + api_key = "dapimykey" + monkeypatch.setenv("DATABRICKS_API_BASE", base_url) + monkeypatch.setenv("DATABRICKS_API_KEY", api_key) + + sync_handler = HTTPHandler() + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = mock_chat_response_claude_prompt_caching_repeat() + + mock_text = 'example text' * 512 + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a helpful assistant that explains the content of the given text." + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": mock_text, + "cache_control": {"type": "ephemeral"} + } + ] + } + ] + + with patch.object(HTTPHandler, "post", return_value=mock_response) as mock_post: + response = litellm.completion( + model="databricks/databricks-claude-3-7-sonnet", + messages=messages, + client=sync_handler, + temperature=0.5, + extraparam="testpassingextraparam", + ) + assert ( + mock_post.call_args.kwargs["headers"]["Content-Type"] == "application/json" + ) + assert ( + mock_post.call_args.kwargs["headers"]["Authorization"] + == f"Bearer {api_key}" + ) + assert mock_post.call_args.kwargs["url"] == f"{base_url}/chat/completions" + assert mock_post.call_args.kwargs["stream"] == False + + + # TODO: add test for entire expected output schema in the future + # Check the response object returned from litellm.completion() + assert 'claude-3-7-sonnet' in response['model'] + assert response['usage']['cache_read_input_tokens'] == 1552 + assert response['usage']['cache_creation_input_tokens'] == 0 + assert response['usage']['prompt_tokens'] == 1556 + assert response['usage']['completion_tokens'] == 65 + assert response['usage']['total_tokens'] == 1621 + + +def test_completion_with_prompt_caching_nonclaude_model(monkeypatch): + base_url = "https://my.workspace.cloud.databricks.com/serving-endpoints" + api_key = "dapimykey" + monkeypatch.setenv("DATABRICKS_API_BASE", base_url) + monkeypatch.setenv("DATABRICKS_API_KEY", api_key) + + sync_handler = HTTPHandler() + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = mock_chat_response_nonclaude_prompt_caching() + + mock_text = 'example text' * 512 + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a helpful assistant that explains the content of the given text." + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": mock_text, + "cache_control": {"type": "ephemeral"} + } + ] + } + ] + + with patch.object(HTTPHandler, "post", return_value=mock_response) as mock_post: + response = litellm.completion( + model="databricks/databricks-gpt-oss-20b", + messages=messages, + client=sync_handler, + temperature=0.5, + extraparam="testpassingextraparam", + ) + assert ( + mock_post.call_args.kwargs["headers"]["Content-Type"] == "application/json" + ) + assert ( + mock_post.call_args.kwargs["headers"]["Authorization"] + == f"Bearer {api_key}" + ) + assert mock_post.call_args.kwargs["url"] == f"{base_url}/chat/completions" + assert mock_post.call_args.kwargs["stream"] == False + + # TODO: add test for entire expected output schema in the future + # Check the response object returned from litellm.completion() + assert 'gpt-oss-20b' in response['model'] + assert ('cache_read_input_tokens' not in response['usage']) or response['usage']['cache_read_input_tokens'] in [0, None] + assert ('cache_creation_input_tokens' not in response['usage']) or response['usage']['cache_creation_input_tokens'] in [0, None] + assert response['usage']['prompt_tokens'] == 1638 + assert response['usage']['completion_tokens'] == 500 + assert response['usage']['total_tokens'] == 2138 + \ No newline at end of file diff --git a/tests/local_testing/test_amazing_vertex_completion.py b/tests/local_testing/test_amazing_vertex_completion.py index 1e06c92105c..fd1df5d0673 100644 --- a/tests/local_testing/test_amazing_vertex_completion.py +++ b/tests/local_testing/test_amazing_vertex_completion.py @@ -1730,7 +1730,7 @@ async def test_gemini_pro_function_calling(provider, sync_mode): ] data = { - "model": "{}/gemini-1.5-pro-preview-0514".format(provider), + "model": "{}/gemini-2.5-flash-lite".format(provider), "messages": messages, "tools": tools, } diff --git a/tests/mcp_tests/test_mcp_auth_header_extraction.py b/tests/mcp_tests/test_mcp_auth_header_extraction.py new file mode 100644 index 00000000000..608a5400072 --- /dev/null +++ b/tests/mcp_tests/test_mcp_auth_header_extraction.py @@ -0,0 +1,162 @@ +""" +Test MCP auth header extraction and case-insensitive server name matching. + +Tests the fixes for: +1. Auth headers being properly extracted from HTTP request headers in REST endpoints +2. Case-insensitive matching for server-specific auth headers in _call_regular_mcp_tool +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock +from starlette.datastructures import Headers + +from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCPRequestHandler, +) +from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager +from litellm.types.mcp import MCPAuth, MCPTransport +from litellm.types.mcp_server.mcp_server_manager import MCPServer + + +class TestRestEndpointAuthHeaderExtraction: + """Test Fix 1: REST endpoints properly extract auth headers from HTTP requests""" + + def test_call_tool_rest_api_extracts_mcp_auth_header(self): + """Test that call_tool REST endpoint extracts x-mcp-auth header""" + headers = Headers({"x-mcp-auth": "Bearer legacy-token"}) + + mcp_auth_header = MCPRequestHandler._get_mcp_auth_header_from_headers(headers) + + assert mcp_auth_header == "Bearer legacy-token" + + def test_call_tool_rest_api_extracts_server_specific_headers(self): + """Test that call_tool REST endpoint extracts server-specific auth headers""" + headers = Headers({ + "x-mcp-github-authorization": "Bearer github-token", + "x-mcp-zapier-x-api-key": "zapier-key-123", + }) + + mcp_server_auth_headers = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers) + + assert "github" in mcp_server_auth_headers + assert mcp_server_auth_headers["github"]["Authorization"] == "Bearer github-token" + assert "zapier" in mcp_server_auth_headers + assert mcp_server_auth_headers["zapier"]["x-api-key"] == "zapier-key-123" + + def test_list_tools_rest_api_extracts_auth_headers(self): + """Test that list_tools REST endpoint extracts auth headers""" + headers = Headers({ + "x-mcp-auth": "Bearer legacy-token", + "x-mcp-zapier-authorization": "Bearer zapier-token", + }) + + mcp_auth_header = MCPRequestHandler._get_mcp_auth_header_from_headers(headers) + mcp_server_auth_headers = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers) + + assert mcp_auth_header == "Bearer legacy-token" + assert "zapier" in mcp_server_auth_headers + assert mcp_server_auth_headers["zapier"]["Authorization"] == "Bearer zapier-token" + + +class TestCaseInsensitiveServerMatching: + """Test Fix 2: Case-insensitive matching for server names in _call_regular_mcp_tool""" + + def test_case_insensitive_alias_matching(self): + """Test server auth headers match case-insensitively by alias""" + server = MCPServer( + server_id="test-server", + name="Test Server", + alias="LiteLLMAGCGateway", + server_name="litellm_gateway", + url="https://api.example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.authorization, + ) + + mcp_server_auth_headers = { + "litellmagcgateway": {"Authorization": "Bearer token"} + } + + # Test the case-insensitive matching logic from _call_regular_mcp_tool + normalized_headers = {k.lower(): v for k, v in mcp_server_auth_headers.items()} + server_auth_header = normalized_headers.get(server.alias.lower()) + + assert server_auth_header is not None + assert server_auth_header["Authorization"] == "Bearer token" + + def test_case_insensitive_server_name_matching(self): + """Test server auth headers match case-insensitively by server_name""" + server = MCPServer( + server_id="test-server", + name="Test Server", + alias=None, + server_name="MyAPIServer", + url="https://api.example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.authorization, + ) + + mcp_server_auth_headers = { + "myapiserver": {"Authorization": "Bearer token"} + } + + # Test the case-insensitive matching logic from _call_regular_mcp_tool + normalized_headers = {k.lower(): v for k, v in mcp_server_auth_headers.items()} + server_auth_header = normalized_headers.get(server.server_name.lower()) + + assert server_auth_header is not None + assert server_auth_header["Authorization"] == "Bearer token" + + def test_alias_checked_before_server_name(self): + """Test that alias is checked before server_name""" + server = MCPServer( + server_id="test-server", + name="Test Server", + alias="MyAlias", + server_name="MyServerName", + url="https://api.example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.authorization, + ) + + mcp_server_auth_headers = { + "myalias": {"Authorization": "Bearer alias-token"}, + "myservername": {"Authorization": "Bearer servername-token"}, + } + + # Simulate the fix + normalized_headers = {k.lower(): v for k, v in mcp_server_auth_headers.items()} + server_auth_header = normalized_headers.get(server.alias.lower()) + if server_auth_header is None and server.server_name: + server_auth_header = normalized_headers.get(server.server_name.lower()) + + assert server_auth_header["Authorization"] == "Bearer alias-token" + + def test_fallback_to_legacy_auth_header(self): + """Test fallback to legacy auth header when no server-specific header found""" + server = MCPServer( + server_id="test-server", + name="Test Server", + alias="MyServer", + server_name="my_server", + url="https://api.example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.authorization, + ) + + mcp_server_auth_headers = {} + mcp_auth_header = "Bearer legacy-token" + + # Simulate the fix + normalized_headers = {k.lower(): v for k, v in mcp_server_auth_headers.items()} + server_auth_header = normalized_headers.get(server.alias.lower()) + if server_auth_header is None and server.server_name: + server_auth_header = normalized_headers.get(server.server_name.lower()) + if server_auth_header is None: + server_auth_header = mcp_auth_header + + assert server_auth_header == "Bearer legacy-token" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/router_unit_tests/test_router_helper_utils.py b/tests/router_unit_tests/test_router_helper_utils.py index c2339d9eec5..fe86d0abe72 100644 --- a/tests/router_unit_tests/test_router_helper_utils.py +++ b/tests/router_unit_tests/test_router_helper_utils.py @@ -1753,3 +1753,185 @@ def test_get_metadata_variable_name_from_kwargs(model_list): } result = router._get_metadata_variable_name_from_kwargs(kwargs_other) assert result == "metadata" + + +@pytest.fixture +def search_tools(): + """Fixture for search tools configuration""" + return [ + { + "search_tool_name": "test-search-tool", + "litellm_params": { + "search_provider": "perplexity", + "api_key": "test-api-key", + "api_base": "https://api.perplexity.ai", + } + }, + { + "search_tool_name": "test-search-tool", + "litellm_params": { + "search_provider": "perplexity", + "api_key": "test-api-key-2", + "api_base": "https://api.perplexity.ai", + } + } + ] + + +@pytest.mark.asyncio +async def test_asearch_with_fallbacks(search_tools): + """ + Test _asearch_with_fallbacks method of Router. + + Tests that the _asearch_with_fallbacks method correctly: + - Accepts search parameters + - Calls async_function_with_fallbacks with correct configuration + - Returns SearchResponse + """ + from litellm.llms.base_llm.search.transformation import SearchResponse, SearchResult + + router = Router(search_tools=search_tools) + + # Create a mock search response + mock_response = SearchResponse( + object="search", + results=[ + SearchResult( + title="Test Result", + url="https://example.com", + snippet="Test snippet content" + ) + ] + ) + + # Mock the async_function_with_fallbacks to return our mock response + with patch.object(router, 'async_function_with_fallbacks', new_callable=AsyncMock) as mock_fallbacks: + mock_fallbacks.return_value = mock_response + + # Mock original function + async def mock_asearch(**kwargs): + return mock_response + + # Call _asearch_with_fallbacks + response = await router._asearch_with_fallbacks( + original_function=mock_asearch, + search_tool_name="test-search-tool", + query="test query", + max_results=5 + ) + + # Verify async_function_with_fallbacks was called + assert mock_fallbacks.called + + # Verify the response + assert isinstance(response, SearchResponse) + assert response.object == "search" + assert len(response.results) == 1 + assert response.results[0].title == "Test Result" + + +@pytest.mark.asyncio +async def test_asearch_with_fallbacks_helper(search_tools): + """ + Test _asearch_with_fallbacks_helper method of Router. + + Tests that the _asearch_with_fallbacks_helper method correctly: + - Selects a search tool from available options + - Calls the original search function with correct provider parameters + - Returns SearchResponse + """ + from litellm.llms.base_llm.search.transformation import SearchResponse, SearchResult + + router = Router(search_tools=search_tools) + + # Create a mock search response + mock_response = SearchResponse( + object="search", + results=[ + SearchResult( + title="Helper Test Result", + url="https://example.com/helper", + snippet="Helper test snippet" + ) + ] + ) + + # Mock the original generic function + async def mock_original_function(**kwargs): + # Verify correct parameters are passed + assert "search_provider" in kwargs + assert kwargs["search_provider"] == "perplexity" + assert "api_key" in kwargs + assert kwargs["query"] == "helper test query" + return mock_response + + # Call _asearch_with_fallbacks_helper + response = await router._asearch_with_fallbacks_helper( + model="test-search-tool", + original_generic_function=mock_original_function, + query="helper test query", + max_results=3 + ) + + # Verify the response + assert isinstance(response, SearchResponse) + assert response.object == "search" + assert len(response.results) == 1 + assert response.results[0].title == "Helper Test Result" + assert response.results[0].url == "https://example.com/helper" + + +@pytest.mark.asyncio +async def test_asearch_with_fallbacks_helper_missing_search_tool(): + """ + Test _asearch_with_fallbacks_helper raises error when search tool not found. + + Tests that the helper method raises a ValueError when the requested + search tool name doesn't exist in the router's search_tools configuration. + """ + # Create router with no search tools + router = Router(model_list=[]) + + async def mock_original_function(**kwargs): + return None + + # Should raise ValueError for missing search tool + with pytest.raises(ValueError, match="Search tool 'nonexistent-tool' not found"): + await router._asearch_with_fallbacks_helper( + model="nonexistent-tool", + original_generic_function=mock_original_function, + query="test query" + ) + + +@pytest.mark.asyncio +async def test_asearch_with_fallbacks_helper_missing_search_provider(): + """ + Test _asearch_with_fallbacks_helper raises error when search_provider not configured. + + Tests that the helper method raises a ValueError when a search tool + is found but doesn't have search_provider in its litellm_params. + """ + # Create router with misconfigured search tool (missing search_provider) + search_tools_bad = [ + { + "search_tool_name": "bad-tool", + "litellm_params": { + "api_key": "test-key" + # Missing search_provider + } + } + ] + + router = Router(search_tools=search_tools_bad) + + async def mock_original_function(**kwargs): + return None + + # Should raise ValueError for missing search_provider + with pytest.raises(ValueError, match="search_provider not found in litellm_params"): + await router._asearch_with_fallbacks_helper( + model="bad-tool", + original_generic_function=mock_original_function, + query="test query" + ) diff --git a/tests/search_tests/__init__.py b/tests/search_tests/__init__.py new file mode 100644 index 00000000000..9e0d0be6afc --- /dev/null +++ b/tests/search_tests/__init__.py @@ -0,0 +1,4 @@ +""" +Search API tests. +""" + diff --git a/tests/search_tests/base_search_unit_tests.py b/tests/search_tests/base_search_unit_tests.py new file mode 100644 index 00000000000..140f76835b0 --- /dev/null +++ b/tests/search_tests/base_search_unit_tests.py @@ -0,0 +1,154 @@ +""" +Base test class for Search functionality across different providers. + +This follows the same pattern as BaseOCRTest in tests/ocr_tests/base_ocr_unit_tests.py +""" +import pytest +import litellm +from abc import ABC, abstractmethod +import os +import json + + +class BaseSearchTest(ABC): + """ + Abstract base test class that enforces common Search tests across all providers. + + Each provider-specific test class should inherit from this and implement + get_search_provider() to return provider name. + """ + + @abstractmethod + def get_search_provider(self) -> str: + """Must return the search_provider for the specific provider""" + pass + + @pytest.fixture(autouse=True) + def _handle_rate_limits(self): + """Fixture to handle rate limit errors for all test methods""" + try: + yield + except litellm.RateLimitError: + pytest.skip("Rate limit exceeded") + except litellm.InternalServerError: + pytest.skip("Model is overloaded") + + @pytest.mark.asyncio + async def test_basic_search(self): + """ + Test basic search functionality with a simple query. + """ + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + litellm._turn_on_debug() + search_provider = self.get_search_provider() + print("Search Provider=", search_provider) + + try: + response = await litellm.asearch( + query="latest developments in AI", + search_provider=search_provider, + ) + print("Search response=", response.model_dump_json(indent=4)) + + print(f"\n{'='*80}") + print(f"Response type: {type(response)}") + print(f"Response object: {response.object if hasattr(response, 'object') else 'N/A'}") + + # Check if response has expected Search format + assert hasattr(response, "results"), "Response should have 'results' attribute" + assert hasattr(response, "object"), "Response should have 'object' attribute" + assert response.object == "search", f"Expected object='search', got '{response.object}'" + + # Validate results structure + assert isinstance(response.results, list), "results should be a list" + assert len(response.results) > 0, "Should have at least one result" + + # Check first result structure + first_result = response.results[0] + assert hasattr(first_result, "title"), "Result should have 'title' attribute" + assert hasattr(first_result, "url"), "Result should have 'url' attribute" + assert hasattr(first_result, "snippet"), "Result should have 'snippet' attribute" + + print(f"Total results: {len(response.results)}") + print(f"First result title: {first_result.title}") + print(f"First result URL: {first_result.url}") + print(f"First result snippet: {first_result.snippet[:100]}...") + print(f"{'='*80}\n") + + assert len(first_result.title) > 0, "Title should not be empty" + assert len(first_result.url) > 0, "URL should not be empty" + assert len(first_result.snippet) > 0, "Snippet should not be empty" + + # Validate cost tracking in _hidden_params + assert hasattr(response, "_hidden_params"), "Response should have '_hidden_params' attribute" + hidden_params = response._hidden_params + assert "response_cost" in hidden_params, "_hidden_params should contain 'response_cost'" + + response_cost = hidden_params["response_cost"] + assert response_cost is not None, "response_cost should not be None" + assert isinstance(response_cost, (int, float)), "response_cost should be a number" + assert response_cost >= 0, "response_cost should be non-negative" + + print(f"Cost tracking: ${response_cost:.6f}") + + except Exception as e: + pytest.fail(f"Search call failed: {str(e)}") + + def test_search_response_structure(self): + """ + Test that the Search response has the correct structure. + """ + litellm.set_verbose = True + search_provider = self.get_search_provider() + + response = litellm.search( + query="artificial intelligence recent news", + search_provider=search_provider, + ) + + # Validate response structure + assert hasattr(response, "results"), "Response should have 'results' attribute" + assert hasattr(response, "object"), "Response should have 'object' attribute" + + assert isinstance(response.results, list), "results should be a list" + assert len(response.results) > 0, "Should have at least one result" + assert response.object == "search", "object should be 'search'" + + # Validate first result structure + first_result = response.results[0] + assert hasattr(first_result, "title"), "Result should have 'title' attribute" + assert hasattr(first_result, "url"), "Result should have 'url' attribute" + assert hasattr(first_result, "snippet"), "Result should have 'snippet' attribute" + assert isinstance(first_result.title, str), "title should be a string" + assert isinstance(first_result.url, str), "url should be a string" + assert isinstance(first_result.snippet, str), "snippet should be a string" + + print(f"\nResponse structure validated:") + print(f" - object: {response.object}") + print(f" - results: {len(response.results)}") + print(f" - first result has all required fields") + + def test_search_with_optional_params(self): + """ + Test search with optional parameters. + """ + litellm.set_verbose = True + search_provider = self.get_search_provider() + + response = litellm.search( + query="machine learning", + search_provider=search_provider, + max_results=5, + ) + + # Validate response + assert hasattr(response, "results"), "Response should have 'results' attribute" + assert isinstance(response.results, list), "results should be a list" + assert len(response.results) > 0, "Should have at least one result" + assert len(response.results) <= 5, "Should have at most 5 results as requested" + + print(f"\nSearch with optional params validated:") + print(f" - Requested max_results: 5") + print(f" - Received results: {len(response.results)}") + diff --git a/tests/search_tests/test_dataforseo_search.py b/tests/search_tests/test_dataforseo_search.py new file mode 100644 index 00000000000..59955323908 --- /dev/null +++ b/tests/search_tests/test_dataforseo_search.py @@ -0,0 +1,26 @@ +""" +Unit tests for DataForSEO Search functionality. + +These tests verify that the DataForSEO search provider integration works correctly +with LiteLLM's unified search interface. +""" + +import sys +import os + +# Add the parent directory to the path so we can import litellm +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from tests.search_tests.base_search_unit_tests import BaseSearchTest + + +class TestDataForSEOSearch(BaseSearchTest): + """ + Test suite for DataForSEO search provider. + Inherits all test cases from BaseSearchTest. + """ + + def get_search_provider(self) -> str: + """Return the search provider name for DataForSEO.""" + return "dataforseo" + diff --git a/tests/search_tests/test_exa_ai_search.py b/tests/search_tests/test_exa_ai_search.py new file mode 100644 index 00000000000..60b4eb0389f --- /dev/null +++ b/tests/search_tests/test_exa_ai_search.py @@ -0,0 +1,18 @@ +import pytest +import litellm +from typing import List, Union + +from tests.search_tests.base_search_unit_tests import BaseSearchTest + + +class TestExaAISearch(BaseSearchTest): + """ + Tests for Exa AI Search functionality. + """ + + def get_search_provider(self) -> str: + """ + Return search_provider for Exa AI Search. + """ + return "exa_ai" + diff --git a/tests/search_tests/test_google_pse_search.py b/tests/search_tests/test_google_pse_search.py new file mode 100644 index 00000000000..c8a46798cca --- /dev/null +++ b/tests/search_tests/test_google_pse_search.py @@ -0,0 +1,26 @@ +""" +Tests for Google Programmable Search Engine (PSE) API integration. +""" +import os +import sys +import pytest + +sys.path.insert( + 0, os.path.abspath("../..") +) + +from tests.search_tests.base_search_unit_tests import BaseSearchTest + + +class TestGooglePSESearch(BaseSearchTest): + """ + Tests for Google PSE Search functionality. + """ + + def get_search_provider(self) -> str: + """ + Return search_provider for Google PSE Search. + """ + return "google_pse" + + diff --git a/tests/search_tests/test_parallel_ai_search.py b/tests/search_tests/test_parallel_ai_search.py new file mode 100644 index 00000000000..1dc3b7c9d83 --- /dev/null +++ b/tests/search_tests/test_parallel_ai_search.py @@ -0,0 +1,19 @@ +import pytest +import litellm +from typing import List, Union + +from tests.search_tests.base_search_unit_tests import BaseSearchTest + + +class TestParallelAISearch(BaseSearchTest): + """ + Tests for Parallel AI Search functionality. + """ + + def get_search_provider(self) -> str: + """ + Return search_provider for Parallel AI Search. + """ + return "parallel_ai" + + diff --git a/tests/search_tests/test_perplexity_search.py b/tests/search_tests/test_perplexity_search.py new file mode 100644 index 00000000000..0c35dd88baa --- /dev/null +++ b/tests/search_tests/test_perplexity_search.py @@ -0,0 +1,87 @@ +""" +Tests for Perplexity Search API integration. +""" +import os +import sys +import pytest + +sys.path.insert( + 0, os.path.abspath("../..") +) + +from tests.search_tests.base_search_unit_tests import BaseSearchTest + + +class TestPerplexitySearch(BaseSearchTest): + """ + Tests for Perplexity Search functionality. + """ + + def get_search_provider(self) -> str: + """ + Return search_provider for Perplexity Search. + """ + return "perplexity" + + +class TestRouterSearch: + """ + Tests for Router Search functionality. + """ + + @pytest.mark.asyncio + async def test_router_search_with_search_tools(self): + """ + Test router's asearch method with search_tools configuration. + """ + from litellm import Router + import litellm + + litellm._turn_on_debug() + + # Create router with search_tools config + router = Router( + search_tools=[ + { + "search_tool_name": "litellm-search", + "litellm_params": { + "search_provider": "perplexity", + "api_key": os.environ.get("PERPLEXITYAI_API_KEY"), + } + } + ] + ) + + # Test the search + response = await router.asearch( + query="latest AI developments", + search_tool_name="litellm-search", + max_results=3 + ) + + print(f"\n{'='*80}") + print(f"Router Search Test Results:") + print(f"Response type: {type(response)}") + print(f"Response object: {response.object}") + print(f"Number of results: {len(response.results)}") + + # Validate response structure + assert hasattr(response, "results"), "Response should have 'results' attribute" + assert hasattr(response, "object"), "Response should have 'object' attribute" + assert response.object == "search", f"Expected object='search', got '{response.object}'" + assert isinstance(response.results, list), "results should be a list" + assert len(response.results) > 0, "Should have at least one result" + assert len(response.results) <= 3, "Should return at most 3 results" + + # Validate first result + first_result = response.results[0] + assert hasattr(first_result, "title"), "Result should have 'title' attribute" + assert hasattr(first_result, "url"), "Result should have 'url' attribute" + assert hasattr(first_result, "snippet"), "Result should have 'snippet' attribute" + + print(f"First result title: {first_result.title}") + print(f"First result URL: {first_result.url}") + print(f"{'='*80}\n") + + print("✅ Router search test passed!") + diff --git a/tests/search_tests/test_tavily_search.py b/tests/search_tests/test_tavily_search.py new file mode 100644 index 00000000000..c7e923d06bc --- /dev/null +++ b/tests/search_tests/test_tavily_search.py @@ -0,0 +1,25 @@ +""" +Tests for Tavily Search API integration. +""" +import os +import sys +import pytest + +sys.path.insert( + 0, os.path.abspath("../..") +) + +from tests.search_tests.base_search_unit_tests import BaseSearchTest + + +class TestTavilySearch(BaseSearchTest): + """ + Tests for Tavily Search functionality. + """ + + def get_search_provider(self) -> str: + """ + Return search_provider for Tavily Search. + """ + return "tavily" + diff --git a/tests/test_litellm/integrations/test_braintrust_span_name.py b/tests/test_litellm/integrations/test_braintrust_span_name.py index 30381e99783..7050a6d355f 100644 --- a/tests/test_litellm/integrations/test_braintrust_span_name.py +++ b/tests/test_litellm/integrations/test_braintrust_span_name.py @@ -224,6 +224,76 @@ class TestBraintrustSpanName(unittest.TestCase): json_data["events"][0]["span_attributes"]["name"], "Async Custom Operation" ) + @patch('litellm.integrations.braintrust_logging.HTTPHandler') + def test_span_attributes_with_multiple_metadata_fields(self, MockHTTPHandler): + """Test that span_name works correctly alongside other metadata fields.""" + # Mock HTTP response + mock_response = Mock() + mock_response.json.return_value = {"id": "test-project-id"} + mock_http_handler = Mock() + mock_http_handler.post.return_value = mock_response + MockHTTPHandler.return_value = mock_http_handler + + # Setup + logger = BraintrustLogger(api_key="test-key") + logger.default_project_id = "test-project-id" + + # Create a mock response object + message_mock = Mock() + message_mock.json = Mock(return_value={"content": "test"}) + + choice_mock = Mock() + choice_mock.message = message_mock + choice_mock.dict = Mock(return_value={"message": {"content": "test"}}) + choice_mock.__getitem__ = Mock(return_value=message_mock) + + response_obj = Mock(spec=litellm.ModelResponse) + response_obj.choices = [choice_mock] + response_obj.__getitem__ = Mock(return_value=[choice_mock]) + response_obj.usage = litellm.Usage( + prompt_tokens=10, + completion_tokens=20, + total_tokens=30 + ) + + kwargs = { + "litellm_call_id": "test-call-id", + "messages": [{"role": "user", "content": "test"}], + "litellm_params": { + "metadata": { + "span_name": "Multi Metadata Test", + "span_id": "span_id", + "root_span_id": "root_span_id", + "span_parents": "span_parent1,span_parent2", + "project_id": "custom-project", + "user_id": "user123", + "session_id": "session456" + } + }, + "model": "gpt-3.5-turbo", + "response_cost": 0.001 + } + + # Execute + logger.log_success_event(kwargs, response_obj, datetime.now(), datetime.now()) + + # Verify + call_args = mock_http_handler.post.call_args + self.assertIsNotNone(call_args) + json_data = call_args.kwargs['json'] + + # Check span name + self.assertEqual(json_data['events'][0]['span_attributes']['name'], 'Multi Metadata Test') + self.assertEqual(json_data['events'][0]['span_id'], 'span_id') + self.assertEqual(json_data['events'][0]['root_span_id'], 'root_span_id') + self.assertEqual(json_data['events'][0]['span_parents'][0], 'span_parent1') + self.assertEqual(json_data['events'][0]['span_parents'][1], 'span_parent2') + + # Check that other metadata is preserved + event_metadata = json_data['events'][0]['metadata'] + self.assertEqual(event_metadata['user_id'], 'user123') + self.assertEqual(event_metadata['session_id'], 'session456') + if __name__ == "__main__": unittest.main() diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index e605718d29f..fc33b736872 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -8,7 +8,7 @@ import time # Adds the grandparent directory to sys.path to allow importing project modules sys.path.insert(0, os.path.abspath("../..")) -from litellm.integrations.opentelemetry import OpenTelemetry +from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from opentelemetry.sdk.trace import TracerProvider @@ -806,3 +806,474 @@ class TestOpenTelemetry(unittest.TestCase): otel._maybe_log_raw_request(kwargs, {}, datetime.now(), datetime.now(), MagicMock()) mock_tracer.start_span.assert_not_called() + + +class TestOpenTelemetryEndpointNormalization(unittest.TestCase): + """Test suite for the unified _normalize_otel_endpoint method""" + + def test_normalize_traces_endpoint_from_logs_path(self): + """Test normalizing endpoint with /v1/logs to /v1/traces""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/v1/logs", "traces") + self.assertEqual(result, "http://collector:4318/v1/traces") + + def test_normalize_traces_endpoint_from_metrics_path(self): + """Test normalizing endpoint with /v1/metrics to /v1/traces""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/v1/metrics", "traces") + self.assertEqual(result, "http://collector:4318/v1/traces") + + def test_normalize_traces_endpoint_from_base_url(self): + """Test adding /v1/traces to base URL""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318", "traces") + self.assertEqual(result, "http://collector:4318/v1/traces") + + def test_normalize_traces_endpoint_from_v1_path(self): + """Test adding traces to /v1 path""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/v1", "traces") + self.assertEqual(result, "http://collector:4318/v1/traces") + + def test_normalize_traces_endpoint_already_correct(self): + """Test endpoint already ending with /v1/traces remains unchanged""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/v1/traces", "traces") + self.assertEqual(result, "http://collector:4318/v1/traces") + + def test_normalize_metrics_endpoint_from_traces_path(self): + """Test normalizing endpoint with /v1/traces to /v1/metrics""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/v1/traces", "metrics") + self.assertEqual(result, "http://collector:4318/v1/metrics") + + def test_normalize_metrics_endpoint_from_logs_path(self): + """Test normalizing endpoint with /v1/logs to /v1/metrics""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/v1/logs", "metrics") + self.assertEqual(result, "http://collector:4318/v1/metrics") + + def test_normalize_metrics_endpoint_from_base_url(self): + """Test adding /v1/metrics to base URL""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318", "metrics") + self.assertEqual(result, "http://collector:4318/v1/metrics") + + def test_normalize_metrics_endpoint_already_correct(self): + """Test endpoint already ending with /v1/metrics remains unchanged""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/v1/metrics", "metrics") + self.assertEqual(result, "http://collector:4318/v1/metrics") + + def test_normalize_logs_endpoint_from_traces_path(self): + """Test normalizing endpoint with /v1/traces to /v1/logs""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/v1/traces", "logs") + self.assertEqual(result, "http://collector:4318/v1/logs") + + def test_normalize_logs_endpoint_from_metrics_path(self): + """Test normalizing endpoint with /v1/metrics to /v1/logs""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/v1/metrics", "logs") + self.assertEqual(result, "http://collector:4318/v1/logs") + + def test_normalize_logs_endpoint_from_base_url(self): + """Test adding /v1/logs to base URL""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318", "logs") + self.assertEqual(result, "http://collector:4318/v1/logs") + + def test_normalize_logs_endpoint_already_correct(self): + """Test endpoint already ending with /v1/logs remains unchanged""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/v1/logs", "logs") + self.assertEqual(result, "http://collector:4318/v1/logs") + + def test_normalize_endpoint_with_trailing_slash(self): + """Test that trailing slashes are properly handled""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/", "traces") + self.assertEqual(result, "http://collector:4318/v1/traces") + + def test_normalize_endpoint_none(self): + """Test that None endpoint returns None""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint(None, "traces") + self.assertIsNone(result) + + def test_normalize_endpoint_empty_string(self): + """Test that empty string returns empty string""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("", "traces") + self.assertEqual(result, "") + + def test_normalize_endpoint_invalid_signal_type(self): + """Test that invalid signal type returns endpoint unchanged with warning""" + otel = OpenTelemetry() + endpoint = "http://collector:4318/v1/traces" + + with patch('litellm._logging.verbose_logger.warning') as mock_warning: + result = otel._normalize_otel_endpoint(endpoint, "invalid") + + # Should return endpoint unchanged + self.assertEqual(result, endpoint) + + # Should log a warning + mock_warning.assert_called_once() + # Check the warning was called with the expected format string and parameters + call_args = mock_warning.call_args[0] + self.assertIn("Invalid signal_type", call_args[0]) + self.assertEqual(call_args[1], "invalid") # signal_type parameter + self.assertEqual(call_args[2], {'traces', 'metrics', 'logs'}) # valid_signals parameter + + def test_normalize_endpoint_https(self): + """Test normalization works with https URLs""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("https://collector.example.com:4318", "logs") + self.assertEqual(result, "https://collector.example.com:4318/v1/logs") + + def test_normalize_endpoint_with_path_prefix(self): + """Test normalization works with URLs that have path prefixes""" + otel = OpenTelemetry() + result = otel._normalize_otel_endpoint("http://collector:4318/otel/v1/traces", "logs") + # Should replace the final /traces with /logs + self.assertEqual(result, "http://collector:4318/otel/v1/logs") + + def test_normalize_endpoint_consistency_across_signals(self): + """Test that normalization is consistent for all signal types from the same base""" + otel = OpenTelemetry() + base = "http://collector:4318" + + traces_result = otel._normalize_otel_endpoint(base, "traces") + metrics_result = otel._normalize_otel_endpoint(base, "metrics") + logs_result = otel._normalize_otel_endpoint(base, "logs") + + # All should have the same base with different signal paths + self.assertEqual(traces_result, "http://collector:4318/v1/traces") + self.assertEqual(metrics_result, "http://collector:4318/v1/metrics") + self.assertEqual(logs_result, "http://collector:4318/v1/logs") + + def test_normalize_endpoint_signal_switching(self): + """Test switching between different signal types on the same endpoint""" + otel = OpenTelemetry() + + # Start with traces + endpoint = "http://collector:4318/v1/traces" + + # Switch to metrics + metrics = otel._normalize_otel_endpoint(endpoint, "metrics") + self.assertEqual(metrics, "http://collector:4318/v1/metrics") + + # Switch to logs + logs = otel._normalize_otel_endpoint(metrics, "logs") + self.assertEqual(logs, "http://collector:4318/v1/logs") + + # Switch back to traces + traces = otel._normalize_otel_endpoint(logs, "traces") + self.assertEqual(traces, "http://collector:4318/v1/traces") + + +class TestOpenTelemetryProtocolSelection(unittest.TestCase): + """Test suite for verifying correct exporter selection based on protocol""" + + def test_get_span_processor_uses_http_exporter_for_otlp_http(self): + """Test that otlp_http protocol uses OTLPSpanExporterHTTP""" + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterHTTP, + ) + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + config = OpenTelemetryConfig( + exporter="otlp_http", + endpoint="http://collector:4318" + ) + otel = OpenTelemetry(config=config) + + processor = otel._get_span_processor() + + # Verify it's a BatchSpanProcessor + self.assertIsInstance(processor, BatchSpanProcessor) + + # Verify the exporter is the HTTP variant + self.assertIsInstance(processor.span_exporter, OTLPSpanExporterHTTP) + + def test_get_span_processor_uses_grpc_exporter_for_otlp_grpc(self): + """Test that otlp_grpc protocol uses OTLPSpanExporterGRPC""" + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterGRPC, + ) + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + config = OpenTelemetryConfig( + exporter="otlp_grpc", + endpoint="http://collector:4317" + ) + otel = OpenTelemetry(config=config) + + processor = otel._get_span_processor() + + # Verify it's a BatchSpanProcessor + self.assertIsInstance(processor, BatchSpanProcessor) + + # Verify the exporter is the gRPC variant + self.assertIsInstance(processor.span_exporter, OTLPSpanExporterGRPC) + + def test_get_span_processor_uses_grpc_exporter_for_grpc_alias(self): + """Test that 'grpc' protocol alias uses OTLPSpanExporterGRPC""" + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterGRPC, + ) + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + config = OpenTelemetryConfig( + exporter="grpc", + endpoint="http://collector:4317" + ) + otel = OpenTelemetry(config=config) + + processor = otel._get_span_processor() + + # Verify it's a BatchSpanProcessor + self.assertIsInstance(processor, BatchSpanProcessor) + + # Verify the exporter is the gRPC variant + self.assertIsInstance(processor.span_exporter, OTLPSpanExporterGRPC) + + def test_get_span_processor_uses_http_exporter_for_http_protobuf(self): + """Test that http/protobuf protocol uses OTLPSpanExporterHTTP""" + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterHTTP, + ) + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + config = OpenTelemetryConfig( + exporter="http/protobuf", + endpoint="http://collector:4318" + ) + otel = OpenTelemetry(config=config) + + processor = otel._get_span_processor() + + # Verify it's a BatchSpanProcessor + self.assertIsInstance(processor, BatchSpanProcessor) + + # Verify the exporter is the HTTP variant + self.assertIsInstance(processor.span_exporter, OTLPSpanExporterHTTP) + + def test_get_span_processor_uses_console_exporter_for_console(self): + """Test that console protocol uses ConsoleSpanExporter""" + from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, + ) + + config = OpenTelemetryConfig(exporter="console") + otel = OpenTelemetry(config=config) + + processor = otel._get_span_processor() + + # Verify it's a BatchSpanProcessor + self.assertIsInstance(processor, BatchSpanProcessor) + + # Verify the exporter is the console variant + self.assertIsInstance(processor.span_exporter, ConsoleSpanExporter) + + def test_get_log_exporter_uses_http_exporter_for_otlp_http(self): + """Test that otlp_http protocol uses HTTP OTLPLogExporter""" + from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter + + config = OpenTelemetryConfig( + exporter="otlp_http", + endpoint="http://collector:4318", + enable_events=True + ) + otel = OpenTelemetry(config=config) + + exporter = otel._get_log_exporter() + + # Verify the exporter is the HTTP variant + self.assertIsInstance(exporter, OTLPLogExporter) + + # Check that it's from the http module by checking the module name + self.assertIn('http', exporter.__class__.__module__) + + def test_get_log_exporter_uses_grpc_exporter_for_otlp_grpc(self): + """Test that otlp_grpc protocol uses gRPC OTLPLogExporter""" + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter + + config = OpenTelemetryConfig( + exporter="otlp_grpc", + endpoint="http://collector:4317", + enable_events=True + ) + otel = OpenTelemetry(config=config) + + exporter = otel._get_log_exporter() + + # Verify the exporter is the gRPC variant + self.assertIsInstance(exporter, OTLPLogExporter) + + # Check that it's from the grpc module by checking the module name + self.assertIn('grpc', exporter.__class__.__module__) + + def test_get_log_exporter_uses_grpc_exporter_for_grpc_alias(self): + """Test that 'grpc' protocol alias uses gRPC OTLPLogExporter""" + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter + + config = OpenTelemetryConfig( + exporter="grpc", + endpoint="http://collector:4317", + enable_events=True + ) + otel = OpenTelemetry(config=config) + + exporter = otel._get_log_exporter() + + # Verify the exporter is the gRPC variant + self.assertIsInstance(exporter, OTLPLogExporter) + + # Check that it's from the grpc module by checking the module name + self.assertIn('grpc', exporter.__class__.__module__) + + def test_get_log_exporter_uses_console_exporter_for_console(self): + """Test that console protocol uses ConsoleLogExporter""" + from opentelemetry.sdk._logs.export import ConsoleLogExporter + + config = OpenTelemetryConfig( + exporter="console", + enable_events=True + ) + otel = OpenTelemetry(config=config) + + exporter = otel._get_log_exporter() + + # Verify the exporter is the console variant + self.assertIsInstance(exporter, ConsoleLogExporter) + + def test_get_log_exporter_defaults_to_console_for_unknown_protocol(self): + """Test that unknown protocol defaults to ConsoleLogExporter with warning""" + from opentelemetry.sdk._logs.export import ConsoleLogExporter + + config = OpenTelemetryConfig( + exporter="unknown_protocol", + enable_events=True + ) + otel = OpenTelemetry(config=config) + + with patch('litellm._logging.verbose_logger.warning') as mock_warning: + exporter = otel._get_log_exporter() + + # Verify the exporter defaults to console + self.assertIsInstance(exporter, ConsoleLogExporter) + + # Verify a warning was logged + mock_warning.assert_called_once() + args = mock_warning.call_args[0] + self.assertIn("Unknown log exporter", args[0]) + self.assertIn("unknown_protocol", args[1]) + + @patch.dict(os.environ, {"OTEL_EXPORTER": "otlp_http", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318"}, clear=False) + def test_protocol_selection_from_environment_http(self): + """Test that protocol selection works correctly from environment variables for HTTP""" + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterHTTP, + ) + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + config = OpenTelemetryConfig.from_env() + otel = OpenTelemetry(config=config) + + processor = otel._get_span_processor() + + # Verify the HTTP exporter is used + self.assertIsInstance(processor, BatchSpanProcessor) + self.assertIsInstance(processor.span_exporter, OTLPSpanExporterHTTP) + + @patch.dict(os.environ, {"OTEL_EXPORTER": "otlp_grpc", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4317"}, clear=False) + def test_protocol_selection_from_environment_grpc(self): + """Test that protocol selection works correctly from environment variables for gRPC""" + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterGRPC, + ) + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + config = OpenTelemetryConfig.from_env() + otel = OpenTelemetry(config=config) + + processor = otel._get_span_processor() + + # Verify the gRPC exporter is used + self.assertIsInstance(processor, BatchSpanProcessor) + self.assertIsInstance(processor.span_exporter, OTLPSpanExporterGRPC) + + def test_http_exporter_endpoint_normalization_for_traces(self): + """Test that HTTP trace exporter gets properly normalized endpoint""" + config = OpenTelemetryConfig( + exporter="otlp_http", + endpoint="http://collector:4318" + ) + otel = OpenTelemetry(config=config) + + processor = otel._get_span_processor() + + # Verify the endpoint was normalized to include /v1/traces + # Access the private _endpoint attribute if available + if hasattr(processor.span_exporter, '_endpoint'): + self.assertEqual(processor.span_exporter._endpoint, "http://collector:4318/v1/traces") # type: ignore[attr-defined] + + def test_grpc_exporter_endpoint_normalization_for_traces(self): + """Test that gRPC trace exporter gets properly normalized endpoint""" + config = OpenTelemetryConfig( + exporter="otlp_grpc", + endpoint="http://collector:4317" + ) + otel = OpenTelemetry(config=config) + + processor = otel._get_span_processor() + + # Verify the endpoint was normalized to include /v1/traces + # Note: gRPC exporters strip the http:// prefix, so we check for the normalized path + if hasattr(processor.span_exporter, '_endpoint'): + # gRPC exporter strips http:// prefix + self.assertIn('collector:4317', processor.span_exporter._endpoint) # type: ignore[attr-defined] + # The endpoint should have been normalized with /v1/traces before being passed to gRPC exporter + # We verify this by checking the normalization function was called correctly + normalized = otel._normalize_otel_endpoint("http://collector:4317", "traces") + self.assertEqual(normalized, "http://collector:4317/v1/traces") + + def test_http_log_exporter_endpoint_normalization_for_logs(self): + """Test that HTTP log exporter gets properly normalized endpoint""" + config = OpenTelemetryConfig( + exporter="otlp_http", + endpoint="http://collector:4318/v1/traces", + enable_events=True + ) + otel = OpenTelemetry(config=config) + + exporter = otel._get_log_exporter() + + # Verify the endpoint was normalized to /v1/logs (not /v1/traces) + # Access the private _endpoint attribute if available + if hasattr(exporter, '_endpoint'): + self.assertEqual(exporter._endpoint, "http://collector:4318/v1/logs") # type: ignore[attr-defined] + + def test_grpc_log_exporter_endpoint_normalization_for_logs(self): + """Test that gRPC log exporter gets properly normalized endpoint""" + config = OpenTelemetryConfig( + exporter="otlp_grpc", + endpoint="http://collector:4317/v1/traces", + enable_events=True + ) + otel = OpenTelemetry(config=config) + + exporter = otel._get_log_exporter() + + # Verify the endpoint was normalized to /v1/logs (not /v1/traces) + # Note: gRPC exporters strip the http:// prefix, so we check for the normalized path + if hasattr(exporter, '_endpoint'): + # gRPC exporter strips http:// prefix + self.assertIn('collector:4317', exporter._endpoint) # type: ignore[attr-defined] + # The endpoint should have been normalized with /v1/logs before being passed to gRPC exporter + # We verify this by checking the normalization function was called correctly + normalized = otel._normalize_otel_endpoint("http://collector:4317/v1/traces", "logs") + self.assertEqual(normalized, "http://collector:4317/v1/logs") diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py index 45b8e8faa70..2abe1587766 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py @@ -157,6 +157,70 @@ def test_get_cost_for_gemini_web_search(model): assert cost > 0.0 +@pytest.mark.parametrize( + "model,custom_llm_provider", + [ + ("vertex_ai/gemini-2.5-flash", "vertex_ai"), + ("gemini-2.5-flash", "vertex_ai"), + ], +) +def test_get_cost_for_vertex_ai_gemini_web_search(model, custom_llm_provider): + """ + Test that Vertex AI Gemini web search costs are tracked when passing + a ModelResponse with usage.prompt_tokens_details.web_search_requests. + + This tests the fix for: https://github.com/BerriAI/litellm/issues/XXXXX + + The issue: When a ModelResponse is passed, the detection logic only checks + for url_citation annotations, not usage.prompt_tokens_details.web_search_requests. + This causes Vertex AI grounding costs to not be tracked. + """ + from litellm.types.utils import PromptTokensDetailsWrapper, Usage, Choices, Message + + # Create a realistic ModelResponse like what Vertex AI returns + response = ModelResponse( + id="test-id", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message( + content="Test response with grounding", + role="assistant" + ) + ) + ], + created=1234567890, + model=model, + object="chat.completion", + system_fingerprint=None, + ) + + # Add usage with web_search_requests (how Vertex AI indicates grounding was used) + usage = Usage( + prompt_tokens=11, + completion_tokens=100, + total_tokens=111, + prompt_tokens_details=PromptTokensDetailsWrapper( + text_tokens=11, + web_search_requests=1 # This should trigger grounding cost + ) + ) + response.usage = usage + + # Calculate cost - should include grounding cost + cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools( + model=model, + usage=usage, + response_object=response, # Pass the ModelResponse + custom_llm_provider=custom_llm_provider, + standard_built_in_tools_params=None, + ) + + # Vertex AI charges $0.035 per grounded request + assert cost == 0.035, f"Expected $0.035 grounding cost, got ${cost}" + + def test_azure_assistant_features_integrated_cost_tracking(): """ Test integrated cost tracking for Azure assistant features. diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 3ab3455d011..aae79532ed7 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -64,6 +64,80 @@ def test_sentry_sample_rate(): del os.environ["SENTRY_API_SAMPLE_RATE"] +def test_sentry_environment(): + """Test that SENTRY_ENVIRONMENT is properly handled during Sentry initialization""" + existing_environment = os.getenv("SENTRY_ENVIRONMENT") + existing_dsn = os.getenv("SENTRY_DSN") + + # Create mock sentry_sdk module + mock_event_scrubber_instance = MagicMock() + mock_event_scrubber_cls = MagicMock(return_value=mock_event_scrubber_instance) + + mock_scrubber_module = MagicMock() + mock_scrubber_module.EventScrubber = mock_event_scrubber_cls + + mock_sentry_sdk = MagicMock() + mock_sentry_sdk.scrubber = mock_scrubber_module + mock_init = MagicMock() + mock_sentry_sdk.init = mock_init + + # Inject mocks into sys.modules + sys.modules["sentry_sdk"] = mock_sentry_sdk + sys.modules["sentry_sdk.scrubber"] = mock_scrubber_module + + try: + # Set a mock DSN to allow Sentry initialization + os.environ["SENTRY_DSN"] = "https://test@sentry.io/123456" + + # Test with default value (no environment set) + if existing_environment: + del os.environ["SENTRY_ENVIRONMENT"] + + mock_init.reset_mock() + set_callbacks(["sentry"]) + # Check that init was called with default environment "production" + mock_init.assert_called_once() + call_kwargs = mock_init.call_args[1] + assert call_kwargs["environment"] == "production" + + # Test with custom environment value + os.environ["SENTRY_ENVIRONMENT"] = "development" + + mock_init.reset_mock() + set_callbacks(["sentry"]) + # Check that init was called with custom environment "development" + mock_init.assert_called_once() + call_kwargs = mock_init.call_args[1] + assert call_kwargs["environment"] == "development" + + # Test with staging environment + os.environ["SENTRY_ENVIRONMENT"] = "staging" + + mock_init.reset_mock() + set_callbacks(["sentry"]) + # Check that init was called with custom environment "staging" + mock_init.assert_called_once() + call_kwargs = mock_init.call_args[1] + assert call_kwargs["environment"] == "staging" + + except Exception as e: + print(f"Error: {e}") + raise + finally: + # Restore the original environment variables + if existing_environment: + os.environ["SENTRY_ENVIRONMENT"] = existing_environment + else: + if "SENTRY_ENVIRONMENT" in os.environ: + del os.environ["SENTRY_ENVIRONMENT"] + + if existing_dsn: + os.environ["SENTRY_DSN"] = existing_dsn + else: + if "SENTRY_DSN" in os.environ: + del os.environ["SENTRY_DSN"] + + def test_use_custom_pricing_for_model(): from litellm.litellm_core_utils.litellm_logging import use_custom_pricing_for_model @@ -411,7 +485,7 @@ async def test_e2e_generate_cold_storage_object_key_successful(): response_id = "chatcmpl-test-12345" team_alias = "test-team" - with patch("litellm.configured_cold_storage_logger", return_value="s3"), \ + with patch("litellm.cold_storage_custom_logger", return_value="s3"), \ patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key: # Mock the S3 object key generation to return a predictable result @@ -456,7 +530,7 @@ async def test_e2e_generate_cold_storage_object_key_with_custom_logger_s3_path() mock_custom_logger = MagicMock() mock_custom_logger.s3_path = "storage" - with patch("litellm.configured_cold_storage_logger", "s3_v2"), \ + with patch("litellm.cold_storage_custom_logger", "s3_v2"), \ patch("litellm.logging_callback_manager.get_active_custom_logger_for_callback_name") as mock_get_logger, \ patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key: @@ -503,7 +577,7 @@ async def test_e2e_generate_cold_storage_object_key_with_logger_no_s3_path(): mock_custom_logger = MagicMock() mock_custom_logger.s3_path = None # or could be missing attribute - with patch("litellm.configured_cold_storage_logger", "s3_v2"), \ + with patch("litellm.cold_storage_custom_logger", "s3_v2"), \ patch("litellm.logging_callback_manager.get_active_custom_logger_for_callback_name") as mock_get_logger, \ patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key: @@ -546,7 +620,7 @@ async def test_e2e_generate_cold_storage_object_key_not_configured(): team_alias = "another-team" # Use patch to ensure test isolation - with patch.object(litellm, 'configured_cold_storage_logger', None): + with patch.object(litellm, 'cold_storage_custom_logger', None): # Call the function result = StandardLoggingPayloadSetup._generate_cold_storage_object_key( start_time=start_time, diff --git a/tests/test_litellm/llms/azure/test_azure_common_utils.py b/tests/test_litellm/llms/azure/test_azure_common_utils.py index bcdcc71ee31..582c7db8187 100644 --- a/tests/test_litellm/llms/azure/test_azure_common_utils.py +++ b/tests/test_litellm/llms/azure/test_azure_common_utils.py @@ -437,6 +437,7 @@ def test_select_azure_base_url_called(setup_mocks): "agenerate_content", "allm_passthrough_route", "llm_passthrough_route", + "asearch", ] ], ) diff --git a/tests/test_litellm/llms/azure/text_to_speech/test_azure_tts_transformation.py b/tests/test_litellm/llms/azure/text_to_speech/test_azure_tts_transformation.py index 25fbcbdefb1..ed3c785c1fa 100644 --- a/tests/test_litellm/llms/azure/text_to_speech/test_azure_tts_transformation.py +++ b/tests/test_litellm/llms/azure/text_to_speech/test_azure_tts_transformation.py @@ -19,30 +19,32 @@ def test_map_openai_params_voice_mapping(azure_tts_config: AzureAVATextToSpeechC """ Test mapping OpenAI voice to Azure AVA voice """ - optional_params = {"voice": "alloy"} + optional_params = {} - mapped = azure_tts_config.map_openai_params( + mapped_voice, mapped_params = azure_tts_config.map_openai_params( model="azure-tts", optional_params=optional_params, + voice="alloy", drop_params=False ) - assert mapped["voice"] == "en-US-JennyNeural" + assert mapped_voice == "en-US-JennyNeural" def test_map_openai_params_custom_azure_voice(azure_tts_config: AzureAVATextToSpeechConfig): """ Test using custom Azure voice directly """ - optional_params = {"voice": "en-GB-RyanNeural"} + optional_params = {} - mapped = azure_tts_config.map_openai_params( + mapped_voice, mapped_params = azure_tts_config.map_openai_params( model="azure-tts", optional_params=optional_params, + voice="en-GB-RyanNeural", drop_params=False ) - assert mapped["voice"] == "en-GB-RyanNeural" + assert mapped_voice == "en-GB-RyanNeural" def test_map_openai_params_response_format(azure_tts_config: AzureAVATextToSpeechConfig): @@ -51,13 +53,13 @@ def test_map_openai_params_response_format(azure_tts_config: AzureAVATextToSpeec """ optional_params = {"response_format": "mp3"} - mapped = azure_tts_config.map_openai_params( + mapped_voice, mapped_params = azure_tts_config.map_openai_params( model="azure-tts", optional_params=optional_params, drop_params=False ) - assert mapped["output_format"] == "audio-24khz-48kbitrate-mono-mp3" + assert mapped_params["output_format"] == "audio-24khz-48kbitrate-mono-mp3" def test_map_openai_params_default_format(azure_tts_config: AzureAVATextToSpeechConfig): @@ -66,13 +68,13 @@ def test_map_openai_params_default_format(azure_tts_config: AzureAVATextToSpeech """ optional_params = {} - mapped = azure_tts_config.map_openai_params( + mapped_voice, mapped_params = azure_tts_config.map_openai_params( model="azure-tts", optional_params=optional_params, drop_params=False ) - assert mapped["output_format"] == "audio-24khz-48kbitrate-mono-mp3" + assert mapped_params["output_format"] == "audio-24khz-48kbitrate-mono-mp3" def test_map_openai_params_speed(azure_tts_config: AzureAVATextToSpeechConfig): @@ -81,14 +83,14 @@ def test_map_openai_params_speed(azure_tts_config: AzureAVATextToSpeechConfig): """ optional_params = {"speed": 1.5} - mapped = azure_tts_config.map_openai_params( + mapped_voice, mapped_params = azure_tts_config.map_openai_params( model="azure-tts", optional_params=optional_params, drop_params=False ) # Speed 1.5 should map to +50% - assert mapped["rate"] == "+50%" + assert mapped_params["rate"] == "+50%" def test_map_openai_params_slow_speed(azure_tts_config: AzureAVATextToSpeechConfig): @@ -97,14 +99,14 @@ def test_map_openai_params_slow_speed(azure_tts_config: AzureAVATextToSpeechConf """ optional_params = {"speed": 0.5} - mapped = azure_tts_config.map_openai_params( + mapped_voice, mapped_params = azure_tts_config.map_openai_params( model="azure-tts", optional_params=optional_params, drop_params=False ) # Speed 0.5 should map to -50% - assert mapped["rate"] == "-50%" + assert mapped_params["rate"] == "-50%" # Tests for get_complete_url @@ -282,3 +284,273 @@ def test_transform_text_to_speech_response(azure_tts_config: AzureAVATextToSpeec from litellm.types.llms.openai import HttpxBinaryResponseContent assert isinstance(result, HttpxBinaryResponseContent) + +# Tests for helper methods +def test_build_express_as_element_with_style(azure_tts_config: AzureAVATextToSpeechConfig): + """ + Test _build_express_as_element helper with style only + """ + result = azure_tts_config._build_express_as_element( + content="Test", + style="cheerful" + ) + + assert result == "Test" + + +def test_build_express_as_element_with_all_attrs(azure_tts_config: AzureAVATextToSpeechConfig): + """ + Test _build_express_as_element helper with all attributes + """ + result = azure_tts_config._build_express_as_element( + content="Test", + style="cheerful", + styledegree="2", + role="SeniorFemale" + ) + + assert "Test" in result + assert "" in result + + +def test_build_express_as_element_no_attrs(azure_tts_config: AzureAVATextToSpeechConfig): + """ + Test _build_express_as_element helper returns content unchanged when no attrs + """ + content = "Test" + result = azure_tts_config._build_express_as_element(content=content) + + assert result == content + assert "" in ssml + assert "" in ssml + + # Should still include the content + assert "Hello world" in ssml + assert "en-US-AriaNeural" in ssml + + +def test_transform_text_to_speech_request_with_style_degree_role(azure_tts_config: AzureAVATextToSpeechConfig): + """ + Test SSML generation with style, styledegree, and role parameters + """ + result = azure_tts_config.transform_text_to_speech_request( + model="azure-tts", + input="Test message", + voice="en-US-AriaNeural", + optional_params={ + "voice": "en-US-AriaNeural", + "style": "cheerful", + "styledegree": "2", + "role": "SeniorFemale" + }, + litellm_params={}, + headers={} + ) + + ssml = result["ssml_body"] + + # Should include mstts namespace + assert "xmlns:mstts='https://www.w3.org/2001/mstts'" in ssml + + # Should include mstts:express-as with all attributes + assert "" in ssml + + +def test_transform_text_to_speech_request_without_style(azure_tts_config: AzureAVATextToSpeechConfig): + """ + Test that SSML without style does not include mstts namespace or express-as + """ + result = azure_tts_config.transform_text_to_speech_request( + model="azure-tts", + input="Hello world", + voice="en-US-AriaNeural", + optional_params={"voice": "en-US-AriaNeural"}, + litellm_params={}, + headers={} + ) + + ssml = result["ssml_body"] + + # Should NOT include mstts namespace + assert "xmlns:mstts" not in ssml + + # Should NOT include mstts:express-as + assert "" in ssml + diff --git a/tests/test_litellm/llms/cohere/embed/test_v1_transformation.py b/tests/test_litellm/llms/cohere/embed/test_v1_transformation.py new file mode 100644 index 00000000000..06ca8b5eeff --- /dev/null +++ b/tests/test_litellm/llms/cohere/embed/test_v1_transformation.py @@ -0,0 +1,225 @@ +import os +import sys +from unittest.mock import MagicMock + +sys.path.insert( + 0, os.path.abspath("../../../../..") +) # Adds the parent directory to the system path + +from litellm.llms.cohere.embed.v1_transformation import CohereEmbeddingConfig +from litellm.types.utils import EmbeddingResponse + + +class TestCohereEmbeddingV1Transform: + def setup_method(self): + self.config = CohereEmbeddingConfig() + self.model = "embed-english-v3.0" + self.logging_obj = MagicMock() + self.encoding = MagicMock() + # Mock the encoding to return a fixed token count + self.encoding.encode = MagicMock(return_value=[1, 2, 3, 4, 5]) + + def test_transform_response_regular_embeddings(self): + """Test that regular embeddings are correctly transformed""" + # Mock httpx.Response + mock_response = MagicMock() + response_json = { + "embeddings": [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ], + "meta": { + "billed_units": { + "input_tokens": 10 + } + } + } + mock_response.json = MagicMock(return_value=response_json) + + input_data = ["test text 1", "test text 2"] + data = {"texts": input_data, "input_type": "search_query"} + model_response = EmbeddingResponse() + + result = self.config._transform_response( + response=mock_response, + api_key="test-api-key", + logging_obj=self.logging_obj, + data=data, + model_response=model_response, + model=self.model, + encoding=self.encoding, + input=input_data, + ) + + # Verify the response structure + assert result.object == "list" + assert result.model == self.model + assert len(result.data) == 2 + + # Verify each embedding object + assert result.data[0]["object"] == "embedding" + assert result.data[0]["index"] == 0 + assert result.data[0]["embedding"] == [0.1, 0.2, 0.3] + assert "type" not in result.data[0] + + assert result.data[1]["object"] == "embedding" + assert result.data[1]["index"] == 1 + assert result.data[1]["embedding"] == [0.4, 0.5, 0.6] + assert "type" not in result.data[1] + + # Verify usage + assert result.usage is not None + assert result.usage.prompt_tokens == 10 + assert result.usage.total_tokens == 10 + assert result.usage.completion_tokens == 0 + + def test_transform_response_embeddings_by_type(self): + """Test that embeddings_by_type are correctly transformed""" + # Mock httpx.Response + mock_response = MagicMock() + response_json = { + "response_type": "embeddings_by_type", + "embeddings": { + "float": [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ], + "int8": [ + [1, 2, 3], + [4, 5, 6], + ], + }, + "meta": { + "billed_units": { + "input_tokens": 10 + } + } + } + mock_response.json = MagicMock(return_value=response_json) + + input_data = ["test text 1", "test text 2"] + data = {"texts": input_data, "input_type": "search_query", "embedding_types": ["float", "int8"]} + model_response = EmbeddingResponse() + + result = self.config._transform_response( + response=mock_response, + api_key="test-api-key", + logging_obj=self.logging_obj, + data=data, + model_response=model_response, + model=self.model, + encoding=self.encoding, + input=input_data, + ) + + # Verify the response structure + assert result.object == "list" + assert result.model == self.model + assert len(result.data) == 4 # 2 texts * 2 embedding types + + # Verify float embeddings + assert result.data[0]["object"] == "embedding" + assert result.data[0]["index"] == 0 + assert result.data[0]["embedding"] == [0.1, 0.2, 0.3] + assert result.data[0]["type"] == "float" + + assert result.data[1]["object"] == "embedding" + assert result.data[1]["index"] == 1 + assert result.data[1]["embedding"] == [0.4, 0.5, 0.6] + assert result.data[1]["type"] == "float" + + # Verify int8 embeddings + assert result.data[2]["object"] == "embedding" + assert result.data[2]["index"] == 0 + assert result.data[2]["embedding"] == [1, 2, 3] + assert result.data[2]["type"] == "int8" + + assert result.data[3]["object"] == "embedding" + assert result.data[3]["index"] == 1 + assert result.data[3]["embedding"] == [4, 5, 6] + assert result.data[3]["type"] == "int8" + + # Verify usage + assert result.usage is not None + assert result.usage.prompt_tokens == 10 + assert result.usage.total_tokens == 10 + assert result.usage.completion_tokens == 0 + + def test_transform_response_with_image_tokens(self): + """Test that image token billing is correctly handled""" + # Mock httpx.Response + mock_response = MagicMock() + response_json = { + "embeddings": [ + [0.1, 0.2, 0.3], + ], + "meta": { + "billed_units": { + "input_tokens": 5, + "images": 100 + } + } + } + mock_response.json = MagicMock(return_value=response_json) + + input_data = ["test image"] + data = {"images": input_data, "input_type": "image"} + model_response = EmbeddingResponse() + + result = self.config._transform_response( + response=mock_response, + api_key="test-api-key", + logging_obj=self.logging_obj, + data=data, + model_response=model_response, + model=self.model, + encoding=self.encoding, + input=input_data, + ) + + # Verify usage includes both text and image tokens + assert result.usage is not None + assert result.usage.prompt_tokens == 105 # 5 text + 100 image + assert result.usage.total_tokens == 105 + assert result.usage.completion_tokens == 0 + assert result.usage.prompt_tokens_details is not None + assert result.usage.prompt_tokens_details.text_tokens == 5 + assert result.usage.prompt_tokens_details.image_tokens == 100 + + def test_transform_response_fallback_token_counting(self): + """Test that token counting falls back to encoding when billed_units not present""" + # Mock httpx.Response + mock_response = MagicMock() + response_json = { + "embeddings": [ + [0.1, 0.2, 0.3], + ], + "meta": {} # No billed_units + } + mock_response.json = MagicMock(return_value=response_json) + + input_data = ["test text"] + data = {"texts": input_data, "input_type": "search_query"} + model_response = EmbeddingResponse() + + result = self.config._transform_response( + response=mock_response, + api_key="test-api-key", + logging_obj=self.logging_obj, + data=data, + model_response=model_response, + model=self.model, + encoding=self.encoding, + input=input_data, + ) + + # Verify usage uses encoding (mocked to return 5 tokens) + assert result.usage is not None + assert result.usage.prompt_tokens == 5 + assert result.usage.total_tokens == 5 + assert result.usage.completion_tokens == 0 + assert result.usage.prompt_tokens_details is None + + # Verify encoding was called + self.encoding.encode.assert_called() + diff --git a/tests/test_litellm/llms/ollama/test_ollama_completion_transformation.py b/tests/test_litellm/llms/ollama/test_ollama_completion_transformation.py index 39386fe4fd2..5f448e06ab0 100644 --- a/tests/test_litellm/llms/ollama/test_ollama_completion_transformation.py +++ b/tests/test_litellm/llms/ollama/test_ollama_completion_transformation.py @@ -459,6 +459,28 @@ class TestOllamaTextCompletionResponseIterator: assert result.choices and result.choices[0].delta is not None assert result.choices[0].delta.content == "Hello world" assert getattr(result.choices[0].delta, "reasoning_content", None) is None + + def test_chunk_parser_empty_response_without_thinking(self): + """Test that empty response chunks without thinking still work.""" + iterator = OllamaTextCompletionResponseIterator( + streaming_response=iter([]), sync_stream=True, json_mode=False + ) + + # Test empty response chunk without thinking + empty_response_chunk = { + "model": "qwen3:4b", + "created_at": "2025-10-16T11:27:14.82881Z", + "response": "", + "done": False, + } + + result = iterator.chunk_parser(empty_response_chunk) + + # Updated to handle ModelResponseStream return type + assert isinstance(result, ModelResponseStream) + assert result.choices and result.choices[0].delta is not None + assert result.choices[0].delta.content == None + assert getattr(result.choices[0].delta, "reasoning_content", None) is "" def test_chunk_parser_done_chunk(self): """Test that done chunks work correctly.""" 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 new file mode 100644 index 00000000000..afa7627952e --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -0,0 +1,228 @@ +"""Tests for MCP OAuth discoverable endpoints""" +import pytest +from unittest.mock import MagicMock, patch + + +@pytest.mark.asyncio +async def test_authorize_endpoint_includes_response_type(): + """Test that authorize endpoint includes response_type=code parameter (fixes #15684)""" + try: + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + authorize, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + from litellm.proxy._types import MCPTransport + from fastapi import Request + except ImportError: + pytest.skip("MCP discoverable endpoints not available") + + # Clear registry + global_mcp_server_manager.registry.clear() + + # Create mock OAuth2 server + oauth2_server = MCPServer( + server_id="test_oauth_server", + name="test_oauth", + server_name="test_oauth", + alias="test_oauth", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="test_client_id", + client_secret="test_client_secret", + authorization_url="https://provider.com/oauth/authorize", + token_url="https://provider.com/oauth/token", + scopes=["read", "write"], + ) + global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server + + # Mock request + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + + # Mock the encryption functions to avoid needing a signing key + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.encrypt_value_helper" + ) as mock_encrypt: + mock_encrypt.return_value = "mocked_encrypted_state" + + # Call authorize endpoint + response = await authorize( + request=mock_request, + client_id="test_oauth", + redirect_uri="https://client.example.com/callback", + state="test_state", + ) + + # Verify response is a redirect + assert response.status_code == 307 # FastAPI RedirectResponse default + + # Verify response_type is in the redirect URL + assert "response_type=code" in response.headers["location"] + assert "https://provider.com/oauth/authorize" in response.headers["location"] + assert "client_id=test_client_id" in response.headers["location"] + assert "scope=read+write" in response.headers["location"] + + +@pytest.mark.asyncio +async def test_authorize_endpoint_forwards_pkce_parameters(): + """Test that authorize endpoint forwards PKCE parameters (code_challenge and code_challenge_method)""" + try: + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + authorize, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + from litellm.proxy._types import MCPTransport + from fastapi import Request + except ImportError: + pytest.skip("MCP discoverable endpoints not available") + + # Clear registry + global_mcp_server_manager.registry.clear() + + # Create mock OAuth2 server (simulating Google OAuth) + oauth2_server = MCPServer( + server_id="google_mcp", + name="google_mcp", + server_name="google_mcp", + alias="google_mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="669428968603-test.apps.googleusercontent.com", + client_secret="GOCSPX-test_secret", + authorization_url="https://accounts.google.com/o/oauth2/v2/auth", + token_url="https://oauth2.googleapis.com/token", + scopes=["https://www.googleapis.com/auth/drive", "openid", "email"], + ) + global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server + + # Mock request + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://litellm-proxy.example.com/" + mock_request.headers = {} + + # Mock the encryption function + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.encrypt_value_helper" + ) as mock_encrypt: + mock_encrypt.return_value = "mocked_encrypted_state_with_pkce" + + # Call authorize endpoint with PKCE parameters + response = await authorize( + request=mock_request, + client_id="google_mcp", + redirect_uri="http://localhost:60108/callback", + state="test_client_state", + code_challenge="x6YH_qgwbvOzbsHDuL1sW9gYkR9-gObUiIB5RkPwxDk", + code_challenge_method="S256", + ) + + # Verify response is a redirect + assert response.status_code == 307 + + # Verify PKCE parameters are included in the redirect URL + location = response.headers["location"] + assert "https://accounts.google.com/o/oauth2/v2/auth" in location + assert "code_challenge=x6YH_qgwbvOzbsHDuL1sW9gYkR9-gObUiIB5RkPwxDk" in location + assert "code_challenge_method=S256" in location + assert "client_id=669428968603-test.apps.googleusercontent.com" in location + assert "response_type=code" in location + + +@pytest.mark.asyncio +async def test_token_endpoint_forwards_code_verifier(): + """Test that token endpoint forwards code_verifier for PKCE flow""" + try: + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + token_endpoint, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + from litellm.proxy._types import MCPTransport + from fastapi import Request + import httpx + except ImportError: + pytest.skip("MCP discoverable endpoints not available") + + # Clear registry + global_mcp_server_manager.registry.clear() + + # Create mock OAuth2 server + oauth2_server = MCPServer( + server_id="google_mcp", + name="google_mcp", + server_name="google_mcp", + alias="google_mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="669428968603-test.apps.googleusercontent.com", + client_secret="GOCSPX-test_secret", + authorization_url="https://accounts.google.com/o/oauth2/v2/auth", + token_url="https://oauth2.googleapis.com/token", + scopes=["https://www.googleapis.com/auth/drive", "openid", "email"], + ) + global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server + + # Mock request + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://litellm-proxy.example.com/" + + # Mock httpx client response + mock_response = MagicMock() + mock_response.json.return_value = { + "access_token": "ya29.test_access_token", + "token_type": "Bearer", + "expires_in": 3599, + "scope": "openid email https://www.googleapis.com/auth/drive", + } + mock_response.raise_for_status = MagicMock() + + # Mock the async httpx client with AsyncMock for async methods + from unittest.mock import AsyncMock + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client" + ) as mock_get_client: + mock_async_client = MagicMock() + # Use AsyncMock for the async post method + mock_async_client.post = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_async_client + + # Call token endpoint with code_verifier + response = await token_endpoint( + request=mock_request, + grant_type="authorization_code", + code="4/test_authorization_code", + redirect_uri="http://localhost:60108/callback", + client_id="google_mcp", + client_secret="dummy", + code_verifier="test_code_verifier_from_client", + ) + + # Verify that the token endpoint was called with code_verifier + mock_async_client.post.assert_called_once() + call_args = mock_async_client.post.call_args + + # Check the data parameter includes code_verifier + assert call_args[1]["data"]["code_verifier"] == "test_code_verifier_from_client" + assert call_args[1]["data"]["code"] == "4/test_authorization_code" + assert call_args[1]["data"]["client_id"] == "669428968603-test.apps.googleusercontent.com" + assert call_args[1]["data"]["client_secret"] == "GOCSPX-test_secret" + assert call_args[1]["data"]["grant_type"] == "authorization_code" + + # Verify response + response_data = response.body + import json + token_data = json.loads(response_data) + assert token_data["access_token"] == "ya29.test_access_token" + assert token_data["token_type"] == "Bearer" diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 09d2cd7ca2b..baf8bcfea35 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -14,6 +14,7 @@ from unittest.mock import MagicMock import pytest from litellm.proxy.auth.user_api_key_auth import get_api_key +from litellm.proxy.auth.route_checks import RouteChecks def test_get_api_key(): @@ -56,3 +57,130 @@ def test_get_api_key_with_custom_litellm_key_header( route="", request=MagicMock(), ) == (api_key, passed_in_key) + + +def test_route_checks_is_llm_api_route(): + """Test RouteChecks.is_llm_api_route() correctly identifies LLM API routes including passthrough endpoints""" + + # Test OpenAI routes + openai_routes = [ + "/v1/chat/completions", + "/chat/completions", + "/v1/completions", + "/completions", + "/v1/embeddings", + "/embeddings", + "/v1/images/generations", + "/images/generations", + "/v1/audio/transcriptions", + "/audio/transcriptions", + "/v1/audio/speech", + "/audio/speech", + "/v1/moderations", + "/moderations", + "/v1/models", + "/models", + "/v1/rerank", + "/rerank", + "/v1/realtime", + "/realtime", + ] + + for route in openai_routes: + assert RouteChecks.is_llm_api_route(route=route), f"Route {route} should be identified as LLM API route" + + # Test Anthropic routes + anthropic_routes = [ + "/v1/messages", + "/v1/messages/count_tokens", + ] + + for route in anthropic_routes: + assert RouteChecks.is_llm_api_route(route=route), f"Route {route} should be identified as LLM API route" + + # Test passthrough routes (this is the key improvement over the old route checking) + passthrough_routes = [ + "/bedrock/v1/chat/completions", + "/vertex-ai/v1/chat/completions", + "/vertex_ai/v1/chat/completions", + "/cohere/v1/chat/completions", + "/gemini/v1/chat/completions", + "/anthropic/v1/messages", + "/langfuse/v1/chat/completions", + "/azure/v1/chat/completions", + "/openai/v1/chat/completions", + "/assemblyai/v1/transcript", + "/eu.assemblyai/v1/transcript", + "/vllm/v1/chat/completions", + "/mistral/v1/chat/completions", + ] + + for route in passthrough_routes: + assert RouteChecks.is_llm_api_route(route=route), f"Route {route} should be identified as LLM API route" + + # Test MCP routes + mcp_routes = [ + "/mcp", + "/mcp/", + "/mcp/test", + ] + + for route in mcp_routes: + assert RouteChecks.is_llm_api_route(route=route), f"Route {route} should be identified as LLM API route" + + # Test routes with placeholders + placeholder_routes = [ + "/v1/threads/thread_49EIN5QF32s4mH20M7GFKdlZ", + "/threads/thread_49EIN5QF32s4mH20M7GFKdlZ", + "/v1/assistants/assistant_123", + "/assistants/assistant_123", + "/v1/files/file_123", + "/files/file_123", + "/v1/batches/batch_123", + "/batches/batch_123", + ] + + for route in placeholder_routes: + assert RouteChecks.is_llm_api_route(route=route), f"Route {route} should be identified as LLM API route" + + # Test Azure OpenAI routes + azure_routes = [ + "/openai/deployments/gpt-4/chat/completions", + "/openai/deployments/gpt-3.5-turbo/completions", + "/engines/gpt-4/chat/completions", + "/engines/gpt-3.5-turbo/completions", + ] + + for route in azure_routes: + assert RouteChecks.is_llm_api_route(route=route), f"Route {route} should be identified as LLM API route" + + # Test non-LLM routes (should return False) + non_llm_routes = [ + "/health", + "/metrics", + "/key/list", + "/team/list", + "/user/list", + "/config", + "/routes", + "/", + "/admin/settings", + "/logs", + "/debug", + "/test", + ] + + for route in non_llm_routes: + assert not RouteChecks.is_llm_api_route(route=route), f"Route {route} should NOT be identified as LLM API route" + + # Test invalid inputs + invalid_inputs = [ + None, + 123, + [], + {}, + "", + ] + + for invalid_input in invalid_inputs: + assert not RouteChecks.is_llm_api_route(route=invalid_input), f"Invalid input {invalid_input} should return False" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_grayswan.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_grayswan.py new file mode 100644 index 00000000000..d6bd0a251b5 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_grayswan.py @@ -0,0 +1,123 @@ +import pytest +from fastapi import HTTPException + +from litellm.proxy.guardrails.guardrail_hooks.grayswan.grayswan import ( + GraySwanGuardrail, + GraySwanGuardrailAPIError, +) +from litellm.types.guardrails import GuardrailEventHooks + + +@pytest.fixture +def grayswan_guardrail() -> GraySwanGuardrail: + return GraySwanGuardrail( + guardrail_name="grayswan-test", + api_key="test-key", + on_flagged_action="monitor", + violation_threshold=0.5, + categories={"safety": "general policy"}, + reasoning_mode="hybrid", + policy_id="default-policy", + event_hook=GuardrailEventHooks.pre_call, + ) + + +def test_prepare_payload_uses_dynamic_overrides(grayswan_guardrail: GraySwanGuardrail) -> None: + messages = [{"role": "user", "content": "hello"}] + dynamic_body = { + "categories": {"custom": "override"}, + "policy_id": "dynamic-policy", + "reasoning_mode": "thinking", + } + + payload = grayswan_guardrail._prepare_payload(messages, dynamic_body) + + assert payload["messages"] == messages + assert payload["categories"] == {"custom": "override"} + assert payload["policy_id"] == "dynamic-policy" + assert payload["reasoning_mode"] == "thinking" + + +def test_prepare_payload_falls_back_to_guardrail_defaults(grayswan_guardrail: GraySwanGuardrail) -> None: + messages = [{"role": "user", "content": "hello"}] + + payload = grayswan_guardrail._prepare_payload(messages, {}) + + assert payload["categories"] == {"safety": "general policy"} + assert payload["policy_id"] == "default-policy" + assert payload["reasoning_mode"] == "hybrid" + + +def test_process_response_does_not_block_under_threshold(grayswan_guardrail: GraySwanGuardrail) -> None: + grayswan_guardrail._process_grayswan_response({"violation": 0.3, "violated_rules": []}) + + +def test_process_response_blocks_when_threshold_exceeded() -> None: + guardrail = GraySwanGuardrail( + guardrail_name="grayswan-block", + api_key="test-key", + on_flagged_action="block", + violation_threshold=0.2, + event_hook=GuardrailEventHooks.pre_call, + ) + + with pytest.raises(HTTPException) as exc: + guardrail._process_grayswan_response({"violation": 0.5, "violated_rules": [1]}) + + assert exc.value.status_code == 400 + assert exc.value.detail["violation"] == 0.5 + + +class _DummyResponse: + def __init__(self, payload: dict): + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return self._payload + + +class _DummyClient: + def __init__(self, payload: dict): + self.payload = payload + self.calls: list[dict] = [] + + async def post(self, *, url: str, headers: dict, json: dict, timeout: float): + self.calls.append({"url": url, "headers": headers, "json": json, "timeout": timeout}) + return _DummyResponse(self.payload) + + +@pytest.mark.asyncio +async def test_run_guardrail_posts_payload(monkeypatch, grayswan_guardrail: GraySwanGuardrail) -> None: + dummy_client = _DummyClient({"violation": 0.1}) + grayswan_guardrail.async_handler = dummy_client + + captured = {} + + def fake_process(response_json: dict) -> None: + captured["response"] = response_json + + monkeypatch.setattr(grayswan_guardrail, "_process_grayswan_response", fake_process) + + payload = {"messages": [{"role": "user", "content": "test"}]} + + await grayswan_guardrail.run_grayswan_guardrail(payload) + + assert dummy_client.calls[0]["json"] == payload + assert captured["response"] == {"violation": 0.1} + + +@pytest.mark.asyncio +async def test_run_guardrail_raises_api_error(grayswan_guardrail: GraySwanGuardrail) -> None: + class _FailingClient: + async def post(self, **_kwargs): + raise RuntimeError("boom") + + grayswan_guardrail.async_handler = _FailingClient() + + payload = {"messages": [{"role": "user", "content": "test"}]} + + with pytest.raises(GraySwanGuardrailAPIError): + await grayswan_guardrail.run_grayswan_guardrail(payload) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index ce90c1ed8ad..3431d168529 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -1693,3 +1693,108 @@ async def test_filter_endpoints_by_team_allowed_routes_partial_match(): assert len(result) == 2 assert result[0].path == "/api/openai" assert result[1].path == "/api/azure" + + +@pytest.mark.asyncio +async def test_bedrock_router_passthrough_metadata_initialization(): + """ + Test that bedrock router passthrough properly initializes metadata for hooks. + + This test verifies the fix for issue #15826 where metadata.headers and + litellm_params.proxy_server_request were missing for /bedrock passthrough + requests with router models. + + The fix ensures router bedrock models use the same common processing path + as non-router models, which properly initializes all metadata structures. + """ + from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( + handle_bedrock_passthrough_router_model, + ) + + # Mock ProxyBaseLLMRequestProcessing to verify it's used + with patch( + "litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing" + ) as mock_processing_class: + # Setup mock instance + mock_processor = MagicMock() + mock_processing_class.return_value = mock_processor + + # Mock successful response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.aread = AsyncMock(return_value=b'{"content": [{"text": "Hello"}]}') + mock_processor.base_passthrough_process_llm_request = AsyncMock( + return_value=mock_response + ) + + # Create mock request with headers + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.url = "http://localhost:4000/bedrock/model/my-model/invoke" + mock_request.headers = Headers({ + "content-type": "application/json", + "authorization": "Bearer sk-test-key", + "x-custom-header": "test-value" + }) + mock_request.query_params = QueryParams({}) + + # Create mock user API key dict with all required fields + mock_user_api_key_dict = MagicMock() + mock_user_api_key_dict.api_key = "sk-test-key" + mock_user_api_key_dict.key_alias = "test-alias" + mock_user_api_key_dict.user_id = "user-123" + mock_user_api_key_dict.team_id = "team-123" + + # Mock other required dependencies + mock_router = MagicMock() + mock_proxy_logging = MagicMock() + mock_general_settings = {} + mock_proxy_config = MagicMock() + mock_select_data_generator = MagicMock() + + request_body = { + "max_tokens": 100, + "messages": [{"role": "user", "content": "Hello"}], + "anthropic_version": "bedrock-2023-05-31" + } + + # Call the function + result = await handle_bedrock_passthrough_router_model( + model="my-bedrock-model", + endpoint="/model/my-bedrock-model/invoke", + request=mock_request, + request_body=request_body, + llm_router=mock_router, + user_api_key_dict=mock_user_api_key_dict, + proxy_logging_obj=mock_proxy_logging, + general_settings=mock_general_settings, + proxy_config=mock_proxy_config, + select_data_generator=mock_select_data_generator, + user_model=None, + user_temperature=None, + user_request_timeout=None, + user_max_tokens=None, + user_api_base=None, + version="1.0", + ) + + # Verify that ProxyBaseLLMRequestProcessing was instantiated + # This is the KEY assertion - router models now use the common processing path + mock_processing_class.assert_called_once() + + # Verify that base_passthrough_process_llm_request was called + # This proves we're using the common processing path that initializes metadata + mock_processor.base_passthrough_process_llm_request.assert_called_once() + + # Verify the call included all required parameters for proper metadata initialization + call_kwargs = mock_processor.base_passthrough_process_llm_request.call_args[1] + + # These are the critical parameters that ensure metadata is properly initialized: + assert call_kwargs["request"] == mock_request, "Request must be passed for header extraction" + assert call_kwargs["user_api_key_dict"] == mock_user_api_key_dict, "User API key dict needed for metadata" + assert call_kwargs["proxy_logging_obj"] == mock_proxy_logging, "Logging obj needed for hooks" + assert call_kwargs["llm_router"] == mock_router, "Router needed for model routing" + assert call_kwargs["model"] == "my-bedrock-model", "Model name must be passed" + + # Verify response was returned + assert result == mock_response diff --git a/tests/test_litellm/proxy/test_custom_proxy.py b/tests/test_litellm/proxy/test_custom_proxy.py index ad2cdead09e..3663183d211 100644 --- a/tests/test_litellm/proxy/test_custom_proxy.py +++ b/tests/test_litellm/proxy/test_custom_proxy.py @@ -12,6 +12,9 @@ sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path +# Set the SERVER_ROOT_PATH environment variable to match the custom mount path +os.environ["SERVER_ROOT_PATH"] = "/my-custom-path" + from litellm.proxy.proxy_server import app as litellm_app from litellm.proxy.proxy_server import proxy_startup_event diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index 6997ac65275..9d0d5e6c0f3 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -16,7 +16,7 @@ sys.path.insert( from unittest.mock import MagicMock -from litellm.proxy.utils import get_custom_url +from litellm.proxy.utils import get_custom_url, join_paths def test_get_custom_url(monkeypatch): @@ -25,7 +25,6 @@ def test_get_custom_url(monkeypatch): assert custom_url == "http://0.0.0.0:4000/litellm/ui/" - def test_proxy_only_error_true_for_llm_route(): proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache()) assert proxy_logging_obj._is_proxy_only_llm_api_error( @@ -60,8 +59,8 @@ def test_proxy_only_error_false_for_other_error_type(): def test_get_model_group_info_order(): - from litellm.proxy.proxy_server import _get_model_group_info from litellm import Router + from litellm.proxy.proxy_server import _get_model_group_info router = Router( model_list=[ @@ -89,3 +88,47 @@ def test_get_model_group_info_order(): model_groups = [m.model_group for m in model_list] assert model_groups == ["openai/tts-1", "openai/gpt-3.5-turbo"] + + +def test_join_paths_no_duplication(): + """Test that join_paths doesn't duplicate route when base_path already ends with it""" + result = join_paths( + base_path="http://0.0.0.0:4000/my-custom-path/", route="/my-custom-path" + ) + assert result == "http://0.0.0.0:4000/my-custom-path" + + +def test_join_paths_normal_join(): + """Test normal path joining""" + result = join_paths(base_path="http://0.0.0.0:4000", route="/api/v1") + assert result == "http://0.0.0.0:4000/api/v1" + + +def test_join_paths_with_trailing_slash(): + """Test path joining with trailing slash on base_path""" + result = join_paths(base_path="http://0.0.0.0:4000/", route="api/v1") + assert result == "http://0.0.0.0:4000/api/v1" + + +def test_join_paths_empty_base(): + """Test path joining with empty base_path""" + result = join_paths(base_path="", route="api/v1") + assert result == "/api/v1" + + +def test_join_paths_empty_route(): + """Test path joining with empty route""" + result = join_paths(base_path="http://0.0.0.0:4000", route="") + assert result == "http://0.0.0.0:4000" + + +def test_join_paths_both_empty(): + """Test path joining with both empty""" + result = join_paths(base_path="", route="") + assert result == "/" + + +def test_join_paths_nested_path(): + """Test path joining with nested paths""" + result = join_paths(base_path="http://0.0.0.0:4000/v1", route="chat/completions") + assert result == "http://0.0.0.0:4000/v1/chat/completions" diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 333fb36cada..a0fd1f78d8c 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -265,7 +265,10 @@ class TestLiteLLMCompletionResponsesConfig: assert len(reasoning_items) == 1, "Should have exactly one reasoning item" reasoning_item = reasoning_items[0] - assert reasoning_item.id.startswith("rs_"), f"Expected ID to start with 'rs_', got: {reasoning_item.id}" + # Note: ID auto-generation was disabled, so reasoning items may not have IDs + # Only assert ID format if an ID is present + if hasattr(reasoning_item, 'id') and reasoning_item.id: + assert reasoning_item.id.startswith("rs_"), f"Expected ID to start with 'rs_', got: {reasoning_item.id}" assert reasoning_item.status == "completed" assert reasoning_item.role == "assistant" assert len(reasoning_item.content) == 1 diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py b/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py index 637f8d449a2..1d90177cdec 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py @@ -223,7 +223,7 @@ async def test_e2e_cold_storage_successful_retrieval(): new_callable=AsyncMock, ) as mock_get_spend_logs, \ patch.object(session_handler, "COLD_STORAGE_HANDLER") as mock_cold_storage, \ - patch("litellm.configured_cold_storage_logger", return_value="s3"): + patch("litellm.cold_storage_custom_logger", return_value="s3"): # Setup mocks mock_get_spend_logs.return_value = mock_spend_logs @@ -343,7 +343,7 @@ async def test_should_check_cold_storage_for_full_payload(): # Test case 4: None request (should return True) proxy_request_none = None - with patch("litellm.configured_cold_storage_logger", return_value="s3"): + with patch("litellm.cold_storage_custom_logger", return_value="s3"): # Test case 1: Should return True for truncated content result1 = ResponsesSessionHandler._should_check_cold_storage_for_full_payload(proxy_request_with_truncated_pdf) assert result1 == True, "Should return True for proxy request with truncated PDF content" @@ -361,7 +361,7 @@ async def test_should_check_cold_storage_for_full_payload(): assert result4 == True, "Should return True for None proxy request" # Test case 5: Should return False when cold storage is not configured - with patch.object(litellm, 'configured_cold_storage_logger', None): + with patch.object(litellm, 'cold_storage_custom_logger', None): result5 = ResponsesSessionHandler._should_check_cold_storage_for_full_payload(proxy_request_with_truncated_pdf) assert result5 == False, "Should return False when cold storage is not configured, even with truncated content" diff --git a/tests/test_litellm/test_filter_out_litellm_params.py b/tests/test_litellm/test_filter_out_litellm_params.py new file mode 100644 index 00000000000..9a5bc3c4e5e --- /dev/null +++ b/tests/test_litellm/test_filter_out_litellm_params.py @@ -0,0 +1,36 @@ +""" +Test filter_out_litellm_params helper function. +""" +from litellm.utils import filter_out_litellm_params + + +def test_filter_out_litellm_params(): + """ + Test that filter_out_litellm_params removes LiteLLM internal parameters + while keeping provider-specific parameters. + """ + kwargs = { + "query": "test query", + "max_results": 10, + "shared_session": "mock_session_object", + "metadata": {"key": "value"}, + "litellm_trace_id": "trace-123", + "proxy_server_request": {"url": "http://example.com"}, + "secret_fields": {"api_key": "secret"}, + "custom_param": "should_be_kept", + } + + filtered = filter_out_litellm_params(kwargs=kwargs) + + # Provider-specific params are kept + assert filtered["query"] == "test query" + assert filtered["max_results"] == 10 + assert filtered["custom_param"] == "should_be_kept" + + # LiteLLM internal params are removed + assert "shared_session" not in filtered + assert "metadata" not in filtered + assert "litellm_trace_id" not in filtered + assert "proxy_server_request" not in filtered + assert "secret_fields" not in filtered + diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 954597dda25..870d3c40512 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -1237,3 +1237,40 @@ def test_anthropic_text_disable_url_suffix_env_var(): # Verify the api_base does not have /v1/complete appended assert actual_api_base == "https://api.example.com/custom/complete" assert not actual_api_base.endswith("/v1/complete") + + +def test_image_edit_merges_headers_and_extra_headers(): + combined_headers = { + "x-test-header-one": "value-1", + "x-test-header-two": "value-2", + } + + mock_image_edit_config = MagicMock() + mock_image_edit_config.get_supported_openai_params.return_value = set() + mock_image_edit_config.map_openai_params.side_effect = ( + lambda **kwargs: dict(kwargs["image_edit_optional_params"]) + ) + + with patch( + "litellm.images.main.ProviderConfigManager.get_provider_image_edit_config", + return_value=mock_image_edit_config, + ) as mock_config, patch( + "litellm.images.main.base_llm_http_handler.image_edit_handler", + return_value="ok", + ) as mock_handler: + response = litellm.image_edit( + image=MagicMock(name="image"), + prompt="test", + model="azure/gpt-image-1", + headers={"x-test-header-one": "value-1"}, + extra_headers={ + "x-test-header-two": "value-2", + }, + ) + + assert response == "ok" + mock_config.assert_called_once() + + handler_kwargs = mock_handler.call_args.kwargs + assert handler_kwargs["extra_headers"] == combined_headers + assert "extra_headers" not in handler_kwargs["image_edit_optional_request_params"] diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index 3ea1811549a..4709faea4bc 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -1,6 +1,8 @@ -from litellm._redis import get_redis_url_from_environment +from litellm._redis import get_redis_url_from_environment, _get_redis_cluster_kwargs, get_redis_async_client import os import pytest +from unittest.mock import MagicMock, patch +import redis.asyncio as async_redis def test_get_redis_url_from_environment_single_url(monkeypatch): """Test when REDIS_URL is directly provided""" @@ -117,3 +119,51 @@ def test_get_redis_url_from_environment_missing_port(monkeypatch): # Check the error message assert "Either 'REDIS_URL' or both 'REDIS_HOST' and 'REDIS_PORT' must be specified" in str(excinfo.value) + +def test_max_connections_in_cluster_kwargs(): + """Test that max_connections is included in Redis cluster kwargs""" + kwargs = _get_redis_cluster_kwargs() + assert "max_connections" in kwargs, "max_connections should be in available Redis cluster kwargs" + +def test_get_redis_async_client_with_connection_pool(): + """Test that connection_pool parameter is properly passed to Redis client""" + # Create a mock connection pool + mock_pool = MagicMock(spec=async_redis.BlockingConnectionPool) + + # Mock the Redis client creation + with patch('litellm._redis.async_redis.Redis') as mock_redis, \ + patch('litellm._redis._get_redis_client_logic') as mock_logic: + + # Configure mock to return basic redis kwargs + mock_logic.return_value = { + "host": "localhost", + "port": 6379, + "db": 0 + } + + # Call get_redis_async_client with connection_pool + get_redis_async_client(connection_pool=mock_pool) + + # Verify Redis was called with connection_pool in kwargs + call_kwargs = mock_redis.call_args[1] + assert "connection_pool" in call_kwargs, "connection_pool should be passed to Redis client" + assert call_kwargs["connection_pool"] == mock_pool, "connection_pool should match the provided pool" + +def test_get_redis_async_client_without_connection_pool(): + """Test that Redis client works without connection_pool parameter""" + with patch('litellm._redis.async_redis.Redis') as mock_redis, \ + patch('litellm._redis._get_redis_client_logic') as mock_logic: + + # Configure mock to return basic redis kwargs + mock_logic.return_value = { + "host": "localhost", + "port": 6379, + "db": 0 + } + + # Call get_redis_async_client without connection_pool + get_redis_async_client() + + # Verify Redis was called without connection_pool in kwargs + call_kwargs = mock_redis.call_args[1] + assert "connection_pool" not in call_kwargs, "connection_pool should not be in kwargs when not provided" diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 99338c8b75c..7221e33a5ad 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -578,6 +578,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "rerank", "responses", "ocr", + "search", ], }, "output_cost_per_audio_token": {"type": "number"}, @@ -684,8 +685,14 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "output_cost_per_token": {"type": "number"}, "cache_read_input_token_cost": {"type": "number"}, "output_cost_per_reasoning_token": {"type": "number"}, + "max_results_range": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + }, + "input_cost_per_query": {"type": "number"}, }, - "required": ["range"], "additionalProperties": False, }, }, diff --git a/tests/vector_store_tests/test_vertex_ai_search_api_vector_store.py b/tests/vector_store_tests/test_vertex_ai_search_api_vector_store.py new file mode 100644 index 00000000000..7cace338616 --- /dev/null +++ b/tests/vector_store_tests/test_vertex_ai_search_api_vector_store.py @@ -0,0 +1,194 @@ +""" +Test for Vertex AI Search API Vector Store with mocked responses +""" + +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +import litellm + + +# Mock response from actual Vertex AI Search API +MOCK_VERTEX_SEARCH_RESPONSE = { + "results": [ + { + "id": "0", + "document": { + "name": "projects/648660250433/locations/global/collections/default_collection/dataStores/litellm-docs_1761094140318/branches/0/documents/0", + "id": "0", + "derivedStructData": { + "htmlTitle": "LiteLLM - Getting Started | liteLLM", + "snippets": [ + { + "htmlSnippet": "https://github.com/BerriAI/litellm.", + "snippet": "https://github.com/BerriAI/litellm.", + } + ], + "title": "LiteLLM - Getting Started | liteLLM", + "link": "https://docs.litellm.ai/docs/", + "displayLink": "docs.litellm.ai", + }, + }, + }, + { + "id": "1", + "document": { + "name": "projects/648660250433/locations/global/collections/default_collection/dataStores/litellm-docs_1761094140318/branches/0/documents/1", + "id": "1", + "derivedStructData": { + "title": "Using Vector Stores (Knowledge Bases) | liteLLM", + "link": "https://docs.litellm.ai/docs/completion/knowledgebase", + "snippets": [ + { + "snippet": "LiteLLM integrates with vector stores, allowing your models to access your organization's data for more accurate and contextually relevant responses." + } + ], + }, + }, + }, + ], + "totalSize": 299, + "attributionToken": "mock_token", + "summary": {}, +} + + +class TestVertexAISearchAPIVectorStore: + """Test Vertex AI Search API Vector Store with mocked responses""" + + @pytest.mark.asyncio + async def test_basic_search_with_mock(self): + """Test basic vector search with mocked backend response""" + + # Mock the HTTP response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = MOCK_VERTEX_SEARCH_RESPONSE + mock_response.text = json.dumps(MOCK_VERTEX_SEARCH_RESPONSE) + + # Mock the access token method to avoid real authentication + with patch( + "litellm.llms.vertex_ai.vector_stores.search_api.transformation.VertexSearchAPIVectorStoreConfig._ensure_access_token" + ) as mock_auth: + mock_auth.return_value = ("mock_token", "test-vector-store-db") + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = mock_response + + # Make the search request + response = await litellm.vector_stores.asearch( + query="what is LiteLLM?", + vector_store_id="test-litellm-app_1761094730750", + custom_llm_provider="vertex_ai/search_api", + vertex_project="test-vector-store-db", + vertex_location="us-central1", + ) + + print("Response:", json.dumps(response, indent=2, default=str)) + + # Validate the response structure (LiteLLM standard format) + assert response is not None + assert response["object"] == "vector_store.search_results.page" + assert "data" in response + assert len(response["data"]) > 0 + assert "search_query" in response + + # Validate first result + first_result = response["data"][0] + assert "score" in first_result + assert "content" in first_result + assert "file_id" in first_result + assert "filename" in first_result + assert "attributes" in first_result + + # Validate content structure + assert len(first_result["content"]) > 0 + assert first_result["content"][0]["type"] == "text" + assert "text" in first_result["content"][0] + + # Verify the API was called + mock_post.assert_called_once() + + # Verify the URL format + call_args = mock_post.call_args + url = call_args[1]["url"] if "url" in call_args[1] else call_args[0][0] + assert "discoveryengine.googleapis.com" in url + assert "test-vector-store-db" in url + assert "test-litellm-app_1761094730750" in url + + def test_basic_search_sync_with_mock(self): + """Test basic vector search (sync) with mocked backend response""" + + # Mock the HTTP response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = MOCK_VERTEX_SEARCH_RESPONSE + mock_response.text = json.dumps(MOCK_VERTEX_SEARCH_RESPONSE) + + # Mock the access token method to avoid real authentication + with patch( + "litellm.llms.vertex_ai.vector_stores.search_api.transformation.VertexSearchAPIVectorStoreConfig._ensure_access_token" + ) as mock_auth: + mock_auth.return_value = ("mock_token", "test-vector-store-db") + + with patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post" + ) as mock_post: + mock_post.return_value = mock_response + + # Make the search request + response = litellm.vector_stores.search( + query="what is LiteLLM?", + vector_store_id="test-litellm-app_1761094730750", + custom_llm_provider="vertex_ai/search_api", + vertex_project="test-vector-store-db", + vertex_location="us-central1", + ) + + print("Response:", json.dumps(response, indent=2, default=str)) + + # Validate the response structure (LiteLLM standard format) + assert response is not None + assert response["object"] == "vector_store.search_results.page" + assert "data" in response + assert len(response["data"]) > 0 + assert "search_query" in response + + # Validate first result structure + first_result = response["data"][0] + assert "score" in first_result + assert "content" in first_result + assert "file_id" in first_result + assert "filename" in first_result + assert "attributes" in first_result + + # Validate content structure + assert len(first_result["content"]) > 0 + assert first_result["content"][0]["type"] == "text" + assert "text" in first_result["content"][0] + + # Validate attributes + assert "document_id" in first_result["attributes"] + assert "link" in first_result["attributes"] + assert "title" in first_result["attributes"] + + # Verify the API was called + mock_post.assert_called_once() + + +if __name__ == "__main__": + # Run tests + import asyncio + + test = TestVertexAISearchAPIVectorStore() + + print("Running async test...") + asyncio.run(test.test_basic_search_with_mock()) + + print("\nRunning sync test...") + test.test_basic_search_sync_with_mock() + + print("\n✅ All tests passed!") diff --git a/ui/litellm-dashboard/public/assets/logos/dataforseo.png b/ui/litellm-dashboard/public/assets/logos/dataforseo.png new file mode 100644 index 00000000000..fced13674b3 Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/dataforseo.png differ diff --git a/ui/litellm-dashboard/public/assets/logos/exa_ai.png b/ui/litellm-dashboard/public/assets/logos/exa_ai.png new file mode 100644 index 00000000000..d5512bbd9ed Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/exa_ai.png differ diff --git a/ui/litellm-dashboard/public/assets/logos/google_pse.png b/ui/litellm-dashboard/public/assets/logos/google_pse.png new file mode 100644 index 00000000000..741997b36b3 Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/google_pse.png differ diff --git a/ui/litellm-dashboard/public/assets/logos/parallel_ai.png b/ui/litellm-dashboard/public/assets/logos/parallel_ai.png new file mode 100644 index 00000000000..c877d869e8b Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/parallel_ai.png differ diff --git a/ui/litellm-dashboard/public/assets/logos/perplexity.png b/ui/litellm-dashboard/public/assets/logos/perplexity.png new file mode 100644 index 00000000000..57d55970452 Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/perplexity.png differ diff --git a/ui/litellm-dashboard/public/assets/logos/search1api.png b/ui/litellm-dashboard/public/assets/logos/search1api.png new file mode 100644 index 00000000000..e9091d3668f Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/search1api.png differ diff --git a/ui/litellm-dashboard/public/assets/logos/tavily.png b/ui/litellm-dashboard/public/assets/logos/tavily.png new file mode 100644 index 00000000000..81dcda6b0d5 Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/tavily.png differ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index 96bb9b5a4bf..06da61a3762 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -31,6 +31,7 @@ import * as React from "react"; import { useRouter, usePathname } from "next/navigation"; import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "@/utils/roles"; import UsageIndicator from "@/components/usage_indicator"; +import { serverRootPath } from "@/components/networking"; const { Sider } = Layout; @@ -56,11 +57,22 @@ interface MenuItemCfg { /** * Normalizes NEXT_PUBLIC_BASE_URL to either "/" or "/ui/" (always with a trailing slash). * Supported env values: "" or "ui/". + * Also considers the serverRootPath from the proxy config (e.g., "/my-custom-path"). */ const getBasePath = () => { const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes - return trimmed ? `/${trimmed}/` : "/"; // ensure trailing slash + const uiPath = trimmed ? `/${trimmed}/` : "/"; + + // If serverRootPath is set and not "/", prepend it to the UI path + if (serverRootPath && serverRootPath !== "/") { + // Remove trailing slash from serverRootPath and ensure uiPath has no leading slash for proper joining + const cleanServerRoot = serverRootPath.replace(/\/+$/, ""); + const cleanUiPath = uiPath.replace(/^\/+/, ""); + return `${cleanServerRoot}/${cleanUiPath}`; + } + + return uiPath; }; /** Map legacy `page` ids to real app routes (relative, no leading slash). */ @@ -134,12 +146,8 @@ const toHref = (slugOrPath: string) => { return `${base}${rel}`; }; -const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => { - const router = useRouter(); - const pathname = usePathname() || "/"; - - // ----- Menu config (unchanged labels/icons; same appearance) ----- - const menuItems: MenuItemCfg[] = [ +// ----- Menu config (unchanged labels/icons; same appearance) ----- +const menuItems: MenuItemCfg[] = [ { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, { key: "3", @@ -291,6 +299,10 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect }, ]; +const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => { + const router = useRouter(); + const pathname = usePathname() || "/"; + // ----- Filter by role without mutating originals ----- const filteredMenuItems = React.useMemo(() => { return menuItems diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index 42f96129552..8d65c0e1702 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -734,6 +734,7 @@ const ModelsAndEndpointsView: React.FC = ({ userRole={userRole} userID={userID} modelData={modelData} + premiumUser={premiumUser} /> diff --git a/ui/litellm-dashboard/src/app/onboarding/page.tsx b/ui/litellm-dashboard/src/app/onboarding/page.tsx index b748446ca98..7e5d91c001f 100644 --- a/ui/litellm-dashboard/src/app/onboarding/page.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/page.tsx @@ -74,21 +74,17 @@ export default function Onboarding() { return; } claimOnboardingToken(accessToken, inviteID, userID, formValues.password).then((data) => { - let litellm_dashboard_ui = "/ui/"; - litellm_dashboard_ui += "?login=success"; - // set cookie "token" to jwtToken document.cookie = "token=" + jwtToken; - console.log("redirecting to:", litellm_dashboard_ui); - + const proxyBaseUrl = getProxyBaseUrl(); console.log("proxyBaseUrl:", proxyBaseUrl); + + // Construct the full redirect URL using the proxyBaseUrl which includes the server root path + let redirectUrl = proxyBaseUrl ? `${proxyBaseUrl}/ui/?login=success` : "/ui/?login=success"; + console.log("redirecting to:", redirectUrl); - if (proxyBaseUrl) { - window.location.href = proxyBaseUrl + litellm_dashboard_ui; - } else { - window.location.href = litellm_dashboard_ui; - } + window.location.href = redirectUrl; }); // redirect to login page diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 1860ffcb60d..f09220772eb 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -41,6 +41,7 @@ import { cx } from "@/lib/cva.config"; import useFeatureFlags from "@/hooks/useFeatureFlags"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import OldTeams from "@/components/OldTeams"; +import { SearchTools } from "@/components/search_tools"; function getCookie(name: string) { // Safer cookie read + decoding; handles '=' inside values @@ -450,6 +451,7 @@ export default function CreateKeyPage() { userRole={userRole} accessToken={accessToken} modelData={modelData} + premiumUser={premiumUser} /> ) : page == "logs" ? ( ) : page == "mcp-servers" ? ( + ) : page == "search-tools" ? ( + ) : page == "tag-management" ? ( ) : page == "vector-stores" ? ( diff --git a/ui/litellm-dashboard/src/components/add_model/add_model_modes.tsx b/ui/litellm-dashboard/src/components/add_model/add_model_modes.tsx index 2440c4e8e4a..b6c2410a027 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_model_modes.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_model_modes.tsx @@ -9,6 +9,7 @@ export const TEST_MODES = [ { value: "rerank", label: "Rerank - /rerank" }, { value: "realtime", label: "Realtime - /realtime" }, { value: "batch", label: "Batch - /batch" }, + { value: "ocr", label: "OCR - /ocr" }, ]; // Define the available auto router routing strategies diff --git a/ui/litellm-dashboard/src/components/add_pass_through.tsx b/ui/litellm-dashboard/src/components/add_pass_through.tsx index 7d4bcc4ab79..2332b9735d7 100644 --- a/ui/litellm-dashboard/src/components/add_pass_through.tsx +++ b/ui/litellm-dashboard/src/components/add_pass_through.tsx @@ -26,6 +26,7 @@ import KeyValueInput from "./key_value_input"; import { passThroughItem } from "./pass_through_settings"; import RoutePreview from "./route_preview"; import NotificationsManager from "./molecules/notifications_manager"; +import PassThroughSecuritySection from "./common_components/PassThroughSecuritySection"; const { Option } = Select2; interface AddFallbacksProps { @@ -33,12 +34,14 @@ interface AddFallbacksProps { accessToken: string; passThroughItems: passThroughItem[]; setPassThroughItems: React.Dispatch>; + premiumUser?: boolean; } const AddPassThroughEndpoint: React.FC = ({ accessToken, setPassThroughItems, passThroughItems, + premiumUser = false, }) => { const [form] = Form.useForm(); const [isModalVisible, setIsModalVisible] = useState(false); @@ -47,7 +50,7 @@ const AddPassThroughEndpoint: React.FC = ({ const [pathValue, setPathValue] = useState(""); const [targetValue, setTargetValue] = useState(""); const [includeSubpath, setIncludeSubpath] = useState(true); - + const [authEnabled, setAuthEnabled] = useState(false); const handleCancel = () => { form.resetFields(); setPathValue(""); @@ -70,6 +73,10 @@ const AddPassThroughEndpoint: React.FC = ({ console.log("addPassThrough called with:", formValues); setIsLoading(true); try { + // Remove auth field if not premium user + if (!premiumUser && 'auth' in formValues) { + delete formValues.auth; + } console.log(`formValues: ${JSON.stringify(formValues)}`); const response = await createPassThroughEndpoint(accessToken, formValues); @@ -233,6 +240,15 @@ const AddPassThroughEndpoint: React.FC = ({ + {/* Security Section */} + { + setAuthEnabled(checked); + form.setFieldsValue({ auth: checked }); + }} + /> {/* Billing Section */} Billing diff --git a/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx b/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx new file mode 100644 index 00000000000..c42094abb55 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { Card, Title, Subtitle, Text } from "@tremor/react"; +import { Form, Switch } from "antd"; + +export interface PassThroughSecuritySectionProps { + premiumUser: boolean; + authEnabled: boolean; + onAuthChange: (checked: boolean) => void; +} + +/** + * Reusable Security section for pass-through endpoints + * Shows authentication toggle for premium users or upgrade message for free users + */ +const PassThroughSecuritySection: React.FC = ({ + premiumUser, + authEnabled, + onAuthChange, +}) => { + return ( + + Security + + When enabled, requests to this endpoint will require a valid LiteLLM API key + + {premiumUser ? ( + + { + onAuthChange(checked); + }} + /> + + ) : ( +
+
+ + Authentication (Premium) +
+
+ + Setting authentication for pass-through endpoints is a LiteLLM Enterprise feature. Get a trial key{" "} + + here + + . + +
+
+ )} +
+ ); +}; + +export default PassThroughSecuritySection; + diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index b974cdfaa9f..7a94c069a81 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -18,6 +18,7 @@ import { ToolOutlined, TagsOutlined, BgColorsOutlined, + SearchOutlined, } from "@ant-design/icons"; import { all_admin_roles, rolesWithWriteAccess, internalUserRoles, isAdminRole } from "../utils/roles"; import UsageIndicator from "./usage_indicator"; @@ -110,6 +111,7 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau icon: , children: [ { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, + { key: "28", page: "search-tools", label: "Search Tools", icon: }, { key: "21", page: "vector-stores", diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 713cb1d58ce..f4bcfd47e7e 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5421,6 +5421,226 @@ export const deleteMCPServer = async (accessToken: string, serverId: string) => } }; +// Search Tools API calls +export const fetchSearchTools = async (accessToken: string) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/search_tools/list` : `/search_tools/list`; + console.log("Fetching search tools from:", url); + + const response = await fetch(url, { + method: HTTP_REQUEST.GET, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + console.log("Fetched search tools:", data); + return data; + } catch (error) { + console.error("Failed to fetch search tools:", error); + throw error; + } +}; + +export const fetchSearchToolById = async (accessToken: string, searchToolId: string) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/search_tools/${searchToolId}` : `/search_tools/${searchToolId}`; + console.log("Fetching search tool by ID from:", url); + + const response = await fetch(url, { + method: HTTP_REQUEST.GET, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + console.log("Fetched search tool:", data); + return data; + } catch (error) { + console.error("Failed to fetch search tool:", error); + throw error; + } +}; + +export const createSearchTool = async (accessToken: string, formValues: Record) => { + try { + console.log("Creating search tool with values:", formValues); + const url = proxyBaseUrl ? `${proxyBaseUrl}/search_tools` : `/search_tools`; + + const response = await fetch(url, { + method: HTTP_REQUEST.POST, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + search_tool: formValues, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + console.log("Created search tool:", data); + return data; + } catch (error) { + console.error("Failed to create search tool:", error); + throw error; + } +}; + +export const updateSearchTool = async (accessToken: string, searchToolId: string, formValues: Record) => { + try { + console.log("Updating search tool with ID:", searchToolId, "values:", formValues); + const url = proxyBaseUrl ? `${proxyBaseUrl}/search_tools/${searchToolId}` : `/search_tools/${searchToolId}`; + + const response = await fetch(url, { + method: HTTP_REQUEST.PUT, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + search_tool: formValues, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + console.log("Updated search tool:", data); + return data; + } catch (error) { + console.error("Failed to update search tool:", error); + throw error; + } +}; + +export const deleteSearchTool = async (accessToken: string, searchToolId: string) => { + try { + const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/search_tools/${searchToolId}`; + console.log("Deleting search tool:", searchToolId); + + const response = await fetch(url, { + method: HTTP_REQUEST.DELETE, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + console.log("Deleted search tool:", data); + return data; + } catch (error) { + console.error("Failed to delete search tool:", error); + throw error; + } +}; + +export const fetchAvailableSearchProviders = async (accessToken: string) => { + try { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/search_tools/ui/available_providers` + : `/search_tools/ui/available_providers`; + console.log("Fetching available search providers from:", url); + + const response = await fetch(url, { + method: HTTP_REQUEST.GET, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + console.log("Fetched available search providers:", data); + return data; + } catch (error) { + console.error("Failed to fetch available search providers:", error); + throw error; + } +}; + +export const testSearchToolConnection = async ( + accessToken: string, + litellmParams: Record +) => { + try { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/search_tools/test_connection` + : `/search_tools/test_connection`; + console.log("Testing search tool connection:", url); + + const response = await fetch(url, { + method: HTTP_REQUEST.POST, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + litellm_params: litellmParams, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + console.log("Test connection response:", data); + return data; + } catch (error) { + console.error("Failed to test search tool connection:", error); + throw error; + } +}; + export const listMCPTools = async (accessToken: string, serverId: string, authValue?: string, serverAlias?: string) => { try { // Construct base URL @@ -6609,6 +6829,40 @@ export const vectorStoreSearchCall = async ( } }; +export const searchToolQueryCall = async ( + accessToken: string, + searchToolName: string, + query: string, + maxResults?: number, +): Promise => { + try { + const url = `${getProxyBaseUrl()}/v1/search/${searchToolName}`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: query, + max_results: maxResults || 5, + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + await handleError(errorData); + return null; + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("Error querying search tool:", error); + throw error; + } +}; + export const userAgentAnalyticsCall = async ( accessToken: string, startTime: Date, diff --git a/ui/litellm-dashboard/src/components/pass_through_info.tsx b/ui/litellm-dashboard/src/components/pass_through_info.tsx index 9b9d8aefaa3..c42a710caf2 100644 --- a/ui/litellm-dashboard/src/components/pass_through_info.tsx +++ b/ui/litellm-dashboard/src/components/pass_through_info.tsx @@ -18,12 +18,14 @@ import { updatePassThroughEndpoint, deletePassThroughEndpointsCall } from "./net import { Eye, EyeOff } from "lucide-react"; import RoutePreview from "./route_preview"; import NotificationsManager from "./molecules/notifications_manager"; +import PassThroughSecuritySection from "./common_components/PassThroughSecuritySection"; export interface PassThroughInfoProps { endpointData: PassThroughEndpoint; onClose: () => void; accessToken: string | null; isAdmin: boolean; + premiumUser?: boolean; onEndpointUpdated?: () => void; } @@ -34,6 +36,7 @@ interface PassThroughEndpoint { headers: Record; include_subpath?: boolean; cost_per_request?: number; + auth?: boolean; } // Password field component for headers @@ -58,11 +61,13 @@ const PassThroughInfoView: React.FC = ({ onClose, accessToken, isAdmin, + premiumUser = false, onEndpointUpdated, }) => { const [endpointData, setEndpointData] = useState(initialEndpointData); const [loading, setLoading] = useState(false); const [isEditing, setIsEditing] = useState(false); + const [authEnabled, setAuthEnabled] = useState(initialEndpointData?.auth || false); const [form] = Form.useForm(); const handleEndpointUpdate = async (values: any) => { @@ -86,6 +91,7 @@ const PassThroughInfoView: React.FC = ({ headers: headers, include_subpath: values.include_subpath, cost_per_request: values.cost_per_request, + auth: premiumUser ? values.auth : undefined, }; await updatePassThroughEndpoint(accessToken, endpointData.id, updateData); @@ -174,6 +180,11 @@ const PassThroughInfoView: React.FC = ({ {endpointData.include_subpath ? "Include Subpath" : "Exact Path"} +
+ + {endpointData.auth ? "Auth Required" : "No Auth"} + +
{endpointData.cost_per_request !== undefined && (
Cost per request: ${endpointData.cost_per_request} @@ -232,6 +243,7 @@ const PassThroughInfoView: React.FC = ({ headers: endpointData.headers ? JSON.stringify(endpointData.headers, null, 2) : "", include_subpath: endpointData.include_subpath || false, cost_per_request: endpointData.cost_per_request, + auth: endpointData.auth || false, }} layout="vertical" > @@ -258,6 +270,15 @@ const PassThroughInfoView: React.FC = ({ + { + setAuthEnabled(checked); + form.setFieldsValue({ auth: checked }); + }} + /> +
Save Changes @@ -285,6 +306,12 @@ const PassThroughInfoView: React.FC = ({
${endpointData.cost_per_request}
)} +
+ Authentication Required + + {endpointData.auth ? "Yes" : "No"} + +
Headers {endpointData.headers && Object.keys(endpointData.headers).length > 0 ? ( diff --git a/ui/litellm-dashboard/src/components/pass_through_settings.tsx b/ui/litellm-dashboard/src/components/pass_through_settings.tsx index c21f2e3c0be..6e22533ed14 100644 --- a/ui/litellm-dashboard/src/components/pass_through_settings.tsx +++ b/ui/litellm-dashboard/src/components/pass_through_settings.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from "react"; import { Text, Button, Icon, Title } from "@tremor/react"; import { deletePassThroughEndpointsCall, getPassThroughEndpointsCall } from "./networking"; -import { Tooltip } from "antd"; -import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; +import { Badge, Tooltip } from "antd"; +import { PencilAltIcon, TrashIcon, InformationCircleIcon } from "@heroicons/react/outline"; import AddPassThroughEndpoint from "./add_pass_through"; import PassThroughInfoView from "./pass_through_info"; import { DataTable } from "./view_logs/table"; @@ -15,6 +15,7 @@ interface GeneralSettingsPageProps { userRole: string | null; userID: string | null; modelData: any; + premiumUser?: boolean; } interface routingStrategyArgs { @@ -37,6 +38,7 @@ export interface passThroughItem { headers: object; include_subpath?: boolean; cost_per_request?: number; + auth?: boolean; } // Password field component for headers @@ -54,7 +56,7 @@ const PasswordField: React.FC<{ value: object }> = ({ value }) => { ); }; -const PassThroughSettings: React.FC = ({ accessToken, userRole, userID, modelData }) => { +const PassThroughSettings: React.FC = ({ accessToken, userRole, userID, modelData, premiumUser }) => { const [generalSettings, setGeneralSettings] = useState([]); const [selectedEndpointId, setSelectedEndpointId] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -144,6 +146,18 @@ const PassThroughSettings: React.FC = ({ accessToken, accessorKey: "target", cell: (info: any) => {info.getValue()}, }, + { + header: () => ( +
+ Authentication + + + +
+ ), + accessorKey: "auth", + cell: (info: any) => {info.getValue() ? "Yes" : "No"}, + }, { header: "Headers", accessorKey: "headers", @@ -192,6 +206,7 @@ const PassThroughSettings: React.FC = ({ accessToken, onClose={() => setSelectedEndpointId(null)} accessToken={accessToken} isAdmin={userRole === "Admin" || userRole === "admin"} + premiumUser={premiumUser} onEndpointUpdated={handleEndpointUpdated} /> ); @@ -208,6 +223,7 @@ const PassThroughSettings: React.FC = ({ accessToken, accessToken={accessToken} setPassThroughItems={setGeneralSettings} passThroughItems={generalSettings} + premiumUser={premiumUser} /> { + return `${searchProviderLogosFolder}${providerName}.png`; +}; + +// Component to display search provider logo and name +interface SearchProviderLabelProps { + providerName: string; + displayName: string; +} + +const SearchProviderLabel: React.FC = ({ providerName, displayName }) => ( +
+ { + e.currentTarget.style.display = "none"; + }} + /> + {displayName} +
+); + +interface CreateSearchToolProps { + userRole: string; + accessToken: string | null; + onCreateSuccess: (newSearchTool: SearchTool) => void; + isModalVisible: boolean; + setModalVisible: (visible: boolean) => void; +} + +const CreateSearchTool: React.FC = ({ + userRole, + accessToken, + onCreateSuccess, + isModalVisible, + setModalVisible, +}) => { + const [form] = Form.useForm(); + const [isLoading, setIsLoading] = useState(false); + const [formValues, setFormValues] = useState>({}); + const [isTestModalVisible, setIsTestModalVisible] = useState(false); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [connectionTestId, setConnectionTestId] = useState(""); + + // Fetch available search providers + const { + data: providersResponse, + isLoading: isLoadingProviders, + } = useQuery({ + queryKey: ["searchProviders"], + queryFn: () => { + if (!accessToken) throw new Error("Access Token required"); + return fetchAvailableSearchProviders(accessToken); + }, + enabled: !!accessToken && isModalVisible, + }) as { data: { providers: AvailableSearchProvider[] }; isLoading: boolean }; + + const availableProviders = providersResponse?.providers || []; + + const handleCreate = async (formValues: Record) => { + setIsLoading(true); + try { + // Prepare the payload + const payload = { + search_tool_name: formValues.search_tool_name, + litellm_params: { + search_provider: formValues.search_provider, + api_key: formValues.api_key, + api_base: formValues.api_base, + timeout: formValues.timeout ? parseFloat(formValues.timeout) : undefined, + max_retries: formValues.max_retries ? parseInt(formValues.max_retries) : undefined, + }, + search_tool_info: formValues.description + ? { + description: formValues.description, + } + : undefined, + }; + + console.log(`Creating search tool with payload:`, payload); + + if (accessToken != null) { + const response = await createSearchTool(accessToken, payload); + + NotificationsManager.success("Search tool created successfully"); + form.resetFields(); + setFormValues({}); + setModalVisible(false); + onCreateSuccess(response); + } + } catch (error) { + NotificationsManager.error("Error creating search tool: " + error); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + form.resetFields(); + setFormValues({}); + setModalVisible(false); + }; + + const handleTestConnection = async () => { + try { + // Validate required fields for testing + await form.validateFields(["search_provider", "api_key"]); + + setIsTestingConnection(true); + // Generate a new test ID (using timestamp for uniqueness) + setConnectionTestId(`test-${Date.now()}`); + // Show the modal with the fresh test + setIsTestModalVisible(true); + } catch (error) { + NotificationsManager.error("Please fill in Search Provider and API Key before testing"); + } + }; + + // Clear formValues when modal closes to reset + React.useEffect(() => { + if (!isModalVisible) { + setFormValues({}); + } + }, [isModalVisible]); + + if (!isAdminRole(userRole)) { + return null; + } + + return ( + + 🔍 +

Add New Search Tool

+
+ } + open={isModalVisible} + width={800} + onCancel={handleCancel} + footer={null} + className="top-8" + styles={{ + body: { padding: "24px" }, + header: { padding: "24px 24px 0 24px", border: "none" }, + }} + > +
+
setFormValues(allValues)} + layout="vertical" + className="space-y-6" + > +
+ + Search Tool Name + + + + + } + name="search_tool_name" + rules={[ + { required: true, message: "Please enter a search tool name" }, + { + pattern: /^[a-zA-Z0-9_-]+$/, + message: "Name can only contain letters, numbers, hyphens, and underscores", + }, + ]} + > + + + + + Search Provider + + + + + } + name="search_provider" + rules={[{ required: true, message: "Please select a search provider" }]} + > + + + + + API Key + + + + + } + name="api_key" + rules={[{ required: false, message: "Please enter an API key" }]} + > + + + + Description (Optional)} + name="description" + > +